Compare commits

..
109 changed files with 2553 additions and 4173 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: Lix GHA Installer Action
uses: samueldr/lix-gha-installer-action@v2026-02-22
with:
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
- name: Decode keystore
run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks
env:
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- name: Decode keystore
run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks
env:
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- name: Build app
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64"
env:
KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
- name: Build app
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64 --dart-define=GIT_COMMIT=${{ github.sha }}"
env:
KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: APK
path: build/app/outputs/flutter-apk/app-release.apk
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: APK
path: build/app/outputs/flutter-apk/app-release.apk

View file

@ -1,73 +1,37 @@
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
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
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: flatpak-${{ matrix.arch }}
path: result/nexus.federated.nexus.flatpak

View file

@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.4
flutter-version: 3.47.0
- 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 --no-codesign --dart-define=GIT_COMMIT=${{ github.sha }}
flutter build ios --release --no-codesign
# Unsigned IPA: SideStore/AltStore re-sign this on-device with the
# 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.4
flutter-version: 3.47.0
- 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 --dart-define=GIT_COMMIT=${{ github.sha }}
flutter build macos --release
- 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.4
flutter-version: 3.47.0
- name: Set up Go
uses: actions/setup-go@v6
@ -29,7 +29,7 @@ jobs:
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: CLANG64
msystem: MINGW64
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 --dart-define=GIT_COMMIT=${{ github.sha }}
flutter build windows --release
- name: Copy MinGW runtime DLLs
shell: msys2 {0}
@ -52,15 +52,6 @@ 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,6 +41,4 @@ key.properties
password.txt
# Nix
/result
# Widget Preview related
.widget_preview/
/result

View file

@ -10,7 +10,6 @@
"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.read(MyController.provider.notifier).helperMethod()
ref.watch(MyController.provider.notifier).helperMethod()
```
We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g.

View file

@ -17,20 +17,15 @@ 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
- [ ] Background notifications
- Currently has some bugs, due to us not having any developers on Windows. If you want to fix these, get in contact with us though the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus).
- [x] Android
- [x] 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
@ -73,7 +68,9 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] URL Previews
- [x] Replies
- [x] Viewing
- [x] Jump to original message
- [ ] Jump to original message
- [x] In loaded timeline
- [ ] Out of loaded timeline
- [x] Edits
- [x] Attachments
- [x] Unencrypted
@ -86,10 +83,12 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Users
- [x] Clickable
- [x] Rooms
- [x] Clickable
- [ ] Clickable
- [x] Matrix URIs
- [x] Matrix.to links
- [x] Events
- [ ] Render more nicely
- [ ] Clickable
- [x] Custom emojis/stickers
- [x] History loading
- [x] Editing
@ -125,45 +124,10 @@ If you want to try out Nexus, grab one of the following artifacts from CI:
- [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip)
- [Unsigned 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)
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.
- 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)
## Build it yourself
@ -171,7 +135,7 @@ This installs Nexus system-wide. With `enableNotifications`, it also installs KU
#### Linux
- On NixOS: Either use direnv and `direnv allow`, or `nix develop`
- On NixOS: Either use direnv and `direnv allow`, or `nix flake develop`
- On other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues.
#### Windows

View file

@ -6,9 +6,7 @@
<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.

Binary file not shown.

BIN
assets/fonts/Roboto.ttf Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/twim/oauth.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
assets/twim/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

24
flake.lock generated
View file

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

2
gomuks

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

View file

@ -6,22 +6,27 @@ import "dart:math";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/foundation.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";
@ -31,7 +36,6 @@ import "package:nexus/models/requests/oauth/exchange_token.dart";
import "package:nexus/models/requests/oauth/get_auth_url.dart";
import "package:nexus/models/requests/oauth/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";
@ -42,9 +46,9 @@ import "package:nexus/models/requests/set_membership.dart";
import "package:nexus/models/requests/set_state.dart";
import "package:nexus/models/requests/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";
@ -52,7 +56,6 @@ 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,
@ -61,17 +64,12 @@ 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>();
try {
GomuksSetEnv(keyPtr, valuePtr);
} finally {
calloc
..free(keyPtr)
..free(valuePtr);
}
GomuksSetEnv(keyPtr, valuePtr);
calloc
..free(keyPtr)
..free(valuePtr);
}
}
debugPrint("Initializing Gomuks...");
final handle = await Isolate.run(() {
final bufferPointer = GomuksConfig(
matrix: .new(
@ -83,15 +81,103 @@ class ClientController extends AsyncNotifier<int> {
try {
return GomuksInit(bufferPointer.ref);
} finally {
calloc
..free(bufferPointer.ref.base)
..free(bufferPointer);
calloc.free(bufferPointer);
}
});
ref.onDispose(() => GomuksDestroy(handle));
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();
return handle;
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");
}
Future<dynamic> callGomuksMethod(
@ -100,43 +186,31 @@ class ClientController extends AsyncNotifier<int> {
callback,
) async {
final bufferPointer = data.toGomuksBufferPtr();
final handle = await future;
final response = await Isolate.run(
() => callback(handle, bufferPointer.ref),
);
try {
final handle = await future;
calloc.free(bufferPointer);
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);
final json = response.buf.toJson();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw json;
}
return json;
}
Future<(Event, RoomMetadata)?> handlePush(Map<String, dynamic> data) async {
Future<Event?> handlePush(Map<String, dynamic> data) async {
final response = await callGomuksMethod(
data,
(handle, data) async => GomuksHandlePush(handle, data),
);
return response == null
? null
: (
Event.fromJson(response["event"]),
RoomMetadata.fromJson(response["room"]),
);
return response == null ? null : .fromJson(response);
}
Future<dynamic> _sendCommand(
dynamic _sendCommand(
String command, [
Map<String, dynamic> data = const {},
]) => callGomuksMethod(data, (handle, data) {
@ -158,7 +232,7 @@ class ClientController extends AsyncNotifier<int> {
final json = request.toJson();
final content = request.content.toJson();
return .fromJson(
return Event.fromJson(
await _sendCommand("send_event", {
...json,
"content": {
@ -196,6 +270,11 @@ 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?;
@ -233,17 +312,11 @@ 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 .new(profile: .new(id: userId));
return ProfileResponse(profile: .new(id: userId));
}
}
@ -274,11 +347,9 @@ class ClientController extends AsyncNotifier<int> {
Future<void> logout() => _sendCommand("logout");
Future<void> 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) return;
if (event == null || room.metadata == null) return;
await _sendCommand("mark_read", {
"room_id": room.metadata!.id,
@ -304,8 +375,9 @@ class ClientController extends AsyncNotifier<int> {
Future<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions"));
Future<Capabilities> getCapabilities() async =>
.fromJson((await _sendCommand("get_capabilities"))["capabilities"]);
Future<Capabilities> getCapabilities() async => Capabilities.fromJson(
(await _sendCommand("get_capabilities"))["capabilities"],
);
Future<Uri?> discoverHomeserver(Uri homeserver) async {
try {

View file

@ -16,28 +16,11 @@ class EventController(final GetEventRequest request)
(event) => event.eventId == request.eventId,
);
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;
return event ??
await ref
.watch(ClientController.provider.notifier)
.getEvent(request)
.onError((_, _) => null);
}
static final provider = AsyncNotifierProvider.family

View file

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

View file

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

View file

@ -27,7 +27,7 @@ class MembersController(final String roomId)
),
);
ref
await 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/widgets/notifications_list.dart";
import "package:nexus/pages/notifications.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
class NotificationController
@ -31,14 +31,12 @@ class NotificationController
macOS: darwin,
),
onDidReceiveNotificationResponse: (details) {
if (details.payload == null) return;
if (int.tryParse(details.payload!) case final rowId?) {
if (details.payload case final eventId?) {
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsList(
highlightedEvent: rowId,
builder: (_) => NotificationsPage(
highlightedEventId: eventId,
defaultToAllNotifications: true,
),
),
@ -57,17 +55,17 @@ class NotificationController
return;
}
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,
),
final eventId = call.arguments as String;
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsPage(
highlightedEventId: eventId,
defaultToAllNotifications: true,
),
);
}
),
);
}
});
@ -126,18 +124,9 @@ 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,93 +1,57 @@
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<(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,
),
),
),
),
),
);
class NotificationsController([final NotificationsRequest? request])
extends AsyncNotifier<IList<Event>> {
static const limit = 20;
@override
Future<IList<(int, String)>> build() async {
final client = ref.read(ClientController.provider.notifier);
Future<IList<Event>> build() async {
final client = ref.watch(ClientController.provider.notifier);
final (unreadType, roomId) = request;
final (unreadType, roomId) = request ?? (null, null);
final mentions = await client.getMentions(
return await client.getMentions(
.new(
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 lastNotification = currentNotifications.lastOrNull;
final lastTs = lastNotification == null
? null
: ref.watch(
RoomsController.provider.select(
(rooms) => rooms[lastNotification.$2]
?.events[lastNotification.$1]
?.timestamp,
),
);
final lastTs = currentNotifications.lastOrNull?.timestamp;
if (lastTs == null) return const .empty();
if (lastTs == null) return currentNotifications;
final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request;
final client = ref.watch(ClientController.provider.notifier);
final (unreadType, roomId) = request ?? (null, null);
final newNotifications = await client.getMentions(
.new(
maxTimestamp: lastTs,
unreadType: unreadType ?? .highlight,
limit: limit,
roomId: roomId,
),
);
storeEvents(newNotifications);
return currentNotifications.addAll(
newNotifications.map((event) => (event.rowId, event.roomId)),
);
return currentNotifications.addAll(newNotifications);
});
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
NotificationsController,
IList<(int, String)>,
NotificationsRequest
IList<Event>,
NotificationsRequest?
>(NotificationsController.new);
}

View file

@ -2,25 +2,26 @@ 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<int>> {
extends AsyncNotifier<IList<Event>> {
@override
Future<IList<int>> build() async {
Future<IList<Event>> 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))
.selectAsync((data) => data?.rowId),
.future,
),
),
)).nonNulls.toIList();
}
static final provider = AsyncNotifierProvider.family
.autoDispose<PinnedEventsController, IList<int>, String>(
.autoDispose<PinnedEventsController, IList<Event>, String>(
PinnedEventsController.new,
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import "package:collection/collection.dart";
import "dart:isolate";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart";
@ -8,35 +9,35 @@ class RoomsController extends Notifier<IMap<String, Room>> {
@override
IMap<String, Room> build() => .new();
void addState(
Future<void> addState(
String roomId,
IList<Event> state, {
bool isMembers = false,
}) => update(
}) async => update(
.new({
roomId: Room(
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
hasFetchedState: true,
hasFetchedMembers: isMembers,
state: .new(
state
.groupListsBy((event) => event.type)
.map(
(type, group) => .new(
type,
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
),
state: await Isolate.run(() {
final newState = state.fold<IMap<String, IMap<String, int>>>(
.new(),
(previousValue, stateEvent) => previousValue.add(
stateEvent.type,
(previousValue[stateEvent.type] ?? .new()).add(
stateEvent.stateKey!,
stateEvent.rowId,
),
),
),
);
return newState;
}),
),
}),
.new(),
);
void update(
IMap<String, Room> rooms, [
ISet<String> leftRooms = const ISet.empty(),
]) {
void update(IMap<String, Room> rooms, ISet<String> leftRooms) {
final merged = rooms.entries.fold(state, (acc, entry) {
final roomId = entry.key;
final incoming = entry.value;
@ -46,16 +47,13 @@ class RoomsController extends Notifier<IMap<String, Room>> {
roomId,
existing?.copyWith(
hasMore: incoming.hasMore,
clientSticky: incoming.resetClientSticky
? incoming.clientSticky
: (incoming.clientSticky.isEmpty == true
? existing.clientSticky
: existing.clientSticky.addAll(
incoming.clientSticky,
))
.removeWhere(
(rowId) => incoming.timeline.values.contains(rowId),
),
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
@ -67,7 +65,6 @@ 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,7 +9,6 @@ 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";
@ -77,38 +76,6 @@ 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.",
@ -285,19 +252,12 @@ class SettingsSectionsController
final colorScheme = Theme.of(context).colorScheme;
return M3EButton.icon(
onPressed: () async {
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)
await ref
.watch(UnifiedPushController.provider.notifier)
.deregister()
.onError(showError);
container.invalidate(InitCompleteController.provider);
await container
.read(ClientController.provider.notifier)
await ref
.watch(ClientController.provider.notifier)
.logout()
.onError(showError);
},

View file

@ -4,16 +4,15 @@ 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 AsyncNotifier<IList<Space>> {
class SpacesController extends Notifier<IList<Space>> {
@override
Future<IList<Space>> build() async {
IList<Space> build() {
final rooms = ref.watch(RoomsController.provider);
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
final spaceEdges = ref.watch(SpaceEdgesController.provider);
@ -78,10 +77,10 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
final spaces = topLevelSpaceIds.map(buildSpace).toIList();
final usedRooms = {
final usedRoomIds = {
for (final space in spaces) ...[
...space.children,
...space.subSpaces.expand((s) => s.children),
...space.children.map((r) => r.metadata?.id),
...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)),
],
}.nonNulls.toISet();
@ -90,9 +89,7 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
final otherRooms = rooms.entries
.where(
(e) =>
!usedRooms
.map((element) => element.metadata?.id)
.contains(e.key) &&
!usedRoomIds.contains(e.key) &&
!topLevelSpaceIds.contains(e.key) &&
!childrenById.containsKey(e.key),
)
@ -100,15 +97,6 @@ class SpacesController extends AsyncNotifier<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();
@ -155,7 +143,7 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
.toIList();
}
static final provider = AsyncNotifierProvider<SpacesController, IList<Space>>(
static final provider = NotifierProvider<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.read(ClientController.provider.notifier).getSpecVersions();
ref.watch(ClientController.provider.notifier).getSpecVersions();
static final provider =
AsyncNotifierProvider.autoDispose<

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,21 +1,20 @@
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 toBytesAndFree() {
Uint8List toBytes() {
try {
if (base == nullptr || length <= 0) return .new(0);
return .fromList(base.asTypedList(length));
} finally {
GomuksFreeBuffer(this);
calloc.free(base);
}
}
dynamic toJsonAndFree() => jsonDecode(utf8.decode(toBytesAndFree()));
dynamic toJson() => jsonDecode(utf8.decode(toBytes()));
}
extension JsonToGomuksBuffer on Map<String, dynamic> {

View file

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

View file

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

View file

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

View file

@ -1,6 +1,11 @@
import "package:flutter/foundation.dart";
const sil = """This Font Software is licensed under the SIL Open Font License, Version 1.1.
const fontLicenses = [
LicenseEntryWithLineBreaks(
["Noto Color Emoji"],
"""Copyright 2021 Google Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
@ -90,426 +95,102 @@ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
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.""";
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
""",
OTHER DEALINGS IN THE FONT SOFTWARE.""",
),
.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.
LicenseEntryWithLineBreaks(
["Roboto"],
"""Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
$sil""",
),
.new(
["Atkinson Hyperlegible Mono"],
"""Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono)
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""",
-----------------------------------------------------------
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.""",
),
];

View file

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

View file

@ -3,10 +3,9 @@ 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";
@ -15,10 +14,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/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/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/appbar.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
@ -43,7 +42,15 @@ New Value: ${newValue is AsyncData ? newValue.value : newValue}
}
void showError(Object error, [StackTrace? stackTrace]) {
if (error.toString().contains("'_nextFrame != null': is not true.")) {
if (error.toString().contains("DioException") ||
error.toString().contains(
"setState() or markNeedsBuild() called during build.",
) ||
error.toString().contains("Invalid source") ||
error.toString().contains("UTF-16") ||
error.toString().contains("HTTP request failed") ||
error.toString().contains("'_nextFrame != null': is not true.") ||
error.toString().contains("Invalid image data")) {
return;
}
@ -64,34 +71,33 @@ void main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
LicenseRegistry.addLicense(() => .fromIterable(fontLicenses));
FlutterError.onError = (FlutterErrorDetails details) =>
showError(details.exception.toString(), details.stack);
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");
if (isInBackground) {
await ProviderContainer()
.read(UnifiedPushController.provider.future)
.timeout(Duration(seconds: 5));
LicenseRegistry.addLicense(() => .fromIterable(fontLicenses));
await Future.delayed(Duration(seconds: 10));
FlutterError.onError = (FlutterErrorDetails details) =>
showError(details.exception.toString(), details.stack);
if (isInBackground) {
await ProviderContainer().read(UnifiedPushController.provider.future);
// In case it didn't exit for some reason
await Future.delayed(Duration(seconds: 20));
exit(0);
} else {
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,
@ -141,61 +147,57 @@ class const App({super.key}) extends StatelessWidget {
),
home: child,
),
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,
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),
),
]),
),
)) {
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,
),
},
),
body: Loading(),
),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
),
),
),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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,
final int limit = 20,
required final int limit,
final String? roomId,
}) with _$GetMentionsRequest {
Map<String, Object?> toJson() => _$GetMentionsRequestToJson(this);

View file

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

View file

@ -33,8 +33,7 @@ class const Mentions({
_$MentionsFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
@Freezed(toJson: 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,
required final MembershipAction action,
@JsonKey(name: "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,28 +12,17 @@ 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(),
@ -43,6 +32,9 @@ 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(

View file

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

View file

@ -9,6 +9,9 @@ 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") final int? joinedMembers,
@JsonKey(name: "num_joined_members") required final int joinedMembers,
final JoinRule? joinRule,
final String? name,
final Uri? avatarUrl,

View file

@ -10,8 +10,6 @@ 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

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

View file

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

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/widgets/pages/settings.dart";
import "package:nexus/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.titleLarge,
style: theme.textTheme.headlineMedium,
overflow: .ellipsis,
),
],
@ -161,7 +161,6 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
child: TextField(
textInputAction: .done,
autofocus: true,
keyboardType: .url,
onSubmitted: (text) => setHomeserver(.tryParse(text)),
controller: homeserverUrl,
decoration: .new(
@ -194,13 +193,12 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
url: .https("federated.nexus"),
iconUrl: "https://federated.nexus/images/icon.png",
),
// 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",
// ),
.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/widgets/pages/settings_category.dart";
import "package:nexus/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/widgets/pages/settings.dart";
import "package:nexus/pages/settings.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/helpers/required_validator_helper.dart";
@ -41,8 +41,6 @@ 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

@ -51,17 +51,6 @@ 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,24 +7,25 @@ final class const AvatarOrHash(
final Uri? avatar,
final String title, {
final Widget? fallback,
final double dimension = 24,
final double height = 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: dimension / 2)),
child: Center(child: Icon(Icons.person, size: height / 2)),
);
return SizedBox(
width: dimension,
height: dimension,
width: height,
height: height,
child: Center(
child: ClipRRect(
borderRadius: .all(.circular((dimension - 8) / 2.5)),
child: SizedBox.square(
dimension: dimension,
borderRadius: .all(.circular((height - 8) / 2.5)),
child: SizedBox(
width: height,
height: height,
child: avatar == null
? fallback ?? box
: Image(

View file

@ -2,7 +2,6 @@ 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";
@ -13,6 +12,7 @@ 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 int? relatedEvent,
required final Event? relatedEvent,
required final RelationType relationType,
required final VoidCallback onDismiss,
required final Future<void> Function(
@ -35,57 +35,19 @@ class const Composer(
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final controller = useMemoized(FlutterTaggerController.new);
final controller = useRef(FlutterTaggerController());
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;
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]);
if (relationType == .edit && controller.value.text.isEmpty) {
controller.value.text =
relatedEvent?.localContent?.editSource ??
switch (relatedEvent?.content) {
TextMessageContent(:final body) => body,
_ => "",
};
}
final attachment = ref.watch(AttachmentController.provider(roomId));
@ -95,13 +57,13 @@ class const Composer(
return;
}
onSend(
controller.formattedText,
controller.value.formattedText,
shouldMention: shouldMention.value,
tags: .new(controller.tags),
tags: .new(controller.value.tags),
);
onDismiss();
controller.text = "";
controller.value.text = "";
}
final style = TextStyle(
@ -146,7 +108,9 @@ class const Composer(
),
Container(
color: theme.colorScheme.surfaceContainerHighest,
padding: .symmetric(horizontal: 8),
child: Row(
spacing: 8,
mainAxisAlignment: .center,
children:
ref.watch(
@ -162,7 +126,7 @@ class const Composer(
builder: (context) => EmojiPicker(
onSelection: (value) {
Navigator.of(context).pop();
controller.text += value;
controller.value.text += value;
node?.requestFocus();
},
),
@ -180,7 +144,7 @@ class const Composer(
leading: Icon(Icons.add_a_photo),
),
onTap: () async => ref
.read(
.watch(
AttachmentController.provider(roomId)
.notifier,
)
@ -199,7 +163,7 @@ class const Composer(
leading: Icon(Icons.add_photo_alternate),
),
onTap: () async => ref
.read(
.watch(
AttachmentController.provider(roomId)
.notifier,
)
@ -228,7 +192,6 @@ class const Composer(
],
icon: Icon(Icons.add),
),
SizedBox(width: 8),
Expanded(
child: FlutterTagger(
triggerStrategy: .eager,
@ -237,11 +200,11 @@ class const Composer(
roomId: roomId,
triggerCharacter: triggerCharacter.value,
addTag: ({required id, required name}) {
controller.addTag(id: id, name: name);
controller.value.addTag(id: id, name: name);
node?.requestFocus();
},
),
controller: controller,
controller: controller.value,
onSearch: (newQuery, newTriggerCharacter) {
triggerCharacter.value = newTriggerCharacter;
query.value = newQuery;
@ -271,16 +234,17 @@ class const Composer(
child: TextField(
maxLines: 12,
minLines: 1,
textCapitalization: .sentences,
autofocus:
(Platform.isLinux ||
Platform.isMacOS ||
Platform.isWindows),
Platform.isMacOS ||
Platform.isWindows)
? true
: false,
decoration: .new(
hintText: "Your message here...",
border: .none,
),
controller: controller,
controller: controller.value,
key: key,
focusNode: node,
),

View file

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

View file

@ -13,23 +13,18 @@ class const EventPreview(final Event event, {super.key})
padding: .symmetric(vertical: 4),
child: Row(
mainAxisSize: .min,
spacing: 8,
spacing: 12,
children: [
if (event.content is MessageContent) MessageAvatar(event, height: 36),
if (event.content is MessageContent) MessageAvatar(event),
Flexible(
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .start,
child: Wrap(
crossAxisAlignment: .center,
spacing: 8,
runSpacing: 2,
children: [
if (event.content is MessageContent)
MessageDisplayname(event, shouldWrap: false),
EventRenderer(
event.rowId,
roomId: event.roomId,
textOnly: true,
maxLines: 1,
),
if (event.content is MessageContent) MessageDisplayname(event),
EventRenderer(event, 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: "bundled-mono"),
style: .new(fontFamily: "monospace"),
),
),
TextButton.icon(
@ -44,7 +44,7 @@ class const CodeBlock(
code,
minLines: 1,
maxLines: 99,
style: .new(fontFamily: "bundled-mono"),
style: .new(fontFamily: "monospace"),
),
),
),

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/get_link_info.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/helpers/mxc_image.dart";
import "package:nexus/widgets/expandable_image.dart";
@ -51,28 +51,14 @@ class const Html(
element.text,
lang: element.className.replaceAll("language-", ""),
)
: 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"),
),
),
),
),
: null,
"blockquote" => Quoted(
Html(element.innerHtml, textStyle: textStyle, roomId: roomId),
),
"a" =>
element.attributes["href"]?.linkInfo == null
element.attributes["href"]?.mention == null
? null
: InlineCustomWidget(
child: MentionChip(element.attributes["href"]!, roomId),

View file

@ -1,21 +1,17 @@
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/focus_room.dart";
import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/helpers/extensions/link_to_mention.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 linkInfo = content.linkInfo;
final mention = linkInfo?.identifier;
final mention = content.mention;
final data = switch (mention?.characters.firstOrNull) {
"@" =>
ref
@ -26,11 +22,10 @@ class const MentionChip(final String content, final String? roomId, {super.key})
"#" || "!" =>
ref
.watch(
RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
)
.whenOrNull(data: (data) => data) ??
RoomSummary(roomId: mention),
.watch(
RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
)
.whenOrNull(data: (data) => data),
_ => null,
};
@ -38,49 +33,19 @@ class const MentionChip(final String content, final String? roomId, {super.key})
return mention == null
? SizedBox.shrink()
: InkWell(
onTap: () async {
onTap: () {
if (data case MembershipContent membership) {
context.showUserPopover(membership, mention, roomId: roomId);
} else if (data case RoomSummary 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,
),
),
);
}
// TODO: Handle summary
}
},
child: Text(
switch (data) {
RoomSummary summary =>
"${(summary.name == null ? null : "#${summary.name}") ?? summary.canonicalAlias ?? summary.roomId}${linkInfo?.eventId == null ? "" : " > ${linkInfo?.eventId}"}",
(summary.name == null ? null : "#${summary.name}") ??
summary.canonicalAlias ??
summary.roomId,
MembershipContent membership =>
membership.displayName == null
? mention

View file

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

View file

@ -1,40 +1,16 @@
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/room_summary.dart";
import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/widgets/room_summary_dialog.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";
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(
@ -45,14 +21,111 @@ class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
SizedBox(height: 12),
TextField(
controller: roomAlias,
decoration: .new(hintText: "#room:server.name"),
onEditingComplete: onJoin,
decoration: .new(hintText: "#room:server"),
),
],
),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
TextButton(onPressed: onJoin, child: Text("Join")),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
if (context.mounted) {
final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text;
final scaffoldMessenger = ScaffoldMessenger.of(context);
final snackbar = scaffoldMessenger.showSnackBar(
.new(
content: Text("Joining room $roomIdOrAlias."),
duration: Duration(days: 999),
),
);
try {
final id = await ref
.watch(ClientController.provider.notifier)
.joinRoom(
.new(
roomIdOrAlias: roomIdOrAlias,
via: .new(
Uri.tryParse(roomAlias.text.replaceAll("/#", ""))
?.queryParametersAll["via"] ??
[],
),
),
);
snackbar.close();
scaffoldMessenger.showSnackBar(
.new(
content: Text("Room $roomIdOrAlias successfully joined."),
action: .new(
label: "Open",
onPressed: () async {
final spaces = ref.watch(SpacesController.provider);
final space = spaces.firstWhereOrNull(
(space) => space.id == id,
);
await ref
.watch(
KeyController.provider(KeyController.spaceKey)
.notifier,
)
.set(
space?.id ??
spaces
.firstWhere(
(space) =>
space.children.any(
(child) =>
child.metadata?.id == id,
) ||
space.subSpaces.any(
(child) =>
child.room.metadata?.id == id,
),
)
.id,
);
if (space == null) {
await ref
.watch(
KeyController.provider(KeyController.roomKey)
.notifier,
)
.set(id);
}
},
),
),
);
} catch (error) {
snackbar.close();
if (context.mounted) {
scaffoldMessenger.showSnackBar(
.new(
backgroundColor: Theme.of(context)
.colorScheme
.errorContainer,
content: Text(
error.toString(),
style: .new(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
);
}
}
}
},
child: Text("Join"),
),
],
);
}

View file

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

View file

@ -5,57 +5,50 @@ 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 MembershipContent? value) ||
AsyncLoading(:final value) ||
AsyncError(:final value) => InkWell(
onTap: clickable && value != null
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
onTap: clickable
? () => context.showUserPopover(
value,
event.sender,
roomId: event.roomId,
)
: null,
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)
child: Wrap(
spacing: 4,
crossAxisAlignment: .center,
children: [
Text(
"(via ${event.sender})",
style: Theme.of(context).textTheme.labelSmall
?.copyWith(color: event.sender.colorHash, fontWeight: .bold),
value.displayName ?? event.sender.localpart,
style:
style ?? .new(color: event.sender.colorHash, fontWeight: .bold),
maxLines: 1,
overflow: .ellipsis,
),
]) {
final children when shouldWrap => Wrap(
spacing: 4,
crossAxisAlignment: .center,
children: children,
),
final children => Row(
spacing: 4,
mainAxisSize: .min,
children: [for (final child in children) Flexible(child: child)],
),
},
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),
),
};
}

View file

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

View file

@ -7,7 +7,6 @@ import "package:m3e_card_list/m3e_card_list.dart";
import "package:nexus/controllers/members_by_status.dart";
import "package:nexus/controllers/members_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";
@ -15,6 +14,7 @@ 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,16 +123,13 @@ class const MemberList(final String roomId, {super.key})
members[index].stateKey!,
overflow: .ellipsis,
),
leading: Transform.translate(
offset: .new(0, 2),
child: AvatarOrHash(
avatarUrl,
dimension: 40,
displayName ??
members[index]
.stateKey!
.localpart,
),
leading: AvatarOrHash(
avatarUrl,
height: 36,
displayName ??
members[index]
.stateKey!
.localpart,
),
),
_ => throw Exception(
@ -143,10 +140,20 @@ class const MemberList(final String roomId, {super.key})
final member = members[index];
if (member.content
case MembershipContent content) {
context.showUserPopover(
content,
member.stateKey!,
roomId: roomId,
showModalBottomSheet(
constraints: .loose(
.new(
500,
(context.size?.height ?? 1000) - 80,
),
),
isScrollControlled: true,
context: context,
builder: (context) => UserBottomSheet(
content,
member.stateKey!,
roomId: roomId,
),
);
}
},

View file

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

View file

@ -2,7 +2,6 @@ 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";
@ -11,7 +10,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 JumpToEvent jumpToEvent,
required final Future<void> Function(String eventId) jumpToId,
super.key,
}) extends HookConsumerWidget {
@override
@ -57,23 +56,21 @@ final class const PinnedEventsDrawer(
reverse: true,
itemCount: value.length,
itemBuilder: (context, index) {
final rowId = value.reversed[index];
final event = value.reversed[index];
return InkWell(
borderRadius: .circular(12),
onTap: () {
Navigator.of(context).pop();
jumpToEvent(rowId);
jumpToId(event.eventId);
},
child: Padding(
padding: .symmetric(vertical: 4),
child: IgnorePointer(
child: EventRenderer(
rowId,
roomId: roomId,
maxLines: 2,
getEventOptions: getEventOptions,
),
child: EventRenderer(
event,
maxLines: 2,
isGrouped: false,
getEventOptions: getEventOptions,
),
),
);

View file

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

View file

@ -3,11 +3,7 @@ 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";
@ -32,12 +28,11 @@ import "package:nexus/widgets/renderers/generic_event.dart";
import "package:nexus/widgets/timestamp.dart";
class const EventRenderer(
final int eventRowId, {
required final String roomId,
final int? previousEventRowId,
final Event event, {
final bool textOnly = false,
final bool isGrouped = false,
final int? maxLines,
final JumpToEvent? jumpToEvent,
final VoidCallback? onTapReply,
final IList<PopupMenuEntry> Function(Event event)? getEventOptions,
super.key,
}) extends HookConsumerWidget {
@ -49,38 +44,6 @@ 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) {
@ -97,18 +60,7 @@ class const EventRenderer(
EncryptedContent() ||
StickerContent() => MessageRenderer(
event,
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);
}
},
onTapReply: onTapReply,
isGrouped: isGrouped,
maxLines: maxLines,
textOnly: textOnly,
@ -116,174 +68,85 @@ class const EventRenderer(
MembershipContent content => switch (event.previousContent) {
MembershipContent(:final status) =>
status == content.status
? null
: MembershipRenderer(event, maxLines: maxLines),
_ => MembershipRenderer(event, maxLines: maxLines),
status == content.status ? null : MembershipRenderer(event),
_ => MembershipRenderer(event),
},
AvatarContent() => GenericEventRenderer(
Icons.interests,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "changed the room avatar"),
],
),
AvatarContent() => GenericEventRenderer(Icons.interests, [
MessageDisplayname(event),
Text("changed the room avatar"),
]),
CreateContent() => GenericEventRenderer(
Icons.add,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "created the room"),
],
),
CreateContent() => GenericEventRenderer(Icons.add, [
MessageDisplayname(event),
Text("created the room"),
]),
PowerLevelsContent() => GenericEventRenderer(
Icons.power,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "changed the room's power levels"),
],
),
PowerLevelsContent() => GenericEventRenderer(Icons.power, [
MessageDisplayname(event),
Text("changed the room's power levels"),
]),
JoinRulesContent() => GenericEventRenderer(
Icons.rule,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "changed the room's join rules"),
],
),
JoinRulesContent() => GenericEventRenderer(Icons.rule, [
MessageDisplayname(event),
Text("changed the room's join rules"),
]),
TopicContent() => GenericEventRenderer(
Icons.description,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "updated the room topic"),
],
),
TopicContent() => GenericEventRenderer(Icons.description, [
MessageDisplayname(event),
Text("updated the room topic"),
]),
HistoryVisibilityContent(:final historyVisibility) =>
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)",
}}",
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)",
}}",
),
]),
PinnedEventsContent() => GenericEventRenderer(
Icons.push_pin,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "pinned/unpinned some events"),
],
),
PinnedEventsContent() => GenericEventRenderer(Icons.push_pin, [
MessageDisplayname(event),
Text("pinned/unpinned some events"),
]),
ServerACLContent() => GenericEventRenderer(
Icons.list,
maxLines: maxLines,
[
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(
event,
shouldWrap: maxLines == null,
),
),
TextSpan(text: "updated the server ban list"),
],
),
ServerACLContent() => GenericEventRenderer(Icons.list, [
MessageDisplayname(event),
Text("updated the server ban list"),
]),
CanonicalAliasContent(:final alias, :final altAliases) =>
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",
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",
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,
};
@ -298,7 +161,7 @@ class const EventRenderer(
return Column(
crossAxisAlignment: .start,
children: [
if (child != null)
if (child != null) ...[
if (textOnly)
child
else ...[
@ -357,39 +220,16 @@ class const EventRenderer(
if (event.sendError != null && event.sendError != "not sent")
Padding(
padding: .only(bottom: 4),
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),
),
],
child: Text(
event.sendError!,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error,
),
),
),
].map((child) => Padding(padding: .only(left: 4), child: child)),
]
else if (textOnly)
],
] else if (textOnly)
Text("Unknown event type", style: errorStyle),
],
);

View file

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

View file

@ -7,11 +7,8 @@ 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, {
final int? maxLines,
super.key,
}) extends StatelessWidget {
class const MembershipRenderer(final Event event, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
assert(
@ -20,45 +17,34 @@ class const MembershipRenderer(
);
return switch (event.content) {
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),
),
),
MembershipContent content => GenericEventRenderer(Icons.people, [
InkWell(
onTap: () => context.showUserPopover(
content,
event.stateKey!,
roomId: event.roomId,
),
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"}",
child: Text(
overflow: .ellipsis,
content.displayName ?? event.stateKey!.localpart,
maxLines: 1,
style: .new(color: event.sender.colorHash, fontWeight: .bold),
),
if (event.sender != event.stateKey)
WidgetSpan(
alignment: .middle,
child: MessageDisplayname(event, shouldWrap: maxLines == null),
),
if (content.reason != null)
TextSpan(text: "for \"${content.reason}\""),
],
),
),
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}\""),
]),
_ => 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,7 +14,6 @@ import "package:nexus/widgets/html/html.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_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";
@ -38,9 +37,7 @@ class const MessageRenderer(
final errorStyle = TextStyle(color: colorScheme.error);
final textStyle = TextStyle(
fontSize: event.localContent?.bigEmoji == true && maxLines != 1
? 32
: null,
fontSize: event.localContent?.bigEmoji == true ? 32 : null,
fontStyle: event.content is EmoteMessageContent ? .italic : null,
);
@ -55,102 +52,104 @@ class const MessageRenderer(
),
// TODO: Handle locations
// LocationMessageContent(:final body , :final geoUri) =>
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>";
},
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,
),
)
: LinkifiedText(
body.isEmpty ? content.filename ?? "" : body,
style: textStyle,
maxLines: maxLines,
(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, 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, 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 (!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 (event.lastEditRowId != 0)
Text("(edited)", style: theme.textTheme.labelSmall),
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 (linkify(body)
.firstWhereOrNull((element) => element is UrlElement)
case final UrlElement link?)
if (Uri.tryParse(link.url) case final Uri url?) UrlPreview(url),
],
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),
],
),
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),
],
),
},
],
),
MessageContent(: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: 6,
spacing: 8,
children: [
if (!textOnly)
if (isGrouped)
@ -159,7 +158,7 @@ class const MessageRenderer(
MessageAvatar(event, height: 40),
Flexible(
child: Column(
spacing: 2,
spacing: 4,
crossAxisAlignment: .start,
children: [
if (!isGrouped && !textOnly)
@ -199,31 +198,34 @@ class const MessageRenderer(
onTap: onTapReply,
child: Padding(
padding: .symmetric(vertical: 8, horizontal: 12),
child: SizedBox(
height: 48,
child: switch (ref.watch(
EventController.provider(
.new(
roomId: event.roomId,
eventId: event.replyTo!,
),
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,
),
_ => Loading(dimension: 12),
},
),
),
)) {
AsyncData(:final value?) ||
AsyncLoading(
:final value?,
) => EventPreview(value),
AsyncError _ => Text(
"An error occurred while fetching the reply",
style: errorStyle,
),
_ => Text("Fetching event..."),
},
),
),
),
rendered,
RendererBinding.instance.mouseTracker.mouseIsConnected
? SelectableRegion(
selectionControls: materialTextSelectionControls,
child: rendered,
)
: rendered,
if (!textOnly) ReactionRow(event),
],
),

View file

@ -3,8 +3,9 @@ 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/room_menu_button.dart";
import "package:nexus/widgets/room_summary_dialog.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/linkified_text.dart";
import "package:nexus/widgets/room_menu.dart";
final class const RoomAppbar({
required final String? roomId,
@ -24,19 +25,61 @@ final class const RoomAppbar({
: ref.watch(RoomsController.provider.select((value) => value[roomId!]));
return Appbar(
onTap: room?.metadata == null
onTap: room == null
? null
: () => showDialog(
context: context,
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,
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,
),
),
],
),
),
),
),
),
@ -46,7 +89,7 @@ final class const RoomAppbar({
: AvatarOrHash(
room.metadata?.avatar,
room.metadata?.name ?? "Unnamed Room",
dimension: 24,
height: 24,
fallback: Icon(Icons.numbers),
)
: DrawerButton(onPressed: onOpenDrawer),
@ -72,23 +115,21 @@ final class const RoomAppbar({
),
],
),
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),
],
),
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),
]),
);
}
}

592
lib/widgets/room_chat.dart Normal file
View file

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

View file

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

View file

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

View file

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

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