diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index a3abae1..b9b6207 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -1,39 +1,39 @@
name: "Build APK"
on:
- push:
- branches: ["main"]
- tags: ["*"]
- workflow_dispatch:
+ push:
+ branches: ["main"]
+ tags: ["*"]
+ workflow_dispatch:
jobs:
- build-apk:
- runs-on: ubuntu-latest
+ build-apk:
+ runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
- with:
- submodules: recursive
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ submodules: recursive
- - name: Lix GHA Installer Action
- uses: samueldr/lix-gha-installer-action@v2026-02-22
- with:
- extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
-
- - name: Decode keystore
- run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks
- env:
- KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
+ - name: Lix GHA Installer Action
+ uses: samueldr/lix-gha-installer-action@v2026-02-22
+ with:
+ extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
- - name: Build app
- run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64"
- env:
- KEYSTORE_PATH: ../../keystore.jks
- KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
+ - name: Decode keystore
+ run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks
+ env:
+ KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- - name: Upload installer artifact
- uses: actions/upload-artifact@v6
- with:
- name: APK
- path: build/app/outputs/flutter-apk/app-release.apk
\ No newline at end of file
+ - 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
diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml
index d280bc6..c255c8d 100644
--- a/.github/workflows/flatpak.yml
+++ b/.github/workflows/flatpak.yml
@@ -1,37 +1,73 @@
name: "Build Flatpaks"
on:
- push:
- branches: ["main"]
- tags: ["*"]
- workflow_dispatch:
+ push:
+ branches: ["main"]
+ tags: ["*"]
+ workflow_dispatch:
jobs:
- build-flatpak:
- strategy:
- fail-fast: false
- matrix:
- include:
- - arch: x86_64
- runner: ubuntu-latest
- - arch: aarch64
- runner: ubuntu-24.04-arm
- runs-on: ${{ matrix.runner }}
+ build-flatpak:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x86_64
+ runner: ubuntu-latest
+ - arch: aarch64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
- - name: Lix GHA Installer Action
- uses: samueldr/lix-gha-installer-action@v2026-02-22
- with:
- extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
+ - name: Lix GHA Installer Action
+ uses: samueldr/lix-gha-installer-action@v2026-02-22
+ with:
+ extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
- - name: Build app
- run: nix build .#flatpak
+ - name: Build app
+ run: nix build .#flatpak
- - name: Upload installer artifact
- uses: actions/upload-artifact@v6
- with:
- name: flatpak-${{ matrix.arch }}
- path: result/nexus.federated.nexus.flatpak
\ No newline at end of file
+ - 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
diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml
index e14f1bb..28344ea 100644
--- a/.github/workflows/ios.yml
+++ b/.github/workflows/ios.yml
@@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
- flutter-version: 3.47.0
+ flutter-version: 3.47.4
- name: Set up Go
uses: actions/setup-go@v6
@@ -31,7 +31,7 @@ jobs:
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
- flutter build ios --release --no-codesign
+ flutter build ios --no-codesign --dart-define=GIT_COMMIT=${{ github.sha }}
# Unsigned IPA: SideStore/AltStore re-sign this on-device with the
# user's own Apple ID at install time, so no certificate is needed here.
diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml
index 53c6746..b56f16f 100644
--- a/.github/workflows/macos.yml
+++ b/.github/workflows/macos.yml
@@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
- flutter-version: 3.47.0
+ flutter-version: 3.47.4
- name: Set up Go
uses: actions/setup-go@v6
@@ -31,7 +31,7 @@ jobs:
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
- flutter build macos --release
+ flutter build macos --dart-define=GIT_COMMIT=${{ github.sha }}
- name: Create DMG
id: create-dmg
diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
index 3ef3552..db339b9 100644
--- a/.github/workflows/windows.yml
+++ b/.github/workflows/windows.yml
@@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
- flutter-version: 3.47.0
+ flutter-version: 3.47.4
- name: Set up Go
uses: actions/setup-go@v6
@@ -29,7 +29,7 @@ jobs:
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
- msystem: MINGW64
+ msystem: CLANG64
install: >-
mingw-w64-x86_64-gcc
@@ -43,7 +43,7 @@ jobs:
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
- flutter build windows --release
+ flutter build windows --dart-define=GIT_COMMIT=${{ github.sha }}
- name: Copy MinGW runtime DLLs
shell: msys2 {0}
@@ -52,6 +52,15 @@ jobs:
cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
+ - name: Copy MSVC runtime DLLs
+ shell: powershell
+ run: |
+ $release = "build/windows/x64/runner/Release"
+
+ Copy-Item C:\Windows\System32\MSVCP140.dll $release -Force
+ Copy-Item C:\Windows\System32\VCRUNTIME140.dll $release -Force
+ Copy-Item C:\Windows\System32\VCRUNTIME140_1.dll $release -Force
+
- name: Install Inno Setup
run: choco install innosetup -y
diff --git a/.gitignore b/.gitignore
index 2bec583..912ced3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,4 +41,6 @@ key.properties
password.txt
# Nix
-/result
\ No newline at end of file
+/result
+# Widget Preview related
+.widget_preview/
diff --git a/.vscode/settings.json b/.vscode/settings.json
index fac2ed8..a5cac8a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -10,6 +10,7 @@
"msgtype",
"muks",
"prefs",
+ "recents",
"unban",
"unifiedpush",
"unredact",
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index fb69a56..d9c7c9d 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -21,7 +21,7 @@ See [Effective Dart: Style](https://dart.dev/effective-dart/style) for general r
Controllers live in `lib/controllers/` and provide a source that exposes data and logic via Riverpod providers, allowing other parts of the code to watch state changes with ref.watch (`ref.watch(MyController.provider)`), access the current value with ref.read (`ref.read(MyController.provider)`), and run helper methods on those classes using the notifier:
```dart
-ref.watch(MyController.provider.notifier).helperMethod()
+ref.read(MyController.provider.notifier).helperMethod()
```
We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g.
diff --git a/README.md b/README.md
index b6adc87..da4a6cb 100644
--- a/README.md
+++ b/README.md
@@ -17,15 +17,20 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Platform Support
- [x] Linux
+ - [x] Notifications through UnifiedPush
- [x] Windows
- - Currently has some bugs, due to us not having any developers on Windows. If you want to fix these, get in contact with us though the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus).
+ - [ ] Background notifications
- [x] Android
+ - [x] Notifications through UnifiedPush
+ - [x] Notifications through FCM
- [x] MacOS
- [x] Unsigned .ipa
- [ ] App Store
+ - [ ] Background notifications
- [x] iOS
- [x] Unsigned .ipa
- [ ] App Store
+ - [ ] Background notifications
- [ ] Web (may not be possible)
- [x] Login (via OAuth)
- [x] Rooms / Spaces
@@ -68,9 +73,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] URL Previews
- [x] Replies
- [x] Viewing
- - [ ] Jump to original message
- - [x] In loaded timeline
- - [ ] Out of loaded timeline
+ - [x] Jump to original message
- [x] Edits
- [x] Attachments
- [x] Unencrypted
@@ -83,12 +86,10 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Users
- [x] Clickable
- [x] Rooms
- - [ ] Clickable
+ - [x] Clickable
- [x] Matrix URIs
- [x] Matrix.to links
- [x] Events
- - [ ] Render more nicely
- - [ ] Clickable
- [x] Custom emojis/stickers
- [x] History loading
- [x] Editing
@@ -124,10 +125,45 @@ If you want to try out Nexus, grab one of the following artifacts from CI:
- [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip)
- [Unsigned MacOS DMG](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/nexus.dmg.zip)
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
-- Flatpak
- - [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip)
- - [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip)
-- [NixOS Module](linux/nix/module.nix)
+
+Or, use one of the below methods:
+
+### Flatpak
+
+Make sure Flatpak is set up on your system, then run:
+
+```bash
+flatpak install --user https://nexus.federated.nexus/flatpak/nexus.flatpakref
+```
+
+This adds the Nexus remote and installs the app for your user only, so no root access is needed (x86_64 and aarch64 are supported). To install system-wide instead, use `--system` in place of `--user`. Updates arrive through `flatpak update` or your software center.
+
+### NixOS Module
+
+Add Nexus as a flake input, following `nixpkgs` for less fetches:
+
+```nix
+nexus = {
+ url = "git+https://git.federated.nexus/Nexus/nexus";
+ inputs.nixpkgs.follows = "nixpkgs";
+};
+```
+
+Then import the module and enable it in your NixOS configuration:
+
+```nix
+{ inputs, ... }:
+{
+ imports = [ inputs.nexus.nixosModules.default ];
+
+ programs.nexus = {
+ enable = true;
+ enableNotifications = true; # optional: push notifications via UnifiedPush
+ };
+}
+```
+
+This installs Nexus system-wide. With `enableNotifications`, it also installs KUnifiedPush and registers its systemd and D-Bus services so notifications work. The `package` option lets you override the package if you need to.
## Build it yourself
@@ -135,7 +171,7 @@ If you want to try out Nexus, grab one of the following artifacts from CI:
#### Linux
-- On NixOS: Either use direnv and `direnv allow`, or `nix flake develop`
+- On NixOS: Either use direnv and `direnv allow`, or `nix develop`
- On other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues.
#### Windows
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a0a0bcc..d4dbebe 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -6,7 +6,9 @@
+
{
@override
Future build() async {
+ debugPrint("Setting Gomuks env...");
if (Platform.isAndroid || Platform.isIOS) {
final env = {
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
@@ -64,12 +61,17 @@ class ClientController extends AsyncNotifier {
for (final MapEntry(:key, :value) in env.entries) {
final keyPtr = key.toNativeUtf8().cast();
final valuePtr = value.toNativeUtf8().cast();
- GomuksSetEnv(keyPtr, valuePtr);
- calloc
- ..free(keyPtr)
- ..free(valuePtr);
+ try {
+ GomuksSetEnv(keyPtr, valuePtr);
+ } finally {
+ calloc
+ ..free(keyPtr)
+ ..free(valuePtr);
+ }
}
}
+
+ debugPrint("Initializing Gomuks...");
final handle = await Isolate.run(() {
final bufferPointer = GomuksConfig(
matrix: .new(
@@ -81,103 +83,15 @@ class ClientController extends AsyncNotifier {
try {
return GomuksInit(bufferPointer.ref);
} finally {
- calloc.free(bufferPointer);
+ calloc
+ ..free(bufferPointer.ref.base)
+ ..free(bufferPointer);
}
});
- final callable =
- NativeCallable<
- Void Function(Pointer, Int64, GomuksOwnedBuffer)
- >.listener((
- Pointer command,
- int requestId,
- GomuksOwnedBuffer data,
- ) {
- try {
- final muksEventType = command.cast().toDartString();
- debugPrint("Handling $muksEventType...");
- final decodedMuksEvent = data.toJson();
-
- switch (muksEventType) {
- case "client_state":
- ref
- .watch(ClientStateController.provider.notifier)
- .set(.fromJson(decodedMuksEvent));
- break;
- case "sync_status":
- ref
- .watch(SyncStatusController.provider.notifier)
- .set(.fromJson(decodedMuksEvent));
- break;
- case "init_complete":
- ref.watch(InitCompleteController.provider.notifier).complete();
- break;
- case "send_complete":
- final event = Event.fromJson(decodedMuksEvent["event"]);
- ref
- .watch(RoomsController.provider.notifier)
- .update(
- .new({
- event.roomId: .new(events: .new({event.rowId: event})),
- }),
- .new(),
- );
-
- break;
- case "sync_complete":
- final syncData = SyncData.fromJson(decodedMuksEvent);
- final roomProvider = RoomsController.provider;
- final accountDataProvider = AccountDataController.provider;
-
- if (syncData.clearState) {
- ref.invalidate(roomProvider);
- ref.invalidate(accountDataProvider);
- }
-
- ref
- .watch(roomProvider.notifier)
- .update(syncData.rooms, syncData.leftRooms);
- ref
- .watch(accountDataProvider.notifier)
- .update(syncData.accountData);
-
- if (syncData.topLevelSpaces != null) {
- ref
- .watch(TopLevelSpacesController.provider.notifier)
- .set(syncData.topLevelSpaces!);
- }
-
- if (syncData.spaceEdges != null) {
- ref
- .watch(SpaceEdgesController.provider.notifier)
- .set(syncData.spaceEdges!);
- }
-
- // ref
- // .watch(SyncStatusController.provider.notifier)
- // .set(SyncStatus.fromJson(decodedMuksEvent));
- break;
- default:
- debugPrint("Unhandled event: $muksEventType");
- }
- debugPrint("Finished handling $muksEventType...");
- } catch (error, stackTrace) {
- if (kDebugMode) {
- debugPrintStack(stackTrace: stackTrace, label: error.toString());
- rethrow;
- } else {
- showError(error, stackTrace);
- }
- }
- });
-
ref.onDispose(() => GomuksDestroy(handle));
- ref.onDispose(callable.close);
- final errorCode = GomuksStart(handle, callable.nativeFunction);
-
- if (errorCode == 0) return handle;
- throw Exception("GomuksStart returned error code $errorCode");
+ return handle;
}
Future callGomuksMethod(
@@ -186,31 +100,43 @@ class ClientController extends AsyncNotifier {
callback,
) async {
final bufferPointer = data.toGomuksBufferPtr();
- final handle = await future;
- final response = await Isolate.run(
- () => callback(handle, bufferPointer.ref),
- );
- calloc.free(bufferPointer);
+ try {
+ final handle = await future;
- final json = response.buf.toJson();
- if (response.command.cast().toDartString() == "error") {
- throw json;
+ final response = await Isolate.run(
+ () => callback(handle, bufferPointer.ref),
+ );
+
+ final json = response.buf.toJsonAndFree();
+
+ if (response.command.cast().toDartString() == "error") {
+ throw json;
+ }
+
+ return json;
+ } finally {
+ calloc
+ ..free(bufferPointer.ref.base)
+ ..free(bufferPointer);
}
-
- return json;
}
- Future handlePush(Map data) async {
+ Future<(Event, RoomMetadata)?> handlePush(Map data) async {
final response = await callGomuksMethod(
data,
(handle, data) async => GomuksHandlePush(handle, data),
);
- return response == null ? null : .fromJson(response);
+ return response == null
+ ? null
+ : (
+ Event.fromJson(response["event"]),
+ RoomMetadata.fromJson(response["room"]),
+ );
}
- dynamic _sendCommand(
+ Future _sendCommand(
String command, [
Map data = const {},
]) => callGomuksMethod(data, (handle, data) {
@@ -232,7 +158,7 @@ class ClientController extends AsyncNotifier {
final json = request.toJson();
final content = request.content.toJson();
- return Event.fromJson(
+ return .fromJson(
await _sendCommand("send_event", {
...json,
"content": {
@@ -270,11 +196,6 @@ class ClientController extends AsyncNotifier {
await _sendCommand("leave_room", {"room_id": room.metadata!.id});
}
- // (await _sendCommand("get_event_context", {
- // "room_id": request.roomId,
- // "event_id": r"$OqZT4NuTj0J1-771IOEEWRI4XdumRNu6ighlvO3K3gc",
- // }));
-
Future> getRoomState(GetRoomStateRequest request) async {
Future getState(GetRoomStateRequest request) async =>
(await _sendCommand("get_room_state", request.toJson())) as List?;
@@ -312,11 +233,17 @@ class ClientController extends AsyncNotifier {
Future paginate(PaginateRequest request) async =>
.fromJson(await _sendCommand("paginate", request.toJson()));
+ Future paginateManual(PaginateManualRequest request) async =>
+ .fromJson(await _sendCommand("paginate_manual", request.toJson()));
+
+ Future getEventContext(GetEventContextRequest request) async =>
+ .fromJson(await _sendCommand("get_event_context", request.toJson()));
+
Future getProfile(String userId) async {
try {
return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));
} catch (_) {
- return ProfileResponse(profile: .new(id: userId));
+ return .new(profile: .new(id: userId));
}
}
@@ -347,9 +274,11 @@ class ClientController extends AsyncNotifier {
Future logout() => _sendCommand("logout");
Future markRead(Room room) async {
+ if (room.timeline.isEmpty || room.metadata == null) return;
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
final event = eventRowId == null ? null : room.events[eventRowId];
- if (event == null || room.metadata == null) return;
+
+ if (event == null) return;
await _sendCommand("mark_read", {
"room_id": room.metadata!.id,
@@ -375,9 +304,8 @@ class ClientController extends AsyncNotifier {
Future getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions"));
- Future getCapabilities() async => Capabilities.fromJson(
- (await _sendCommand("get_capabilities"))["capabilities"],
- );
+ Future getCapabilities() async =>
+ .fromJson((await _sendCommand("get_capabilities"))["capabilities"]);
Future discoverHomeserver(Uri homeserver) async {
try {
diff --git a/lib/controllers/event.dart b/lib/controllers/event.dart
index 7a1e5dc..5c6513b 100644
--- a/lib/controllers/event.dart
+++ b/lib/controllers/event.dart
@@ -16,11 +16,28 @@ class EventController(final GetEventRequest request)
(event) => event.eventId == request.eventId,
);
- return event ??
- await ref
- .watch(ClientController.provider.notifier)
- .getEvent(request)
- .onError((_, _) => null);
+ if (event != null) return event;
+
+ final fetchedEvent = await ref
+ .watch(ClientController.provider.notifier)
+ .getEvent(request)
+ .onError((_, _) => null);
+
+ if (fetchedEvent != null) {
+ ref
+ .watch(RoomsController.provider.notifier)
+ .update(
+ .new({
+ request.roomId: .new(
+ events: .new({fetchedEvent.rowId: fetchedEvent}),
+ ),
+ }),
+ );
+
+ return fetchedEvent;
+ }
+
+ return null;
}
static final provider = AsyncNotifierProvider.family
diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart
new file mode 100644
index 0000000..2a43676
--- /dev/null
+++ b/lib/controllers/gomuks_listener.dart
@@ -0,0 +1,123 @@
+import "dart:async";
+import "dart:ffi";
+
+import "package:flutter/foundation.dart";
+import "package:nexus/controllers/account_data.dart";
+import "package:nexus/controllers/client.dart";
+import "package:nexus/controllers/client_state.dart";
+import "package:nexus/controllers/init_complete.dart";
+import "package:nexus/controllers/rooms.dart";
+import "package:nexus/controllers/space_edges.dart";
+import "package:nexus/controllers/sync_status.dart";
+import "package:nexus/controllers/top_level_spaces.dart";
+import "package:nexus/helpers/extensions/gomuks_buffer.dart";
+import "package:nexus/main.dart";
+import "package:ffi/ffi.dart";
+import "package:nexus/models/event.dart";
+import "package:nexus/models/sync_data.dart";
+import "package:nexus/src/third_party/gomuks.g.dart";
+import "package:flutter_riverpod/flutter_riverpod.dart";
+
+class GomuksListenerController extends AsyncNotifier {
+ @override
+ Future build() async {
+ debugPrint("Starting gomuks...");
+ final handle = await ref.watch(ClientController.provider.future);
+
+ final callable =
+ NativeCallable<
+ Void Function(Pointer, Int64, GomuksOwnedBuffer)
+ >.listener((
+ Pointer command,
+ int requestId,
+ GomuksOwnedBuffer data,
+ ) {
+ try {
+ final muksEventType = command.cast().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.new,
+ );
+}
diff --git a/lib/controllers/jump_request.dart b/lib/controllers/jump_request.dart
new file mode 100644
index 0000000..fc21ab2
--- /dev/null
+++ b/lib/controllers/jump_request.dart
@@ -0,0 +1,17 @@
+import "package:flutter_riverpod/flutter_riverpod.dart";
+
+typedef JumpRequest = ({String roomId, int rowId});
+
+class JumpRequestController extends Notifier {
+ @override
+ JumpRequest? build() => null;
+
+ void request(String roomId, int rowId) =>
+ state = (roomId: roomId, rowId: rowId);
+
+ void consume() => state = null;
+
+ static final provider = NotifierProvider(
+ JumpRequestController.new,
+ );
+}
diff --git a/lib/controllers/members.dart b/lib/controllers/members.dart
index 3c287ea..d8172b1 100644
--- a/lib/controllers/members.dart
+++ b/lib/controllers/members.dart
@@ -27,7 +27,7 @@ class MembersController(final String roomId)
),
);
- await ref
+ ref
.read(RoomsController.provider.notifier)
.addState(roomId, fetchedState, isMembers: true);
}
diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart
index 681c625..fe631d4 100644
--- a/lib/controllers/notification.dart
+++ b/lib/controllers/notification.dart
@@ -7,7 +7,7 @@ import "package:nexus/controllers/portal.dart";
import "package:nexus/main.dart";
import "package:flutter_local_notifications/flutter_local_notifications.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
-import "package:nexus/pages/notifications.dart";
+import "package:nexus/widgets/notifications_list.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
class NotificationController
@@ -31,12 +31,14 @@ class NotificationController
macOS: darwin,
),
onDidReceiveNotificationResponse: (details) {
- if (details.payload case final eventId?) {
+ if (details.payload == null) return;
+
+ if (int.tryParse(details.payload!) case final rowId?) {
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
- builder: (_) => NotificationsPage(
- highlightedEventId: eventId,
+ builder: (_) => NotificationsList(
+ highlightedEvent: rowId,
defaultToAllNotifications: true,
),
),
@@ -55,17 +57,17 @@ class NotificationController
return;
}
- final eventId = call.arguments as String;
-
- if (navigatorKey.currentContext case final context?) {
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => NotificationsPage(
- highlightedEventId: eventId,
- defaultToAllNotifications: true,
+ if (int.tryParse(call.arguments) case int rowId) {
+ if (navigatorKey.currentContext case final context?) {
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => NotificationsList(
+ highlightedEvent: rowId,
+ defaultToAllNotifications: true,
+ ),
),
- ),
- );
+ );
+ }
}
});
@@ -124,9 +126,18 @@ class NotificationController
id.toString(),
title: title,
body: body,
- icon: icon == null ? null : XdgNotificationIconFile(icon.path),
defaultAction: "app.event",
defaultActionTarget: payload,
+ category: XdgNotificationCategory.imReceived,
+ priority: .normal,
+ buttons: [
+ // TODO: Impl when notif daemons actualy support imReplyWithText
+ // .new(
+ // label: "Reply",
+ // action: "app.reply",
+ // purpose: XdgNotificationButtonPurpose.imReplyWithText,
+ // ),
+ ],
);
} else {
final notificationDetails = NotificationDetails(
diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart
index bed3013..cd10dff 100644
--- a/lib/controllers/notifications.dart
+++ b/lib/controllers/notifications.dart
@@ -1,57 +1,93 @@
+import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
+import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/event.dart";
typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
-class NotificationsController([final NotificationsRequest? request])
- extends AsyncNotifier> {
- static const limit = 20;
+class NotificationsController(final NotificationsRequest request)
+ extends AsyncNotifier> {
+ void storeEvents(Iterable events) => ref
+ .read(RoomsController.provider.notifier)
+ .update(
+ .new(
+ events
+ .groupListsBy((event) => event.roomId)
+ .map(
+ (roomId, roomEvents) => .new(
+ roomId,
+ .new(
+ events: IMap.fromIterable(
+ roomEvents,
+ keyMapper: (event) => event.rowId,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
@override
- Future> build() async {
- final client = ref.watch(ClientController.provider.notifier);
+ Future> build() async {
+ final client = ref.read(ClientController.provider.notifier);
- final (unreadType, roomId) = request ?? (null, null);
+ final (unreadType, roomId) = request;
- return await client.getMentions(
+ final mentions = await client.getMentions(
.new(
maxTimestamp: .now(),
unreadType: unreadType ?? .highlight,
- limit: limit,
roomId: roomId,
),
);
+
+ storeEvents(mentions);
+
+ return .new(mentions.map((event) => (event.rowId, event.roomId)));
}
Future loadOlder() async {
final currentNotifications = await future;
state = .loading();
state = await .guard(() async {
- final lastTs = currentNotifications.lastOrNull?.timestamp;
- if (lastTs == null) return const .empty();
+ final lastNotification = currentNotifications.lastOrNull;
+ final lastTs = lastNotification == null
+ ? null
+ : ref.watch(
+ RoomsController.provider.select(
+ (rooms) => rooms[lastNotification.$2]
+ ?.events[lastNotification.$1]
+ ?.timestamp,
+ ),
+ );
- final client = ref.watch(ClientController.provider.notifier);
- final (unreadType, roomId) = request ?? (null, null);
+ if (lastTs == null) return currentNotifications;
+
+ final client = ref.read(ClientController.provider.notifier);
+ final (unreadType, roomId) = request;
final newNotifications = await client.getMentions(
.new(
maxTimestamp: lastTs,
unreadType: unreadType ?? .highlight,
- limit: limit,
roomId: roomId,
),
);
- return currentNotifications.addAll(newNotifications);
+ storeEvents(newNotifications);
+
+ return currentNotifications.addAll(
+ newNotifications.map((event) => (event.rowId, event.roomId)),
+ );
});
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
NotificationsController,
- IList,
- NotificationsRequest?
+ IList<(int, String)>,
+ NotificationsRequest
>(NotificationsController.new);
}
diff --git a/lib/controllers/pinned_events.dart b/lib/controllers/pinned_events.dart
index 323a275..d0d7bb5 100644
--- a/lib/controllers/pinned_events.dart
+++ b/lib/controllers/pinned_events.dart
@@ -2,26 +2,25 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/pinned_ids.dart";
-import "package:nexus/models/event.dart";
class PinnedEventsController(final String roomId)
- extends AsyncNotifier> {
+ extends AsyncNotifier> {
@override
- Future> build() async {
+ Future> build() async {
final pinIds = ref.watch(PinnedIdsController.provider(roomId));
return (await Future.wait(
pinIds.map(
(eventId) => ref.watch(
EventController.provider(.new(eventId: eventId, roomId: roomId))
- .future,
+ .selectAsync((data) => data?.rowId),
),
),
)).nonNulls.toIList();
}
static final provider = AsyncNotifierProvider.family
- .autoDispose, String>(
+ .autoDispose, String>(
PinnedEventsController.new,
);
}
diff --git a/lib/controllers/profile.dart b/lib/controllers/profile.dart
index 6f0291d..7a1477c 100644
--- a/lib/controllers/profile.dart
+++ b/lib/controllers/profile.dart
@@ -6,7 +6,7 @@ class ProfileController(final String userId)
extends AsyncNotifier {
@override
Future build() {
- final client = ref.watch(ClientController.provider.notifier);
+ final client = ref.read(ClientController.provider.notifier);
return client.getProfile(userId);
}
diff --git a/lib/controllers/recent_emoji.dart b/lib/controllers/recent_emoji.dart
index 6cf57f7..d814a79 100644
--- a/lib/controllers/recent_emoji.dart
+++ b/lib/controllers/recent_emoji.dart
@@ -1,17 +1,30 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
+import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/account_data.dart";
class RecentEmojiController extends Notifier> {
@override
- IList build() => ref.watch(
- AccountDataController.provider.select(
- (value) => value.recentEmoji.recentEmoji,
- ),
- );
+ IList build() {
+ final Set emoji = ref
+ .watch(EmojiController.provider)
+ .requireValue
+ .map((element) => element.emojis)
+ .flattened
+ .map((e) => e.value)
+ .toSet();
+
+ final IList recents = ref.watch(
+ AccountDataController.provider.select(
+ (value) => value.recentEmoji.recentEmoji,
+ ),
+ );
+
+ return .new(recents.where((recent) => emoji.contains(recent.emoji)));
+ }
Future add(String emoji) => ref
.watch(ClientController.provider.notifier)
diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart
index d761ecf..1576401 100644
--- a/lib/controllers/room_chat.dart
+++ b/lib/controllers/room_chat.dart
@@ -10,57 +10,81 @@ import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/reaction.dart";
+import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/room.dart";
+import "package:nexus/models/room_chat.dart";
-class RoomChatController(final String roomId)
- extends AsyncNotifier?> {
+typedef ChatParams = ({String roomId, int? contextualEvent});
+
+class RoomChatController(final ChatParams info)
+ extends AsyncNotifier {
@override
- Future?> build() async {
- final client = ref.watch(ClientController.provider.notifier);
- final room = ref.watch(
- RoomsController.provider.select((rooms) => rooms[roomId]),
+ Future build() async {
+ final client = ref.read(ClientController.provider.notifier);
+ final room = ref.read(
+ RoomsController.provider.select((rooms) => rooms[info.roomId]),
);
if (room == null) return null;
if (!room.hasFetchedState) {
- final state = await client.getRoomState(.new(roomId: roomId));
-
- await ref.read(RoomsController.provider.notifier).addState(roomId, state);
+ final state = await client.getRoomState(.new(roomId: info.roomId));
+ ref.read(RoomsController.provider.notifier).addState(info.roomId, state);
}
- // While there are under 20 events, try to load more
- // until there's no more or the conditions are met.
- if (room.hasMore && room.timeline.length < 20) {
- loadOlder();
- }
-
- return room.timeline
+ final timeline = room.timeline
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
- .map((element) => element.value)
+ .map((e) => e.value)
.toIList()
.addAll(room.clientSticky)
- .map((entry) {
- final foundEvent = entry == null ? null : room.events[entry];
-
- final editedEvent =
- foundEvent == null || foundEvent.lastEditRowId == 0
- ? null
- : room.events[foundEvent.lastEditRowId];
-
- return editedEvent == null
- ? foundEvent
- : foundEvent?.copyWith(
- content: editedEvent.content,
- localContent: editedEvent.localContent,
- );
- })
.nonNulls
.toIList();
+ if (info.contextualEvent == null ||
+ timeline.contains(info.contextualEvent)) {
+ ref.watch(
+ RoomsController.provider.select(
+ (rooms) =>
+ (rooms[info.roomId]?.timeline, rooms[info.roomId]?.clientSticky),
+ ),
+ );
+
+ return .new(
+ timeline: timeline,
+ hasMoreBackward: room.hasMore,
+ hasMoreForward: false,
+ );
+ } else {
+ final context = await client.getEventContext(
+ .new(
+ roomId: info.roomId,
+ eventId: room.events[info.contextualEvent!]!.eventId,
+ ),
+ );
+
+ final events = context.before.add(context.event).addAll(context.after);
+ ref
+ .read(RoomsController.provider.notifier)
+ .update(
+ .new({
+ info.roomId: Room(
+ events: IMap.fromIterable(
+ events,
+ keyMapper: (event) => event.rowId,
+ ),
+ ),
+ }),
+ );
+ return .new(
+ timeline: .new(events.map((element) => element.rowId)),
+ hasMoreBackward: true,
+ hasMoreForward: true,
+ historicalData: .new(start: context.start, end: context.end),
+ );
+ }
}
Future deleteMessage(Event event, {String? reason}) => ref
@@ -68,36 +92,68 @@ class RoomChatController(final String roomId)
.redactEvent(
RedactEventRequest(
eventId: event.eventId,
- roomId: roomId,
+ roomId: info.roomId,
reason: reason,
),
);
- Future loadOlder() async {
- state = .loading();
- final timelineKeys = ref
- .read(RoomsController.provider.select((value) => value[roomId]))
- ?.timeline
- .keys;
- final response = await ref
- .read(ClientController.provider.notifier)
- .paginate(
- .new(
- roomId: roomId,
- maxTimelineId: timelineKeys?.isNotEmpty == true
- ? timelineKeys?.reduce(min)
- : null,
+ void hideClientSticky(int eventRowId) => ref
+ .read(RoomsController.provider.notifier)
+ .update(
+ .new({
+ info.roomId: Room(
+ resetClientSticky: true,
+ clientSticky:
+ ref
+ .read(
+ RoomsController.provider.select(
+ (rooms) => rooms[info.roomId]?.clientSticky,
+ ),
+ )
+ ?.remove(eventRowId) ??
+ .new(),
),
- );
+ }),
+ );
+
+ Future paginate(Direction direction) async {
+ if (state.isLoading) return;
+
+ final chat = await future;
+
+ if (direction == .forward
+ ? chat?.hasMoreForward == false
+ : chat?.hasMoreBackward == false) {
+ return;
+ }
+
+ state = .loading();
+
+ final client = ref.read(ClientController.provider.notifier);
+
+ if (chat?.historicalData == null) {
+ final timelineKeys = ref
+ .read(RoomsController.provider.select((value) => value[info.roomId]))
+ ?.timeline
+ .keys;
+ final response = await client.paginate(
+ .new(
+ roomId: info.roomId,
+ maxTimelineId: timelineKeys?.isNotEmpty == true
+ ? timelineKeys?.reduce(min)
+ : null,
+ ),
+ );
+
+ if (response.events.isEmpty) {
+ state = .data(state.value);
+ }
- if (response.events.isEmpty) {
- state = .data(state.value);
- } else {
ref
.read(RoomsController.provider.notifier)
.update(
IMap({
- roomId: Room(
+ info.roomId: Room(
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
@@ -111,11 +167,61 @@ class RoomChatController(final String roomId)
),
),
}),
- .new(),
);
- }
+ } else {
+ final paginationResponse = await client.paginateManual(
+ .new(
+ roomId: info.roomId,
+ direction: direction,
+ since: direction == .forward
+ ? chat!.historicalData!.end
+ : chat!.historicalData!.start,
+ ),
+ );
- return response.hasMore;
+ ref
+ .read(RoomsController.provider.notifier)
+ .update(
+ .new({
+ info.roomId: Room(
+ events: IMap.fromIterable(
+ paginationResponse.events,
+ keyMapper: (event) => event.rowId,
+ ),
+ ),
+ }),
+ );
+
+ final eventRowIds = paginationResponse.events
+ .map((element) => element.rowId)
+ .toIList();
+
+ state = .data(
+ .new(
+ timeline: direction == .forward
+ ? chat.timeline.addAll(eventRowIds)
+ : eventRowIds.addAll(chat.timeline),
+ hasMoreForward:
+ direction == .forward && paginationResponse.nextBatch == null
+ ? false
+ : chat.hasMoreForward,
+ hasMoreBackward:
+ direction == .backward && paginationResponse.nextBatch == null
+ ? false
+ : chat.hasMoreBackward,
+ historicalData: chat.historicalData?.copyWith(
+ start:
+ (direction == .backward
+ ? paginationResponse.nextBatch
+ : null) ??
+ chat.historicalData!.start,
+ end:
+ (direction == .forward ? paginationResponse.nextBatch : null) ??
+ chat.historicalData!.end,
+ ),
+ ),
+ );
+ }
}
Future send(
@@ -123,14 +229,22 @@ class RoomChatController(final String roomId)
bool shouldMention = true,
required IList tags,
required RelationType relationType,
- Event? relation,
+ int? relatedEventRowId,
}) async {
Content? baseContent;
- if (relationType == .edit) {
- baseContent = relation?.content;
+ final relatedEvent = relatedEventRowId == null
+ ? null
+ : ref.watch(
+ RoomsController.provider.select(
+ (rooms) => rooms[info.roomId]?.events[relatedEventRowId],
+ ),
+ );
+
+ if (relationType == .edit && relatedEventRowId != null) {
+ baseContent = relatedEvent?.content;
} else {
- final provider = AttachmentController.provider(roomId);
- baseContent = ref.watch(provider)?.$2;
+ final provider = AttachmentController.provider(info.roomId);
+ baseContent = ref.read(provider)?.$2;
ref.invalidate(provider);
}
@@ -146,37 +260,36 @@ class RoomChatController(final String roomId)
);
}
- final client = ref.watch(ClientController.provider.notifier);
+ final client = ref.read(ClientController.provider.notifier);
final event = await client.sendMessage(
SendMessageRequest(
- roomId: roomId,
+ roomId: info.roomId,
baseContent: baseContent,
mentions: Mentions(
userIds: [
if (shouldMention == true &&
- relation != null &&
+ relatedEvent != null &&
relationType == RelationType.reply)
- relation.sender,
+ relatedEvent.sender,
].toIList(),
room: taggedMessage.contains("@room"),
),
text: taggedMessage,
- relation: relation == null
+ relation: relatedEvent == null
? null
- : .new(eventId: relation.eventId, relationType: relationType),
+ : .new(eventId: relatedEvent.eventId, relationType: relationType),
),
);
ref
- .watch(RoomsController.provider.notifier)
+ .read(RoomsController.provider.notifier)
.update(
.new({
- roomId: .new(
+ info.roomId: .new(
events: .new({event.rowId: event}),
clientSticky: .new({event.rowId}),
),
}),
- .new(),
);
}
@@ -185,10 +298,10 @@ class RoomChatController(final String roomId)
Event event,
String userId,
) async {
- final client = ref.watch(ClientController.provider.notifier);
+ final client = ref.read(ClientController.provider.notifier);
final allReactionEvents = await client.getRelatedEvents(
.new(
- roomId: roomId,
+ roomId: info.roomId,
eventId: event.eventId,
relationType: "m.annotation",
),
@@ -209,16 +322,18 @@ class RoomChatController(final String roomId)
if (reactionEvent != null) {
await ref
.watch(ClientController.provider.notifier)
- .redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId));
+ .redactEvent(
+ .new(eventId: reactionEvent.eventId, roomId: info.roomId),
+ );
}
}
Future sendReaction(String reaction, Event event) async {
- final client = ref.watch(ClientController.provider.notifier);
+ final client = ref.read(ClientController.provider.notifier);
await client.sendEvent(
.new(
- roomId: roomId,
+ roomId: info.roomId,
type: EventType.reaction.type,
content: ReactionContent(key: reaction),
synchronous: true,
@@ -230,7 +345,7 @@ class RoomChatController(final String roomId)
}
static final provider = AsyncNotifierProvider.family
- .autoDispose?, String>(
+ .autoDispose(
RoomChatController.new,
);
}
diff --git a/lib/controllers/room_summary.dart b/lib/controllers/room_summary.dart
index 76271a4..e5b3174 100644
--- a/lib/controllers/room_summary.dart
+++ b/lib/controllers/room_summary.dart
@@ -7,7 +7,7 @@ class RoomSummaryController(final JoinRoomRequest request)
extends AsyncNotifier {
@override
Future build() =>
- ref.watch(ClientController.provider.notifier).getRoomSummary(request);
+ ref.read(ClientController.provider.notifier).getRoomSummary(request);
static final provider = AsyncNotifierProvider.family
.autoDispose(
diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart
index ff4d6a2..7b86f2a 100644
--- a/lib/controllers/rooms.dart
+++ b/lib/controllers/rooms.dart
@@ -1,5 +1,4 @@
-import "dart:isolate";
-
+import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart";
@@ -9,35 +8,35 @@ class RoomsController extends Notifier> {
@override
IMap build() => .new();
- Future addState(
+ void addState(
String roomId,
IList state, {
bool isMembers = false,
- }) async => update(
+ }) => update(
.new({
roomId: Room(
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
hasFetchedState: true,
hasFetchedMembers: isMembers,
- state: await Isolate.run(() {
- final newState = state.fold>>(
- .new(),
- (previousValue, stateEvent) => previousValue.add(
- stateEvent.type,
- (previousValue[stateEvent.type] ?? .new()).add(
- stateEvent.stateKey!,
- stateEvent.rowId,
+ state: .new(
+ state
+ .groupListsBy((event) => event.type)
+ .map(
+ (type, group) => .new(
+ type,
+ .fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
+ ),
),
- ),
- );
- return newState;
- }),
+ ),
),
}),
.new(),
);
- void update(IMap rooms, ISet leftRooms) {
+ void update(
+ IMap rooms, [
+ ISet leftRooms = const ISet.empty(),
+ ]) {
final merged = rooms.entries.fold(state, (acc, entry) {
final roomId = entry.key;
final incoming = entry.value;
@@ -47,13 +46,16 @@ class RoomsController extends Notifier> {
roomId,
existing?.copyWith(
hasMore: incoming.hasMore,
- clientSticky:
- (incoming.clientSticky.isEmpty == true
- ? existing.clientSticky
- : existing.clientSticky.addAll(incoming.clientSticky))
- .removeWhere(
- (rowId) => incoming.timeline.values.contains(rowId),
- ),
+ clientSticky: incoming.resetClientSticky
+ ? incoming.clientSticky
+ : (incoming.clientSticky.isEmpty == true
+ ? existing.clientSticky
+ : existing.clientSticky.addAll(
+ incoming.clientSticky,
+ ))
+ .removeWhere(
+ (rowId) => incoming.timeline.values.contains(rowId),
+ ),
metadata: incoming.metadata ?? existing.metadata,
events: incoming.events.isEmpty
? existing.events
@@ -65,6 +67,7 @@ class RoomsController extends Notifier> {
(previousValue[event.key] ?? .new()).addAll(event.value),
),
),
+ resetClientSticky: false,
reset: false,
hasFetchedMembers:
incoming.hasFetchedMembers || existing.hasFetchedMembers,
diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart
index 7fc1b3f..073019a 100644
--- a/lib/controllers/settings_sections.dart
+++ b/lib/controllers/settings_sections.dart
@@ -9,6 +9,7 @@ import "package:m3e_buttons/m3e_buttons.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
+import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart";
@@ -76,6 +77,38 @@ class SettingsSectionsController
title: "Behavior",
icon: Icons.psychology,
settings: .new([
+ .new(
+ title: "All Rooms In Home",
+ description: "Show all rooms in home, instead of just ones that aren't in spaces",
+ icon: Icons.home,
+ builder: (title, description, icon) => SwitchListTile(
+ title: Text(title),
+ subtitle: Text(description),
+ secondary: Icon(icon),
+ value: settings.showAllInHome,
+ onChanged: (value) => ref
+ .watch(SettingsController.provider.notifier)
+ .set(settings.copyWith(showAllInHome: value))
+ .onError(showError),
+ ),
+ ),
+ if (!Platform.isAndroid && !Platform.isIOS)
+ .new(
+ title: "Show Minimize Button",
+ description:
+ "Enable this to add a minimize button to the appbar",
+ icon: Icons.minimize,
+ builder: (title, description, icon) => SwitchListTile(
+ title: Text(title),
+ subtitle: Text(description),
+ secondary: Icon(icon),
+ value: settings.showMinimizeButton,
+ onChanged: (value) => ref
+ .watch(SettingsController.provider.notifier)
+ .set(settings.copyWith(showMinimizeButton: value))
+ .onError(showError),
+ ),
+ ),
.new(
title: "Linux Mobile Mode",
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
@@ -252,12 +285,19 @@ class SettingsSectionsController
final colorScheme = Theme.of(context).colorScheme;
return M3EButton.icon(
onPressed: () async {
- await ref
- .watch(UnifiedPushController.provider.notifier)
+ final container = ProviderScope.containerOf(context);
+
+ final route = ModalRoute.of(context)!;
+ Navigator.of(context).popUntil((r) => r.isFirst);
+ await route.completed;
+
+ await container
+ .read(UnifiedPushController.provider.notifier)
.deregister()
.onError(showError);
- await ref
- .watch(ClientController.provider.notifier)
+ container.invalidate(InitCompleteController.provider);
+ await container
+ .read(ClientController.provider.notifier)
.logout()
.onError(showError);
},
diff --git a/lib/controllers/spaces.dart b/lib/controllers/spaces.dart
index 7dc14b0..e86cfe1 100644
--- a/lib/controllers/spaces.dart
+++ b/lib/controllers/spaces.dart
@@ -4,15 +4,16 @@ import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/rooms.dart";
+import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/top_level_spaces.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/space.dart";
import "package:nexus/models/subspace.dart";
-class SpacesController extends Notifier> {
+class SpacesController extends AsyncNotifier> {
@override
- IList build() {
+ Future> build() async {
final rooms = ref.watch(RoomsController.provider);
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
final spaceEdges = ref.watch(SpaceEdgesController.provider);
@@ -77,10 +78,10 @@ class SpacesController extends Notifier> {
final spaces = topLevelSpaceIds.map(buildSpace).toIList();
- final usedRoomIds = {
+ final usedRooms = {
for (final space in spaces) ...[
- ...space.children.map((r) => r.metadata?.id),
- ...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)),
+ ...space.children,
+ ...space.subSpaces.expand((s) => s.children),
],
}.nonNulls.toISet();
@@ -89,7 +90,9 @@ class SpacesController extends Notifier> {
final otherRooms = rooms.entries
.where(
(e) =>
- !usedRoomIds.contains(e.key) &&
+ !usedRooms
+ .map((element) => element.metadata?.id)
+ .contains(e.key) &&
!topLevelSpaceIds.contains(e.key) &&
!childrenById.containsKey(e.key),
)
@@ -97,6 +100,15 @@ class SpacesController extends Notifier> {
.toIList();
final homeRooms = otherRooms
+ .addAll(
+ await ref.watch(
+ SettingsController.provider.selectAsync(
+ (data) => data.showAllInHome,
+ ),
+ )
+ ? usedRooms
+ : .empty(),
+ )
.where((r) => !directMessages.contains(r.metadata?.id))
.toIList();
@@ -143,7 +155,7 @@ class SpacesController extends Notifier> {
.toIList();
}
- static final provider = NotifierProvider>(
+ static final provider = AsyncNotifierProvider>(
SpacesController.new,
);
}
diff --git a/lib/controllers/spec_versions.dart b/lib/controllers/spec_versions.dart
index 1f879bd..d2316bd 100644
--- a/lib/controllers/spec_versions.dart
+++ b/lib/controllers/spec_versions.dart
@@ -5,7 +5,7 @@ import "package:nexus/models/spec_versions_response.dart";
class SpecVersionsController extends AsyncNotifier {
@override
Future build() =>
- ref.watch(ClientController.provider.notifier).getSpecVersions();
+ ref.read(ClientController.provider.notifier).getSpecVersions();
static final provider =
AsyncNotifierProvider.autoDispose<
diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart
index 1b52895..97849fe 100644
--- a/lib/controllers/unified_push.dart
+++ b/lib/controllers/unified_push.dart
@@ -8,7 +8,6 @@ import "package:intl/intl.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/push_key.dart";
-import "package:nexus/controllers/rooms.dart";
import "package:nexus/main.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
@@ -19,6 +18,8 @@ import "package:unifiedpush_storage_shared_preferences/storage.dart";
import "package:window_manager/window_manager.dart";
class UnifiedPushController extends AsyncNotifier {
+ Completer? _endpoint;
+
@override
Future build() async {
if (!Platform.isLinux && !Platform.isAndroid) return false;
@@ -31,30 +32,45 @@ class UnifiedPushController extends AsyncNotifier {
shouldWriteService: false,
),
onNewEndpoint: (endpoint, instance) async {
- final pushKey = endpoint.pubKeySet!.pubKey;
- await ref
- .read(PushKeyController.provider(instance).notifier)
- .set(pushKey);
+ final task = Future(() async {
+ final pushKey = endpoint.pubKeySet!.pubKey;
+ await ref
+ .read(PushKeyController.provider(instance).notifier)
+ .set(pushKey);
- await ref
- .read(ClientController.provider.notifier)
- .registerPusher(
- .new(
- appDisplayName: "Nexus",
- appId: "nexus.federated.nexus",
- data: .webPush(
- url: .parse(endpoint.url),
- auth: endpoint.pubKeySet!.auth,
+ await ref
+ .read(ClientController.provider.notifier)
+ .registerPusher(
+ .new(
+ appDisplayName: "Nexus",
+ appId: "nexus.federated.nexus",
+ data: .webPush(
+ url: .parse(endpoint.url),
+ auth: endpoint.pubKeySet!.auth,
+ ),
+ deviceDisplayName:
+ "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
+ kind: .webPush,
+ lang: "en",
+ pushKey: pushKey,
),
- deviceDisplayName:
- "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
- kind: .webPush,
- lang: "en",
- pushKey: pushKey,
- ),
- );
+ );
- state = .data(true);
+ state = .data(true);
+ });
+
+ if (_endpoint case final completer? when !completer.isCompleted) {
+ completer.complete(task);
+ } else {
+ await task;
+ }
+ },
+ onRegistrationFailed: (reason, instance) {
+ if (_endpoint case final completer? when !completer.isCompleted) {
+ completer.completeError(reason);
+ } else {
+ throw reason;
+ }
},
onMessage: (message, instance) async {
debugPrint("UP message received for $instance");
@@ -63,12 +79,15 @@ class UnifiedPushController extends AsyncNotifier {
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
);
}
- final event = await ref
+ final pushResponse = await ref
.read(ClientController.provider.notifier)
.handlePush(json.decode(String.fromCharCodes(message.content)));
- if (event == null ||
- event.unreadType?.shouldNotify() != true ||
+ if (pushResponse == null) return;
+
+ final (event, roomMetadata) = pushResponse;
+
+ if (event.unreadType?.has(.notify) != true ||
(!isInBackground &&
await windowManager.isFocused().onError((_, _) => true) &&
await ref.read(
@@ -79,43 +98,21 @@ class UnifiedPushController extends AsyncNotifier {
return;
}
- final provider = RoomsController.provider.select(
- (rooms) => rooms[event.roomId],
- );
-
- if (ref.read(provider)?.metadata == null) {
- final completer = Completer();
-
- final subscription = ref.listen(provider, (previous, next) {
- if (next?.metadata != null && !completer.isCompleted) {
- completer.complete();
- }
- }, fireImmediately: true);
-
- try {
- await completer.future.timeout(.new(seconds: 10));
- } on TimeoutException {
- // metadata didn't show up in time
- } finally {
- subscription.close();
- }
- }
-
- final room = ref.read(provider);
- final avatar = room?.metadata?.avatar;
- final icon = avatar == null
+ final icon = roomMetadata.avatar == null
? null
: await ref
.read(ClientController.provider.notifier)
- .downloadMedia(.new(mxc: avatar, isAvatar: true));
+ .downloadMedia(
+ .new(mxc: roomMetadata.avatar!, isAvatar: true),
+ );
await ref
.read(NotificationController.provider.notifier)
.send(
id: event.eventId.hashCode & 0x7fffffff,
- title: room?.metadata?.name ?? "New Event",
+ title: roomMetadata.name ?? "New Event",
icon: icon,
- payload: event.eventId,
+ payload: event.rowId.toString(),
body: switch (event.content) {
MessageContent(:final body?) ||
StickerContent(:final body) => body,
@@ -128,45 +125,68 @@ class UnifiedPushController extends AsyncNotifier {
onUnregistered: deregister,
);
- ref.listen(
- ClientStateController.provider.select((value) => value?.deviceId),
- (_, _) => register(),
- );
+ if (!registered) return false;
- if (registered) {
- // Needs to be registered every startup
- await register();
+ try {
+ return await register();
+ } catch (error, stackTrace) {
+ showError(error, stackTrace);
+ return false;
}
-
- return registered;
}
- Future register() async {
+ Future register() async {
state = .loading();
try {
- final deviceId = ref.read(
+ final deviceIdCompleter = Completer();
+ final subscription = ref.listen(
ClientStateController.provider.select((value) => value?.deviceId),
+ (_, next) {
+ if (next != null && !deviceIdCompleter.isCompleted) {
+ deviceIdCompleter.complete(next);
+ }
+ },
+ fireImmediately: true,
);
- if (deviceId == null) return;
-
- final capabilities = await ref
- .read(ClientController.provider.notifier)
- .getCapabilities();
-
- if (capabilities.webpush?.vapid == null) {
- throw UnsupportedError(
- "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.",
- );
- }
-
- if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
- throw Exception("No UnifiedPush distributors found.");
- }
-
- await UnifiedPush.register(
- instance: deviceId,
- vapid: capabilities.webpush?.vapid,
+ final deviceId = await deviceIdCompleter.future.whenComplete(
+ subscription.close,
);
+
+ await Future(() async {
+ final capabilities = await ref
+ .read(ClientController.provider.notifier)
+ .getCapabilities();
+
+ if (capabilities.webpush?.vapid == null) {
+ throw UnsupportedError(
+ "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.",
+ );
+ }
+
+ if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
+ throw Exception("No UnifiedPush distributors found.");
+ }
+
+ final endpoint = _endpoint = Completer();
+
+ 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;
diff --git a/lib/controllers/unified_push_allowed.dart b/lib/controllers/unified_push_allowed.dart
index 8f7fd8e..604a060 100644
--- a/lib/controllers/unified_push_allowed.dart
+++ b/lib/controllers/unified_push_allowed.dart
@@ -7,7 +7,11 @@ import "package:unifiedpush/unifiedpush.dart";
class UnifiedPushAllowedController extends AsyncNotifier {
@override
Future build() async {
- if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
+ if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor().timeout(
+ .new(seconds: 10),
+ onTimeout: () =>
+ throw Exception("UnifiedPush distributor check timed out."),
+ )) {
return "No valid distributors found. ${Platform.isLinux
? "Try installing KUnifiedPush"
: Platform.isAndroid
diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart
new file mode 100644
index 0000000..fdf3bdf
--- /dev/null
+++ b/lib/helpers/extensions/build_event_options.dart
@@ -0,0 +1,283 @@
+import "package:fast_immutable_collections/fast_immutable_collections.dart";
+import "package:material_ui/material_ui.dart";
+import "package:flutter/services.dart";
+import "package:flutter_hooks/flutter_hooks.dart";
+import "package:hooks_riverpod/hooks_riverpod.dart";
+import "package:nexus/controllers/client.dart";
+import "package:nexus/controllers/pinned_ids.dart";
+import "package:nexus/controllers/power_level.dart";
+import "package:nexus/controllers/recent_emoji.dart";
+import "package:nexus/controllers/rooms.dart";
+import "package:nexus/controllers/room_chat.dart";
+import "package:nexus/controllers/via.dart";
+import "package:nexus/helpers/extensions/event_id_is_fake.dart";
+import "package:nexus/models/content/message.dart";
+import "package:nexus/models/event.dart";
+import "package:nexus/models/relation_type.dart";
+import "package:nexus/widgets/emoji_picker.dart";
+import "package:nexus/main.dart";
+
+extension BuildEventOptions on Event {
+ IList 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 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)),
+ ),
+ ),
+ ]);
+ }
+}
diff --git a/lib/helpers/extensions/event_id_is_fake.dart b/lib/helpers/extensions/event_id_is_fake.dart
new file mode 100644
index 0000000..2e660af
--- /dev/null
+++ b/lib/helpers/extensions/event_id_is_fake.dart
@@ -0,0 +1,3 @@
+extension EventIdIsFake on String {
+ bool get isFake => startsWith("~") || startsWith("\$gomuks");
+}
diff --git a/lib/helpers/extensions/focus_room.dart b/lib/helpers/extensions/focus_room.dart
new file mode 100644
index 0000000..33111ea
--- /dev/null
+++ b/lib/helpers/extensions/focus_room.dart
@@ -0,0 +1,46 @@
+import "package:collection/collection.dart";
+import "package:flutter_riverpod/experimental/mutation.dart";
+import "package:nexus/controllers/jump_request.dart";
+import "package:nexus/controllers/key.dart";
+import "package:nexus/controllers/spaces.dart";
+import "package:nexus/models/space.dart";
+
+extension FocusRoom on MutationTarget {
+ Future 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;
+ }
+}
diff --git a/lib/helpers/extensions/get_all_child_rooms.dart b/lib/helpers/extensions/get_all_child_rooms.dart
new file mode 100644
index 0000000..7a17eec
--- /dev/null
+++ b/lib/helpers/extensions/get_all_child_rooms.dart
@@ -0,0 +1,8 @@
+import "package:fast_immutable_collections/fast_immutable_collections.dart";
+import "package:nexus/models/room.dart";
+import "package:nexus/models/space.dart";
+
+extension GetAllChildRooms on Space {
+ IList get allChildRooms =>
+ children.addAll(subSpaces.expand((s) => s.children));
+}
diff --git a/lib/helpers/extensions/get_filename.dart b/lib/helpers/extensions/get_filename.dart
new file mode 100644
index 0000000..c0c20e4
--- /dev/null
+++ b/lib/helpers/extensions/get_filename.dart
@@ -0,0 +1,11 @@
+import "package:nexus/models/content/message.dart";
+
+extension GetFilename on MessageContent {
+ String? get filename => switch (this) {
+ ImageMessageContent(:final filename) ||
+ VideoMessageContent(:final filename) ||
+ AudioMessageContent(:final filename) ||
+ FileMessageContent(:final filename) => filename,
+ _ => null,
+ };
+}
diff --git a/lib/helpers/extensions/get_link_info.dart b/lib/helpers/extensions/get_link_info.dart
new file mode 100644
index 0000000..28e999b
--- /dev/null
+++ b/lib/helpers/extensions/get_link_info.dart
@@ -0,0 +1,70 @@
+import "package:fast_immutable_collections/fast_immutable_collections.dart";
+
+/// A parsed Matrix link.
+///
+/// [identifier] is the room ID, room alias or user ID (e.g. "#room:matrix.org"),
+/// [eventId] is set for event permalinks (e.g. "$abc123"), and [via] holds any
+/// `via` servers from the link.
+typedef LinkInfo = ({String identifier, String? eventId, IList 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;
+ }
+}
diff --git a/lib/helpers/extensions/gomuks_buffer.dart b/lib/helpers/extensions/gomuks_buffer.dart
index cc16b46..10a273a 100644
--- a/lib/helpers/extensions/gomuks_buffer.dart
+++ b/lib/helpers/extensions/gomuks_buffer.dart
@@ -1,20 +1,21 @@
import "dart:convert";
import "dart:ffi";
import "dart:typed_data";
+
import "package:ffi/ffi.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
- Uint8List toBytes() {
+ Uint8List toBytesAndFree() {
try {
if (base == nullptr || length <= 0) return .new(0);
return .fromList(base.asTypedList(length));
} finally {
- calloc.free(base);
+ GomuksFreeBuffer(this);
}
}
- dynamic toJson() => jsonDecode(utf8.decode(toBytes()));
+ dynamic toJsonAndFree() => jsonDecode(utf8.decode(toBytesAndFree()));
}
extension JsonToGomuksBuffer on Map {
diff --git a/lib/helpers/extensions/link_to_mention.dart b/lib/helpers/extensions/link_to_mention.dart
deleted file mode 100644
index 8595669..0000000
--- a/lib/helpers/extensions/link_to_mention.dart
+++ /dev/null
@@ -1,46 +0,0 @@
-extension LinkToMention on String {
- /// Extracts a Matrix identifier from this string.
- ///
- /// Supports:
- /// - https://matrix.to/#/...
- /// - matrix:roomid/...
- /// - matrix:r/...
- /// - matrix:u/...
- ///
- /// Returns the decoded identifier (e.g. "#room:matrix.org")
- /// or null if this is not a Matrix link.
- /// TODO: Needs to be reworked to handle event links. Might be worth just rewriting, I don't like this code.
- String? get mention {
- final trimmed = trim();
-
- final matrixTo = RegExp(
- r"^https?://matrix\.to/#/(.[^/?#]+)",
- caseSensitive: false,
- );
-
- final matrixToMatch = matrixTo.firstMatch(trimmed);
- if (matrixToMatch != null) {
- return Uri.decodeComponent(matrixToMatch.group(1)!);
- }
-
- if (trimmed.toLowerCase().startsWith("matrix:")) {
- try {
- final uri = Uri.parse(trimmed);
-
- if (uri.pathSegments.isNotEmpty) {
- final identifier = uri.pathSegments.last;
- if (identifier.isNotEmpty) {
- return "${switch (uri.pathSegments.firstOrNull) {
- "r" => "#",
- "roomid" => "!",
- "u" => "@",
- _ => "",
- }}${Uri.decodeComponent(identifier)}";
- }
- }
- } catch (_) {}
- }
-
- return null;
- }
-}
diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart
index 6beca04..ac50a09 100644
--- a/lib/helpers/extensions/scheme_to_theme.dart
+++ b/lib/helpers/extensions/scheme_to_theme.dart
@@ -3,7 +3,8 @@ import "package:material_ui/material_ui.dart";
extension SchemeToTheme on ColorScheme {
ThemeData get theme {
final textTheme = ThemeData(
- fontFamilyFallback: ["sans", "emoji", "fallback-sans", "fallback-emoji"],
+ fontFamily: "bundled-sans",
+ fontFamilyFallback: ["bundled-emoji"],
brightness: brightness,
).textTheme;
return .from(colorScheme: this).copyWith(
@@ -27,6 +28,7 @@ extension SchemeToTheme on ColorScheme {
borderRadius: .circular(8),
),
),
+ iconButtonTheme: .new(variant: StyleVariant.material3Expressive),
textTheme: textTheme,
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
diff --git a/lib/helpers/extensions/show_about_dialog.dart b/lib/helpers/extensions/show_about_dialog.dart
index 6ccba4c..4729e99 100644
--- a/lib/helpers/extensions/show_about_dialog.dart
+++ b/lib/helpers/extensions/show_about_dialog.dart
@@ -33,7 +33,9 @@ extension ShowAboutDialog on BuildContext {
"Nexus",
style: Theme.of(context).textTheme.headlineMedium,
),
- Text("(${packageInfo.version})"),
+ Text(
+ "(${packageInfo.version}+${const String.fromEnvironment("GIT_COMMIT", defaultValue: "dev")})",
+ ),
],
),
diff --git a/lib/helpers/font_licenses.dart b/lib/helpers/font_licenses.dart
index 41567c2..4bd65d9 100644
--- a/lib/helpers/font_licenses.dart
+++ b/lib/helpers/font_licenses.dart
@@ -1,11 +1,6 @@
import "package:flutter/foundation.dart";
-const fontLicenses = [
- LicenseEntryWithLineBreaks(
- ["Noto Color Emoji"],
- """Copyright 2021 Google Inc. All Rights Reserved.
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
+const sil = """This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
@@ -95,102 +90,426 @@ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.""",
+OTHER DEALINGS IN THE FONT SOFTWARE.""";
+
+const List fontLicenses = [
+ .new(
+ ["Twitter Color Emoji"],
+ """Twitter Color Emoji is built from Twemoji (https://github.com/jdecked/twemoji)
+using the Noto Emoji as fallback.
+Its parts are under different licenses:
+
+1) Emoji artwork: Copyright Twitter, Inc and other contributors.
+Attribution 4.0 International
+
+=======================================================================
+
+Creative Commons Corporation ("Creative Commons") is not a law firm and
+does not provide legal services or legal advice. Distribution of
+Creative Commons public licenses does not create a lawyer-client or
+other relationship. Creative Commons makes its licenses and related
+information available on an "as-is" basis. Creative Commons gives no
+warranties regarding its licenses, any material licensed under their
+terms and conditions, or any related information. Creative Commons
+disclaims all liability for damages resulting from their use to the
+fullest extent possible.
+
+Using Creative Commons Public Licenses
+
+Creative Commons public licenses provide a standard set of terms and
+conditions that creators and other rights holders may use to share
+original works of authorship and other material subject to copyright
+and certain other rights specified in the public license below. The
+following considerations are for informational purposes only, are not
+exhaustive, and do not form part of our licenses.
+
+ Considerations for licensors: Our public licenses are
+ intended for use by those authorized to give the public
+ permission to use material in ways otherwise restricted by
+ copyright and certain other rights. Our licenses are
+ irrevocable. Licensors should read and understand the terms
+ and conditions of the license they choose before applying it.
+ Licensors should also secure all rights necessary before
+ applying our licenses so that the public can reuse the
+ material as expected. Licensors should clearly mark any
+ material not subject to the license. This includes other CC-
+ licensed material, or material used under an exception or
+ limitation to copyright. More considerations for licensors:
+ wiki.creativecommons.org/Considerations_for_licensors
+
+ Considerations for the public: By using one of our public
+ licenses, a licensor grants the public permission to use the
+ licensed material under specified terms and conditions. If
+ the licensor's permission is not necessary for any reason--for
+ example, because of any applicable exception or limitation to
+ copyright--then that use is not regulated by the license. Our
+ licenses grant only permissions under copyright and certain
+ other rights that a licensor has authority to grant. Use of
+ the licensed material may still be restricted for other
+ reasons, including because others have copyright or other
+ rights in the material. A licensor may make special requests,
+ such as asking that all changes be marked or described.
+ Although not required by our licenses, you are encouraged to
+ respect those requests where reasonable. More_considerations
+ for the public:
+ wiki.creativecommons.org/Considerations_for_licensees
+
+=======================================================================
+
+Creative Commons Attribution 4.0 International Public License
+
+By exercising the Licensed Rights (defined below), You accept and agree
+to be bound by the terms and conditions of this Creative Commons
+Attribution 4.0 International Public License ("Public License"). To the
+extent this Public License may be interpreted as a contract, You are
+granted the Licensed Rights in consideration of Your acceptance of
+these terms and conditions, and the Licensor grants You such rights in
+consideration of benefits the Licensor receives from making the
+Licensed Material available under these terms and conditions.
+
+
+Section 1 -- Definitions.
+
+ a. Adapted Material means material subject to Copyright and Similar
+ Rights that is derived from or based upon the Licensed Material
+ and in which the Licensed Material is translated, altered,
+ arranged, transformed, or otherwise modified in a manner requiring
+ permission under the Copyright and Similar Rights held by the
+ Licensor. For purposes of this Public License, where the Licensed
+ Material is a musical work, performance, or sound recording,
+ Adapted Material is always produced where the Licensed Material is
+ synched in timed relation with a moving image.
+
+ b. Adapter's License means the license You apply to Your Copyright
+ and Similar Rights in Your contributions to Adapted Material in
+ accordance with the terms and conditions of this Public License.
+
+ c. Copyright and Similar Rights means copyright and/or similar rights
+ closely related to copyright including, without limitation,
+ performance, broadcast, sound recording, and Sui Generis Database
+ Rights, without regard to how the rights are labeled or
+ categorized. For purposes of this Public License, the rights
+ specified in Section 2(b)(1)-(2) are not Copyright and Similar
+ Rights.
+
+ d. Effective Technological Measures means those measures that, in the
+ absence of proper authority, may not be circumvented under laws
+ fulfilling obligations under Article 11 of the WIPO Copyright
+ Treaty adopted on December 20, 1996, and/or similar international
+ agreements.
+
+ e. Exceptions and Limitations means fair use, fair dealing, and/or
+ any other exception or limitation to Copyright and Similar Rights
+ that applies to Your use of the Licensed Material.
+
+ f. Licensed Material means the artistic or literary work, database,
+ or other material to which the Licensor applied this Public
+ License.
+
+ g. Licensed Rights means the rights granted to You subject to the
+ terms and conditions of this Public License, which are limited to
+ all Copyright and Similar Rights that apply to Your use of the
+ Licensed Material and that the Licensor has authority to license.
+
+ h. Licensor means the individual(s) or entity(ies) granting rights
+ under this Public License.
+
+ i. Share means to provide material to the public by any means or
+ process that requires permission under the Licensed Rights, such
+ as reproduction, public display, public performance, distribution,
+ dissemination, communication, or importation, and to make material
+ available to the public including in ways that members of the
+ public may access the material from a place and at a time
+ individually chosen by them.
+
+ j. Sui Generis Database Rights means rights other than copyright
+ resulting from Directive 96/9/EC of the European Parliament and of
+ the Council of 11 March 1996 on the legal protection of databases,
+ as amended and/or succeeded, as well as other essentially
+ equivalent rights anywhere in the world.
+
+ k. You means the individual or entity exercising the Licensed Rights
+ under this Public License. Your has a corresponding meaning.
+
+
+Section 2 -- Scope.
+
+ a. License grant.
+
+ 1. Subject to the terms and conditions of this Public License,
+ the Licensor hereby grants You a worldwide, royalty-free,
+ non-sublicensable, non-exclusive, irrevocable license to
+ exercise the Licensed Rights in the Licensed Material to:
+
+ a. reproduce and Share the Licensed Material, in whole or
+ in part; and
+
+ b. produce, reproduce, and Share Adapted Material.
+
+ 2. Exceptions and Limitations. For the avoidance of doubt, where
+ Exceptions and Limitations apply to Your use, this Public
+ License does not apply, and You do not need to comply with
+ its terms and conditions.
+
+ 3. Term. The term of this Public License is specified in Section
+ 6(a).
+
+ 4. Media and formats; technical modifications allowed. The
+ Licensor authorizes You to exercise the Licensed Rights in
+ all media and formats whether now known or hereafter created,
+ and to make technical modifications necessary to do so. The
+ Licensor waives and/or agrees not to assert any right or
+ authority to forbid You from making technical modifications
+ necessary to exercise the Licensed Rights, including
+ technical modifications necessary to circumvent Effective
+ Technological Measures. For purposes of this Public License,
+ simply making modifications authorized by this Section 2(a)
+ (4) never produces Adapted Material.
+
+ 5. Downstream recipients.
+
+ a. Offer from the Licensor -- Licensed Material. Every
+ recipient of the Licensed Material automatically
+ receives an offer from the Licensor to exercise the
+ Licensed Rights under the terms and conditions of this
+ Public License.
+
+ b. No downstream restrictions. You may not offer or impose
+ any additional or different terms or conditions on, or
+ apply any Effective Technological Measures to, the
+ Licensed Material if doing so restricts exercise of the
+ Licensed Rights by any recipient of the Licensed
+ Material.
+
+ 6. No endorsement. Nothing in this Public License constitutes or
+ may be construed as permission to assert or imply that You
+ are, or that Your use of the Licensed Material is, connected
+ with, or sponsored, endorsed, or granted official status by,
+ the Licensor or others designated to receive attribution as
+ provided in Section 3(a)(1)(A)(i).
+
+ b. Other rights.
+
+ 1. Moral rights, such as the right of integrity, are not
+ licensed under this Public License, nor are publicity,
+ privacy, and/or other similar personality rights; however, to
+ the extent possible, the Licensor waives and/or agrees not to
+ assert any such rights held by the Licensor to the limited
+ extent necessary to allow You to exercise the Licensed
+ Rights, but not otherwise.
+
+ 2. Patent and trademark rights are not licensed under this
+ Public License.
+
+ 3. To the extent possible, the Licensor waives any right to
+ collect royalties from You for the exercise of the Licensed
+ Rights, whether directly or through a collecting society
+ under any voluntary or waivable statutory or compulsory
+ licensing scheme. In all other cases the Licensor expressly
+ reserves any right to collect such royalties.
+
+
+Section 3 -- License Conditions.
+
+Your exercise of the Licensed Rights is expressly made subject to the
+following conditions.
+
+ a. Attribution.
+
+ 1. If You Share the Licensed Material (including in modified
+ form), You must:
+
+ a. retain the following if it is supplied by the Licensor
+ with the Licensed Material:
+
+ i. identification of the creator(s) of the Licensed
+ Material and any others designated to receive
+ attribution, in any reasonable manner requested by
+ the Licensor (including by pseudonym if
+ designated);
+
+ ii. a copyright notice;
+
+ iii. a notice that refers to this Public License;
+
+ iv. a notice that refers to the disclaimer of
+ warranties;
+
+ v. a URI or hyperlink to the Licensed Material to the
+ extent reasonably practicable;
+
+ b. indicate if You modified the Licensed Material and
+ retain an indication of any previous modifications; and
+
+ c. indicate the Licensed Material is licensed under this
+ Public License, and include the text of, or the URI or
+ hyperlink to, this Public License.
+
+ 2. You may satisfy the conditions in Section 3(a)(1) in any
+ reasonable manner based on the medium, means, and context in
+ which You Share the Licensed Material. For example, it may be
+ reasonable to satisfy the conditions by providing a URI or
+ hyperlink to a resource that includes the required
+ information.
+
+ 3. If requested by the Licensor, You must remove any of the
+ information required by Section 3(a)(1)(A) to the extent
+ reasonably practicable.
+
+ 4. If You Share Adapted Material You produce, the Adapter's
+ License You apply must not prevent recipients of the Adapted
+ Material from complying with this Public License.
+
+
+Section 4 -- Sui Generis Database Rights.
+
+Where the Licensed Rights include Sui Generis Database Rights that
+apply to Your use of the Licensed Material:
+
+ a. for the avoidance of doubt, Section 2(a)(1) grants You the right
+ to extract, reuse, reproduce, and Share all or a substantial
+ portion of the contents of the database;
+
+ b. if You include all or a substantial portion of the database
+ contents in a database in which You have Sui Generis Database
+ Rights, then the database in which You have Sui Generis Database
+ Rights (but not its individual contents) is Adapted Material; and
+
+ c. You must comply with the conditions in Section 3(a) if You Share
+ all or a substantial portion of the contents of the database.
+
+For the avoidance of doubt, this Section 4 supplements and does not
+replace Your obligations under this Public License where the Licensed
+Rights include other Copyright and Similar Rights.
+
+
+Section 5 -- Disclaimer of Warranties and Limitation of Liability.
+
+ a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
+ EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
+ AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
+ ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
+ IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
+ WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
+ PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
+ ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
+ KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
+ ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
+
+ b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
+ TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
+ NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
+ INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
+ COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
+ USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
+ ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
+ DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
+ IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
+
+ c. The disclaimer of warranties and limitation of liability provided
+ above shall be interpreted in a manner that, to the extent
+ possible, most closely approximates an absolute disclaimer and
+ waiver of all liability.
+
+
+Section 6 -- Term and Termination.
+
+ a. This Public License applies for the term of the Copyright and
+ Similar Rights licensed here. However, if You fail to comply with
+ this Public License, then Your rights under this Public License
+ terminate automatically.
+
+ b. Where Your right to use the Licensed Material has terminated under
+ Section 6(a), it reinstates:
+
+ 1. automatically as of the date the violation is cured, provided
+ it is cured within 30 days of Your discovery of the
+ violation; or
+
+ 2. upon express reinstatement by the Licensor.
+
+ For the avoidance of doubt, this Section 6(b) does not affect any
+ right the Licensor may have to seek remedies for Your violations
+ of this Public License.
+
+ c. For the avoidance of doubt, the Licensor may also offer the
+ Licensed Material under separate terms or conditions or stop
+ distributing the Licensed Material at any time; however, doing so
+ will not terminate this Public License.
+
+ d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
+ License.
+
+
+Section 7 -- Other Terms and Conditions.
+
+ a. The Licensor shall not be bound by any additional or different
+ terms or conditions communicated by You unless expressly agreed.
+
+ b. Any arrangements, understandings, or agreements regarding the
+ Licensed Material not stated herein are separate from and
+ independent of the terms and conditions of this Public License.
+
+
+Section 8 -- Interpretation.
+
+ a. For the avoidance of doubt, this Public License does not, and
+ shall not be interpreted to, reduce, limit, restrict, or impose
+ conditions on any use of the Licensed Material that could lawfully
+ be made without permission under this Public License.
+
+ b. To the extent possible, if any provision of this Public License is
+ deemed unenforceable, it shall be automatically reformed to the
+ minimum extent necessary to make it enforceable. If the provision
+ cannot be reformed, it shall be severed from this Public License
+ without affecting the enforceability of the remaining terms and
+ conditions.
+
+ c. No term or condition of this Public License will be waived and no
+ failure to comply consented to unless expressly agreed to by the
+ Licensor.
+
+ d. Nothing in this Public License constitutes or may be interpreted
+ as a limitation upon, or waiver of, any privileges and immunities
+ that apply to the Licensor or You, including from the legal
+ processes of any jurisdiction or authority.
+
+
+=======================================================================
+
+Creative Commons is not a party to its public licenses.
+Notwithstanding, Creative Commons may elect to apply one of its public
+licenses to material it publishes and in those instances will be
+considered the "Licensor." Except for the limited purpose of indicating
+that material is shared under a Creative Commons public license or as
+otherwise permitted by the Creative Commons policies published at
+creativecommons.org/policies, Creative Commons does not authorize the
+use of the trademark "Creative Commons" or any other trademark or logo
+of Creative Commons without its prior written consent including,
+without limitation, in connection with any unauthorized modifications
+to any of its public licenses or any other arrangements,
+understandings, or agreements concerning use of licensed material. For
+the avoidance of doubt, this paragraph does not form part of the public
+licenses.
+
+Creative Commons may be contacted at creativecommons.org.
+
+2) Font files and font tooling (Noto Emoji): SIL Open Font License, Version 1.1.
+Copyright 2021 Google Inc. All Rights Reserved
+$sil
+""",
),
- LicenseEntryWithLineBreaks(
- ["Roboto"],
- """Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
+ .new(
+ ["Inter"],
+ """Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
+Modified for use in this application by removing selected Unicode character mappings.
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-This license is copied below, and is also available with a FAQ at:
-https://openfontlicense.org
+$sil""",
+ ),
+ .new(
+ ["Atkinson Hyperlegible Mono"],
+ """Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono)
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded,
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply
-to any document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION & CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.""",
+$sil""",
),
];
diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart
new file mode 100644
index 0000000..7845576
--- /dev/null
+++ b/lib/helpers/hooks/chat_scroll.dart
@@ -0,0 +1,207 @@
+import "package:fast_immutable_collections/fast_immutable_collections.dart";
+import "package:flutter_hooks/flutter_hooks.dart";
+import "package:hooks_riverpod/hooks_riverpod.dart";
+import "package:material_ui/material_ui.dart";
+import "package:nexus/models/direction.dart";
+import "package:nexus/models/room_chat.dart";
+
+typedef JumpToEvent = Future Function(int rowId);
+
+final class ChatScroll({
+ required final IList historyRows,
+ required final IList liveRows,
+ required final GlobalKey centerKey,
+ required final ScrollController scrollController,
+ required final bool atBottom,
+ required final JumpToEvent jumpToEvent,
+ required final Future Function() jumpToBottom,
+ required final GlobalKey Function(int eventRowId) keyFor,
+}) {
+ factory use({
+ required AsyncValue controllerData,
+ required Future Function(Direction direction) paginate,
+ required Future Function() markRead,
+ required ValueNotifier contextualEvent,
+ }) {
+ final anchorId = useState(null);
+ final atBottom = useState(true);
+ final pendingJump = useState(null);
+ final scrollController = useScrollController();
+ final centerKey = useMemoized(GlobalKey.new);
+
+ final itemKeys = useMemoized(() => {}, []);
+ 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 history, IList 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(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 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,
+ );
+ },
+ );
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
index 98d9a92..3501d5c 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -3,9 +3,10 @@ import "dart:io";
import "package:dynamic_color/dynamic_color.dart";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
+import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:media_kit/media_kit.dart";
-import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
+import "package:nexus/controllers/gomuks_listener.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/member_list_opened.dart";
import "package:nexus/controllers/multi_provider.dart";
@@ -14,10 +15,10 @@ import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart";
import "package:nexus/helpers/extensions/scheme_to_theme.dart";
import "package:nexus/helpers/font_licenses.dart";
-import "package:nexus/pages/chat.dart";
-import "package:nexus/pages/select_server.dart";
-import "package:nexus/pages/settings.dart";
-import "package:nexus/pages/verify.dart";
+import "package:nexus/widgets/pages/chat.dart";
+import "package:nexus/widgets/pages/select_server.dart";
+import "package:nexus/widgets/pages/settings.dart";
+import "package:nexus/widgets/pages/verify.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
@@ -42,15 +43,7 @@ New Value: ${newValue is AsyncData ? newValue.value : newValue}
}
void showError(Object error, [StackTrace? stackTrace]) {
- if (error.toString().contains("DioException") ||
- error.toString().contains(
- "setState() or markNeedsBuild() called during build.",
- ) ||
- error.toString().contains("Invalid source") ||
- error.toString().contains("UTF-16") ||
- error.toString().contains("HTTP request failed") ||
- error.toString().contains("'_nextFrame != null': is not true.") ||
- error.toString().contains("Invalid image data")) {
+ if (error.toString().contains("'_nextFrame != null': is not true.")) {
return;
}
@@ -71,33 +64,34 @@ void main(List args) async {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
- if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
- await windowManager.ensureInitialized();
- await windowManager.waitUntilReadyToShow(
- WindowOptions(
- titleBarStyle: TitleBarStyle.hidden,
- windowButtonVisibility: false,
- ),
- );
- await windowManager.setMinimumSize(Size.square(500));
- }
-
- isInBackground =
- Platform.environment["FLUTTER_HEADLESS"] != null ||
- args.contains("--unifiedpush-bg");
-
LicenseRegistry.addLicense(() => .fromIterable(fontLicenses));
FlutterError.onError = (FlutterErrorDetails details) =>
showError(details.exception.toString(), details.stack);
- if (isInBackground) {
- await ProviderContainer().read(UnifiedPushController.provider.future);
+ isInBackground =
+ Platform.environment["FLUTTER_HEADLESS"] != null ||
+ args.contains("--unifiedpush-bg");
- // In case it didn't exit for some reason
- await Future.delayed(Duration(seconds: 20));
+ if (isInBackground) {
+ await ProviderContainer()
+ .read(UnifiedPushController.provider.future)
+ .timeout(Duration(seconds: 5));
+
+ await Future.delayed(Duration(seconds: 10));
exit(0);
} else {
+ if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
+ await windowManager.ensureInitialized();
+ await windowManager.waitUntilReadyToShow(
+ WindowOptions(
+ titleBarStyle: TitleBarStyle.hidden,
+ windowButtonVisibility: false,
+ ),
+ );
+ await windowManager.setMinimumSize(Size.square(500));
+ }
+
runApp(
ProviderScope(
retry: (_, _) => null,
@@ -147,57 +141,61 @@ class const App({super.key}) extends StatelessWidget {
),
home: child,
),
- child: Scaffold(
- body: Consumer(
- builder: (_, ref, _) => switch (ref.watch(
- MultiProviderController.provider(
- .new([
- ClientController.provider,
- NotificationController.provider,
- UnifiedPushController.provider,
- MemberListOpenedController.provider,
- KeyController.provider(KeyController.roomKey),
- KeyController.provider(KeyController.spaceKey),
- ]),
- ),
- )) {
- AsyncData(value: _) || AsyncLoading(value: _?) => Consumer(
- builder: (_, ref, _) {
- final clientState = ref.watch(ClientStateController.provider);
-
- if (clientState == null || !clientState.isInitialized) {
- return Loading();
- }
-
- if (!clientState.isLoggedIn) {
- return SelectServerPage();
- } else if (!clientState.isVerified) {
- return VerifyPage();
- } else {
- return ChatPage();
- }
- },
- ),
-
- AsyncLoading _ => Scaffold(
- appBar: Appbar(
- actions: .new([
- IconButton(
- onPressed: () => showDialog(
- context: context,
- builder: (_) => SettingsPage(),
- ),
- icon: Icon(Icons.settings),
- ),
+ child: DefaultTextHeightBehavior(
+ textHeightBehavior: .new(leadingDistribution: .even),
+ child: Scaffold(
+ body: Consumer(
+ builder: (context, ref, _) => switch (ref.watch(
+ MultiProviderController.provider(
+ .new([
+ GomuksListenerController.provider,
+ NotificationController.provider,
+ UnifiedPushController.provider,
+ MemberListOpenedController.provider,
+ KeyController.provider(KeyController.roomKey),
+ KeyController.provider(KeyController.spaceKey),
+ EmojiController.provider,
]),
),
- body: Loading(),
- ),
- AsyncError(:final error, :final stackTrace) => ErrorDialog(
- error,
- stackTrace,
- ),
- },
+ )) {
+ AsyncData(value: _) || AsyncLoading(value: _?) => Consumer(
+ builder: (_, ref, _) {
+ final clientState = ref.watch(ClientStateController.provider);
+
+ if (clientState == null || !clientState.isInitialized) {
+ return Loading();
+ }
+
+ if (!clientState.isLoggedIn) {
+ return SelectServerPage();
+ } else if (!clientState.isVerified) {
+ return VerifyPage();
+ } else {
+ return ChatPage();
+ }
+ },
+ ),
+
+ AsyncLoading _ => Scaffold(
+ appBar: Appbar(
+ actions: .new([
+ IconButton(
+ onPressed: () => showDialog(
+ context: context,
+ builder: (_) => SettingsPage(),
+ ),
+ icon: Icon(Icons.settings),
+ ),
+ ]),
+ ),
+ body: Loading(),
+ ),
+ AsyncError(:final error, :final stackTrace) => ErrorDialog(
+ error,
+ stackTrace,
+ ),
+ },
+ ),
),
),
),
diff --git a/lib/models/direction.dart b/lib/models/direction.dart
new file mode 100644
index 0000000..8c4947e
--- /dev/null
+++ b/lib/models/direction.dart
@@ -0,0 +1,8 @@
+import "package:freezed_annotation/freezed_annotation.dart";
+
+enum Direction {
+ @JsonValue("f")
+ forward,
+ @JsonValue("b")
+ backward,
+}
diff --git a/lib/models/event.dart b/lib/models/event.dart
index dc3dd46..de065fe 100644
--- a/lib/models/event.dart
+++ b/lib/models/event.dart
@@ -102,9 +102,6 @@ class const UnreadType(final int value) with _$UnreadType {
static const highlight = UnreadType(4);
static const sound = UnreadType(8);
- bool isNone() => value == 0;
- bool isNormal() => (value & 1) != 0;
- bool shouldNotify() => (value & 2) != 0;
- bool isHighlighted() => (value & 4) != 0;
- bool playsSound() => (value & 8) != 0;
+ bool has(UnreadType flag) => value & flag.value != 0;
+ UnreadType operator |(UnreadType other) => UnreadType(value | other.value);
}
diff --git a/lib/models/event_context.dart b/lib/models/event_context.dart
new file mode 100644
index 0000000..6103c7d
--- /dev/null
+++ b/lib/models/event_context.dart
@@ -0,0 +1,22 @@
+import "package:fast_immutable_collections/fast_immutable_collections.dart";
+import "package:freezed_annotation/freezed_annotation.dart";
+import "package:nexus/models/event.dart";
+
+part "event_context.freezed.dart";
+part "event_context.g.dart";
+
+@Freezed(toJson: false, fromJson: false)
+@JsonSerializable()
+class const EventContext({
+ required final Event event,
+ required final IList before,
+ required final IList after,
+ required final String start,
+ required final String end,
+ final IList relatedEvents = const IList.empty(),
+}) with _$EventContext {
+ Map toJson() => _$EventContextToJson(this);
+
+ factory EventContext.fromJson(Map json) =>
+ _$EventContextFromJson(json);
+}
diff --git a/lib/models/lazy_load_summary.dart b/lib/models/lazy_load_summary.dart
index f9a814e..fba1c98 100644
--- a/lib/models/lazy_load_summary.dart
+++ b/lib/models/lazy_load_summary.dart
@@ -7,8 +7,9 @@ part "lazy_load_summary.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const LazyLoadSummary({
- required final IList? heroes,
- required final int? joinedMemberCount,
+ @JsonKey(name: "m.heroes") required final IList? heroes,
+ @JsonKey(name: "m.joined_member_count") required final int? joinedMemberCount,
+ @JsonKey(name: "m.invited_member_count")
required final int? invitedMemberCount,
}) with _$LazyLoadSummary {
Map toJson() => _$LazyLoadSummaryToJson(this);
diff --git a/lib/models/nav_page.dart b/lib/models/nav_page.dart
new file mode 100644
index 0000000..2443c53
--- /dev/null
+++ b/lib/models/nav_page.dart
@@ -0,0 +1,8 @@
+import "package:flutter/widgets.dart";
+import "package:flutter_riverpod/flutter_riverpod.dart";
+
+abstract class const NavPage({super.key}) extends Widget {
+ String get title;
+ IconData get icon;
+ Future? badgeBuilder(WidgetRef ref) => null;
+}
diff --git a/lib/models/paginate_manual.dart b/lib/models/paginate_manual.dart
new file mode 100644
index 0000000..9f45c1f
--- /dev/null
+++ b/lib/models/paginate_manual.dart
@@ -0,0 +1,19 @@
+import "package:fast_immutable_collections/fast_immutable_collections.dart";
+import "package:freezed_annotation/freezed_annotation.dart";
+import "package:nexus/models/event.dart";
+
+part "paginate_manual.freezed.dart";
+part "paginate_manual.g.dart";
+
+@Freezed(toJson: false, fromJson: false)
+@JsonSerializable()
+class const PaginateManual({
+ required final IList events,
+ final IList relatedEvents = const IList.empty(),
+ required final String? nextBatch,
+}) with _$PaginateManual {
+ Map toJson() => _$PaginateManualToJson(this);
+
+ factory PaginateManual.fromJson(Map json) =>
+ _$PaginateManualFromJson(json);
+}
diff --git a/lib/models/profile_response.dart b/lib/models/profile_response.dart
index fcaaf2e..57f76c5 100644
--- a/lib/models/profile_response.dart
+++ b/lib/models/profile_response.dart
@@ -57,7 +57,7 @@ class const Profile({
try {
return Profile.fromJson(json);
} catch (error) {
- return _Profile(parseError: error.toString());
+ return Profile(parseError: error.toString());
}
}
}
diff --git a/lib/models/requests/get_event_context.dart b/lib/models/requests/get_event_context.dart
new file mode 100644
index 0000000..68f23a0
--- /dev/null
+++ b/lib/models/requests/get_event_context.dart
@@ -0,0 +1,17 @@
+import "package:freezed_annotation/freezed_annotation.dart";
+
+part "get_event_context.freezed.dart";
+part "get_event_context.g.dart";
+
+@Freezed(toJson: false, fromJson: false)
+@JsonSerializable()
+class const GetEventContextRequest({
+ required final String roomId,
+ required final String eventId,
+ final int limit = 20,
+}) with _$GetEventContextRequest {
+ Map toJson() => _$GetEventContextRequestToJson(this);
+
+ factory GetEventContextRequest.fromJson(Map json) =>
+ _$GetEventContextRequestFromJson(json);
+}
diff --git a/lib/models/requests/get_mentions.dart b/lib/models/requests/get_mentions.dart
index 5df6870..1512b6f 100644
--- a/lib/models/requests/get_mentions.dart
+++ b/lib/models/requests/get_mentions.dart
@@ -9,7 +9,7 @@ part "get_mentions.g.dart";
class GetMentionsRequest({
@EpochDateTimeConverter() required final DateTime maxTimestamp,
@JsonKey(name: "type") required final UnreadType unreadType,
- required final int limit,
+ final int limit = 20,
final String? roomId,
}) with _$GetMentionsRequest {
Map toJson() => _$GetMentionsRequestToJson(this);
diff --git a/lib/models/requests/paginate_manual.dart b/lib/models/requests/paginate_manual.dart
new file mode 100644
index 0000000..9f8a1d3
--- /dev/null
+++ b/lib/models/requests/paginate_manual.dart
@@ -0,0 +1,22 @@
+import "package:freezed_annotation/freezed_annotation.dart";
+import "package:nexus/models/direction.dart";
+
+part "paginate_manual.freezed.dart";
+part "paginate_manual.g.dart";
+
+@Freezed(toJson: false, fromJson: false)
+@JsonSerializable()
+class const PaginateManualRequest({
+ required final String roomId,
+ // Root event ID of a thread to paginate
+ final String? threadRoot,
+ // Can be null for starting pagination of a thread
+ final String? since,
+ required final Direction direction,
+ final int limit = 20,
+}) with _$PaginateManualRequest {
+ Map toJson() => _$PaginateManualRequestToJson(this);
+
+ factory PaginateManualRequest.fromJson(Map json) =>
+ _$PaginateManualRequestFromJson(json);
+}
diff --git a/lib/models/requests/send_message.dart b/lib/models/requests/send_message.dart
index e9bbbb2..9907439 100644
--- a/lib/models/requests/send_message.dart
+++ b/lib/models/requests/send_message.dart
@@ -33,7 +33,8 @@ class const Mentions({
_$MentionsFromJson(json);
}
-@Freezed(toJson: false)
+@Freezed(toJson: false, fromJson: false)
+@JsonSerializable(createToJson: false)
class const Relation({
required final String eventId,
required final RelationType relationType,
diff --git a/lib/models/requests/set_membership.dart b/lib/models/requests/set_membership.dart
index 9d88da2..9b8a03f 100644
--- a/lib/models/requests/set_membership.dart
+++ b/lib/models/requests/set_membership.dart
@@ -10,7 +10,7 @@ class const SetMembershipRequest({
required final String userId,
required final String roomId,
final String? reason,
- @JsonKey(name: "action") required final MembershipAction action,
+ required final MembershipAction action,
@JsonKey(name: "msc4293_redact_events") final bool redact = false,
}) with _$SetMembershipRequest {
Map toJson() => _$SetMembershipRequestToJson(this);
diff --git a/lib/models/room.dart b/lib/models/room.dart
index 6d7553f..24b0c5e 100644
--- a/lib/models/room.dart
+++ b/lib/models/room.dart
@@ -12,17 +12,28 @@ part "room.g.dart";
class const Room({
@JsonKey(name: "meta") final RoomMetadata? metadata,
+ /// [timeline] is an IMap of timelineRowId to eventRowId
@JsonKey(fromJson: Room.timelineTupleJsonToIMap)
final IMap timeline = const IMap.empty(),
+ /// [clientSticky] is an ISet of eventRowId
+ @JsonKey(includeFromJson: false, includeToJson: false)
final ISet clientSticky = const ISet.empty(),
+ /// [events] is an IMap of eventRowId to event
@JsonKey(fromJson: Room.eventsJsonToIMap)
final IMap 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> state = const IMap.empty(),
final IMap> receipts = const IMap.empty(),
@@ -32,9 +43,6 @@ class const Room({
// IMap accountData,
// IList 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 timelineTupleJsonToIMap(List json) =>
IMap.fromEntries(
json.map(
diff --git a/lib/models/room_chat.dart b/lib/models/room_chat.dart
new file mode 100644
index 0000000..e784222
--- /dev/null
+++ b/lib/models/room_chat.dart
@@ -0,0 +1,32 @@
+import "package:fast_immutable_collections/fast_immutable_collections.dart";
+import "package:freezed_annotation/freezed_annotation.dart";
+
+part "room_chat.freezed.dart";
+part "room_chat.g.dart";
+
+@Freezed(toJson: false, fromJson: false)
+@JsonSerializable()
+class const RoomChat({
+ /// List of eventRowId
+ required final IList timeline,
+ required final bool hasMoreForward,
+ required final bool hasMoreBackward,
+ final HistoricalData? historicalData,
+}) with _$RoomChat {
+ Map toJson() => _$RoomChatToJson(this);
+
+ factory RoomChat.fromJson(Map json) =>
+ _$RoomChatFromJson(json);
+}
+
+@Freezed(toJson: false, fromJson: false)
+@JsonSerializable()
+class const HistoricalData({
+ required final String start,
+ required final String end,
+}) with _$HistoricalData {
+ Map toJson() => _$HistoricalDataToJson(this);
+
+ factory HistoricalData.fromJson(Map json) =>
+ _$HistoricalDataFromJson(json);
+}
diff --git a/lib/models/room_metadata.dart b/lib/models/room_metadata.dart
index 3aafd0e..822fcda 100644
--- a/lib/models/room_metadata.dart
+++ b/lib/models/room_metadata.dart
@@ -9,9 +9,6 @@ part "room_metadata.g.dart";
@JsonSerializable()
class const RoomMetadata({
@JsonKey(name: "room_id") required final String id,
-
- // CreateEventContent creationContent,
- // TombstoneEventContent tombstoneEventContent,
final String? name,
final Uri? avatar,
final String? dmUserId,
diff --git a/lib/models/room_summary.dart b/lib/models/room_summary.dart
index 7c2d6d2..d0a6bee 100644
--- a/lib/models/room_summary.dart
+++ b/lib/models/room_summary.dart
@@ -9,7 +9,7 @@ part "room_summary.g.dart";
@JsonSerializable()
class const RoomSummary({
required final String roomId,
- @JsonKey(name: "num_joined_members") required final int joinedMembers,
+ @JsonKey(name: "num_joined_members") final int? joinedMembers,
final JoinRule? joinRule,
final String? name,
final Uri? avatarUrl,
diff --git a/lib/models/settings.dart b/lib/models/settings.dart
index 7f73375..97cd6ca 100644
--- a/lib/models/settings.dart
+++ b/lib/models/settings.dart
@@ -10,6 +10,8 @@ class const Settings({
final ThemeMode theme = ThemeMode.system,
final bool useDynamicTheming = true,
final bool linuxMobileMode = false,
+ final bool showAllInHome = false,
+ final bool showMinimizeButton = false,
}) with _$Settings {
Map toJson() => _$SettingsToJson(this);
diff --git a/lib/pages/notifications.dart b/lib/pages/notifications.dart
deleted file mode 100644
index 3e70104..0000000
--- a/lib/pages/notifications.dart
+++ /dev/null
@@ -1,164 +0,0 @@
-import "dart:async";
-
-import "package:m3e_buttons/m3e_buttons.dart";
-import "package:material_ui/material_ui.dart";
-import "package:flutter_hooks/flutter_hooks.dart";
-import "package:hooks_riverpod/hooks_riverpod.dart";
-import "package:nexus/controllers/notifications.dart";
-import "package:nexus/models/event.dart";
-import "package:nexus/widgets/appbar.dart";
-import "package:nexus/widgets/error_dialog.dart";
-import "package:nexus/widgets/highlight_wrapper.dart";
-import "package:nexus/widgets/loading.dart";
-import "package:nexus/widgets/renderers/event.dart";
-import "package:super_sliver_list/super_sliver_list.dart";
-
-class const NotificationsPage({
- final String? highlightedEventId,
- final bool defaultToAllNotifications = false,
- super.key,
-}) extends HookConsumerWidget {
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final options = {
- M3EToggleButtonGroupAction(
- 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 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(),
- ),
- ),
- ),
- ],
- ),
- );
- }
-}
diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart
index 33727ac..a6489a2 100644
--- a/lib/widgets/appbar.dart
+++ b/lib/widgets/appbar.dart
@@ -51,6 +51,17 @@ final class const Appbar({
actions: [
...actions,
if (!(Platform.isAndroid || Platform.isIOS)) ...[
+ if (ref
+ .watch(SettingsController.provider)
+ .whenOrNull(
+ data: (settings) => settings.showMinimizeButton,
+ ) ??
+ false)
+ IconButton(
+ tooltip: "Minimize window",
+ onPressed: windowManager.minimize,
+ icon: const Icon(Icons.minimize),
+ ),
if (!Platform.isLinux)
IconButton(
tooltip: "Maximize window",
diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart
index 982d56e..bdaf657 100644
--- a/lib/widgets/avatar_or_hash.dart
+++ b/lib/widgets/avatar_or_hash.dart
@@ -7,25 +7,24 @@ final class const AvatarOrHash(
final Uri? avatar,
final String title, {
final Widget? fallback,
- final double height = 24,
+ final double dimension = 24,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final box = ColoredBox(
color: ColorHash(title).color,
- child: Center(child: Icon(Icons.person, size: height / 2)),
+ child: Center(child: Icon(Icons.person, size: dimension / 2)),
);
return SizedBox(
- width: height,
- height: height,
+ width: dimension,
+ height: dimension,
child: Center(
child: ClipRRect(
- borderRadius: .all(.circular((height - 8) / 2.5)),
- child: SizedBox(
- width: height,
- height: height,
+ borderRadius: .all(.circular((dimension - 8) / 2.5)),
+ child: SizedBox.square(
+ dimension: dimension,
child: avatar == null
? fallback ?? box
: Image(
diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart
index 3d04a78..cc3a49a 100644
--- a/lib/widgets/composer/composer.dart
+++ b/lib/widgets/composer/composer.dart
@@ -2,6 +2,7 @@ import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:file_selector/file_selector.dart";
+import "package:nexus/controllers/rooms.dart";
import "package:nexus/widgets/emoji_picker.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter/services.dart";
@@ -12,7 +13,6 @@ import "package:nexus/controllers/attachment.dart";
import "package:nexus/controllers/image_picker.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/models/content/message.dart";
-import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
import "package:nexus/widgets/composer/relation_preview.dart";
@@ -20,7 +20,7 @@ import "package:nexus/main.dart";
class const Composer(
final String roomId, {
- required final Event? relatedEvent,
+ required final int? relatedEvent,
required final RelationType relationType,
required final VoidCallback onDismiss,
required final Future Function(
@@ -35,19 +35,57 @@ class const Composer(
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
- final controller = useRef(FlutterTaggerController());
+ final controller = useMemoized(FlutterTaggerController.new);
final triggerCharacter = useState("");
final shouldMention = useState(true);
final query = useState("");
+ final relatedEvent = this.relatedEvent == null
+ ? null
+ : ref.watch(
+ RoomsController.provider.select((rooms) {
+ final event = rooms[roomId]?.events[this.relatedEvent!];
+ if (event == null) return null;
- if (relationType == .edit && controller.value.text.isEmpty) {
- controller.value.text =
- relatedEvent?.localContent?.editSource ??
- switch (relatedEvent?.content) {
- TextMessageContent(:final body) => body,
- _ => "",
- };
- }
+ final edited = event.lastEditRowId == 0
+ ? null
+ : rooms[roomId]?.events[event.lastEditRowId];
+
+ return edited == null
+ ? event
+ : event.copyWith(
+ content: edited.content,
+ localContent: edited.localContent,
+ );
+ }),
+ );
+
+ final textBefore = useState(null);
+
+ useEffect(() {
+ if (relationType == .edit && relatedEvent != null) {
+ textBefore.value ??= controller.text;
+
+ final text =
+ relatedEvent.localContent?.editSource ??
+ switch (relatedEvent.content) {
+ TextMessageContent(:final body) => body,
+ _ => "",
+ };
+
+ controller.value = .new(
+ text: text,
+ selection: .collapsed(offset: text.length),
+ );
+ } else if (textBefore.value case final draft?) {
+ controller.value = .new(
+ text: draft,
+ selection: .collapsed(offset: draft.length),
+ );
+ textBefore.value = null;
+ }
+
+ return null;
+ }, [relationType, relatedEvent == null]);
final attachment = ref.watch(AttachmentController.provider(roomId));
@@ -57,13 +95,13 @@ class const Composer(
return;
}
onSend(
- controller.value.formattedText,
+ controller.formattedText,
shouldMention: shouldMention.value,
- tags: .new(controller.value.tags),
+ tags: .new(controller.tags),
);
onDismiss();
- controller.value.text = "";
+ controller.text = "";
}
final style = TextStyle(
@@ -108,9 +146,7 @@ class const Composer(
),
Container(
color: theme.colorScheme.surfaceContainerHighest,
- padding: .symmetric(horizontal: 8),
child: Row(
- spacing: 8,
mainAxisAlignment: .center,
children:
ref.watch(
@@ -126,7 +162,7 @@ class const Composer(
builder: (context) => EmojiPicker(
onSelection: (value) {
Navigator.of(context).pop();
- controller.value.text += value;
+ controller.text += value;
node?.requestFocus();
},
),
@@ -144,7 +180,7 @@ class const Composer(
leading: Icon(Icons.add_a_photo),
),
onTap: () async => ref
- .watch(
+ .read(
AttachmentController.provider(roomId)
.notifier,
)
@@ -163,7 +199,7 @@ class const Composer(
leading: Icon(Icons.add_photo_alternate),
),
onTap: () async => ref
- .watch(
+ .read(
AttachmentController.provider(roomId)
.notifier,
)
@@ -192,6 +228,7 @@ class const Composer(
],
icon: Icon(Icons.add),
),
+ SizedBox(width: 8),
Expanded(
child: FlutterTagger(
triggerStrategy: .eager,
@@ -200,11 +237,11 @@ class const Composer(
roomId: roomId,
triggerCharacter: triggerCharacter.value,
addTag: ({required id, required name}) {
- controller.value.addTag(id: id, name: name);
+ controller.addTag(id: id, name: name);
node?.requestFocus();
},
),
- controller: controller.value,
+ controller: controller,
onSearch: (newQuery, newTriggerCharacter) {
triggerCharacter.value = newTriggerCharacter;
query.value = newQuery;
@@ -234,17 +271,16 @@ class const Composer(
child: TextField(
maxLines: 12,
minLines: 1,
+ textCapitalization: .sentences,
autofocus:
(Platform.isLinux ||
- Platform.isMacOS ||
- Platform.isWindows)
- ? true
- : false,
+ Platform.isMacOS ||
+ Platform.isWindows),
decoration: .new(
hintText: "Your message here...",
border: .none,
),
- controller: controller.value,
+ controller: controller,
key: key,
focusNode: node,
),
diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart
index a0c691a..453213a 100644
--- a/lib/widgets/emoji_picker.dart
+++ b/lib/widgets/emoji_picker.dart
@@ -21,12 +21,15 @@ class const EmojiPicker({
.watch(upstream.EmojiController.provider)
.betterWhen(
data: (categories) => upstream.EmojiPicker(
- onSelection: (emoji) async {
+ onSelection: (emoji, isFreeText) async {
await onSelection(emoji);
- await ref
- .watch(RecentEmojiController.provider.notifier)
- .add(emoji)
- .onError(showError);
+
+ if (!isFreeText) {
+ await ref
+ .watch(RecentEmojiController.provider.notifier)
+ .add(emoji)
+ .onError(showError);
+ }
},
allowFreeText: allowFreeText,
prependCategories: .new([
diff --git a/lib/widgets/event_preview.dart b/lib/widgets/event_preview.dart
index 30dfbfe..1b88fa8 100644
--- a/lib/widgets/event_preview.dart
+++ b/lib/widgets/event_preview.dart
@@ -13,18 +13,23 @@ class const EventPreview(final Event event, {super.key})
padding: .symmetric(vertical: 4),
child: Row(
mainAxisSize: .min,
- spacing: 12,
+ spacing: 8,
children: [
- if (event.content is MessageContent) MessageAvatar(event),
+ if (event.content is MessageContent) MessageAvatar(event, height: 36),
Flexible(
- child: Wrap(
- crossAxisAlignment: .center,
- spacing: 8,
- runSpacing: 2,
+ child: Column(
+ mainAxisAlignment: .center,
+ crossAxisAlignment: .start,
children: [
- if (event.content is MessageContent) MessageDisplayname(event),
- EventRenderer(event, textOnly: true, maxLines: 1),
+ if (event.content is MessageContent)
+ MessageDisplayname(event, shouldWrap: false),
+ EventRenderer(
+ event.rowId,
+ roomId: event.roomId,
+ textOnly: true,
+ maxLines: 1,
+ ),
],
),
),
diff --git a/lib/widgets/html/code_block.dart b/lib/widgets/html/code_block.dart
index abd9be2..1881bc6 100644
--- a/lib/widgets/html/code_block.dart
+++ b/lib/widgets/html/code_block.dart
@@ -25,7 +25,7 @@ class const CodeBlock(
padding: .symmetric(horizontal: 8),
child: Text(
lang.substring(0, min(lang.length, 15)),
- style: .new(fontFamily: "monospace"),
+ style: .new(fontFamily: "bundled-mono"),
),
),
TextButton.icon(
@@ -44,7 +44,7 @@ class const CodeBlock(
code,
minLines: 1,
maxLines: 99,
- style: .new(fontFamily: "monospace"),
+ style: .new(fontFamily: "bundled-mono"),
),
),
),
diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart
index 85f8054..7c99471 100644
--- a/lib/widgets/html/html.dart
+++ b/lib/widgets/html/html.dart
@@ -2,7 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart";
-import "package:nexus/helpers/extensions/link_to_mention.dart";
+import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/helpers/mxc_image.dart";
import "package:nexus/widgets/expandable_image.dart";
@@ -51,14 +51,28 @@ class const Html(
element.text,
lang: element.className.replaceAll("language-", ""),
)
- : null,
+ : InlineCustomWidget(
+ child: ClipRRect(
+ borderRadius: .all(.circular(4)),
+ child: Container(
+ padding: .all(8),
+ color: Theme.of(context)
+ .colorScheme
+ .surfaceContainerHighest,
+ child: SelectableText(
+ element.text,
+ style: .new(fontFamily: "bundled-mono"),
+ ),
+ ),
+ ),
+ ),
"blockquote" => Quoted(
Html(element.innerHtml, textStyle: textStyle, roomId: roomId),
),
"a" =>
- element.attributes["href"]?.mention == null
+ element.attributes["href"]?.linkInfo == null
? null
: InlineCustomWidget(
child: MentionChip(element.attributes["href"]!, roomId),
diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart
index 2387a49..29e2310 100644
--- a/lib/widgets/html/mention_chip.dart
+++ b/lib/widgets/html/mention_chip.dart
@@ -1,17 +1,21 @@
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
+import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/room_summary.dart";
import "package:nexus/controllers/user.dart";
-import "package:nexus/helpers/extensions/link_to_mention.dart";
+import "package:nexus/helpers/extensions/focus_room.dart";
+import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/room_summary.dart";
+import "package:nexus/widgets/room_summary_dialog.dart";
class const MentionChip(final String content, final String? roomId, {super.key})
extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final mention = content.mention;
+ final linkInfo = content.linkInfo;
+ final mention = linkInfo?.identifier;
final data = switch (mention?.characters.firstOrNull) {
"@" =>
ref
@@ -22,10 +26,11 @@ class const MentionChip(final String content, final String? roomId, {super.key})
"#" || "!" =>
ref
- .watch(
- RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
- )
- .whenOrNull(data: (data) => data),
+ .watch(
+ RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
+ )
+ .whenOrNull(data: (data) => data) ??
+ RoomSummary(roomId: mention),
_ => null,
};
@@ -33,19 +38,49 @@ class const MentionChip(final String content, final String? roomId, {super.key})
return mention == null
? SizedBox.shrink()
: InkWell(
- onTap: () {
+ onTap: () async {
if (data case MembershipContent membership) {
context.showUserPopover(membership, mention, roomId: roomId);
} else if (data case RoomSummary summary) {
- // TODO: Handle summary
+ // TODO: This is an extra call that isn't truly needed, get rid of it
+ final eventRowId = linkInfo?.eventId == null
+ ? null
+ : await ref
+ .read(
+ EventController.provider(
+ .new(
+ roomId: summary.roomId,
+ eventId: linkInfo!.eventId!,
+ ),
+ ).selectAsync((data) => data?.rowId),
+ )
+ .onError((_, _) => null);
+
+ if (!await ref.focusRoom(summary.roomId, eventRowId) &&
+ context.mounted) {
+ showDialog(
+ context: context,
+ builder: (context) => Consumer(
+ builder: (context, ref, _) => RoomSummaryDialog(
+ ref
+ .watch(
+ RoomSummaryController.provider(
+ .new(roomIdOrAlias: mention),
+ ),
+ )
+ .whenOrNull(data: (data) => data) ??
+ summary,
+ via: linkInfo?.via,
+ ),
+ ),
+ );
+ }
}
},
child: Text(
switch (data) {
RoomSummary summary =>
- (summary.name == null ? null : "#${summary.name}") ??
- summary.canonicalAlias ??
- summary.roomId,
+ "${(summary.name == null ? null : "#${summary.name}") ?? summary.canonicalAlias ?? summary.roomId}${linkInfo?.eventId == null ? "" : " > ${linkInfo?.eventId}"}",
MembershipContent membership =>
membership.displayName == null
? mention
diff --git a/lib/widgets/invites_list.dart b/lib/widgets/invites_list.dart
new file mode 100644
index 0000000..35dd26f
--- /dev/null
+++ b/lib/widgets/invites_list.dart
@@ -0,0 +1,22 @@
+import "package:flutter_riverpod/flutter_riverpod.dart";
+import "package:material_ui/material_ui.dart";
+import "package:nexus/models/nav_page.dart";
+
+class const InvitesList({super.key})
+ extends StatelessWidget
+ implements NavPage {
+ @override
+ String get title => "Invites";
+
+ @override
+ IconData get icon => Icons.inbox;
+
+ // TODO: Show notification badge here
+ @override
+ Future? badgeBuilder(WidgetRef ref) => null;
+
+ @override
+ Widget build(BuildContext context) {
+ return const Placeholder();
+ }
+}
diff --git a/lib/widgets/join_dialog.dart b/lib/widgets/join_dialog.dart
index e818c7d..b559023 100644
--- a/lib/widgets/join_dialog.dart
+++ b/lib/widgets/join_dialog.dart
@@ -1,16 +1,40 @@
-import "package:collection/collection.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
-import "package:nexus/controllers/client.dart";
-import "package:nexus/controllers/key.dart";
-import "package:nexus/controllers/spaces.dart";
-import "package:nexus/helpers/extensions/link_to_mention.dart";
+import "package:nexus/controllers/room_summary.dart";
+import "package:nexus/helpers/extensions/get_link_info.dart";
+import "package:nexus/widgets/room_summary_dialog.dart";
class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
@override
Widget build(BuildContext context) {
final roomAlias = useTextEditingController();
+
+ Future onJoin() async {
+ Navigator.of(context).pop();
+
+ final linkInfo = roomAlias.text.linkInfo;
+ final via = linkInfo?.via ?? const .empty();
+ final roomIdOrAlias = linkInfo?.identifier ?? roomAlias.text;
+
+ showDialog(
+ context: context,
+ builder: (context) => Consumer(
+ builder: (context, ref, _) => switch (ref.watch(
+ RoomSummaryController.provider(
+ .new(roomIdOrAlias: roomIdOrAlias, via: via),
+ ),
+ )) {
+ AsyncData(:final value) => RoomSummaryDialog(value, via: via),
+ AsyncError _ || AsyncLoading _ => RoomSummaryDialog(
+ .new(roomId: roomIdOrAlias),
+ via: via,
+ ),
+ },
+ ),
+ );
+ }
+
return AlertDialog(
title: Text("Join a Room"),
content: Column(
@@ -21,111 +45,14 @@ class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
SizedBox(height: 12),
TextField(
controller: roomAlias,
- decoration: .new(hintText: "#room:server"),
+ decoration: .new(hintText: "#room:server.name"),
+ onEditingComplete: onJoin,
),
],
),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
- TextButton(
- onPressed: () async {
- Navigator.of(context).pop();
-
- if (context.mounted) {
- final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text;
-
- final scaffoldMessenger = ScaffoldMessenger.of(context);
-
- final snackbar = scaffoldMessenger.showSnackBar(
- .new(
- content: Text("Joining room $roomIdOrAlias."),
- duration: Duration(days: 999),
- ),
- );
-
- try {
- final id = await ref
- .watch(ClientController.provider.notifier)
- .joinRoom(
- .new(
- roomIdOrAlias: roomIdOrAlias,
- via: .new(
- Uri.tryParse(roomAlias.text.replaceAll("/#", ""))
- ?.queryParametersAll["via"] ??
- [],
- ),
- ),
- );
-
- snackbar.close();
-
- scaffoldMessenger.showSnackBar(
- .new(
- content: Text("Room $roomIdOrAlias successfully joined."),
- action: .new(
- label: "Open",
- onPressed: () async {
- final spaces = ref.watch(SpacesController.provider);
- final space = spaces.firstWhereOrNull(
- (space) => space.id == id,
- );
-
- await ref
- .watch(
- KeyController.provider(KeyController.spaceKey)
- .notifier,
- )
- .set(
- space?.id ??
- spaces
- .firstWhere(
- (space) =>
- space.children.any(
- (child) =>
- child.metadata?.id == id,
- ) ||
- space.subSpaces.any(
- (child) =>
- child.room.metadata?.id == id,
- ),
- )
- .id,
- );
-
- if (space == null) {
- await ref
- .watch(
- KeyController.provider(KeyController.roomKey)
- .notifier,
- )
- .set(id);
- }
- },
- ),
- ),
- );
- } catch (error) {
- snackbar.close();
- if (context.mounted) {
- scaffoldMessenger.showSnackBar(
- .new(
- backgroundColor: Theme.of(context)
- .colorScheme
- .errorContainer,
- content: Text(
- error.toString(),
- style: .new(
- color: Theme.of(context).colorScheme.onErrorContainer,
- ),
- ),
- ),
- );
- }
- }
- }
- },
- child: Text("Join"),
- ),
+ TextButton(onPressed: onJoin, child: Text("Join")),
],
);
}
diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart
index 709d558..50b612a 100644
--- a/lib/widgets/lazy_loading/message_avatar.dart
+++ b/lib/widgets/lazy_loading/message_avatar.dart
@@ -21,9 +21,9 @@ class const MessageAvatar(
child: AvatarOrHash(
value.avatarUrl,
value.displayName ?? event.sender.localpart,
- height: height,
+ dimension: height,
),
),
- _ => AvatarOrHash(null, event.sender.localpart, height: height),
+ _ => AvatarOrHash(null, event.sender.localpart, dimension: height),
};
}
diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart
index 9203f69..593e755 100644
--- a/lib/widgets/lazy_loading/message_displayname.dart
+++ b/lib/widgets/lazy_loading/message_displayname.dart
@@ -5,50 +5,57 @@ import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/helpers/extensions/string_to_color.dart";
import "package:nexus/models/event.dart";
+import "package:nexus/models/content/membership.dart";
class const MessageDisplayname(
final Event event, {
final TextStyle? style,
final bool clickable = true,
+ final bool shouldWrap = true,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(
AuthorController.provider(event),
)) {
- AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
- onTap: clickable
+ AsyncData(:final MembershipContent? value) ||
+ AsyncLoading(:final value) ||
+ AsyncError(:final value) => InkWell(
+ onTap: clickable && value != null
? () => context.showUserPopover(
value,
event.sender,
roomId: event.roomId,
)
: null,
- child: Wrap(
- spacing: 4,
- crossAxisAlignment: .center,
- children: [
+ child: switch ([
+ Text(
+ value?.displayName ?? event.sender.localpart,
+ style:
+ style ?? .new(color: event.sender.colorHash, fontWeight: .bold),
+ maxLines: 1,
+ overflow: .ellipsis,
+ ),
+ if (event.pmp != null)
Text(
- value.displayName ?? event.sender.localpart,
- style:
- style ?? .new(color: event.sender.colorHash, fontWeight: .bold),
+ "(via ${event.sender})",
+ style: Theme.of(context).textTheme.labelSmall
+ ?.copyWith(color: event.sender.colorHash, fontWeight: .bold),
maxLines: 1,
overflow: .ellipsis,
),
- if (event.pmp != null)
- Text(
- "(via ${event.sender})",
- style: Theme.of(context).textTheme.labelSmall
- ?.copyWith(color: event.sender.colorHash, fontWeight: .bold),
- maxLines: 1,
- overflow: .ellipsis,
- ),
- ],
- ),
- ),
- _ => Text(
- event.sender.localpart,
- style: .new(color: event.sender.colorHash, fontWeight: .bold),
+ ]) {
+ final children when shouldWrap => Wrap(
+ spacing: 4,
+ crossAxisAlignment: .center,
+ children: children,
+ ),
+ final children => Row(
+ spacing: 4,
+ mainAxisSize: .min,
+ children: [for (final child in children) Flexible(child: child)],
+ ),
+ },
),
};
}
diff --git a/lib/widgets/loading.dart b/lib/widgets/loading.dart
index 180cd1b..e92860e 100644
--- a/lib/widgets/loading.dart
+++ b/lib/widgets/loading.dart
@@ -1,11 +1,15 @@
import "package:material_ui/material_ui.dart";
-class const Loading({super.key, final double? height}) extends StatelessWidget {
+class const Loading({super.key, final double? dimension})
+ extends StatelessWidget {
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: .all(16),
- child: SizedBox(height: height, child: CircularProgressIndicator()),
+ child: SizedBox.square(
+ dimension: dimension,
+ child: CircularProgressIndicator(),
+ ),
),
);
}
diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart
index 9ddafa3..ac0cb2a 100644
--- a/lib/widgets/member_list.dart
+++ b/lib/widgets/member_list.dart
@@ -7,6 +7,7 @@ import "package:m3e_card_list/m3e_card_list.dart";
import "package:nexus/controllers/members_by_status.dart";
import "package:nexus/controllers/members_grouped.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
+import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/helpers/extensions/string_to_color.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/membership_status.dart";
@@ -14,7 +15,6 @@ import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
-import "package:nexus/widgets/user_bottom_sheet.dart";
class const MemberList(final String roomId, {super.key})
extends HookConsumerWidget {
@@ -123,13 +123,16 @@ class const MemberList(final String roomId, {super.key})
members[index].stateKey!,
overflow: .ellipsis,
),
- leading: AvatarOrHash(
- avatarUrl,
- height: 36,
- displayName ??
- members[index]
- .stateKey!
- .localpart,
+ leading: Transform.translate(
+ offset: .new(0, 2),
+ child: AvatarOrHash(
+ avatarUrl,
+ dimension: 40,
+ displayName ??
+ members[index]
+ .stateKey!
+ .localpart,
+ ),
),
),
_ => throw Exception(
@@ -140,20 +143,10 @@ class const MemberList(final String roomId, {super.key})
final member = members[index];
if (member.content
case MembershipContent content) {
- showModalBottomSheet(
- constraints: .loose(
- .new(
- 500,
- (context.size?.height ?? 1000) - 80,
- ),
- ),
- isScrollControlled: true,
- context: context,
- builder: (context) => UserBottomSheet(
- content,
- member.stateKey!,
- roomId: roomId,
- ),
+ context.showUserPopover(
+ content,
+ member.stateKey!,
+ roomId: roomId,
);
}
},
diff --git a/lib/widgets/notifications_list.dart b/lib/widgets/notifications_list.dart
new file mode 100644
index 0000000..fde1bd5
--- /dev/null
+++ b/lib/widgets/notifications_list.dart
@@ -0,0 +1,176 @@
+import "dart:async";
+
+import "package:m3e_buttons/m3e_buttons.dart";
+import "package:material_ui/material_ui.dart";
+import "package:flutter_hooks/flutter_hooks.dart";
+import "package:hooks_riverpod/hooks_riverpod.dart";
+import "package:nexus/controllers/notifications.dart";
+import "package:nexus/helpers/extensions/focus_room.dart";
+import "package:nexus/models/event.dart";
+import "package:nexus/models/nav_page.dart";
+import "package:nexus/widgets/error_dialog.dart";
+import "package:nexus/widgets/highlight_wrapper.dart";
+import "package:nexus/widgets/loading.dart";
+import "package:nexus/widgets/renderers/event.dart";
+import "package:super_sliver_list/super_sliver_list.dart";
+
+class const NotificationsList({
+ final int? highlightedEvent,
+ final bool defaultToAllNotifications = false,
+ super.key,
+}) extends HookConsumerWidget implements NavPage {
+ @override
+ String get title => "Notifications";
+
+ @override
+ IconData get icon => Icons.notifications;
+
+ // TODO: Show notification badge here
+ @override
+ Future? badgeBuilder(WidgetRef ref) => null;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final options = {
+ 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 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(),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/pages/chat.dart b/lib/widgets/pages/chat.dart
similarity index 83%
rename from lib/pages/chat.dart
rename to lib/widgets/pages/chat.dart
index fb165d3..74aea14 100644
--- a/lib/pages/chat.dart
+++ b/lib/widgets/pages/chat.dart
@@ -4,10 +4,10 @@ import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/sidebar.dart";
-import "package:nexus/widgets/room_chat.dart";
+import "package:nexus/widgets/room_chat/room_chat.dart";
import "package:nexus/widgets/loading.dart";
-class const ChatPage({super.key}) extends ConsumerWidget {
+class const ChatPage({super.key}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
builder: (context, constraints) {
@@ -24,9 +24,10 @@ class const ChatPage({super.key}) extends ConsumerWidget {
body: initComplete
? Row(
children: [
- if (isDesktop) Sidebar(isDesktop: isDesktop),
+ if (isDesktop) Sidebar(),
Expanded(
child: RoomChat(
+ key: ValueKey(roomId),
roomId: roomId,
isDesktop: isDesktop,
showMembersByDefault: showMembersByDefault,
@@ -40,9 +41,7 @@ class const ChatPage({super.key}) extends ConsumerWidget {
children: [Loading(), Text("Syncing...")],
),
),
- drawer: isDesktop || !initComplete
- ? null
- : Sidebar(isDesktop: isDesktop),
+ drawer: isDesktop || !initComplete ? null : Sidebar(),
),
);
},
diff --git a/lib/pages/select_server.dart b/lib/widgets/pages/select_server.dart
similarity index 92%
rename from lib/pages/select_server.dart
rename to lib/widgets/pages/select_server.dart
index a903cf6..c690dc1 100644
--- a/lib/pages/select_server.dart
+++ b/lib/widgets/pages/select_server.dart
@@ -9,7 +9,7 @@ import "package:nexus/controllers/client_id.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/main.dart";
import "package:nexus/models/homeserver.dart";
-import "package:nexus/pages/settings.dart";
+import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/divider_text.dart";
@@ -134,9 +134,9 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
padding: .symmetric(vertical: 8, horizontal: 12),
children: [
Row(
+ spacing: 12,
children: [
SvgPicture.asset("assets/bundled/icon.svg", width: 128),
- SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
@@ -144,7 +144,7 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
Text("Nexus", style: theme.textTheme.displayMedium),
Text(
"A Simple Matrix Client",
- style: theme.textTheme.headlineMedium,
+ style: theme.textTheme.titleLarge,
overflow: .ellipsis,
),
],
@@ -161,6 +161,7 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
child: TextField(
textInputAction: .done,
autofocus: true,
+ keyboardType: .url,
onSubmitted: (text) => setHomeserver(.tryParse(text)),
controller: homeserverUrl,
decoration: .new(
@@ -193,12 +194,13 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
url: .https("federated.nexus"),
iconUrl: "https://federated.nexus/images/icon.png",
),
- .new(
- name: "Unredacted",
- description: "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.",
- url: .https("unredacted.org", "services/si/matrix"),
- iconUrl: "https://unredacted.org/favicon.ico",
- ),
+ // TODO: Add back unredacted when they have MAS, find where new favicon is
+ // .new(
+ // name: "Unredacted",
+ // description: "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.",
+ // url: .https("unredacted.org", "services/si/matrix"),
+ // iconUrl: "https://unredacted.org/favicon.ico",
+ // ),
].map(
(homeserver) => Card(
child: ListTile(
diff --git a/lib/pages/settings.dart b/lib/widgets/pages/settings.dart
similarity index 99%
rename from lib/pages/settings.dart
rename to lib/widgets/pages/settings.dart
index ed9dc59..50eb7f0 100644
--- a/lib/pages/settings.dart
+++ b/lib/widgets/pages/settings.dart
@@ -8,7 +8,7 @@ import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:nexus/controllers/settings_sections.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/show_about_dialog.dart";
-import "package:nexus/pages/settings_category.dart";
+import "package:nexus/widgets/pages/settings_category.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart";
diff --git a/lib/pages/settings_category.dart b/lib/widgets/pages/settings_category.dart
similarity index 100%
rename from lib/pages/settings_category.dart
rename to lib/widgets/pages/settings_category.dart
diff --git a/lib/pages/verify.dart b/lib/widgets/pages/verify.dart
similarity index 94%
rename from lib/pages/verify.dart
rename to lib/widgets/pages/verify.dart
index 42a8dfc..44d276f 100644
--- a/lib/pages/verify.dart
+++ b/lib/widgets/pages/verify.dart
@@ -2,7 +2,7 @@ import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
-import "package:nexus/pages/settings.dart";
+import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/helpers/required_validator_helper.dart";
@@ -41,6 +41,8 @@ class const VerifyPage({super.key}) extends HookConsumerWidget {
]),
),
body: AlertDialog(
+ insetPadding: .symmetric(vertical: 12, horizontal: 32),
+ scrollable: true,
title: Text("Verify"),
content: Form(
key: formKey.value,
diff --git a/lib/widgets/pinned_events_drawer.dart b/lib/widgets/pinned_events_drawer.dart
index 046e231..cff6ad6 100644
--- a/lib/widgets/pinned_events_drawer.dart
+++ b/lib/widgets/pinned_events_drawer.dart
@@ -2,6 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/pinned_events.dart";
+import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
@@ -10,7 +11,7 @@ import "package:nexus/widgets/renderers/event.dart";
final class const PinnedEventsDrawer(
final String roomId, {
required final IList Function(Event event) getEventOptions,
- required final Future Function(String eventId) jumpToId,
+ required final JumpToEvent jumpToEvent,
super.key,
}) extends HookConsumerWidget {
@override
@@ -56,21 +57,23 @@ final class const PinnedEventsDrawer(
reverse: true,
itemCount: value.length,
itemBuilder: (context, index) {
- final event = value.reversed[index];
+ final rowId = value.reversed[index];
return InkWell(
borderRadius: .circular(12),
onTap: () {
Navigator.of(context).pop();
- jumpToId(event.eventId);
+ jumpToEvent(rowId);
},
child: Padding(
padding: .symmetric(vertical: 4),
- child: EventRenderer(
- event,
- maxLines: 2,
- isGrouped: false,
- getEventOptions: getEventOptions,
+ child: IgnorePointer(
+ child: EventRenderer(
+ rowId,
+ roomId: roomId,
+ maxLines: 2,
+ getEventOptions: getEventOptions,
+ ),
),
),
);
diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart
index 56f2356..edef81c 100644
--- a/lib/widgets/reaction_row.dart
+++ b/lib/widgets/reaction_row.dart
@@ -64,8 +64,10 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
enabled.value = false;
try {
final controller = ref.watch(
- RoomChatController.provider(event.roomId)
- .notifier,
+ RoomChatController.provider((
+ roomId: event.roomId,
+ contextualEvent: null,
+ )).notifier,
);
if (selected) {
diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart
index 697161f..af4cef4 100644
--- a/lib/widgets/renderers/event.dart
+++ b/lib/widgets/renderers/event.dart
@@ -3,7 +3,11 @@ import "package:flutter/gestures.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
+import "package:nexus/controllers/event.dart";
+import "package:nexus/controllers/room_chat.dart";
+import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart";
+import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.dart";
@@ -28,11 +32,12 @@ import "package:nexus/widgets/renderers/generic_event.dart";
import "package:nexus/widgets/timestamp.dart";
class const EventRenderer(
- final Event event, {
+ final int eventRowId, {
+ required final String roomId,
+ final int? previousEventRowId,
final bool textOnly = false,
- final bool isGrouped = false,
final int? maxLines,
- final VoidCallback? onTapReply,
+ final JumpToEvent? jumpToEvent,
final IList Function(Event event)? getEventOptions,
super.key,
}) extends HookConsumerWidget {
@@ -44,6 +49,38 @@ class const EventRenderer(
final focusNode = useFocusNode();
useListenable(focusNode);
+ final (event, previousEvent) = ref.watch(
+ RoomsController.provider.select((rooms) {
+ final event = rooms[roomId]?.events[eventRowId];
+ if (event == null) return (null, null);
+
+ final edited = event.lastEditRowId == 0
+ ? null
+ : rooms[roomId]?.events[event.lastEditRowId];
+
+ return (
+ edited == null
+ ? event
+ : event.copyWith(
+ content: edited.content,
+ localContent: edited.localContent,
+ ),
+ previousEventRowId == null
+ ? null
+ : rooms[roomId]?.events[previousEventRowId!],
+ );
+ }),
+ );
+
+ if (event == null) return SizedBox.shrink();
+
+ final isGrouped =
+ previousEvent?.content is MessageContent &&
+ previousEvent?.redactedBy == null &&
+ previousEvent?.relationType != "m.replace" &&
+ event.sender == previousEvent?.sender &&
+ event.pmp?.id == previousEvent?.pmp?.id;
+
final child = event.redactedBy != null || event.relationType == "m.replace"
? null
: switch (event.content) {
@@ -60,7 +97,18 @@ class const EventRenderer(
EncryptedContent() ||
StickerContent() => MessageRenderer(
event,
- onTapReply: onTapReply,
+ onTapReply: jumpToEvent == null
+ ? null
+ : () async {
+ final replyEvent = await ref.read(
+ EventController.provider(
+ .new(roomId: roomId, eventId: event.replyTo!),
+ ).future,
+ );
+ if (replyEvent != null) {
+ await jumpToEvent!(replyEvent.rowId);
+ }
+ },
isGrouped: isGrouped,
maxLines: maxLines,
textOnly: textOnly,
@@ -68,85 +116,174 @@ class const EventRenderer(
MembershipContent content => switch (event.previousContent) {
MembershipContent(:final status) =>
- status == content.status ? null : MembershipRenderer(event),
- _ => MembershipRenderer(event),
+ status == content.status
+ ? null
+ : MembershipRenderer(event, maxLines: maxLines),
+ _ => MembershipRenderer(event, maxLines: maxLines),
},
- AvatarContent() => GenericEventRenderer(Icons.interests, [
- MessageDisplayname(event),
- Text("changed the room avatar"),
- ]),
+ AvatarContent() => GenericEventRenderer(
+ Icons.interests,
+ maxLines: maxLines,
+ [
+ WidgetSpan(
+ alignment: .middle,
+ child: MessageDisplayname(
+ event,
+ shouldWrap: maxLines == null,
+ ),
+ ),
+ TextSpan(text: "changed the room avatar"),
+ ],
+ ),
- CreateContent() => GenericEventRenderer(Icons.add, [
- MessageDisplayname(event),
- Text("created the room"),
- ]),
+ CreateContent() => GenericEventRenderer(
+ Icons.add,
+ maxLines: maxLines,
+ [
+ WidgetSpan(
+ alignment: .middle,
+ child: MessageDisplayname(
+ event,
+ shouldWrap: maxLines == null,
+ ),
+ ),
+ TextSpan(text: "created the room"),
+ ],
+ ),
- PowerLevelsContent() => GenericEventRenderer(Icons.power, [
- MessageDisplayname(event),
- Text("changed the room's power levels"),
- ]),
+ PowerLevelsContent() => GenericEventRenderer(
+ Icons.power,
+ maxLines: maxLines,
+ [
+ WidgetSpan(
+ alignment: .middle,
+ child: MessageDisplayname(
+ event,
+ shouldWrap: maxLines == null,
+ ),
+ ),
+ TextSpan(text: "changed the room's power levels"),
+ ],
+ ),
- JoinRulesContent() => GenericEventRenderer(Icons.rule, [
- MessageDisplayname(event),
- Text("changed the room's join rules"),
- ]),
+ JoinRulesContent() => GenericEventRenderer(
+ Icons.rule,
+ maxLines: maxLines,
+ [
+ WidgetSpan(
+ alignment: .middle,
+ child: MessageDisplayname(
+ event,
+ shouldWrap: maxLines == null,
+ ),
+ ),
+ TextSpan(text: "changed the room's join rules"),
+ ],
+ ),
- TopicContent() => GenericEventRenderer(Icons.description, [
- MessageDisplayname(event),
- Text("updated the room topic"),
- ]),
+ TopicContent() => GenericEventRenderer(
+ Icons.description,
+ maxLines: maxLines,
+ [
+ WidgetSpan(
+ alignment: .middle,
+ child: MessageDisplayname(
+ event,
+ shouldWrap: maxLines == null,
+ ),
+ ),
+ TextSpan(text: "updated the room topic"),
+ ],
+ ),
HistoryVisibilityContent(:final historyVisibility) =>
- GenericEventRenderer(Icons.history, [
- MessageDisplayname(event),
- Text(
- "changed the room's history visibility to ${switch (historyVisibility) {
- .invited => "since invited",
- .joined => "since joined",
- .shared => "all history visible (shared)",
- .worldReadable => "all history visible (world readable)",
- }}",
+ GenericEventRenderer(Icons.history, maxLines: maxLines, [
+ WidgetSpan(
+ alignment: .middle,
+ child: MessageDisplayname(
+ event,
+ shouldWrap: maxLines == null,
+ ),
+ ),
+ TextSpan(
+ text:
+ "changed the room's history visibility to ${switch (historyVisibility) {
+ .invited => "since invited",
+ .joined => "since joined",
+ .shared => "all history visible (shared)",
+ .worldReadable => "all history visible (world readable)",
+ }}",
),
]),
- PinnedEventsContent() => GenericEventRenderer(Icons.push_pin, [
- MessageDisplayname(event),
- Text("pinned/unpinned some events"),
- ]),
+ PinnedEventsContent() => GenericEventRenderer(
+ Icons.push_pin,
+ maxLines: maxLines,
+ [
+ WidgetSpan(
+ alignment: .middle,
+ child: MessageDisplayname(
+ event,
+ shouldWrap: maxLines == null,
+ ),
+ ),
+ TextSpan(text: "pinned/unpinned some events"),
+ ],
+ ),
- ServerACLContent() => GenericEventRenderer(Icons.list, [
- MessageDisplayname(event),
- Text("updated the server ban list"),
- ]),
+ ServerACLContent() => GenericEventRenderer(
+ Icons.list,
+ maxLines: maxLines,
+ [
+ WidgetSpan(
+ alignment: .middle,
+ child: MessageDisplayname(
+ event,
+ shouldWrap: maxLines == null,
+ ),
+ ),
+ TextSpan(text: "updated the server ban list"),
+ ],
+ ),
CanonicalAliasContent(:final alias, :final altAliases) =>
- GenericEventRenderer(Icons.numbers, [
- MessageDisplayname(event),
- Text(switch ([
- if (event.previousContent case CanonicalAliasContent(
- alias: final prevAlias,
- altAliases: final prevAltAliases,
- )) ...[
- if (prevAlias != alias)
- if (alias == null)
- "removed the room's canonical alias"
- else
- "changed the room's canonical alias to $alias",
+ GenericEventRenderer(Icons.numbers, maxLines: maxLines, [
+ WidgetSpan(
+ alignment: .middle,
+ child: MessageDisplayname(
+ event,
+ shouldWrap: maxLines == null,
+ ),
+ ),
+ TextSpan(
+ text: switch ([
+ if (event.previousContent case CanonicalAliasContent(
+ alias: final prevAlias,
+ altAliases: final prevAltAliases,
+ )) ...[
+ if (prevAlias != alias)
+ if (alias == null)
+ "removed the room's canonical alias"
+ else
+ "changed the room's canonical alias to $alias",
- if (prevAltAliases
- .remove(alias ?? "")
- .remove(prevAlias ?? "") !=
- altAliases.remove(alias ?? "").remove(prevAlias ?? ""))
- "changed the room's aliases",
- ] else ...[
- if (alias != null) "set the room's canonical alias",
- if (altAliases.isNotEmpty) "set the room's aliases",
- ],
- ]) {
- [] => "did something related to room aliases",
- List prev => prev.join(" and "),
- }),
+ if (prevAltAliases
+ .remove(alias ?? "")
+ .remove(prevAlias ?? "") !=
+ altAliases
+ .remove(alias ?? "")
+ .remove(prevAlias ?? ""))
+ "changed the room's aliases",
+ ] else ...[
+ if (alias != null) "set the room's canonical alias",
+ if (altAliases.isNotEmpty) "set the room's aliases",
+ ],
+ ]) {
+ [] => "did something related to room aliases",
+ List prev => prev.join(" and "),
+ },
+ ),
]),
_ => null,
};
@@ -161,7 +298,7 @@ class const EventRenderer(
return Column(
crossAxisAlignment: .start,
children: [
- if (child != null) ...[
+ if (child != null)
if (textOnly)
child
else ...[
@@ -220,16 +357,39 @@ class const EventRenderer(
if (event.sendError != null && event.sendError != "not sent")
Padding(
padding: .only(bottom: 4),
- child: Text(
- event.sendError!,
- style: theme.textTheme.labelSmall?.copyWith(
- color: theme.colorScheme.error,
- ),
+ child: Row(
+ spacing: 4,
+ children: [
+ Text(
+ event.sendError!,
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: theme.colorScheme.error,
+ ),
+ ),
+ InkWell(
+ child: Text(
+ "Hide",
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: theme.colorScheme.primary,
+ decoration: .underline,
+ decorationColor: theme.colorScheme.primary,
+ ),
+ ),
+ onTap: () => ref
+ .read(
+ RoomChatController.provider((
+ roomId: roomId,
+ contextualEvent: null,
+ )).notifier,
+ )
+ .hideClientSticky(event.rowId),
+ ),
+ ],
),
),
].map((child) => Padding(padding: .only(left: 4), child: child)),
- ],
- ] else if (textOnly)
+ ]
+ else if (textOnly)
Text("Unknown event type", style: errorStyle),
],
);
diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart
index eb5b29b..5ff9dbc 100644
--- a/lib/widgets/renderers/generic_event.dart
+++ b/lib/widgets/renderers/generic_event.dart
@@ -2,7 +2,8 @@ import "package:material_ui/material_ui.dart";
class const GenericEventRenderer(
final IconData icon,
- final List children, {
+ final List children, {
+ final int? maxLines,
super.key,
}) extends StatelessWidget {
@override
@@ -13,7 +14,20 @@ class const GenericEventRenderer(
mainAxisSize: .min,
children: [
Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)),
- Flexible(child: Wrap(spacing: 4, children: children)),
+ Flexible(
+ child: Text.rich(
+ TextSpan(
+ children: [
+ for (final (index, child) in children.indexed) ...[
+ if (index > 0) TextSpan(text: " "),
+ child,
+ ],
+ ],
+ ),
+ maxLines: maxLines ?? 999,
+ overflow: .ellipsis,
+ ),
+ ),
],
),
);
diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart
index 5ce5d68..c57200c 100644
--- a/lib/widgets/renderers/membership.dart
+++ b/lib/widgets/renderers/membership.dart
@@ -7,8 +7,11 @@ import "package:nexus/models/event.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/renderers/generic_event.dart";
-class const MembershipRenderer(final Event event, {super.key})
- extends StatelessWidget {
+class const MembershipRenderer(
+ final Event event, {
+ final int? maxLines,
+ super.key,
+}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
assert(
@@ -17,34 +20,45 @@ class const MembershipRenderer(final Event event, {super.key})
);
return switch (event.content) {
- MembershipContent content => GenericEventRenderer(Icons.people, [
- InkWell(
- onTap: () => context.showUserPopover(
- content,
- event.stateKey!,
- roomId: event.roomId,
+ MembershipContent content => GenericEventRenderer(
+ Icons.people,
+ maxLines: maxLines,
+ [
+ WidgetSpan(
+ alignment: .middle,
+ child: InkWell(
+ onTap: () => context.showUserPopover(
+ content,
+ event.stateKey!,
+ roomId: event.roomId,
+ ),
+ child: Text(
+ overflow: .ellipsis,
+ content.displayName ?? event.stateKey!.localpart,
+ maxLines: 1,
+ style: .new(color: event.sender.colorHash, fontWeight: .bold),
+ ),
+ ),
),
- child: Text(
- overflow: .ellipsis,
- content.displayName ?? event.stateKey!.localpart,
- maxLines: 1,
- style: .new(color: event.sender.colorHash, fontWeight: .bold),
+ TextSpan(
+ text:
+ "${switch (content.status) {
+ .invite => "was invited to",
+ .join => "joined",
+ .leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
+ .ban => "was banned from",
+ .knock => "asked to join",
+ }} the room${event.sender == event.stateKey ? "" : " by"}",
),
- ),
- Text(
- overflow: .ellipsis,
- maxLines: 1,
- "${switch (content.status) {
- .invite => "was invited to",
- .join => "joined",
- .leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
- .ban => "was banned from",
- .knock => "asked to join",
- }} the room${event.sender == event.stateKey ? "" : " by "}",
- ),
- if (event.sender != event.stateKey) MessageDisplayname(event),
- if (content.reason != null) Text("for \"${content.reason}\""),
- ]),
+ if (event.sender != event.stateKey)
+ WidgetSpan(
+ alignment: .middle,
+ child: MessageDisplayname(event, shouldWrap: maxLines == null),
+ ),
+ if (content.reason != null)
+ TextSpan(text: "for \"${content.reason}\""),
+ ],
+ ),
_ => SizedBox.shrink(),
};
}
diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart
index a0bbf99..3404bc5 100644
--- a/lib/widgets/renderers/message.dart
+++ b/lib/widgets/renderers/message.dart
@@ -1,10 +1,10 @@
import "package:collection/collection.dart";
-import "package:flutter/rendering.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:linkify/linkify.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/event.dart";
+import "package:nexus/helpers/extensions/get_filename.dart";
import "package:nexus/models/content/encrypted.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/sticker.dart";
@@ -14,6 +14,7 @@ import "package:nexus/widgets/html/html.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/linkified_text.dart";
+import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/message_image.dart";
import "package:nexus/widgets/reaction_row.dart";
import "package:nexus/widgets/timestamp.dart";
@@ -37,7 +38,9 @@ class const MessageRenderer(
final errorStyle = TextStyle(color: colorScheme.error);
final textStyle = TextStyle(
- fontSize: event.localContent?.bigEmoji == true ? 32 : null,
+ fontSize: event.localContent?.bigEmoji == true && maxLines != 1
+ ? 32
+ : null,
fontStyle: event.content is EmoteMessageContent ? .italic : null,
);
@@ -52,104 +55,102 @@ class const MessageRenderer(
),
// TODO: Handle locations
// LocationMessageContent(:final body , :final geoUri) =>
- TextMessageContent(:final body, :final formattedBody, :final format) ||
- NoticeMessageContent(:final body, :final formattedBody, :final format) ||
- EmoteMessageContent(:final body, :final formattedBody, :final format) ||
- ImageMessageContent(:final body, :final formattedBody, :final format) ||
- VideoMessageContent(:final body, :final formattedBody, :final format) ||
- AudioMessageContent(:final body, :final formattedBody, :final format) ||
- FileMessageContent(
- :final body,
- :final formattedBody,
- :final format,
- ) => Column(
- crossAxisAlignment: .start,
- children: [
- format == .html && !textOnly
- ? Html(
- roomId: event.roomId,
- textStyle: textStyle,
- formattedBody!.replaceAllMapped(
- RegExp(
- r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)",
- caseSensitive: false,
- dotAll: true,
+ MessageContent content => switch (content) {
+ TextMessageContent(:final body, :final formattedBody, :final format) ||
+ NoticeMessageContent(
+ :final body,
+ :final formattedBody,
+ :final format,
+ ) ||
+ EmoteMessageContent(:final body, :final formattedBody, :final format) ||
+ ImageMessageContent(:final body, :final formattedBody, :final format) ||
+ VideoMessageContent(:final body, :final formattedBody, :final format) ||
+ AudioMessageContent(:final body, :final formattedBody, :final format) ||
+ FileMessageContent(
+ :final body,
+ :final formattedBody,
+ :final format,
+ ) => Column(
+ crossAxisAlignment: .start,
+ children: [
+ format == .html && !textOnly
+ ? Html(
+ roomId: event.roomId,
+ textStyle: textStyle,
+ formattedBody!.replaceAllMapped(
+ RegExp(
+ r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)",
+ caseSensitive: false,
+ dotAll: true,
+ ),
+ (m) {
+ // If it's already an tag, leave it unchanged
+ if (m.group(1) != null) {
+ return m.group(1)!;
+ }
+
+ // Otherwise, wrap the bare URL
+ final url = m.group(2)!;
+ return "$url";
+ },
),
- (m) {
- // If it's already an tag, leave it unchanged
- if (m.group(1) != null) {
- return m.group(1)!;
- }
-
- // Otherwise, wrap the bare URL
- final url = m.group(2)!;
- return "$url";
- },
+ )
+ : LinkifiedText(
+ body.isEmpty ? content.filename ?? "" : body,
+ style: textStyle,
+ maxLines: maxLines,
),
- )
- : LinkifiedText(body, style: textStyle, maxLines: maxLines),
- if (!textOnly) ...[
- if (event.content
- case ImageMessageContent(:final url) ||
- FileMessageContent(:final url) ||
- VideoMessageContent(:final url) ||
- AudioMessageContent(:final url))
- ConstrainedBox(
- constraints: .loose(.square(500)),
- child: switch (event.content) {
- VideoMessageContent(:final info, :final file) => VideoPlayer(
- url,
- info,
- encrypted: file != null,
- ),
- AudioMessageContent(:final info, :final file) => AudioPlayer(
- url,
- info,
+ if (!textOnly) ...[
+ if (event.content
+ case ImageMessageContent(:final url) ||
+ FileMessageContent(:final url) ||
+ VideoMessageContent(:final url) ||
+ AudioMessageContent(:final url))
+ ConstrainedBox(
+ constraints: .loose(.square(500)),
+ child: switch (event.content) {
+ VideoMessageContent(:final info, :final file) =>
+ VideoPlayer(url, info, encrypted: file != null),
+ AudioMessageContent(:final info, :final file) =>
+ AudioPlayer(url, info, encrypted: file != null),
+ FileMessageContent(:final info, :final filename) =>
+ FileCard(url, info, filename: filename),
+ ImageMessageContent(:final info, :final file) =>
+ MessageImage(url, info: info, encrypted: file != null),
+ _ => SizedBox.shrink(),
+ },
+ ),
- encrypted: file != null,
- ),
- FileMessageContent(:final info, :final filename) => FileCard(
- url,
- info,
- filename: filename,
- ),
- ImageMessageContent(:final info, :final file) => MessageImage(
- url,
- info: info,
- encrypted: file != null,
- ),
- _ => SizedBox.shrink(),
- },
- ),
+ if (event.lastEditRowId != 0)
+ Text("(edited)", style: theme.textTheme.labelSmall),
- if (event.lastEditRowId != 0)
- Text("(edited)", style: theme.textTheme.labelSmall),
-
- if (linkify(body)
- .firstWhereOrNull((element) => element is UrlElement)
- case final UrlElement link?)
- if (Uri.tryParse(link.url) case final Uri url?) UrlPreview(url),
+ if (linkify(body)
+ .firstWhereOrNull((element) => element is UrlElement)
+ case final UrlElement link?)
+ if (Uri.tryParse(link.url) case final Uri url?) UrlPreview(url),
+ ],
],
- ],
- ),
- MessageContent(:final body) =>
- body == null
- ? Text("This message is redacted", style: errorStyle)
- : Wrap(
- spacing: 8,
- children: [
- Text("Unknown message type:", style: errorStyle),
- Text(body),
- ],
- ),
+ ),
+ LocationMessageContent(:final String? body) ||
+ UnknownMessageContent(:final body) =>
+ body == null
+ ? Text("This message is redacted", style: errorStyle)
+ : Wrap(
+ spacing: 8,
+ children: [
+ Text("Unknown message type:", style: errorStyle),
+ Text(body),
+ ],
+ ),
+ },
_ => throw Exception("This is impossible"),
};
return Row(
crossAxisAlignment: .start,
mainAxisSize: .min,
- spacing: 8,
+ spacing: 6,
children: [
if (!textOnly)
if (isGrouped)
@@ -158,7 +159,7 @@ class const MessageRenderer(
MessageAvatar(event, height: 40),
Flexible(
child: Column(
- spacing: 4,
+ spacing: 2,
crossAxisAlignment: .start,
children: [
if (!isGrouped && !textOnly)
@@ -198,34 +199,31 @@ class const MessageRenderer(
onTap: onTapReply,
child: Padding(
padding: .symmetric(vertical: 8, horizontal: 12),
- child: switch (ref.watch(
- EventController.provider(
- .new(
- roomId: event.roomId,
- eventId: event.replyTo!,
+ child: SizedBox(
+ height: 48,
+ child: switch (ref.watch(
+ EventController.provider(
+ .new(
+ roomId: event.roomId,
+ eventId: event.replyTo!,
+ ),
),
- ),
- )) {
- AsyncData(:final value?) ||
- AsyncLoading(
- :final value?,
- ) => EventPreview(value),
- AsyncError _ => Text(
- "An error occurred while fetching the reply",
- style: errorStyle,
- ),
- _ => Text("Fetching event..."),
- },
+ )) {
+ AsyncData(:final value?) ||
+ AsyncLoading(
+ :final value?,
+ ) => EventPreview(value),
+ AsyncError _ => Text(
+ "An error occurred while fetching the reply",
+ style: errorStyle,
+ ),
+ _ => Loading(dimension: 12),
+ },
+ ),
),
),
),
-
- RendererBinding.instance.mouseTracker.mouseIsConnected
- ? SelectableRegion(
- selectionControls: materialTextSelectionControls,
- child: rendered,
- )
- : rendered,
+ rendered,
if (!textOnly) ReactionRow(event),
],
),
diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart
index 305c490..3b29129 100644
--- a/lib/widgets/room_appbar.dart
+++ b/lib/widgets/room_appbar.dart
@@ -3,9 +3,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
-import "package:nexus/widgets/expandable_image.dart";
-import "package:nexus/widgets/linkified_text.dart";
-import "package:nexus/widgets/room_menu.dart";
+import "package:nexus/widgets/room_menu_button.dart";
+import "package:nexus/widgets/room_summary_dialog.dart";
final class const RoomAppbar({
required final String? roomId,
@@ -25,61 +24,19 @@ final class const RoomAppbar({
: ref.watch(RoomsController.provider.select((value) => value[roomId!]));
return Appbar(
- onTap: room == null
+ onTap: room?.metadata == null
? null
: () => showDialog(
context: context,
- builder: (context) => Dialog(
- constraints: .loose(.fromWidth(400)),
- child: Padding(
- padding: .all(24),
- child: SingleChildScrollView(
- child: Column(
- mainAxisSize: .min,
- crossAxisAlignment: .start,
- spacing: 8,
- children: [
- Row(
- spacing: 12,
- mainAxisSize: .min,
- children: [
- if (room.metadata?.avatar != null)
- ExpandableImage(
- room.metadata?.avatar == null
- ? null
- : .new(mxc: room.metadata!.avatar!),
- child: AvatarOrHash(
- room.metadata?.avatar,
- room.metadata?.name ?? "Unnamed Room",
- height: 64,
- fallback: Icon(Icons.numbers),
- ),
- ),
- Expanded(
- child: Text(
- room.metadata?.name ?? "Unnamed Room",
- overflow: .ellipsis,
- maxLines: 3,
- style: Theme.of(context)
- .textTheme
- .headlineSmall,
- ),
- ),
- ],
- ),
- if (room.metadata?.topic?.isNotEmpty == true)
- LinkifiedText(
- room.metadata!.topic!,
- style: Theme.of(context).textTheme.bodyLarge
- ?.copyWith(
- color: Theme.of(context)
- .colorScheme
- .onSurfaceVariant,
- ),
- ),
- ],
- ),
- ),
+ builder: (context) => RoomSummaryDialog(
+ .new(
+ roomId: room!.metadata!.id,
+ joinedMembers:
+ room.metadata!.lazyLoadSummary?.joinedMemberCount,
+ avatarUrl: room.metadata?.avatar,
+ canonicalAlias: room.metadata?.canonicalAlias,
+ name: room.metadata?.name,
+ topic: room.metadata?.topic,
),
),
),
@@ -89,7 +46,7 @@ final class const RoomAppbar({
: AvatarOrHash(
room.metadata?.avatar,
room.metadata?.name ?? "Unnamed Room",
- height: 24,
+ dimension: 24,
fallback: Icon(Icons.numbers),
)
: DrawerButton(onPressed: onOpenDrawer),
@@ -115,21 +72,23 @@ final class const RoomAppbar({
),
],
),
- actions: room == null
- ? .new()
- : .new([
- IconButton(
- onPressed: onOpenPinnedMessagesList?.call,
- icon: Icon(Icons.push_pin),
- tooltip: "Open pinned messages",
- ),
- IconButton(
- onPressed: () => onOpenMemberList?.call(context),
- tooltip: "Open member list",
- icon: Icon(Icons.people),
- ),
- RoomMenu(room),
- ]),
+ actions: .new(
+ room == null
+ ? []
+ : [
+ IconButton(
+ onPressed: onOpenPinnedMessagesList?.call,
+ icon: Icon(Icons.push_pin),
+ tooltip: "Open pinned messages",
+ ),
+ IconButton(
+ onPressed: () => onOpenMemberList?.call(context),
+ tooltip: "Open member list",
+ icon: Icon(Icons.people),
+ ),
+ RoomMenuButton(room),
+ ],
+ ),
);
}
}
diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart
deleted file mode 100644
index 623e71f..0000000
--- a/lib/widgets/room_chat.dart
+++ /dev/null
@@ -1,592 +0,0 @@
-import "package:fast_immutable_collections/fast_immutable_collections.dart";
-import "package:material_ui/material_ui.dart";
-import "package:flutter/services.dart";
-import "package:flutter_hooks/flutter_hooks.dart";
-import "package:hooks_riverpod/hooks_riverpod.dart";
-import "package:measure_size/measure_size.dart";
-import "package:nexus/controllers/client.dart";
-import "package:nexus/controllers/client_state.dart";
-import "package:nexus/controllers/member_list_opened.dart";
-import "package:nexus/controllers/pinned_ids.dart";
-import "package:nexus/controllers/power_level.dart";
-import "package:nexus/controllers/recent_emoji.dart";
-import "package:nexus/controllers/rooms.dart";
-import "package:nexus/controllers/room_chat.dart";
-import "package:nexus/controllers/via.dart";
-import "package:nexus/models/content/message.dart";
-import "package:nexus/models/event.dart";
-import "package:nexus/models/relation_type.dart";
-import "package:nexus/widgets/composer/composer.dart";
-import "package:nexus/widgets/emoji_picker.dart";
-import "package:nexus/widgets/pinned_events_drawer.dart";
-import "package:nexus/widgets/renderers/event.dart";
-import "package:nexus/widgets/member_list.dart";
-import "package:nexus/widgets/room_appbar.dart";
-import "package:nexus/widgets/highlight_wrapper.dart";
-import "package:nexus/widgets/error_dialog.dart";
-import "package:nexus/main.dart";
-import "package:nexus/widgets/loading.dart";
-import "package:super_sliver_list/super_sliver_list.dart";
-
-final class const RoomChat({
- required final String? roomId,
- required final bool isDesktop,
- required final bool showMembersByDefault,
- super.key,
-}) extends HookConsumerWidget {
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final relatedEvent = useState(null);
- final relationType = useState(RelationType.reply);
- final highlightedEvent = useState(null);
-
- final composerSize = useState(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(null);
- final hasMore = useState(true);
-
- Future 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 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 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 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),
- ],
- ),
- ),
- ),
- );
- }
-}
diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart
new file mode 100644
index 0000000..254c782
--- /dev/null
+++ b/lib/widgets/room_chat/chat_timeline.dart
@@ -0,0 +1,65 @@
+import "package:fast_immutable_collections/fast_immutable_collections.dart";
+import "package:material_ui/material_ui.dart";
+import "package:nexus/helpers/hooks/chat_scroll.dart";
+import "package:nexus/models/event.dart";
+import "package:nexus/widgets/renderers/event.dart";
+import "package:nexus/widgets/highlight_wrapper.dart";
+import "package:super_sliver_list/super_sliver_list.dart";
+
+class const ChatTimeline({
+ required final String roomId,
+ required final ChatScroll scroll,
+ required final JumpToEvent jumpToEvent,
+ required final IList 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]),
+ ),
+ ),
+ ],
+ );
+}
diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart
new file mode 100644
index 0000000..a49e6f9
--- /dev/null
+++ b/lib/widgets/room_chat/room_chat.dart
@@ -0,0 +1,282 @@
+import "package:fast_immutable_collections/fast_immutable_collections.dart";
+import "package:material_ui/material_ui.dart";
+import "package:flutter/services.dart";
+import "package:flutter_hooks/flutter_hooks.dart";
+import "package:hooks_riverpod/hooks_riverpod.dart";
+import "package:measure_size/measure_size.dart";
+import "package:nexus/controllers/client.dart";
+import "package:nexus/controllers/client_state.dart";
+import "package:nexus/controllers/member_list_opened.dart";
+import "package:nexus/controllers/rooms.dart";
+import "package:nexus/controllers/room_chat.dart";
+import "package:nexus/helpers/hooks/chat_scroll.dart";
+import "package:nexus/models/event.dart";
+import "package:nexus/models/relation_type.dart";
+import "package:nexus/widgets/composer/composer.dart";
+import "package:nexus/widgets/error_dialog.dart";
+import "package:nexus/widgets/loading.dart";
+import "package:nexus/widgets/pinned_events_drawer.dart";
+import "package:nexus/widgets/member_list.dart";
+import "package:nexus/widgets/room_appbar.dart";
+import "package:nexus/main.dart";
+import "package:nexus/widgets/room_chat/chat_timeline.dart";
+import "package:nexus/helpers/extensions/build_event_options.dart";
+import "package:nexus/controllers/jump_request.dart";
+
+final class const RoomChat({
+ required final String? roomId,
+ required final bool isDesktop,
+ required final bool showMembersByDefault,
+ super.key,
+}) extends HookConsumerWidget {
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final relatedEvent = useState(null);
+ final relationType = useState(RelationType.reply);
+ final pendingJump = ref.read(JumpRequestController.provider);
+ final contextualEvent = useState(
+ pendingJump != null && pendingJump.roomId == this.roomId
+ ? pendingJump.rowId
+ : null,
+ );
+ final highlightedEvent = useState(null);
+
+ final composerSize = useState(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 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 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),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart
new file mode 100644
index 0000000..b4f07f5
--- /dev/null
+++ b/lib/widgets/room_list.dart
@@ -0,0 +1,318 @@
+import "dart:math";
+
+import "package:hooks_riverpod/hooks_riverpod.dart";
+import "package:material_ui/material_ui.dart";
+import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
+import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
+import "package:nexus/models/nav_page.dart";
+import "package:nexus/widgets/avatar_or_hash.dart";
+import "package:nexus/widgets/divider_widget.dart";
+import "package:nexus/widgets/error_dialog.dart";
+import "package:nexus/widgets/pages/settings.dart";
+import "package:nexus/widgets/join_dialog.dart";
+import "package:nexus/widgets/room_menu_button.dart";
+import "package:nexus/controllers/key.dart";
+import "package:nexus/controllers/spaces.dart";
+import "package:nexus/models/room.dart";
+import "package:collection/collection.dart";
+import "package:fast_immutable_collections/fast_immutable_collections.dart";
+
+// Needed for navigation_rail_m3e (#65).
+import "package:flutter/material.dart" as old_mat;
+
+List roomDestinations(
+ IList 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? 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();
+ }
+ },
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/room_menu.dart b/lib/widgets/room_menu_button.dart
similarity index 55%
rename from lib/widgets/room_menu.dart
rename to lib/widgets/room_menu_button.dart
index 278e479..7e6516d 100644
--- a/lib/widgets/room_menu.dart
+++ b/lib/widgets/room_menu_button.dart
@@ -5,8 +5,10 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/via.dart";
import "package:nexus/models/room.dart";
+import "package:flutter_hooks/flutter_hooks.dart";
+import "package:nexus/main.dart";
-final class const RoomMenu(
+final class const RoomMenuButton(
final Room? room, {
final IList children = const IList.empty(),
super.key,
@@ -14,7 +16,7 @@ final class const RoomMenu(
@override
Widget build(BuildContext context, WidgetRef ref) {
final danger = Theme.of(context).colorScheme.error;
- final client = ref.watch(ClientController.provider.notifier);
+ final client = ref.read(ClientController.provider.notifier);
return PopupMenuButton(
itemBuilder: (_) => [
@@ -44,6 +46,63 @@ final class const RoomMenu(
title: Text("Copy Link"),
),
),
+ PopupMenuItem(
+ onTap: () => showDialog(
+ context: context,
+ builder: (context) => HookBuilder(
+ builder: (context) {
+ final userIdController = useTextEditingController();
+ final text = useValueListenable(userIdController).text.trim();
+ final valid = RegExp(r"^@[^:\s]+:[^\s]+$").hasMatch(text);
+
+ void submit() {
+ if (!valid) return;
+ Navigator.of(context).pop();
+ client
+ .setMembership(
+ .new(
+ userId: text,
+ roomId: room!.metadata!.id,
+ action: .invite,
+ ),
+ )
+ .onError(showError);
+ }
+
+ return AlertDialog(
+ title: Text("Invite User"),
+ content: TextField(
+ controller: userIdController,
+ autofocus: true,
+ keyboardType: .emailAddress,
+ onSubmitted: (_) => submit(),
+ decoration: .new(
+ labelText: "User ID",
+ hintText: "@user:example.com",
+ errorText: text.isEmpty || valid
+ ? null
+ : "Enter a valid user ID",
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: Navigator.of(context).pop,
+ child: Text("Cancel"),
+ ),
+ TextButton(
+ onPressed: valid ? submit : null,
+ child: Text("Invite"),
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ child: ListTile(
+ leading: Icon(Icons.person_add),
+ title: Text("Invite"),
+ ),
+ ),
PopupMenuItem(
onTap: () => showDialog(
context: context,
@@ -81,54 +140,6 @@ final class const RoomMenu(
),
),
],
-
- // PopupMenuItem(
- // onTap: () => showDialog(
- // context: context,
- // builder: (context) => HookBuilder(
- // builder: (_) {
- // final reasonController = useTextEditingController();
- // return AlertDialog(
- // title: Text("Report"),
- // content: Column(
- // mainAxisSize: MainAxisSize.min,
- // crossAxisAlignment: CrossAxisAlignment.start,
- // children: [
- // Text(
- // "Report this room to your server administrators, who can take action like banning this room.",
- // ),
-
- // SizedBox(height: 12),
- // FormTextInput(
- // required: false,
- // capitalize: true,
- // controller: reasonController,
- // title: "Reason for report (optional)",
- // ),
- // ],
- // ),
- // actions: [
- // TextButton(
- // onPressed: Navigator.of(context).pop,
- // child: Text("Cancel"),
- // ),
- // TextButton(
- // onPressed: () {
- // room.client.reportRoom(room.id, reasonController.text);
- // Navigator.of(context).pop();
- // },
- // child: Text("Report"),
- // ),
- // ],
- // );
- // },
- // ),
- // ),
- // child: ListTile(
- // leading: Icon(Icons.report, color: danger),
- // title: Text("Report", style: TextStyle(color: danger)),
- // ),
- // ),
],
);
}
diff --git a/lib/widgets/room_summary_dialog.dart b/lib/widgets/room_summary_dialog.dart
new file mode 100644
index 0000000..496e72f
--- /dev/null
+++ b/lib/widgets/room_summary_dialog.dart
@@ -0,0 +1,122 @@
+import "package:fast_immutable_collections/fast_immutable_collections.dart";
+import "package:flutter_riverpod/flutter_riverpod.dart";
+import "package:material_ui/material_ui.dart";
+import "package:nexus/controllers/client.dart";
+import "package:nexus/helpers/extensions/focus_room.dart";
+import "package:nexus/widgets/avatar_or_hash.dart";
+import "package:nexus/widgets/expandable_image.dart";
+import "package:nexus/widgets/linkified_text.dart";
+import "package:nexus/models/room_summary.dart";
+
+class const RoomSummaryDialog(
+ final RoomSummary summary, {
+ final IList? via,
+ super.key,
+}) extends StatelessWidget {
+ @override
+ Widget build(BuildContext context) => AlertDialog(
+ constraints: .loose(.fromWidth(400)),
+ scrollable: true,
+ contentPadding: EdgeInsets.all(24).copyWith(top: 8),
+ title: Row(
+ spacing: 12,
+ mainAxisSize: .min,
+ children: [
+ if (summary.avatarUrl != null)
+ ExpandableImage(
+ summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!),
+ child: AvatarOrHash(
+ summary.avatarUrl,
+ "",
+ dimension: 64,
+ fallback: Icon(Icons.numbers),
+ ),
+ ),
+ Expanded(
+ child: Text(
+ summary.name ?? summary.canonicalAlias ?? "Unnamed Room",
+ overflow: .ellipsis,
+ maxLines: 3,
+ ),
+ ),
+ ],
+ ),
+ content: Column(
+ children: [
+ ListTile(
+ title: Text(
+ "${summary.joinedMembers ?? "Unknown number of"} members",
+ ),
+ leading: Icon(Icons.people),
+ ),
+ ListTile(
+ title: SelectableText(summary.canonicalAlias ?? summary.roomId),
+ leading: Icon(Icons.numbers),
+ ),
+
+ if (summary.topic != null)
+ ListTile(
+ titleAlignment: ListTileTitleAlignment.titleHeight,
+ title: LinkifiedText(
+ summary.topic!,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ leading: Icon(Icons.info),
+ ),
+ ],
+ ),
+
+ actions: [
+ TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
+ if (via != null)
+ TextButton(
+ onPressed: () async {
+ // Capture everything before the dialog closes.
+ final container = ProviderScope.containerOf(context);
+ final scaffoldMessenger = ScaffoldMessenger.of(context);
+ final colors = Theme.of(context).colorScheme;
+ Navigator.of(context).pop();
+
+ final roomIdOrAlias = summary.canonicalAlias ?? summary.roomId;
+
+ final snackbar = scaffoldMessenger.showSnackBar(
+ .new(
+ content: Text("Joining room $roomIdOrAlias."),
+ duration: Duration(days: 999),
+ ),
+ );
+
+ try {
+ final id = await container
+ .read(ClientController.provider.notifier)
+ .joinRoom(.new(roomIdOrAlias: summary.roomId, via: via!));
+
+ snackbar.close();
+
+ scaffoldMessenger.showSnackBar(
+ .new(
+ content: Text("Room $roomIdOrAlias successfully joined."),
+ action: .new(
+ label: "Open",
+ onPressed: () => container.focusRoom(id),
+ ),
+ ),
+ );
+ } catch (error) {
+ snackbar.close();
+ scaffoldMessenger.showSnackBar(
+ .new(
+ backgroundColor: colors.errorContainer,
+ content: Text(
+ error.toString(),
+ style: .new(color: colors.onErrorContainer),
+ ),
+ ),
+ );
+ }
+ },
+ child: Text("Join"),
+ ),
+ ],
+ );
+}
diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart
index b2d8295..2d1c728 100644
--- a/lib/widgets/sidebar.dart
+++ b/lib/widgets/sidebar.dart
@@ -1,320 +1,34 @@
-import "package:collection/collection.dart";
-import "package:fast_immutable_collections/fast_immutable_collections.dart";
+import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
-import "package:hooks_riverpod/hooks_riverpod.dart";
-import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
-import "package:nexus/controllers/key.dart";
-import "package:nexus/controllers/spaces.dart";
-import "package:nexus/models/room.dart";
-import "package:nexus/pages/notifications.dart";
-import "package:nexus/pages/settings.dart";
-import "package:nexus/widgets/avatar_or_hash.dart";
-import "package:nexus/widgets/divider_widget.dart";
-import "package:nexus/widgets/join_dialog.dart";
-import "package:nexus/widgets/room_menu.dart";
+import "package:nexus/models/nav_page.dart";
+import "package:nexus/widgets/invites_list.dart";
+import "package:nexus/widgets/notifications_list.dart";
+import "package:nexus/widgets/room_list.dart";
-// Needed for navigation_rail_m3e (#65).
-import "package:flutter/material.dart" as old_mat;
-
-class const Sidebar({required final bool isDesktop, super.key})
- extends HookConsumerWidget {
+class const Sidebar({super.key}) extends HookWidget {
@override
- Widget build(BuildContext context, WidgetRef ref) {
- final selectedSpaceProvider = KeyController.provider(
- KeyController.spaceKey,
- );
- final selectedSpaceId = ref.watch(selectedSpaceProvider).requireValue;
- final selectedSpaceIdNotifier = ref.watch(selectedSpaceProvider.notifier);
-
- final selectedRoomController = KeyController.provider(
- KeyController.roomKey,
- );
- final selectedRoomId = ref.watch(selectedRoomController).requireValue;
- final selectedRoomIdNotifier = ref.watch(selectedRoomController.notifier);
-
- final spaces = ref.watch(SpacesController.provider);
- final indexOfSelected = spaces.indexWhere(
- (space) => space.id == selectedSpaceId,
- );
- final selectedIndex = indexOfSelected == -1 ? 0 : indexOfSelected;
-
- final selectedSpace =
- spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ??
- spaces.first;
-
- final indexOfSelectedRoom = selectedSpace.children
- .addAll(
- selectedSpace.subSpaces.map((element) => element.children).flattened,
- )
- .indexWhere((room) => room.metadata?.id == selectedRoomId);
- final selectedRoomIndex = indexOfSelectedRoom == -1
- ? null
- : indexOfSelectedRoom;
-
- List roomsToDestinations(IList rooms) =>
- rooms
- .map(
- (room) => NavigationRailM3EDestination(
- 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: selectedSpaceId == "dms"
- ? null
- : Icon(Icons.numbers),
- ),
- ),
- )
- .toList();
+ Widget build(BuildContext context) {
+ final selectedIndex = useState(0);
+ const pages = [RoomList(), NotificationsList(), InvitesList()];
return Drawer(
width: 330,
shape: Border(),
- child: Row(
- children: [
- MaterialUiCompatibilityBridge(
- child: Builder(
- builder: (context) => old_mat.Theme(
- data: old_mat.Theme.of(context).copyWith(
- extensions: [
- NavigationRailM3ETheme(
- itemCollapsedHeight: 48,
- itemVerticalGap: 0,
- ),
- ],
+ child: Scaffold(
+ backgroundColor: Colors.transparent,
+ body: pages[selectedIndex.value],
+ bottomNavigationBar: NavigationBar(
+ selectedIndex: selectedIndex.value,
+ destinations: pages
+ .map(
+ (element) => NavigationDestination(
+ icon: Icon(element.icon),
+ label: element.title,
),
- child: Container(
- color: NavigationRailTokensAdapter(context).containerColor,
- padding: EdgeInsets.only(top: 16),
- child: NavigationRailM3E(
- type: .alwaysCollapse,
- labelBehavior: .alwaysHide,
- scrollable: true,
- onDestinationSelected: (value) {
- selectedSpaceIdNotifier.set(spaces[value].id);
- selectedRoomIdNotifier.set(
- spaces[value].children.firstOrNull?.metadata?.id,
- );
- },
- sections: [
- .new(
- destinations: spaces
- .map(
- (space) => NavigationRailM3EDestination(
- badgeCount: switch (space.children
- .addAll(
- space.subSpaces
- .map((element) => element.children)
- .flattened,
- )
- .fold(
- 0,
- (previousValue, room) =>
- previousValue +
- (room.metadata?.unreadNotifications ??
- 0),
- )) {
- 0 =>
- space.children
- .addAll(
- space.subSpaces
- .map(
- (element) =>
- element.children,
- )
- .flattened,
- )
- .any(
- (room) =>
- room
- .metadata
- ?.unreadMessages !=
- 0,
- )
- ? 0
- : null,
- int badgeCount => badgeCount,
- },
- short: true,
- icon: AvatarOrHash(
- height: 28,
- space.room?.metadata?.avatar,
- fallback: space.icon == null
- ? null
- : Icon(space.icon),
- space.title,
- ),
- label: space.title,
- ),
- )
- .toList(),
- ),
- ],
- selectedIndex: selectedIndex,
- trailingAtBottom: true,
- trailing: Padding(
- padding: .symmetric(vertical: 16),
- child: Column(
- spacing: 8,
- children: [
- PopupMenuButton(
- itemBuilder: (_) => [
- PopupMenuItem(
- onTap: () => showDialog(
- context: context,
- builder: (_) => JoinDialog(ref),
- ),
- child: ListTile(
- title: Text(
- "Join an existing room (or space)",
- ),
- leading: Icon(Icons.numbers),
- ),
- ),
- PopupMenuItem(
- onTap: null,
- child: ListTile(
- title: Text("Create a new room"),
- leading: Icon(Icons.add),
- ),
- ),
- ],
- icon: Icon(Icons.add),
- ),
- IconButton(
- tooltip: "Explore other rooms",
- onPressed: null,
- icon: Icon(Icons.explore),
- ),
- IconButton(
- tooltip: "Open notifications",
- onPressed: () => Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => NotificationsPage(),
- ),
- ),
- icon: Icon(Icons.notifications),
- ),
- IconButton(
- tooltip: "Open settings",
- onPressed: () => showDialog(
- context: context,
- builder: (_) => SettingsPage(),
- ),
- icon: Icon(Icons.settings),
- ),
- ],
- ),
- ),
- ),
- ),
- ),
- ),
- ),
-
- Expanded(
- child: 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: [
- RoomMenu(
- selectedSpace.room,
- children: selectedSpace.children.addAll(
- selectedSpace.subSpaces
- .map((element) => element.children)
- .flattened,
- ),
- ),
- ],
- ),
- 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: selectedRoomIndex ?? 0,
- sections: [
- .new(
- header: selectedSpace.room == null
- ? null
- : DividerWidget(Text("Rooms")),
- destinations: roomsToDestinations(
- selectedSpace.children,
- ),
- ),
- for (final subSpace in selectedSpace.subSpaces)
- .new(
- header: DividerWidget(
- Row(
- mainAxisSize: MainAxisSize.min,
- spacing: 8,
- children: [
- if (subSpace.room.metadata?.avatar != null)
- AvatarOrHash(
- subSpace.room.metadata?.avatar,
- subSpace.room.metadata?.name ??
- "Unnamed Room",
- height: 16,
- ),
- Flexible(
- child: Text(
- subSpace.room.metadata?.name ??
- "Unnamed Space",
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- ),
- ),
- ],
- ),
- ),
- destinations: roomsToDestinations(
- subSpace.children,
- ),
- ),
- ],
- onDestinationSelected: (value) {
- final children = selectedSpace.children.addAll(
- selectedSpace.subSpaces
- .map((element) => element.children)
- .flattened,
- );
- selectedRoomIdNotifier.set(
- children[value].metadata?.id, //
- );
- if (!isDesktop) Navigator.of(context).pop();
- },
- ),
- ),
- ),
- ),
- ),
- ),
- ],
+ )
+ .toList(),
+ onDestinationSelected: (value) => selectedIndex.value = value,
+ ),
),
);
}
diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart
index feeb5a1..d7cc55b 100644
--- a/lib/widgets/user_bottom_sheet.dart
+++ b/lib/widgets/user_bottom_sheet.dart
@@ -26,7 +26,7 @@ final class const UserBottomSheet(
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
- final client = ref.watch(ClientController.provider.notifier);
+ final client = ref.read(ClientController.provider.notifier);
void showMembershipDialog(MembershipAction action) => showDialog(
context: context,
@@ -77,178 +77,180 @@ final class const UserBottomSheet(
),
);
- return Padding(
- padding: .all(42),
- child: Column(
- spacing: 4,
- mainAxisSize: .min,
- crossAxisAlignment: .center,
- children: [
- Row(
- mainAxisAlignment: .end,
- children: [
- M3EButton(
- onPressed: Navigator.of(context).pop,
- child: Icon(Icons.close),
- ),
- ],
- ),
- SizedBox(height: 18),
-
- ExpandableImage(
- member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
- child: AvatarOrHash(
- member.avatarUrl,
- member.displayName ?? userId.localpart,
- height: 200,
- ),
- ),
-
- SizedBox(height: 8),
-
- SelectableText(
- member.displayName ?? userId.localpart,
- style: textTheme.headlineLarge,
- textAlign: .center,
- ),
- SelectableText(
- userId,
- textAlign: .center,
- style: textTheme.titleSmall?.copyWith(
- color: theme.colorScheme.onSurfaceVariant,
- ),
- ),
-
- ref
- .watch(ProfileController.provider(userId))
- .betterWhen(
- loading: () => Text(""),
- data: (profileResponse) => Column(
- children: [
- if (profileResponse.profile.timezone == null &&
- profileResponse.profile.pronouns.isEmpty)
- Text(""),
- Wrap(
- crossAxisAlignment: .center,
- alignment: .center,
- spacing: 4,
- runSpacing: 4,
- children: [
- ...profileResponse.profile.pronouns
- .where(
- // TODO: Check system language (l10n)
- (pronoun) => pronoun.language == "en",
- )
- .mapIndexed(
- (index, pronoun) => [
- if (index != 0)
- Icon(
- Icons.circle,
- size: 4,
- color: theme.colorScheme.onSurfaceVariant,
- ),
- Text(
- pronoun.summary,
- textAlign: .center,
- style: textTheme.titleSmall?.copyWith(
- color: theme.colorScheme.onSurfaceVariant,
- ),
- ),
- ],
- )
- .flattened,
-
- if (profileResponse.profile.timezone != null) ...[
- if (profileResponse.profile.pronouns.isNotEmpty)
- SizedBox(
- height: 16,
- child: VerticalDivider(
- thickness: 1.5,
- width: 4,
- color: theme.colorScheme.onSurfaceVariant,
- ),
- ),
- Text(
- profileResponse.profile.timezone!,
- textAlign: .center,
- style: textTheme.titleSmall?.copyWith(
- color: theme.colorScheme.onSurfaceVariant,
- ),
- ),
- ],
- ],
- ),
- ],
- ),
- ),
-
- SizedBox(height: 8),
- if (userId != ref.watch(ClientStateController.provider)?.userId &&
- roomId != null) ...[
+ return SingleChildScrollView(
+ child: Padding(
+ padding: .all(42),
+ child: Column(
+ spacing: 4,
+ mainAxisSize: .min,
+ crossAxisAlignment: .center,
+ children: [
Row(
+ mainAxisAlignment: .end,
children: [
- Expanded(
- child: M3EButton.icon(
- onPressed: null,
- shape: .square,
- style: .tonal,
- icon: Icon(Icons.message),
- label: Text("Message"),
- ),
+ M3EButton(
+ onPressed: Navigator.of(context).pop,
+ child: Icon(Icons.close),
),
],
),
+ SizedBox(height: 18),
- if (ref.watch(
- PowerLevelController.provider(
- .membershipAction(
- action: .kick,
- roomId: roomId!,
- targetUser: userId,
- ),
+ ExpandableImage(
+ member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
+ child: AvatarOrHash(
+ member.avatarUrl,
+ member.displayName ?? userId.localpart,
+ dimension: 200,
),
- ))
- Padding(
- padding: .only(top: 4),
- child: Row(
- mainAxisSize: MainAxisSize.max,
- spacing: 8,
- children: [
- if (member.status == .join || member.status == .invite)
+ ),
+
+ SizedBox(height: 8),
+
+ SelectableText(
+ member.displayName ?? userId.localpart,
+ style: textTheme.headlineLarge,
+ textAlign: .center,
+ ),
+ SelectableText(
+ userId,
+ textAlign: .center,
+ style: textTheme.titleSmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+
+ ref
+ .watch(ProfileController.provider(userId))
+ .betterWhen(
+ loading: () => Text(""),
+ data: (profileResponse) => Column(
+ children: [
+ if (profileResponse.profile.timezone == null &&
+ profileResponse.profile.pronouns.isEmpty)
+ Text(""),
+ Wrap(
+ crossAxisAlignment: .center,
+ alignment: .center,
+ spacing: 4,
+ runSpacing: 4,
+ children: [
+ ...profileResponse.profile.pronouns
+ .where(
+ // TODO: Check system language (l10n)
+ (pronoun) => pronoun.language == "en",
+ )
+ .mapIndexed(
+ (index, pronoun) => [
+ if (index != 0)
+ Icon(
+ Icons.circle,
+ size: 4,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ Text(
+ pronoun.summary,
+ textAlign: .center,
+ style: textTheme.titleSmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ )
+ .flattened,
+
+ if (profileResponse.profile.timezone != null) ...[
+ if (profileResponse.profile.pronouns.isNotEmpty)
+ SizedBox(
+ height: 16,
+ child: VerticalDivider(
+ thickness: 1.5,
+ width: 4,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ Text(
+ profileResponse.profile.timezone!,
+ textAlign: .center,
+ style: textTheme.titleSmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ],
+ ),
+ ),
+
+ SizedBox(height: 8),
+ if (userId != ref.watch(ClientStateController.provider)?.userId &&
+ roomId != null) ...[
+ Row(
+ children: [
+ Expanded(
+ child: M3EButton.icon(
+ onPressed: null,
+ shape: .square,
+ style: .tonal,
+ icon: Icon(Icons.message),
+ label: Text("Message"),
+ ),
+ ),
+ ],
+ ),
+
+ if (ref.watch(
+ PowerLevelController.provider(
+ .membershipAction(
+ action: .kick,
+ roomId: roomId!,
+ targetUser: userId,
+ ),
+ ),
+ ))
+ Padding(
+ padding: .only(top: 4),
+ child: Row(
+ mainAxisSize: MainAxisSize.max,
+ spacing: 8,
+ children: [
+ if (member.status == .join || member.status == .invite)
+ M3EButton.icon(
+ onPressed: () => showMembershipDialog(.kick),
+ shape: .square,
+ icon: Icon(Icons.sports_martial_arts),
+ label: Text("Kick"),
+ decoration: .new(
+ backgroundColor: WidgetStatePropertyAll(
+ theme.colorScheme.error,
+ ),
+ foregroundColor: WidgetStatePropertyAll(
+ theme.colorScheme.onError,
+ ),
+ ),
+ ),
+
M3EButton.icon(
- onPressed: () => showMembershipDialog(.kick),
+ onPressed: () => showMembershipDialog(.ban),
shape: .square,
- icon: Icon(Icons.sports_martial_arts),
- label: Text("Kick"),
+ icon: Icon(Icons.gavel),
+ label: Text("Ban"),
decoration: .new(
backgroundColor: WidgetStatePropertyAll(
- theme.colorScheme.error,
+ theme.colorScheme.errorContainer,
),
foregroundColor: WidgetStatePropertyAll(
- theme.colorScheme.onError,
+ theme.colorScheme.onErrorContainer,
),
),
),
-
- M3EButton.icon(
- onPressed: () => showMembershipDialog(.ban),
- shape: .square,
- icon: Icon(Icons.gavel),
- label: Text("Ban"),
- decoration: .new(
- backgroundColor: WidgetStatePropertyAll(
- theme.colorScheme.errorContainer,
- ),
- foregroundColor: WidgetStatePropertyAll(
- theme.colorScheme.onErrorContainer,
- ),
- ),
- ),
- ].map((e) => Expanded(child: e)).toList(),
+ ].map((e) => Expanded(child: e)).toList(),
+ ),
),
- ),
+ ],
],
- ],
+ ),
),
);
}
diff --git a/linux/nix/default.nix b/linux/nix/default.nix
index 756eb0b..3c0a301 100644
--- a/linux/nix/default.nix
+++ b/linux/nix/default.nix
@@ -20,6 +20,7 @@
let
default = pkgs.callPackage ./pkg {
src = self;
+ commit = self.shortRev or self.dirtyShortRev;
};
in
{
@@ -30,7 +31,7 @@
developer = "QuadRadical";
appId = "nexus.federated.nexus";
package = default;
- runtime = "org.gnome.Platform/49";
+ runtime = "org.gnome.Platform/51";
permissions = {
share = [ "network" ];
sockets = [
diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix
index 3e55dfe..7574d45 100644
--- a/linux/nix/pkg/default.nix
+++ b/linux/nix/pkg/default.nix
@@ -5,7 +5,9 @@
libass,
libclang,
flutter,
+ stdenv,
src,
+ commit ? "dev",
}:
flutter.buildFlutterApplication {
@@ -19,6 +21,10 @@ flutter.buildFlutterApplication {
packageRun build_runner build
'';
+ flutterBuildFlags = [
+ "--dart-define=GIT_COMMIT=${commit}"
+ ];
+
buildInputs = [
mpv-unwrapped
libass
@@ -34,7 +40,7 @@ flutter.buildFlutterApplication {
navigation_rail_m3e = "sha256-+2awDTQnK58gGRY1nuHckG/jjxarsYSRu9ovR4i4TEc=";
unifiedpush_linux = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ=";
unifiedpush_platform_interface = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ=";
- xdg_desktop_portal = "sha256-As1X1/IfCbSD5ful2kAxgBita95KxmInRN4/6QmVaTI=";
+ xdg_desktop_portal = "sha256-8GgiXHGv+wy6hJgj/rZxbymMitNVmdDLAnedZm9STSc=";
};
postInstall = ''
@@ -49,6 +55,10 @@ flutter.buildFlutterApplication {
description = "A simple and user-friendly Matrix client";
mainProgram = "nexus";
platforms = lib.platforms.unix;
+
+ # https://github.com/NixOS/nixpkgs/blob/c59305bab2065cfecc4944690d9eedbb56f3a9fa/pkgs/development/compilers/flutter/build-support/build-flutter-application.nix#L15
+ broken = stdenv.hostPlatform.isDarwin;
+
maintainers = with lib.maintainers; [ quadradical ];
};
}
diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix
index ed6d8aa..d84450c 100644
--- a/linux/nix/pkg/gomuks.nix
+++ b/linux/nix/pkg/gomuks.nix
@@ -17,7 +17,7 @@ buildGoModule (
src = "${src}/gomuks";
- vendorHash = "sha256-e3+bUe7IURUUaLy905a3xlRgKbkHpVfMuGlZCNSxOyU=";
+ vendorHash = "sha256-lKiVzlFk0yffT6uUiSqQo/CiyTOfgKqiD+MEMoBvmE8=";
buildPhase = ''
runHook preBuild
diff --git a/pubspec.lock b/pubspec.lock
index 337cbae..a0e58e4 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -5,26 +5,26 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
- sha256: fe18c7e37d5acb3b43224fa255281dec007970d6fd01f6d76fcd88ed3777a117
+ sha256: fdcd9f70f9eb80df3bc5ed0fa67280df2595fd481948df8a9ab082e6a40ad04b
url: "https://pub.dev"
source: hosted
- version: "107.0.0"
+ version: "108.0.0"
analysis_server_plugin:
dependency: transitive
description:
name: analysis_server_plugin
- sha256: fc372bb4778b901afe4dea2e7f71ec74e6cbe772999d9961a4d8e382bc4fa49c
+ sha256: "3460f040e1f77647fcd82db9bd3bb117de04d04a3d67c39ad361794331147a4e"
url: "https://pub.dev"
source: hosted
- version: "0.3.22"
+ version: "0.3.23"
analyzer:
dependency: transitive
description:
name: analyzer
- sha256: f2dde9e50c23fc95b846b4ef334e98c3ae8c0effd5d2453b02fc9fb3f8939791
+ sha256: a51c769bff3b6dfbe9d60199b8606d808290702a296bef0c26a4ca391d414e46
url: "https://pub.dev"
source: hosted
- version: "14.3.0"
+ version: "14.4.0"
analyzer_buffer:
dependency: transitive
description:
@@ -37,10 +37,10 @@ packages:
dependency: transitive
description:
name: analyzer_plugin
- sha256: "521fbb02dada7d99d0fc6d10e4b501a07ae1d39ff2a850afbd0ec2279fb81f5c"
+ sha256: "9a3518eb84e8e26665874d9867534961d855493e9c5eb0eb55cbd46edd995529"
url: "https://pub.dev"
source: hosted
- version: "0.14.16"
+ version: "0.14.17"
app_links:
dependency: "direct main"
description:
@@ -77,10 +77,10 @@ packages:
dependency: transitive
description:
name: archive
- sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
+ sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4"
url: "https://pub.dev"
source: hosted
- version: "4.2.0"
+ version: "4.3.0"
args:
dependency: transitive
description:
@@ -149,10 +149,10 @@ packages:
dependency: transitive
description:
name: built_collection
- sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
+ sha256: "44922ecebd8e3d0cef743dadd3b54df4e87e2a4c52a29465a1872aa9e192caa8"
url: "https://pub.dev"
source: hosted
- version: "5.1.1"
+ version: "5.1.2"
built_value:
dependency: transitive
description:
@@ -205,10 +205,10 @@ packages:
dependency: "direct main"
description:
name: code_assets
- sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941
+ sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57"
url: "https://pub.dev"
source: hosted
- version: "2.0.0"
+ version: "2.1.0"
collection:
dependency: "direct main"
description:
@@ -261,10 +261,10 @@ packages:
dependency: transitive
description:
name: cupertino_ui
- sha256: e9dfe7fac704028f8928cbe4028a0be5e8a709498e8daf8247de99e99a32aef3
+ sha256: c23816577a1280cff409e989541767887011f8f1d8c1863a794ed308af46f2a9
url: "https://pub.dev"
source: hosted
- version: "1.0.2"
+ version: "1.1.1"
dart_style:
dependency: transitive
description:
@@ -381,10 +381,10 @@ packages:
dependency: transitive
description:
name: file_selector_android
- sha256: "7c76473740e33a11343c8fce88166049230850d2f19cd8a652ea935fcb8c9206"
+ sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c
url: "https://pub.dev"
source: hosted
- version: "0.5.2+10"
+ version: "0.5.2+11"
file_selector_ios:
dependency: transitive
description:
@@ -490,10 +490,10 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
- sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad"
+ sha256: "357be7ff996dc272e2037c69c1bdd5d1bd21172219d55f8bd2e11aeea602e6d3"
url: "https://pub.dev"
source: hosted
- version: "22.3.0"
+ version: "22.3.1"
flutter_local_notifications_linux:
dependency: transitive
description:
@@ -585,10 +585,10 @@ packages:
dependency: "direct dev"
description:
name: freezed
- sha256: "541a82723c84298f3ca46a6c2186935aa21ad529ad1f1423d67af18040494f51"
+ sha256: "3af98c92d7d4dd182ea1393193e69a840fcbaee1f66f1158bd20504e5f3f6f4f"
url: "https://pub.dev"
source: hosted
- version: "4.0.1"
+ version: "4.0.2"
freezed_annotation:
dependency: "direct main"
description:
@@ -689,10 +689,10 @@ packages:
dependency: transitive
description:
name: image
- sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
+ sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89
url: "https://pub.dev"
source: hosted
- version: "4.9.2"
+ version: "4.10.1"
image_picker:
dependency: "direct main"
description:
@@ -705,10 +705,10 @@ packages:
dependency: transitive
description:
name: image_picker_android
- sha256: "1c0c38790306fda4ed774095620444333e56a2b6bc8fc98f3a35c9398781cf54"
+ sha256: "0a55d645a670d6ae11efa948f955ae904afa472f1344fc15a7ddacc20c1e7219"
url: "https://pub.dev"
source: hosted
- version: "0.8.13+22"
+ version: "0.8.13+23"
image_picker_for_web:
dependency: transitive
description:
@@ -721,10 +721,10 @@ packages:
dependency: transitive
description:
name: image_picker_ios
- sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae
+ sha256: "852a7762962f3bb3ad54ef1d342669e41557f466033a676baa8876b367cf4050"
url: "https://pub.dev"
source: hosted
- version: "0.8.13+7"
+ version: "0.8.13+8"
image_picker_linux:
dependency: transitive
description:
@@ -858,10 +858,10 @@ packages:
dependency: "direct main"
description:
name: m3e_buttons
- sha256: "6bf60389eb7c838ac0402dba3657738ae1b015e312bc955ad8330ac17695a516"
+ sha256: dca780e813563754576194ed18e3f6a33422ed136c8d4ccfeca20909edddd728
url: "https://pub.dev"
source: hosted
- version: "1.0.2"
+ version: "1.0.3"
m3e_card_list:
dependency: "direct main"
description:
@@ -898,18 +898,18 @@ packages:
dependency: "direct main"
description:
name: material_emoji_picker
- sha256: feaaf38a4fa4010727dfdb0e05d32c3f49c1162bb7d6103ea808fc157d92a2e0
+ sha256: "738e70a8d2f592be05dd9889037cc6098a63e0d8c48723125848865ab305f138"
url: "https://pub.dev"
source: hosted
- version: "1.1.1"
+ version: "1.2.0"
material_ui:
dependency: "direct main"
description:
name: material_ui
- sha256: fbfb53cab6c4629438feeade5f3d305f72944bc9d9f25786b19106d32b80ec45
+ sha256: "32d98f11cf5942a095a8c3e01b03e4de3b17941437713dcf36b1088fb62e23d2"
url: "https://pub.dev"
source: hosted
- version: "1.2.0"
+ version: "1.5.0"
measure_size:
dependency: "direct main"
description:
@@ -1010,10 +1010,10 @@ packages:
dependency: transitive
description:
name: native_toolchain_c
- sha256: "9d233b6f2d9c52e1a2b5fbe70451d2c10ac674d3bb419d0ec8de14989d437c26"
+ sha256: "90eaad8059ebc2004a63810a4696ea9acdb02473e47ed3b6b3bafb100ac77e31"
url: "https://pub.dev"
source: hosted
- version: "0.19.4"
+ version: "0.19.5"
navigation_rail_m3e:
dependency: "direct main"
description:
@@ -1123,10 +1123,10 @@ packages:
dependency: transitive
description:
name: petitparser
- sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
+ sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790"
url: "https://pub.dev"
source: hosted
- version: "7.0.2"
+ version: "7.1.0"
platform:
dependency: transitive
description:
@@ -1650,10 +1650,10 @@ packages:
dependency: transitive
description:
name: vector_math
- sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
+ sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.dev"
source: hosted
- version: "2.4.2"
+ version: "2.4.3"
vm_service:
dependency: transitive
description:
@@ -1746,8 +1746,8 @@ packages:
dependency: "direct main"
description:
path: "."
- ref: HEAD
- resolved-ref: "9ead341e908da2972293b2edc345f2b0a6423d78"
+ ref: "quad/nexus"
+ resolved-ref: "964249996e536b3c5b10c2654d1dc69044693431"
url: "https://github.com/Henry-Hiles/xdg_desktop_portal.dart"
source: git
version: "0.1.14"
@@ -1784,5 +1784,5 @@ packages:
source: hosted
version: "2.2.4"
sdks:
- dart: "3.13.0"
+ dart: "3.13.3"
flutter: ">=3.47.0"
diff --git a/pubspec.yaml b/pubspec.yaml
index 403fe72..7f99ba3 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -5,18 +5,21 @@ publish_to: none
flutter:
fonts:
- - family: fallback-emoji
+ - family: bundled-emoji
fonts:
- - asset: assets/fonts/NotoColorEmoji.ttf
- - family: fallback-sans
+ - asset: assets/fonts/Twemoji.ttf
+ - family: bundled-sans
fonts:
- - asset: assets/fonts/Roboto.ttf
+ - asset: assets/fonts/Inter.ttf
+ - family: bundled-mono
+ fonts:
+ - asset: assets/fonts/AtkinsonHyperlegibleMono.ttf
assets:
- assets/bundled/
uses-material-design: true
environment:
- sdk: 3.13.0
+ sdk: 3.13.3
dependency_overrides:
dynamic_color: 2.1.0
@@ -63,7 +66,7 @@ dependencies:
flutter_hooks: 0.21.3+1
ffi: 2.2.0
hooks: 2.2.0
- code_assets: 2.0.0
+ code_assets: 2.1.0
ffigen: 22.0.0
timeago: 3.7.1
http: 1.6.0
@@ -75,7 +78,7 @@ dependencies:
media_kit_video: 2.0.1
media_kit_libs_video: 1.0.7
measure_size: 5.0.2
- m3e_buttons: 1.0.2
+ m3e_buttons: 1.0.3
navigation_rail_m3e:
git:
url: https://github.com/Henry-Hiles/material_3_expressive
@@ -85,24 +88,25 @@ dependencies:
package_info_plus: 10.2.1
app_links: 7.2.1
file_selector: 1.1.0
- material_ui: 1.2.0
+ material_ui: 1.5.0
unifiedpush: 6.2.0
unifiedpush_storage_shared_preferences: 1.0.0
- flutter_local_notifications: 22.3.0
- material_emoji_picker: 1.1.1
+ flutter_local_notifications: 22.3.1
+ material_emoji_picker: 1.2.0
xdg_desktop_portal:
git:
url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart
+ ref: quad/nexus
dev_dependencies:
build_runner: 2.16.1
flutter_lints: 6.0.0
- freezed: 4.0.1
+ freezed: 4.0.2
riverpod_lint: 3.1.9
flutter_launcher_icons: 0.14.4
json_serializable: 6.14.1
# Transitive dep but needed for commands
- webcrypto: any
+ webcrypto: 0.6.0
flutter_launcher_icons:
image_path: assets/icon.png