Compare commits

..
Author SHA1 Message Date
e300f4efc9
small fixups 2026-09-26 21:11:11 -04:00
bab699658f
pop sidebar before pushing notif page 2026-09-26 20:32:44 -04:00
1c1f585362
add JumpToEventController to allow jumping to events from the NotificationsPage when it has been pushed from the NotificationController 2026-09-26 19:37:47 -04:00
c90a1c41b0
working jumps from notif page
Doesn't work from notif page pushed from notif controller
2026-09-26 19:13:30 -04:00
9f070fcdba
reorganize pages into being under widgets 2026-09-26 18:06:51 -04:00
d712bb3193
tweak scroll behavior 2026-09-26 14:52:24 -04:00
3217c17ea0
don't depend on SuperSliverList for scrolls 2026-09-26 13:58:30 -04:00
031fbe326c
change highlight timing 2026-09-26 13:42:31 -04:00
0149aa2f84
fixup jumping to pins 2026-09-26 13:30:39 -04:00
a1efdb0f46
check off jumping on readme 2026-09-26 13:15:48 -04:00
389b65ae61 add support for jumping to out-of-context events (#95)
Fixes #84.

Reviewed-on: Nexus/nexus#95
2026-09-26 13:03:16 -04:00
3429a3ed98
add twim image 2026-09-25 10:20:53 -04:00
7ab92f76bf
fix for janky scrolling when tapping reply with SuperSliverView
I don't like this but it does work.
2026-09-24 22:46:48 -04:00
aecfac9533
free base before buffer 2026-09-24 22:20:31 -04:00
c7b89b8e56
small cleanups 2026-09-24 20:59:18 -04:00
721727d48c
small cleanups 2026-09-24 20:50:28 -04:00
004c3bcd0d
remove outdated error ignores
I believe some of these are fixed, if not they should be noisy so I notice them and can re-add
2026-09-24 20:38:30 -04:00
943cd85e22
treewide: ref.watch(ClientController.provider.notifier) -> ref.read 2026-09-24 20:35:20 -04:00
09904536e7
small fixups 2026-09-24 20:31:22 -04:00
856072e0b0
fixup! refactor chatview to have two sliver lists 2026-09-24 20:22:30 -04:00
110caff170
refactor chatview to have two sliver lists
Co-authored-by: Erwan Leboucher <erwanleboucher@gmail.com>
2026-09-24 20:02:23 -04:00
eeb2e2f54b
extract scroll helpers into their own hook 2026-09-24 18:10:30 -04:00
529a2e35c4
make buildEventOptions an extension 2026-09-24 14:41:13 -04:00
6ad4657d76
refactor chatview 2026-09-24 14:24:41 -04:00
38fd19ad1a
move isInBackground in main 2026-09-24 14:08:54 -04:00
0636366359
fixup errors caused by bump 2026-09-24 11:56:46 -04:00
efe9262a97
tweak timeout 2026-09-24 11:49:19 -04:00
2763a0fdf5
bump deps 2026-09-24 11:47:20 -04:00
a5da44fdea
more restrictive timeout on notification init 2026-09-24 11:42:57 -04:00
ccca02a0bc
Separate out GomuksListenerController from ClientController
Fixes #89
2026-09-24 11:03:16 -04:00
721349ca21
explicit listener for ClientStateController in notifs 2026-09-23 11:22:47 -04:00
5d994bf2bb
wait for metadata to come in on notif's rooms before sending them 2026-09-23 11:17:02 -04:00
a90bf00f18
dispose notif handler 2026-09-23 10:30:12 -04:00
f2efbbeb9f
fixup select 2026-09-22 20:48:59 -04:00
ed761d6071
gate selectable text behind mouse connected 2026-09-22 20:40:55 -04:00
8f35ea2435
Add "Copy Text" context menu option 2026-09-22 20:18:16 -04:00
023e75e063
always make events selectable (on mobile the long press overrides it) 2026-09-22 20:11:18 -04:00
4d0a4605d6
type contextMenuCallback 2026-09-22 19:57:23 -04:00
faa7292001
add codeblock copy button 2026-09-22 19:19:21 -04:00
027033ad9e
allow copying messages when on desktop without mobile mode on 2026-09-22 19:13:32 -04:00
ece3d16318
use dot shorthands in about dialog 2026-09-22 18:48:47 -04:00
5738239da2
fix name of ShowAboutDialog extension 2026-09-22 18:31:38 -04:00
a2791a6717
various small fixups 2026-09-22 15:53:37 -04:00
f910d87a23
don't require login for UP controller to finish init 2026-09-22 15:33:55 -04:00
f4142859ba
fallback isFocused to true 2026-09-22 15:05:57 -04:00
775c77194b
run in background properly on android 2026-09-22 15:02:30 -04:00
41806be9bf
More platform-independent approach to managing registered state 2026-09-22 14:34:08 -04:00
6e47594a91
fix race condition on startup 2026-09-22 11:36:29 -04:00
386b3ad1cf
inline the mention fetch 2026-09-22 10:37:49 -04:00
61f2aa7f2a
update gomuks vendorHash 2026-09-22 10:02:56 -04:00
0c660a98a7
just grab events out of get_mentions for now 2026-09-22 09:57:10 -04:00
09534a6775
use GomuksConfig 2026-09-22 09:53:00 -04:00
0cd815dc79
init ClientController before other providers 2026-09-21 23:03:57 -04:00
800ed0636d
bump nix2flatpak 2026-09-21 22:18:24 -04:00
e4cf2a837c
fixup login 2026-09-21 21:41:18 -04:00
11dca9bf14
seperate URI handler desktop file 2026-09-21 21:20:16 -04:00
436b8642b3
read not watch MultiProviderController providers
Fixes random loading animations
2026-09-21 19:27:10 -04:00
269d97be8f
bump nix2flatpak 2026-09-21 18:53:49 -04:00
43f70f35a3
bump nix2flatpak 2026-09-21 18:00:42 -04:00
92b148f5b6
gate registration behind not flatpak 2026-09-21 17:54:03 -04:00
00a42b5f1a
fixup flatpak build 2026-09-21 17:20:43 -04:00
700b4e720f
open notifications page on notif tap
please please please let me never touch c++ again i hate it

also the c++ got reformatted here but i dont really care
2026-09-21 16:56:12 -04:00
2a11bcb7e8
update hashes 2026-09-21 15:44:10 -04:00
fea39e4191
use xdg_desktop_portal over flutter_local_notifications on linux 2026-09-21 15:43:01 -04:00
deaf564c36
use portals for linux notifs 2026-09-21 14:16:29 -04:00
c0d14ac30e
don't write service by default 2026-09-20 17:27:45 -04:00
1a6a5c36ea
link to module from readme 2026-09-20 17:21:26 -04:00
35ef82accd
fixup module 2026-09-20 17:18:39 -04:00
f40dd22277
use mkEnableOption for enableNotifications 2026-09-20 17:14:56 -04:00
1cfea5a819
add nix module 2026-09-20 17:13:22 -04:00
b3023d8147
don't auto create dbus service 2026-09-20 17:00:10 -04:00
e8fb3a8523
use SharedPreferencesAsync to avoid race condition in Sharedprefs
This was so painful to debug...
2026-09-20 15:45:32 -04:00
0d1ace3fd0
add more logs for notifs 2026-09-20 13:41:22 -04:00
3d690c26d8
hopefully fixup UP on linux when backgrounded 2026-09-20 13:27:47 -04:00
3fee789c90
fixup flatpak perms 2026-09-20 00:07:47 -04:00
d7c64fea80
potentially fix notifs on flatpak 2026-09-20 00:04:50 -04:00
87e7e093f7
Revert "don't invalidate UP controller"
This reverts commit 73c4a7d07f.
2026-09-20 00:02:49 -04:00
8b7e29ae33
Set device name to "Nexus on $platform"
Fixes #86.
2026-09-19 22:41:13 -04:00
673c5bd894
fix memory leaks
Oops! They were pretty slow, but still adds up. This might fix the bug that was stopping Nexus from working on GraphineOS.
2026-09-19 21:13:03 -04:00
650c62fb99
update gomuks hash 2026-09-19 18:45:05 -04:00
4f3b77f9f3
set env for gomuks
Fixes #62
2026-09-19 18:41:09 -04:00
85bb653980
fix gomuks init on android / ios 2026-09-18 15:39:45 -04:00
a9d2ccbcd1
bump gradle 2026-09-18 13:02:40 -04:00
73c4a7d07f
don't invalidate UP controller 2026-09-18 10:57:16 -04:00
57f9498aa3
better wrapping behavior for timestamps on state events 2026-09-18 09:58:49 -04:00
cfab803fa0
show timestamps for state events 2026-09-17 23:21:33 -04:00
f23a35384d
nicer / less intrusive mention style 2026-09-17 14:27:51 -04:00
7f7fb451e6
Bump emoji picker to 1.1.1
Better performance now.
2026-09-17 12:09:16 -04:00
5d62e66cd9
remove duplicate "Windows EXE" entry in "Try it out" section of README
Thanks to @julian45 for spotting this!
2026-09-16 22:11:43 -04:00
009ba504e3
fixup logic for not init-ing UP
An XOR was never the right choice for this
2026-09-16 22:07:00 -04:00
30b922d390
enlarge emojis on emoji overlay 2026-09-16 22:03:42 -04:00
63183e0824
don't init UP on platforms other than Linux or Android 2026-09-16 21:59:55 -04:00
163947fc90
add emoji overlay 2026-09-16 16:52:58 -04:00
11457470a0
move around args to overlays 2026-09-16 16:30:15 -04:00
dca0b7dd7d
refactor overlay system 2026-09-16 16:27:10 -04:00
e5dc270f83
bump emoji picker 2026-09-16 16:27:07 -04:00
810a0b102b
add support for setting recent emoji 2026-09-16 15:46:51 -04:00
ec1603b828
add support for showing recent emoji 2026-09-16 15:18:41 -04:00
5acdbb5be0 Better emoji picker (#73)
Adds a better, custom, emoji picker.

Reviewed-on: Nexus/nexus#73
2026-09-16 14:47:46 -04:00
e21c9e8c22
make highlights work better for Notifications page 2026-09-12 09:52:01 -04:00
ea687471bc
Make TODO on notifications page more useful 2026-09-11 22:42:43 -04:00
8c440447f2
check off Notifications page in readme 2026-09-11 22:42:17 -04:00
8ebec15324
hide label whe not selected for filter 2026-09-11 22:41:38 -04:00
cd8d551e26
take notif filter out of card 2026-09-11 22:39:18 -04:00
118e5a5826
update readme for platform support to be more clear 2026-09-11 22:35:13 -04:00
6ca9b2a948
add notification page filter 2026-09-11 22:34:43 -04:00
23e01c7a0d
Notifications page
Doesn't yet allow for jumping to events
2026-09-11 22:03:42 -04:00
9e7fcdbef7
sticky -> clientSticky to avoid conflicts with sticky events 2026-09-11 19:50:29 -04:00
9507d69458
move windows warning in readme 2026-09-11 19:29:47 -04:00
13d90df226
remove un-needed overrides 2026-09-11 19:28:10 -04:00
e925f9657c
add needed permissions for UnifiedPush to nexus flatpak 2026-09-10 17:14:41 -04:00
b8882d6e48
mark off notification support as added, add notifications page todo 2026-09-10 17:06:14 -04:00
ff9bf3bec4 Push Notifications (#66)
Fixes #35.

Things to do:
- [x] Register with local distributor
- [x] Register with homeserver
- [x] Recieve messages
- [x] Show notifications
- [ ] Notifications page (could come later)

Reviewed-on: Nexus/nexus#66
2026-09-10 15:37:05 -04:00
6e42902322
run GomuksInit on an Isolate
Fixes #70
2026-09-10 15:32:30 -04:00
c105a6035c
use dot shorthands where possible in generate script 2026-09-10 15:21:53 -04:00
3d7ef9b32f
actually generate bindings
Yes `ffigen`, I did actually want you to generate the bindings, not a file consisting only of comments. Crazy, right?
2026-09-10 15:20:56 -04:00
51ef66174a
fix generate script to work with new ffigen 2026-09-10 15:15:12 -04:00
1efb68ebe0
bump deps, remove unwanted and now un-needed analyzer rule
Fixes #69
2026-09-10 15:09:47 -04:00
0a667fda0b
update gomuks vendorHash 2026-09-09 15:44:25 -04:00
da352cd035
cap max length of embeds 2026-09-09 15:37:41 -04:00
0201f71e5d
allow banning left users 2026-09-09 15:26:04 -04:00
4314c88d5c
fallback to empty profile if fetch fails 2026-09-09 15:25:10 -04:00
88cdcf9863
use compat bridge for settings sidebar 2026-08-23 14:02:25 -04:00
e2f25749e0
fix fetching spec versions early 2026-08-23 14:00:11 -04:00
47a3d9d92f
Add comment about why MaterialUiCompatibilityBridge is needed for HtmlWidget
[skip ci]
2026-08-22 15:19:46 -04:00
a680c5cd0c
update navigation_rail_m3e hash 2026-08-22 15:15:39 -04:00
0887421f3e
put bridge for link colors 2026-08-22 15:14:41 -04:00
a090d88a23
add theming workaround for navigation_rail_m3e 2026-08-22 15:12:35 -04:00
32693fb3b5
fix path in nix build 2026-08-22 14:26:28 -04:00
4e1f1b64bc
run APK build on x86_64 2026-08-22 14:18:12 -04:00
20f1c784f6
optimize gomuks build size 2026-08-22 14:16:35 -04:00
9b79304edc
try to optimize APK size 2026-08-22 14:05:31 -04:00
b797b8de9c
fix icons not rendering 2026-08-22 13:51:44 -04:00
56bb75866c
bump JDK version 2026-08-22 13:50:00 -04:00
3baea40669
skip broken android lints 2026-08-22 13:24:26 -04:00
2e72ecd559
fix navigation_rail_m3e hash
Fixes nix build
2026-08-22 13:06:33 -04:00
077044ec19 Move to Flutter 3.47, use the improvements that come with this (#64)
Reviewed-on: Nexus/nexus#64
2026-08-22 13:04:33 -04:00
01b13b4764
set min version of macos to 13.0 2026-08-12 10:46:32 -04:00
050e42cc6f
comp for older macos 2026-08-12 10:34:36 -04:00
67cee201b6
add TODO comment to LinkToMention extension 2026-08-11 20:52:14 -04:00
2d7b067c93
use mobile icon for macOS
this looks better in the macos launcher squircles
2026-08-11 20:39:04 -04:00
a9993013f5
add rooms to mention chips
Needs handling for event links, plus clickability
2026-08-11 20:34:43 -04:00
9c488e8981
only upload .dmgs 2026-08-11 20:08:59 -04:00
5c16f42d20
pin upload-artifact to a commit 2026-08-11 19:58:15 -04:00
092fc616e6
fix paths in macos CI 2026-08-11 19:56:26 -04:00
6cbf78be6b
change step names for macos
[skip ci]
2026-08-11 19:20:08 -04:00
68f4bb0c03
update macos status in readme 2026-08-11 19:18:09 -04:00
9e24f4cb31
add DMG builds 2026-08-11 19:12:56 -04:00
43d2e94d62
make loadOlder properly guard 2026-08-11 10:39:45 -04:00
62fe37c6ae
use mobile icons on iOS 2026-08-11 10:17:24 -04:00
b9dfbd3ca2
update MacOS icons 2026-08-11 10:16:18 -04:00
620440e2a1
Allow logging into homeservers without discovery working 2026-08-10 22:50:13 -04:00
259 changed files with 5691 additions and 3681 deletions

View file

@ -27,7 +27,7 @@ jobs:
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }} KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- name: Build app - name: Build app
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --release" run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64"
env: env:
KEYSTORE_PATH: ../../keystore.jks KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}

View file

@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version: 3.44.4 flutter-version: 3.47.4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6

View file

@ -19,22 +19,29 @@ jobs:
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version: 3.44.4 flutter-version: 3.47.4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: gomuks/go.mod go-version-file: gomuks/go.mod
- name: Build app - name: Build App
run: | run: |
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 --release
- name: Upload installer artifact - name: Create DMG
id: create-dmg
uses: L-Super/create-dmg-actions@28511e988b13ca34096d439159a66d468b8e724f
with:
dmg_name: nexus
src_dir: build/macos/Build/Products/Release/Nexus.app
- name: Upload DMG
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
with: with:
name: Nexus.App name: nexus.dmg
path: build/macos/Build/Products/Release/Nexus.app path: ${{ steps.create-dmg.outputs.dmg_path }}

View file

@ -1,65 +1,64 @@
name: "Build EXE" name: "Build EXE"
on: on:
push: push:
branches: ["main"] branches: ["main"]
tags: ["*"] tags: ["*"]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build-exe: build-exe:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
submodules: recursive submodules: recursive
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version: 3.44.4 flutter-version: 3.47.4
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: gomuks/go.mod
- name: Set up Go - name: Setup MSYS2
uses: actions/setup-go@v6 uses: msys2/setup-msys2@v2
with: with:
go-version-file: gomuks/go.mod msystem: MINGW64
install: >-
mingw-w64-x86_64-gcc
- name: Setup MSYS2 - name: Go build
uses: msys2/setup-msys2@v2 run: |
with: cd gomuks/pkg/ffi
msystem: MINGW64 go build -tags goolm,sqlite_fts5 -o ../../../libgomuks.dll -buildmode=c-shared
install: >-
mingw-w64-x86_64-gcc
- name: Go build - name: Build with Flutter
run: | run: |
cd gomuks/pkg/ffi flutter pub get
go build -tags goolm,sqlite_fts5 -o ../../../libgomuks.dll -buildmode=c-shared dart scripts/generate.dart
flutter pub run build_runner build
flutter build windows --release
- name: Build with Flutter - name: Copy MinGW runtime DLLs
run: | shell: msys2 {0}
flutter pub get run: |
dart scripts/generate.dart cp /mingw64/bin/libgcc_s_seh-1.dll build/windows/x64/runner/Release/
flutter pub run build_runner build cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
flutter build windows --release cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
- name: Copy MinGW runtime DLLs - name: Install Inno Setup
shell: msys2 {0} run: choco install innosetup -y
run: |
cp /mingw64/bin/libgcc_s_seh-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/
- name: Install Inno Setup - name: Build Inno Setup installer
run: choco install innosetup -y run: iscc windows/installer.iss
- name: Build Inno Setup installer - name: Upload installer artifact
run: iscc windows/installer.iss uses: actions/upload-artifact@v6
with:
- name: Upload installer artifact name: windows-installer
uses: actions/upload-artifact@v6 path: windows/dist/Nexus-Setup.exe
with:
name: windows-installer
path: windows/dist/Nexus-Setup.exe

1
.gitmodules vendored
View file

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

View file

@ -11,6 +11,8 @@
"muks", "muks",
"prefs", "prefs",
"unban", "unban",
"unredact" "unifiedpush",
"unredact",
"webpush"
] ]
} }

View file

@ -21,7 +21,7 @@ See [Effective Dart: Style](https://dart.dev/effective-dart/style) for general r
Controllers live in `lib/controllers/` and provide a source that exposes data and logic via Riverpod providers, allowing other parts of the code to watch state changes with ref.watch (`ref.watch(MyController.provider)`), access the current value with ref.read (`ref.read(MyController.provider)`), and run helper methods on those classes using the notifier: Controllers live in `lib/controllers/` and provide a source that exposes data and logic via Riverpod providers, allowing other parts of the code to watch state changes with ref.watch (`ref.watch(MyController.provider)`), access the current value with ref.read (`ref.read(MyController.provider)`), and run helper methods on those classes using the notifier:
```dart ```dart
ref.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.

View file

@ -17,10 +17,15 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Platform Support - [x] Platform Support
- [x] Linux - [x] Linux
- [x] 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] 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] Android
- [x] MacOS - Unsigned .App only - [x] MacOS
- [x] iOS - Unsigned .ipa only - [x] Unsigned .ipa
- [ ] App Store
- [x] iOS
- [x] Unsigned .ipa
- [ ] App Store
- [ ] Web (may not be possible) - [ ] Web (may not be possible)
- [x] Login (via OAuth) - [x] Login (via OAuth)
- [x] Rooms / Spaces - [x] Rooms / Spaces
@ -50,10 +55,10 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Per message profiles - [x] Per message profiles
- [x] Attachments - [x] Attachments
- [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391)
- [x] Mentions - [x] Tags
- [x] Users - [x] Users
- [x] Rooms - [x] Rooms
- [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions) - [x] Emojis
- [ ] Custom emojis/stickers - [ ] Custom emojis/stickers
- [ ] GIFs using Gomuks' GIF proxies - [ ] GIFs using Gomuks' GIF proxies
- [x] Receiving - [x] Receiving
@ -63,9 +68,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] URL Previews - [x] URL Previews
- [x] Replies - [x] Replies
- [x] Viewing - [x] Viewing
- [ ] Jump to original message - [x] Jump to original message
- [x] In loaded timeline
- [ ] Out of loaded timeline
- [x] Edits - [x] Edits
- [x] Attachments - [x] Attachments
- [x] Unencrypted - [x] Unencrypted
@ -105,7 +108,8 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Member list - [x] Member list
- [x] Sort by power level - [x] Sort by power level
- [ ] Colors based off of power level - [ ] Colors based off of power level
- [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35)) - [x] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
- [x] Notifications page
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) - [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
- [ ] Invites - [ ] Invites
- [x] Settings - [x] Settings
@ -115,13 +119,13 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
If you want to try out Nexus, grab one of the following artifacts from CI: If you want to try out Nexus, grab one of the following artifacts from CI:
- [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 App](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/Nexus.App.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 - Flatpak
- [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip) - [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip)
- [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip) - [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip)
- [NixOS Module](linux/nix/module.nix)
## Build it yourself ## Build it yourself
@ -190,6 +194,12 @@ dart scripts/generate.dart
> export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" > export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include"
> ``` > ```
Build webcrypto:
```sh
flutter pub run webcrypto:setup
```
Build generated files, and watch for new changes: Build generated files, and watch for new changes:
```sh ```sh

View file

@ -1,12 +1,18 @@
analyzer: analyzer:
errors: errors:
invalid_annotation_target: ignore invalid_annotation_target: ignore
avoid_print: ignore avoid_print: ignore
exclude: exclude:
- "build/**" - "build/**"
- "**/*.g.dart" - "**/*.g.dart"
- "**/*.freezed.dart" - "**/*.freezed.dart"
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:
rules: rules:
prefer_double_quotes: true prefer_double_quotes: true

View file

@ -1,77 +0,0 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader("UTF-8") { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty("flutter.versionCode")
if (flutterVersionCode == null) {
flutterVersionCode = "1"
}
def flutterVersionName = localProperties.getProperty("flutter.versionName")
if (flutterVersionName == null) {
flutterVersionName = "1.0"
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace = "nexus.federated.nexus"
ndkVersion = flutter.ndkVersion
compileSdk = 34
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
defaultConfig {
applicationId = "nexus.federated.nexus"
minSdk = 29
targetSdkVersion flutter.targetSdkVersion
versionCode = flutterVersionCode.toInteger()
versionName = flutterVersionName
}
signingConfigs {
release {
keyAlias "key"
def storePath = keystoreProperties['path'] ?: System.getenv("KEYSTORE_PATH")
storeFile storePath ? file(storePath) : null
keyPassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD")
storePassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD")
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
debug {
applicationIdSuffix = ".debug"
}
}
}
flutter {
source = "../.."
}

View file

@ -0,0 +1,84 @@
import java.util.Properties
plugins {
id("com.android.application")
id("dev.flutter.flutter-gradle-plugin")
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use {
keystoreProperties.load(it)
}
}
android {
namespace = "nexus.federated.nexus"
compileSdk = 36
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
defaultConfig {
applicationId = "nexus.federated.nexus"
minSdk = 29
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
}
signingConfigs {
create("release") {
keyAlias = "key"
val storePath =
keystoreProperties["path"]?.toString()
?: System.getenv("KEYSTORE_PATH")
storeFile = storePath?.let { file(it) }
keyPassword =
keystoreProperties["password"]?.toString()
?: System.getenv("KEYSTORE_PASSWORD")
storePassword =
keystoreProperties["password"]?.toString()
?: System.getenv("KEYSTORE_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
debug {
applicationIdSuffix = ".debug"
}
}
}
dependencies {
implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
}
}
flutter {
source = "../.."
}

View file

@ -1,26 +0,0 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
afterEvaluate {project ->
if (project.hasProperty("android")) {
android {
compileSdkVersion 36
buildToolsVersion '33.0.1'
}
}
}
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

25
android/build.gradle.kts Normal file
View file

@ -0,0 +1,25 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-all.zip

View file

@ -1,25 +0,0 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.13.0" apply false
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
}
include ":app"

View file

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.3.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.10" apply false
}
include(":app")

View file

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Before After
Before After

BIN
assets/twim/notif.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

29
flake.lock generated
View file

@ -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,15 +42,16 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1774860670, "lastModified": 1790043059,
"narHash": "sha256-YjJkQrvxrErXtfDi3obUn6rNmkA+CIAZ3f5NgL5xuYE=", "narHash": "sha256-7ID4HL4IrrlalCsxMASkcAr+v0VHTNFp6+sZ+dpvOV8=",
"owner": "neobrain", "owner": "Henry-Hiles",
"repo": "nix2flatpak", "repo": "nix2flatpak",
"rev": "61d68e21e3fbc2d57590051f48736bea271f4aba", "rev": "290dd282e555cab988c021c2be5f2361e58c4d51",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "neobrain", "owner": "Henry-Hiles",
"ref": "quad/nexus",
"repo": "nix2flatpak", "repo": "nix2flatpak",
"type": "github" "type": "github"
} }
@ -73,11 +74,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 +89,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1785967620, "lastModified": 1790185690,
"narHash": "sha256-IItrdb7Puk05RqOBWZYFC5X6Wl1sJmCfh5MWVHw5iMM=", "narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "b7c2ada94fe99c15b0dbcf4d11fd7850b957a436", "rev": "4975466d324710c576dc11ad614684e6bd8cad8e",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -5,7 +5,7 @@
self.submodules = true; self.submodules = true;
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
nix2flatpak.url = "github:neobrain/nix2flatpak"; nix2flatpak.url = "github:Henry-Hiles/nix2flatpak/quad/nexus";
}; };
outputs = outputs =
@ -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

@ -1 +1 @@
Subproject commit 9444dd293664c300c6e710bb6cddcd9c6cab365d Subproject commit 480938f9e01e7eae2cf249657efae03df76599d6

View file

@ -1,4 +1,5 @@
import "dart:io"; import "dart:io";
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:hooks/hooks.dart"; import "package:hooks/hooks.dart";
import "package:code_assets/code_assets.dart"; import "package:code_assets/code_assets.dart";
@ -50,7 +51,10 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
break; break;
case OS.macOS: case OS.macOS:
libFileName = "libgomuks.dylib"; libFileName = "libgomuks.dylib";
extraEnv = {"SDKROOT": await getXCodeTool()}; extraEnv = {
"SDKROOT": await getXCodeTool(),
"MACOSX_DEPLOYMENT_TARGET": codeConfig.macOS.targetVersion.toString(),
};
break; break;
case OS.windows: case OS.windows:
libFileName = "libgomuks.dll"; libFileName = "libgomuks.dll";
@ -131,6 +135,8 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
"go", "go",
[ [
"build", "build",
"-trimpath",
"-ldflags=-s -w",
"-tags", "-tags",
tags, tags,
"-o", "-o",

View file

@ -7,7 +7,8 @@ import UIKit
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions) UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 B

After

Width:  |  Height:  |  Size: 846 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Before After
Before After

View file

@ -6,10 +6,8 @@ import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:path/path.dart"; import "package:path/path.dart";
class AttachmentController extends Notifier<(String, MessageContent?)?> { class AttachmentController(final String roomId)
final String roomId; extends Notifier<(String, MessageContent?)?> {
AttachmentController(this.roomId);
@override @override
Null build() => null; Null build() => null;

View file

@ -4,10 +4,8 @@ import "package:nexus/controllers/client_id.dart";
import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/oauth_auth_code_response.dart";
import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart";
class AuthUrlController extends AsyncNotifier<OAuthAuthCodeResponse> { class AuthUrlController(final Uri homeserver)
final Uri homeserver; extends AsyncNotifier<OAuthAuthCodeResponse> {
AuthUrlController(this.homeserver);
@override @override
Future<OAuthAuthCodeResponse> build() async => ref Future<OAuthAuthCodeResponse> build() async => ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)

View file

@ -1,19 +1,17 @@
import "dart:async"; import "dart:async";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/user.dart"; import "package:nexus/controllers/user.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
class AuthorController extends AsyncNotifier<MembershipContent> { class AuthorController(final Event event)
final Event event; extends AsyncNotifier<MembershipContent> {
AuthorController(this.event);
@override @override
Future<MembershipContent> build() async { Future<MembershipContent> build() async {
final member = await ref.watch( final member = await ref.watch(
UserController.provider( UserController.provider(.new(roomId: event.roomId, userId: event.sender))
.new(roomId: event.roomId, userId: event.sender), .future,
).future,
); );
return .new( return .new(

View file

@ -1,26 +1,28 @@
import "dart:async";
import "dart:ffi"; import "dart:ffi";
import "dart:io"; import "dart:io";
import "dart:isolate"; import "dart:isolate";
import "dart:math"; 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/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/gomuks_config.dart";
import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/oauth_auth_code_response.dart";
import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/open_graph_data.dart";
import "package:nexus/models/paginate.dart"; import "package:nexus/models/paginate.dart";
import "package:nexus/models/paginate_manual.dart";
import "package:nexus/models/requests/deregister_pusher.dart";
import "package:nexus/models/requests/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/join_room.dart"; import "package:nexus/models/requests/join_room.dart";
@ -29,7 +31,9 @@ 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/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";
@ -38,8 +42,9 @@ import "package:nexus/models/requests/set_membership.dart";
import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/set_state.dart";
import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/requests/upload_media.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/room_metadata.dart";
import "package:nexus/models/room_summary.dart";
import "package:nexus/models/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";
@ -47,135 +52,100 @@ 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 valuePtr = value.toNativeUtf8().cast<Char>();
try {
GomuksSetEnv(keyPtr, valuePtr);
} finally {
calloc
..free(keyPtr)
..free(valuePtr);
}
}
} }
final handle = GomuksInit(root); debugPrint("Initializing Gomuks...");
final handle = await Isolate.run(() {
final bufferPointer = GomuksConfig(
matrix: .new(
initialDeviceDisplayName:
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
),
).toJson().toGomuksBufferPtr();
final callable = try {
NativeCallable< return GomuksInit(bufferPointer.ref);
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer) } finally {
>.listener(( calloc
Pointer<Char> command, ..free(bufferPointer.ref.base)
int requestId, ..free(bufferPointer);
GomuksOwnedBuffer data, }
) { });
try {
final muksEventType = command.cast<Utf8>().toDartString();
debugPrint("Handling $muksEventType...");
final decodedMuksEvent = data.toJson();
switch (muksEventType) {
case "client_state":
ref
.watch(ClientStateController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
break;
case "sync_status":
ref
.watch(SyncStatusController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
break;
case "init_complete":
ref.watch(InitCompleteController.provider.notifier).complete();
break;
case "send_complete":
final event = Event.fromJson(decodedMuksEvent["event"]);
ref
.watch(RoomsController.provider.notifier)
.update(
.new({
event.roomId: .new(events: .new({event.rowId: event})),
}),
.new(),
);
break;
case "sync_complete":
final syncData = SyncData.fromJson(decodedMuksEvent);
final roomProvider = RoomsController.provider;
final accountDataProvider = AccountDataController.provider;
if (syncData.clearState) {
ref.invalidate(roomProvider);
ref.invalidate(accountDataProvider);
}
ref
.watch(roomProvider.notifier)
.update(syncData.rooms, syncData.leftRooms);
ref
.watch(accountDataProvider.notifier)
.update(syncData.accountData);
if (syncData.topLevelSpaces != null) {
ref
.watch(TopLevelSpacesController.provider.notifier)
.set(syncData.topLevelSpaces!);
}
if (syncData.spaceEdges != null) {
ref
.watch(SpaceEdgesController.provider.notifier)
.set(syncData.spaceEdges!);
}
// ref
// .watch(SyncStatusController.provider.notifier)
// .set(SyncStatus.fromJson(decodedMuksEvent));
break;
default:
debugPrint("Unhandled event: $muksEventType");
}
debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) {
if (kDebugMode) {
debugPrintStack(stackTrace: stackTrace, label: error.toString());
rethrow;
} else {
showError(error, stackTrace);
}
}
});
ref.onDispose(() => GomuksDestroy(handle)); ref.onDispose(() => GomuksDestroy(handle));
ref.onDispose(callable.close);
final errorCode = GomuksStart(handle, callable.nativeFunction); return handle;
if (errorCode == 0) return handle;
throw Exception("GomuksStart returned error code $errorCode");
} }
Future<dynamic> _sendCommand( Future<dynamic> callGomuksMethod(
String command, [ Map<String, dynamic> data,
Map<String, dynamic> data = const {}, FutureOr<GomuksResponse> Function(int handle, GomuksBorrowedBuffer data)
]) async { callback,
) async {
final bufferPointer = data.toGomuksBufferPtr(); final bufferPointer = data.toGomuksBufferPtr();
final handle = await future;
final response = await Isolate.run( try {
() => GomuksSubmitCommand( final handle = await future;
handle,
command.toNativeUtf8().cast<Char>(), final response = await Isolate.run(
bufferPointer.ref, () => callback(handle, bufferPointer.ref),
), );
final json = response.buf.toJson();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw json;
}
return json;
} finally {
calloc
..free(bufferPointer.ref.base)
..free(bufferPointer);
}
}
Future<(Event, RoomMetadata)> handlePush(Map<String, dynamic> data) async {
final response = await callGomuksMethod(
data,
(handle, data) async => GomuksHandlePush(handle, data),
); );
calloc.free(bufferPointer); return (
Event.fromJson(response["event"]),
final json = response.buf.toJson(); RoomMetadata.fromJson(response["room"]),
if (response.command.cast<Utf8>().toDartString() == "error") { );
throw json;
}
return json;
} }
dynamic _sendCommand(
String command, [
Map<String, dynamic> data = const {},
]) => callGomuksMethod(data, (handle, data) {
final commandPointer = command.toNativeUtf8().cast<Char>();
try {
return GomuksSubmitCommand(handle, commandPointer, data);
} finally {
calloc.free(commandPointer);
}
});
Future<void> redactEvent(RedactEventRequest report) => Future<void> redactEvent(RedactEventRequest report) =>
_sendCommand("redact_event", report.toJson()); _sendCommand("redact_event", report.toJson());
@ -213,21 +183,17 @@ class ClientController extends AsyncNotifier<int> {
} }
} }
Future<String> joinRoom(JoinRoomRequest request) async { Future<String> joinRoom(JoinRoomRequest request) async =>
final response = await _sendCommand("join_room", request.toJson()); (await _sendCommand("join_room", request.toJson()))["room_id"];
return response["room_id"];
} Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
Future<void> leaveRoom(Room room) async { Future<void> leaveRoom(Room room) async {
if (room.metadata == null) return; if (room.metadata == null) return;
await _sendCommand("leave_room", {"room_id": room.metadata!.id}); await _sendCommand("leave_room", {"room_id": room.metadata!.id});
} }
// (await _sendCommand("get_event_context", {
// "room_id": request.roomId,
// "event_id": r"$OqZT4NuTj0J1-771IOEEWRI4XdumRNu6ighlvO3K3gc",
// }));
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async { Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
Future<List?> getState(GetRoomStateRequest request) async => Future<List?> getState(GetRoomStateRequest request) async =>
(await _sendCommand("get_room_state", request.toJson())) as List?; (await _sendCommand("get_room_state", request.toJson())) as List?;
@ -248,6 +214,12 @@ class ClientController extends AsyncNotifier<int> {
return .new(response?.map((event) => .fromJson(event))); return .new(response?.map((event) => .fromJson(event)));
} }
Future<IList<Event>> getMentions(GetMentionsRequest request) async => .new(
// TODO: Handle `related_events`
((await _sendCommand("get_mentions", request.toJson()))["events"] as List)
.map((event) => .fromJson(event)),
);
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);
@ -259,8 +231,19 @@ 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<ProfileResponse> getProfile(String userId) async => Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("get_profile", {"user_id": userId})); .fromJson(await _sendCommand("paginate_manual", request.toJson()));
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
.fromJson(await _sendCommand("get_event_context", request.toJson()));
Future<ProfileResponse> getProfile(String userId) async {
try {
return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));
} catch (_) {
return ProfileResponse(profile: .new(id: userId));
}
}
Future<void> reportEvent(ReportRequest request) => Future<void> reportEvent(ReportRequest request) =>
_sendCommand("report_event", request.toJson()); _sendCommand("report_event", request.toJson());
@ -271,6 +254,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()));
@ -280,9 +272,11 @@ class ClientController extends AsyncNotifier<int> {
Future<void> logout() => _sendCommand("logout"); Future<void> logout() => _sendCommand("logout");
Future<void> markRead(Room room) async { Future<void> markRead(Room room) async {
if (room.timeline.isEmpty || room.metadata == null) return;
final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
final event = eventRowId == null ? null : room.events[eventRowId]; final event = eventRowId == null ? null : room.events[eventRowId];
if (event == null || room.metadata == null) return;
if (event == null) return;
await _sendCommand("mark_read", { await _sendCommand("mark_read", {
"room_id": room.metadata!.id, "room_id": room.metadata!.id,
@ -308,10 +302,14 @@ class ClientController extends AsyncNotifier<int> {
Future<SpecVersionsResponse> getSpecVersions() async => Future<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions")); .fromJson(await _sendCommand("get_versions"));
Future<Capabilities> getCapabilities() async => Capabilities.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", {
"user_id": "@fake-user:${homeserver.host}", "user_id": "@fake-user:${homeserver.authority}",
}); });
return Uri.parse(response["m.homeserver"]?["base_url"]); return Uri.parse(response["m.homeserver"]?["base_url"]);
} catch (error) { } catch (error) {

View file

@ -1,10 +1,7 @@
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
class ClientIdController extends AsyncNotifier<String> { class ClientIdController(final Uri homeserver) extends AsyncNotifier<String> {
final Uri homeserver;
ClientIdController(this.homeserver);
@override @override
Future<String> build() => ref Future<String> build() => ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)

View file

@ -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:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:http/http.dart";
import "package:nexus/models/emoji.dart";
typedef EmojiTuple = (IMap<String, EmojiCategory>, IMap<String, List<String>>);
class EmojiController extends AsyncNotifier<EmojiTuple> {
@override
Future<EmojiTuple> build() async {
final response = await get(
.https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"),
);
if (response.statusCode != 200) {
throw Exception("Failed to load emoji data");
}
final data = json.decode(response.body);
final entries = (data as List)
.cast<Map<String, dynamic>>()
.map(Emoji.fromJson)
.toIList();
final categoryMap = entries.fold<IMap<String, IList<String>>>(
.new(),
(acc, entry) => acc.update(
entry.category,
(list) => list.add(entry.emoji),
ifAbsent: () => .new([entry.emoji]),
),
);
final keywordMap = entries.fold<IMap<String, IList<String>>>(
.new(),
(acc, entry) => acc.add(
entry.emoji,
.new([...entry.tags, ...entry.aliases, entry.description]),
),
);
final customCategories = IMap.fromEntries(
categoryMap.entries.map(
(entry) => MapEntry(
entry.key,
EmojiCategory(
name: entry.key,
icon: switch (entry.key) {
"Smileys & Emotion" => Icons.emoji_emotions,
"People & Body" => Icons.emoji_people,
"Animals & Nature" => Icons.emoji_nature,
"Food & Drink" => Icons.emoji_food_beverage,
"Travel & Places" => Icons.travel_explore,
"Activities" => Icons.sports_soccer,
"Objects" => Icons.emoji_objects,
"Symbols" => Icons.emoji_symbols,
"Flags" => Icons.emoji_flags,
_ => Icons.category,
},
emojis: entry.value.toList(growable: false),
),
),
),
);
final customKeywords = IMap(
.fromEntries(
keywordMap.entries.map(
(e) => .new(e.key, e.value.toList(growable: false)),
),
),
);
return (customCategories, customKeywords);
}
static final provider = AsyncNotifierProvider<EmojiController, EmojiTuple>(
EmojiController.new,
);
}

View file

@ -5,10 +5,8 @@ import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/get_event.dart";
class EventController extends AsyncNotifier<Event?> { class EventController(final GetEventRequest request)
final GetEventRequest request; extends AsyncNotifier<Event?> {
EventController(this.request);
@override @override
Future<Event?> build() async { Future<Event?> build() async {
final room = ref.watch( final room = ref.watch(

View file

@ -0,0 +1,124 @@
import "dart:async";
import "dart:ffi";
import "package:flutter/foundation.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/controllers/sync_status.dart";
import "package:nexus/controllers/top_level_spaces.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.dart";
import "package:ffi/ffi.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
class GomuksListenerController extends AsyncNotifier<void> {
@override
Future<void> build() async {
debugPrint("Starting gomuks...");
final handle = await ref.watch(ClientController.provider.future);
final callable =
NativeCallable<
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
>.listener((
Pointer<Char> command,
int requestId,
GomuksOwnedBuffer data,
) {
try {
final muksEventType = command.cast<Utf8>().toDartString();
debugPrint("Handling $muksEventType...");
final decodedMuksEvent = data.toJson();
switch (muksEventType) {
case "client_state":
ref
.watch(ClientStateController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
break;
case "sync_status":
ref
.watch(SyncStatusController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
break;
case "init_complete":
ref.watch(InitCompleteController.provider.notifier).complete();
break;
case "send_complete":
final event = Event.fromJson(decodedMuksEvent["event"]);
ref
.watch(RoomsController.provider.notifier)
.update(
.new({
event.roomId: .new(events: .new({event.rowId: event})),
}),
.new(),
);
break;
case "sync_complete":
final syncData = SyncData.fromJson(decodedMuksEvent);
final roomProvider = RoomsController.provider;
final accountDataProvider = AccountDataController.provider;
if (syncData.clearState) {
ref.invalidate(roomProvider);
ref.invalidate(accountDataProvider);
}
ref
.watch(roomProvider.notifier)
.update(syncData.rooms, syncData.leftRooms);
ref
.watch(accountDataProvider.notifier)
.update(syncData.accountData);
if (syncData.topLevelSpaces != null) {
ref
.watch(TopLevelSpacesController.provider.notifier)
.set(syncData.topLevelSpaces!);
}
if (syncData.spaceEdges != null) {
ref
.watch(SpaceEdgesController.provider.notifier)
.set(syncData.spaceEdges!);
}
// ref
// .watch(SyncStatusController.provider.notifier)
// .set(SyncStatus.fromJson(decodedMuksEvent));
break;
default:
debugPrint("Unhandled event: $muksEventType");
}
debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) {
if (kDebugMode) {
debugPrintStack(stackTrace: stackTrace, label: error.toString());
rethrow;
} else {
showError(error, stackTrace);
}
}
});
ref.onDispose(callable.close);
final errorCode = GomuksStart(handle, callable.nativeFunction);
if (errorCode != 0) {
throw Exception("GomuksStart returned error code $errorCode");
}
}
static final provider = AsyncNotifierProvider<GomuksListenerController, void>(
GomuksListenerController.new,
);
}

View file

@ -0,0 +1,16 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
class JumpToEventController(String? _) extends Notifier<String?> {
@override
String? build() => null;
void set(String? eventId) => state = eventId;
@override
bool updateShouldNotify(_, _) => true;
static final provider = NotifierProvider.family
.autoDispose<JumpToEventController, String?, String?>(
JumpToEventController.new,
);
}

View file

@ -1,30 +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 extends Notifier<String?> { class KeyController(final String key) extends AsyncNotifier<String?> {
final String key;
KeyController(this.key);
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.watch(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,
); );
} }

View file

@ -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.watch(SharedPrefsController.provider).setBool(key, value);
prefs.setBool(key, value);
} }
static final provider = NotifierProvider<MemberListOpenedController, bool>( static final provider =
MemberListOpenedController.new, AsyncNotifierProvider<MemberListOpenedController, bool>(
); MemberListOpenedController.new,
);
} }

View file

@ -6,10 +6,8 @@ import "package:nexus/models/content/content.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_room_state.dart";
class MembersController extends AsyncNotifier<ISet<Event>> { class MembersController(final String roomId)
final String roomId; extends AsyncNotifier<ISet<Event>> {
MembersController(this.roomId);
@override @override
Future<ISet<Event>> build() async { Future<ISet<Event>> build() async {
final room = ref.watch( final room = ref.watch(

View file

@ -5,10 +5,8 @@ import "package:nexus/models/configs/members_by_status.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
class MembersByStatusController extends AsyncNotifier<ISet<Event>> { class MembersByStatusController(final MembersByStatusConfig config)
final MembersByStatusConfig config; extends AsyncNotifier<ISet<Event>> {
MembersByStatusController(this.config);
@override @override
Future<ISet<Event>> build() => ref.watch( Future<ISet<Event>> build() => ref.watch(
MembersController.provider(config.roomId).selectAsync( MembersController.provider(config.roomId).selectAsync(

View file

@ -8,11 +8,8 @@ import "package:nexus/models/content/content.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/event.dart";
class MembersGroupedController class MembersGroupedController(final MembersByStatusConfig config)
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> { extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
final MembersByStatusConfig config;
MembersGroupedController(this.config);
@override @override
Future<IList<MapEntry<int?, ISet<Event>>>> build() async { Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
final room = ref.watch( final room = ref.watch(

View file

@ -1,14 +1,15 @@
import "dart:async"; import "dart:async";
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";
class MultiProviderController extends AsyncNotifier<void> { class MultiProviderController(final IList<AsyncNotifierProvider> providers)
MultiProviderController(this.providers); extends AsyncNotifier<void> {
final IList<AsyncNotifierProvider> providers;
@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<

View file

@ -0,0 +1,162 @@
import "dart:async";
import "dart:io";
import "package:flutter/services.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/portal.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/widgets/pages/notifications.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
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 case final eventId?) {
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsPage(
highlightedEventId: eventId,
defaultToAllNotifications: true,
),
),
);
}
}
},
);
}
if (Platform.isLinux) {
const notificationChannel = MethodChannel("nexus/notifications");
notificationChannel.setMethodCallHandler((call) async {
if (call.method != "notificationClicked") {
return;
}
final eventId = call.arguments as String;
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsPage(
highlightedEventId: eventId,
defaultToAllNotifications: true,
),
),
);
}
});
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;
}
Future<void> send({
required int id,
required String title,
String? body,
File? icon,
String? payload,
}) async {
debugPrint("Sending notification for $id");
if (Platform.isLinux) {
final portal = await ref.watch(PortalController.provider.future);
await portal.notification.addNotification(
id.toString(),
title: title,
body: body,
icon: icon == null ? null : XdgNotificationIconFile(icon.path),
defaultAction: "app.event",
defaultActionTarget: payload,
);
} else {
final notificationDetails = NotificationDetails(
android: .new(
"messages",
"Messages",
largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path),
),
// TODO: See if icons can be added to iOS, macOS, and Windows
// notifications (#68)
iOS: .new(),
macOS: .new(),
windows: .new(),
);
final controller = await future;
await controller.show(
id: id,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: payload,
);
}
}
static final provider =
AsyncNotifierProvider<
NotificationController,
FlutterLocalNotificationsPlugin
>(NotificationController.new);
}

View file

@ -0,0 +1,53 @@
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/models/event.dart";
typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController([final NotificationsRequest? request])
extends AsyncNotifier<IList<Event>> {
@override
Future<IList<Event>> build() async {
final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request ?? (null, null);
return await client.getMentions(
.new(
maxTimestamp: .now(),
unreadType: unreadType ?? .highlight,
roomId: roomId,
),
);
}
Future<void> loadOlder() async {
final currentNotifications = await future;
state = .loading();
state = await .guard(() async {
final lastTs = currentNotifications.lastOrNull?.timestamp;
if (lastTs == null) return const .empty();
final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request ?? (null, null);
final newNotifications = await client.getMentions(
.new(
maxTimestamp: lastTs,
unreadType: unreadType ?? .highlight,
roomId: roomId,
),
);
return currentNotifications.addAll(newNotifications);
});
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
NotificationsController,
IList<Event>,
NotificationsRequest?
>(NotificationsController.new);
}

View file

@ -4,10 +4,8 @@ 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"; import "package:nexus/models/event.dart";
class PinnedEventsController extends AsyncNotifier<IList<Event>> { class PinnedEventsController(final String roomId)
final String roomId; extends AsyncNotifier<IList<Event>> {
PinnedEventsController(this.roomId);
@override @override
Future<IList<Event>> build() async { Future<IList<Event>> build() async {
final pinIds = ref.watch(PinnedIdsController.provider(roomId)); final pinIds = ref.watch(PinnedIdsController.provider(roomId));
@ -15,9 +13,8 @@ class PinnedEventsController extends AsyncNotifier<IList<Event>> {
return (await Future.wait( return (await Future.wait(
pinIds.map( pinIds.map(
(eventId) => ref.watch( (eventId) => ref.watch(
EventController.provider( EventController.provider(.new(eventId: eventId, roomId: roomId))
.new(eventId: eventId, roomId: roomId), .future,
).future,
), ),
), ),
)).nonNulls.toIList(); )).nonNulls.toIList();

View file

@ -5,10 +5,7 @@ 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/pinned_events.dart"; import "package:nexus/models/content/pinned_events.dart";
class PinnedIdsController extends Notifier<IList<String>> { class PinnedIdsController(final String roomId) extends Notifier<IList<String>> {
final String roomId;
PinnedIdsController(this.roomId);
@override @override
IList<String> build() { IList<String> build() {
final room = ref.watch( final room = ref.watch(

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

View file

@ -6,10 +6,8 @@ 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"; import "package:nexus/models/content/power_levels.dart";
class PowerLevelController extends Notifier<bool> { class PowerLevelController(final PowerLevelConfig config)
final PowerLevelConfig config; extends Notifier<bool> {
PowerLevelController(this.config);
@override @override
bool build() { bool build() {
if (config case EventPowerLevelConfig(:final eventType)) { if (config case EventPowerLevelConfig(:final eventType)) {

View file

@ -2,13 +2,11 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/models/profile_response.dart"; import "package:nexus/models/profile_response.dart";
class ProfileController extends AsyncNotifier<ProfileResponse> { class ProfileController(final String userId)
final String userId; extends AsyncNotifier<ProfileResponse> {
ProfileController(this.userId);
@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);
} }

View 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.watch(provider.notifier);
final prefs = IMap(json.decode(await ref.watch(provider.future) ?? "{}"));
state = .data(value);
notifier.set(
json.encode(
prefs.add(instance, value).where((_, value) => value != null).unlock,
),
);
}
static final provider =
AsyncNotifierProvider.family<PushKeyController, String?, String>(
PushKeyController.new,
);
}

View file

@ -5,10 +5,8 @@ import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/configs/reactions.dart"; import "package:nexus/models/configs/reactions.dart";
import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/reaction.dart";
class ReactionsController extends AsyncNotifier<IMap<String, IList<String>>> { class ReactionsController(final ReactionsConfig config)
final ReactionsConfig config; extends AsyncNotifier<IMap<String, IList<String>>> {
ReactionsController(this.config);
@override @override
Future<IMap<String, IList<String>>> build() async { Future<IMap<String, IList<String>>> build() async {
final eventInfo = ref.watch( final eventInfo = ref.watch(

View file

@ -0,0 +1,44 @@
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/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() => ref.watch(
AccountDataController.provider.select(
(value) => value.recentEmoji.recentEmoji,
),
);
Future<void> add(String emoji) => ref
.watch(ClientController.provider.notifier)
.setAccountData(
.new(
type: AccountData.recentEmojiKey,
content: RecentEmojiData(
recentEmoji: .new([
.new(
emoji: emoji,
total:
(state
.firstWhereOrNull(
(element) => element.emoji == emoji,
)
?.total ??
0) +
1,
),
...state.whereNot((element) => element.emoji == emoji),
]),
),
),
);
static final provider =
NotifierProvider.autoDispose<RecentEmojiController, IList<RecentEmoji>>(
RecentEmojiController.new,
);
}

View file

@ -1,5 +1,6 @@
import "dart:async"; import "dart:async";
import "dart:math"; import "dart:math";
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
@ -9,45 +10,36 @@ import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/room_chat.dart";
class RoomChatController extends AsyncNotifier<IList<Event>?> { class RoomChatController(final (String roomId, String? contextualEvent) info)
final String roomId; extends AsyncNotifier<RoomChat?> {
Future<bool>? _loadingOlder;
RoomChatController(this.roomId);
@override @override
Future<IList<Event>?> build() async { Future<RoomChat?> build() async {
final client = ref.watch(ClientController.provider.notifier); final (roomId, eventId) = info;
final initialRoom = ref.watch( final client = ref.read(ClientController.provider.notifier);
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]), RoomsController.provider.select((rooms) => rooms[roomId]),
); );
if (initialRoom == null) return null; if (room == null) return null;
Room room = initialRoom;
if (!room.hasFetchedState) { if (!room.hasFetchedState) {
final state = await client.getRoomState(.new(roomId: roomId)); final state = await client.getRoomState(.new(roomId: roomId));
await ref.read(RoomsController.provider.notifier).addState(roomId, state); await ref.read(RoomsController.provider.notifier).addState(roomId, state);
room = ref.read(RoomsController.provider)[roomId] ?? room;
} }
// Load one more page when the initial timeline is short. final timeline = room.timeline
if (room.hasMore && room.timeline.length < 20) {
await loadOlder();
room = ref.read(RoomsController.provider)[roomId] ?? room;
}
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((element) => element.value)
.toIList() .toIList()
.addAll(room.sticky) .addAll(room.clientSticky)
.map((entry) { .map((entry) {
final foundEvent = entry == null ? null : room.events[entry]; final foundEvent = entry == null ? null : room.events[entry];
@ -65,6 +57,26 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
}) })
.nonNulls .nonNulls
.toIList(); .toIList();
if (info.$2 == null || timeline.map((e) => e.eventId).contains(info.$2)) {
ref.watch(RoomsController.provider.select((rooms) => rooms[roomId]));
return .new(
timeline: timeline,
hasMoreBackward: room.hasMore,
hasMoreForward: false,
);
} else {
final context = await client.getEventContext(
.new(roomId: roomId, eventId: info.$2!),
);
return .new(
timeline: context.before.add(context.event).addAll(context.after),
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
@ -72,53 +84,101 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
.redactEvent( .redactEvent(
RedactEventRequest( RedactEventRequest(
eventId: event.eventId, eventId: event.eventId,
roomId: roomId, roomId: info.$1,
reason: reason, reason: reason,
), ),
); );
Future<bool> loadOlder() => _loadingOlder ??= _loadOlder().whenComplete(() { Future<void> paginate(Direction direction) async {
_loadingOlder = null; if (state.isLoading) return;
});
Future<bool> _loadOlder() async { final chat = await future;
final room = ref.read(RoomsController.provider)[roomId];
if (room == null || !room.hasMore) return false;
final timelineKeys = room.timeline.keys; if (direction == .forward
final response = await ref ? chat?.hasMoreForward == false
.watch(ClientController.provider.notifier) : chat?.hasMoreBackward == false) {
.paginate( return;
.new( }
roomId: roomId,
maxTimelineId: timelineKeys.isNotEmpty state = .loading();
? timelineKeys.reduce(min)
: null, final client = ref.read(ClientController.provider.notifier);
if (chat?.historicalData == null) {
final timelineKeys = ref
.read(RoomsController.provider.select((value) => value[info.$1]))
?.timeline
.keys;
final response = await client.paginate(
.new(
roomId: info.$1,
maxTimelineId: timelineKeys?.isNotEmpty == true
? timelineKeys?.reduce(min)
: null,
),
);
if (response.events.isEmpty) {
state = .data(state.value);
}
ref
.read(RoomsController.provider.notifier)
.update(
IMap({
info.$1: Room(
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
hasMore: response.hasMore,
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
),
),
}),
.new(),
);
} else {
final paginationResponse = await client.paginateManual(
.new(
roomId: info.$1,
direction: direction,
since: direction == .forward
? chat!.historicalData!.end
: chat!.historicalData!.start,
),
);
state = .data(
.new(
timeline: direction == .forward
? chat.timeline.addAll(paginationResponse.events)
: paginationResponse.events.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,
), ),
); ),
);
ref }
.watch(RoomsController.provider.notifier)
.update(
IMap({
roomId: Room(
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
hasMore: response.hasMore,
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
),
),
}),
.new(),
);
return response.hasMore;
} }
Future<void> send( Future<void> send(
@ -132,8 +192,8 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
if (relationType == .edit) { if (relationType == .edit) {
baseContent = relation?.content; baseContent = relation?.content;
} else { } else {
final provider = AttachmentController.provider(roomId); final provider = AttachmentController.provider(info.$1);
baseContent = ref.watch(provider)?.$2; baseContent = ref.read(provider)?.$2;
ref.invalidate(provider); ref.invalidate(provider);
} }
@ -149,10 +209,10 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
); );
} }
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.$1,
baseContent: baseContent, baseContent: baseContent,
mentions: Mentions( mentions: Mentions(
userIds: [ userIds: [
@ -171,12 +231,12 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
); );
ref ref
.watch(RoomsController.provider.notifier) .read(RoomsController.provider.notifier)
.update( .update(
.new({ .new({
roomId: .new( info.$1: .new(
events: .new({event.rowId: event}), events: .new({event.rowId: event}),
sticky: .new({event.rowId}), clientSticky: .new({event.rowId}),
), ),
}), }),
.new(), .new(),
@ -188,10 +248,10 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
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.$1,
eventId: event.eventId, eventId: event.eventId,
relationType: "m.annotation", relationType: "m.annotation",
), ),
@ -212,16 +272,16 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
if (reactionEvent != null) { if (reactionEvent != null) {
await ref await ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId)); .redactEvent(.new(eventId: reactionEvent.eventId, roomId: info.$1));
} }
} }
Future<void> sendReaction(String reaction, Event event) async { Future<void> sendReaction(String reaction, Event event) async {
final client = ref.watch(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
await client.sendEvent( await client.sendEvent(
.new( .new(
roomId: roomId, roomId: info.$1,
type: EventType.reaction.type, type: EventType.reaction.type,
content: ReactionContent(key: reaction), content: ReactionContent(key: reaction),
synchronous: true, synchronous: true,
@ -233,7 +293,7 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
} }
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, IList<Event>?, String>( .autoDispose<RoomChatController, RoomChat?, (String, String?)>(
RoomChatController.new, RoomChatController.new,
); );
} }

View file

@ -4,10 +4,7 @@ import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/create.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class RoomCreatorsController extends Notifier<IList<String>> { class RoomCreatorsController(final Room room) extends Notifier<IList<String>> {
final Room room;
RoomCreatorsController(this.room);
@override @override
IList<String> build() { IList<String> build() {
final createRowId = room.state[EventType.create.type]?[""]; final createRowId = room.state[EventType.create.type]?[""];

View file

@ -0,0 +1,16 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/requests/join_room.dart";
import "package:nexus/models/room_summary.dart";
class RoomSummaryController(final JoinRoomRequest request)
extends AsyncNotifier<RoomSummary> {
@override
Future<RoomSummary> build() =>
ref.read(ClientController.provider.notifier).getRoomSummary(request);
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomSummaryController, RoomSummary, JoinRoomRequest>(
RoomSummaryController.new,
);
}

View file

@ -1,4 +1,5 @@
import "dart:isolate"; import "dart:isolate";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
@ -46,10 +47,10 @@ class RoomsController extends Notifier<IMap<String, Room>> {
roomId, roomId,
existing?.copyWith( existing?.copyWith(
hasMore: incoming.hasMore, hasMore: incoming.hasMore,
sticky: clientSticky:
(incoming.sticky.isEmpty == true (incoming.clientSticky.isEmpty == true
? existing.sticky ? existing.clientSticky
: existing.sticky.addAll(incoming.sticky)) : existing.clientSticky.addAll(incoming.clientSticky))
.removeWhere( .removeWhere(
(rowId) => incoming.timeline.values.contains(rowId), (rowId) => incoming.timeline.values.contains(rowId),
), ),

View file

@ -9,14 +9,14 @@ class SettingsController extends AsyncNotifier<Settings> {
final file = await ref.watch(SettingsFileController.provider.future); final file = await ref.watch(SettingsFileController.provider.future);
try { try {
return Settings.fromJson(json.decode(await file.readAsString())); return .fromJson(json.decode(await file.readAsString()));
} catch (_) { } catch (_) {
return Settings(); return .new();
} }
} }
Future<void> set(Settings settings) async { Future<void> set(Settings settings) async {
state = AsyncData(settings); state = .data(settings);
final file = await ref.watch(SettingsFileController.provider.future); final file = await ref.watch(SettingsFileController.provider.future);
await file.writeAsString(json.encode(settings.toJson())); await file.writeAsString(json.encode(settings.toJson()));
} }

View file

@ -1,13 +1,19 @@
import "dart:io"; import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.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/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/unified_push_allowed.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
@ -18,9 +24,6 @@ class SettingsSectionsController
@override @override
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);
final specVersionsResponse = await ref
.watch(ClientController.provider.notifier)
.getSpecVersions();
return .new({ return .new({
"General": .new([ "General": .new([
@ -49,8 +52,7 @@ class SettingsSectionsController
.new( .new(
title: "Use Dynamic Theme", title: "Use Dynamic Theme",
icon: Icons.palette, icon: Icons.palette,
description: description: "Toggle on or off Dynamic Theme. Only available on Android, Linux, Windows, or MacOS.",
"Toggle on or off Dynamic Theme. Only available on Android, Linux, Windows, or MacOS.",
builder: (title, description, icon) => SwitchListTile( builder: (title, description, icon) => SwitchListTile(
title: Text(title), title: Text(title),
subtitle: Text(description), subtitle: Text(description),
@ -76,8 +78,7 @@ class SettingsSectionsController
settings: .new([ settings: .new([
.new( .new(
title: "Linux Mobile Mode", title: "Linux Mobile Mode",
description: description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
"Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
icon: Icons.construction, icon: Icons.construction,
builder: (title, description, icon) => SwitchListTile( builder: (title, description, icon) => SwitchListTile(
title: Text(title), title: Text(title),
@ -98,46 +99,141 @@ class SettingsSectionsController
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true) if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
"Account": .new([ "Account": .new([
.new(title: "Profile", icon: Icons.person, settings: .new([])), .new(title: "Profile", icon: Icons.person, settings: .new([])),
.new(
title: "Notifications",
icon: Icons.notifications,
settings: .new([
.new(
title: "Push notifications",
description: "Enable push notifications using Web Push",
builder: (title, description, icon) => HookConsumer(
builder: (context, ref, _) {
final loading = useState(false);
final pusherRegistered = ref.watch(
UnifiedPushController.provider,
);
final unifiedPushAllowed = ref.watch(
UnifiedPushAllowedController.provider,
);
return SwitchListTile(
title: Text(title),
subtitle: Text(
unifiedPushAllowed.maybeWhen(
data: (data) => data,
orElse: () => null,
) ??
description,
),
secondary: Icon(icon),
value: pusherRegistered.maybeWhen(
data: (value) => value,
orElse: () => false,
),
onChanged: loading.value
? null
: unifiedPushAllowed.maybeWhen(
data: (data) => data == null,
orElse: () => false,
)
? pusherRegistered.maybeWhen(
data: (_) => (value) async {
try {
loading.value = true;
if (value) {
if (await ref
.watch(
NotificationController
.provider
.notifier,
)
.requestPermissions()) {
await ref
.watch(
UnifiedPushController
.provider
.notifier,
)
.register();
} else {
// TODO: Handle not granted
}
} else {
await ref
.watch(
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,
settings: .new([ settings: .new([
.new( .new(
title: "Invite Blocking", title: "Invite Blocking",
description: description: "Block invites, either completely, or block only invites from users without shared private rooms (depends on server support).",
"Block invites, either completely, or block only invites from users without shared private rooms (depends on server support).",
builder: (title, description, icon) => Consumer( builder: (title, description, icon) => Consumer(
builder: (context, ref, _) => builder: (context, ref, _) {
DialogListTile<DefaultInviteAction>( final specVersionsResponse = ref.watch(
icon: Icon(icon), SpecVersionsController.provider,
title: title, );
subtitle: Text(description), return DialogListTile<DefaultInviteAction>(
initialValue: ref icon: Icon(icon),
.watch(AccountDataController.provider) title: title,
.invitePermissionConfig subtitle: Text(description),
.defaultAction, initialValue: ref
options: specVersionsResponse.unstableFeatures.msc4494 .watch(AccountDataController.provider)
? DefaultInviteAction.values .invitePermissionConfig
: IList( .defaultAction,
DefaultInviteAction.values, options:
).remove(.denyPublic).toList(), specVersionsResponse.maybeWhen(
getName: (option) => switch (option) { data: (data) => data.unstableFeatures.msc4494,
.allow => "Allow", orElse: () => true,
.deny => "Deny", )
.denyPublic => "Deny public", ? DefaultInviteAction.values
}, : IList(DefaultInviteAction.values)
onChanged: (value) => ref .remove(.denyPublic)
.watch(ClientController.provider.notifier) .toList(),
.setAccountData( getName: (option) => switch (option) {
.new( .allow => "Allow",
type: AccountData.invitePermissionConfigKey, .deny => "Deny",
content: InvitePermissionConfig( .denyPublic => "Deny public",
defaultAction: value, },
), onChanged: specVersionsResponse.maybeWhen(
), data: (_) =>
) (value) => ref
.onError(showError), .watch(ClientController.provider.notifier)
.setAccountData(
.new(
type: AccountData.invitePermissionConfigKey,
content: InvitePermissionConfig(
defaultAction: value,
),
),
)
.onError(showError),
orElse: () => null,
), ),
);
},
), ),
icon: Icons.person_off, icon: Icons.person_off,
), ),
@ -156,15 +252,14 @@ class SettingsSectionsController
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return M3EButton.icon( return M3EButton.icon(
onPressed: () async { onPressed: () async {
Navigator.of( await ref
context, .watch(UnifiedPushController.provider.notifier)
).popUntil((route) => route.isFirst); .deregister()
.onError(showError);
await WidgetsBinding.instance.endOfFrame;
await ref await ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.logout(); .logout()
.onError(showError);
}, },
label: Text(title), label: Text(title),
icon: Icon(icon), icon: Icon(icon),

View file

@ -1,12 +1,12 @@
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:shared_preferences/shared_preferences.dart"; import "package:shared_preferences/shared_preferences.dart";
class SharedPrefsController extends 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,
); );
} }

View file

@ -1,6 +1,6 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";

View file

@ -0,0 +1,15 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/spec_versions_response.dart";
class SpecVersionsController extends AsyncNotifier<SpecVersionsResponse> {
@override
Future<SpecVersionsResponse> build() =>
ref.read(ClientController.provider.notifier).getSpecVersions();
static final provider =
AsyncNotifierProvider.autoDispose<
SpecVersionsController,
SpecVersionsResponse
>(SpecVersionsController.new);
}

View file

@ -0,0 +1,178 @@
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/key.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/client_state.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/sticker.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<bool> {
@override
Future<bool> build() async {
if (!Platform.isLinux && !Platform.isAndroid) return false;
final registered = await UnifiedPush.initialize(
linuxOptions: .new(
dbusName: "nexus.federated.nexus.UnifiedPush",
storage: UnifiedPushStorageSharedPreferences(),
background: isInBackground,
shouldWriteService: false,
),
onNewEndpoint: (endpoint, instance) 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(true);
},
onMessage: (message, instance) async {
debugPrint("UP message received for $instance");
if (message.decrypted == false) {
throw Exception(
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
);
}
final (event, roomMetadata) = await ref
.read(ClientController.provider.notifier)
.handlePush(json.decode(String.fromCharCodes(message.content)));
if (event.unreadType?.shouldNotify() != true ||
(!isInBackground &&
await windowManager.isFocused().onError((_, _) => true) &&
await ref.read(
KeyController.provider(KeyController.roomKey).future,
) ==
event.roomId)) {
if (isInBackground) exit(0);
return;
}
final icon = roomMetadata.avatar == null
? null
: await ref
.read(ClientController.provider.notifier)
.downloadMedia(
.new(mxc: roomMetadata.avatar!, isAvatar: true),
);
await ref
.read(NotificationController.provider.notifier)
.send(
id: event.eventId.hashCode & 0x7fffffff,
title: roomMetadata.name ?? "New Event",
icon: icon,
payload: event.eventId,
body: switch (event.content) {
MessageContent(:final body?) ||
StickerContent(:final body) => body,
_ => null,
},
);
if (isInBackground) exit(0);
},
onUnregistered: deregister,
);
ref.listen(
ClientStateController.provider.select((value) => value?.deviceId),
(_, _) => register(),
);
if (registered) {
// Needs to be registered every startup
await register();
}
return registered;
}
Future<void> register() async {
state = .loading();
try {
final deviceId = ref.read(
ClientStateController.provider.select((value) => value?.deviceId),
);
if (deviceId == null) return;
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 (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
throw Exception("No UnifiedPush distributors found.");
}
await UnifiedPush.register(
instance: deviceId,
vapid: capabilities.webpush?.vapid,
);
} catch (_) {
state = .data(false);
rethrow;
}
}
Future<void> deregister([String? instance]) async {
final clientState = ref.read(ClientStateController.provider);
final keyProvider = PushKeyController.provider(
instance ?? clientState!.deviceId!,
);
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 ?? clientState!.deviceId!);
state = .data(false);
}
static final provider = AsyncNotifierProvider<UnifiedPushController, bool>(
UnifiedPushController.new,
);
}

View file

@ -0,0 +1,33 @@
import "dart:io";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:unifiedpush/unifiedpush.dart";
class UnifiedPushAllowedController extends AsyncNotifier<String?> {
@override
Future<String?> build() async {
if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
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
.watch(ClientController.provider.notifier)
.getCapabilities();
if (capabilities.webpush?.vapid == null) {
return "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.";
}
return null;
}
static final provider =
AsyncNotifierProvider.autoDispose<UnifiedPushAllowedController, String?>(
UnifiedPushAllowedController.new,
);
}

View file

@ -3,10 +3,8 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/open_graph_data.dart";
class UrlPreviewController extends AsyncNotifier<OpenGraphData?> { class UrlPreviewController(final Uri url)
final Uri url; extends AsyncNotifier<OpenGraphData?> {
UrlPreviewController(this.url);
@override @override
Future<OpenGraphData?> build() async { Future<OpenGraphData?> build() async {
if (url.host == "matrix.to") return null; if (url.host == "matrix.to") return null;

View file

@ -7,10 +7,7 @@ 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/room.dart"; import "package:nexus/models/room.dart";
class ViaController extends Notifier<String> { class ViaController(final Room room) extends Notifier<String> {
final Room room;
ViaController(this.room);
@override @override
String build() { String build() {
final servers = <String>{}; final servers = <String>{};

View file

@ -0,0 +1,266 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/pinned_ids.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/recent_emoji.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/via.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/emoji_picker.dart";
import "package:nexus/main.dart";
extension BuildEventOptions on Event {
IList<PopupMenuEntry> buildEventOptions({
required BuildContext context,
required WidgetRef ref,
required String roomId,
required String userId,
required void Function(Event, RelationType) onRelation,
}) {
final theme = Theme.of(context);
final danger = theme.colorScheme.error;
final notifier = ref.read(
RoomChatController.provider((roomId, null)).notifier,
);
final client = ref.read(ClientController.provider.notifier);
final isPinned = ref
.watch(PinnedIdsController.provider(roomId))
.contains(eventId);
Future<void> sendReaction(String emoji) async {
await notifier.sendReaction(emoji, this).onError(showError);
await ref
.read(RecentEmojiController.provider.notifier)
.add(emoji)
.onError(showError);
}
void showReasonDialog({
required String title,
required String description,
required String action,
required Future<void> Function(String reason) onConfirm,
}) {
showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (context) {
final reasonController = useTextEditingController();
return AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(description),
const SizedBox(height: 12),
TextField(
controller: reasonController,
textCapitalization: .sentences,
decoration: .new(labelText: "Reason (optional)"),
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text("Cancel"),
),
TextButton(
onPressed: () {
final reason = reasonController.text;
Navigator.of(context).pop();
onConfirm(reason).onError(showError);
},
child: Text(action),
),
],
);
},
),
);
}
return .new([
if (ref.watch(
PowerLevelController.provider(
.new(eventType: .reaction, roomId: roomId),
),
))
PopupMenuItem(
enabled: false,
child: IconTheme(
data: theme.iconTheme,
child: Row(
children: [
for (final emoji in {
...ref
.watch(RecentEmojiController.provider)
.map((entry) => entry.emoji),
"👍",
"🤣",
"😭",
"🤔",
}.take(4))
IconButton(
icon: Text(emoji),
onPressed: () {
Navigator.of(context).pop();
sendReaction(emoji);
},
),
IconButton(
icon: const Icon(Icons.emoji_emotions),
onPressed: () {
Navigator.of(context).pop();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
allowFreeText: true,
onSelection: (emoji) {
Navigator.of(context).pop();
sendReaction(emoji);
},
),
);
},
),
],
),
),
),
if (ref.watch(
PowerLevelController.provider(
.new(eventType: .message, roomId: roomId),
),
))
PopupMenuItem(
onTap: () => onRelation(this, .reply),
child: const ListTile(
leading: Icon(Icons.reply),
title: Text("Reply"),
),
),
if (content is MessageContent && sender == userId)
PopupMenuItem(
onTap: () => onRelation(this, .edit),
child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
),
if (ref.watch(
PowerLevelController.provider(
.state(eventType: .pinnedEvents, roomId: roomId),
),
))
PopupMenuItem(
onTap: () async {
try {
final pins = ref.read(
PinnedIdsController.provider(roomId).notifier,
);
if (isPinned) {
await pins.removePin(eventId);
} else {
await pins.addPin(eventId);
}
} catch (error, stackTrace) {
showError(error, stackTrace);
}
},
child: ListTile(
leading: const Icon(Icons.push_pin),
title: Text(isPinned ? "Unpin Event" : "Pin Event"),
),
),
PopupMenuItem(
onTap: () async {
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
if (room == null) return;
final vias = ref.read(ViaController.provider(room));
await Clipboard.setData(
ClipboardData(
text:
"matrix:roomid/${room.metadata?.id.substring(1)}/e/$eventId$vias",
),
);
},
child: const ListTile(
leading: Icon(Icons.link),
title: Text("Copy Link"),
),
),
if (content case MessageContent(:final body?))
PopupMenuItem(
onTap: () => Clipboard.setData(ClipboardData(text: body)),
child: const ListTile(
leading: Icon(Icons.copy),
title: Text("Copy Text"),
),
),
if (ref.watch(
PowerLevelController.provider(
.redaction(targetUser: sender, roomId: roomId),
),
))
PopupMenuItem(
onTap: () => showReasonDialog(
title: "Delete Message",
description:
"Are you sure you want to delete this message? "
"This cannot be reversed.",
action: "Delete",
onConfirm: (reason) => notifier.deleteMessage(this, reason: reason),
),
child: ListTile(
leading: Icon(Icons.delete, color: danger),
title: Text("Delete", style: .new(color: danger)),
),
),
PopupMenuItem(
onTap: () => showReasonDialog(
title: "Report",
description:
"Report this this to your server administrators, "
"who can take action like banning this server or room.",
action: "Report",
onConfirm: (reason) => client.reportEvent(
.new(
roomId: roomId,
eventId: eventId,
reason: reason.isEmpty ? null : reason,
),
),
),
child: ListTile(
leading: Icon(Icons.report, color: danger),
title: Text("Report", style: .new(color: danger)),
),
),
]);
}
}

View file

@ -9,6 +9,7 @@ extension LinkToMention on String {
/// ///
/// Returns the decoded identifier (e.g. "#room:matrix.org") /// Returns the decoded identifier (e.g. "#room:matrix.org")
/// or null if this is not a Matrix link. /// or null if this is not a Matrix link.
/// TODO: Needs to be reworked to handle event links. Might be worth just rewriting, I don't like this code.
String? get mention { String? get mention {
final trimmed = trim(); final trimmed = trim();

View file

@ -1,4 +1,4 @@
import "package:flutter/material.dart"; import "package:material_ui/material_ui.dart";
extension SchemeToTheme on ColorScheme { extension SchemeToTheme on ColorScheme {
ThemeData get theme { ThemeData get theme {

View file

@ -1,11 +1,11 @@
import "package:flutter/material.dart"; import "package:material_ui/material_ui.dart";
import "package:flutter_svg/flutter_svg.dart"; import "package:flutter_svg/flutter_svg.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:m3e_card_list/m3e_card_list.dart"; import "package:m3e_card_list/m3e_card_list.dart";
import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/launch_helper.dart";
import "package:package_info_plus/package_info_plus.dart"; import "package:package_info_plus/package_info_plus.dart";
extension ShowContextMenu on BuildContext { extension ShowAboutDialog on BuildContext {
Future<void> showAboutDialog(WidgetRef ref) async { Future<void> showAboutDialog(WidgetRef ref) async {
final packageInfo = await PackageInfo.fromPlatform(); final packageInfo = await PackageInfo.fromPlatform();
@ -20,7 +20,7 @@ extension ShowContextMenu on BuildContext {
Row( Row(
spacing: 12, spacing: 12,
children: [ children: [
SvgPicture.asset("assets/icon.svg", width: 64), SvgPicture.asset("assets/bundled/icon.svg", width: 64),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: .start, crossAxisAlignment: .start,
@ -50,8 +50,8 @@ extension ShowContextMenu on BuildContext {
M3ECardColumn( M3ECardColumn(
onTap: (index) => onTap: (index) =>
ref.watch(LaunchHelper.provider).launchUrl(switch (index) { ref.watch(LaunchHelper.provider).launchUrl(switch (index) {
0 => Uri.https("git.federated.nexus", "nexus/nexus"), 0 => .https("git.federated.nexus", "nexus/nexus"),
_ => Uri.https("liberapay.com", "QuadRadical"), _ => .https("liberapay.com", "QuadRadical"),
}), }),
children: [ children: [
ListTile( ListTile(

View file

@ -1,4 +1,4 @@
import "package:flutter/material.dart"; import "package:material_ui/material_ui.dart";
extension ShowContextMenu on BuildContext { extension ShowContextMenu on BuildContext {
void showContextMenu({ void showContextMenu({
@ -9,7 +9,7 @@ extension ShowContextMenu on BuildContext {
showMenu( showMenu(
context: this, context: this,
constraints: .loose(Size.infinite), constraints: .loose(.infinite),
position: .fromLTRB( position: .fromLTRB(
globalPosition.dx, globalPosition.dx,
globalPosition.dy, globalPosition.dy,

View file

@ -1,4 +1,4 @@
import "package:flutter/material.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/widgets/user_bottom_sheet.dart"; import "package:nexus/widgets/user_bottom_sheet.dart";

View file

@ -1,5 +1,5 @@
import "package:color_hash/color_hash.dart"; import "package:color_hash/color_hash.dart";
import "package:flutter/material.dart"; import "package:material_ui/material_ui.dart";
extension ToColor on String { extension ToColor on String {
Color get colorHash => ColorHash(this, lightness: .5, saturation: .7).color; Color get colorHash => ColorHash(this, lightness: .5, saturation: .7).color;

View file

@ -0,0 +1,203 @@
import "dart:async";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room_chat.dart";
final class ChatScroll({
required final IList<Event> historyItems,
required final IList<Event> liveItems,
required final GlobalKey centerKey,
required final ScrollController scrollController,
required final bool atBottom,
required final Future<void> Function(String id) jumpToId,
required final Future<void> Function() jumpToBottom,
required final GlobalKey Function(String eventId) keyFor,
}) {
factory use({
required AsyncValue<RoomChat?> controllerData,
required Future<void> Function(Direction direction) paginate,
required Future<void> Function() markRead,
required ValueNotifier<String?> contextualEvent,
}) {
final anchorId = useState<String?>(null);
final itemKeys = useMemoized(() => <String, GlobalKey>{}, []);
GlobalKey keyFor(String eventId) =>
itemKeys.putIfAbsent(eventId, GlobalKey.new);
final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new);
final atBottom = useState(true);
final pendingAnchorTarget = useState<String?>(null);
final anchorMountedCompleter = useRef<Completer<BuildContext>?>(null);
useEffect(() {
if (anchorId.value == null) {
if (controllerData case AsyncData(:final value?)
when value.timeline.isNotEmpty) {
final hasContextualEvent = value.timeline.any(
(event) => event.eventId == contextualEvent.value,
);
anchorId.value = hasContextualEvent
? contextualEvent.value
: value.timeline.last.eventId;
}
}
return null;
}, [controllerData, contextualEvent.value]);
useEffect(() {
final target = pendingAnchorTarget.value;
if (target == null) return null;
final found =
controllerData.value?.timeline.any(
(event) => event.eventId == target,
) ??
false;
if (found || controllerData is AsyncError) {
if (found) {
anchorId.value = target;
WidgetsBinding.instance.addPostFrameCallback((_) {
final context = keyFor(target).currentContext;
if (context != null && context.mounted) {
anchorMountedCompleter.value?.complete(context);
anchorMountedCompleter.value = null;
}
});
} else {
anchorMountedCompleter.value?.completeError(
StateError("Failed to load context for $target"),
);
anchorMountedCompleter.value = null;
}
pendingAnchorTarget.value = null;
}
return null;
}, [controllerData, pendingAnchorTarget.value]);
final ({IList<Event> history, IList<Event> live}) split = useMemoized(() {
final items = controllerData.value?.timeline;
final anchor = anchorId.value;
if (items == null || anchor == null) {
return (history: const .empty(), live: const .empty());
}
final anchorIndex = items.indexWhere((item) => item.eventId == anchor);
if (anchorIndex == -1) {
return (history: const .empty(), live: items);
}
return (
history: items.take(anchorIndex).toIList().reversed.toIList(),
live: items.skip(anchorIndex).toIList(),
);
}, [controllerData, anchorId.value]);
useEffect(
() {
const loadThreshold = 500.0;
const readThreshold = 50.0;
Future<void> checkPosition() async {
if (!scrollController.hasClients) return;
final position = scrollController.position;
final isAtBottom = position.extentBefore <= readThreshold;
if (isAtBottom != atBottom.value) atBottom.value = isAtBottom;
if (position.extentAfter <= loadThreshold) {
await paginate(.backward);
} else if (contextualEvent.value != null &&
position.extentBefore <= loadThreshold) {
await paginate(.forward);
} else if (position.extentBefore <= readThreshold) {
await markRead();
}
}
scrollController.addListener(checkPosition);
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
return () => scrollController.removeListener(checkPosition);
},
[
scrollController,
controllerData,
paginate,
markRead,
contextualEvent.value,
],
);
return .new(
historyItems: split.history,
liveItems: split.live,
centerKey: centerKey,
scrollController: scrollController,
atBottom: atBottom.value,
jumpToId: (String itemId) async {
if (!scrollController.hasClients) return;
final existing = keyFor(itemId).currentContext;
if (existing != null && existing.mounted) {
// Already mounted, just scroll
await Scrollable.ensureVisible(
existing,
alignment: 0.5,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
} else {
final completer = Completer<BuildContext>();
anchorMountedCompleter.value = completer;
pendingAnchorTarget.value = itemId;
contextualEvent.value = itemId;
final context = await completer.future;
if (!context.mounted) return;
await Scrollable.ensureVisible(context, alignment: 10);
if (!context.mounted) return;
await Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const .new(milliseconds: 700),
curve: Curves.easeOutCirc,
);
}
},
jumpToBottom: () async {
if (contextualEvent.value != null) {
anchorId.value = null;
contextualEvent.value = null;
}
if (!scrollController.hasClients) return;
await scrollController.animateTo(
scrollController.position.minScrollExtent,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
},
keyFor: keyFor,
);
}
}

View file

@ -2,10 +2,7 @@ import "package:flutter/services.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:url_launcher/url_launcher.dart" as ul; import "package:url_launcher/url_launcher.dart" as ul;
class LaunchHelper { class LaunchHelper(Ref ref) {
final Ref ref;
LaunchHelper(this.ref);
Future<bool> launchUrl(Uri url, {bool useWebview = false}) async { Future<bool> launchUrl(Uri url, {bool useWebview = false}) async {
try { try {
return await ul.launchUrl( return await ul.launchUrl(

View file

@ -1,14 +1,12 @@
import "dart:ui"; import "dart:ui";
import "package:flutter/widgets.dart"; import "package:flutter/widgets.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/download_media.dart";
class MxcImage extends ImageProvider<MxcImage> { class MxcImage(final WidgetRef ref, final DownloadMediaRequest request)
final WidgetRef ref; extends ImageProvider<MxcImage> {
final DownloadMediaRequest request;
const MxcImage(this.ref, this.request);
@override @override
Future<MxcImage> obtainKey(ImageConfiguration configuration) => Future<MxcImage> obtainKey(ImageConfiguration configuration) =>
Future.value(this); Future.value(this);

View file

@ -1,26 +1,31 @@
import "dart:io"; import "dart:io";
import "package:dynamic_color/dynamic_color.dart"; import "package:dynamic_color/dynamic_color.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:media_kit/media_kit.dart"; import "package:media_kit/media_kit.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/gomuks_listener.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/member_list_opened.dart";
import "package:nexus/controllers/multi_provider.dart"; import "package:nexus/controllers/multi_provider.dart";
import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/shared_prefs.dart"; import "package:nexus/controllers/unified_push.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart";
import "package:nexus/helpers/font_licenses.dart"; import "package:nexus/helpers/font_licenses.dart";
import "package:nexus/pages/chat.dart"; import "package:nexus/widgets/pages/chat.dart";
import "package:nexus/pages/select_server.dart"; import "package:nexus/widgets/pages/select_server.dart";
import "package:nexus/pages/verify.dart"; import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/pages/verify.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";
import "package:flutter/material.dart"; import "package:material_ui/material_ui.dart";
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
late final bool isInBackground;
final class Logger extends ProviderObserver { final class Logger extends ProviderObserver {
@override @override
@ -37,14 +42,7 @@ New Value: ${newValue is AsyncData ? newValue.value : newValue}
} }
void showError(Object error, [StackTrace? stackTrace]) { void showError(Object error, [StackTrace? stackTrace]) {
if (error.toString().contains("DioException") || if (error.toString().contains("'_nextFrame != null': is not true.")) {
error.toString().contains(
"setState() or markNeedsBuild() called during build.",
) ||
error.toString().contains("Invalid source") ||
error.toString().contains("UTF-16") ||
error.toString().contains("HTTP request failed") ||
error.toString().contains("Invalid image data")) {
return; return;
} }
@ -61,49 +59,59 @@ void showError(Object error, [StackTrace? stackTrace]) {
} }
} }
void main() async { void main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { LicenseRegistry.addLicense(() => .fromIterable(fontLicenses));
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(
WindowOptions(
titleBarStyle: TitleBarStyle.hidden,
windowButtonVisibility: false,
),
);
await windowManager.setMinimumSize(Size.square(500));
}
LicenseRegistry.addLicense(() => Stream.fromIterable(fontLicenses));
FlutterError.onError = (FlutterErrorDetails details) => FlutterError.onError = (FlutterErrorDetails details) =>
showError(details.exception.toString(), details.stack); showError(details.exception.toString(), details.stack);
runApp( isInBackground =
ProviderScope( Platform.environment["FLUTTER_HEADLESS"] != null ||
retry: (_, _) => null, args.contains("--unifiedpush-bg");
observers: [
// Change false to true if you want debug information on provider reloads if (isInBackground) {
// ignore: dead_code await ProviderContainer()
if (false && kDebugMode) Logger(), .read(UnifiedPushController.provider.future)
], .timeout(Duration(seconds: 5));
child: const App(),
), await Future.delayed(Duration(seconds: 10));
); exit(0);
} else {
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(
WindowOptions(
titleBarStyle: TitleBarStyle.hidden,
windowButtonVisibility: false,
),
);
await windowManager.setMinimumSize(Size.square(500));
}
runApp(
ProviderScope(
retry: (_, _) => null,
observers: [
// Change false to true if you want debug information on provider reloads
// ignore: dead_code
if (false && kDebugMode) Logger(),
],
child: App(),
),
);
}
} }
class App extends StatelessWidget { class const App({super.key}) extends StatelessWidget {
const App({super.key});
@override @override
Widget build(BuildContext context) => DynamicColorBuilder( Widget build(BuildContext context) => DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => Consumer( builder: (lightDynamic, darkDynamic) => Consumer(
builder: (context, ref, child) => MaterialApp( builder: (context, ref, child) => MaterialApp(
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
// Use indigo to work around bugs in theme generation
theme: theme:
(ref (ref
.watch(SettingsController.provider) .watch(SettingsController.provider)
@ -134,36 +142,55 @@ class App extends StatelessWidget {
), ),
child: Scaffold( child: Scaffold(
body: Consumer( body: Consumer(
builder: (_, ref, _) => ref builder: (_, ref, _) => switch (ref.watch(
.watch( MultiProviderController.provider(
MultiProviderController.provider( .new([
IListConst([ GomuksListenerController.provider,
SharedPrefsController.provider, NotificationController.provider,
ClientController.provider, UnifiedPushController.provider,
]), MemberListOpenedController.provider,
), KeyController.provider(KeyController.roomKey),
) KeyController.provider(KeyController.spaceKey),
.betterWhen( ]),
data: (_) => Consumer( ),
builder: (_, ref, _) { )) {
final clientState = ref.watch( AsyncData(value: _) || AsyncLoading(value: _?) => Consumer(
ClientStateController.provider, builder: (_, ref, _) {
); final clientState = ref.watch(ClientStateController.provider);
if (clientState == null || !clientState.isInitialized) { if (clientState == null || !clientState.isInitialized) {
return Loading(); return Loading();
} }
if (!clientState.isLoggedIn) { if (!clientState.isLoggedIn) {
return SelectServerPage(); return SelectServerPage();
} else if (!clientState.isVerified) { } else if (!clientState.isVerified) {
return VerifyPage(); return VerifyPage();
} else { } else {
return ChatPage(); return ChatPage();
} }
}, },
), ),
AsyncLoading _ => Scaffold(
appBar: Appbar(
actions: .new([
IconButton(
onPressed: () => showDialog(
context: context,
builder: (_) => SettingsPage(),
),
icon: Icon(Icons.settings),
),
]),
), ),
body: Loading(),
),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
), ),
), ),
), ),

View file

@ -1,62 +1,60 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
part "account_data.freezed.dart"; part "account_data.freezed.dart";
part "account_data.g.dart"; part "account_data.g.dart";
@freezed @Freezed(toJson: false, fromJson: false)
abstract class AccountData with _$AccountData { @JsonSerializable()
const AccountData._(); class const AccountData({
static List<dynamic>? readRecentEmojiValue( @JsonKey(name: AccountData.invitePermissionConfigKey)
Map<dynamic, dynamic> json, final InvitePermissionConfig invitePermissionConfig =
String key, const InvitePermissionConfig(),
) => json[key]?["recent_emoji"];
static Map<String, List<dynamic>>? recentEmojiToJson( @JsonKey(name: AccountData.directKey)
IList<RecentEmoji> recentEmoji, final IMap<String, IList<String>> directMessages = const IMap.empty(),
) => {"recent_emoji": recentEmoji.map((emoji) => emoji.toJson()).toList()};
@JsonKey(name: AccountData.recentEmojiKey)
final RecentEmojiData recentEmoji = const RecentEmojiData(),
}) with _$AccountData {
static const invitePermissionConfigKey = "m.invite_permission_config"; static const invitePermissionConfigKey = "m.invite_permission_config";
static const directKey = "m.direct"; static const directKey = "m.direct";
static const recentEmojiKey = "m.recent_emoji"; static const recentEmojiKey = "m.recent_emoji";
const factory AccountData({ Map<String, Object?> toJson() => _$AccountDataToJson(this);
@JsonKey(name: AccountData.invitePermissionConfigKey)
@Default(InvitePermissionConfig())
InvitePermissionConfig invitePermissionConfig,
@JsonKey(name: AccountData.directKey)
@Default(IMap.empty())
IMap<String, IList<String>> directMessages,
@JsonKey(
name: AccountData.recentEmojiKey,
readValue: AccountData.readRecentEmojiValue,
toJson: AccountData.recentEmojiToJson,
)
@Default(IList.empty())
IList<RecentEmoji> recentEmoji,
}) = _AccountData;
factory AccountData.fromJson(Map<String, Object?> json) => factory AccountData.fromJson(Map<String, Object?> json) =>
_$AccountDataFromJson(json); _$AccountDataFromJson(json);
} }
@freezed @Freezed(toJson: false, fromJson: false)
abstract class InvitePermissionConfig with _$InvitePermissionConfig { @JsonSerializable()
const factory InvitePermissionConfig({ class const RecentEmojiData({
@JsonKey(unknownEnumValue: DefaultInviteAction.allow) final IList<RecentEmoji> recentEmoji = const IList.empty(),
@Default(DefaultInviteAction.allow) }) with _$RecentEmojiData {
DefaultInviteAction defaultAction, Map<String, Object?> toJson() => _$RecentEmojiDataToJson(this);
}) = _InvitePermissionConfig;
factory RecentEmojiData.fromJson(Map<String, Object?> json) =>
_$RecentEmojiDataFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const InvitePermissionConfig({
@JsonKey(unknownEnumValue: DefaultInviteAction.allow)
final DefaultInviteAction defaultAction = DefaultInviteAction.allow,
}) with _$InvitePermissionConfig {
Map<String, Object?> toJson() => _$InvitePermissionConfigToJson(this);
factory InvitePermissionConfig.fromJson(Map<String, Object?> json) => factory InvitePermissionConfig.fromJson(Map<String, Object?> json) =>
_$InvitePermissionConfigFromJson(json); _$InvitePermissionConfigFromJson(json);
} }
@freezed @Freezed(toJson: false, fromJson: false)
abstract class RecentEmoji with _$RecentEmoji { @JsonSerializable()
const factory RecentEmoji({required String emoji, required int total}) = class const RecentEmoji({required final String emoji, required final int total})
_RecentEmoji; with _$RecentEmoji {
Map<String, Object?> toJson() => _$RecentEmojiToJson(this);
factory RecentEmoji.fromJson(Map<String, Object?> json) => factory RecentEmoji.fromJson(Map<String, Object?> json) =>
_$RecentEmojiFromJson(json); _$RecentEmojiFromJson(json);

View file

@ -0,0 +1,25 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "capabilities.freezed.dart";
part "capabilities.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const Capabilities({
@JsonKey(name: "org.matrix.msc4174.webpush") final WebPush? webpush,
}) with _$Capabilities {
Map<String, Object?> toJson() => _$CapabilitiesToJson(this);
factory Capabilities.fromJson(Map<String, Object?> json) =>
_$CapabilitiesFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const WebPush({required final bool enabled, final String? vapid})
with _$WebPush {
Map<String, Object?> toJson() => _$WebPushToJson(this);
factory WebPush.fromJson(Map<String, Object?> json) =>
_$WebPushFromJson(json);
}

View file

@ -1,16 +1,19 @@
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
part "client_state.freezed.dart"; part "client_state.freezed.dart";
part "client_state.g.dart"; part "client_state.g.dart";
@freezed @Freezed(toJson: false, fromJson: false)
abstract class ClientState with _$ClientState { @JsonSerializable()
const factory ClientState({ class const ClientState({
required bool isInitialized, required final bool isInitialized,
required bool isLoggedIn, required final bool isLoggedIn,
required bool isVerified, required final bool isVerified,
required String? userId, required final String? userId,
required String? homeserverUrl, required final String? deviceId,
}) = _ClientState; required final String? homeserverUrl,
}) with _$ClientState {
Map<String, Object?> toJson() => _$ClientStateToJson(this);
factory ClientState.fromJson(Map<String, Object?> json) => factory ClientState.fromJson(Map<String, Object?> json) =>
_$ClientStateFromJson(json); _$ClientStateFromJson(json);

View file

@ -1,14 +1,16 @@
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/membership_status.dart"; import "package:nexus/models/membership_status.dart";
part "members_by_status.freezed.dart"; part "members_by_status.freezed.dart";
part "members_by_status.g.dart"; part "members_by_status.g.dart";
@freezed @Freezed(toJson: false, fromJson: false)
abstract class MembersByStatusConfig with _$MembersByStatusConfig { @JsonSerializable()
const factory MembersByStatusConfig({ class const MembersByStatusConfig({
required String roomId, required final String roomId,
required MembershipStatus status, required final MembershipStatus status,
}) = _MembersByStatusConfig; }) with _$MembersByStatusConfig {
Map<String, Object?> toJson() => _$MembersByStatusConfigToJson(this);
factory MembersByStatusConfig.fromJson(Map<String, Object?> json) => factory MembersByStatusConfig.fromJson(Map<String, Object?> json) =>
_$MembersByStatusConfigFromJson(json); _$MembersByStatusConfigFromJson(json);

Some files were not shown because too many files have changed in this diff Show more