Watch
1
0
Fork
You've already forked nexus
0
forked from Nexus/nexus

Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
ad525b338c
fix state events not wrapping
Why did it do this ??????
2026-10-08 10:12:43 -04:00
0010160714
fix bigEmoji causing an overflow in replies 2026-10-08 10:08:33 -04:00
f741a6790d
update gomuks vendorHash 2026-10-06 19:27:37 -04:00
7be1dfe0bc
add inviting from room menu 2026-10-06 19:23:00 -04:00
08dcfdab75
fixup topic alignment for short topics 2026-10-06 10:26:59 -04:00
c81000d6c6
slightly improve bitfield handling for UnreadType 2026-10-05 20:16:01 -04:00
3c6c610f7c
improve UP timeout behavior
Hopefully more reliable.
2026-10-05 17:00:56 -04:00
61c5aa94c2
add timeout for UP reg 2026-10-05 15:07:22 -04:00
02ba9180db
fix error when opening settings during init sync 2026-10-05 14:23:06 -04:00
3c6f9f5da2
allow adding minimized button on all desktop platforms
Didn't mean to limit that to Linux
2026-10-05 12:35:18 -04:00
1d4af179d4
try opting out of predictive back
Needed until flutter bug is fixed
2026-10-05 12:23:08 -04:00
9974a6c225
add enableOnBackInvokedCallback true
Hopefully fixes drawers not popping?
2026-10-05 12:01:13 -04:00
9c83ae573a
add setting for showing minimize button 2026-10-05 11:26:26 -04:00
66cfb0e2c6
fix wrapping issues on event previews 2026-10-05 11:16:11 -04:00
02c486e1b3
make it more clear which methods free the buffer 2026-10-05 10:11:16 -04:00
b694da31f9
Revert "swap to SystemNavigator.pop from exit"
This reverts commit a86748e85f, as it was causing issues on Windows.
2026-10-05 10:10:47 -04:00
f931615890
fix scrollview not sticking to bottom if composer sends multiline messages 2026-10-04 17:15:23 -04:00
ae8fa17db3
mark homeserver url field as a .url keyboardType 2026-10-04 16:45:19 -04:00
a63f4674fd
make composer auto capitalize sentences 2026-10-04 16:06:54 -04:00
a46d93dfc5
filter shown recent emoji to valid emoji 2026-10-04 13:01:06 -04:00
294723fdfb
tweak reply sizing more 2026-10-04 11:22:14 -04:00
5e3c7ffa68
alter text alignment and icon sizes treewide 2026-10-04 11:16:39 -04:00
1fd9810b83
fall back to filename when body is empty 2026-10-04 10:25:29 -04:00
d119370816
tweak padding and sizing in reply renderer 2026-10-04 10:08:17 -04:00
ffaa516dcd
always make event previews two lines for non-state events 2026-10-04 00:32:20 -04:00
e480e6193b
refactor and improve room list 2026-10-03 21:58:01 -04:00
e41efd92c1
turn up verify page horizontal padding
looks a little nicer
2026-10-03 21:32:53 -04:00
b253fc1d96
change font size of tagline on select server page 2026-10-03 21:05:42 -04:00
967fe66a1b
remove unredacted from login page for now 2026-10-03 21:01:20 -04:00
658fa562cf
await route.completed before assuming settings are popped 2026-10-03 20:58:11 -04:00
fe33ae2e97
lower insetPadding for verify dialog 2026-10-03 20:39:00 -04:00
bcb085c035
scroll chat by composer height change delta
slightly evil but works
2026-10-03 20:35:44 -04:00
eae79d943d
make verify dialog scrollable 2026-10-03 20:15:17 -04:00
bdac4a11d7
default to empty via when none are provided
fixes joining with only a room alias
2026-10-03 14:44:22 -04:00
2ed2443298
make composer revert to unedited content when dismissing edit, plus more fixes 2026-10-03 14:40:19 -04:00
7f71adab4c
fix double watch in composer 2026-10-03 13:59:11 -04:00
0a3ec830a3
stop event preview resizing itself on load
Fixes #109
2026-10-03 13:50:46 -04:00
650f1ac8e4
allow hiding gomuks internal messages 2026-10-03 13:24:47 -04:00
af5f238db0
stop complaining at me formatter 2026-10-03 12:34:56 -04:00
006008f61d
rename AvatarOrHash.height to be more accurate 2026-10-03 12:25:22 -04:00
f23a29e7c1
tweak MessageDisplayname fallback state 2026-10-03 12:23:33 -04:00
493776e3dd
clear init complete state on logout 2026-10-03 11:56:17 -04:00
25afcf4acd
fix silent useless error during verify 2026-10-03 11:53:32 -04:00
e54b059bc9
fix not in range error when logging out 2026-10-03 11:44:35 -04:00
b8b074b229
bump gomuks 2026-10-03 11:26:18 -04:00
9ffad93469
cleanup logic in event renderer 2026-10-03 01:22:03 -04:00
3875c3a14a
Add a setting (off by default) for showing all rooms in home
Fixes #107

@LogN review
2026-10-03 01:15:11 -04:00
a86748e85f
swap to SystemNavigator.pop from exit 2026-10-03 01:01:59 -04:00
19363baa8d
fixup issues in join dialog
Fixes #112
2026-10-03 00:53:05 -04:00
41bdb04b72
make chat follow new messages when scrolled to bottom
Fixes #106
2026-10-02 19:57:48 -04:00
8da46dffe4
allow hiding client stickies
Fixes #105
2026-10-02 17:01:25 -04:00
49f2dc55b8
update platform support in readme 2026-10-02 12:13:26 -04:00
f9ff04405f
mark nix package as broken on darwin 2026-10-02 12:09:55 -04:00
ecb965994a
small tweaks to room list 2026-10-02 11:36:03 -04:00
36917bbea5
fix localContent being outdated for edits 2026-10-02 11:32:10 -04:00
684d6bad82
only pop from notifications list if Navigator reports canPop 2026-10-02 11:22:03 -04:00
45d69b0b9f
move NotificationsPage into sidebar via NavigationBar 2026-10-02 11:19:46 -04:00
3b2cf6b2a4
move padding for user sheet inside the scrollview 2026-10-01 18:29:03 -04:00
a61cac130b
make user bottom sheet scrollable 2026-10-01 18:22:02 -04:00
ec30adc0e3
allow sending media even when last event was an edit 2026-10-01 18:12:59 -04:00
a63d182c50
show git commit in about dialog 2026-10-01 16:15:36 -04:00
64a8edda7d
don't make composer buttons narrow
It doesn't actually change the hitbox? Only the shape of the InkWell.
2026-10-01 15:27:22 -04:00
2e85b26511
make PinnedEventsController expose IList of eventRowIds 2026-10-01 12:26:12 -04:00
a91b45be31
make send button narrow 2026-10-01 12:23:13 -04:00
d9062f68c4
bump nix2flatpak, use updated runtime 2026-10-01 12:07:24 -04:00
b3b873ffce
hardcode padding in order to make PopupMenuButton actually .narrow
This is needed because of a flutter bug, see https://github.com/flutter/flutter/issues/193656.
2026-10-01 11:33:10 -04:00
ab69bfe553
bump lock 2026-10-01 10:31:41 -04:00
98108d013e
use m3e iconbuttons 2026-09-30 11:47:40 -04:00
bdf8fc6ce1
make inline codeblocks actually inline 2026-09-30 11:16:03 -04:00
b738c1d0e0
Bump gomuks
Fixes #104
2026-09-30 09:51:52 -04:00
93b832839b
only add non-free text reactions to recent 2026-09-29 21:40:08 -04:00
301bcd94f2
fixup jumps from notifs sometimes not working 2026-09-29 20:47:20 -04:00
da7f2dcf4e
change how cross-room jumping works 2026-09-29 20:23:16 -04:00
b92aaed610
fixup notif jumping 2026-09-29 20:11:57 -04:00
1e4aab08ad
clean up and fix up jumping 2026-09-29 19:52:11 -04:00
1c4bf99be2 Add support for jumping to event links (#103)
Fixes #78.

Reviewed-on: Nexus/nexus#103
2026-09-29 17:00:43 -04:00
597112f5d3
add room summary image for TWIM
Yes, I'm preparing rather early this week :p
2026-09-29 16:05:28 -04:00
c8b9654219
Clickable room links
Fixes #80
2026-09-29 16:02:25 -04:00
9bc2d9955a
make relatedEvent store a rowId 2026-09-29 15:17:35 -04:00
ead463262c
make NotificationsController work off of rowIds 2026-09-29 15:10:54 -04:00
281bab0202
Add room preview dialog
Closes #79
2026-09-29 13:55:17 -04:00
9e024fb6f2
only mark as read when fully at bottom 2026-09-29 10:46:15 -04:00
a0036bdda3
watch clientSticky in RoomChatController 2026-09-29 10:24:45 -04:00
bd7cd0b10c
fixup edit handling 2026-09-29 09:45:51 -04:00
ec0934110f
swap msystem to avoid warning on windows build 2026-09-28 23:01:50 -04:00
1cfda9c68b
bundle more DLLs with windows build 2026-09-28 22:03:21 -04:00
1758815cc9
bundle all fonts
Should fix #57
2026-09-28 20:43:50 -04:00
9f4702b9d1
remove SelectableRegion as it is causing issues 2026-09-28 19:32:18 -04:00
195d212d68
improve speed of state merge 2026-09-28 18:29:46 -04:00
a698cf8c28
small cleanups 2026-09-28 18:20:36 -04:00
338d5dfee9
clarify flatpak updates 2026-09-28 17:56:13 -04:00
efa3681816
improve install docs, incl flatpak repo and nixos module 2026-09-28 17:53:38 -04:00
3824ca1c13
remove metadata
It is now statically hosted by caddy
2026-09-28 17:45:01 -04:00
01566894a6
add flatpak repo
Hopefully this works
2026-09-28 16:48:18 -04:00
64e548f0c5
fix hash for xdg_desktop_portal
Fixes nix build
2026-09-28 14:36:27 -04:00
4bde7875fb
up-to-date xdg portals
Fixes #101
2026-09-28 14:32:18 -04:00
07f8e58a4c
named records, small fixes to last commit 2026-09-28 13:17:15 -04:00
a220e27877
Make RoomChatController only return IList of eventRowId
Fixes #102
2026-09-28 12:46:00 -04:00
5304f22456
workaround for reactions not updating on historical timeline 2026-09-28 11:03:55 -04:00
e5b39507c8
Jump to current timelime when scrolled to bottom of historical timeline
Fixes #100.
2026-09-28 09:55:08 -04:00
d37538b69f
gate pop behind canPop 2026-09-28 09:49:20 -04:00
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
109 changed files with 4173 additions and 2553 deletions

View file

@ -1,39 +1,39 @@
name: "Build APK"
on:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
jobs:
build-apk:
runs-on: ubuntu-latest
build-apk:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
- name: Lix GHA Installer Action
uses: samueldr/lix-gha-installer-action@v2026-02-22
with:
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
- name: Decode keystore
run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks
env:
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- name: Lix GHA Installer Action
uses: samueldr/lix-gha-installer-action@v2026-02-22
with:
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
- name: Build app
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64"
env:
KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
- name: Decode keystore
run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks
env:
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: APK
path: build/app/outputs/flutter-apk/app-release.apk
- name: Build app
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64 --dart-define=GIT_COMMIT=${{ github.sha }}"
env:
KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: APK
path: build/app/outputs/flutter-apk/app-release.apk

View file

@ -1,37 +1,73 @@
name: "Build Flatpaks"
on:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
jobs:
build-flatpak:
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
- arch: aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
build-flatpak:
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
- arch: aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lix GHA Installer Action
uses: samueldr/lix-gha-installer-action@v2026-02-22
with:
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
- name: Lix GHA Installer Action
uses: samueldr/lix-gha-installer-action@v2026-02-22
with:
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
- name: Build app
run: nix build .#flatpak
- name: Build app
run: nix build .#flatpak
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: flatpak-${{ matrix.arch }}
path: result/nexus.federated.nexus.flatpak
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: flatpak-${{ matrix.arch }}
path: result/nexus.federated.nexus.flatpak
publish:
needs: build-flatpak
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Install flatpak tooling
run: sudo apt-get update && sudo apt-get install -y flatpak ostree
- name: Download bundles
uses: actions/download-artifact@v7
with:
pattern: flatpak-*
path: bundles
- name: Build OSTree repo
run: |
ostree init --repo=repo --mode=archive-z2
for bundle in bundles/*/nexus.federated.nexus.flatpak; do
flatpak build-import-bundle repo "$bundle"
done
flatpak build-update-repo --prune repo
- uses: actions/upload-pages-artifact@v4
with:
path: repo
- name: Deploy to Pages
id: deployment
uses: actions/deploy-pages@v4

View file

@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.0
flutter-version: 3.47.4
- name: Set up Go
uses: actions/setup-go@v6
@ -31,7 +31,7 @@ jobs:
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
flutter build ios --release --no-codesign
flutter build ios --no-codesign --dart-define=GIT_COMMIT=${{ github.sha }}
# Unsigned IPA: SideStore/AltStore re-sign this on-device with the
# user's own Apple ID at install time, so no certificate is needed here.

View file

@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.0
flutter-version: 3.47.4
- name: Set up Go
uses: actions/setup-go@v6
@ -31,7 +31,7 @@ jobs:
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
flutter build macos --release
flutter build macos --dart-define=GIT_COMMIT=${{ github.sha }}
- name: Create DMG
id: create-dmg

View file

@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.0
flutter-version: 3.47.4
- name: Set up Go
uses: actions/setup-go@v6
@ -29,7 +29,7 @@ jobs:
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
msystem: CLANG64
install: >-
mingw-w64-x86_64-gcc
@ -43,7 +43,7 @@ jobs:
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
flutter build windows --release
flutter build windows --dart-define=GIT_COMMIT=${{ github.sha }}
- name: Copy MinGW runtime DLLs
shell: msys2 {0}
@ -52,6 +52,15 @@ jobs:
cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
- name: Copy MSVC runtime DLLs
shell: powershell
run: |
$release = "build/windows/x64/runner/Release"
Copy-Item C:\Windows\System32\MSVCP140.dll $release -Force
Copy-Item C:\Windows\System32\VCRUNTIME140.dll $release -Force
Copy-Item C:\Windows\System32\VCRUNTIME140_1.dll $release -Force
- name: Install Inno Setup
run: choco install innosetup -y

4
.gitignore vendored
View file

@ -41,4 +41,6 @@ key.properties
password.txt
# Nix
/result
/result
# Widget Preview related
.widget_preview/

View file

@ -10,6 +10,7 @@
"msgtype",
"muks",
"prefs",
"recents",
"unban",
"unifiedpush",
"unredact",

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:
```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.

View file

@ -17,15 +17,20 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Platform Support
- [x] Linux
- [x] Notifications through UnifiedPush
- [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).
- [ ] Background notifications
- [x] Android
- [x] Notifications through UnifiedPush
- [x] Notifications through FCM
- [x] MacOS
- [x] Unsigned .ipa
- [ ] App Store
- [ ] Background notifications
- [x] iOS
- [x] Unsigned .ipa
- [ ] App Store
- [ ] Background notifications
- [ ] Web (may not be possible)
- [x] Login (via OAuth)
- [x] Rooms / Spaces
@ -68,9 +73,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] URL Previews
- [x] Replies
- [x] Viewing
- [ ] Jump to original message
- [x] In loaded timeline
- [ ] Out of loaded timeline
- [x] Jump to original message
- [x] Edits
- [x] Attachments
- [x] Unencrypted
@ -83,12 +86,10 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Users
- [x] Clickable
- [x] Rooms
- [ ] Clickable
- [x] Clickable
- [x] Matrix URIs
- [x] Matrix.to links
- [x] Events
- [ ] Render more nicely
- [ ] Clickable
- [x] Custom emojis/stickers
- [x] History loading
- [x] Editing
@ -124,10 +125,45 @@ If you want to try out Nexus, grab one of the following artifacts from CI:
- [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip)
- [Unsigned MacOS DMG](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/nexus.dmg.zip)
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
- Flatpak
- [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip)
- [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip)
- [NixOS Module](linux/nix/module.nix)
Or, use one of the below methods:
### Flatpak
Make sure Flatpak is set up on your system, then run:
```bash
flatpak install --user https://nexus.federated.nexus/flatpak/nexus.flatpakref
```
This adds the Nexus remote and installs the app for your user only, so no root access is needed (x86_64 and aarch64 are supported). To install system-wide instead, use `--system` in place of `--user`. Updates arrive through `flatpak update` or your software center.
### NixOS Module
Add Nexus as a flake input, following `nixpkgs` for less fetches:
```nix
nexus = {
url = "git+https://git.federated.nexus/Nexus/nexus";
inputs.nixpkgs.follows = "nixpkgs";
};
```
Then import the module and enable it in your NixOS configuration:
```nix
{ inputs, ... }:
{
imports = [ inputs.nexus.nixosModules.default ];
programs.nexus = {
enable = true;
enableNotifications = true; # optional: push notifications via UnifiedPush
};
}
```
This installs Nexus system-wide. With `enableNotifications`, it also installs KUnifiedPush and registers its systemd and D-Bus services so notifications work. The `package` option lets you override the package if you need to.
## Build it yourself
@ -135,7 +171,7 @@ If you want to try out Nexus, grab one of the following artifacts from CI:
#### Linux
- On NixOS: Either use direnv and `direnv allow`, or `nix flake develop`
- On NixOS: Either use direnv and `direnv allow`, or `nix develop`
- On other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues.
#### Windows

View file

@ -6,7 +6,9 @@
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<!-- Until https://github.com/flutter/flutter/issues/145290 is fixed, we need enableOnBackInvokedCallback as false -->
<application
android:enableOnBackInvokedCallback="false"
android:label="Nexus"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"

Binary file not shown.

BIN
assets/fonts/Inter.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/fonts/Twemoji.ttf Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

BIN
assets/twim/summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

24
flake.lock generated
View file

@ -5,11 +5,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1785627969,
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
"lastModified": 1788450739,
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
"type": "github"
},
"original": {
@ -42,11 +42,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1790043059,
"narHash": "sha256-7ID4HL4IrrlalCsxMASkcAr+v0VHTNFp6+sZ+dpvOV8=",
"lastModified": 1790870263,
"narHash": "sha256-ZBblCzxsUBBCZJRJR0knnaOt2+Xl5JGfI+YkGPf2SI4=",
"owner": "Henry-Hiles",
"repo": "nix2flatpak",
"rev": "290dd282e555cab988c021c2be5f2361e58c4d51",
"rev": "2ef0e3cde2bdce0870f79a7568a72c2681ad20b5",
"type": "github"
},
"original": {
@ -74,11 +74,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1785031560,
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
"lastModified": 1788057806,
"narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
"rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c",
"type": "github"
},
"original": {
@ -89,11 +89,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1786862985,
"narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=",
"lastModified": 1790689126,
"narHash": "sha256-ilerN1WLSvF+HMjziC/Wv99J5y02maDH+6hZPwsORKg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44",
"rev": "b4fd65b198c599cbe814fcb9f42d25d021595ec9",
"type": "github"
},
"original": {

2
gomuks

@ -1 +1 @@
Subproject commit b11eedca121a7081d3ee48165ba363e722d18d87
Subproject commit 38e5033c3a91db0cbea6cbd9f0feef9ff6441038

View file

@ -6,27 +6,22 @@ import "dart:math";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart";
import "package:flutter/foundation.dart";
import "package:flutter/cupertino.dart";
import "package:intl/intl.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/controllers/sync_status.dart";
import "package:nexus/controllers/top_level_spaces.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.dart";
import "package:nexus/models/capabilities.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/event_context.dart";
import "package:nexus/models/gomuks_config.dart";
import "package:nexus/models/oauth_auth_code_response.dart";
import "package:nexus/models/open_graph_data.dart";
import "package:nexus/models/paginate.dart";
import "package:nexus/models/paginate_manual.dart";
import "package:nexus/models/requests/deregister_pusher.dart";
import "package:nexus/models/requests/download_media.dart";
import "package:nexus/models/requests/get_event.dart";
import "package:nexus/models/requests/get_event_context.dart";
import "package:nexus/models/requests/get_mentions.dart";
import "package:nexus/models/requests/get_related_events.dart";
import "package:nexus/models/requests/get_room_state.dart";
@ -36,6 +31,7 @@ import "package:nexus/models/requests/oauth/exchange_token.dart";
import "package:nexus/models/requests/oauth/get_auth_url.dart";
import "package:nexus/models/requests/oauth/register_client.dart";
import "package:nexus/models/requests/paginate.dart";
import "package:nexus/models/requests/paginate_manual.dart";
import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/requests/register_pusher.dart";
import "package:nexus/models/requests/report.dart";
@ -46,9 +42,9 @@ import "package:nexus/models/requests/set_membership.dart";
import "package:nexus/models/requests/set_state.dart";
import "package:nexus/models/requests/upload_media.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/room_metadata.dart";
import "package:nexus/models/room_summary.dart";
import "package:nexus/models/spec_versions_response.dart";
import "package:nexus/models/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:path_provider/path_provider.dart";
@ -56,6 +52,7 @@ import "package:path_provider/path_provider.dart";
class ClientController extends AsyncNotifier<int> {
@override
Future<int> build() async {
debugPrint("Setting Gomuks env...");
if (Platform.isAndroid || Platform.isIOS) {
final env = {
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
@ -64,12 +61,17 @@ class ClientController extends AsyncNotifier<int> {
for (final MapEntry(:key, :value) in env.entries) {
final keyPtr = key.toNativeUtf8().cast<Char>();
final valuePtr = value.toNativeUtf8().cast<Char>();
GomuksSetEnv(keyPtr, valuePtr);
calloc
..free(keyPtr)
..free(valuePtr);
try {
GomuksSetEnv(keyPtr, valuePtr);
} finally {
calloc
..free(keyPtr)
..free(valuePtr);
}
}
}
debugPrint("Initializing Gomuks...");
final handle = await Isolate.run(() {
final bufferPointer = GomuksConfig(
matrix: .new(
@ -81,103 +83,15 @@ class ClientController extends AsyncNotifier<int> {
try {
return GomuksInit(bufferPointer.ref);
} finally {
calloc.free(bufferPointer);
calloc
..free(bufferPointer.ref.base)
..free(bufferPointer);
}
});
final callable =
NativeCallable<
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
>.listener((
Pointer<Char> command,
int requestId,
GomuksOwnedBuffer data,
) {
try {
final muksEventType = command.cast<Utf8>().toDartString();
debugPrint("Handling $muksEventType...");
final decodedMuksEvent = data.toJson();
switch (muksEventType) {
case "client_state":
ref
.watch(ClientStateController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
break;
case "sync_status":
ref
.watch(SyncStatusController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
break;
case "init_complete":
ref.watch(InitCompleteController.provider.notifier).complete();
break;
case "send_complete":
final event = Event.fromJson(decodedMuksEvent["event"]);
ref
.watch(RoomsController.provider.notifier)
.update(
.new({
event.roomId: .new(events: .new({event.rowId: event})),
}),
.new(),
);
break;
case "sync_complete":
final syncData = SyncData.fromJson(decodedMuksEvent);
final roomProvider = RoomsController.provider;
final accountDataProvider = AccountDataController.provider;
if (syncData.clearState) {
ref.invalidate(roomProvider);
ref.invalidate(accountDataProvider);
}
ref
.watch(roomProvider.notifier)
.update(syncData.rooms, syncData.leftRooms);
ref
.watch(accountDataProvider.notifier)
.update(syncData.accountData);
if (syncData.topLevelSpaces != null) {
ref
.watch(TopLevelSpacesController.provider.notifier)
.set(syncData.topLevelSpaces!);
}
if (syncData.spaceEdges != null) {
ref
.watch(SpaceEdgesController.provider.notifier)
.set(syncData.spaceEdges!);
}
// ref
// .watch(SyncStatusController.provider.notifier)
// .set(SyncStatus.fromJson(decodedMuksEvent));
break;
default:
debugPrint("Unhandled event: $muksEventType");
}
debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) {
if (kDebugMode) {
debugPrintStack(stackTrace: stackTrace, label: error.toString());
rethrow;
} else {
showError(error, stackTrace);
}
}
});
ref.onDispose(() => GomuksDestroy(handle));
ref.onDispose(callable.close);
final errorCode = GomuksStart(handle, callable.nativeFunction);
if (errorCode == 0) return handle;
throw Exception("GomuksStart returned error code $errorCode");
return handle;
}
Future<dynamic> callGomuksMethod(
@ -186,31 +100,43 @@ class ClientController extends AsyncNotifier<int> {
callback,
) async {
final bufferPointer = data.toGomuksBufferPtr();
final handle = await future;
final response = await Isolate.run(
() => callback(handle, bufferPointer.ref),
);
calloc.free(bufferPointer);
try {
final handle = await future;
final json = response.buf.toJson();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw json;
final response = await Isolate.run(
() => callback(handle, bufferPointer.ref),
);
final json = response.buf.toJsonAndFree();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw json;
}
return json;
} finally {
calloc
..free(bufferPointer.ref.base)
..free(bufferPointer);
}
return json;
}
Future<Event?> handlePush(Map<String, dynamic> data) async {
Future<(Event, RoomMetadata)?> handlePush(Map<String, dynamic> data) async {
final response = await callGomuksMethod(
data,
(handle, data) async => GomuksHandlePush(handle, data),
);
return response == null ? null : .fromJson(response);
return response == null
? null
: (
Event.fromJson(response["event"]),
RoomMetadata.fromJson(response["room"]),
);
}
dynamic _sendCommand(
Future<dynamic> _sendCommand(
String command, [
Map<String, dynamic> data = const {},
]) => callGomuksMethod(data, (handle, data) {
@ -232,7 +158,7 @@ class ClientController extends AsyncNotifier<int> {
final json = request.toJson();
final content = request.content.toJson();
return Event.fromJson(
return .fromJson(
await _sendCommand("send_event", {
...json,
"content": {
@ -270,11 +196,6 @@ class ClientController extends AsyncNotifier<int> {
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<List?> getState(GetRoomStateRequest request) async =>
(await _sendCommand("get_room_state", request.toJson())) as List?;
@ -312,11 +233,17 @@ class ClientController extends AsyncNotifier<int> {
Future<Paginate> paginate(PaginateRequest request) async =>
.fromJson(await _sendCommand("paginate", request.toJson()));
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
.fromJson(await _sendCommand("get_event_context", request.toJson()));
Future<ProfileResponse> getProfile(String userId) async {
try {
return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));
} catch (_) {
return ProfileResponse(profile: .new(id: userId));
return .new(profile: .new(id: userId));
}
}
@ -347,9 +274,11 @@ class ClientController extends AsyncNotifier<int> {
Future<void> logout() => _sendCommand("logout");
Future<void> markRead(Room room) async {
if (room.timeline.isEmpty || room.metadata == null) return;
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
final event = eventRowId == null ? null : room.events[eventRowId];
if (event == null || room.metadata == null) return;
if (event == null) return;
await _sendCommand("mark_read", {
"room_id": room.metadata!.id,
@ -375,9 +304,8 @@ class ClientController extends AsyncNotifier<int> {
Future<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions"));
Future<Capabilities> getCapabilities() async => Capabilities.fromJson(
(await _sendCommand("get_capabilities"))["capabilities"],
);
Future<Capabilities> getCapabilities() async =>
.fromJson((await _sendCommand("get_capabilities"))["capabilities"]);
Future<Uri?> discoverHomeserver(Uri homeserver) async {
try {

View file

@ -16,11 +16,28 @@ class EventController(final GetEventRequest request)
(event) => event.eventId == request.eventId,
);
return event ??
await ref
.watch(ClientController.provider.notifier)
.getEvent(request)
.onError((_, _) => null);
if (event != null) return event;
final fetchedEvent = await ref
.watch(ClientController.provider.notifier)
.getEvent(request)
.onError((_, _) => null);
if (fetchedEvent != null) {
ref
.watch(RoomsController.provider.notifier)
.update(
.new({
request.roomId: .new(
events: .new({fetchedEvent.rowId: fetchedEvent}),
),
}),
);
return fetchedEvent;
}
return null;
}
static final provider = AsyncNotifierProvider.family

View file

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

View file

@ -0,0 +1,17 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
typedef JumpRequest = ({String roomId, int rowId});
class JumpRequestController extends Notifier<JumpRequest?> {
@override
JumpRequest? build() => null;
void request(String roomId, int rowId) =>
state = (roomId: roomId, rowId: rowId);
void consume() => state = null;
static final provider = NotifierProvider<JumpRequestController, JumpRequest?>(
JumpRequestController.new,
);
}

View file

@ -27,7 +27,7 @@ class MembersController(final String roomId)
),
);
await ref
ref
.read(RoomsController.provider.notifier)
.addState(roomId, fetchedState, isMembers: true);
}

View file

@ -7,7 +7,7 @@ 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/pages/notifications.dart";
import "package:nexus/widgets/notifications_list.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
class NotificationController
@ -31,12 +31,14 @@ class NotificationController
macOS: darwin,
),
onDidReceiveNotificationResponse: (details) {
if (details.payload case final eventId?) {
if (details.payload == null) return;
if (int.tryParse(details.payload!) case final rowId?) {
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsPage(
highlightedEventId: eventId,
builder: (_) => NotificationsList(
highlightedEvent: rowId,
defaultToAllNotifications: true,
),
),
@ -55,17 +57,17 @@ class NotificationController
return;
}
final eventId = call.arguments as String;
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsPage(
highlightedEventId: eventId,
defaultToAllNotifications: true,
if (int.tryParse(call.arguments) case int rowId) {
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsList(
highlightedEvent: rowId,
defaultToAllNotifications: true,
),
),
),
);
);
}
}
});
@ -124,9 +126,18 @@ class NotificationController
id.toString(),
title: title,
body: body,
icon: icon == null ? null : XdgNotificationIconFile(icon.path),
defaultAction: "app.event",
defaultActionTarget: payload,
category: XdgNotificationCategory.imReceived,
priority: .normal,
buttons: [
// TODO: Impl when notif daemons actualy support imReplyWithText
// .new(
// label: "Reply",
// action: "app.reply",
// purpose: XdgNotificationButtonPurpose.imReplyWithText,
// ),
],
);
} else {
final notificationDetails = NotificationDetails(

View file

@ -1,57 +1,93 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/event.dart";
typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController([final NotificationsRequest? request])
extends AsyncNotifier<IList<Event>> {
static const limit = 20;
class NotificationsController(final NotificationsRequest request)
extends AsyncNotifier<IList<(int, String)>> {
void storeEvents(Iterable<Event> events) => ref
.read(RoomsController.provider.notifier)
.update(
.new(
events
.groupListsBy((event) => event.roomId)
.map(
(roomId, roomEvents) => .new(
roomId,
.new(
events: IMap.fromIterable(
roomEvents,
keyMapper: (event) => event.rowId,
),
),
),
),
),
);
@override
Future<IList<Event>> build() async {
final client = ref.watch(ClientController.provider.notifier);
Future<IList<(int, String)>> build() async {
final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request ?? (null, null);
final (unreadType, roomId) = request;
return await client.getMentions(
final mentions = await client.getMentions(
.new(
maxTimestamp: .now(),
unreadType: unreadType ?? .highlight,
limit: limit,
roomId: roomId,
),
);
storeEvents(mentions);
return .new(mentions.map((event) => (event.rowId, event.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 lastNotification = currentNotifications.lastOrNull;
final lastTs = lastNotification == null
? null
: ref.watch(
RoomsController.provider.select(
(rooms) => rooms[lastNotification.$2]
?.events[lastNotification.$1]
?.timestamp,
),
);
final client = ref.watch(ClientController.provider.notifier);
final (unreadType, roomId) = request ?? (null, null);
if (lastTs == null) return currentNotifications;
final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request;
final newNotifications = await client.getMentions(
.new(
maxTimestamp: lastTs,
unreadType: unreadType ?? .highlight,
limit: limit,
roomId: roomId,
),
);
return currentNotifications.addAll(newNotifications);
storeEvents(newNotifications);
return currentNotifications.addAll(
newNotifications.map((event) => (event.rowId, event.roomId)),
);
});
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
NotificationsController,
IList<Event>,
NotificationsRequest?
IList<(int, String)>,
NotificationsRequest
>(NotificationsController.new);
}

View file

@ -2,26 +2,25 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/pinned_ids.dart";
import "package:nexus/models/event.dart";
class PinnedEventsController(final String roomId)
extends AsyncNotifier<IList<Event>> {
extends AsyncNotifier<IList<int>> {
@override
Future<IList<Event>> build() async {
Future<IList<int>> build() async {
final pinIds = ref.watch(PinnedIdsController.provider(roomId));
return (await Future.wait(
pinIds.map(
(eventId) => ref.watch(
EventController.provider(.new(eventId: eventId, roomId: roomId))
.future,
.selectAsync((data) => data?.rowId),
),
),
)).nonNulls.toIList();
}
static final provider = AsyncNotifierProvider.family
.autoDispose<PinnedEventsController, IList<Event>, String>(
.autoDispose<PinnedEventsController, IList<int>, String>(
PinnedEventsController.new,
);
}

View file

@ -6,7 +6,7 @@ class ProfileController(final String userId)
extends AsyncNotifier<ProfileResponse> {
@override
Future<ProfileResponse> build() {
final client = ref.watch(ClientController.provider.notifier);
final client = ref.read(ClientController.provider.notifier);
return client.getProfile(userId);
}

View file

@ -1,17 +1,30 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/account_data.dart";
class RecentEmojiController extends Notifier<IList<RecentEmoji>> {
@override
IList<RecentEmoji> build() => ref.watch(
AccountDataController.provider.select(
(value) => value.recentEmoji.recentEmoji,
),
);
IList<RecentEmoji> build() {
final Set<String> emoji = ref
.watch(EmojiController.provider)
.requireValue
.map((element) => element.emojis)
.flattened
.map((e) => e.value)
.toSet();
final IList<RecentEmoji> recents = ref.watch(
AccountDataController.provider.select(
(value) => value.recentEmoji.recentEmoji,
),
);
return .new(recents.where((recent) => emoji.contains(recent.emoji)));
}
Future<void> add(String emoji) => ref
.watch(ClientController.provider.notifier)

View file

@ -10,57 +10,81 @@ import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/room_chat.dart";
class RoomChatController(final String roomId)
extends AsyncNotifier<IList<Event>?> {
typedef ChatParams = ({String roomId, int? contextualEvent});
class RoomChatController(final ChatParams info)
extends AsyncNotifier<RoomChat?> {
@override
Future<IList<Event>?> build() async {
final client = ref.watch(ClientController.provider.notifier);
final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]),
Future<RoomChat?> build() async {
final client = ref.read(ClientController.provider.notifier);
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[info.roomId]),
);
if (room == null) return null;
if (!room.hasFetchedState) {
final state = await client.getRoomState(.new(roomId: roomId));
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
final state = await client.getRoomState(.new(roomId: info.roomId));
ref.read(RoomsController.provider.notifier).addState(info.roomId, state);
}
// While there are under 20 events, try to load more
// until there's no more or the conditions are met.
if (room.hasMore && room.timeline.length < 20) {
loadOlder();
}
return room.timeline
final timeline = room.timeline
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
.map((element) => element.value)
.map((e) => e.value)
.toIList()
.addAll(room.clientSticky)
.map((entry) {
final foundEvent = entry == null ? null : room.events[entry];
final editedEvent =
foundEvent == null || foundEvent.lastEditRowId == 0
? null
: room.events[foundEvent.lastEditRowId];
return editedEvent == null
? foundEvent
: foundEvent?.copyWith(
content: editedEvent.content,
localContent: editedEvent.localContent,
);
})
.nonNulls
.toIList();
if (info.contextualEvent == null ||
timeline.contains(info.contextualEvent)) {
ref.watch(
RoomsController.provider.select(
(rooms) =>
(rooms[info.roomId]?.timeline, rooms[info.roomId]?.clientSticky),
),
);
return .new(
timeline: timeline,
hasMoreBackward: room.hasMore,
hasMoreForward: false,
);
} else {
final context = await client.getEventContext(
.new(
roomId: info.roomId,
eventId: room.events[info.contextualEvent!]!.eventId,
),
);
final events = context.before.add(context.event).addAll(context.after);
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: Room(
events: IMap.fromIterable(
events,
keyMapper: (event) => event.rowId,
),
),
}),
);
return .new(
timeline: .new(events.map((element) => element.rowId)),
hasMoreBackward: true,
hasMoreForward: true,
historicalData: .new(start: context.start, end: context.end),
);
}
}
Future<void> deleteMessage(Event event, {String? reason}) => ref
@ -68,36 +92,68 @@ class RoomChatController(final String roomId)
.redactEvent(
RedactEventRequest(
eventId: event.eventId,
roomId: roomId,
roomId: info.roomId,
reason: reason,
),
);
Future<bool> loadOlder() async {
state = .loading();
final timelineKeys = ref
.read(RoomsController.provider.select((value) => value[roomId]))
?.timeline
.keys;
final response = await ref
.read(ClientController.provider.notifier)
.paginate(
.new(
roomId: roomId,
maxTimelineId: timelineKeys?.isNotEmpty == true
? timelineKeys?.reduce(min)
: null,
void hideClientSticky(int eventRowId) => ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: Room(
resetClientSticky: true,
clientSticky:
ref
.read(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.clientSticky,
),
)
?.remove(eventRowId) ??
.new(),
),
);
}),
);
Future<void> paginate(Direction direction) async {
if (state.isLoading) return;
final chat = await future;
if (direction == .forward
? chat?.hasMoreForward == false
: chat?.hasMoreBackward == false) {
return;
}
state = .loading();
final client = ref.read(ClientController.provider.notifier);
if (chat?.historicalData == null) {
final timelineKeys = ref
.read(RoomsController.provider.select((value) => value[info.roomId]))
?.timeline
.keys;
final response = await client.paginate(
.new(
roomId: info.roomId,
maxTimelineId: timelineKeys?.isNotEmpty == true
? timelineKeys?.reduce(min)
: null,
),
);
if (response.events.isEmpty) {
state = .data(state.value);
}
if (response.events.isEmpty) {
state = .data(state.value);
} else {
ref
.read(RoomsController.provider.notifier)
.update(
IMap({
roomId: Room(
info.roomId: Room(
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
@ -111,11 +167,61 @@ class RoomChatController(final String roomId)
),
),
}),
.new(),
);
}
} else {
final paginationResponse = await client.paginateManual(
.new(
roomId: info.roomId,
direction: direction,
since: direction == .forward
? chat!.historicalData!.end
: chat!.historicalData!.start,
),
);
return response.hasMore;
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: Room(
events: IMap.fromIterable(
paginationResponse.events,
keyMapper: (event) => event.rowId,
),
),
}),
);
final eventRowIds = paginationResponse.events
.map((element) => element.rowId)
.toIList();
state = .data(
.new(
timeline: direction == .forward
? chat.timeline.addAll(eventRowIds)
: eventRowIds.addAll(chat.timeline),
hasMoreForward:
direction == .forward && paginationResponse.nextBatch == null
? false
: chat.hasMoreForward,
hasMoreBackward:
direction == .backward && paginationResponse.nextBatch == null
? false
: chat.hasMoreBackward,
historicalData: chat.historicalData?.copyWith(
start:
(direction == .backward
? paginationResponse.nextBatch
: null) ??
chat.historicalData!.start,
end:
(direction == .forward ? paginationResponse.nextBatch : null) ??
chat.historicalData!.end,
),
),
);
}
}
Future<void> send(
@ -123,14 +229,22 @@ class RoomChatController(final String roomId)
bool shouldMention = true,
required IList<Tag> tags,
required RelationType relationType,
Event? relation,
int? relatedEventRowId,
}) async {
Content? baseContent;
if (relationType == .edit) {
baseContent = relation?.content;
final relatedEvent = relatedEventRowId == null
? null
: ref.watch(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.events[relatedEventRowId],
),
);
if (relationType == .edit && relatedEventRowId != null) {
baseContent = relatedEvent?.content;
} else {
final provider = AttachmentController.provider(roomId);
baseContent = ref.watch(provider)?.$2;
final provider = AttachmentController.provider(info.roomId);
baseContent = ref.read(provider)?.$2;
ref.invalidate(provider);
}
@ -146,37 +260,36 @@ class RoomChatController(final String roomId)
);
}
final client = ref.watch(ClientController.provider.notifier);
final client = ref.read(ClientController.provider.notifier);
final event = await client.sendMessage(
SendMessageRequest(
roomId: roomId,
roomId: info.roomId,
baseContent: baseContent,
mentions: Mentions(
userIds: [
if (shouldMention == true &&
relation != null &&
relatedEvent != null &&
relationType == RelationType.reply)
relation.sender,
relatedEvent.sender,
].toIList(),
room: taggedMessage.contains("@room"),
),
text: taggedMessage,
relation: relation == null
relation: relatedEvent == null
? null
: .new(eventId: relation.eventId, relationType: relationType),
: .new(eventId: relatedEvent.eventId, relationType: relationType),
),
);
ref
.watch(RoomsController.provider.notifier)
.read(RoomsController.provider.notifier)
.update(
.new({
roomId: .new(
info.roomId: .new(
events: .new({event.rowId: event}),
clientSticky: .new({event.rowId}),
),
}),
.new(),
);
}
@ -185,10 +298,10 @@ class RoomChatController(final String roomId)
Event event,
String userId,
) async {
final client = ref.watch(ClientController.provider.notifier);
final client = ref.read(ClientController.provider.notifier);
final allReactionEvents = await client.getRelatedEvents(
.new(
roomId: roomId,
roomId: info.roomId,
eventId: event.eventId,
relationType: "m.annotation",
),
@ -209,16 +322,18 @@ class RoomChatController(final String roomId)
if (reactionEvent != null) {
await ref
.watch(ClientController.provider.notifier)
.redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId));
.redactEvent(
.new(eventId: reactionEvent.eventId, roomId: info.roomId),
);
}
}
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(
.new(
roomId: roomId,
roomId: info.roomId,
type: EventType.reaction.type,
content: ReactionContent(key: reaction),
synchronous: true,
@ -230,7 +345,7 @@ class RoomChatController(final String roomId)
}
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, IList<Event>?, String>(
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
RoomChatController.new,
);
}

View file

@ -7,7 +7,7 @@ class RoomSummaryController(final JoinRoomRequest request)
extends AsyncNotifier<RoomSummary> {
@override
Future<RoomSummary> build() =>
ref.watch(ClientController.provider.notifier).getRoomSummary(request);
ref.read(ClientController.provider.notifier).getRoomSummary(request);
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomSummaryController, RoomSummary, JoinRoomRequest>(

View file

@ -1,5 +1,4 @@
import "dart:isolate";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart";
@ -9,35 +8,35 @@ class RoomsController extends Notifier<IMap<String, Room>> {
@override
IMap<String, Room> build() => .new();
Future<void> addState(
void addState(
String roomId,
IList<Event> state, {
bool isMembers = false,
}) async => update(
}) => update(
.new({
roomId: Room(
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
hasFetchedState: true,
hasFetchedMembers: isMembers,
state: await Isolate.run(() {
final newState = state.fold<IMap<String, IMap<String, int>>>(
.new(),
(previousValue, stateEvent) => previousValue.add(
stateEvent.type,
(previousValue[stateEvent.type] ?? .new()).add(
stateEvent.stateKey!,
stateEvent.rowId,
state: .new(
state
.groupListsBy((event) => event.type)
.map(
(type, group) => .new(
type,
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
),
),
),
);
return newState;
}),
),
),
}),
.new(),
);
void update(IMap<String, Room> rooms, ISet<String> leftRooms) {
void update(
IMap<String, Room> rooms, [
ISet<String> leftRooms = const ISet.empty(),
]) {
final merged = rooms.entries.fold(state, (acc, entry) {
final roomId = entry.key;
final incoming = entry.value;
@ -47,13 +46,16 @@ class RoomsController extends Notifier<IMap<String, Room>> {
roomId,
existing?.copyWith(
hasMore: incoming.hasMore,
clientSticky:
(incoming.clientSticky.isEmpty == true
? existing.clientSticky
: existing.clientSticky.addAll(incoming.clientSticky))
.removeWhere(
(rowId) => incoming.timeline.values.contains(rowId),
),
clientSticky: incoming.resetClientSticky
? incoming.clientSticky
: (incoming.clientSticky.isEmpty == true
? existing.clientSticky
: existing.clientSticky.addAll(
incoming.clientSticky,
))
.removeWhere(
(rowId) => incoming.timeline.values.contains(rowId),
),
metadata: incoming.metadata ?? existing.metadata,
events: incoming.events.isEmpty
? existing.events
@ -65,6 +67,7 @@ class RoomsController extends Notifier<IMap<String, Room>> {
(previousValue[event.key] ?? .new()).addAll(event.value),
),
),
resetClientSticky: false,
reset: false,
hasFetchedMembers:
incoming.hasFetchedMembers || existing.hasFetchedMembers,

View file

@ -9,6 +9,7 @@ import "package:m3e_buttons/m3e_buttons.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart";
@ -76,6 +77,38 @@ class SettingsSectionsController
title: "Behavior",
icon: Icons.psychology,
settings: .new([
.new(
title: "All Rooms In Home",
description: "Show all rooms in home, instead of just ones that aren't in spaces",
icon: Icons.home,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
subtitle: Text(description),
secondary: Icon(icon),
value: settings.showAllInHome,
onChanged: (value) => ref
.watch(SettingsController.provider.notifier)
.set(settings.copyWith(showAllInHome: value))
.onError(showError),
),
),
if (!Platform.isAndroid && !Platform.isIOS)
.new(
title: "Show Minimize Button",
description:
"Enable this to add a minimize button to the appbar",
icon: Icons.minimize,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
subtitle: Text(description),
secondary: Icon(icon),
value: settings.showMinimizeButton,
onChanged: (value) => ref
.watch(SettingsController.provider.notifier)
.set(settings.copyWith(showMinimizeButton: value))
.onError(showError),
),
),
.new(
title: "Linux Mobile Mode",
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
@ -252,12 +285,19 @@ class SettingsSectionsController
final colorScheme = Theme.of(context).colorScheme;
return M3EButton.icon(
onPressed: () async {
await ref
.watch(UnifiedPushController.provider.notifier)
final container = ProviderScope.containerOf(context);
final route = ModalRoute.of(context)!;
Navigator.of(context).popUntil((r) => r.isFirst);
await route.completed;
await container
.read(UnifiedPushController.provider.notifier)
.deregister()
.onError(showError);
await ref
.watch(ClientController.provider.notifier)
container.invalidate(InitCompleteController.provider);
await container
.read(ClientController.provider.notifier)
.logout()
.onError(showError);
},

View file

@ -4,15 +4,16 @@ import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/top_level_spaces.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/space.dart";
import "package:nexus/models/subspace.dart";
class SpacesController extends Notifier<IList<Space>> {
class SpacesController extends AsyncNotifier<IList<Space>> {
@override
IList<Space> build() {
Future<IList<Space>> build() async {
final rooms = ref.watch(RoomsController.provider);
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
final spaceEdges = ref.watch(SpaceEdgesController.provider);
@ -77,10 +78,10 @@ class SpacesController extends Notifier<IList<Space>> {
final spaces = topLevelSpaceIds.map(buildSpace).toIList();
final usedRoomIds = {
final usedRooms = {
for (final space in spaces) ...[
...space.children.map((r) => r.metadata?.id),
...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)),
...space.children,
...space.subSpaces.expand((s) => s.children),
],
}.nonNulls.toISet();
@ -89,7 +90,9 @@ class SpacesController extends Notifier<IList<Space>> {
final otherRooms = rooms.entries
.where(
(e) =>
!usedRoomIds.contains(e.key) &&
!usedRooms
.map((element) => element.metadata?.id)
.contains(e.key) &&
!topLevelSpaceIds.contains(e.key) &&
!childrenById.containsKey(e.key),
)
@ -97,6 +100,15 @@ class SpacesController extends Notifier<IList<Space>> {
.toIList();
final homeRooms = otherRooms
.addAll(
await ref.watch(
SettingsController.provider.selectAsync(
(data) => data.showAllInHome,
),
)
? usedRooms
: .empty(),
)
.where((r) => !directMessages.contains(r.metadata?.id))
.toIList();
@ -143,7 +155,7 @@ class SpacesController extends Notifier<IList<Space>> {
.toIList();
}
static final provider = NotifierProvider<SpacesController, IList<Space>>(
static final provider = AsyncNotifierProvider<SpacesController, IList<Space>>(
SpacesController.new,
);
}

View file

@ -5,7 +5,7 @@ import "package:nexus/models/spec_versions_response.dart";
class SpecVersionsController extends AsyncNotifier<SpecVersionsResponse> {
@override
Future<SpecVersionsResponse> build() =>
ref.watch(ClientController.provider.notifier).getSpecVersions();
ref.read(ClientController.provider.notifier).getSpecVersions();
static final provider =
AsyncNotifierProvider.autoDispose<

View file

@ -8,7 +8,6 @@ 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/controllers/rooms.dart";
import "package:nexus/main.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
@ -19,6 +18,8 @@ import "package:unifiedpush_storage_shared_preferences/storage.dart";
import "package:window_manager/window_manager.dart";
class UnifiedPushController extends AsyncNotifier<bool> {
Completer<void>? _endpoint;
@override
Future<bool> build() async {
if (!Platform.isLinux && !Platform.isAndroid) return false;
@ -31,30 +32,45 @@ class UnifiedPushController extends AsyncNotifier<bool> {
shouldWriteService: false,
),
onNewEndpoint: (endpoint, instance) async {
final pushKey = endpoint.pubKeySet!.pubKey;
await ref
.read(PushKeyController.provider(instance).notifier)
.set(pushKey);
final task = Future(() async {
final pushKey = endpoint.pubKeySet!.pubKey;
await ref
.read(PushKeyController.provider(instance).notifier)
.set(pushKey);
await ref
.read(ClientController.provider.notifier)
.registerPusher(
.new(
appDisplayName: "Nexus",
appId: "nexus.federated.nexus",
data: .webPush(
url: .parse(endpoint.url),
auth: endpoint.pubKeySet!.auth,
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,
),
deviceDisplayName:
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
kind: .webPush,
lang: "en",
pushKey: pushKey,
),
);
);
state = .data(true);
state = .data(true);
});
if (_endpoint case final completer? when !completer.isCompleted) {
completer.complete(task);
} else {
await task;
}
},
onRegistrationFailed: (reason, instance) {
if (_endpoint case final completer? when !completer.isCompleted) {
completer.completeError(reason);
} else {
throw reason;
}
},
onMessage: (message, instance) async {
debugPrint("UP message received for $instance");
@ -63,12 +79,15 @@ class UnifiedPushController extends AsyncNotifier<bool> {
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
);
}
final event = await ref
final pushResponse = await ref
.read(ClientController.provider.notifier)
.handlePush(json.decode(String.fromCharCodes(message.content)));
if (event == null ||
event.unreadType?.shouldNotify() != true ||
if (pushResponse == null) return;
final (event, roomMetadata) = pushResponse;
if (event.unreadType?.has(.notify) != true ||
(!isInBackground &&
await windowManager.isFocused().onError((_, _) => true) &&
await ref.read(
@ -79,43 +98,21 @@ class UnifiedPushController extends AsyncNotifier<bool> {
return;
}
final provider = RoomsController.provider.select(
(rooms) => rooms[event.roomId],
);
if (ref.read(provider)?.metadata == null) {
final completer = Completer<void>();
final subscription = ref.listen(provider, (previous, next) {
if (next?.metadata != null && !completer.isCompleted) {
completer.complete();
}
}, fireImmediately: true);
try {
await completer.future.timeout(.new(seconds: 10));
} on TimeoutException {
// metadata didn't show up in time
} finally {
subscription.close();
}
}
final room = ref.read(provider);
final avatar = room?.metadata?.avatar;
final icon = avatar == null
final icon = roomMetadata.avatar == null
? null
: await ref
.read(ClientController.provider.notifier)
.downloadMedia(.new(mxc: avatar, isAvatar: true));
.downloadMedia(
.new(mxc: roomMetadata.avatar!, isAvatar: true),
);
await ref
.read(NotificationController.provider.notifier)
.send(
id: event.eventId.hashCode & 0x7fffffff,
title: room?.metadata?.name ?? "New Event",
title: roomMetadata.name ?? "New Event",
icon: icon,
payload: event.eventId,
payload: event.rowId.toString(),
body: switch (event.content) {
MessageContent(:final body?) ||
StickerContent(:final body) => body,
@ -128,45 +125,68 @@ class UnifiedPushController extends AsyncNotifier<bool> {
onUnregistered: deregister,
);
ref.listen(
ClientStateController.provider.select((value) => value?.deviceId),
(_, _) => register(),
);
if (!registered) return false;
if (registered) {
// Needs to be registered every startup
await register();
try {
return await register();
} catch (error, stackTrace) {
showError(error, stackTrace);
return false;
}
return registered;
}
Future<void> register() async {
Future<bool> register() async {
state = .loading();
try {
final deviceId = ref.read(
final deviceIdCompleter = Completer<String>();
final subscription = ref.listen(
ClientStateController.provider.select((value) => value?.deviceId),
(_, next) {
if (next != null && !deviceIdCompleter.isCompleted) {
deviceIdCompleter.complete(next);
}
},
fireImmediately: true,
);
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,
final deviceId = await deviceIdCompleter.future.whenComplete(
subscription.close,
);
await Future(() async {
final capabilities = await ref
.read(ClientController.provider.notifier)
.getCapabilities();
if (capabilities.webpush?.vapid == null) {
throw UnsupportedError(
"Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.",
);
}
if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
throw Exception("No UnifiedPush distributors found.");
}
final endpoint = _endpoint = Completer<void>();
try {
await Future.wait([
UnifiedPush.register(
instance: deviceId,
vapid: capabilities.webpush?.vapid,
),
endpoint.future,
], eagerError: true);
} finally {
if (_endpoint == endpoint) _endpoint = null;
}
}).timeout(
.new(seconds: 10),
onTimeout: () => throw Exception("UnifiedPush registration timed out."),
);
state = .data(true);
return true;
} catch (_) {
state = .data(false);
rethrow;

View file

@ -7,7 +7,11 @@ import "package:unifiedpush/unifiedpush.dart";
class UnifiedPushAllowedController extends AsyncNotifier<String?> {
@override
Future<String?> build() async {
if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor().timeout(
.new(seconds: 10),
onTimeout: () =>
throw Exception("UnifiedPush distributor check timed out."),
)) {
return "No valid distributors found. ${Platform.isLinux
? "Try installing KUnifiedPush"
: Platform.isAndroid

View file

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

View file

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

View file

@ -0,0 +1,46 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/experimental/mutation.dart";
import "package:nexus/controllers/jump_request.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/spaces.dart";
import "package:nexus/models/space.dart";
extension FocusRoom on MutationTarget {
Future<bool> focusRoom(String roomId, [int? eventRowId]) async {
final spaces = await container.read(SpacesController.provider.future);
if (spaces.firstWhereOrNull((space) => space.id == roomId) case Space _?) {
await container
.read(KeyController.provider(KeyController.spaceKey).notifier)
.set(roomId);
return true;
}
final parent = spaces.firstWhereOrNull(
(space) =>
space.children.any((room) => room.metadata?.id == roomId) ||
space.subSpaces.any(
(sub) =>
sub.room.metadata?.id == roomId ||
sub.children.any((room) => room.metadata?.id == roomId),
),
);
if (parent == null) return false;
await container
.read(KeyController.provider(KeyController.spaceKey).notifier)
.set(parent.id);
if (eventRowId != null) {
container
.read(JumpRequestController.provider.notifier)
.request(roomId, eventRowId);
}
await container
.read(KeyController.provider(KeyController.roomKey).notifier)
.set(roomId);
return true;
}
}

View file

@ -0,0 +1,8 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/space.dart";
extension GetAllChildRooms on Space {
IList<Room> get allChildRooms =>
children.addAll(subSpaces.expand((s) => s.children));
}

View file

@ -0,0 +1,11 @@
import "package:nexus/models/content/message.dart";
extension GetFilename on MessageContent {
String? get filename => switch (this) {
ImageMessageContent(:final filename) ||
VideoMessageContent(:final filename) ||
AudioMessageContent(:final filename) ||
FileMessageContent(:final filename) => filename,
_ => null,
};
}

View file

@ -0,0 +1,70 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
/// A parsed Matrix link.
///
/// [identifier] is the room ID, room alias or user ID (e.g. "#room:matrix.org"),
/// [eventId] is set for event permalinks (e.g. "$abc123"), and [via] holds any
/// `via` servers from the link.
typedef LinkInfo = ({String identifier, String? eventId, IList<String> via});
extension GetLinkInfo on String {
/// Parses this string as a Matrix link.
///
/// Supports:
/// - https://matrix.to/#/{id}[/{eventId}][?via=...]
/// - matrix:roomid/{id}[/e/{eventId}][?via=...]
/// - matrix:r/{alias}[/e/{eventId}][?via=...]
/// - matrix:u/{userId}
///
/// Returns null if this is not a Matrix link.
LinkInfo? get linkInfo {
final uri = Uri.tryParse(trim());
if (uri == null) return null;
if ((uri.scheme == "http" || uri.scheme == "https") &&
uri.host == "matrix.to") {
// matrix.to keeps everything, including the query, in the fragment,
// so parse the fragment as its own URI.
final inner = Uri.tryParse(uri.fragment);
final segments = inner?.pathSegments.where((s) => s.isNotEmpty).toList();
if (inner == null || segments == null || segments.isEmpty) return null;
return (
identifier: segments.first,
eventId: segments.length > 1 && segments[1].startsWith(r"$")
? segments[1]
: null,
via: .new(inner.queryParametersAll["via"] ?? []),
);
}
if (uri.scheme == "matrix") {
final segments = uri.pathSegments;
if (segments.length < 2 || segments[1].isEmpty) return null;
final sigil = switch (segments[0].toLowerCase()) {
"r" => "#",
"roomid" => "!",
"u" => "@",
_ => null,
};
if (sigil == null) return null;
// Event segment is "e/{eventId}" (without the "$" sigil) and is
// only valid on room links.
final hasEvent =
sigil != "@" &&
segments.length >= 4 &&
segments[2].toLowerCase() == "e" &&
segments[3].isNotEmpty;
return (
identifier: "$sigil${segments[1]}",
eventId: hasEvent ? "\$${segments[3]}" : null,
via: .new(uri.queryParametersAll["via"] ?? []),
);
}
return null;
}
}

View file

@ -1,20 +1,21 @@
import "dart:convert";
import "dart:ffi";
import "dart:typed_data";
import "package:ffi/ffi.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
Uint8List toBytes() {
Uint8List toBytesAndFree() {
try {
if (base == nullptr || length <= 0) return .new(0);
return .fromList(base.asTypedList(length));
} finally {
calloc.free(base);
GomuksFreeBuffer(this);
}
}
dynamic toJson() => jsonDecode(utf8.decode(toBytes()));
dynamic toJsonAndFree() => jsonDecode(utf8.decode(toBytesAndFree()));
}
extension JsonToGomuksBuffer on Map<String, dynamic> {

View file

@ -1,46 +0,0 @@
extension LinkToMention on String {
/// Extracts a Matrix identifier from this string.
///
/// Supports:
/// - https://matrix.to/#/...
/// - matrix:roomid/...
/// - matrix:r/...
/// - matrix:u/...
///
/// Returns the decoded identifier (e.g. "#room:matrix.org")
/// or null if this is not a Matrix link.
/// TODO: Needs to be reworked to handle event links. Might be worth just rewriting, I don't like this code.
String? get mention {
final trimmed = trim();
final matrixTo = RegExp(
r"^https?://matrix\.to/#/(.[^/?#]+)",
caseSensitive: false,
);
final matrixToMatch = matrixTo.firstMatch(trimmed);
if (matrixToMatch != null) {
return Uri.decodeComponent(matrixToMatch.group(1)!);
}
if (trimmed.toLowerCase().startsWith("matrix:")) {
try {
final uri = Uri.parse(trimmed);
if (uri.pathSegments.isNotEmpty) {
final identifier = uri.pathSegments.last;
if (identifier.isNotEmpty) {
return "${switch (uri.pathSegments.firstOrNull) {
"r" => "#",
"roomid" => "!",
"u" => "@",
_ => "",
}}${Uri.decodeComponent(identifier)}";
}
}
} catch (_) {}
}
return null;
}
}

View file

@ -3,7 +3,8 @@ import "package:material_ui/material_ui.dart";
extension SchemeToTheme on ColorScheme {
ThemeData get theme {
final textTheme = ThemeData(
fontFamilyFallback: ["sans", "emoji", "fallback-sans", "fallback-emoji"],
fontFamily: "bundled-sans",
fontFamilyFallback: ["bundled-emoji"],
brightness: brightness,
).textTheme;
return .from(colorScheme: this).copyWith(
@ -27,6 +28,7 @@ extension SchemeToTheme on ColorScheme {
borderRadius: .circular(8),
),
),
iconButtonTheme: .new(variant: StyleVariant.material3Expressive),
textTheme: textTheme,
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),

View file

@ -33,7 +33,9 @@ extension ShowAboutDialog on BuildContext {
"Nexus",
style: Theme.of(context).textTheme.headlineMedium,
),
Text("(${packageInfo.version})"),
Text(
"(${packageInfo.version}+${const String.fromEnvironment("GIT_COMMIT", defaultValue: "dev")})",
),
],
),

View file

@ -1,11 +1,6 @@
import "package:flutter/foundation.dart";
const fontLicenses = [
LicenseEntryWithLineBreaks(
["Noto Color Emoji"],
"""Copyright 2021 Google Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
const sil = """This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
@ -95,102 +90,426 @@ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.""",
OTHER DEALINGS IN THE FONT SOFTWARE.""";
const List<LicenseEntryWithLineBreaks> fontLicenses = [
.new(
["Twitter Color Emoji"],
"""Twitter Color Emoji is built from Twemoji (https://github.com/jdecked/twemoji)
using the Noto Emoji as fallback.
Its parts are under different licenses:
1) Emoji artwork: Copyright Twitter, Inc and other contributors.
Attribution 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More_considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution 4.0 International Public License ("Public License"). To the
extent this Public License may be interpreted as a contract, You are
granted the Licensed Rights in consideration of Your acceptance of
these terms and conditions, and the Licensor grants You such rights in
consideration of benefits the Licensor receives from making the
Licensed Material available under these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
d. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
g. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
i. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
j. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
k. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part; and
b. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's
License You apply must not prevent recipients of the Adapted
Material from complying with this Public License.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public licenses.
Notwithstanding, Creative Commons may elect to apply one of its public
licenses to material it publishes and in those instances will be
considered the "Licensor." Except for the limited purpose of indicating
that material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the public
licenses.
Creative Commons may be contacted at creativecommons.org.
2) Font files and font tooling (Noto Emoji): SIL Open Font License, Version 1.1.
Copyright 2021 Google Inc. All Rights Reserved
$sil
""",
),
LicenseEntryWithLineBreaks(
["Roboto"],
"""Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
.new(
["Inter"],
"""Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
Modified for use in this application by removing selected Unicode character mappings.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
$sil""",
),
.new(
["Atkinson Hyperlegible Mono"],
"""Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono)
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.""",
$sil""",
),
];

View file

@ -0,0 +1,207 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/room_chat.dart";
typedef JumpToEvent = Future<void> Function(int rowId);
final class ChatScroll({
required final IList<int> historyRows,
required final IList<int> liveRows,
required final GlobalKey centerKey,
required final ScrollController scrollController,
required final bool atBottom,
required final JumpToEvent jumpToEvent,
required final Future<void> Function() jumpToBottom,
required final GlobalKey Function(int eventRowId) keyFor,
}) {
factory use({
required AsyncValue<RoomChat?> controllerData,
required Future<void> Function(Direction direction) paginate,
required Future<void> Function() markRead,
required ValueNotifier<int?> contextualEvent,
}) {
final anchorId = useState<int?>(null);
final atBottom = useState(true);
final pendingJump = useState<int?>(null);
final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new);
final itemKeys = useMemoized(() => <int, GlobalKey>{}, []);
GlobalKey keyFor(int eventRowId) =>
itemKeys.putIfAbsent(eventRowId, GlobalKey.new);
useEffect(() {
if (anchorId.value == null) {
if (controllerData case AsyncData(:final value?)
when value.timeline.isNotEmpty) {
anchorId.value = value.timeline.contains(contextualEvent.value)
? contextualEvent.value
: value.timeline.last;
}
}
return null;
}, [controllerData, contextualEvent.value]);
useEffect(() {
final rowId = pendingJump.value;
if (rowId == null || anchorId.value != rowId) return null;
pendingJump.value = null;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!scrollController.hasClients) return;
final position = scrollController.position;
scrollController.jumpTo(
(-position.viewportDimension)
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble(),
);
await WidgetsBinding.instance.endOfFrame;
final context = keyFor(rowId).currentContext;
if (context?.mounted == true) {
await Scrollable.ensureVisible(
context!,
alignment: 0.5,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
}
});
return null;
}, [anchorId.value, pendingJump.value]);
final ({IList<int> history, IList<int> live}) split = useMemoized(() {
final items = controllerData.value?.timeline;
final anchor = anchorId.value;
if (items == null || anchor == null) {
return (history: const .empty(), live: const .empty());
}
final anchorIndex = items.indexOf(anchor);
if (anchorIndex == -1) return (history: const .empty(), live: items);
return (
history: items.take(anchorIndex).toIList().reversed.toIList(),
live: items.skip(anchorIndex).toIList(),
);
}, [controllerData, anchorId.value]);
final lastRowId = split.live.lastOrNull;
final previousLastRowId = useRef<int?>(null);
useEffect(() {
final previous = previousLastRowId.value;
previousLastRowId.value = lastRowId;
if (previous == null || lastRowId == null || previous == lastRowId) {
return null;
}
if (!atBottom.value || contextualEvent.value != null) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!scrollController.hasClients) return;
scrollController.jumpTo(scrollController.position.minScrollExtent);
});
return null;
}, [lastRowId]);
useEffect(
() {
const loadThreshold = 500.0;
Future<void> checkPosition() async {
if (!scrollController.hasClients) return;
final position = scrollController.position;
final isAtBottom = position.extentBefore <= 0;
if (isAtBottom != atBottom.value) atBottom.value = isAtBottom;
if (position.extentAfter <= loadThreshold) {
await paginate(.backward);
} else if (contextualEvent.value != null &&
position.extentBefore <= loadThreshold) {
await paginate(.forward);
} else if (position.extentBefore <= 0) {
await markRead();
}
if (controllerData.isLoading == false &&
position.extentBefore <= 0 &&
controllerData.value?.hasMoreForward == false &&
contextualEvent.value != null) {
anchorId.value = null;
contextualEvent.value = null;
}
}
scrollController.addListener(checkPosition);
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
return () => scrollController.removeListener(checkPosition);
},
[
scrollController,
controllerData,
paginate,
markRead,
contextualEvent.value,
],
);
return .new(
historyRows: split.history,
liveRows: split.live,
centerKey: centerKey,
scrollController: scrollController,
atBottom: atBottom.value,
keyFor: keyFor,
jumpToEvent: (int rowId) async {
if (!scrollController.hasClients) return;
final context = keyFor(rowId).currentContext;
if (context != null) {
await Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
return;
}
pendingJump.value = rowId;
if (controllerData.value?.timeline.contains(rowId) ?? false) {
anchorId.value = rowId;
} else {
anchorId.value = null;
contextualEvent.value = rowId;
}
},
jumpToBottom: () async {
if (contextualEvent.value != null) {
anchorId.value = null;
contextualEvent.value = null;
}
if (!scrollController.hasClients) return;
await scrollController.animateTo(
scrollController.position.minScrollExtent,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
},
);
}
}

View file

@ -3,9 +3,10 @@ import "dart:io";
import "package:dynamic_color/dynamic_color.dart";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:media_kit/media_kit.dart";
import "package:nexus/controllers/client.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";
@ -14,10 +15,10 @@ import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart";
import "package:nexus/helpers/extensions/scheme_to_theme.dart";
import "package:nexus/helpers/font_licenses.dart";
import "package:nexus/pages/chat.dart";
import "package:nexus/pages/select_server.dart";
import "package:nexus/pages/settings.dart";
import "package:nexus/pages/verify.dart";
import "package:nexus/widgets/pages/chat.dart";
import "package:nexus/widgets/pages/select_server.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/loading.dart";
@ -42,15 +43,7 @@ New Value: ${newValue is AsyncData ? newValue.value : newValue}
}
void showError(Object error, [StackTrace? stackTrace]) {
if (error.toString().contains("DioException") ||
error.toString().contains(
"setState() or markNeedsBuild() called during build.",
) ||
error.toString().contains("Invalid source") ||
error.toString().contains("UTF-16") ||
error.toString().contains("HTTP request failed") ||
error.toString().contains("'_nextFrame != null': is not true.") ||
error.toString().contains("Invalid image data")) {
if (error.toString().contains("'_nextFrame != null': is not true.")) {
return;
}
@ -71,33 +64,34 @@ void main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
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));
}
isInBackground =
Platform.environment["FLUTTER_HEADLESS"] != null ||
args.contains("--unifiedpush-bg");
LicenseRegistry.addLicense(() => .fromIterable(fontLicenses));
FlutterError.onError = (FlutterErrorDetails details) =>
showError(details.exception.toString(), details.stack);
if (isInBackground) {
await ProviderContainer().read(UnifiedPushController.provider.future);
isInBackground =
Platform.environment["FLUTTER_HEADLESS"] != null ||
args.contains("--unifiedpush-bg");
// In case it didn't exit for some reason
await Future.delayed(Duration(seconds: 20));
if (isInBackground) {
await ProviderContainer()
.read(UnifiedPushController.provider.future)
.timeout(Duration(seconds: 5));
await Future.delayed(Duration(seconds: 10));
exit(0);
} else {
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(
WindowOptions(
titleBarStyle: TitleBarStyle.hidden,
windowButtonVisibility: false,
),
);
await windowManager.setMinimumSize(Size.square(500));
}
runApp(
ProviderScope(
retry: (_, _) => null,
@ -147,57 +141,61 @@ class const App({super.key}) extends StatelessWidget {
),
home: child,
),
child: Scaffold(
body: Consumer(
builder: (_, ref, _) => switch (ref.watch(
MultiProviderController.provider(
.new([
ClientController.provider,
NotificationController.provider,
UnifiedPushController.provider,
MemberListOpenedController.provider,
KeyController.provider(KeyController.roomKey),
KeyController.provider(KeyController.spaceKey),
]),
),
)) {
AsyncData(value: _) || AsyncLoading(value: _?) => Consumer(
builder: (_, ref, _) {
final clientState = ref.watch(ClientStateController.provider);
if (clientState == null || !clientState.isInitialized) {
return Loading();
}
if (!clientState.isLoggedIn) {
return SelectServerPage();
} else if (!clientState.isVerified) {
return VerifyPage();
} else {
return ChatPage();
}
},
),
AsyncLoading _ => Scaffold(
appBar: Appbar(
actions: .new([
IconButton(
onPressed: () => showDialog(
context: context,
builder: (_) => SettingsPage(),
),
icon: Icon(Icons.settings),
),
child: DefaultTextHeightBehavior(
textHeightBehavior: .new(leadingDistribution: .even),
child: Scaffold(
body: Consumer(
builder: (context, ref, _) => switch (ref.watch(
MultiProviderController.provider(
.new([
GomuksListenerController.provider,
NotificationController.provider,
UnifiedPushController.provider,
MemberListOpenedController.provider,
KeyController.provider(KeyController.roomKey),
KeyController.provider(KeyController.spaceKey),
EmojiController.provider,
]),
),
body: Loading(),
),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
)) {
AsyncData(value: _) || AsyncLoading(value: _?) => Consumer(
builder: (_, ref, _) {
final clientState = ref.watch(ClientStateController.provider);
if (clientState == null || !clientState.isInitialized) {
return Loading();
}
if (!clientState.isLoggedIn) {
return SelectServerPage();
} else if (!clientState.isVerified) {
return VerifyPage();
} else {
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

@ -0,0 +1,8 @@
import "package:freezed_annotation/freezed_annotation.dart";
enum Direction {
@JsonValue("f")
forward,
@JsonValue("b")
backward,
}

View file

@ -102,9 +102,6 @@ class const UnreadType(final int value) with _$UnreadType {
static const highlight = UnreadType(4);
static const sound = UnreadType(8);
bool isNone() => value == 0;
bool isNormal() => (value & 1) != 0;
bool shouldNotify() => (value & 2) != 0;
bool isHighlighted() => (value & 4) != 0;
bool playsSound() => (value & 8) != 0;
bool has(UnreadType flag) => value & flag.value != 0;
UnreadType operator |(UnreadType other) => UnreadType(value | other.value);
}

View file

@ -0,0 +1,22 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart";
part "event_context.freezed.dart";
part "event_context.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const EventContext({
required final Event event,
required final IList<Event> before,
required final IList<Event> after,
required final String start,
required final String end,
final IList<Event> relatedEvents = const IList.empty(),
}) with _$EventContext {
Map<String, Object?> toJson() => _$EventContextToJson(this);
factory EventContext.fromJson(Map<String, Object?> json) =>
_$EventContextFromJson(json);
}

View file

@ -7,8 +7,9 @@ part "lazy_load_summary.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const LazyLoadSummary({
required final IList<String>? heroes,
required final int? joinedMemberCount,
@JsonKey(name: "m.heroes") required final IList<String>? heroes,
@JsonKey(name: "m.joined_member_count") required final int? joinedMemberCount,
@JsonKey(name: "m.invited_member_count")
required final int? invitedMemberCount,
}) with _$LazyLoadSummary {
Map<String, Object?> toJson() => _$LazyLoadSummaryToJson(this);

8
lib/models/nav_page.dart Normal file
View file

@ -0,0 +1,8 @@
import "package:flutter/widgets.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
abstract class const NavPage({super.key}) extends Widget {
String get title;
IconData get icon;
Future<int>? badgeBuilder(WidgetRef ref) => null;
}

View file

@ -0,0 +1,19 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart";
part "paginate_manual.freezed.dart";
part "paginate_manual.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PaginateManual({
required final IList<Event> events,
final IList<Event> relatedEvents = const IList.empty(),
required final String? nextBatch,
}) with _$PaginateManual {
Map<String, Object?> toJson() => _$PaginateManualToJson(this);
factory PaginateManual.fromJson(Map<String, Object?> json) =>
_$PaginateManualFromJson(json);
}

View file

@ -57,7 +57,7 @@ class const Profile({
try {
return Profile.fromJson(json);
} catch (error) {
return _Profile(parseError: error.toString());
return Profile(parseError: error.toString());
}
}
}

View file

@ -0,0 +1,17 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "get_event_context.freezed.dart";
part "get_event_context.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const GetEventContextRequest({
required final String roomId,
required final String eventId,
final int limit = 20,
}) with _$GetEventContextRequest {
Map<String, Object?> toJson() => _$GetEventContextRequestToJson(this);
factory GetEventContextRequest.fromJson(Map<String, Object?> json) =>
_$GetEventContextRequestFromJson(json);
}

View file

@ -9,7 +9,7 @@ part "get_mentions.g.dart";
class GetMentionsRequest({
@EpochDateTimeConverter() required final DateTime maxTimestamp,
@JsonKey(name: "type") required final UnreadType unreadType,
required final int limit,
final int limit = 20,
final String? roomId,
}) with _$GetMentionsRequest {
Map<String, Object?> toJson() => _$GetMentionsRequestToJson(this);

View file

@ -0,0 +1,22 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/direction.dart";
part "paginate_manual.freezed.dart";
part "paginate_manual.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PaginateManualRequest({
required final String roomId,
// Root event ID of a thread to paginate
final String? threadRoot,
// Can be null for starting pagination of a thread
final String? since,
required final Direction direction,
final int limit = 20,
}) with _$PaginateManualRequest {
Map<String, Object?> toJson() => _$PaginateManualRequestToJson(this);
factory PaginateManualRequest.fromJson(Map<String, Object?> json) =>
_$PaginateManualRequestFromJson(json);
}

View file

@ -33,7 +33,8 @@ class const Mentions({
_$MentionsFromJson(json);
}
@Freezed(toJson: false)
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const Relation({
required final String eventId,
required final RelationType relationType,

View file

@ -10,7 +10,7 @@ class const SetMembershipRequest({
required final String userId,
required final String roomId,
final String? reason,
@JsonKey(name: "action") required final MembershipAction action,
required final MembershipAction action,
@JsonKey(name: "msc4293_redact_events") final bool redact = false,
}) with _$SetMembershipRequest {
Map<String, Object?> toJson() => _$SetMembershipRequestToJson(this);

View file

@ -12,17 +12,28 @@ part "room.g.dart";
class const Room({
@JsonKey(name: "meta") final RoomMetadata? metadata,
/// [timeline] is an IMap of timelineRowId to eventRowId
@JsonKey(fromJson: Room.timelineTupleJsonToIMap)
final IMap<int, int?> timeline = const IMap.empty(),
/// [clientSticky] is an ISet of eventRowId
@JsonKey(includeFromJson: false, includeToJson: false)
final ISet<int> clientSticky = const ISet.empty(),
/// [events] is an IMap of eventRowId to event
@JsonKey(fromJson: Room.eventsJsonToIMap)
final IMap<int, Event> events = const IMap.empty(),
final bool reset = false,
@JsonKey(includeFromJson: false, includeToJson: false)
final bool resetClientSticky = false,
@JsonKey(includeFromJson: false, includeToJson: false)
final bool hasFetchedState = false,
@JsonKey(includeFromJson: false, includeToJson: false)
final bool hasFetchedMembers = false,
final IMap<String, IMap<String, int>> state = const IMap.empty(),
final IMap<String, IList<ReadReceipt>> receipts = const IMap.empty(),
@ -32,9 +43,6 @@ class const Room({
// IMap<String, AccountData> accountData,
// IList<Notification> notifications,
}) with _$Room {
/// [timeline] is an IMap of timelineRowId to eventRowId
/// [events] is an IMap of eventRowId to event
/// [clientSticky] is an ISet of eventRowId
static IMap<int, int?> timelineTupleJsonToIMap(List<dynamic> json) =>
IMap.fromEntries(
json.map(

32
lib/models/room_chat.dart Normal file
View file

@ -0,0 +1,32 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "room_chat.freezed.dart";
part "room_chat.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const RoomChat({
/// List of eventRowId
required final IList<int> timeline,
required final bool hasMoreForward,
required final bool hasMoreBackward,
final HistoricalData? historicalData,
}) with _$RoomChat {
Map<String, Object?> toJson() => _$RoomChatToJson(this);
factory RoomChat.fromJson(Map<String, Object?> json) =>
_$RoomChatFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const HistoricalData({
required final String start,
required final String end,
}) with _$HistoricalData {
Map<String, Object?> toJson() => _$HistoricalDataToJson(this);
factory HistoricalData.fromJson(Map<String, Object?> json) =>
_$HistoricalDataFromJson(json);
}

View file

@ -9,9 +9,6 @@ part "room_metadata.g.dart";
@JsonSerializable()
class const RoomMetadata({
@JsonKey(name: "room_id") required final String id,
// CreateEventContent creationContent,
// TombstoneEventContent tombstoneEventContent,
final String? name,
final Uri? avatar,
final String? dmUserId,

View file

@ -9,7 +9,7 @@ part "room_summary.g.dart";
@JsonSerializable()
class const RoomSummary({
required final String roomId,
@JsonKey(name: "num_joined_members") required final int joinedMembers,
@JsonKey(name: "num_joined_members") final int? joinedMembers,
final JoinRule? joinRule,
final String? name,
final Uri? avatarUrl,

View file

@ -10,6 +10,8 @@ class const Settings({
final ThemeMode theme = ThemeMode.system,
final bool useDynamicTheming = true,
final bool linuxMobileMode = false,
final bool showAllInHome = false,
final bool showMinimizeButton = false,
}) with _$Settings {
Map<String, Object?> toJson() => _$SettingsToJson(this);

View file

@ -1,164 +0,0 @@
import "dart:async";
import "package:m3e_buttons/m3e_buttons.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/notifications.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const NotificationsPage({
final String? highlightedEventId,
final bool defaultToAllNotifications = false,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final options = <M3EToggleButtonGroupAction, UnreadType>{
M3EToggleButtonGroupAction(
checkedLabel: Text("Mentions"),
icon: Icon(Icons.alternate_email),
): .highlight,
M3EToggleButtonGroupAction(
checkedLabel: Text("All Notifications"),
icon: Icon(Icons.notifications),
): .notify,
};
final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0);
final highlightedId = useState(highlightedEventId);
final listController = useRef(ListController());
final scrollController = useScrollController();
final provider = NotificationsController.provider((
options.values.toList()[unreadTypeIndex.value],
null,
));
final notifications = ref.watch(provider);
final notifier = ref.watch(provider.notifier);
useEffect(() {
if (highlightedId.value == null) return null;
Timer? timer;
void listener() =>
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!listController.value.isAttached) return;
final notifications = await ref.watch(provider.future);
final index = notifications.indexWhere(
(element) => element.eventId == highlightedId.value!,
);
if (index == -1) return;
listController.value.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut,
);
timer = Timer(.new(seconds: 1), () {
highlightedId.value = null;
});
listController.value.removeListener(listener);
});
listController.value.addListener(listener);
return timer?.cancel;
}, []);
useEffect(() {
Future<void> listener() async {
if (!scrollController.hasClients || notifications.isLoading) return;
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent) {
await notifier.loadOlder();
}
}
scrollController.addListener(listener);
return () => scrollController.removeListener(listener);
}, [scrollController, notifications]);
return Scaffold(
appBar: Appbar(title: Text("Notifications")),
body: Stack(
children: [
Column(
children: [
if (notifications is AsyncLoading && notifications.value != null)
const LinearProgressIndicator(minHeight: 2),
Expanded(
child: switch (notifications) {
AsyncData(:final value) || AsyncLoading(:final value?) =>
value.isEmpty
? Center(
child: Text(
"No notifications yet",
style: Theme.of(context).textTheme.headlineMedium,
),
)
: SuperListView.builder(
listController: listController.value,
controller: scrollController,
itemCount: value.length,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 12,
),
reverse: true,
itemBuilder: (context, index) {
final event = value[index];
final isHighlighted =
event.eventId == highlightedId.value;
return Padding(
padding: .only(top: 8),
child: HighlightWrapper(
InkWell(
onTap: () {
// TODO: Jump to event
},
child: EventRenderer(event),
),
isHighlighted: isHighlighted,
),
);
},
),
AsyncLoading() => const Loading(),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
),
],
),
Align(
alignment: .topRight,
child: Padding(
padding: .all(16),
child: M3EToggleButtonGroup(
selectedIndex: unreadTypeIndex.value,
onSelectedIndexChanged: (index) =>
unreadTypeIndex.value = index ?? unreadTypeIndex.value,
actions: options.keys.toList(),
),
),
),
],
),
);
}
}

View file

@ -51,6 +51,17 @@ final class const Appbar({
actions: [
...actions,
if (!(Platform.isAndroid || Platform.isIOS)) ...[
if (ref
.watch(SettingsController.provider)
.whenOrNull(
data: (settings) => settings.showMinimizeButton,
) ??
false)
IconButton(
tooltip: "Minimize window",
onPressed: windowManager.minimize,
icon: const Icon(Icons.minimize),
),
if (!Platform.isLinux)
IconButton(
tooltip: "Maximize window",

View file

@ -7,25 +7,24 @@ final class const AvatarOrHash(
final Uri? avatar,
final String title, {
final Widget? fallback,
final double height = 24,
final double dimension = 24,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final box = ColoredBox(
color: ColorHash(title).color,
child: Center(child: Icon(Icons.person, size: height / 2)),
child: Center(child: Icon(Icons.person, size: dimension / 2)),
);
return SizedBox(
width: height,
height: height,
width: dimension,
height: dimension,
child: Center(
child: ClipRRect(
borderRadius: .all(.circular((height - 8) / 2.5)),
child: SizedBox(
width: height,
height: height,
borderRadius: .all(.circular((dimension - 8) / 2.5)),
child: SizedBox.square(
dimension: dimension,
child: avatar == null
? fallback ?? box
: Image(

View file

@ -2,6 +2,7 @@ import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:file_selector/file_selector.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/widgets/emoji_picker.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter/services.dart";
@ -12,7 +13,6 @@ import "package:nexus/controllers/attachment.dart";
import "package:nexus/controllers/image_picker.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
import "package:nexus/widgets/composer/relation_preview.dart";
@ -20,7 +20,7 @@ import "package:nexus/main.dart";
class const Composer(
final String roomId, {
required final Event? relatedEvent,
required final int? relatedEvent,
required final RelationType relationType,
required final VoidCallback onDismiss,
required final Future<void> Function(
@ -35,19 +35,57 @@ class const Composer(
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final controller = useRef(FlutterTaggerController());
final controller = useMemoized(FlutterTaggerController.new);
final triggerCharacter = useState("");
final shouldMention = useState(true);
final query = useState("");
final relatedEvent = this.relatedEvent == null
? null
: ref.watch(
RoomsController.provider.select((rooms) {
final event = rooms[roomId]?.events[this.relatedEvent!];
if (event == null) return null;
if (relationType == .edit && controller.value.text.isEmpty) {
controller.value.text =
relatedEvent?.localContent?.editSource ??
switch (relatedEvent?.content) {
TextMessageContent(:final body) => body,
_ => "",
};
}
final edited = event.lastEditRowId == 0
? null
: rooms[roomId]?.events[event.lastEditRowId];
return edited == null
? event
: event.copyWith(
content: edited.content,
localContent: edited.localContent,
);
}),
);
final textBefore = useState<String?>(null);
useEffect(() {
if (relationType == .edit && relatedEvent != null) {
textBefore.value ??= controller.text;
final text =
relatedEvent.localContent?.editSource ??
switch (relatedEvent.content) {
TextMessageContent(:final body) => body,
_ => "",
};
controller.value = .new(
text: text,
selection: .collapsed(offset: text.length),
);
} else if (textBefore.value case final draft?) {
controller.value = .new(
text: draft,
selection: .collapsed(offset: draft.length),
);
textBefore.value = null;
}
return null;
}, [relationType, relatedEvent == null]);
final attachment = ref.watch(AttachmentController.provider(roomId));
@ -57,13 +95,13 @@ class const Composer(
return;
}
onSend(
controller.value.formattedText,
controller.formattedText,
shouldMention: shouldMention.value,
tags: .new(controller.value.tags),
tags: .new(controller.tags),
);
onDismiss();
controller.value.text = "";
controller.text = "";
}
final style = TextStyle(
@ -108,9 +146,7 @@ class const Composer(
),
Container(
color: theme.colorScheme.surfaceContainerHighest,
padding: .symmetric(horizontal: 8),
child: Row(
spacing: 8,
mainAxisAlignment: .center,
children:
ref.watch(
@ -126,7 +162,7 @@ class const Composer(
builder: (context) => EmojiPicker(
onSelection: (value) {
Navigator.of(context).pop();
controller.value.text += value;
controller.text += value;
node?.requestFocus();
},
),
@ -144,7 +180,7 @@ class const Composer(
leading: Icon(Icons.add_a_photo),
),
onTap: () async => ref
.watch(
.read(
AttachmentController.provider(roomId)
.notifier,
)
@ -163,7 +199,7 @@ class const Composer(
leading: Icon(Icons.add_photo_alternate),
),
onTap: () async => ref
.watch(
.read(
AttachmentController.provider(roomId)
.notifier,
)
@ -192,6 +228,7 @@ class const Composer(
],
icon: Icon(Icons.add),
),
SizedBox(width: 8),
Expanded(
child: FlutterTagger(
triggerStrategy: .eager,
@ -200,11 +237,11 @@ class const Composer(
roomId: roomId,
triggerCharacter: triggerCharacter.value,
addTag: ({required id, required name}) {
controller.value.addTag(id: id, name: name);
controller.addTag(id: id, name: name);
node?.requestFocus();
},
),
controller: controller.value,
controller: controller,
onSearch: (newQuery, newTriggerCharacter) {
triggerCharacter.value = newTriggerCharacter;
query.value = newQuery;
@ -234,17 +271,16 @@ class const Composer(
child: TextField(
maxLines: 12,
minLines: 1,
textCapitalization: .sentences,
autofocus:
(Platform.isLinux ||
Platform.isMacOS ||
Platform.isWindows)
? true
: false,
Platform.isMacOS ||
Platform.isWindows),
decoration: .new(
hintText: "Your message here...",
border: .none,
),
controller: controller.value,
controller: controller,
key: key,
focusNode: node,
),

View file

@ -21,12 +21,15 @@ class const EmojiPicker({
.watch(upstream.EmojiController.provider)
.betterWhen(
data: (categories) => upstream.EmojiPicker(
onSelection: (emoji) async {
onSelection: (emoji, isFreeText) async {
await onSelection(emoji);
await ref
.watch(RecentEmojiController.provider.notifier)
.add(emoji)
.onError(showError);
if (!isFreeText) {
await ref
.watch(RecentEmojiController.provider.notifier)
.add(emoji)
.onError(showError);
}
},
allowFreeText: allowFreeText,
prependCategories: .new([

View file

@ -13,18 +13,23 @@ class const EventPreview(final Event event, {super.key})
padding: .symmetric(vertical: 4),
child: Row(
mainAxisSize: .min,
spacing: 12,
spacing: 8,
children: [
if (event.content is MessageContent) MessageAvatar(event),
if (event.content is MessageContent) MessageAvatar(event, height: 36),
Flexible(
child: Wrap(
crossAxisAlignment: .center,
spacing: 8,
runSpacing: 2,
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .start,
children: [
if (event.content is MessageContent) MessageDisplayname(event),
EventRenderer(event, textOnly: true, maxLines: 1),
if (event.content is MessageContent)
MessageDisplayname(event, shouldWrap: false),
EventRenderer(
event.rowId,
roomId: event.roomId,
textOnly: true,
maxLines: 1,
),
],
),
),

View file

@ -25,7 +25,7 @@ class const CodeBlock(
padding: .symmetric(horizontal: 8),
child: Text(
lang.substring(0, min(lang.length, 15)),
style: .new(fontFamily: "monospace"),
style: .new(fontFamily: "bundled-mono"),
),
),
TextButton.icon(
@ -44,7 +44,7 @@ class const CodeBlock(
code,
minLines: 1,
maxLines: 99,
style: .new(fontFamily: "monospace"),
style: .new(fontFamily: "bundled-mono"),
),
),
),

View file

@ -2,7 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/helpers/mxc_image.dart";
import "package:nexus/widgets/expandable_image.dart";
@ -51,14 +51,28 @@ class const Html(
element.text,
lang: element.className.replaceAll("language-", ""),
)
: null,
: InlineCustomWidget(
child: ClipRRect(
borderRadius: .all(.circular(4)),
child: Container(
padding: .all(8),
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: SelectableText(
element.text,
style: .new(fontFamily: "bundled-mono"),
),
),
),
),
"blockquote" => Quoted(
Html(element.innerHtml, textStyle: textStyle, roomId: roomId),
),
"a" =>
element.attributes["href"]?.mention == null
element.attributes["href"]?.linkInfo == null
? null
: InlineCustomWidget(
child: MentionChip(element.attributes["href"]!, roomId),

View file

@ -1,17 +1,21 @@
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/room_summary.dart";
import "package:nexus/controllers/user.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/room_summary.dart";
import "package:nexus/widgets/room_summary_dialog.dart";
class const MentionChip(final String content, final String? roomId, {super.key})
extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final mention = content.mention;
final linkInfo = content.linkInfo;
final mention = linkInfo?.identifier;
final data = switch (mention?.characters.firstOrNull) {
"@" =>
ref
@ -22,10 +26,11 @@ class const MentionChip(final String content, final String? roomId, {super.key})
"#" || "!" =>
ref
.watch(
RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
)
.whenOrNull(data: (data) => data),
.watch(
RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
)
.whenOrNull(data: (data) => data) ??
RoomSummary(roomId: mention),
_ => null,
};
@ -33,19 +38,49 @@ class const MentionChip(final String content, final String? roomId, {super.key})
return mention == null
? SizedBox.shrink()
: InkWell(
onTap: () {
onTap: () async {
if (data case MembershipContent membership) {
context.showUserPopover(membership, mention, roomId: roomId);
} else if (data case RoomSummary summary) {
// TODO: Handle summary
// TODO: This is an extra call that isn't truly needed, get rid of it
final eventRowId = linkInfo?.eventId == null
? null
: await ref
.read(
EventController.provider(
.new(
roomId: summary.roomId,
eventId: linkInfo!.eventId!,
),
).selectAsync((data) => data?.rowId),
)
.onError((_, _) => null);
if (!await ref.focusRoom(summary.roomId, eventRowId) &&
context.mounted) {
showDialog(
context: context,
builder: (context) => Consumer(
builder: (context, ref, _) => RoomSummaryDialog(
ref
.watch(
RoomSummaryController.provider(
.new(roomIdOrAlias: mention),
),
)
.whenOrNull(data: (data) => data) ??
summary,
via: linkInfo?.via,
),
),
);
}
}
},
child: Text(
switch (data) {
RoomSummary summary =>
(summary.name == null ? null : "#${summary.name}") ??
summary.canonicalAlias ??
summary.roomId,
"${(summary.name == null ? null : "#${summary.name}") ?? summary.canonicalAlias ?? summary.roomId}${linkInfo?.eventId == null ? "" : " > ${linkInfo?.eventId}"}",
MembershipContent membership =>
membership.displayName == null
? mention

View file

@ -0,0 +1,22 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/nav_page.dart";
class const InvitesList({super.key})
extends StatelessWidget
implements NavPage {
@override
String get title => "Invites";
@override
IconData get icon => Icons.inbox;
// TODO: Show notification badge here
@override
Future<int>? badgeBuilder(WidgetRef ref) => null;
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}

View file

@ -1,16 +1,40 @@
import "package:collection/collection.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/spaces.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/controllers/room_summary.dart";
import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/widgets/room_summary_dialog.dart";
class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
@override
Widget build(BuildContext context) {
final roomAlias = useTextEditingController();
Future<void> onJoin() async {
Navigator.of(context).pop();
final linkInfo = roomAlias.text.linkInfo;
final via = linkInfo?.via ?? const .empty();
final roomIdOrAlias = linkInfo?.identifier ?? roomAlias.text;
showDialog(
context: context,
builder: (context) => Consumer(
builder: (context, ref, _) => switch (ref.watch(
RoomSummaryController.provider(
.new(roomIdOrAlias: roomIdOrAlias, via: via),
),
)) {
AsyncData(:final value) => RoomSummaryDialog(value, via: via),
AsyncError _ || AsyncLoading _ => RoomSummaryDialog(
.new(roomId: roomIdOrAlias),
via: via,
),
},
),
);
}
return AlertDialog(
title: Text("Join a Room"),
content: Column(
@ -21,111 +45,14 @@ class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
SizedBox(height: 12),
TextField(
controller: roomAlias,
decoration: .new(hintText: "#room:server"),
decoration: .new(hintText: "#room:server.name"),
onEditingComplete: onJoin,
),
],
),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
if (context.mounted) {
final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text;
final scaffoldMessenger = ScaffoldMessenger.of(context);
final snackbar = scaffoldMessenger.showSnackBar(
.new(
content: Text("Joining room $roomIdOrAlias."),
duration: Duration(days: 999),
),
);
try {
final id = await ref
.watch(ClientController.provider.notifier)
.joinRoom(
.new(
roomIdOrAlias: roomIdOrAlias,
via: .new(
Uri.tryParse(roomAlias.text.replaceAll("/#", ""))
?.queryParametersAll["via"] ??
[],
),
),
);
snackbar.close();
scaffoldMessenger.showSnackBar(
.new(
content: Text("Room $roomIdOrAlias successfully joined."),
action: .new(
label: "Open",
onPressed: () async {
final spaces = ref.watch(SpacesController.provider);
final space = spaces.firstWhereOrNull(
(space) => space.id == id,
);
await ref
.watch(
KeyController.provider(KeyController.spaceKey)
.notifier,
)
.set(
space?.id ??
spaces
.firstWhere(
(space) =>
space.children.any(
(child) =>
child.metadata?.id == id,
) ||
space.subSpaces.any(
(child) =>
child.room.metadata?.id == id,
),
)
.id,
);
if (space == null) {
await ref
.watch(
KeyController.provider(KeyController.roomKey)
.notifier,
)
.set(id);
}
},
),
),
);
} catch (error) {
snackbar.close();
if (context.mounted) {
scaffoldMessenger.showSnackBar(
.new(
backgroundColor: Theme.of(context)
.colorScheme
.errorContainer,
content: Text(
error.toString(),
style: .new(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
);
}
}
}
},
child: Text("Join"),
),
TextButton(onPressed: onJoin, child: Text("Join")),
],
);
}

View file

@ -21,9 +21,9 @@ class const MessageAvatar(
child: AvatarOrHash(
value.avatarUrl,
value.displayName ?? event.sender.localpart,
height: height,
dimension: height,
),
),
_ => AvatarOrHash(null, event.sender.localpart, height: height),
_ => AvatarOrHash(null, event.sender.localpart, dimension: height),
};
}

View file

@ -5,50 +5,57 @@ import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/helpers/extensions/string_to_color.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/content/membership.dart";
class const MessageDisplayname(
final Event event, {
final TextStyle? style,
final bool clickable = true,
final bool shouldWrap = true,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(
AuthorController.provider(event),
)) {
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
onTap: clickable
AsyncData(:final MembershipContent? value) ||
AsyncLoading(:final value) ||
AsyncError(:final value) => InkWell(
onTap: clickable && value != null
? () => context.showUserPopover(
value,
event.sender,
roomId: event.roomId,
)
: null,
child: Wrap(
spacing: 4,
crossAxisAlignment: .center,
children: [
child: switch ([
Text(
value?.displayName ?? event.sender.localpart,
style:
style ?? .new(color: event.sender.colorHash, fontWeight: .bold),
maxLines: 1,
overflow: .ellipsis,
),
if (event.pmp != null)
Text(
value.displayName ?? event.sender.localpart,
style:
style ?? .new(color: event.sender.colorHash, fontWeight: .bold),
"(via ${event.sender})",
style: Theme.of(context).textTheme.labelSmall
?.copyWith(color: event.sender.colorHash, fontWeight: .bold),
maxLines: 1,
overflow: .ellipsis,
),
if (event.pmp != null)
Text(
"(via ${event.sender})",
style: Theme.of(context).textTheme.labelSmall
?.copyWith(color: event.sender.colorHash, fontWeight: .bold),
maxLines: 1,
overflow: .ellipsis,
),
],
),
),
_ => Text(
event.sender.localpart,
style: .new(color: event.sender.colorHash, fontWeight: .bold),
]) {
final children when shouldWrap => Wrap(
spacing: 4,
crossAxisAlignment: .center,
children: children,
),
final children => Row(
spacing: 4,
mainAxisSize: .min,
children: [for (final child in children) Flexible(child: child)],
),
},
),
};
}

View file

@ -1,11 +1,15 @@
import "package:material_ui/material_ui.dart";
class const Loading({super.key, final double? height}) extends StatelessWidget {
class const Loading({super.key, final double? dimension})
extends StatelessWidget {
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: .all(16),
child: SizedBox(height: height, child: CircularProgressIndicator()),
child: SizedBox.square(
dimension: dimension,
child: CircularProgressIndicator(),
),
),
);
}

View file

@ -7,6 +7,7 @@ import "package:m3e_card_list/m3e_card_list.dart";
import "package:nexus/controllers/members_by_status.dart";
import "package:nexus/controllers/members_grouped.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/helpers/extensions/string_to_color.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/membership_status.dart";
@ -14,7 +15,6 @@ import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/user_bottom_sheet.dart";
class const MemberList(final String roomId, {super.key})
extends HookConsumerWidget {
@ -123,13 +123,16 @@ class const MemberList(final String roomId, {super.key})
members[index].stateKey!,
overflow: .ellipsis,
),
leading: AvatarOrHash(
avatarUrl,
height: 36,
displayName ??
members[index]
.stateKey!
.localpart,
leading: Transform.translate(
offset: .new(0, 2),
child: AvatarOrHash(
avatarUrl,
dimension: 40,
displayName ??
members[index]
.stateKey!
.localpart,
),
),
),
_ => throw Exception(
@ -140,20 +143,10 @@ class const MemberList(final String roomId, {super.key})
final member = members[index];
if (member.content
case MembershipContent content) {
showModalBottomSheet(
constraints: .loose(
.new(
500,
(context.size?.height ?? 1000) - 80,
),
),
isScrollControlled: true,
context: context,
builder: (context) => UserBottomSheet(
content,
member.stateKey!,
roomId: roomId,
),
context.showUserPopover(
content,
member.stateKey!,
roomId: roomId,
);
}
},

View file

@ -0,0 +1,176 @@
import "dart:async";
import "package:m3e_buttons/m3e_buttons.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/notifications.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/nav_page.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const NotificationsList({
final int? highlightedEvent,
final bool defaultToAllNotifications = false,
super.key,
}) extends HookConsumerWidget implements NavPage {
@override
String get title => "Notifications";
@override
IconData get icon => Icons.notifications;
// TODO: Show notification badge here
@override
Future<int>? badgeBuilder(WidgetRef ref) => null;
@override
Widget build(BuildContext context, WidgetRef ref) {
final options = <M3EToggleButtonGroupAction, UnreadType>{
M3EToggleButtonGroupAction(
checkedLabel: Text("Mentions"),
icon: Icon(Icons.alternate_email),
): .highlight,
M3EToggleButtonGroupAction(
checkedLabel: Text("All Notifications"),
icon: Icon(Icons.notifications),
): .notify,
};
final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0);
final highlightedId = useState(highlightedEvent);
final listController = useRef(ListController());
final scrollController = useScrollController();
final provider = NotificationsController.provider((
options.values.toList()[unreadTypeIndex.value],
null,
));
final notifications = ref.watch(provider);
final notifier = ref.watch(provider.notifier);
useEffect(() {
if (highlightedId.value == null) return null;
Timer? timer;
void listener() =>
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!listController.value.isAttached) return;
final notifications = await ref.watch(provider.future);
final index = notifications.indexWhere(
(tuple) => tuple.$1 == highlightedId.value!,
);
if (index == -1) return;
listController.value.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut,
);
timer = Timer(.new(seconds: 1), () {
highlightedId.value = null;
});
listController.value.removeListener(listener);
});
listController.value.addListener(listener);
return timer?.cancel;
}, []);
useEffect(() {
Future<void> listener() async {
if (!scrollController.hasClients || notifications.isLoading) return;
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent) {
await notifier.loadOlder();
}
}
scrollController.addListener(listener);
return () => scrollController.removeListener(listener);
}, [scrollController, notifications]);
return Stack(
children: [
Column(
children: [
if (notifications is AsyncLoading && notifications.value != null)
const LinearProgressIndicator(minHeight: 2),
Expanded(
child: switch (notifications) {
AsyncData(:final value) || AsyncLoading(:final value?) =>
value.isEmpty
? Center(
child: Text(
"No notifications yet",
style: Theme.of(context).textTheme.headlineMedium,
),
)
: SuperListView.builder(
listController: listController.value,
controller: scrollController,
itemCount: value.length,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 12,
),
reverse: true,
itemBuilder: (context, index) {
final (rowId, roomId) = value[index];
final isHighlighted = rowId == highlightedId.value;
return Padding(
padding: .only(top: 8),
child: HighlightWrapper(
InkWell(
onTap: () async {
final navigator = Navigator.of(context);
if (navigator.canPop()) navigator.pop();
await ref.focusRoom(roomId, rowId);
},
child: IgnorePointer(
child: EventRenderer(rowId, roomId: roomId),
),
),
isHighlighted: isHighlighted,
),
);
},
),
AsyncLoading() => const Loading(),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
),
],
),
Align(
alignment: .topRight,
child: Padding(
padding: .all(16),
child: M3EToggleButtonGroup(
selectedIndex: unreadTypeIndex.value,
onSelectedIndexChanged: (index) =>
unreadTypeIndex.value = index ?? unreadTypeIndex.value,
actions: options.keys.toList(),
),
),
),
],
);
}
}

View file

@ -4,10 +4,10 @@ import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/sidebar.dart";
import "package:nexus/widgets/room_chat.dart";
import "package:nexus/widgets/room_chat/room_chat.dart";
import "package:nexus/widgets/loading.dart";
class const ChatPage({super.key}) extends ConsumerWidget {
class const ChatPage({super.key}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
builder: (context, constraints) {
@ -24,9 +24,10 @@ class const ChatPage({super.key}) extends ConsumerWidget {
body: initComplete
? Row(
children: [
if (isDesktop) Sidebar(isDesktop: isDesktop),
if (isDesktop) Sidebar(),
Expanded(
child: RoomChat(
key: ValueKey(roomId),
roomId: roomId,
isDesktop: isDesktop,
showMembersByDefault: showMembersByDefault,
@ -40,9 +41,7 @@ class const ChatPage({super.key}) extends ConsumerWidget {
children: [Loading(), Text("Syncing...")],
),
),
drawer: isDesktop || !initComplete
? null
: Sidebar(isDesktop: isDesktop),
drawer: isDesktop || !initComplete ? null : Sidebar(),
),
);
},

View file

@ -9,7 +9,7 @@ import "package:nexus/controllers/client_id.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/main.dart";
import "package:nexus/models/homeserver.dart";
import "package:nexus/pages/settings.dart";
import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/divider_text.dart";
@ -134,9 +134,9 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
padding: .symmetric(vertical: 8, horizontal: 12),
children: [
Row(
spacing: 12,
children: [
SvgPicture.asset("assets/bundled/icon.svg", width: 128),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
@ -144,7 +144,7 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
Text("Nexus", style: theme.textTheme.displayMedium),
Text(
"A Simple Matrix Client",
style: theme.textTheme.headlineMedium,
style: theme.textTheme.titleLarge,
overflow: .ellipsis,
),
],
@ -161,6 +161,7 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
child: TextField(
textInputAction: .done,
autofocus: true,
keyboardType: .url,
onSubmitted: (text) => setHomeserver(.tryParse(text)),
controller: homeserverUrl,
decoration: .new(
@ -193,12 +194,13 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
url: .https("federated.nexus"),
iconUrl: "https://federated.nexus/images/icon.png",
),
.new(
name: "Unredacted",
description: "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.",
url: .https("unredacted.org", "services/si/matrix"),
iconUrl: "https://unredacted.org/favicon.ico",
),
// TODO: Add back unredacted when they have MAS, find where new favicon is
// .new(
// name: "Unredacted",
// description: "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.",
// url: .https("unredacted.org", "services/si/matrix"),
// iconUrl: "https://unredacted.org/favicon.ico",
// ),
].map(
(homeserver) => Card(
child: ListTile(

View file

@ -8,7 +8,7 @@ import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:nexus/controllers/settings_sections.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/show_about_dialog.dart";
import "package:nexus/pages/settings_category.dart";
import "package:nexus/widgets/pages/settings_category.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart";

View file

@ -2,7 +2,7 @@ import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/pages/settings.dart";
import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/helpers/required_validator_helper.dart";
@ -41,6 +41,8 @@ class const VerifyPage({super.key}) extends HookConsumerWidget {
]),
),
body: AlertDialog(
insetPadding: .symmetric(vertical: 12, horizontal: 32),
scrollable: true,
title: Text("Verify"),
content: Form(
key: formKey.value,

View file

@ -2,6 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/pinned_events.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
@ -10,7 +11,7 @@ import "package:nexus/widgets/renderers/event.dart";
final class const PinnedEventsDrawer(
final String roomId, {
required final IList<PopupMenuEntry> Function(Event event) getEventOptions,
required final Future<void> Function(String eventId) jumpToId,
required final JumpToEvent jumpToEvent,
super.key,
}) extends HookConsumerWidget {
@override
@ -56,21 +57,23 @@ final class const PinnedEventsDrawer(
reverse: true,
itemCount: value.length,
itemBuilder: (context, index) {
final event = value.reversed[index];
final rowId = value.reversed[index];
return InkWell(
borderRadius: .circular(12),
onTap: () {
Navigator.of(context).pop();
jumpToId(event.eventId);
jumpToEvent(rowId);
},
child: Padding(
padding: .symmetric(vertical: 4),
child: EventRenderer(
event,
maxLines: 2,
isGrouped: false,
getEventOptions: getEventOptions,
child: IgnorePointer(
child: EventRenderer(
rowId,
roomId: roomId,
maxLines: 2,
getEventOptions: getEventOptions,
),
),
),
);

View file

@ -64,8 +64,10 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
enabled.value = false;
try {
final controller = ref.watch(
RoomChatController.provider(event.roomId)
.notifier,
RoomChatController.provider((
roomId: event.roomId,
contextualEvent: null,
)).notifier,
);
if (selected) {

View file

@ -3,7 +3,11 @@ import "package:flutter/gestures.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.dart";
@ -28,11 +32,12 @@ import "package:nexus/widgets/renderers/generic_event.dart";
import "package:nexus/widgets/timestamp.dart";
class const EventRenderer(
final Event event, {
final int eventRowId, {
required final String roomId,
final int? previousEventRowId,
final bool textOnly = false,
final bool isGrouped = false,
final int? maxLines,
final VoidCallback? onTapReply,
final JumpToEvent? jumpToEvent,
final IList<PopupMenuEntry> Function(Event event)? getEventOptions,
super.key,
}) extends HookConsumerWidget {
@ -44,6 +49,38 @@ class const EventRenderer(
final focusNode = useFocusNode();
useListenable(focusNode);
final (event, previousEvent) = ref.watch(
RoomsController.provider.select((rooms) {
final event = rooms[roomId]?.events[eventRowId];
if (event == null) return (null, null);
final edited = event.lastEditRowId == 0
? null
: rooms[roomId]?.events[event.lastEditRowId];
return (
edited == null
? event
: event.copyWith(
content: edited.content,
localContent: edited.localContent,
),
previousEventRowId == null
? null
: rooms[roomId]?.events[previousEventRowId!],
);
}),
);
if (event == null) return SizedBox.shrink();
final isGrouped =
previousEvent?.content is MessageContent &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType != "m.replace" &&
event.sender == previousEvent?.sender &&
event.pmp?.id == previousEvent?.pmp?.id;
final child = event.redactedBy != null || event.relationType == "m.replace"
? null
: switch (event.content) {
@ -60,7 +97,18 @@ class const EventRenderer(
EncryptedContent() ||
StickerContent() => MessageRenderer(
event,
onTapReply: onTapReply,
onTapReply: jumpToEvent == null
? null
: () async {
final replyEvent = await ref.read(
EventController.provider(
.new(roomId: roomId, eventId: event.replyTo!),
).future,
);
if (replyEvent != null) {
await jumpToEvent!(replyEvent.rowId);
}
},
isGrouped: isGrouped,
maxLines: maxLines,
textOnly: textOnly,
@ -68,85 +116,174 @@ class const EventRenderer(
MembershipContent content => switch (event.previousContent) {
MembershipContent(:final status) =>
status == content.status ? null : MembershipRenderer(event),
_ => MembershipRenderer(event),
status == content.status
? null
: MembershipRenderer(event, maxLines: maxLines),
_ => MembershipRenderer(event, maxLines: maxLines),
},
AvatarContent() => GenericEventRenderer(Icons.interests, [
MessageDisplayname(event),
Text("changed the room avatar"),
]),
AvatarContent() => GenericEventRenderer(
Icons.interests,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "changed the room avatar"),
],
),
CreateContent() => GenericEventRenderer(Icons.add, [
MessageDisplayname(event),
Text("created the room"),
]),
CreateContent() => GenericEventRenderer(
Icons.add,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "created the room"),
],
),
PowerLevelsContent() => GenericEventRenderer(Icons.power, [
MessageDisplayname(event),
Text("changed the room's power levels"),
]),
PowerLevelsContent() => GenericEventRenderer(
Icons.power,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "changed the room's power levels"),
],
),
JoinRulesContent() => GenericEventRenderer(Icons.rule, [
MessageDisplayname(event),
Text("changed the room's join rules"),
]),
JoinRulesContent() => GenericEventRenderer(
Icons.rule,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "changed the room's join rules"),
],
),
TopicContent() => GenericEventRenderer(Icons.description, [
MessageDisplayname(event),
Text("updated the room topic"),
]),
TopicContent() => GenericEventRenderer(
Icons.description,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "updated the room topic"),
],
),
HistoryVisibilityContent(:final historyVisibility) =>
GenericEventRenderer(Icons.history, [
MessageDisplayname(event),
Text(
"changed the room's history visibility to ${switch (historyVisibility) {
.invited => "since invited",
.joined => "since joined",
.shared => "all history visible (shared)",
.worldReadable => "all history visible (world readable)",
}}",
GenericEventRenderer(Icons.history, maxLines: maxLines, [
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(
text:
"changed the room's history visibility to ${switch (historyVisibility) {
.invited => "since invited",
.joined => "since joined",
.shared => "all history visible (shared)",
.worldReadable => "all history visible (world readable)",
}}",
),
]),
PinnedEventsContent() => GenericEventRenderer(Icons.push_pin, [
MessageDisplayname(event),
Text("pinned/unpinned some events"),
]),
PinnedEventsContent() => GenericEventRenderer(
Icons.push_pin,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "pinned/unpinned some events"),
],
),
ServerACLContent() => GenericEventRenderer(Icons.list, [
MessageDisplayname(event),
Text("updated the server ban list"),
]),
ServerACLContent() => GenericEventRenderer(
Icons.list,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "updated the server ban list"),
],
),
CanonicalAliasContent(:final alias, :final altAliases) =>
GenericEventRenderer(Icons.numbers, [
MessageDisplayname(event),
Text(switch ([
if (event.previousContent case CanonicalAliasContent(
alias: final prevAlias,
altAliases: final prevAltAliases,
)) ...[
if (prevAlias != alias)
if (alias == null)
"removed the room's canonical alias"
else
"changed the room's canonical alias to $alias",
GenericEventRenderer(Icons.numbers, maxLines: maxLines, [
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(
text: switch ([
if (event.previousContent case CanonicalAliasContent(
alias: final prevAlias,
altAliases: final prevAltAliases,
)) ...[
if (prevAlias != alias)
if (alias == null)
"removed the room's canonical alias"
else
"changed the room's canonical alias to $alias",
if (prevAltAliases
.remove(alias ?? "")
.remove(prevAlias ?? "") !=
altAliases.remove(alias ?? "").remove(prevAlias ?? ""))
"changed the room's aliases",
] else ...[
if (alias != null) "set the room's canonical alias",
if (altAliases.isNotEmpty) "set the room's aliases",
],
]) {
[] => "did something related to room aliases",
List prev => prev.join(" and "),
}),
if (prevAltAliases
.remove(alias ?? "")
.remove(prevAlias ?? "") !=
altAliases
.remove(alias ?? "")
.remove(prevAlias ?? ""))
"changed the room's aliases",
] else ...[
if (alias != null) "set the room's canonical alias",
if (altAliases.isNotEmpty) "set the room's aliases",
],
]) {
[] => "did something related to room aliases",
List prev => prev.join(" and "),
},
),
]),
_ => null,
};
@ -161,7 +298,7 @@ class const EventRenderer(
return Column(
crossAxisAlignment: .start,
children: [
if (child != null) ...[
if (child != null)
if (textOnly)
child
else ...[
@ -220,16 +357,39 @@ class const EventRenderer(
if (event.sendError != null && event.sendError != "not sent")
Padding(
padding: .only(bottom: 4),
child: Text(
event.sendError!,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error,
),
child: Row(
spacing: 4,
children: [
Text(
event.sendError!,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error,
),
),
InkWell(
child: Text(
"Hide",
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
decoration: .underline,
decorationColor: theme.colorScheme.primary,
),
),
onTap: () => ref
.read(
RoomChatController.provider((
roomId: roomId,
contextualEvent: null,
)).notifier,
)
.hideClientSticky(event.rowId),
),
],
),
),
].map((child) => Padding(padding: .only(left: 4), child: child)),
],
] else if (textOnly)
]
else if (textOnly)
Text("Unknown event type", style: errorStyle),
],
);

View file

@ -2,7 +2,8 @@ import "package:material_ui/material_ui.dart";
class const GenericEventRenderer(
final IconData icon,
final List<Widget> children, {
final List<InlineSpan> children, {
final int? maxLines,
super.key,
}) extends StatelessWidget {
@override
@ -13,7 +14,20 @@ class const GenericEventRenderer(
mainAxisSize: .min,
children: [
Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)),
Flexible(child: Wrap(spacing: 4, children: children)),
Flexible(
child: Text.rich(
TextSpan(
children: [
for (final (index, child) in children.indexed) ...[
if (index > 0) TextSpan(text: " "),
child,
],
],
),
maxLines: maxLines ?? 999,
overflow: .ellipsis,
),
),
],
),
);

View file

@ -7,8 +7,11 @@ import "package:nexus/models/event.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/renderers/generic_event.dart";
class const MembershipRenderer(final Event event, {super.key})
extends StatelessWidget {
class const MembershipRenderer(
final Event event, {
final int? maxLines,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
assert(
@ -17,34 +20,45 @@ class const MembershipRenderer(final Event event, {super.key})
);
return switch (event.content) {
MembershipContent content => GenericEventRenderer(Icons.people, [
InkWell(
onTap: () => context.showUserPopover(
content,
event.stateKey!,
roomId: event.roomId,
MembershipContent content => GenericEventRenderer(
Icons.people,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: InkWell(
onTap: () => context.showUserPopover(
content,
event.stateKey!,
roomId: event.roomId,
),
child: Text(
overflow: .ellipsis,
content.displayName ?? event.stateKey!.localpart,
maxLines: 1,
style: .new(color: event.sender.colorHash, fontWeight: .bold),
),
),
),
child: Text(
overflow: .ellipsis,
content.displayName ?? event.stateKey!.localpart,
maxLines: 1,
style: .new(color: event.sender.colorHash, fontWeight: .bold),
TextSpan(
text:
"${switch (content.status) {
.invite => "was invited to",
.join => "joined",
.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
.ban => "was banned from",
.knock => "asked to join",
}} the room${event.sender == event.stateKey ? "" : " by"}",
),
),
Text(
overflow: .ellipsis,
maxLines: 1,
"${switch (content.status) {
.invite => "was invited to",
.join => "joined",
.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
.ban => "was banned from",
.knock => "asked to join",
}} the room${event.sender == event.stateKey ? "" : " by "}",
),
if (event.sender != event.stateKey) MessageDisplayname(event),
if (content.reason != null) Text("for \"${content.reason}\""),
]),
if (event.sender != event.stateKey)
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(event, shouldWrap: maxLines == null),
),
if (content.reason != null)
TextSpan(text: "for \"${content.reason}\""),
],
),
_ => SizedBox.shrink(),
};
}

View file

@ -1,10 +1,10 @@
import "package:collection/collection.dart";
import "package:flutter/rendering.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:linkify/linkify.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/helpers/extensions/get_filename.dart";
import "package:nexus/models/content/encrypted.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/sticker.dart";
@ -14,6 +14,7 @@ import "package:nexus/widgets/html/html.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/linkified_text.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/message_image.dart";
import "package:nexus/widgets/reaction_row.dart";
import "package:nexus/widgets/timestamp.dart";
@ -37,7 +38,9 @@ class const MessageRenderer(
final errorStyle = TextStyle(color: colorScheme.error);
final textStyle = TextStyle(
fontSize: event.localContent?.bigEmoji == true ? 32 : null,
fontSize: event.localContent?.bigEmoji == true && maxLines != 1
? 32
: null,
fontStyle: event.content is EmoteMessageContent ? .italic : null,
);
@ -52,104 +55,102 @@ class const MessageRenderer(
),
// TODO: Handle locations
// LocationMessageContent(:final body , :final geoUri) =>
TextMessageContent(:final body, :final formattedBody, :final format) ||
NoticeMessageContent(:final body, :final formattedBody, :final format) ||
EmoteMessageContent(:final body, :final formattedBody, :final format) ||
ImageMessageContent(:final body, :final formattedBody, :final format) ||
VideoMessageContent(:final body, :final formattedBody, :final format) ||
AudioMessageContent(:final body, :final formattedBody, :final format) ||
FileMessageContent(
:final body,
:final formattedBody,
:final format,
) => Column(
crossAxisAlignment: .start,
children: [
format == .html && !textOnly
? Html(
roomId: event.roomId,
textStyle: textStyle,
formattedBody!.replaceAllMapped(
RegExp(
r"(<a\b[^>]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)",
caseSensitive: false,
dotAll: true,
MessageContent content => switch (content) {
TextMessageContent(:final body, :final formattedBody, :final format) ||
NoticeMessageContent(
:final body,
:final formattedBody,
:final format,
) ||
EmoteMessageContent(:final body, :final formattedBody, :final format) ||
ImageMessageContent(:final body, :final formattedBody, :final format) ||
VideoMessageContent(:final body, :final formattedBody, :final format) ||
AudioMessageContent(:final body, :final formattedBody, :final format) ||
FileMessageContent(
:final body,
:final formattedBody,
:final format,
) => Column(
crossAxisAlignment: .start,
children: [
format == .html && !textOnly
? Html(
roomId: event.roomId,
textStyle: textStyle,
formattedBody!.replaceAllMapped(
RegExp(
r"(<a\b[^>]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)",
caseSensitive: false,
dotAll: true,
),
(m) {
// If it's already an <a> tag, leave it unchanged
if (m.group(1) != null) {
return m.group(1)!;
}
// Otherwise, wrap the bare URL
final url = m.group(2)!;
return "<a href=\"$url\">$url</a>";
},
),
(m) {
// If it's already an <a> tag, leave it unchanged
if (m.group(1) != null) {
return m.group(1)!;
}
// Otherwise, wrap the bare URL
final url = m.group(2)!;
return "<a href=\"$url\">$url</a>";
},
)
: LinkifiedText(
body.isEmpty ? content.filename ?? "" : body,
style: textStyle,
maxLines: maxLines,
),
)
: LinkifiedText(body, style: textStyle, maxLines: maxLines),
if (!textOnly) ...[
if (event.content
case ImageMessageContent(:final url) ||
FileMessageContent(:final url) ||
VideoMessageContent(:final url) ||
AudioMessageContent(:final url))
ConstrainedBox(
constraints: .loose(.square(500)),
child: switch (event.content) {
VideoMessageContent(:final info, :final file) => VideoPlayer(
url,
info,
encrypted: file != null,
),
AudioMessageContent(:final info, :final file) => AudioPlayer(
url,
info,
if (!textOnly) ...[
if (event.content
case ImageMessageContent(:final url) ||
FileMessageContent(:final url) ||
VideoMessageContent(:final url) ||
AudioMessageContent(:final url))
ConstrainedBox(
constraints: .loose(.square(500)),
child: switch (event.content) {
VideoMessageContent(:final info, :final file) =>
VideoPlayer(url, info, encrypted: file != null),
AudioMessageContent(:final info, :final file) =>
AudioPlayer(url, info, encrypted: file != null),
FileMessageContent(:final info, :final filename) =>
FileCard(url, info, filename: filename),
ImageMessageContent(:final info, :final file) =>
MessageImage(url, info: info, encrypted: file != null),
_ => SizedBox.shrink(),
},
),
encrypted: file != null,
),
FileMessageContent(:final info, :final filename) => FileCard(
url,
info,
filename: filename,
),
ImageMessageContent(:final info, :final file) => MessageImage(
url,
info: info,
encrypted: file != null,
),
_ => SizedBox.shrink(),
},
),
if (event.lastEditRowId != 0)
Text("(edited)", style: theme.textTheme.labelSmall),
if (event.lastEditRowId != 0)
Text("(edited)", style: theme.textTheme.labelSmall),
if (linkify(body)
.firstWhereOrNull((element) => element is UrlElement)
case final UrlElement link?)
if (Uri.tryParse(link.url) case final Uri url?) UrlPreview(url),
if (linkify(body)
.firstWhereOrNull((element) => element is UrlElement)
case final UrlElement link?)
if (Uri.tryParse(link.url) case final Uri url?) UrlPreview(url),
],
],
],
),
MessageContent(:final body) =>
body == null
? Text("This message is redacted", style: errorStyle)
: Wrap(
spacing: 8,
children: [
Text("Unknown message type:", style: errorStyle),
Text(body),
],
),
),
LocationMessageContent(:final String? body) ||
UnknownMessageContent(:final body) =>
body == null
? Text("This message is redacted", style: errorStyle)
: Wrap(
spacing: 8,
children: [
Text("Unknown message type:", style: errorStyle),
Text(body),
],
),
},
_ => throw Exception("This is impossible"),
};
return Row(
crossAxisAlignment: .start,
mainAxisSize: .min,
spacing: 8,
spacing: 6,
children: [
if (!textOnly)
if (isGrouped)
@ -158,7 +159,7 @@ class const MessageRenderer(
MessageAvatar(event, height: 40),
Flexible(
child: Column(
spacing: 4,
spacing: 2,
crossAxisAlignment: .start,
children: [
if (!isGrouped && !textOnly)
@ -198,34 +199,31 @@ class const MessageRenderer(
onTap: onTapReply,
child: Padding(
padding: .symmetric(vertical: 8, horizontal: 12),
child: switch (ref.watch(
EventController.provider(
.new(
roomId: event.roomId,
eventId: event.replyTo!,
child: SizedBox(
height: 48,
child: switch (ref.watch(
EventController.provider(
.new(
roomId: event.roomId,
eventId: event.replyTo!,
),
),
),
)) {
AsyncData(:final value?) ||
AsyncLoading(
:final value?,
) => EventPreview(value),
AsyncError _ => Text(
"An error occurred while fetching the reply",
style: errorStyle,
),
_ => Text("Fetching event..."),
},
)) {
AsyncData(:final value?) ||
AsyncLoading(
:final value?,
) => EventPreview(value),
AsyncError _ => Text(
"An error occurred while fetching the reply",
style: errorStyle,
),
_ => Loading(dimension: 12),
},
),
),
),
),
RendererBinding.instance.mouseTracker.mouseIsConnected
? SelectableRegion(
selectionControls: materialTextSelectionControls,
child: rendered,
)
: rendered,
rendered,
if (!textOnly) ReactionRow(event),
],
),

View file

@ -3,9 +3,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/linkified_text.dart";
import "package:nexus/widgets/room_menu.dart";
import "package:nexus/widgets/room_menu_button.dart";
import "package:nexus/widgets/room_summary_dialog.dart";
final class const RoomAppbar({
required final String? roomId,
@ -25,61 +24,19 @@ final class const RoomAppbar({
: ref.watch(RoomsController.provider.select((value) => value[roomId!]));
return Appbar(
onTap: room == null
onTap: room?.metadata == null
? null
: () => showDialog(
context: context,
builder: (context) => Dialog(
constraints: .loose(.fromWidth(400)),
child: Padding(
padding: .all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
spacing: 8,
children: [
Row(
spacing: 12,
mainAxisSize: .min,
children: [
if (room.metadata?.avatar != null)
ExpandableImage(
room.metadata?.avatar == null
? null
: .new(mxc: room.metadata!.avatar!),
child: AvatarOrHash(
room.metadata?.avatar,
room.metadata?.name ?? "Unnamed Room",
height: 64,
fallback: Icon(Icons.numbers),
),
),
Expanded(
child: Text(
room.metadata?.name ?? "Unnamed Room",
overflow: .ellipsis,
maxLines: 3,
style: Theme.of(context)
.textTheme
.headlineSmall,
),
),
],
),
if (room.metadata?.topic?.isNotEmpty == true)
LinkifiedText(
room.metadata!.topic!,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
builder: (context) => RoomSummaryDialog(
.new(
roomId: room!.metadata!.id,
joinedMembers:
room.metadata!.lazyLoadSummary?.joinedMemberCount,
avatarUrl: room.metadata?.avatar,
canonicalAlias: room.metadata?.canonicalAlias,
name: room.metadata?.name,
topic: room.metadata?.topic,
),
),
),
@ -89,7 +46,7 @@ final class const RoomAppbar({
: AvatarOrHash(
room.metadata?.avatar,
room.metadata?.name ?? "Unnamed Room",
height: 24,
dimension: 24,
fallback: Icon(Icons.numbers),
)
: DrawerButton(onPressed: onOpenDrawer),
@ -115,21 +72,23 @@ final class const RoomAppbar({
),
],
),
actions: room == null
? .new()
: .new([
IconButton(
onPressed: onOpenPinnedMessagesList?.call,
icon: Icon(Icons.push_pin),
tooltip: "Open pinned messages",
),
IconButton(
onPressed: () => onOpenMemberList?.call(context),
tooltip: "Open member list",
icon: Icon(Icons.people),
),
RoomMenu(room),
]),
actions: .new(
room == null
? []
: [
IconButton(
onPressed: onOpenPinnedMessagesList?.call,
icon: Icon(Icons.push_pin),
tooltip: "Open pinned messages",
),
IconButton(
onPressed: () => onOpenMemberList?.call(context),
tooltip: "Open member list",
icon: Icon(Icons.people),
),
RoomMenuButton(room),
],
),
);
}
}

View file

@ -1,592 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:measure_size/measure_size.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/member_list_opened.dart";
import "package:nexus/controllers/pinned_ids.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/recent_emoji.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/via.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/composer.dart";
import "package:nexus/widgets/emoji_picker.dart";
import "package:nexus/widgets/pinned_events_drawer.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/loading.dart";
import "package:super_sliver_list/super_sliver_list.dart";
final class const RoomChat({
required final String? roomId,
required final bool isDesktop,
required final bool showMembersByDefault,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final relatedEvent = useState<Event?>(null);
final relationType = useState(RelationType.reply);
final highlightedEvent = useState<String?>(null);
final composerSize = useState<double>(64);
final userId = ref.watch(ClientStateController.provider)?.userId;
final memberListOpened = ref
.watch(MemberListOpenedController.provider)
.requireValue;
final theme = Theme.of(context);
final nothing = Center(
child: Text(
"Nothing to see here...",
style: theme.textTheme.headlineMedium,
),
);
if (userId == null || this.roomId == null) {
return Scaffold(
appBar: RoomAppbar(
roomId: this.roomId,
isDesktop: isDesktop,
onOpenDrawer: () => Scaffold.of(context).openDrawer(),
),
body: nothing,
);
}
final roomId = this.roomId!;
final controllerProvider = RoomChatController.provider(roomId);
final notifier = ref.watch(controllerProvider.notifier);
final client = ref.watch(ClientController.provider.notifier);
final listController = useRef(ListController());
final scrollController = useScrollController();
final controllerData = ref.watch(controllerProvider);
final topEventBeforeLoad = useState<String?>(null);
final hasMore = useState<bool>(true);
Future<void> jumpToId(String eventId) async {
final index =
controllerData.value?.indexWhere(
(element) => element.eventId == eventId,
) ??
-1;
if (index == -1) return;
listController.value.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut,
);
highlightedEvent.value = eventId;
await Future.delayed(.new(seconds: 1), () {
if (highlightedEvent.value == eventId) {
highlightedEvent.value = null;
}
});
}
Future<void> loadOlder() async {
if (controllerData case AsyncData(:final value?)) {
topEventBeforeLoad.value = value.firstOrNull?.eventId;
hasMore.value = await notifier.loadOlder();
}
}
useEffect(() {
ref
.read(controllerProvider.future)
.then(
(_) => WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent - .000001,
);
}
}),
);
return null;
}, [scrollController.hasClients]);
useEffect(() {
if (controllerData case AsyncData(:final value?)
when scrollController.hasClients) {
if (topEventBeforeLoad.value != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
final index = value.indexWhere(
(event) => event.eventId == topEventBeforeLoad.value,
);
if (index != -1) {
listController.value.jumpToItem(
index: index,
scrollController: scrollController,
alignment: 0,
);
}
}
topEventBeforeLoad.value = null;
});
} else if (scrollController.position.atEdge &&
scrollController.position.pixels != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent,
);
}
});
}
}
return null;
}, [controllerData]);
useEffect(() {
Future<void> listener() async {
if (!scrollController.hasClients || !scrollController.position.atEdge) {
return;
}
final room = ref.watch(
RoomsController.provider.select((value) => value[roomId]),
);
if (room == null) return;
if (scrollController.position.pixels == 0) {
if (room.hasMore) {
await loadOlder();
}
} else {
await client.markRead(room);
}
}
scrollController.addListener(listener);
return () => scrollController.removeListener(listener);
}, [roomId, controllerData]);
final composerNode = useFocusNode(
onKeyEvent: (_, event) {
if (event is KeyDownEvent && event.logicalKey == .escape) {
relatedEvent.value = null;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
IList<PopupMenuEntry> getEventOptions(Event event) {
final danger = theme.colorScheme.error;
final isSentByMe = event.sender == userId;
final recentEmoji = ref.watch(RecentEmojiController.provider);
return [
if (ref.watch(
PowerLevelController.provider(
.new(eventType: .reaction, roomId: roomId),
),
))
PopupMenuItem(
enabled: false,
child: IconTheme(
data: theme.iconTheme,
child: Row(
children: [
...{
...recentEmoji.map((entry) => entry.emoji),
"👍",
"🤣",
"😭",
"🤔",
}
.toIList()
.sublist(0, 4)
.map(
(emoji) => IconButton(
onPressed: () async {
Navigator.of(context).pop();
await notifier
.sendReaction(emoji, event)
.onError(showError);
ref
.watch(RecentEmojiController.provider.notifier)
.add(emoji)
.onError(showError);
},
icon: Text(emoji),
),
),
IconButton(
icon: Icon(Icons.emoji_emotions),
onPressed: () {
// TODO: More adaptive, e.g. dialog on desktop
Navigator.of(context).pop();
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => EmojiPicker(
onSelection: (value) {
Navigator.of(context).pop();
notifier
.sendReaction(value, event)
.onError(showError);
},
allowFreeText: true,
),
);
},
),
],
),
),
),
if (ref.watch(
PowerLevelController.provider(
.new(eventType: .message, roomId: roomId),
),
))
PopupMenuItem(
onTap: () {
relatedEvent.value = event;
relationType.value = .reply;
composerNode.requestFocus();
},
child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
),
if (event.content is MessageContent && isSentByMe)
PopupMenuItem(
onTap: () {
relatedEvent.value = event;
relationType.value = .edit;
composerNode.requestFocus();
},
child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
),
if (ref.watch(
PowerLevelController.provider(
.state(eventType: .pinnedEvents, roomId: roomId),
),
))
switch (ref
.watch(PinnedIdsController.provider(roomId))
.contains(event.eventId)) {
bool isPinned => PopupMenuItem(
onTap: () async {
try {
final notifier = ref.read(
PinnedIdsController.provider(roomId).notifier,
);
if (isPinned) {
await notifier.removePin(event.eventId);
} else {
await notifier.addPin(event.eventId);
}
} catch (error, stackTrace) {
showError(error, stackTrace);
}
},
child: ListTile(
leading: Icon(Icons.push_pin),
title: Text(isPinned == true ? "Unpin Event" : "Pin Event"),
),
),
},
PopupMenuItem(
onTap: () async {
final room = ref.watch(
RoomsController.provider.select((value) => value[roomId]),
);
if (room == null) return;
final vias = ref.watch(ViaController.provider(room));
await Clipboard.setData(
ClipboardData(
text:
"matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias",
),
);
},
child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")),
),
if (event.content case MessageContent(:final body?))
PopupMenuItem(
onTap: () async {
await Clipboard.setData(ClipboardData(text: body));
},
child: ListTile(
leading: Icon(Icons.copy),
title: Text("Copy Text"),
),
),
if (ref.watch(
PowerLevelController.provider(
.redaction(targetUser: event.sender, roomId: roomId),
),
))
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (_) {
final deleteReasonController = useTextEditingController();
return AlertDialog(
title: Text("Delete Message"),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(
"Are you sure you want to delete this message? This can not be reversed.",
),
SizedBox(height: 12),
TextField(
controller: deleteReasonController,
textCapitalization: .sentences,
decoration: .new(
labelText: "Reason for deletion (optional)",
),
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await notifier
.deleteMessage(
event,
reason: deleteReasonController.text,
)
.onError(showError);
},
child: Text("Delete"),
),
],
);
},
),
),
child: ListTile(
leading: Icon(Icons.delete, color: danger),
title: Text("Delete", style: .new(color: danger)),
),
),
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (_) {
final reasonController = useTextEditingController();
return AlertDialog(
title: Text("Report"),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(
"Report this event to your server administrators, who can take action like banning this server or room.",
),
SizedBox(height: 12),
TextField(
controller: reasonController,
textCapitalization: .sentences,
decoration: .new(
labelText: "Reason for report (optional)",
),
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () {
client.reportEvent(
.new(
roomId: roomId,
eventId: event.eventId,
reason: reasonController.text.isEmpty
? null
: reasonController.text,
),
);
Navigator.of(context).pop();
},
child: Text("Report"),
),
],
);
},
),
),
child: ListTile(
leading: Icon(Icons.report, color: danger),
title: Text("Report", style: .new(color: danger)),
),
),
].toIList();
}
return Scaffold(
endDrawer: PinnedEventsDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToId: jumpToId,
),
body: Builder(
builder: (middleContext) => Scaffold(
endDrawer: showMembersByDefault ? null : MemberList(roomId),
appBar: RoomAppbar(
roomId: roomId,
isDesktop: isDesktop,
onOpenDrawer: Scaffold.of(context).openDrawer,
onOpenMemberList: (thisContext) {
ref
.watch(MemberListOpenedController.provider.notifier)
.set(!memberListOpened);
Scaffold.of(thisContext).openEndDrawer();
},
onOpenPinnedMessagesList: () {
Scaffold.of(middleContext).openEndDrawer();
},
),
body: Row(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncData(:final value?) ||
AsyncLoading(:final value?) => CustomScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
controller: scrollController,
slivers: [
if (hasMore.value)
SliverToBoxAdapter(
child: Padding(
padding: .symmetric(vertical: 36),
child: Center(
child: ElevatedButton(
onPressed: controllerData is AsyncData
? loadOlder
: null,
child: Text("Load More"),
),
),
),
),
SuperSliverList.builder(
listController: listController.value,
itemCount: value.length,
itemBuilder: (_, index) {
final event = value[index];
final previousEvent = value.getOrNull(
index - 1,
);
return HighlightWrapper(
EventRenderer(
event,
onTapReply: () =>
jumpToId(event.replyTo!),
getEventOptions: getEventOptions,
isGrouped:
previousEvent?.content
is MessageContent &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType !=
"m.replace" &&
"${event.sender}${event.pmp?.id}" ==
"${previousEvent?.sender}${previousEvent?.pmp?.id}",
),
isHighlighted:
highlightedEvent.value == event.eventId,
);
},
),
SliverPadding(
padding: .only(bottom: composerSize.value),
),
],
),
AsyncData() => nothing,
AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) =>
ErrorDialog(error, stackTrace),
},
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) => composerSize.value = size.height,
child: Composer(
roomId,
node: composerNode,
onSend:
(text, {required shouldMention, required tags}) =>
notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relation: relatedEvent.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () => relatedEvent.value = null,
),
),
),
],
),
),
if (memberListOpened == true && showMembersByDefault)
MemberList(roomId),
],
),
),
),
);
}
}

View file

@ -0,0 +1,65 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const ChatTimeline({
required final String roomId,
required final ChatScroll scroll,
required final JumpToEvent jumpToEvent,
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final int? highlightedEvent,
required final double composerHeight,
super.key,
}) extends StatelessWidget {
Widget eventRow(
int eventRowId,
int? previousEventRowId, {
required Key key,
}) => HighlightWrapper(
EventRenderer(
eventRowId,
previousEventRowId: previousEventRowId,
roomId: roomId,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
),
key: key,
isHighlighted: highlightedEvent == eventRowId,
);
@override
Widget build(BuildContext context) => CustomScrollView(
reverse: true,
center: scroll.centerKey,
keyboardDismissBehavior: .onDrag,
controller: scroll.scrollController,
slivers: [
SliverToBoxAdapter(child: SizedBox(height: composerHeight)),
SuperSliverList.builder(
itemCount: scroll.liveRows.length,
itemBuilder: (_, index) => eventRow(
scroll.liveRows[index],
index > 0
? scroll.liveRows.getOrNull(index - 1)
: scroll.historyRows.firstOrNull,
key: scroll.keyFor(scroll.liveRows[index]),
),
),
SuperSliverList.builder(
key: scroll.centerKey,
itemCount: scroll.historyRows.length,
itemBuilder: (_, index) => eventRow(
scroll.historyRows[index],
scroll.historyRows.getOrNull(index + 1),
key: scroll.keyFor(scroll.historyRows[index]),
),
),
],
);
}

View file

@ -0,0 +1,282 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:measure_size/measure_size.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/member_list_opened.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/composer.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/pinned_events_drawer.dart";
import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/room_chat/chat_timeline.dart";
import "package:nexus/helpers/extensions/build_event_options.dart";
import "package:nexus/controllers/jump_request.dart";
final class const RoomChat({
required final String? roomId,
required final bool isDesktop,
required final bool showMembersByDefault,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final relatedEvent = useState<int?>(null);
final relationType = useState(RelationType.reply);
final pendingJump = ref.read(JumpRequestController.provider);
final contextualEvent = useState<int?>(
pendingJump != null && pendingJump.roomId == this.roomId
? pendingJump.rowId
: null,
);
final highlightedEvent = useState<int?>(null);
final composerSize = useState<double>(64);
final userId = ref.watch(ClientStateController.provider)?.userId;
final memberListOpened = ref
.watch(MemberListOpenedController.provider)
.requireValue;
final theme = Theme.of(context);
final nothing = Center(
child: Text(
"Nothing to see here...",
style: theme.textTheme.headlineMedium,
),
);
if (userId == null || this.roomId == null) {
return Scaffold(
appBar: RoomAppbar(
roomId: this.roomId,
isDesktop: isDesktop,
onOpenDrawer: Scaffold.of(context).openDrawer,
),
body: nothing,
);
}
final roomId = this.roomId!;
final jumpRowId = ref.watch(
JumpRequestController.provider.select(
(request) => request?.roomId == roomId ? request?.rowId : null,
),
);
final controllerProvider = RoomChatController.provider((
roomId: roomId,
contextualEvent: contextualEvent.value,
));
final notifier = ref.watch(controllerProvider.notifier);
final client = ref.read(ClientController.provider.notifier);
final controllerData = ref.watch(controllerProvider);
final scroll = ChatScroll.use(
controllerData: controllerData,
paginate: notifier.paginate,
contextualEvent: contextualEvent,
markRead: () async {
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
if (room != null) {
await client.markRead(room);
}
},
);
final lastComposerHeight = useRef(composerSize.value);
useEffect(() {
final delta = composerSize.value - lastComposerHeight.value;
lastComposerHeight.value = composerSize.value;
if (delta != 0) {
final wasAtBottom = scroll.atBottom;
WidgetsBinding.instance.addPostFrameCallback((_) {
final controller = scroll.scrollController;
if (!controller.hasClients) return;
final position = controller.position;
controller.jumpTo(
wasAtBottom ? position.minScrollExtent : position.pixels - delta,
);
});
}
return null;
}, [composerSize.value]);
final composerNode = useFocusNode(
onKeyEvent: (_, event) {
if (event is KeyDownEvent && event.logicalKey == .escape) {
relatedEvent.value = null;
return .handled;
}
return .ignored;
},
);
Future<void> jumpToEvent(int rowId) async {
highlightedEvent.value = rowId;
await scroll.jumpToEvent(rowId);
await Future.delayed(.new(milliseconds: 700), () {
if (highlightedEvent.value == rowId) {
highlightedEvent.value = null;
}
});
}
useEffect(() {
if (jumpRowId == null) return null;
void check() {
if (!context.mounted) return;
if (scroll.scrollController.hasClients) {
jumpToEvent(jumpRowId);
Future.microtask(
() => ref.read(JumpRequestController.provider.notifier).consume(),
);
} else {
WidgetsBinding.instance.addPostFrameCallback((_) => check());
}
}
check();
return null;
}, [jumpRowId]);
IList<PopupMenuEntry> getEventOptions(Event event) =>
event.buildEventOptions(
context: context,
ref: ref,
roomId: roomId,
userId: userId,
onRelation: (rowId, type) {
relatedEvent.value = rowId;
relationType.value = type;
composerNode.requestFocus();
},
);
return Scaffold(
endDrawer: PinnedEventsDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToEvent: jumpToEvent,
),
body: Builder(
builder: (middleContext) => Scaffold(
endDrawer: showMembersByDefault ? null : MemberList(roomId),
appBar: RoomAppbar(
roomId: roomId,
isDesktop: isDesktop,
onOpenDrawer: Scaffold.of(context).openDrawer,
onOpenMemberList: (thisContext) {
ref
.read(MemberListOpenedController.provider.notifier)
.set(!memberListOpened);
Scaffold.of(thisContext).openEndDrawer();
},
onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer,
),
body: Row(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncLoading(value: _?) ||
AsyncData(value: _) => ChatTimeline(
scroll: scroll,
roomId: roomId,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent.value,
composerHeight: composerSize.value,
),
AsyncLoading _ => Loading(),
AsyncError(:final error, :final stackTrace) =>
ErrorDialog(error, stackTrace),
},
),
),
Positioned(
right: 16,
bottom: composerSize.value,
child: IgnorePointer(
ignoring: scroll.atBottom,
child: AnimatedOpacity(
opacity: scroll.atBottom ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: FloatingActionButton.small(
onPressed: scroll.jumpToBottom,
child: const Icon(Icons.keyboard_arrow_down),
),
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) => composerSize.value = size.height,
child: Composer(
roomId,
node: composerNode,
onSend:
(text, {required shouldMention, required tags}) =>
notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relatedEventRowId: relatedEvent.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () {
relatedEvent.value = null;
composerNode.requestFocus();
},
),
),
),
],
),
),
if (memberListOpened == true && showMembersByDefault)
MemberList(roomId),
],
),
),
),
);
}
}

318
lib/widgets/room_list.dart Normal file
View file

@ -0,0 +1,318 @@
import "dart:math";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
import "package:nexus/models/nav_page.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_widget.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/join_dialog.dart";
import "package:nexus/widgets/room_menu_button.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/spaces.dart";
import "package:nexus/models/room.dart";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
// Needed for navigation_rail_m3e (#65).
import "package:flutter/material.dart" as old_mat;
List<NavigationRailM3EDestination> roomDestinations(
IList<Room> rooms, {
required bool isDm,
}) => [
for (final room in rooms)
.new(
label: room.metadata?.name ?? "Unnamed Room",
badgeCount: switch (room.metadata?.unreadNotifications) {
0 || null => room.metadata?.unreadMessages == 0 ? null : 0,
int unread => unread,
},
icon: AvatarOrHash(
room.metadata?.avatar,
room.metadata?.name ?? "Unnamed Room",
fallback: isDm ? null : const Icon(Icons.numbers),
),
),
];
class const RoomList({super.key}) extends ConsumerWidget implements NavPage {
@override
String get title => "Rooms";
@override
IconData get icon => Icons.list;
// TODO: Show unread badge here and on sidebar hamburger
@override
Future<int>? badgeBuilder(WidgetRef ref) => null;
@override
Widget build(BuildContext context, WidgetRef ref) {
final spacesAsync = ref.watch(SpacesController.provider);
if (spacesAsync.value == null) {
return switch (spacesAsync) {
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
_ => const Center(child: CircularProgressIndicator()),
};
}
return const Row(
children: [
SpacesRail(),
Expanded(child: RoomsPane()),
],
);
}
}
class const SpacesRail({super.key}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final spaces = ref.watch(
SpacesController.provider.select((async) => async.requireValue),
);
final selectedSpaceId = ref.watch(
KeyController.provider(KeyController.spaceKey)
.select((async) => async.requireValue),
);
return MaterialUiCompatibilityBridge(
child: Builder(
builder: (context) => old_mat.Theme(
data: old_mat.Theme.of(context).copyWith(
extensions: [
NavigationRailM3ETheme(
itemCollapsedHeight: 48,
itemVerticalGap: 0,
),
],
),
child: Container(
color: NavigationRailTokensAdapter(context).containerColor,
padding: .only(top: 16),
child: NavigationRailM3E(
type: .alwaysCollapse,
labelBehavior: .alwaysHide,
scrollable: true,
onDestinationSelected: (value) {
ref
.read(
KeyController.provider(KeyController.spaceKey).notifier,
)
.set(spaces[value].id);
ref
.read(
KeyController.provider(KeyController.roomKey).notifier,
)
.set(spaces[value].children.firstOrNull?.metadata?.id);
},
sections: [
.new(
destinations: spaces.map((space) {
final notifications = space.allChildRooms.fold(
0,
(sum, room) =>
sum + (room.metadata?.unreadNotifications ?? 0),
);
return NavigationRailM3EDestination(
badgeCount: notifications > 0
? notifications
: space.allChildRooms.any(
(room) => room.metadata?.unreadMessages != 0,
)
? 0
: null,
short: true,
icon: AvatarOrHash(
dimension: 28,
space.room?.metadata?.avatar,
fallback: space.icon == null ? null : Icon(space.icon),
space.title,
),
label: space.title,
);
}).toList(),
),
],
selectedIndex: max(
0,
spaces.indexWhere((space) => space.id == selectedSpaceId),
),
trailingAtBottom: true,
trailing: Padding(
padding: .only(top: 8),
child: Column(
children: [
PopupMenuButton(
itemBuilder: (_) => [
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (_) => JoinDialog(ref),
),
child: const ListTile(
title: Text("Join an existing room (or space)"),
leading: Icon(Icons.numbers),
),
),
const PopupMenuItem(
onTap: null,
child: ListTile(
title: Text("Create a new room"),
leading: Icon(Icons.add),
),
),
],
icon: const Icon(Icons.add),
),
const IconButton(
tooltip: "Explore other rooms",
onPressed: null,
icon: Icon(Icons.explore),
),
IconButton(
tooltip: "Open settings",
onPressed: () => showDialog(
context: context,
builder: (_) => SettingsPage(),
),
icon: const Icon(Icons.settings),
),
],
),
),
),
),
),
),
);
}
}
class const RoomsPane({super.key}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedSpaceId = ref.watch(
KeyController.provider(KeyController.spaceKey)
.select((async) => async.requireValue),
);
final selectedRoomId = ref.watch(
KeyController.provider(KeyController.roomKey)
.select((async) => async.requireValue),
);
final selectedSpace = ref.watch(
SpacesController.provider.select((async) {
final spaces = async.requireValue;
return spaces.firstWhereOrNull(
(space) => space.id == selectedSpaceId,
) ??
spaces.first;
}),
);
final isDm = selectedSpace.id == "dms";
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
leading: AvatarOrHash(
selectedSpace.room?.metadata?.avatar,
fallback: selectedSpace.icon == null
? null
: Icon(selectedSpace.icon),
selectedSpace.title,
),
title: Text(selectedSpace.title, overflow: .ellipsis),
backgroundColor: Colors.transparent,
actions: [
RoomMenuButton(
selectedSpace.room,
children: selectedSpace.allChildRooms,
),
],
),
body: MaterialUiCompatibilityBridge(
child: Builder(
builder: (context) => old_mat.Theme(
data: old_mat.Theme.of(context).copyWith(
extensions: [
NavigationRailM3ETheme(
itemExpandedHeight: 48,
iconLabelGap: 16,
),
],
),
child: NavigationRailM3E(
expandedWidth: double.infinity,
scrollable: true,
background: Colors.transparent,
type: .alwaysExpand,
selectedIndex: max(
0,
selectedSpace.allChildRooms.indexWhere(
(room) => room.metadata?.id == selectedRoomId,
),
),
sections: [
.new(
header: selectedSpace.room == null
? null
: const DividerWidget(Text("Rooms")),
destinations: roomDestinations(
selectedSpace.children,
isDm: isDm,
),
),
for (final subSpace in selectedSpace.subSpaces)
.new(
header: DividerWidget(
Row(
mainAxisSize: .min,
spacing: 8,
children: [
if (subSpace.room.metadata?.avatar != null)
AvatarOrHash(
subSpace.room.metadata?.avatar,
subSpace.room.metadata?.name ?? "Unnamed Room",
dimension: 16,
),
Flexible(
child: Text(
subSpace.room.metadata?.name ?? "Unnamed Space",
maxLines: 1,
overflow: .ellipsis,
),
),
],
),
),
destinations: roomDestinations(
subSpace.children,
isDm: isDm,
),
),
],
onDestinationSelected: (value) {
ref
.read(
KeyController.provider(KeyController.roomKey).notifier,
)
.set(selectedSpace.allChildRooms[value].metadata?.id);
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
},
),
),
),
),
);
}
}

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