From 5f9cf8c7f56207eb3de77e883bedb709096ea6ab Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 6 Nov 2026 20:14:14 -0500 Subject: [PATCH 01/13] add infra for e2e testing --- flake.lock | 194 ++++++++++++++++++++++++++++++++++++++-- flake.nix | 2 + linux/nix/build_env.nix | 30 +++++++ linux/nix/default.nix | 23 +++++ linux/nix/devshell.nix | 43 +++------ linux/nix/e2e.sh | 77 ++++++++++++++++ pubspec.lock | 37 +++++++- pubspec.yaml | 6 ++ 8 files changed, 371 insertions(+), 41 deletions(-) create mode 100644 linux/nix/build_env.nix create mode 100755 linux/nix/e2e.sh diff --git a/flake.lock b/flake.lock index db5f957..6508ef8 100644 --- a/flake.lock +++ b/flake.lock @@ -1,9 +1,121 @@ { "nodes": { + "advisory-db": { + "flake": false, + "locked": { + "lastModified": 1789329924, + "narHash": "sha256-mykGMFabQ7yP6G373M3VHkrac9PJPKJstrhQz2D/Rdc=", + "owner": "rustsec", + "repo": "advisory-db", + "rev": "455fd4bac659b5f1fca3810661c2d8b3c25dad05", + "type": "github" + }, + "original": { + "owner": "rustsec", + "repo": "advisory-db", + "type": "github" + } + }, + "continuwuity": { + "inputs": { + "advisory-db": "advisory-db", + "crane": "crane", + "fenix": "fenix", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1794004794, + "narHash": "sha256-fNmKraRLE963JuV3K4giays4KcOVCGpjvFuEG2E0hmE=", + "ref": "refs/heads/quad/fix/latest-event-in-thread-aggregation", + "rev": "35813f52748c31b0b10cc72e69dabef6a838c46a", + "revCount": 8067, + "type": "git", + "url": "file:///home/quadradical/Documents/Code/continuwuity" + }, + "original": { + "type": "git", + "url": "file:///home/quadradical/Documents/Code/continuwuity" + } + }, + "crane": { + "locked": { + "lastModified": 1788465171, + "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "continuwuity", + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1789298866, + "narHash": "sha256-6eaV5+MdWy1tAlN4HAFr64pTnYc9jiKGXBsu18cyVjE=", + "owner": "nix-community", + "repo": "fenix", + "rev": "1fc6f36a23fa16e3740987957266f6e4b915c3cd", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "edolstra", + "ref": "master", + "repo": "flake-compat", + "type": "github" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" }, + "locked": { + "lastModified": 1788450739, + "narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_2": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib_2" + }, "locked": { "lastModified": 1793576659, "narHash": "sha256-V1h85lUaJBQoHFrB2vpXZOE0Ju1MYSnIpsehoVZYlDo=", @@ -39,7 +151,7 @@ "nix2flatpak": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs_2" }, "locked": { "lastModified": 1791751882, @@ -57,11 +169,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1773389992, - "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", + "lastModified": 1789149629, + "narHash": "sha256-H6GwaZzZf+4npqv0tph94w9tZddSjFjmQrVsW0z78uk=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", + "rev": "eaad089433ca2bb662274377d33df3d0e51ef28b", "type": "github" }, "original": { @@ -72,6 +184,21 @@ } }, "nixpkgs-lib": { + "locked": { + "lastModified": 1788057806, + "narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs-lib_2": { "locked": { "lastModified": 1793504054, "narHash": "sha256-ThkfL3D/FhCiZFc9+3BtxwuMmpuK+/bGtXCSo1+yVew=", @@ -87,6 +214,22 @@ } }, "nixpkgs_2": { + "locked": { + "lastModified": 1773389992, + "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { "locked": { "lastModified": 1793540411, "narHash": "sha256-fiPJ88izTVucjeo2EQ3jk4aWmLg03I1D4vxnX2QjEgE=", @@ -104,9 +247,27 @@ }, "root": { "inputs": { - "flake-parts": "flake-parts", + "continuwuity": "continuwuity", + "flake-parts": "flake-parts_2", "nix2flatpak": "nix2flatpak", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_3" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1789128286, + "narHash": "sha256-KRxxgHiKIBp7R2n41IJuE5K4IUiLOpg47EEi3AxuSp8=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "6aeeb8cf02741e5da07a1615a05faa506cecb21a", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" } }, "systems": { @@ -123,6 +284,27 @@ "repo": "default", "type": "github" } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "continuwuity", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786901030, + "narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index e4cae8a..2074aa6 100644 --- a/flake.nix +++ b/flake.nix @@ -6,6 +6,8 @@ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; nix2flatpak.url = "github:neobrain/nix2flatpak"; + # TEMPORARY: the e2e tests against a local Continuwuity checkout + continuwuity.url = "git+file:///home/quadradical/Documents/Code/continuwuity"; }; outputs = diff --git a/linux/nix/build_env.nix b/linux/nix/build_env.nix new file mode 100644 index 0000000..cac5fbd --- /dev/null +++ b/linux/nix/build_env.nix @@ -0,0 +1,30 @@ +{ pkgs, lib }: +# What building Nexus needs, shared by the dev shell and the e2e tests +{ + packages = + with pkgs; + [ + stdenv.cc + go + git + + libGL + (flutter.override { + extraPkgConfigPackages = [ + mpv-unwrapped + libass + ]; + }) + ] + ++ lib.optionals pkgs.stdenv.isLinux [ + wayland + # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately + libxext + libxfixes + (callPackage ./pkg/hook_pkg_config.nix { }) + ]; + + env = lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; + }; +} diff --git a/linux/nix/default.nix b/linux/nix/default.nix index 3c0a301..3d2f7b2 100644 --- a/linux/nix/default.nix +++ b/linux/nix/default.nix @@ -54,6 +54,29 @@ }; devShells.default = pkgs.callPackage ./devshell.nix { }; + + apps.e2e = + let + buildEnv = import ./build_env.nix { inherit pkgs lib; }; + in + { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "nexus-e2e"; + runtimeInputs = + buildEnv.packages + ++ (with pkgs; [ + # TEMPORARY: matrix-continuwuity, once the local fixes are released + inputs.continuwuity.packages.${system}.default + curl + dbus + ]); + runtimeEnv = buildEnv.env; + text = builtins.readFile ./e2e.sh; + } + ); + }; }; flake.nixosModules.default = import ./module.nix self; diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index f4bf1c6..cc2223e 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -20,41 +20,20 @@ let includeNDK = true; ndkVersions = [ "28.2.13676358" ]; }; + buildEnv = import ./build_env.nix { inherit pkgs lib; }; in pkgs.mkShell { - packages = - with pkgs; - [ - go - git + packages = buildEnv.packages ++ [ android.platform-tools ]; - libGL - (flutter.override { - extraPkgConfigPackages = [ - mpv-unwrapped - libass - ]; - }) - android.platform-tools - ] - ++ lib.optionals pkgs.stdenv.isLinux [ - wayland - # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately - libxext - libxfixes - (callPackage ./pkg/hook_pkg_config.nix { }) - ]; + env = + rec { + LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; - env = rec { - LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; + ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; + ANDROID_SDK_ROOT = ANDROID_HOME; + JAVA_HOME = pkgs.jdk21; - ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; - ANDROID_SDK_ROOT = ANDROID_HOME; - JAVA_HOME = pkgs.jdk21; - - GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; - } - // lib.optionalAttrs pkgs.stdenv.isLinux { - LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; - }; + GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; + } + // buildEnv.env; } diff --git a/linux/nix/e2e.sh b/linux/nix/e2e.sh new file mode 100755 index 0000000..e57e812 --- /dev/null +++ b/linux/nix/e2e.sh @@ -0,0 +1,77 @@ +# shellcheck shell=bash + +files=() +flutter_args=() +for arg in "$@"; do + if [[ "$arg" == *.dart ]]; then files+=("$arg"); else flutter_args+=("$arg"); fi +done +if [[ ${#files[@]} -eq 0 ]]; then files=(integration_test/*_test.dart); fi + +homeserver="http://localhost:8448" + +if curl -s -o /dev/null "$homeserver"; then + echo "Something is already using port 8448, like another e2e run." >&2 + exit 1 +fi + +work="$(mktemp -d)" +# shellcheck disable=SC2329 # Run by the trap +cleanup() { + [[ -n "${server_pid:-}" ]] && kill "$server_pid" 2>/dev/null && wait "$server_pid" 2>/dev/null + rm -rf "$work" +} +trap cleanup EXIT + +mkdir -p "$work/db" +cat > "$work/continuwuity.toml" < "$work/continuwuity.log" 2>&1 & +server_pid=$! + +all_can_log_in() { + for user in "${users[@]}"; do + curl -sf -XPOST "$homeserver/_matrix/client/v3/login" \ + -d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"$user\"},\"password\":\"${user}pass\"}" \ + > /dev/null || return 1 + done +} + +# Wait until login succeeds, otherwise you might try to log in before the account is created +attempts=60 +until all_can_log_in; do + kill -0 "$server_pid" 2>/dev/null || { cat "$work/continuwuity.log"; exit 1; } + (( --attempts )) || { echo "Test users weren't created" >&2; exit 1; } + sleep 0.5 +done + +GOCACHE="$(go env GOCACHE)" +export GOCACHE + +status=0 +for file in "${files[@]}"; do + name="$(basename "$file" .dart)" + mkdir -p "$work/$name/data" "$work/$name/cache" + XDG_DATA_HOME="$work/$name/data" XDG_CACHE_HOME="$work/$name/cache" \ + dbus-run-session -- \ + flutter test "$file" -d linux \ + --dart-define="NEXUS_E2E_HOMESERVER=$homeserver" "${flutter_args[@]}" || + status=$? +done +exit "$status" diff --git a/pubspec.lock b/pubspec.lock index 9ea2664..c4ccd9b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -58,7 +58,7 @@ packages: source: hosted version: "1.0.4" app_links_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: app_links_platform_interface sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" @@ -471,6 +471,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_file_dialog: dependency: "direct main" description: @@ -581,7 +586,7 @@ packages: source: hosted version: "2.3.2" flutter_test: - dependency: transitive + dependency: "direct dev" description: flutter source: sdk version: "0.0.0" @@ -623,6 +628,11 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" geoclue: dependency: transitive description: @@ -839,6 +849,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -1552,6 +1567,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: @@ -1715,7 +1738,7 @@ packages: source: hosted version: "3.2.6" url_launcher_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" @@ -1843,6 +1866,14 @@ packages: url: "https://github.com/DavidClaudeAI/webcrypto.dart" source: git version: "0.6.2-wip" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" webpush_encryption: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a1dbdf5..145a55c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -122,6 +122,12 @@ dependencies: latlong2: 0.10.1 geolocator: 14.1.2 dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + app_links_platform_interface: 2.0.4 + url_launcher_platform_interface: 2.3.2 build_runner: 2.17.1 flutter_lints: 6.0.0 freezed: 4.0.3 From 15287621e1d59e05505eb86189ec050c8723450e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 6 Nov 2026 21:17:24 -0500 Subject: [PATCH 02/13] add thread e2e tests --- integration_test/helpers/fakes.dart | 47 ++++ integration_test/helpers/matrix_user.dart | 174 +++++++++++++++ integration_test/helpers/oauth.dart | 68 ++++++ integration_test/helpers/pump_until.dart | 56 +++++ integration_test/helpers/test_app.dart | 115 ++++++++++ integration_test/helpers/threads.dart | 34 +++ integration_test/long_thread_test.dart | 115 ++++++++++ integration_test/threads_test.dart | 258 ++++++++++++++++++++++ 8 files changed, 867 insertions(+) create mode 100644 integration_test/helpers/fakes.dart create mode 100644 integration_test/helpers/matrix_user.dart create mode 100644 integration_test/helpers/oauth.dart create mode 100644 integration_test/helpers/pump_until.dart create mode 100644 integration_test/helpers/test_app.dart create mode 100644 integration_test/helpers/threads.dart create mode 100644 integration_test/long_thread_test.dart create mode 100644 integration_test/threads_test.dart diff --git a/integration_test/helpers/fakes.dart b/integration_test/helpers/fakes.dart new file mode 100644 index 0000000..050eb99 --- /dev/null +++ b/integration_test/helpers/fakes.dart @@ -0,0 +1,47 @@ +import "dart:async"; + +import "package:app_links_platform_interface/app_links_platform_interface.dart"; +import "package:url_launcher_platform_interface/link.dart"; +import "package:url_launcher_platform_interface/url_launcher_platform_interface.dart"; + +/// Delivers links to the app as if the OS had opened them. +class FakeAppLinks extends AppLinksPlatform { + final _links = StreamController.broadcast(); + + void open(Uri uri) => _links.add(uri); + + @override + Future getInitialLink() async => null; + + @override + Future getInitialLinkString() async => null; + + @override + Future getLatestLink() async => null; + + @override + Future getLatestLinkString() async => null; + + @override + Stream get uriLinkStream => _links.stream; + + @override + Stream get stringLinkStream => + _links.stream.map((uri) => uri.toString()); +} + +/// Hands launched URLs to [onLaunch] instead of opening a browser. +class FakeUrlLauncher(final void Function(Uri url) onLaunch) + extends UrlLauncherPlatform { + @override + LinkDelegate? get linkDelegate => null; + + @override + Future canLaunch(String url) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + onLaunch(.parse(url)); + return true; + } +} diff --git a/integration_test/helpers/matrix_user.dart b/integration_test/helpers/matrix_user.dart new file mode 100644 index 0000000..bd2692c --- /dev/null +++ b/integration_test/helpers/matrix_user.dart @@ -0,0 +1,174 @@ +import "dart:convert"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:http/http.dart" as http; + +const homeserver = String.fromEnvironment("NEXUS_E2E_HOMESERVER"); + +/// A user acting over the client-server API directly, as a control for the +/// tests: it doesn't go through gomuks or any other Nexus code, so it sets +/// things up and checks the server's side independently of what's being +/// tested. That includes Nexus' models, so the JSON it sends is written out +/// here rather than built the way Nexus builds it. +class const MatrixUser(final String userId, final String accessToken) { + static Future login(String username, String password) async { + final json = await request( + "POST", + "/_matrix/client/v3/login", + body: { + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": username}, + "password": password, + }, + ); + return MatrixUser(json["user_id"], json["access_token"]); + } + + static Future> request( + String method, + String path, { + String? accessToken, + Object? body, + }) async { + final httpRequest = + http.Request(method, Uri.parse(homeserver).resolve(path)) + ..headers.addAll({ + "Content-Type": "application/json", + if (accessToken != null) "Authorization": "Bearer $accessToken", + }); + if (method != "GET") httpRequest.body = jsonEncode(body ?? {}); + final response = await http.Response.fromStream(await httpRequest.send()); + + if (response.statusCode == 429) { + final retryAfter = jsonDecode(response.body)["retry_after_ms"] ?? 1000; + await Future.delayed(.new(milliseconds: retryAfter)); + return request(method, path, accessToken: accessToken, body: body); + } + if (response.statusCode >= 300) { + throw StateError( + "$method $path failed with ${response.statusCode}: ${response.body}", + ); + } + return jsonDecode(response.body); + } + + Future createRoom(String name) async => (await request( + "POST", + "/_matrix/client/v3/createRoom", + accessToken: accessToken, + body: {"name": name, "preset": "private_chat"}, + ))["room_id"]; + + Future invite(String roomId, String userId) => request( + "POST", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/invite", + accessToken: accessToken, + body: {"user_id": userId}, + ); + + Future join(String roomId) => request( + "POST", + "/_matrix/client/v3/join/${Uri.encodeComponent(roomId)}", + accessToken: accessToken, + ); + + Future logout() => + request("POST", "/_matrix/client/v3/logout", accessToken: accessToken); + + Future sendEvent( + String roomId, + String type, + Map content, + ) async => (await request( + "PUT", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/send/$type/" + "${DateTime.now().microsecondsSinceEpoch}", + accessToken: accessToken, + body: content, + ))["event_id"]; + + /// Sends a text message. With [threadRoot] it's a reply in that thread, and + /// otherwise with [replyTo] it's a reply to that event. + Future send( + String roomId, + String body, { + String? threadRoot, + String? replyTo, + }) => sendEvent(roomId, "m.room.message", { + "msgtype": "m.text", + "body": body, + "m.relates_to": ?switch ((threadRoot, replyTo)) { + (final threadRoot?, _) => { + "rel_type": "m.thread", + "event_id": threadRoot, + "is_falling_back": true, + "m.in_reply_to": {"event_id": threadRoot}, + }, + (null, final replyTo?) => { + "m.in_reply_to": {"event_id": replyTo}, + }, + (null, null) => null, + }, + }); + + /// Sends [rootBody], then [replies] in a thread from it, oldest first. + Future<({String rootId, IList replyIds})> sendThread( + String roomId, + String rootBody, + IList replies, + ) async { + final rootId = await send(roomId, rootBody); + // One at a time, so they're in order (oops earlier tests weren't reproducable) + final replyIds = await Stream.fromIterable(replies) + .asyncMap((body) => send(roomId, body, threadRoot: rootId)) + .toList(); + return (rootId: rootId, replyIds: replyIds.toIList()); + } + + /// Edits the text message [eventId] to say [body]. + Future edit(String roomId, String eventId, String body) => + sendEvent(roomId, "m.room.message", { + "msgtype": "m.text", + "body": "* $body", + "m.new_content": {"msgtype": "m.text", "body": body}, + "m.relates_to": {"rel_type": "m.replace", "event_id": eventId}, + }); + + Future redact(String roomId, String eventId) => request( + "PUT", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/redact/" + "${Uri.encodeComponent(eventId)}/${DateTime.now().microsecondsSinceEpoch}", + accessToken: accessToken, + ); + + /// The bodies of the replies in the thread from [threadRoot], oldest first. + Future> threadReplies(String roomId, String threadRoot) async { + final json = await request( + "GET", + "/_matrix/client/v1/rooms/${Uri.encodeComponent(roomId)}/relations/" + "${Uri.encodeComponent(threadRoot)}/m.thread?dir=f&limit=100", + accessToken: accessToken, + ); + return (json["chunk"] as List) + .map((event) => event["content"]["body"] as String) + .toIList(); + } + + Future> threadRepliesIncluding( + String roomId, + String threadRoot, + String body, { + int attempts = 20, + }) async { + final replies = await threadReplies(roomId, threadRoot); + if (replies.contains(body) || attempts <= 1) return replies; + + await Future.delayed(const .new(milliseconds: 500)); + return threadRepliesIncluding( + roomId, + threadRoot, + body, + attempts: attempts - 1, + ); + } +} diff --git a/integration_test/helpers/oauth.dart b/integration_test/helpers/oauth.dart new file mode 100644 index 0000000..be652d7 --- /dev/null +++ b/integration_test/helpers/oauth.dart @@ -0,0 +1,68 @@ +import "package:http/http.dart" as http; + +import "fakes.dart"; + +/// Fake platforms that do the browser's part of OAuth on Continuwuity, logging +/// in as [username], then hand the redirect back to Nexus. +class OAuthFakes(final String username, final String password) { + final links = FakeAppLinks(); + late final launcher = FakeUrlLauncher(onLaunch); + + Future onLaunch(Uri url) async { + if (!url.queryParameters.containsKey("code_challenge")) return; + + final redirect = await authorize(url); + // Nexus listens for the redirect after the launch returns + await Future.delayed(const .new(milliseconds: 500)); + links.open(redirect); + } + + /// Logs in on [authorizationUrl], then confirms the grant, returning the URI + /// Continuwuity redirects back to the app with. + Future authorize(Uri authorizationUrl) async { + final client = http.Client(); + String? cookie; + + Future followRedirect( + String method, + Uri url, [ + Map? form, + ]) async { + final request = http.Request(method, url) + ..followRedirects = false + ..headers.addAll({ + // Continuwuity only accepts its forms from top-level navigations + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + "Cookie": ?cookie, + }); + if (form != null) request.bodyFields = form; + + final response = await http.Response.fromStream( + await client.send(request), + ); + if (response.headers["set-cookie"] case final setCookie?) { + cookie = setCookie.split(";").first; + } + + return switch ((response.statusCode, response.headers["location"])) { + (302 || 303 || 307, final location?) => url.resolve(location), + (final status, _) => throw StateError( + "Expected $method $url to redirect, got $status: ${response.body}", + ), + }; + } + + try { + final loginUrl = await followRedirect("GET", authorizationUrl); + final grantUrl = await followRedirect("POST", loginUrl, { + "identifier": username, + "password": password, + }); + return await followRedirect("POST", grantUrl, {"stage": "confirm"}); + } finally { + client.close(); + } + } +} diff --git a/integration_test/helpers/pump_until.dart b/integration_test/helpers/pump_until.dart new file mode 100644 index 0000000..5f278f4 --- /dev/null +++ b/integration_test/helpers/pump_until.dart @@ -0,0 +1,56 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/error_dialog.dart"; + +// Custom because `patrol_finder` gives me jQuery vibes, and that's Not GoodTM +extension PumpUntil on WidgetTester { + /// Pumps until [finder] finds something, failing if it doesn't within 30 + /// seconds, or if the app shows an error. + Future pumpUntil(Finder finder) async { + const timeout = Duration(seconds: 30); + + String textsIn(Finder finder) => widgetList( + find.descendant(of: finder, matching: find.byType(Text)), + ).map((text) => text.data).nonNulls.join("\n"); + + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsed < timeout) { + await pump(const .new(milliseconds: 100)); + + if (finder.evaluate().isNotEmpty) return; + + final errorDialog = find.byType(ErrorDialog); + if (errorDialog.evaluate().isNotEmpty) { + fail( + "The app showed an error while waiting for $finder:\n" + "${textsIn(errorDialog)}", + ); + } + } + + final snackBar = find.byType(SnackBar); + final shown = find + .byType(RichText) + .hitTestable() + .evaluate() + .map((element) => (element.widget as RichText).text.toPlainText()) + .where((text) => text.trim().isNotEmpty) + .toIList() + .removeDuplicates() + .join(" | "); + fail( + "Timed out after $timeout waiting for $finder" + "${snackBar.evaluate().isEmpty ? "" : "\nSnack bar: ${textsIn(snackBar)}"}" + "\nShown: $shown", + ); + } + + /// Taps [finder] once it can be tapped, like after animating in. + Future tapWhenShown(Finder finder) async { + final tappable = finder.hitTestable(); + await pumpUntil(tappable); + await tap(tappable.first); + await pump(); + } +} diff --git a/integration_test/helpers/test_app.dart b/integration_test/helpers/test_app.dart new file mode 100644 index 0000000..d6724c0 --- /dev/null +++ b/integration_test/helpers/test_app.dart @@ -0,0 +1,115 @@ +import "package:app_links_platform_interface/app_links_platform_interface.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:media_kit/media_kit.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/main.dart" as nexus; +import "package:nexus/widgets/account_scope.dart"; +import "package:nexus/widgets/pages/chat.dart"; +import "package:nexus/widgets/pages/select_server.dart"; +import "package:nexus/widgets/pages/verify.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:url_launcher_platform_interface/url_launcher_platform_interface.dart"; + +import "matrix_user.dart"; +import "oauth.dart"; +import "pump_until.dart"; + +/// Nexus, run as the account [username], which is set up through the UI the +/// first time it's opened. +class TestApp(final String username, final String password) { + late final fakes = OAuthFakes(username, password); + + void setUp() { + nexus.isInBackground = false; + MediaKit.ensureInitialized(); + AppLinksPlatform.instance = fakes.links; + UrlLauncherPlatform.instance = fakes.launcher; + + // A tap that misses would otherwise only warn + WidgetController.hitTestWarningShouldBeFatal = true; + } + + Future join(MatrixUser inviter, String roomId) async { + final user = await MatrixUser.login(username, password); + await inviter.invite(roomId, user.userId); + await user.join(roomId); + await user.logout(); + } + + /// Starts Nexus like [open], then opens [roomName]. + Future openRoom(WidgetTester tester, String roomName) async { + await open(tester); + await selectRoom(tester, roomName); + } + + /// Taps [roomName] in the room list, opening the drawer it's in first when + /// the window is narrow enough for the mobile layout. + Future selectRoom(WidgetTester tester, String roomName) async { + // Shown once the first sync is done, as is the drawer button on mobile + await tester.pumpUntil(find.byType(RoomChat)); + + final drawerButton = find + .byTooltip(const DefaultMaterialLocalizations().openAppDrawerTooltip) + .hitTestable(); + if (drawerButton.evaluate().isNotEmpty) await tester.tap(drawerButton); + await tester.tapWhenShown(find.text(roomName)); + } + + /// Starts Nexus, logged in and verified, logging in and setting up + /// encryption through the UI first if it isn't. + Future open(WidgetTester tester) async { + await tester.pumpWidget( + AccountScope( + initialAccountId: await ActiveAccountController.stored(), + retry: (_, _) => null, + child: nexus.App(), + ), + ); + + await tester.pumpUntil( + find.byWidgetPredicate( + (widget) => + widget is SelectServerPage || + widget is VerifyPage || + widget is ChatPage, + ), + ); + if (find.byType(SelectServerPage).evaluate().isNotEmpty) { + await logIn(tester); + } + if (find.byType(VerifyPage).evaluate().isNotEmpty) { + await setUpEncryption(tester); + } + await tester.pumpUntil(find.byType(ChatPage)); + } + + Future logIn(WidgetTester tester) async { + final homeserverField = find.widgetWithText(TextField, "Homeserver URL"); + await tester.pumpUntil(homeserverField); + await tester.enterText(homeserverField, homeserver); + await tester.testTextInput.receiveAction(.done); + + // Discovery only uses HTTPS, which the test homeserver doesn't serve, so + // Nexus offers to log in with the URL as typed + await tester.tapWhenShown(find.text("Attempt log in anyways")); + + // Nexus opens the authorization page, which fakes authorizes + await tester.pumpUntil( + find.byWidgetPredicate( + (widget) => widget is VerifyPage || widget is ChatPage, + ), + ); + } + + /// Sets up encryption from the verify page, for an account without it. + Future setUpEncryption(WidgetTester tester) async { + await tester.pumpUntil(find.text("Set Up Encryption")); + await tester.tapWhenShown(find.widgetWithText(TextButton, "Set Up")); + + await tester.tapWhenShown(find.text("I've saved my recovery key")); + await tester.tapWhenShown(find.widgetWithText(TextButton, "Set Up")); + + await tester.tapWhenShown(find.widgetWithText(TextButton, "Done")); + } +} diff --git a/integration_test/helpers/threads.dart b/integration_test/helpers/threads.dart new file mode 100644 index 0000000..d2eee0d --- /dev/null +++ b/integration_test/helpers/threads.dart @@ -0,0 +1,34 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; + +import "pump_until.dart"; + +extension MessageFinders on CommonFinders { + Finder message(String body) => textContaining(body, findRichText: true); +} + +extension RelativeFinders on Finder { + Finder inside(Type type) => + find.descendant(of: find.byType(type), matching: this); + + /// The [InkWell] ancestor around what this finds + Finder get inkWell => find.ancestor(of: this, matching: find.byType(InkWell)); +} + +extension ReplyCounts on WidgetTester { + /// Waits for a reply count in widgets of [type], then checks it, so a wrong + /// count fails straight away with what was shown. + Future expectReplyCount(Type type, int expected) async { + final repliesLabel = RegExp(r"^(\d+) repl(?:y|ies)$"); + final labels = find.textContaining(repliesLabel).inside(type); + await pumpUntil(labels); + + final shown = widgetList(labels) + .map((label) => label.data) + .nonNulls + .map(repliesLabel.firstMatch) + .nonNulls + .map((match) => int.parse(match[1]!)); + expect(shown, everyElement(expected), reason: "Reply count in $type"); + } +} diff --git a/integration_test/long_thread_test.dart b/integration_test/long_thread_test.dart new file mode 100644 index 0000000..b292c12 --- /dev/null +++ b/integration_test/long_thread_test.dart @@ -0,0 +1,115 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/thread_drawer.dart"; +import "package:nexus/widgets/threads_drawer.dart"; + +import "helpers/matrix_user.dart"; +import "helpers/test_app.dart"; +import "helpers/pump_until.dart"; +import "helpers/threads.dart"; + +/// A thread longer than gomuks' initial sync, which gets the latest 100 +/// events, so its start only reaches Nexus through `/relations`. That needs +/// an account whose first sync is after the thread exists, so it's not the one +/// other tests use. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final app = TestApp("carol", "carolpass"); + setUpAll(app.setUp); + final run = DateTime.now().millisecondsSinceEpoch; + final roomName = "Long thread $run"; + final rootBody = "Long thread root $run"; + const replyCount = 120; + // Among the first replies, which aren't synced + const jumpedTo = 10; + // Padded, so no reply's text contains another's (oops those failures were confusing when I didn't do that) + String reply(int number) => "Long reply ${number.toString().padLeft(3, "0")}"; + + setUpAll(() async { + final bob = await MatrixUser.login("bob", "bobpass"); + final roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + Iterable.generate(replyCount, (index) => reply(index + 1)).toIList(), + ); + // In the room's timeline, so there's something to jump from, to a reply + // with others on both sides that aren't synced either + await bob.send( + roomId, + "Quoting an old reply", + replyTo: thread.replyIds[jumpedTo - 1], + ); + }); + + /// Drags the open thread by [offset] until [finder] is shown in it, loading + /// more replies as it goes, for up to 30 seconds. + Future scrollThread( + WidgetTester tester, + Finder finder, + Offset offset, + ) => tester.dragUntilVisible( + finder.inside(ThreadDrawer), + find.byType(CustomScrollView).inside(ThreadDrawer).first, + offset, + maxIteration: 60, + duration: const .new(milliseconds: 500), + ); + + Future openThreadsDrawer(WidgetTester tester) async { + await app.openRoom(tester, roomName); + await tester.tapWhenShown(find.byTooltip("Open threads")); + await tester.pumpUntil(find.message(rootBody).inside(ThreadsDrawer)); + } + + testWidgets("lists the thread with all of its replies", (tester) async { + await openThreadsDrawer(tester); + + await tester.expectReplyCount(ThreadsDrawer, replyCount); + }); + + // Before the whole thread is loaded by scrolling through it below + testWidgets("jumps to a reply in the thread that isn't loaded", ( + tester, + ) async { + await app.openRoom(tester, roomName); + + // The quote of the reply is fetched on its own, then tapped + await tester.tapWhenShown( + find.message(reply(jumpedTo)).inside(RoomChat).inkWell, + ); + + // The thread opens around the reply, with replies on both sides + await tester.pumpUntil( + find.message(reply(jumpedTo)).inside(ThreadDrawer).hitTestable(), + ); + await tester.pumpUntil( + find.message(reply(jumpedTo - 1)).inside(ThreadDrawer), + ); + await tester.pumpUntil( + find.message(reply(jumpedTo + 1)).inside(ThreadDrawer), + ); + + await scrollThread(tester, find.message(reply(60)), const .new(0, -400)); + }); + + testWidgets("loads older replies back to the root", (tester) async { + await openThreadsDrawer(tester); + await tester.tapWhenShown( + find.message(rootBody).inside(ThreadsDrawer).inkWell, + ); + await tester.pumpUntil( + find.message(reply(replyCount)).inside(ThreadDrawer), + ); + + // Dragging down scrolls up the reversed timeline, to older replies + await scrollThread(tester, find.message(rootBody), const .new(0, 400)); + expect(find.message(reply(1)).inside(ThreadDrawer), findsWidgets); + }); +} diff --git a/integration_test/threads_test.dart b/integration_test/threads_test.dart new file mode 100644 index 0000000..6eba136 --- /dev/null +++ b/integration_test/threads_test.dart @@ -0,0 +1,258 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/thread_drawer.dart"; +import "package:nexus/widgets/thread_summary.dart"; +import "package:nexus/widgets/threads_drawer.dart"; + +import "helpers/matrix_user.dart"; +import "helpers/test_app.dart"; +import "helpers/pump_until.dart"; +import "helpers/threads.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final app = TestApp("alice", "alicepass"); + setUpAll(app.setUp); + final run = DateTime.now().millisecondsSinceEpoch; + late MatrixUser bob; + + setUpAll(() async => bob = await MatrixUser.login("bob", "bobpass")); + + /// Opens [roomName], waiting for the thread from [rootBody] in its timeline. + Future openRoom( + WidgetTester tester, + String roomName, + String rootBody, + ) async { + await app.openRoom(tester, roomName); + await tester.pumpUntil( + find.message(rootBody).inside(RoomChat).hitTestable(), + ); + } + + /// Opens the thread from the room's summary, waiting for [reply] in it. + Future openThread(WidgetTester tester, String reply) async { + await tester.tapWhenShown(find.byType(ThreadSummary)); + await tester.pumpUntil(find.message(reply).inside(ThreadDrawer)); + } + + group("a thread", () { + final roomName = "Threads $run"; + final rootBody = "Thread root $run"; + const firstReply = "First reply"; + const secondReply = "Second reply"; + + late String roomId; + late String rootId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + // Joined before Nexus logs in, so the room is there once it does + await app.join(bob, roomId); + + rootId = (await bob.sendThread( + roomId, + rootBody, + .new([firstReply, secondReply]), + )).rootId; + }); + + Future replyCount() async => + (await bob.threadReplies(roomId, rootId)).length; + + testWidgets("logs in with OAuth and sets up encryption", (tester) async { + await app.open(tester); + + await app.selectRoom(tester, roomName); + }); + + testWidgets("summarises the thread under its root", (tester) async { + await openRoom(tester, roomName, rootBody); + + await tester.expectReplyCount(ThreadSummary, 2); + expect(find.message(secondReply).inside(ThreadSummary), findsWidgets); + // Replies are in the thread, not the room's timeline + expect(find.message(firstReply), findsNothing); + }); + + testWidgets("lists the thread in the threads drawer", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.tapWhenShown(find.byTooltip("Open threads")); + + await tester.pumpUntil(find.message(rootBody).inside(ThreadsDrawer)); + await tester.expectReplyCount(ThreadsDrawer, 2); + expect(find.text("Last active:").inside(ThreadsDrawer), findsOneWidget); + }); + + testWidgets("opens the thread from the threads drawer", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.tapWhenShown(find.byTooltip("Open threads")); + await tester.tapWhenShown( + find.message(rootBody).inside(ThreadsDrawer).inkWell, + ); + + await tester.pumpUntil(find.message(secondReply).inside(ThreadDrawer)); + }); + + testWidgets("shows the root and its replies, oldest first", (tester) async { + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final root = find.message(rootBody).inside(ThreadDrawer).first; + final first = find.message(firstReply).inside(ThreadDrawer).first; + final second = find.message(secondReply).inside(ThreadDrawer).first; + expect(root, findsOneWidget); + expect(first, findsOneWidget); + expect( + tester.getTopLeft(root).dy, + lessThan(tester.getTopLeft(first).dy), + reason: "The root should be above the replies", + ); + expect( + tester.getTopLeft(first).dy, + lessThan(tester.getTopLeft(second).dy), + reason: "Older replies should be above newer ones", + ); + }); + + testWidgets("sends replies to the thread", (tester) async { + final body = "Reply from Nexus $run"; + final before = await replyCount(); + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final composer = find + .widgetWithText(TextField, "Your message here...") + .inside(ThreadDrawer); + await tester.pumpUntil(composer); + await tester.enterText(composer, body); + await tester.tapWhenShown( + find.byTooltip("Send message").inside(ThreadDrawer), + ); + await tester.pumpUntil(find.message(body).inside(ThreadDrawer)); + + // It's in the thread for other clients too + final replies = await bob.threadRepliesIncluding(roomId, rootId, body); + expect(replies.last, body); + + // Shown as the latest reply once the summary has caught up + await tester.pumpUntil(find.message(body).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, before + 1); + expect( + find.text(body).inside(Composer), + findsNothing, + reason: "The composer should be cleared once sent", + ); + }); + + testWidgets("shows replies from others as they arrive", (tester) async { + final body = "Live reply $run"; + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final before = await replyCount(); + await bob.send(roomId, body, threadRoot: rootId); + + await tester.pumpUntil(find.message(body).inside(ThreadDrawer)); + await tester.pumpUntil(find.message(body).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, before + 1); + }); + }); + + group("redactions", () { + final roomName = "Redactions $run"; + final rootBody = "Redaction root $run"; + const keptReply = "Kept reply"; + const redactedReply = "Redacted reply"; + + late String roomId; + late String rootId; + late String redactedId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + .new([keptReply, redactedReply]), + ); + rootId = thread.rootId; + redactedId = thread.replyIds.last; + }); + + testWidgets("drops a redacted reply from its thread", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.expectReplyCount(ThreadSummary, 2); + + await bob.redact(roomId, redactedId); + + // The reply before it becomes the latest + await tester.pumpUntil(find.message(keptReply).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, 1); + + await openThread(tester, keptReply); + expect(find.message(redactedReply).inside(ThreadDrawer), findsNothing); + }); + + testWidgets("still opens the thread of a redacted root", (tester) async { + await bob.redact(roomId, rootId); + await app.openRoom(tester, roomName); + await tester.tapWhenShown(find.byTooltip("Open threads")); + + // The root's preview is redacted, so the card is found by its count + await tester.tapWhenShown( + find.text("1 reply").inside(ThreadsDrawer).inkWell, + ); + + final root = find.text("Redacted event").inside(ThreadDrawer); + final reply = find.message(keptReply).inside(ThreadDrawer); + await tester.pumpUntil(root); + await tester.pumpUntil(reply); + expect( + tester.getTopLeft(root.first).dy, + lessThan(tester.getTopLeft(reply.first).dy), + reason: "The redacted root should still be above the replies", + ); + }); + }); + + group("edits", () { + final roomName = "Edits $run"; + final rootBody = "Edit root $run"; + const originalReply = "Reply before editing"; + const editedReply = "Reply after editing"; + + late String roomId; + late String replyId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + .new([originalReply]), + ); + replyId = thread.replyIds.single; + }); + + testWidgets("shows an edited reply's new text", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.pumpUntil(find.message(originalReply).inside(ThreadSummary)); + + await bob.edit(roomId, replyId, editedReply); + + await tester.pumpUntil(find.message(editedReply).inside(ThreadSummary)); + await openThread(tester, editedReply); + expect(find.message(originalReply).inside(ThreadDrawer), findsNothing); + expect(find.text("(edited)").inside(ThreadDrawer), findsOneWidget); + }); + }); +} From 7bbe2cccf62b329b23a37bb1707e354729bc0b21 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 6 Nov 2026 21:44:03 -0500 Subject: [PATCH 03/13] add headless app for tests on CI --- .github/workflows/e2e.yml | 28 +++++++++++++++++++ flake.lock | 8 +++--- linux/nix/default.nix | 57 +++++++++++++++++++++++++++------------ 3 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..c374e64 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,28 @@ +name: "End-to-end tests" + +on: + push: + branches: ["main"] + pull_request: + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + + 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: Generate code + run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build" + + - name: Run end-to-end tests + run: nix run .#e2e-headless diff --git a/flake.lock b/flake.lock index 6508ef8..5dc84b3 100644 --- a/flake.lock +++ b/flake.lock @@ -27,11 +27,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1794004794, - "narHash": "sha256-fNmKraRLE963JuV3K4giays4KcOVCGpjvFuEG2E0hmE=", + "lastModified": 1794063651, + "narHash": "sha256-EXBj8a1aBO99CpMZWGqf6z6D/LFdsW/aB2xfDDrJuHw=", "ref": "refs/heads/quad/fix/latest-event-in-thread-aggregation", - "rev": "35813f52748c31b0b10cc72e69dabef6a838c46a", - "revCount": 8067, + "rev": "40a4100ec8734d18f8baf8b0dcfea80b6944e90e", + "revCount": 8068, "type": "git", "url": "file:///home/quadradical/Documents/Code/continuwuity" }, diff --git a/linux/nix/default.nix b/linux/nix/default.nix index 3d2f7b2..3d3b395 100644 --- a/linux/nix/default.nix +++ b/linux/nix/default.nix @@ -55,27 +55,50 @@ devShells.default = pkgs.callPackage ./devshell.nix { }; - apps.e2e = + apps = let buildEnv = import ./build_env.nix { inherit pkgs lib; }; + e2e = pkgs.writeShellApplication { + name = "nexus-e2e"; + runtimeInputs = + buildEnv.packages + ++ (with pkgs; [ + # TEMPORARY: matrix-continuwuity, once the local fixes are released + inputs.continuwuity.packages.${system}.default + curl + dbus + ]); + runtimeEnv = buildEnv.env; + text = builtins.readFile ./e2e.sh; + }; in { - type = "app"; - program = lib.getExe ( - pkgs.writeShellApplication { - name = "nexus-e2e"; - runtimeInputs = - buildEnv.packages - ++ (with pkgs; [ - # TEMPORARY: matrix-continuwuity, once the local fixes are released - inputs.continuwuity.packages.${system}.default - curl - dbus - ]); - runtimeEnv = buildEnv.env; - text = builtins.readFile ./e2e.sh; - } - ); + e2e = { + type = "app"; + program = lib.getExe e2e; + }; + + e2e-headless = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "nexus-e2e-headless"; + runtimeInputs = [ pkgs.cage ]; + runtimeEnv = { + WLR_BACKENDS = "headless"; + __EGL_VENDOR_LIBRARY_FILENAMES = "${pkgs.mesa}/share/glvnd/egl_vendor.d/50_mesa.json"; + }; + text = '' + unset DISPLAY WAYLAND_DISPLAY + # Wayland needs one, which CI may not have + XDG_RUNTIME_DIR="''${XDG_RUNTIME_DIR:-$(mktemp -d)}" + export XDG_RUNTIME_DIR + + exec cage -- ${lib.getExe e2e} "$@" + ''; + } + ); + }; }; }; From 52c84c93d14114a2844d6869efa17673b7e012bf Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 9 Nov 2026 15:20:29 -0500 Subject: [PATCH 04/13] bump lock --- flake.lock | 58 +++++++++++++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/flake.lock b/flake.lock index 5dc84b3..be46f01 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "advisory-db": { "flake": false, "locked": { - "lastModified": 1789329924, - "narHash": "sha256-mykGMFabQ7yP6G373M3VHkrac9PJPKJstrhQz2D/Rdc=", + "lastModified": 1793979272, + "narHash": "sha256-oUcBtPbtS7PlEMuSrgTAN4wv49cXYbwKbQqhH2pah5w=", "owner": "rustsec", "repo": "advisory-db", - "rev": "455fd4bac659b5f1fca3810661c2d8b3c25dad05", + "rev": "309392b18f6b17022f82c701f300433126901068", "type": "github" }, "original": { @@ -27,11 +27,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1794063651, - "narHash": "sha256-EXBj8a1aBO99CpMZWGqf6z6D/LFdsW/aB2xfDDrJuHw=", - "ref": "refs/heads/quad/fix/latest-event-in-thread-aggregation", - "rev": "40a4100ec8734d18f8baf8b0dcfea80b6944e90e", - "revCount": 8068, + "lastModified": 1794250216, + "narHash": "sha256-IQcTJbLuxL9BxtTp9tjhC5NM8YvSfODbEP4wKXzc9gM=", + "ref": "refs/heads/quad/nexus", + "rev": "62b78e20b0f28a7a3c847cc4baa6223eeeed53f1", + "revCount": 8090, "type": "git", "url": "file:///home/quadradical/Documents/Code/continuwuity" }, @@ -42,11 +42,11 @@ }, "crane": { "locked": { - "lastModified": 1788465171, - "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=", + "lastModified": 1793836618, + "narHash": "sha256-TREenflwEjxOSq292FYwbc3kEm7TDRtXCZRNWEdjBmg=", "owner": "ipetkov", "repo": "crane", - "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54", + "rev": "79fd63528501446749c09f08cad486a58c7d173e", "type": "github" }, "original": { @@ -64,11 +64,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1789298866, - "narHash": "sha256-6eaV5+MdWy1tAlN4HAFr64pTnYc9jiKGXBsu18cyVjE=", + "lastModified": 1794142885, + "narHash": "sha256-xWFSr7q8IerDAVRk4W7/CgTmi05hMy2qjZ0z3VYpOVk=", "owner": "nix-community", "repo": "fenix", - "rev": "1fc6f36a23fa16e3740987957266f6e4b915c3cd", + "rev": "f7d416b5073152c7f2e4de1a28d1a4330dd30a26", "type": "github" }, "original": { @@ -99,11 +99,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1788450739, - "narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=", + "lastModified": 1793576659, + "narHash": "sha256-V1h85lUaJBQoHFrB2vpXZOE0Ju1MYSnIpsehoVZYlDo=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993", + "rev": "ea2630b7470978622c7689a1ff75d2b389175399", "type": "github" }, "original": { @@ -169,11 +169,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1789149629, - "narHash": "sha256-H6GwaZzZf+4npqv0tph94w9tZddSjFjmQrVsW0z78uk=", + "lastModified": 1794042290, + "narHash": "sha256-ziZn2PvCJE5JEEpqTdxXwJpPB+L3YqoCpTAMCzyaP3U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "eaad089433ca2bb662274377d33df3d0e51ef28b", + "rev": "f4a03c008967841f2a0b3cb7c436fc6fc31671f6", "type": "github" }, "original": { @@ -185,11 +185,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1788057806, - "narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=", + "lastModified": 1793504054, + "narHash": "sha256-ThkfL3D/FhCiZFc9+3BtxwuMmpuK+/bGtXCSo1+yVew=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c", + "rev": "7851ba61fd862b277cb6a9cef37394bc18bb94e4", "type": "github" }, "original": { @@ -256,11 +256,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1789128286, - "narHash": "sha256-KRxxgHiKIBp7R2n41IJuE5K4IUiLOpg47EEi3AxuSp8=", + "lastModified": 1794096810, + "narHash": "sha256-dWC+YtMX+cwH3e9ndiFB77ijTsKtO5I2OxfcvUY4j3k=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "6aeeb8cf02741e5da07a1615a05faa506cecb21a", + "rev": "03ae6be57aa063bb373f69eead3b8e15168a45d2", "type": "github" }, "original": { @@ -293,11 +293,11 @@ ] }, "locked": { - "lastModified": 1786901030, - "narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=", + "lastModified": 1791885023, + "narHash": "sha256-C3alQ2MEt0zVrUSho5NUDo3jmhbff72++bfwoqT9Omo=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa", + "rev": "0d6d739a833ba9a9c93034862e4a7b061a062a1c", "type": "github" }, "original": { From d0da09cf156155b94866b4720de1a9f9f2d71368 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 9 Nov 2026 16:04:55 -0500 Subject: [PATCH 05/13] add a matrix: link test --- integration_test/links_test.dart | 56 ++++++++++++++++++++++++++++++++ linux/nix/e2e.sh | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 integration_test/links_test.dart diff --git a/integration_test/links_test.dart b/integration_test/links_test.dart new file mode 100644 index 0000000..7690cd7 --- /dev/null +++ b/integration_test/links_test.dart @@ -0,0 +1,56 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/room_summary_sheet.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; + +import "helpers/matrix_user.dart"; +import "helpers/test_app.dart"; +import "helpers/pump_until.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final app = TestApp("dave", "davepass"); + setUpAll(app.setUp); + final run = DateTime.now().millisecondsSinceEpoch; + Future bob() => MatrixUser.login("bob", "bobpass"); + + Future openLink(WidgetTester tester, String link) async { + await app.open(tester); + app.fakes.links.open(.parse(link)); + } + + group("a link opened by the OS", () { + testWidgets("to a user opens their popover", (tester) async { + await openLink(tester, "matrix:u/bob:localhost"); + + await tester.pumpUntil(find.byType(UserBottomSheet)); + }); + + testWidgets("to a joined room opens it", (tester) async { + final inviter = await bob(); + final roomId = await inviter.createRoom("Linked $run"); + // Joined before Nexus starts, so the room is there once it syncs + await app.join(inviter, roomId); + + await openLink(tester, "matrix:roomid/${roomId.substring(1)}"); + + await tester.pumpUntil( + find.byWidgetPredicate( + (widget) => widget is RoomChat && widget.roomId == roomId, + ), + ); + }); + + testWidgets("to a room that isn't joined shows its summary", ( + tester, + ) async { + final roomId = await (await bob()).createRoom("Unjoined $run"); + + await openLink(tester, "matrix:roomid/${roomId.substring(1)}"); + + await tester.pumpUntil(find.byType(RoomSummarySheet)); + }); + }); +} diff --git a/linux/nix/e2e.sh b/linux/nix/e2e.sh index e57e812..6cc9f09 100755 --- a/linux/nix/e2e.sh +++ b/linux/nix/e2e.sh @@ -34,7 +34,7 @@ port = 8448 client = "$homeserver" EOF -users=(alice bob carol) +users=(alice bob carol dave) create_users=() for user in "${users[@]}"; do create_users+=(--execute "users create-user $user ${user}pass") From 05d25869efdd2d5fa354331fb674ab26392c6d25 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 9 Nov 2026 17:14:46 -0500 Subject: [PATCH 06/13] add reactions popover Fixes #168 --- lib/helpers/extensions/scheme_to_theme.dart | 2 + lib/helpers/extensions/show_image_viewer.dart | 83 ++++++++++ lib/widgets/expandable_image.dart | 81 ++-------- lib/widgets/reaction_key.dart | 25 +++ lib/widgets/reaction_row.dart | 116 +++++++------- lib/widgets/reactions_popover.dart | 144 ++++++++++++++++++ 6 files changed, 323 insertions(+), 128 deletions(-) create mode 100644 lib/helpers/extensions/show_image_viewer.dart create mode 100644 lib/widgets/reaction_key.dart create mode 100644 lib/widgets/reactions_popover.dart diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart index 97d239d..73ce176 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -20,6 +20,8 @@ extension SchemeToTheme on ColorScheme { shape: RoundedRectangleBorder(borderRadius: .circular(16)), color: surfaceContainerHigh, ), + // So ink at the edges of a sheet doesn't spill past its rounded corners + bottomSheetTheme: .new(clipBehavior: .antiAlias), appBarTheme: AppBarTheme( titleSpacing: 0, backgroundColor: surfaceContainerLow, diff --git a/lib/helpers/extensions/show_image_viewer.dart b/lib/helpers/extensions/show_image_viewer.dart new file mode 100644 index 0000000..ec40344 --- /dev/null +++ b/lib/helpers/extensions/show_image_viewer.dart @@ -0,0 +1,83 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; +import "package:nexus/helpers/extensions/size_to_string.dart"; +import "package:nexus/helpers/mxc_image.dart"; +import "package:nexus/models/requests/download_media.dart"; +import "package:nexus/widgets/error_dialog.dart"; + +extension ShowImageViewer on BuildContext { + /// Shows the image from [request] fullscreen, zoomable. + /// + /// If [downloadFilename] is set, a download button is shown, suggesting it. + void showImageViewer( + WidgetRef ref, + DownloadMediaRequest request, { + String? downloadFilename, + int? size, + }) => showDialog( + context: this, + builder: (context) => SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: Navigator.of(context).pop, + child: InteractiveViewer( + maxScale: 10, + child: Image( + errorBuilder: (_, error, stackTrace) => ErrorDialog( + "Loading failed for ${request.mxc}\nError: $error", + stackTrace, + ), + image: MxcImage(ref, request), + ), + ), + ), + ), + if (size != null) + Align( + alignment: .topLeft, + child: Padding( + padding: .all(32), + child: Card( + margin: .zero, + child: Padding( + padding: .symmetric(horizontal: 12, vertical: 8), + child: Text(size.sizeAsString), + ), + ), + ), + ), + Align( + alignment: .topRight, + child: Padding( + padding: .all(32), + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + if (downloadFilename != null) + M3EButton( + onPressed: () => context.saveAttachment( + ref, + request.mxc, + encrypted: request.encrypted, + filename: downloadFilename, + ), + child: Icon(Icons.download), + ), + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], + ), + ), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/expandable_image.dart b/lib/widgets/expandable_image.dart index 848596f..34a1f76 100644 --- a/lib/widgets/expandable_image.dart +++ b/lib/widgets/expandable_image.dart @@ -1,11 +1,7 @@ import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:m3e_buttons/m3e_buttons.dart"; -import "package:nexus/helpers/extensions/save_attachment.dart"; -import "package:nexus/helpers/extensions/size_to_string.dart"; -import "package:nexus/helpers/mxc_image.dart"; +import "package:nexus/helpers/extensions/show_image_viewer.dart"; import "package:nexus/models/requests/download_media.dart"; -import "package:nexus/widgets/error_dialog.dart"; final class const ExpandableImage( final DownloadMediaRequest? request, { @@ -18,72 +14,15 @@ final class const ExpandableImage( }) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => InkWell( - onTap: request == null - ? null - : () => showDialog( - context: context, - builder: (_) => SafeArea( - child: Stack( - children: [ - Positioned.fill( - child: GestureDetector( - onTap: Navigator.of(context).pop, - child: InteractiveViewer( - maxScale: 10, - child: Image( - errorBuilder: (_, error, stackTrace) => ErrorDialog( - "Loading failed for ${request?.mxc}\nError: $error", - stackTrace, - ), - image: MxcImage(ref, request!), - ), - ), - ), - ), - if (size != null) - Align( - alignment: .topLeft, - child: Padding( - padding: .all(32), - child: Card( - margin: .zero, - child: Padding( - padding: .symmetric(horizontal: 12, vertical: 8), - child: Text(size!.sizeAsString), - ), - ), - ), - ), - Align( - alignment: .topRight, - child: Padding( - padding: .all(32), - child: Row( - mainAxisSize: .min, - spacing: 8, - children: [ - if (downloadFilename != null) - M3EButton( - onPressed: () => context.saveAttachment( - ref, - request!.mxc, - encrypted: request!.encrypted, - filename: downloadFilename, - ), - child: Icon(Icons.download), - ), - M3EButton( - onPressed: Navigator.of(context).pop, - child: Icon(Icons.close), - ), - ], - ), - ), - ), - ], - ), - ), - ), + onTap: switch (request) { + final request? => () => context.showImageViewer( + ref, + request, + downloadFilename: downloadFilename, + size: size, + ), + null => null, + }, child: child, ); } diff --git a/lib/widgets/reaction_key.dart b/lib/widgets/reaction_key.dart new file mode 100644 index 0000000..ca00602 --- /dev/null +++ b/lib/widgets/reaction_key.dart @@ -0,0 +1,25 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/mxc_image.dart"; + +/// A reaction's key, either an emoji or a custom (mxc) image. +class const ReactionKey( + final String reaction, { + required final double height, + final TextStyle? style, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => + reaction.startsWith("mxc://") + ? Image( + height: height, + image: MxcImage(ref, .new(mxc: Uri.parse(reaction))), + errorBuilder: (context, _, _) => Icon( + Icons.broken_image, + size: height, + color: Theme.of(context).colorScheme.error, + ), + ) + : Text(reaction, style: style, overflow: .ellipsis); +} diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart index 288ebd6..ecf1f9e 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reaction_row.dart @@ -1,12 +1,15 @@ +import "package:flutter/gestures.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/reactions.dart"; import "package:nexus/controllers/room_chat.dart"; -import "package:nexus/helpers/mxc_image.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/reaction_key.dart"; +import "package:nexus/widgets/reactions_popover.dart"; import "package:nexus/main.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; @@ -36,65 +39,64 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { final selected = reactors?[reaction]?.contains(clientState!.userId) ?? false; - return Tooltip( - message: reactors?[reaction]?.join(", ") ?? "", - child: ChoiceChip( - showCheckmark: false, - selected: selected, - label: Row( - mainAxisSize: .min, - spacing: 8, - children: [ - Flexible( - child: reaction.startsWith("mxc://") - ? Image( - height: 20, - image: MxcImage( - ref, - .new(mxc: Uri.parse(reaction)), - ), - errorBuilder: (context, _, _) => Icon( - Icons.broken_image, - color: Theme.of(context) - .colorScheme - .error, - ), - ) - : Text(reaction, overflow: .ellipsis), - ), - Text(count.toString(), overflow: .ellipsis), - ], - ), - onSelected: enabled.value - ? (value) async { - enabled.value = false; - try { - final controller = ref.watch( - RoomChatController.provider(( - roomId: event.roomId, - contextualEvent: null, - threadRoot: null, - )).notifier, - ); + void showReactions(PositionedGestureDetails details) => + context.showAdaptivePopover( + (_) => ReactionsPopover( + event, + initialReaction: reaction, + ), + anchor: context.globalRectAt(details.globalPosition), + ); - if (selected) { - await controller - .removeReaction( - reaction, - event, - clientState!.userId!, - ) - .onError(showError); - } else { - await controller - .sendReaction(reaction, event) - .onError(showError); + return GestureDetector( + onSecondaryTapUp: showReactions, + onLongPressStart: showReactions, + child: Tooltip( + message: reactors?[reaction]?.join(", ") ?? "", + child: ChoiceChip( + showCheckmark: false, + selected: selected, + label: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + Flexible( + child: ReactionKey(reaction, height: 20), + ), + Text(count.toString(), overflow: .ellipsis), + ], + ), + onSelected: enabled.value + ? (value) async { + enabled.value = false; + try { + final controller = ref.watch( + RoomChatController.provider(( + roomId: event.roomId, + contextualEvent: null, + threadRoot: null, + )).notifier, + ); + + if (selected) { + await controller + .removeReaction( + reaction, + event, + clientState!.userId!, + ) + .onError(showError); + } else { + await controller + .sendReaction(reaction, event) + .onError(showError); + } + } finally { + enabled.value = true; } - } finally { - enabled.value = true; } - } - : null, + : null, + ), ), ); }, diff --git a/lib/widgets/reactions_popover.dart b/lib/widgets/reactions_popover.dart new file mode 100644 index 0000000..01f2efa --- /dev/null +++ b/lib/widgets/reactions_popover.dart @@ -0,0 +1,144 @@ +import "dart:math"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/reactions.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/helpers/extensions/show_image_viewer.dart"; +import "package:nexus/widgets/reaction_key.dart"; + +/// Every reaction to an event in tabs, each listing who reacted with it. +class const ReactionsPopover( + final Event event, { + final String? initialReaction, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final reactions = ref.watch( + ReactionsController.provider( + .new(roomId: event.roomId, eventRowId: event.rowId), + ), + ); + // Ordered like the reaction row on the timeline + final order = event.reactions.keys.toIList(); + final keys = + reactions.value?.keys.toIList().sortOrdered( + (a, b) => order.indexOf(a).compareTo(order.indexOf(b)), + ) ?? + const .empty(); + + final vsync = useSingleTickerProvider(keys: [keys.length]); + final tabController = useMemoized( + () => TabController( + length: keys.length, + initialIndex: keys + .indexOf(initialReaction ?? "") + .clamp(0, max(0, keys.length - 1)), + vsync: vsync, + ), + [keys.length], + ); + useEffect(() => tabController.dispose, [tabController]); + useListenable(tabController); + + return reactions.betterWhen( + skipLoadingOnReload: true, + data: (reactions) { + if (keys.isEmpty) return SizedBox.shrink(); + + final reaction = keys[tabController.index]; + + return Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + TabBar( + controller: tabController, + isScrollable: true, + tabAlignment: .start, + tabs: keys.map((reaction) { + void expand() => context.showImageViewer( + ref, + .new(mxc: Uri.parse(reaction)), + downloadFilename: reaction.split("/").last, + ); + final isImage = reaction.startsWith("mxc://"); + + return GestureDetector( + onSecondaryTap: isImage ? expand : null, + onLongPress: isImage ? expand : null, + child: Tab( + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + ReactionKey(reaction, height: 20), + Text(reactions[reaction]!.length.toString()), + ], + ), + ), + ); + }).toList(), + ), + Flexible( + child: AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOutCubicEmphasized, + alignment: .topCenter, + child: SingleChildScrollView( + key: ValueKey(reaction), + padding: .symmetric(vertical: 8), + child: Column( + crossAxisAlignment: .stretch, + children: reactions[reaction]! + .map( + (userId) => Consumer( + builder: (context, ref, _) { + final member = ref + .watch( + UserController.provider( + .new( + roomId: event.roomId, + userId: userId, + ), + ), + ) + .value; + final name = + member?.displayName ?? userId.localpart; + + return ListTile( + leading: AvatarOrHash( + member?.avatarUrl, + name, + dimension: 40, + ), + title: Text(name, overflow: .ellipsis), + subtitle: Text(userId, overflow: .ellipsis), + onTap: () => context.showUserPopover( + userId, + roomId: event.roomId, + ), + ); + }, + ), + ) + .toList(), + ), + ), + ), + ), + ], + ); + }, + ); + } +} From d5f29dc344a929b44995826e4fb8bdac59b7c18b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 9 Nov 2026 17:25:02 -0500 Subject: [PATCH 07/13] reorganize and rename widgets --- lib/controllers/room_settings_sections.dart | 6 +- lib/controllers/settings_sections.dart | 10 +- lib/helpers/extensions/better_when.dart | 4 +- .../extensions/build_event_options.dart | 4 +- lib/helpers/extensions/image_pack_emoji.dart | 2 +- lib/helpers/extensions/open_link_info.dart | 2 +- lib/helpers/extensions/room_destinations.dart | 2 +- lib/helpers/extensions/show_image_viewer.dart | 2 +- lib/helpers/extensions/show_user_popover.dart | 2 +- lib/main.dart | 6 +- lib/widgets/{ => avatars}/avatar_or_hash.dart | 0 lib/widgets/{ => avatars}/avatar_ring.dart | 0 .../{ => avatars}/editable_avatar.dart | 0 .../{ => avatars}/image_pack_avatar.dart | 2 +- .../{ => common}/account_switcher.dart | 2 +- lib/widgets/{ => common}/appbar.dart | 0 lib/widgets/{ => common}/bouncing_dots.dart | 0 lib/widgets/{ => common}/count_badge.dart | 0 lib/widgets/{ => common}/divider_text.dart | 2 +- lib/widgets/{ => common}/divider_widget.dart | 0 lib/widgets/{ => common}/error_dialog.dart | 0 .../{ => common}/expandable_image.dart | 0 .../{ => common}/highlight_wrapper.dart | 0 lib/widgets/{ => common}/linkified_text.dart | 0 lib/widgets/{ => common}/loading.dart | 0 lib/widgets/{ => common}/m3e_rail_theme.dart | 0 .../{ => common}/sync_status_banner.dart | 2 +- lib/widgets/{ => common}/timestamp.dart | 0 lib/widgets/composer/bot_command_panel.dart | 2 +- lib/widgets/composer/composer.dart | 10 +- .../{ => composer}/create_poll_sheet.dart | 0 .../composer/overlays/room_overlay.dart | 2 +- .../composer/overlays/tagger_overlay.dart | 2 +- .../composer/overlays/user_overlay.dart | 2 +- lib/widgets/composer/relation_preview.dart | 2 +- lib/widgets/composer/typing_indicator.dart | 4 +- lib/widgets/{ => emoji}/emoji_picker.dart | 0 lib/widgets/{ => emoji}/mxc_emoji.dart | 0 lib/widgets/{ => emoji}/sticker_picker.dart | 0 .../{ => encryption}/recovery_key_sheet.dart | 0 .../reset_encryption_sheet.dart | 2 +- lib/widgets/html/html.dart | 2 +- lib/widgets/lazy_loading/message_avatar.dart | 2 +- lib/widgets/{ => location}/location_map.dart | 0 .../{ => location}/location_message.dart | 4 +- .../{ => location}/location_picker_sheet.dart | 2 +- .../{ => location}/location_viewer_sheet.dart | 2 +- lib/widgets/pages/chat.dart | 8 +- lib/widgets/pages/explore.dart | 16 +- lib/widgets/pages/select_server.dart | 6 +- lib/widgets/pages/settings_category.dart | 2 +- lib/widgets/pages/verify.dart | 8 +- lib/widgets/{ => reactions}/reaction_key.dart | 0 lib/widgets/{ => reactions}/reaction_row.dart | 6 +- .../{ => reactions}/reactions_popover.dart | 4 +- .../read_receipts_row.dart | 6 +- .../read_receipts_sheet.dart | 2 +- .../renderers/{ => diffs}/change_line.dart | 0 .../{ => diffs}/join_rules_diff.dart | 2 +- .../{ => diffs}/pinned_events_diff.dart | 0 .../{ => diffs}/power_levels_diff.dart | 0 .../{ => diffs}/server_acl_diff.dart | 2 +- lib/widgets/renderers/event.dart | 24 +- .../{ => renderers}/event_preview.dart | 0 lib/widgets/{ => renderers}/file_card.dart | 0 lib/widgets/renderers/membership.dart | 2 +- lib/widgets/renderers/message.dart | 20 +- .../{ => renderers}/message_image.dart | 4 +- lib/widgets/renderers/poll.dart | 2 +- lib/widgets/{ => renderers}/poll_option.dart | 0 .../{ => renderers}/swipe_to_reply.dart | 0 lib/widgets/{ => renderers}/url_preview.dart | 0 lib/widgets/room_chat/chat_body.dart | 6 +- lib/widgets/room_chat/chat_timeline.dart | 2 +- lib/widgets/{ => room_chat}/member_list.dart | 8 +- .../{ => room_chat}/pinned_events_drawer.dart | 4 +- lib/widgets/{ => room_chat}/room_appbar.dart | 14 +- lib/widgets/room_chat/room_chat.dart | 14 +- .../{ => room_chat}/search_drawer.dart | 4 +- .../{ => room_chat}/tombstone_banner.dart | 0 lib/widgets/room_list.dart | 328 ------------------ .../add_directory_server_sheet.dart | 0 .../{ => rooms}/add_space_rooms_sheet.dart | 2 +- .../{ => rooms}/create_room_sheet.dart | 0 lib/widgets/{ => rooms}/join_sheet.dart | 2 +- lib/widgets/{ => rooms}/knock_sheet.dart | 0 .../{ => rooms}/room_directory_card.dart | 6 +- lib/widgets/{ => rooms}/room_menu_button.dart | 0 .../{ => rooms}/room_summary_sheet.dart | 8 +- lib/widgets/{ => rooms}/room_title.dart | 0 .../{ => rooms}/space_hierarchy_sheet.dart | 12 +- lib/widgets/settings/allowed_rooms_sheet.dart | 2 +- .../settings/enable_image_packs_sheet.dart | 4 +- lib/widgets/settings/image_pack_sheet.dart | 4 +- lib/widgets/settings/member_chooser.dart | 2 +- lib/widgets/settings/pack_image_sheet.dart | 2 +- lib/widgets/settings/settings_layout.dart | 6 +- lib/widgets/sidebar/invite_card.dart | 52 +++ lib/widgets/{ => sidebar}/invites_list.dart | 49 +-- .../{ => sidebar}/notifications_list.dart | 6 +- lib/widgets/sidebar/room_list.dart | 59 ++++ lib/widgets/sidebar/rooms_pane.dart | 143 ++++++++ lib/widgets/{ => sidebar}/sidebar.dart | 8 +- lib/widgets/sidebar/spaces_rail.dart | 142 ++++++++ lib/widgets/{ => threads}/thread_drawer.dart | 0 lib/widgets/{ => threads}/thread_summary.dart | 0 lib/widgets/{ => threads}/threads_drawer.dart | 8 +- .../{ => users}/mutual_rooms_sheet.dart | 2 +- lib/widgets/{ => users}/start_dm_sheet.dart | 0 lib/widgets/{ => users}/timezone_field.dart | 0 .../{ => users}/user_bottom_sheet.dart | 16 +- lib/widgets/{ => users}/user_span.dart | 0 112 files changed, 568 insertions(+), 547 deletions(-) rename lib/widgets/{ => avatars}/avatar_or_hash.dart (100%) rename lib/widgets/{ => avatars}/avatar_ring.dart (100%) rename lib/widgets/{ => avatars}/editable_avatar.dart (100%) rename lib/widgets/{ => avatars}/image_pack_avatar.dart (86%) rename lib/widgets/{ => common}/account_switcher.dart (98%) rename lib/widgets/{ => common}/appbar.dart (100%) rename lib/widgets/{ => common}/bouncing_dots.dart (100%) rename lib/widgets/{ => common}/count_badge.dart (100%) rename lib/widgets/{ => common}/divider_text.dart (82%) rename lib/widgets/{ => common}/divider_widget.dart (100%) rename lib/widgets/{ => common}/error_dialog.dart (100%) rename lib/widgets/{ => common}/expandable_image.dart (100%) rename lib/widgets/{ => common}/highlight_wrapper.dart (100%) rename lib/widgets/{ => common}/linkified_text.dart (100%) rename lib/widgets/{ => common}/loading.dart (100%) rename lib/widgets/{ => common}/m3e_rail_theme.dart (100%) rename lib/widgets/{ => common}/sync_status_banner.dart (98%) rename lib/widgets/{ => common}/timestamp.dart (100%) rename lib/widgets/{ => composer}/create_poll_sheet.dart (100%) rename lib/widgets/{ => emoji}/emoji_picker.dart (100%) rename lib/widgets/{ => emoji}/mxc_emoji.dart (100%) rename lib/widgets/{ => emoji}/sticker_picker.dart (100%) rename lib/widgets/{ => encryption}/recovery_key_sheet.dart (100%) rename lib/widgets/{ => encryption}/reset_encryption_sheet.dart (99%) rename lib/widgets/{ => location}/location_map.dart (100%) rename lib/widgets/{ => location}/location_message.dart (94%) rename lib/widgets/{ => location}/location_picker_sheet.dart (98%) rename lib/widgets/{ => location}/location_viewer_sheet.dart (95%) rename lib/widgets/{ => reactions}/reaction_key.dart (100%) rename lib/widgets/{ => reactions}/reaction_row.dart (96%) rename lib/widgets/{ => reactions}/reactions_popover.dart (97%) rename lib/widgets/{ => read_receipts}/read_receipts_row.dart (96%) rename lib/widgets/{ => read_receipts}/read_receipts_sheet.dart (96%) rename lib/widgets/renderers/{ => diffs}/change_line.dart (100%) rename lib/widgets/renderers/{ => diffs}/join_rules_diff.dart (97%) rename lib/widgets/renderers/{ => diffs}/pinned_events_diff.dart (100%) rename lib/widgets/renderers/{ => diffs}/power_levels_diff.dart (100%) rename lib/widgets/renderers/{ => diffs}/server_acl_diff.dart (97%) rename lib/widgets/{ => renderers}/event_preview.dart (100%) rename lib/widgets/{ => renderers}/file_card.dart (100%) rename lib/widgets/{ => renderers}/message_image.dart (94%) rename lib/widgets/{ => renderers}/poll_option.dart (100%) rename lib/widgets/{ => renderers}/swipe_to_reply.dart (100%) rename lib/widgets/{ => renderers}/url_preview.dart (100%) rename lib/widgets/{ => room_chat}/member_list.dart (97%) rename lib/widgets/{ => room_chat}/pinned_events_drawer.dart (96%) rename lib/widgets/{ => room_chat}/room_appbar.dart (87%) rename lib/widgets/{ => room_chat}/search_drawer.dart (98%) rename lib/widgets/{ => room_chat}/tombstone_banner.dart (100%) delete mode 100644 lib/widgets/room_list.dart rename lib/widgets/{ => rooms}/add_directory_server_sheet.dart (100%) rename lib/widgets/{ => rooms}/add_space_rooms_sheet.dart (99%) rename lib/widgets/{ => rooms}/create_room_sheet.dart (100%) rename lib/widgets/{ => rooms}/join_sheet.dart (96%) rename lib/widgets/{ => rooms}/knock_sheet.dart (100%) rename lib/widgets/{ => rooms}/room_directory_card.dart (96%) rename lib/widgets/{ => rooms}/room_menu_button.dart (100%) rename lib/widgets/{ => rooms}/room_summary_sheet.dart (96%) rename lib/widgets/{ => rooms}/room_title.dart (100%) rename lib/widgets/{ => rooms}/space_hierarchy_sheet.dart (97%) create mode 100644 lib/widgets/sidebar/invite_card.dart rename lib/widgets/{ => sidebar}/invites_list.dart (63%) rename lib/widgets/{ => sidebar}/notifications_list.dart (97%) create mode 100644 lib/widgets/sidebar/room_list.dart create mode 100644 lib/widgets/sidebar/rooms_pane.dart rename lib/widgets/{ => sidebar}/sidebar.dart (88%) create mode 100644 lib/widgets/sidebar/spaces_rail.dart rename lib/widgets/{ => threads}/thread_drawer.dart (100%) rename lib/widgets/{ => threads}/thread_summary.dart (100%) rename lib/widgets/{ => threads}/threads_drawer.dart (96%) rename lib/widgets/{ => users}/mutual_rooms_sheet.dart (97%) rename lib/widgets/{ => users}/start_dm_sheet.dart (100%) rename lib/widgets/{ => users}/timezone_field.dart (100%) rename lib/widgets/{ => users}/user_bottom_sheet.dart (97%) rename lib/widgets/{ => users}/user_span.dart (100%) diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index bf7159a..bababee 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -36,9 +36,9 @@ import "package:nexus/models/join_rule.dart"; import "package:nexus/models/power_level_permission.dart"; import "package:nexus/models/setting.dart"; import "package:nexus/models/settings_category.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/divider_text.dart"; -import "package:nexus/widgets/image_pack_avatar.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/divider_text.dart"; +import "package:nexus/widgets/avatars/image_pack_avatar.dart"; import "package:nexus/widgets/settings/add_users_power_level_sheet.dart"; import "package:nexus/widgets/settings/aliases_sheet.dart"; import "package:nexus/widgets/settings/allowed_rooms_sheet.dart"; diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 35c7f94..999fea6 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -35,15 +35,15 @@ import "package:nexus/models/settings.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/models/show_and_send.dart"; import "package:nexus/main.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/divider_text.dart"; -import "package:nexus/widgets/image_pack_avatar.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/divider_text.dart"; +import "package:nexus/widgets/avatars/image_pack_avatar.dart"; import "package:nexus/widgets/settings/block_user_field.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/enable_image_packs_sheet.dart"; import "package:nexus/widgets/settings/keyword_field.dart"; -import "package:nexus/widgets/user_bottom_sheet.dart"; -import "package:nexus/widgets/reset_encryption_sheet.dart"; +import "package:nexus/widgets/users/user_bottom_sheet.dart"; +import "package:nexus/widgets/encryption/reset_encryption_sheet.dart"; class SettingsSectionsController extends AsyncNotifier>> { diff --git a/lib/helpers/extensions/better_when.dart b/lib/helpers/extensions/better_when.dart index e054d02..459d139 100644 --- a/lib/helpers/extensions/better_when.dart +++ b/lib/helpers/extensions/better_when.dart @@ -1,7 +1,7 @@ import "package:flutter/widgets.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; extension BetterWhen on AsyncValue { Widget betterWhen({ diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index aff4ba0..a6ecc06 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -15,8 +15,8 @@ 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/widgets/mxc_emoji.dart"; +import "package:nexus/widgets/emoji/emoji_picker.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; import "package:nexus/main.dart"; extension BuildEventOptions on Event { diff --git a/lib/helpers/extensions/image_pack_emoji.dart b/lib/helpers/extensions/image_pack_emoji.dart index 2b04cf1..fe480d5 100644 --- a/lib/helpers/extensions/image_pack_emoji.dart +++ b/lib/helpers/extensions/image_pack_emoji.dart @@ -2,7 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_emoji_picker/material_emoji_picker.dart"; import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/image_pack.dart"; -import "package:nexus/widgets/mxc_emoji.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; extension ImagePackEmoji on ImagePack { EmojiCategory? get emojiCategory => _category(emoticons); diff --git a/lib/helpers/extensions/open_link_info.dart b/lib/helpers/extensions/open_link_info.dart index e15e9b0..60947bb 100644 --- a/lib/helpers/extensions/open_link_info.dart +++ b/lib/helpers/extensions/open_link_info.dart @@ -7,7 +7,7 @@ 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/room_summary.dart"; -import "package:nexus/widgets/room_summary_sheet.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; extension OpenLinkInfo on WidgetRef { Future openLinkInfo( diff --git a/lib/helpers/extensions/room_destinations.dart b/lib/helpers/extensions/room_destinations.dart index 3628d05..3d0323b 100644 --- a/lib/helpers/extensions/room_destinations.dart +++ b/lib/helpers/extensions/room_destinations.dart @@ -2,7 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; import "package:nexus/models/room.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; extension RoomDestination on Room { NavigationRailM3EDestination toDestination({ diff --git a/lib/helpers/extensions/show_image_viewer.dart b/lib/helpers/extensions/show_image_viewer.dart index ec40344..9be95b8 100644 --- a/lib/helpers/extensions/show_image_viewer.dart +++ b/lib/helpers/extensions/show_image_viewer.dart @@ -5,7 +5,7 @@ import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/helpers/extensions/size_to_string.dart"; import "package:nexus/helpers/mxc_image.dart"; import "package:nexus/models/requests/download_media.dart"; -import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; extension ShowImageViewer on BuildContext { /// Shows the image from [request] fullscreen, zoomable. diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index af3e00e..e7018c0 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,6 +1,6 @@ import "package:material_ui/material_ui.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; -import "package:nexus/widgets/user_bottom_sheet.dart"; +import "package:nexus/widgets/users/user_bottom_sheet.dart"; extension ShowUserPopover on BuildContext { void showUserPopover(String userId, {String? roomId, Rect? anchor}) => diff --git a/lib/main.dart b/lib/main.dart index 6234b0c..590c74d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,9 +22,9 @@ 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/account_scope.dart"; -import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/common/appbar.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; import "package:window_manager/window_manager.dart"; import "package:material_ui/material_ui.dart"; diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatars/avatar_or_hash.dart similarity index 100% rename from lib/widgets/avatar_or_hash.dart rename to lib/widgets/avatars/avatar_or_hash.dart diff --git a/lib/widgets/avatar_ring.dart b/lib/widgets/avatars/avatar_ring.dart similarity index 100% rename from lib/widgets/avatar_ring.dart rename to lib/widgets/avatars/avatar_ring.dart diff --git a/lib/widgets/editable_avatar.dart b/lib/widgets/avatars/editable_avatar.dart similarity index 100% rename from lib/widgets/editable_avatar.dart rename to lib/widgets/avatars/editable_avatar.dart diff --git a/lib/widgets/image_pack_avatar.dart b/lib/widgets/avatars/image_pack_avatar.dart similarity index 86% rename from lib/widgets/image_pack_avatar.dart rename to lib/widgets/avatars/image_pack_avatar.dart index aaebca2..42504ae 100644 --- a/lib/widgets/image_pack_avatar.dart +++ b/lib/widgets/avatars/image_pack_avatar.dart @@ -1,5 +1,5 @@ import "package:material_ui/material_ui.dart"; -import "package:nexus/widgets/mxc_emoji.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; class const ImagePackAvatar(final Uri? icon, {super.key}) extends StatelessWidget { diff --git a/lib/widgets/account_switcher.dart b/lib/widgets/common/account_switcher.dart similarity index 98% rename from lib/widgets/account_switcher.dart rename to lib/widgets/common/account_switcher.dart index 6c9c93b..3d0f996 100644 --- a/lib/widgets/account_switcher.dart +++ b/lib/widgets/common/account_switcher.dart @@ -7,7 +7,7 @@ import "package:nexus/controllers/active_account.dart"; import "package:nexus/helpers/extensions/log_out.dart"; import "package:nexus/main.dart"; import "package:nexus/models/account.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; /// Switches between, adds and logs out of accounts. With [onlyWithOthers], it's /// hidden when there's only one account. diff --git a/lib/widgets/appbar.dart b/lib/widgets/common/appbar.dart similarity index 100% rename from lib/widgets/appbar.dart rename to lib/widgets/common/appbar.dart diff --git a/lib/widgets/bouncing_dots.dart b/lib/widgets/common/bouncing_dots.dart similarity index 100% rename from lib/widgets/bouncing_dots.dart rename to lib/widgets/common/bouncing_dots.dart diff --git a/lib/widgets/count_badge.dart b/lib/widgets/common/count_badge.dart similarity index 100% rename from lib/widgets/count_badge.dart rename to lib/widgets/common/count_badge.dart diff --git a/lib/widgets/divider_text.dart b/lib/widgets/common/divider_text.dart similarity index 82% rename from lib/widgets/divider_text.dart rename to lib/widgets/common/divider_text.dart index c44ae11..2c623ba 100644 --- a/lib/widgets/divider_text.dart +++ b/lib/widgets/common/divider_text.dart @@ -1,5 +1,5 @@ import "package:material_ui/material_ui.dart"; -import "package:nexus/widgets/divider_widget.dart"; +import "package:nexus/widgets/common/divider_widget.dart"; final class const DividerText(final String text, {super.key}) extends StatelessWidget { diff --git a/lib/widgets/divider_widget.dart b/lib/widgets/common/divider_widget.dart similarity index 100% rename from lib/widgets/divider_widget.dart rename to lib/widgets/common/divider_widget.dart diff --git a/lib/widgets/error_dialog.dart b/lib/widgets/common/error_dialog.dart similarity index 100% rename from lib/widgets/error_dialog.dart rename to lib/widgets/common/error_dialog.dart diff --git a/lib/widgets/expandable_image.dart b/lib/widgets/common/expandable_image.dart similarity index 100% rename from lib/widgets/expandable_image.dart rename to lib/widgets/common/expandable_image.dart diff --git a/lib/widgets/highlight_wrapper.dart b/lib/widgets/common/highlight_wrapper.dart similarity index 100% rename from lib/widgets/highlight_wrapper.dart rename to lib/widgets/common/highlight_wrapper.dart diff --git a/lib/widgets/linkified_text.dart b/lib/widgets/common/linkified_text.dart similarity index 100% rename from lib/widgets/linkified_text.dart rename to lib/widgets/common/linkified_text.dart diff --git a/lib/widgets/loading.dart b/lib/widgets/common/loading.dart similarity index 100% rename from lib/widgets/loading.dart rename to lib/widgets/common/loading.dart diff --git a/lib/widgets/m3e_rail_theme.dart b/lib/widgets/common/m3e_rail_theme.dart similarity index 100% rename from lib/widgets/m3e_rail_theme.dart rename to lib/widgets/common/m3e_rail_theme.dart diff --git a/lib/widgets/sync_status_banner.dart b/lib/widgets/common/sync_status_banner.dart similarity index 98% rename from lib/widgets/sync_status_banner.dart rename to lib/widgets/common/sync_status_banner.dart index 10aec7e..4199866 100644 --- a/lib/widgets/sync_status_banner.dart +++ b/lib/widgets/common/sync_status_banner.dart @@ -1,7 +1,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/sync_status.dart"; -import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; import "package:timeago/timeago.dart"; class const SyncStatusBanner({super.key}) extends ConsumerWidget { diff --git a/lib/widgets/timestamp.dart b/lib/widgets/common/timestamp.dart similarity index 100% rename from lib/widgets/timestamp.dart rename to lib/widgets/common/timestamp.dart diff --git a/lib/widgets/composer/bot_command_panel.dart b/lib/widgets/composer/bot_command_panel.dart index 8a06754..bc6c05a 100644 --- a/lib/widgets/composer/bot_command_panel.dart +++ b/lib/widgets/composer/bot_command_panel.dart @@ -9,7 +9,7 @@ import "package:nexus/controllers/bot_command_draft.dart"; import "package:nexus/models/bot_argument_suggestion.dart"; import "package:nexus/models/bot_command.dart"; import "package:nexus/models/text_command.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; class const BotCommandPanel( final ComposerParams composer, { diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 1b70ec3..4c3caf6 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -17,7 +17,7 @@ import "package:nexus/models/composer_params.dart"; import "package:nexus/models/text_command.dart"; import "package:nexus/widgets/composer/bot_command_form.dart"; import "package:nexus/widgets/composer/bot_command_panel.dart"; -import "package:nexus/widgets/emoji_picker.dart"; +import "package:nexus/widgets/emoji/emoji_picker.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -35,10 +35,10 @@ import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/poll_start.dart"; import "package:nexus/models/image_pack.dart"; -import "package:nexus/widgets/mxc_emoji.dart"; -import "package:nexus/widgets/sticker_picker.dart"; -import "package:nexus/widgets/create_poll_sheet.dart"; -import "package:nexus/widgets/location_picker_sheet.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; +import "package:nexus/widgets/emoji/sticker_picker.dart"; +import "package:nexus/widgets/composer/create_poll_sheet.dart"; +import "package:nexus/widgets/location/location_picker_sheet.dart"; import "package:nexus/models/location.dart"; import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; import "package:nexus/widgets/composer/paste_files_action.dart"; diff --git a/lib/widgets/create_poll_sheet.dart b/lib/widgets/composer/create_poll_sheet.dart similarity index 100% rename from lib/widgets/create_poll_sheet.dart rename to lib/widgets/composer/create_poll_sheet.dart diff --git a/lib/widgets/composer/overlays/room_overlay.dart b/lib/widgets/composer/overlays/room_overlay.dart index f4778a8..c95db16 100644 --- a/lib/widgets/composer/overlays/room_overlay.dart +++ b/lib/widgets/composer/overlays/room_overlay.dart @@ -3,7 +3,7 @@ import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/via.dart"; import "package:nexus/helpers/extensions/weighted_search.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; class const RoomOverlay( final String query, { diff --git a/lib/widgets/composer/overlays/tagger_overlay.dart b/lib/widgets/composer/overlays/tagger_overlay.dart index 7d973dc..f760e01 100644 --- a/lib/widgets/composer/overlays/tagger_overlay.dart +++ b/lib/widgets/composer/overlays/tagger_overlay.dart @@ -3,7 +3,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/widgets/composer/overlays/room_overlay.dart"; import "package:nexus/widgets/composer/overlays/user_overlay.dart"; import "package:nexus/widgets/composer/overlays/emoji_overlay.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/common/loading.dart"; class const TaggerOverlay( final String query, { diff --git a/lib/widgets/composer/overlays/user_overlay.dart b/lib/widgets/composer/overlays/user_overlay.dart index e6f122b..eec15f5 100644 --- a/lib/widgets/composer/overlays/user_overlay.dart +++ b/lib/widgets/composer/overlays/user_overlay.dart @@ -5,7 +5,7 @@ import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/weighted_search.dart"; import "package:nexus/models/content/membership.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; class const UserOverlay( final String query, { diff --git a/lib/widgets/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart index 5af01ee..f319d74 100644 --- a/lib/widgets/composer/relation_preview.dart +++ b/lib/widgets/composer/relation_preview.dart @@ -2,7 +2,7 @@ import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/event_preview.dart"; +import "package:nexus/widgets/renderers/event_preview.dart"; class const RelationPreview( final Event? relatedEvent, { diff --git a/lib/widgets/composer/typing_indicator.dart b/lib/widgets/composer/typing_indicator.dart index 9624937..9cacb9c 100644 --- a/lib/widgets/composer/typing_indicator.dart +++ b/lib/widgets/composer/typing_indicator.dart @@ -5,8 +5,8 @@ import "package:nexus/controllers/room_typing.dart"; import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/hooks/tap_recognizer.dart"; -import "package:nexus/widgets/bouncing_dots.dart"; -import "package:nexus/widgets/user_span.dart"; +import "package:nexus/widgets/common/bouncing_dots.dart"; +import "package:nexus/widgets/users/user_span.dart"; class const TypingIndicator(final String roomId, {super.key}) extends HookConsumerWidget { diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji/emoji_picker.dart similarity index 100% rename from lib/widgets/emoji_picker.dart rename to lib/widgets/emoji/emoji_picker.dart diff --git a/lib/widgets/mxc_emoji.dart b/lib/widgets/emoji/mxc_emoji.dart similarity index 100% rename from lib/widgets/mxc_emoji.dart rename to lib/widgets/emoji/mxc_emoji.dart diff --git a/lib/widgets/sticker_picker.dart b/lib/widgets/emoji/sticker_picker.dart similarity index 100% rename from lib/widgets/sticker_picker.dart rename to lib/widgets/emoji/sticker_picker.dart diff --git a/lib/widgets/recovery_key_sheet.dart b/lib/widgets/encryption/recovery_key_sheet.dart similarity index 100% rename from lib/widgets/recovery_key_sheet.dart rename to lib/widgets/encryption/recovery_key_sheet.dart diff --git a/lib/widgets/reset_encryption_sheet.dart b/lib/widgets/encryption/reset_encryption_sheet.dart similarity index 99% rename from lib/widgets/reset_encryption_sheet.dart rename to lib/widgets/encryption/reset_encryption_sheet.dart index c223b37..40b0074 100644 --- a/lib/widgets/reset_encryption_sheet.dart +++ b/lib/widgets/encryption/reset_encryption_sheet.dart @@ -6,7 +6,7 @@ import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/main.dart"; import "package:nexus/models/client_state.dart"; -import "package:nexus/widgets/recovery_key_sheet.dart"; +import "package:nexus/widgets/encryption/recovery_key_sheet.dart"; class const ResetEncryptionSheet({ /// Whether this is shown as a page rather than in a sheet, in which case it diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart index d717a8a..9236e9a 100644 --- a/lib/widgets/html/html.dart +++ b/lib/widgets/html/html.dart @@ -6,7 +6,7 @@ import "package:html/dom.dart" as dom; 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"; +import "package:nexus/widgets/common/expandable_image.dart"; import "package:nexus/widgets/html/mention_chip.dart"; import "package:nexus/widgets/html/spoiler_text.dart"; import "package:nexus/widgets/html/code_block.dart"; diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart index 0279eca..95f987f 100644 --- a/lib/widgets/lazy_loading/message_avatar.dart +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -4,7 +4,7 @@ import "package:nexus/controllers/author.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; class const MessageAvatar( final Event event, { diff --git a/lib/widgets/location_map.dart b/lib/widgets/location/location_map.dart similarity index 100% rename from lib/widgets/location_map.dart rename to lib/widgets/location/location_map.dart diff --git a/lib/widgets/location_message.dart b/lib/widgets/location/location_message.dart similarity index 94% rename from lib/widgets/location_message.dart rename to lib/widgets/location/location_message.dart index 2f22b9d..661b7dd 100644 --- a/lib/widgets/location_message.dart +++ b/lib/widgets/location/location_message.dart @@ -2,8 +2,8 @@ import "package:material_ui/material_ui.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/location.dart"; -import "package:nexus/widgets/location_map.dart"; -import "package:nexus/widgets/location_viewer_sheet.dart"; +import "package:nexus/widgets/location/location_map.dart"; +import "package:nexus/widgets/location/location_viewer_sheet.dart"; class const LocationMessage(final LocationMessageContent content, {super.key}) extends StatelessWidget { diff --git a/lib/widgets/location_picker_sheet.dart b/lib/widgets/location/location_picker_sheet.dart similarity index 98% rename from lib/widgets/location_picker_sheet.dart rename to lib/widgets/location/location_picker_sheet.dart index 10b77ae..4fb61bf 100644 --- a/lib/widgets/location_picker_sheet.dart +++ b/lib/widgets/location/location_picker_sheet.dart @@ -7,7 +7,7 @@ import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/helpers/current_location_helper.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/location.dart"; -import "package:nexus/widgets/location_map.dart"; +import "package:nexus/widgets/location/location_map.dart"; /// Chooses a location to share, either the user's own or a pin dropped on the /// map. diff --git a/lib/widgets/location_viewer_sheet.dart b/lib/widgets/location/location_viewer_sheet.dart similarity index 95% rename from lib/widgets/location_viewer_sheet.dart rename to lib/widgets/location/location_viewer_sheet.dart index 48965bc..ca74ab0 100644 --- a/lib/widgets/location_viewer_sheet.dart +++ b/lib/widgets/location/location_viewer_sheet.dart @@ -4,7 +4,7 @@ import "package:material_ui/material_ui.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/models/location.dart"; -import "package:nexus/widgets/location_map.dart"; +import "package:nexus/widgets/location/location_map.dart"; class const LocationViewerSheet( final LatLng point, { diff --git a/lib/widgets/pages/chat.dart b/lib/widgets/pages/chat.dart index 53d3571..c5d704f 100644 --- a/lib/widgets/pages/chat.dart +++ b/lib/widgets/pages/chat.dart @@ -8,11 +8,11 @@ import "package:nexus/helpers/extensions/open_link_info.dart"; import "package:nexus/main.dart"; import "package:nexus/controllers/selected_room.dart"; import "package:nexus/controllers/notification_request.dart"; -import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/sidebar.dart"; -import "package:nexus/widgets/sync_status_banner.dart"; +import "package:nexus/widgets/common/appbar.dart"; +import "package:nexus/widgets/sidebar/sidebar.dart"; +import "package:nexus/widgets/common/sync_status_banner.dart"; import "package:nexus/widgets/room_chat/room_chat.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/common/loading.dart"; class const ChatPage({super.key}) extends HookConsumerWidget { @override diff --git a/lib/widgets/pages/explore.dart b/lib/widgets/pages/explore.dart index bc8ab2d..3cd77ea 100644 --- a/lib/widgets/pages/explore.dart +++ b/lib/widgets/pages/explore.dart @@ -14,14 +14,14 @@ import "package:nexus/controllers/settings.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/main.dart"; import "package:nexus/helpers/extensions/room_summary_display.dart"; -import "package:nexus/widgets/add_directory_server_sheet.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/room_summary_sheet.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/m3e_rail_theme.dart"; -import "package:nexus/widgets/room_directory_card.dart"; +import "package:nexus/widgets/rooms/add_directory_server_sheet.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; +import "package:nexus/widgets/common/divider_text.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/common/m3e_rail_theme.dart"; +import "package:nexus/widgets/rooms/room_directory_card.dart"; class const ExplorePage({super.key}) extends HookConsumerWidget { @override diff --git a/lib/widgets/pages/select_server.dart b/lib/widgets/pages/select_server.dart index 1e20f32..a44e4b8 100644 --- a/lib/widgets/pages/select_server.dart +++ b/lib/widgets/pages/select_server.dart @@ -13,9 +13,9 @@ import "package:nexus/models/gomuks_exception.dart"; import "package:nexus/models/homeserver.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/widgets/pages/settings.dart"; -import "package:nexus/widgets/account_switcher.dart"; -import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/common/account_switcher.dart"; +import "package:nexus/widgets/common/appbar.dart"; +import "package:nexus/widgets/common/divider_text.dart"; class const SelectServerPage({super.key}) extends HookConsumerWidget { @override diff --git a/lib/widgets/pages/settings_category.dart b/lib/widgets/pages/settings_category.dart index 69513b8..0efa68e 100644 --- a/lib/widgets/pages/settings_category.dart +++ b/lib/widgets/pages/settings_category.dart @@ -8,7 +8,7 @@ import "package:hooks_riverpod/misc.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/widgets/highlight_wrapper.dart"; +import "package:nexus/widgets/common/highlight_wrapper.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class const SettingsCategoryPage( diff --git a/lib/widgets/pages/verify.dart b/lib/widgets/pages/verify.dart index 6cf935d..aa342cd 100644 --- a/lib/widgets/pages/verify.dart +++ b/lib/widgets/pages/verify.dart @@ -5,12 +5,12 @@ import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/models/client_state.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/common/loading.dart"; import "package:nexus/widgets/pages/settings.dart"; -import "package:nexus/widgets/account_switcher.dart"; -import "package:nexus/widgets/appbar.dart"; +import "package:nexus/widgets/common/account_switcher.dart"; +import "package:nexus/widgets/common/appbar.dart"; import "package:nexus/helpers/required_validator_helper.dart"; -import "package:nexus/widgets/reset_encryption_sheet.dart"; +import "package:nexus/widgets/encryption/reset_encryption_sheet.dart"; class const VerifyPage({super.key}) extends HookConsumerWidget { @override diff --git a/lib/widgets/reaction_key.dart b/lib/widgets/reactions/reaction_key.dart similarity index 100% rename from lib/widgets/reaction_key.dart rename to lib/widgets/reactions/reaction_key.dart diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reactions/reaction_row.dart similarity index 96% rename from lib/widgets/reaction_row.dart rename to lib/widgets/reactions/reaction_row.dart index ecf1f9e..6020358 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reactions/reaction_row.dart @@ -7,9 +7,9 @@ import "package:nexus/controllers/reactions.dart"; import "package:nexus/controllers/room_chat.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/reaction_key.dart"; -import "package:nexus/widgets/reactions_popover.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/reactions/reaction_key.dart"; +import "package:nexus/widgets/reactions/reactions_popover.dart"; import "package:nexus/main.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; diff --git a/lib/widgets/reactions_popover.dart b/lib/widgets/reactions/reactions_popover.dart similarity index 97% rename from lib/widgets/reactions_popover.dart rename to lib/widgets/reactions/reactions_popover.dart index 01f2efa..0c7660b 100644 --- a/lib/widgets/reactions_popover.dart +++ b/lib/widgets/reactions/reactions_popover.dart @@ -10,9 +10,9 @@ import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; import "package:nexus/helpers/extensions/show_image_viewer.dart"; -import "package:nexus/widgets/reaction_key.dart"; +import "package:nexus/widgets/reactions/reaction_key.dart"; /// Every reaction to an event in tabs, each listing who reacted with it. class const ReactionsPopover( diff --git a/lib/widgets/read_receipts_row.dart b/lib/widgets/read_receipts/read_receipts_row.dart similarity index 96% rename from lib/widgets/read_receipts_row.dart rename to lib/widgets/read_receipts/read_receipts_row.dart index fb20e3d..156d3f8 100644 --- a/lib/widgets/read_receipts_row.dart +++ b/lib/widgets/read_receipts/read_receipts_row.dart @@ -8,9 +8,9 @@ import "package:nexus/controllers/room_chat.dart"; import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/avatar_ring.dart"; -import "package:nexus/widgets/read_receipts_sheet.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_ring.dart"; +import "package:nexus/widgets/read_receipts/read_receipts_sheet.dart"; class const ReadReceiptsRow( final String eventId, { diff --git a/lib/widgets/read_receipts_sheet.dart b/lib/widgets/read_receipts/read_receipts_sheet.dart similarity index 96% rename from lib/widgets/read_receipts_sheet.dart rename to lib/widgets/read_receipts/read_receipts_sheet.dart index 135e3fb..3021102 100644 --- a/lib/widgets/read_receipts_sheet.dart +++ b/lib/widgets/read_receipts/read_receipts_sheet.dart @@ -6,7 +6,7 @@ import "package:nexus/controllers/room_chat.dart"; import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; /// Everyone who has read up to an event, kept up to date while open. class const ReadReceiptsSheet( diff --git a/lib/widgets/renderers/change_line.dart b/lib/widgets/renderers/diffs/change_line.dart similarity index 100% rename from lib/widgets/renderers/change_line.dart rename to lib/widgets/renderers/diffs/change_line.dart diff --git a/lib/widgets/renderers/join_rules_diff.dart b/lib/widgets/renderers/diffs/join_rules_diff.dart similarity index 97% rename from lib/widgets/renderers/join_rules_diff.dart rename to lib/widgets/renderers/diffs/join_rules_diff.dart index 6d41c0b..1698aa0 100644 --- a/lib/widgets/renderers/join_rules_diff.dart +++ b/lib/widgets/renderers/diffs/join_rules_diff.dart @@ -5,7 +5,7 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/join_rule_name.dart"; import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/models/content/join_rules.dart"; -import "package:nexus/widgets/renderers/change_line.dart"; +import "package:nexus/widgets/renderers/diffs/change_line.dart"; /// The join rule a join rules event changed to, and for restricted rooms, the /// rooms whose members it let join, or stopped letting join. diff --git a/lib/widgets/renderers/pinned_events_diff.dart b/lib/widgets/renderers/diffs/pinned_events_diff.dart similarity index 100% rename from lib/widgets/renderers/pinned_events_diff.dart rename to lib/widgets/renderers/diffs/pinned_events_diff.dart diff --git a/lib/widgets/renderers/power_levels_diff.dart b/lib/widgets/renderers/diffs/power_levels_diff.dart similarity index 100% rename from lib/widgets/renderers/power_levels_diff.dart rename to lib/widgets/renderers/diffs/power_levels_diff.dart diff --git a/lib/widgets/renderers/server_acl_diff.dart b/lib/widgets/renderers/diffs/server_acl_diff.dart similarity index 97% rename from lib/widgets/renderers/server_acl_diff.dart rename to lib/widgets/renderers/diffs/server_acl_diff.dart index e232614..9364c04 100644 --- a/lib/widgets/renderers/server_acl_diff.dart +++ b/lib/widgets/renderers/diffs/server_acl_diff.dart @@ -2,7 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/models/content/server_acl.dart"; -import "package:nexus/widgets/renderers/change_line.dart"; +import "package:nexus/widgets/renderers/diffs/change_line.dart"; /// The servers newly denied (in red) and allowed (in green) by a server ACL /// event, and those no longer denied or allowed, struck through. diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index deff7f7..4e14fad 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -32,21 +32,21 @@ import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/tombstone.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; import "package:nexus/widgets/renderers/message.dart"; -import "package:nexus/widgets/reaction_row.dart"; -import "package:nexus/widgets/read_receipts_row.dart"; +import "package:nexus/widgets/reactions/reaction_row.dart"; +import "package:nexus/widgets/read_receipts/read_receipts_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; -import "package:nexus/widgets/renderers/change_line.dart"; +import "package:nexus/widgets/renderers/diffs/change_line.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; -import "package:nexus/widgets/renderers/join_rules_diff.dart"; -import "package:nexus/widgets/renderers/power_levels_diff.dart"; -import "package:nexus/widgets/renderers/pinned_events_diff.dart"; -import "package:nexus/widgets/renderers/server_acl_diff.dart"; -import "package:nexus/widgets/swipe_to_reply.dart"; -import "package:nexus/widgets/thread_summary.dart"; -import "package:nexus/widgets/timestamp.dart"; -import "package:nexus/widgets/user_span.dart"; +import "package:nexus/widgets/renderers/diffs/join_rules_diff.dart"; +import "package:nexus/widgets/renderers/diffs/power_levels_diff.dart"; +import "package:nexus/widgets/renderers/diffs/pinned_events_diff.dart"; +import "package:nexus/widgets/renderers/diffs/server_acl_diff.dart"; +import "package:nexus/widgets/renderers/swipe_to_reply.dart"; +import "package:nexus/widgets/threads/thread_summary.dart"; +import "package:nexus/widgets/common/timestamp.dart"; +import "package:nexus/widgets/users/user_span.dart"; class const EventRenderer( final int eventRowId, { diff --git a/lib/widgets/event_preview.dart b/lib/widgets/renderers/event_preview.dart similarity index 100% rename from lib/widgets/event_preview.dart rename to lib/widgets/renderers/event_preview.dart diff --git a/lib/widgets/file_card.dart b/lib/widgets/renderers/file_card.dart similarity index 100% rename from lib/widgets/file_card.dart rename to lib/widgets/renderers/file_card.dart diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index 17f8128..125ea64 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -6,7 +6,7 @@ import "package:nexus/helpers/hooks/tap_recognizer.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; -import "package:nexus/widgets/user_span.dart"; +import "package:nexus/widgets/users/user_span.dart"; class const MembershipRenderer( final Event event, { diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index 4e6a45a..40ee5e7 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -12,22 +12,22 @@ import "package:nexus/models/location.dart"; import "package:nexus/models/content/poll_start.dart"; import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/file_card.dart"; +import "package:nexus/widgets/renderers/file_card.dart"; 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/location_message.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"; -import "package:nexus/widgets/url_preview.dart"; -import "package:nexus/widgets/event_preview.dart"; +import "package:nexus/widgets/common/linkified_text.dart"; +import "package:nexus/widgets/location/location_message.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/renderers/message_image.dart"; +import "package:nexus/widgets/reactions/reaction_row.dart"; +import "package:nexus/widgets/common/timestamp.dart"; +import "package:nexus/widgets/renderers/url_preview.dart"; +import "package:nexus/widgets/renderers/event_preview.dart"; import "package:nexus/widgets/players/video.dart"; import "package:nexus/widgets/players/audio.dart"; import "package:nexus/widgets/renderers/poll.dart"; -import "package:nexus/widgets/thread_summary.dart"; +import "package:nexus/widgets/threads/thread_summary.dart"; class const MessageRenderer( final Event event, { diff --git a/lib/widgets/message_image.dart b/lib/widgets/renderers/message_image.dart similarity index 94% rename from lib/widgets/message_image.dart rename to lib/widgets/renderers/message_image.dart index c0dfa87..a8c44e3 100644 --- a/lib/widgets/message_image.dart +++ b/lib/widgets/renderers/message_image.dart @@ -4,8 +4,8 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/helpers/mxc_image.dart"; import "package:nexus/models/info/image.dart" as i; import "package:nexus/models/requests/download_media.dart"; -import "package:nexus/widgets/expandable_image.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/common/expandable_image.dart"; +import "package:nexus/widgets/common/loading.dart"; final class const MessageImage( final Uri url, { diff --git a/lib/widgets/renderers/poll.dart b/lib/widgets/renderers/poll.dart index d3a8cb0..1f738b5 100644 --- a/lib/widgets/renderers/poll.dart +++ b/lib/widgets/renderers/poll.dart @@ -15,7 +15,7 @@ import "package:nexus/helpers/extensions/event_id_is_fake.dart"; import "package:nexus/main.dart"; import "package:nexus/models/content/poll_start.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/poll_option.dart"; +import "package:nexus/widgets/renderers/poll_option.dart"; class const PollRenderer(final Event event, final PollStart poll, {super.key}) extends HookConsumerWidget { diff --git a/lib/widgets/poll_option.dart b/lib/widgets/renderers/poll_option.dart similarity index 100% rename from lib/widgets/poll_option.dart rename to lib/widgets/renderers/poll_option.dart diff --git a/lib/widgets/swipe_to_reply.dart b/lib/widgets/renderers/swipe_to_reply.dart similarity index 100% rename from lib/widgets/swipe_to_reply.dart rename to lib/widgets/renderers/swipe_to_reply.dart diff --git a/lib/widgets/url_preview.dart b/lib/widgets/renderers/url_preview.dart similarity index 100% rename from lib/widgets/url_preview.dart rename to lib/widgets/renderers/url_preview.dart diff --git a/lib/widgets/room_chat/chat_body.dart b/lib/widgets/room_chat/chat_body.dart index 239ae4b..3b5f752 100644 --- a/lib/widgets/room_chat/chat_body.dart +++ b/lib/widgets/room_chat/chat_body.dart @@ -13,10 +13,10 @@ import "package:nexus/main.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/composer/composer.dart"; import "package:nexus/widgets/composer/typing_indicator.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; import "package:nexus/widgets/room_chat/chat_timeline.dart"; -import "package:nexus/widgets/tombstone_banner.dart"; +import "package:nexus/widgets/room_chat/tombstone_banner.dart"; final class const ChatBody({ required final ChatParams chat, diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 57388b9..d6aaef7 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -4,7 +4,7 @@ import "package:nexus/controllers/room_chat.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:nexus/widgets/common/highlight_wrapper.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class const ChatTimeline({ diff --git a/lib/widgets/member_list.dart b/lib/widgets/room_chat/member_list.dart similarity index 97% rename from lib/widgets/member_list.dart rename to lib/widgets/room_chat/member_list.dart index 02cc0c6..bf86b9c 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/room_chat/member_list.dart @@ -11,10 +11,10 @@ import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/helpers/extensions/displayname_color.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; -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/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/divider_text.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; class const MemberList(final String roomId, {super.key}) extends HookConsumerWidget { diff --git a/lib/widgets/pinned_events_drawer.dart b/lib/widgets/room_chat/pinned_events_drawer.dart similarity index 96% rename from lib/widgets/pinned_events_drawer.dart rename to lib/widgets/room_chat/pinned_events_drawer.dart index cff6ad6..1e0ab6a 100644 --- a/lib/widgets/pinned_events_drawer.dart +++ b/lib/widgets/room_chat/pinned_events_drawer.dart @@ -4,8 +4,8 @@ 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"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; import "package:nexus/widgets/renderers/event.dart"; final class const PinnedEventsDrawer( diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_chat/room_appbar.dart similarity index 87% rename from lib/widgets/room_appbar.dart rename to lib/widgets/room_chat/room_appbar.dart index 83a3278..14b9df5 100644 --- a/lib/widgets/room_appbar.dart +++ b/lib/widgets/room_chat/room_appbar.dart @@ -2,13 +2,13 @@ import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.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/count_badge.dart"; -import "package:nexus/widgets/room_menu_button.dart"; -import "package:nexus/widgets/room_summary_sheet.dart"; -import "package:nexus/widgets/room_title.dart"; -import "package:nexus/widgets/sidebar.dart"; +import "package:nexus/widgets/common/appbar.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/count_badge.dart"; +import "package:nexus/widgets/rooms/room_menu_button.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; +import "package:nexus/widgets/rooms/room_title.dart"; +import "package:nexus/widgets/sidebar/sidebar.dart"; final class const RoomAppbar({ required final String? roomId, diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 0062450..929be84 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -10,13 +10,13 @@ import "package:nexus/controllers/room_chat.dart"; import "package:nexus/helpers/hooks/chat_composer.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/event.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/widgets/search_drawer.dart"; -import "package:nexus/widgets/sync_status_banner.dart"; -import "package:nexus/widgets/thread_drawer.dart"; -import "package:nexus/widgets/threads_drawer.dart"; +import "package:nexus/widgets/room_chat/pinned_events_drawer.dart"; +import "package:nexus/widgets/room_chat/member_list.dart"; +import "package:nexus/widgets/room_chat/room_appbar.dart"; +import "package:nexus/widgets/room_chat/search_drawer.dart"; +import "package:nexus/widgets/common/sync_status_banner.dart"; +import "package:nexus/widgets/threads/thread_drawer.dart"; +import "package:nexus/widgets/threads/threads_drawer.dart"; import "package:nexus/widgets/room_chat/chat_body.dart"; import "package:nexus/helpers/extensions/build_event_options.dart"; import "package:nexus/controllers/jump_request.dart"; diff --git a/lib/widgets/search_drawer.dart b/lib/widgets/room_chat/search_drawer.dart similarity index 98% rename from lib/widgets/search_drawer.dart rename to lib/widgets/room_chat/search_drawer.dart index 03418df..2905789 100644 --- a/lib/widgets/search_drawer.dart +++ b/lib/widgets/room_chat/search_drawer.dart @@ -6,8 +6,8 @@ import "package:nexus/controllers/event_search.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/main.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; import "package:nexus/widgets/renderers/event.dart"; final class const SearchDrawer( diff --git a/lib/widgets/tombstone_banner.dart b/lib/widgets/room_chat/tombstone_banner.dart similarity index 100% rename from lib/widgets/tombstone_banner.dart rename to lib/widgets/room_chat/tombstone_banner.dart diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart deleted file mode 100644 index bdbf9cd..0000000 --- a/lib/widgets/room_list.dart +++ /dev/null @@ -1,328 +0,0 @@ -import "dart:math"; - -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -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:material_adaptive_sheets/material_adaptive_sheets.dart"; -import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; -import "package:nexus/helpers/extensions/room_destinations.dart"; -import "package:nexus/widgets/account_switcher.dart"; -import "package:nexus/models/nav_page.dart"; -import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/room_title.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/create_room_sheet.dart"; -import "package:nexus/widgets/start_dm_sheet.dart"; -import "package:nexus/widgets/divider_widget.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/pages/explore.dart"; -import "package:nexus/widgets/pages/settings.dart"; -import "package:nexus/widgets/join_sheet.dart"; -import "package:nexus/widgets/m3e_rail_theme.dart"; -import "package:nexus/widgets/room_summary_sheet.dart"; -import "package:nexus/widgets/room_menu_button.dart"; -import "package:nexus/widgets/space_hierarchy_sheet.dart"; -import "package:nexus/controllers/key.dart"; -import "package:nexus/controllers/selected_room.dart"; -import "package:nexus/controllers/selected_space.dart"; -import "package:nexus/controllers/rooms.dart"; -import "package:nexus/controllers/spaces.dart"; - -class const RoomList({super.key}) extends ConsumerWidget implements NavPage { - @override - String get title => "Rooms"; - - @override - IconData get icon => Icons.list; - - @override - int? badgeCount(WidgetRef ref) { - final (notifications, hasUnread) = ref.watch( - RoomsController.provider.select( - (rooms) => rooms.values.fold( - (0, false), - (total, room) => ( - total.$1 + (room.metadata?.unreadNotifications ?? 0), - total.$2 || (room.metadata?.unreadMessages ?? 0) > 0, - ), - ), - ), - ); - - return notifications > 0 - ? notifications - : hasUnread - ? 0 - : 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 M3ERailTheme( - extensions: [ - NavigationRailM3ETheme(itemCollapsedHeight: 48, itemVerticalGap: 0), - ], - builder: (context) => 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: [ - AdaptiveMenuButton( - tooltip: "Add a room", - itemBuilder: (_) => [ - PopupMenuItem( - onTap: () => - context.showAdaptiveSheet((_) => JoinSheet(ref)), - child: const ListTile( - title: Text("Join an existing room (or space)"), - leading: Icon(Icons.numbers), - ), - ), - PopupMenuItem( - onTap: () => context.showAdaptiveSheet( - (_) => const CreateRoomSheet(), - ), - child: const ListTile( - title: Text("Create a new room (or space)"), - leading: Icon(Icons.add), - ), - ), - PopupMenuItem( - onTap: () => context.showAdaptiveSheet( - (_) => const StartDmSheet(), - ), - child: const ListTile( - title: Text("Start a direct message"), - leading: Icon(Icons.person_add), - ), - ), - ], - icon: const Icon(Icons.add), - ), - const AccountSwitcher(), - IconButton( - tooltip: "Explore other rooms", - onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ExplorePage()), - ), - icon: const 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 selectedRoomId = ref.watch(SelectedRoomController.provider); - final selectedSpace = ref.watch(SelectedSpaceController.provider)!; - final isDm = selectedSpace.id == "dms"; - final spaceMetadata = selectedSpace.room?.metadata; - // In the order the rail shows them, with the space's own timeline first - final destinationIds = IList([?spaceMetadata?.id]) - .addAll(selectedSpace.allChildRooms.map((room) => room.metadata?.id)); - - return Scaffold( - backgroundColor: Colors.transparent, - appBar: Appbar( - // Home and DMs aren't real spaces, so they don't have a summary. - onTap: spaceMetadata == null - ? null - : () => context.showAdaptiveSheet( - (_) => RoomSummarySheet(.fromMetadata(spaceMetadata)), - ), - windowControls: false, - leading: AvatarOrHash( - spaceMetadata?.avatar, - fallback: selectedSpace.icon == null - ? null - : Icon(selectedSpace.icon), - selectedSpace.title, - ), - title: RoomTitle(selectedSpace.title, topic: spaceMetadata?.topic), - backgroundColor: Colors.transparent, - actions: .new([ - RoomMenuButton( - selectedSpace.room, - children: selectedSpace.allChildRooms, - ), - ]), - ), - body: M3ERailTheme( - extensions: [ - NavigationRailM3ETheme( - itemExpandedHeight: 48, - iconLabelGap: 16, - sectionHeaderSpacingTop: 0, - ), - ], - builder: (context) => Padding( - padding: const .only(top: 4), - child: NavigationRailM3E( - fab: selectedSpace.room == null - ? null - : .new( - icon: const Icon(Icons.explore), - label: "Browse space", - tooltip: "Browse and join rooms in this space", - onPressed: () => context.showAdaptiveSheet( - (_) => SpaceHierarchySheet(selectedSpace.id), - ), - ), - expandedWidth: double.infinity, - scrollable: true, - background: Colors.transparent, - type: .alwaysExpand, - selectedIndex: max(0, destinationIds.indexOf(selectedRoomId)), - sections: [ - if (selectedSpace.room case final room? - when spaceMetadata != null) - .new( - destinations: [ - room.toDestination( - isDm: isDm, - label: "Space timeline", - icon: const Icon(Icons.forum), - ), - ], - ), - .new( - header: selectedSpace.room == null - ? null - : const DividerWidget(Text("Rooms")), - destinations: selectedSpace.children.toDestinations(isDm: isDm), - ), - ...selectedSpace.subSpaces.map( - (subSpace) => NavigationRailM3ESection( - 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: subSpace.children.toDestinations(isDm: isDm), - ), - ), - ], - onDestinationSelected: (value) { - ref - .read(KeyController.provider(KeyController.roomKey).notifier) - .set(destinationIds[value]); - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - }, - ), - ), - ), - ); - } -} diff --git a/lib/widgets/add_directory_server_sheet.dart b/lib/widgets/rooms/add_directory_server_sheet.dart similarity index 100% rename from lib/widgets/add_directory_server_sheet.dart rename to lib/widgets/rooms/add_directory_server_sheet.dart diff --git a/lib/widgets/add_space_rooms_sheet.dart b/lib/widgets/rooms/add_space_rooms_sheet.dart similarity index 99% rename from lib/widgets/add_space_rooms_sheet.dart rename to lib/widgets/rooms/add_space_rooms_sheet.dart index b6d8767..e78ba7a 100644 --- a/lib/widgets/add_space_rooms_sheet.dart +++ b/lib/widgets/rooms/add_space_rooms_sheet.dart @@ -14,7 +14,7 @@ import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; import "package:nexus/main.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/space_child.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; import "package:nexus/widgets/settings/search_chooser.dart"; /// Chooses rooms to add to the space [spaceId], then adds them. diff --git a/lib/widgets/create_room_sheet.dart b/lib/widgets/rooms/create_room_sheet.dart similarity index 100% rename from lib/widgets/create_room_sheet.dart rename to lib/widgets/rooms/create_room_sheet.dart diff --git a/lib/widgets/join_sheet.dart b/lib/widgets/rooms/join_sheet.dart similarity index 96% rename from lib/widgets/join_sheet.dart rename to lib/widgets/rooms/join_sheet.dart index 8977814..e9ee719 100644 --- a/lib/widgets/join_sheet.dart +++ b/lib/widgets/rooms/join_sheet.dart @@ -4,7 +4,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/controllers/room_summary.dart"; import "package:nexus/helpers/extensions/get_link_info.dart"; -import "package:nexus/widgets/room_summary_sheet.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; class const JoinSheet(final WidgetRef ref, {super.key}) extends HookWidget { @override diff --git a/lib/widgets/knock_sheet.dart b/lib/widgets/rooms/knock_sheet.dart similarity index 100% rename from lib/widgets/knock_sheet.dart rename to lib/widgets/rooms/knock_sheet.dart diff --git a/lib/widgets/room_directory_card.dart b/lib/widgets/rooms/room_directory_card.dart similarity index 96% rename from lib/widgets/room_directory_card.dart rename to lib/widgets/rooms/room_directory_card.dart index e8d8bfc..10cc071 100644 --- a/lib/widgets/room_directory_card.dart +++ b/lib/widgets/rooms/room_directory_card.dart @@ -8,9 +8,9 @@ import "package:nexus/helpers/extensions/focus_room.dart"; import "package:nexus/helpers/extensions/join_room.dart"; import "package:nexus/helpers/extensions/room_summary_display.dart"; import "package:nexus/models/room_summary.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/knock_sheet.dart"; -import "package:nexus/widgets/room_summary_sheet.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/rooms/knock_sheet.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; class const RoomDirectoryCard( final RoomSummary summary, { diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/rooms/room_menu_button.dart similarity index 100% rename from lib/widgets/room_menu_button.dart rename to lib/widgets/rooms/room_menu_button.dart diff --git a/lib/widgets/room_summary_sheet.dart b/lib/widgets/rooms/room_summary_sheet.dart similarity index 96% rename from lib/widgets/room_summary_sheet.dart rename to lib/widgets/rooms/room_summary_sheet.dart index 5579e59..2195101 100644 --- a/lib/widgets/room_summary_sheet.dart +++ b/lib/widgets/rooms/room_summary_sheet.dart @@ -9,10 +9,10 @@ import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/join_room.dart"; import "package:nexus/helpers/extensions/room_summary_display.dart"; import "package:nexus/main.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/expandable_image.dart"; -import "package:nexus/widgets/knock_sheet.dart"; -import "package:nexus/widgets/linkified_text.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/expandable_image.dart"; +import "package:nexus/widgets/rooms/knock_sheet.dart"; +import "package:nexus/widgets/common/linkified_text.dart"; import "package:nexus/models/room_summary.dart"; class const RoomSummarySheet( diff --git a/lib/widgets/room_title.dart b/lib/widgets/rooms/room_title.dart similarity index 100% rename from lib/widgets/room_title.dart rename to lib/widgets/rooms/room_title.dart diff --git a/lib/widgets/space_hierarchy_sheet.dart b/lib/widgets/rooms/space_hierarchy_sheet.dart similarity index 97% rename from lib/widgets/space_hierarchy_sheet.dart rename to lib/widgets/rooms/space_hierarchy_sheet.dart index 613c13c..65fb5da 100644 --- a/lib/widgets/space_hierarchy_sheet.dart +++ b/lib/widgets/rooms/space_hierarchy_sheet.dart @@ -17,12 +17,12 @@ import "package:nexus/main.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; import "package:nexus/models/space_hierarchy.dart"; -import "package:nexus/widgets/add_space_rooms_sheet.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/expandable_image.dart"; -import "package:nexus/widgets/knock_sheet.dart"; -import "package:nexus/widgets/loading.dart"; -import "package:nexus/widgets/room_summary_sheet.dart"; +import "package:nexus/widgets/rooms/add_space_rooms_sheet.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/expandable_image.dart"; +import "package:nexus/widgets/rooms/knock_sheet.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; class const SpaceHierarchySheet(final String spaceId, {super.key}) extends ConsumerWidget { diff --git a/lib/widgets/settings/allowed_rooms_sheet.dart b/lib/widgets/settings/allowed_rooms_sheet.dart index 8123c51..63aac8b 100644 --- a/lib/widgets/settings/allowed_rooms_sheet.dart +++ b/lib/widgets/settings/allowed_rooms_sheet.dart @@ -7,7 +7,7 @@ import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/main.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; import "package:nexus/widgets/settings/search_chooser.dart"; /// Chooses the rooms whose members can join [roomId] without an invite. diff --git a/lib/widgets/settings/enable_image_packs_sheet.dart b/lib/widgets/settings/enable_image_packs_sheet.dart index 9bc952f..427f7d8 100644 --- a/lib/widgets/settings/enable_image_packs_sheet.dart +++ b/lib/widgets/settings/enable_image_packs_sheet.dart @@ -8,8 +8,8 @@ import "package:nexus/controllers/room_image_packs.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/models/account_data.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/image_pack_avatar.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/image_pack_avatar.dart"; /// Chooses a room, then which of its image packs to use in every room. class const EnableImagePacksSheet({ diff --git a/lib/widgets/settings/image_pack_sheet.dart b/lib/widgets/settings/image_pack_sheet.dart index 2dd394e..d18d3f6 100644 --- a/lib/widgets/settings/image_pack_sheet.dart +++ b/lib/widgets/settings/image_pack_sheet.dart @@ -9,8 +9,8 @@ import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/main.dart"; import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/message.dart"; -import "package:nexus/widgets/image_pack_avatar.dart"; -import "package:nexus/widgets/mxc_emoji.dart"; +import "package:nexus/widgets/avatars/image_pack_avatar.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; import "package:nexus/widgets/settings/image_usage_chips.dart"; import "package:nexus/widgets/settings/pack_image_sheet.dart"; diff --git a/lib/widgets/settings/member_chooser.dart b/lib/widgets/settings/member_chooser.dart index 18ed2e2..fbaaa8e 100644 --- a/lib/widgets/settings/member_chooser.dart +++ b/lib/widgets/settings/member_chooser.dart @@ -6,7 +6,7 @@ import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/members.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/models/content/membership.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; import "package:nexus/widgets/settings/search_chooser.dart"; /// The [chosen] users, above a field searching [roomId]'s members, or taking a user ID, to choose more. diff --git a/lib/widgets/settings/pack_image_sheet.dart b/lib/widgets/settings/pack_image_sheet.dart index da17f38..89ed4f7 100644 --- a/lib/widgets/settings/pack_image_sheet.dart +++ b/lib/widgets/settings/pack_image_sheet.dart @@ -3,7 +3,7 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:material_ui/material_ui.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/models/content/image_pack.dart"; -import "package:nexus/widgets/mxc_emoji.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; import "package:nexus/widgets/settings/image_usage_chips.dart"; /// Edits an image in a pack, or only shows it if [onSave] is null. diff --git a/lib/widgets/settings/settings_layout.dart b/lib/widgets/settings/settings_layout.dart index 9741dcb..de20137 100644 --- a/lib/widgets/settings/settings_layout.dart +++ b/lib/widgets/settings/settings_layout.dart @@ -10,9 +10,9 @@ import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/weighted_search.dart"; import "package:nexus/models/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:nexus/widgets/m3e_rail_theme.dart"; +import "package:nexus/widgets/common/divider_text.dart"; +import "package:nexus/widgets/common/highlight_wrapper.dart"; +import "package:nexus/widgets/common/m3e_rail_theme.dart"; import "package:super_sliver_list/super_sliver_list.dart"; /// A settings screen split into categories: a list of them that opens each diff --git a/lib/widgets/sidebar/invite_card.dart b/lib/widgets/sidebar/invite_card.dart new file mode 100644 index 0000000..239d3c1 --- /dev/null +++ b/lib/widgets/sidebar/invite_card.dart @@ -0,0 +1,52 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/invites.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/room_summary_display.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; + +class const InviteCard(final RoomSummary invite, {super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final responding = ref + .watch(InvitesController.respondMutation(invite.roomId)) + .isPending; + final name = invite.name ?? invite.canonicalAlias ?? invite.roomId; + final inviterName = + invite.inviterProfile?.displayName ?? invite.invitedBy?.localpart; + + return Card( + clipBehavior: .antiAlias, + child: ListTile( + onTap: responding + ? null + : () => context.showAdaptiveSheet((_) => RoomSummarySheet(invite)), + trailing: responding + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ) + : null, + leading: AvatarOrHash( + invite.avatarUrl, + name, + dimension: 40, + fallback: invite.isDirect ? null : Icon(invite.icon), + ), + title: Text(name, maxLines: 1, overflow: .ellipsis), + subtitle: Text( + [ + if (inviterName != null) + "${invite.isDirect ? "Direct message from" : "Invited by"} $inviterName", + ].join("\n"), + maxLines: 2, + overflow: .ellipsis, + ), + ), + ); + } +} diff --git a/lib/widgets/invites_list.dart b/lib/widgets/sidebar/invites_list.dart similarity index 63% rename from lib/widgets/invites_list.dart rename to lib/widgets/sidebar/invites_list.dart index 68d8b3b..18fb5f0 100644 --- a/lib/widgets/invites_list.dart +++ b/lib/widgets/sidebar/invites_list.dart @@ -1,14 +1,10 @@ +import "package:nexus/widgets/sidebar/invite_card.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/controllers/invites.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/helpers/extensions/room_summary_display.dart"; import "package:nexus/main.dart"; import "package:nexus/models/nav_page.dart"; -import "package:nexus/models/room_summary.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/room_summary_sheet.dart"; class const InvitesList({super.key}) extends ConsumerWidget implements NavPage { @override @@ -92,46 +88,3 @@ class const InvitesList({super.key}) extends ConsumerWidget implements NavPage { ); } } - -class const InviteCard(final RoomSummary invite, {super.key}) - extends ConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final responding = ref - .watch(InvitesController.respondMutation(invite.roomId)) - .isPending; - final name = invite.name ?? invite.canonicalAlias ?? invite.roomId; - final inviterName = - invite.inviterProfile?.displayName ?? invite.invitedBy?.localpart; - - return Card( - clipBehavior: .antiAlias, - child: ListTile( - onTap: responding - ? null - : () => context.showAdaptiveSheet((_) => RoomSummarySheet(invite)), - trailing: responding - ? const SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(), - ) - : null, - leading: AvatarOrHash( - invite.avatarUrl, - name, - dimension: 40, - fallback: invite.isDirect ? null : Icon(invite.icon), - ), - title: Text(name, maxLines: 1, overflow: .ellipsis), - subtitle: Text( - [ - if (inviterName != null) - "${invite.isDirect ? "Direct message from" : "Invited by"} $inviterName", - ].join("\n"), - maxLines: 2, - overflow: .ellipsis, - ), - ), - ); - } -} diff --git a/lib/widgets/notifications_list.dart b/lib/widgets/sidebar/notifications_list.dart similarity index 97% rename from lib/widgets/notifications_list.dart rename to lib/widgets/sidebar/notifications_list.dart index 23ff676..f52f944 100644 --- a/lib/widgets/notifications_list.dart +++ b/lib/widgets/sidebar/notifications_list.dart @@ -9,9 +9,9 @@ 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/common/error_dialog.dart"; +import "package:nexus/widgets/common/highlight_wrapper.dart"; +import "package:nexus/widgets/common/loading.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:super_sliver_list/super_sliver_list.dart"; diff --git a/lib/widgets/sidebar/room_list.dart b/lib/widgets/sidebar/room_list.dart new file mode 100644 index 0000000..0e94cdb --- /dev/null +++ b/lib/widgets/sidebar/room_list.dart @@ -0,0 +1,59 @@ +import "package:nexus/widgets/sidebar/spaces_rail.dart"; +import "package:nexus/widgets/sidebar/rooms_pane.dart"; + +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/nav_page.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/spaces.dart"; + +class const RoomList({super.key}) extends ConsumerWidget implements NavPage { + @override + String get title => "Rooms"; + + @override + IconData get icon => Icons.list; + + @override + int? badgeCount(WidgetRef ref) { + final (notifications, hasUnread) = ref.watch( + RoomsController.provider.select( + (rooms) => rooms.values.fold( + (0, false), + (total, room) => ( + total.$1 + (room.metadata?.unreadNotifications ?? 0), + total.$2 || (room.metadata?.unreadMessages ?? 0) > 0, + ), + ), + ), + ); + + return notifications > 0 + ? notifications + : hasUnread + ? 0 + : 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()), + ], + ); + } +} diff --git a/lib/widgets/sidebar/rooms_pane.dart b/lib/widgets/sidebar/rooms_pane.dart new file mode 100644 index 0000000..aabebbb --- /dev/null +++ b/lib/widgets/sidebar/rooms_pane.dart @@ -0,0 +1,143 @@ +import "dart:math"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +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:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; +import "package:nexus/helpers/extensions/room_destinations.dart"; +import "package:nexus/widgets/common/appbar.dart"; +import "package:nexus/widgets/rooms/room_title.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/divider_widget.dart"; +import "package:nexus/widgets/common/m3e_rail_theme.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; +import "package:nexus/widgets/rooms/room_menu_button.dart"; +import "package:nexus/widgets/rooms/space_hierarchy_sheet.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/selected_room.dart"; +import "package:nexus/controllers/selected_space.dart"; + +class const RoomsPane({super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedRoomId = ref.watch(SelectedRoomController.provider); + final selectedSpace = ref.watch(SelectedSpaceController.provider)!; + final isDm = selectedSpace.id == "dms"; + final spaceMetadata = selectedSpace.room?.metadata; + // In the order the rail shows them, with the space's own timeline first + final destinationIds = IList([?spaceMetadata?.id]) + .addAll(selectedSpace.allChildRooms.map((room) => room.metadata?.id)); + + return Scaffold( + backgroundColor: Colors.transparent, + appBar: Appbar( + // Home and DMs aren't real spaces, so they don't have a summary. + onTap: spaceMetadata == null + ? null + : () => context.showAdaptiveSheet( + (_) => RoomSummarySheet(.fromMetadata(spaceMetadata)), + ), + windowControls: false, + leading: AvatarOrHash( + spaceMetadata?.avatar, + fallback: selectedSpace.icon == null + ? null + : Icon(selectedSpace.icon), + selectedSpace.title, + ), + title: RoomTitle(selectedSpace.title, topic: spaceMetadata?.topic), + backgroundColor: Colors.transparent, + actions: .new([ + RoomMenuButton( + selectedSpace.room, + children: selectedSpace.allChildRooms, + ), + ]), + ), + body: M3ERailTheme( + extensions: [ + NavigationRailM3ETheme( + itemExpandedHeight: 48, + iconLabelGap: 16, + sectionHeaderSpacingTop: 0, + ), + ], + builder: (context) => Padding( + padding: const .only(top: 4), + child: NavigationRailM3E( + fab: selectedSpace.room == null + ? null + : .new( + icon: const Icon(Icons.explore), + label: "Browse space", + tooltip: "Browse and join rooms in this space", + onPressed: () => context.showAdaptiveSheet( + (_) => SpaceHierarchySheet(selectedSpace.id), + ), + ), + expandedWidth: double.infinity, + scrollable: true, + background: Colors.transparent, + type: .alwaysExpand, + selectedIndex: max(0, destinationIds.indexOf(selectedRoomId)), + sections: [ + if (selectedSpace.room case final room? + when spaceMetadata != null) + .new( + destinations: [ + room.toDestination( + isDm: isDm, + label: "Space timeline", + icon: const Icon(Icons.forum), + ), + ], + ), + .new( + header: selectedSpace.room == null + ? null + : const DividerWidget(Text("Rooms")), + destinations: selectedSpace.children.toDestinations(isDm: isDm), + ), + ...selectedSpace.subSpaces.map( + (subSpace) => NavigationRailM3ESection( + 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: subSpace.children.toDestinations(isDm: isDm), + ), + ), + ], + onDestinationSelected: (value) { + ref + .read(KeyController.provider(KeyController.roomKey).notifier) + .set(destinationIds[value]); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar/sidebar.dart similarity index 88% rename from lib/widgets/sidebar.dart rename to lib/widgets/sidebar/sidebar.dart index 3ed8daa..d8f98cb 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar/sidebar.dart @@ -4,10 +4,10 @@ import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/sidebar_page.dart"; import "package:nexus/models/nav_page.dart"; import "package:nexus/models/sidebar_page.dart"; -import "package:nexus/widgets/count_badge.dart"; -import "package:nexus/widgets/invites_list.dart"; -import "package:nexus/widgets/notifications_list.dart"; -import "package:nexus/widgets/room_list.dart"; +import "package:nexus/widgets/common/count_badge.dart"; +import "package:nexus/widgets/sidebar/invites_list.dart"; +import "package:nexus/widgets/sidebar/notifications_list.dart"; +import "package:nexus/widgets/sidebar/room_list.dart"; class const Sidebar({super.key}) extends ConsumerWidget { static NavPage pageFor(SidebarPage page) => switch (page) { diff --git a/lib/widgets/sidebar/spaces_rail.dart b/lib/widgets/sidebar/spaces_rail.dart new file mode 100644 index 0000000..d84a40a --- /dev/null +++ b/lib/widgets/sidebar/spaces_rail.dart @@ -0,0 +1,142 @@ +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:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; +import "package:nexus/widgets/common/account_switcher.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/rooms/create_room_sheet.dart"; +import "package:nexus/widgets/users/start_dm_sheet.dart"; +import "package:nexus/widgets/pages/explore.dart"; +import "package:nexus/widgets/pages/settings.dart"; +import "package:nexus/widgets/rooms/join_sheet.dart"; +import "package:nexus/widgets/common/m3e_rail_theme.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/spaces.dart"; + +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 M3ERailTheme( + extensions: [ + NavigationRailM3ETheme(itemCollapsedHeight: 48, itemVerticalGap: 0), + ], + builder: (context) => 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: [ + AdaptiveMenuButton( + tooltip: "Add a room", + itemBuilder: (_) => [ + PopupMenuItem( + onTap: () => + context.showAdaptiveSheet((_) => JoinSheet(ref)), + child: const ListTile( + title: Text("Join an existing room (or space)"), + leading: Icon(Icons.numbers), + ), + ), + PopupMenuItem( + onTap: () => context.showAdaptiveSheet( + (_) => const CreateRoomSheet(), + ), + child: const ListTile( + title: Text("Create a new room (or space)"), + leading: Icon(Icons.add), + ), + ), + PopupMenuItem( + onTap: () => context.showAdaptiveSheet( + (_) => const StartDmSheet(), + ), + child: const ListTile( + title: Text("Start a direct message"), + leading: Icon(Icons.person_add), + ), + ), + ], + icon: const Icon(Icons.add), + ), + const AccountSwitcher(), + IconButton( + tooltip: "Explore other rooms", + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ExplorePage()), + ), + icon: const Icon(Icons.explore), + ), + IconButton( + tooltip: "Open settings", + onPressed: () => showDialog( + context: context, + builder: (_) => SettingsPage(), + ), + icon: const Icon(Icons.settings), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/thread_drawer.dart b/lib/widgets/threads/thread_drawer.dart similarity index 100% rename from lib/widgets/thread_drawer.dart rename to lib/widgets/threads/thread_drawer.dart diff --git a/lib/widgets/thread_summary.dart b/lib/widgets/threads/thread_summary.dart similarity index 100% rename from lib/widgets/thread_summary.dart rename to lib/widgets/threads/thread_summary.dart diff --git a/lib/widgets/threads_drawer.dart b/lib/widgets/threads/threads_drawer.dart similarity index 96% rename from lib/widgets/threads_drawer.dart rename to lib/widgets/threads/threads_drawer.dart index a2ff127..e7931da 100644 --- a/lib/widgets/threads_drawer.dart +++ b/lib/widgets/threads/threads_drawer.dart @@ -4,10 +4,10 @@ import "package:nexus/controllers/thread_list.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/thread_events.dart"; import "package:nexus/main.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/loading.dart"; -import "package:nexus/widgets/event_preview.dart"; -import "package:nexus/widgets/timestamp.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/renderers/event_preview.dart"; +import "package:nexus/widgets/common/timestamp.dart"; final class const ThreadsDrawer( final String roomId, { diff --git a/lib/widgets/mutual_rooms_sheet.dart b/lib/widgets/users/mutual_rooms_sheet.dart similarity index 97% rename from lib/widgets/mutual_rooms_sheet.dart rename to lib/widgets/users/mutual_rooms_sheet.dart index 4c47577..16066b0 100644 --- a/lib/widgets/mutual_rooms_sheet.dart +++ b/lib/widgets/users/mutual_rooms_sheet.dart @@ -7,7 +7,7 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/sidebar_page.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/focus_room.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; /// The rooms the user shares with [userId]. class const MutualRoomsSheet(final String userId, {super.key}) diff --git a/lib/widgets/start_dm_sheet.dart b/lib/widgets/users/start_dm_sheet.dart similarity index 100% rename from lib/widgets/start_dm_sheet.dart rename to lib/widgets/users/start_dm_sheet.dart diff --git a/lib/widgets/timezone_field.dart b/lib/widgets/users/timezone_field.dart similarity index 100% rename from lib/widgets/timezone_field.dart rename to lib/widgets/users/timezone_field.dart diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/users/user_bottom_sheet.dart similarity index 97% rename from lib/widgets/user_bottom_sheet.dart rename to lib/widgets/users/user_bottom_sheet.dart index 373ed52..12e0e24 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/users/user_bottom_sheet.dart @@ -19,19 +19,19 @@ import "package:nexus/helpers/extensions/create_room.dart"; import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/loading.dart"; -import "package:nexus/widgets/mutual_rooms_sheet.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/users/mutual_rooms_sheet.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_action.dart"; import "package:nexus/models/profile_response.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; import "package:nexus/main.dart"; -import "package:nexus/widgets/editable_avatar.dart"; -import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/avatars/editable_avatar.dart"; +import "package:nexus/widgets/common/expandable_image.dart"; import "package:nexus/widgets/settings/inline_text_field.dart"; -import "package:nexus/widgets/start_dm_sheet.dart"; -import "package:nexus/widgets/timezone_field.dart"; +import "package:nexus/widgets/users/start_dm_sheet.dart"; +import "package:nexus/widgets/users/timezone_field.dart"; final class const UserBottomSheet( final String userId, { diff --git a/lib/widgets/user_span.dart b/lib/widgets/users/user_span.dart similarity index 100% rename from lib/widgets/user_span.dart rename to lib/widgets/users/user_span.dart From ffbb0ad9e24f8d499ca850061357db030e5ee02a Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 9 Nov 2026 17:31:19 -0500 Subject: [PATCH 08/13] make hovering a reaction show its shortcode --- lib/controllers/emoji_shortcodes.dart | 31 ++++++++++++++++++++ lib/controllers/reactions.dart | 31 +++++++++++++++----- lib/models/reaction_group.dart | 10 +++++++ lib/widgets/reactions/reaction_row.dart | 9 ++++-- lib/widgets/reactions/reactions_popover.dart | 4 +-- 5 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 lib/controllers/emoji_shortcodes.dart create mode 100644 lib/models/reaction_group.dart diff --git a/lib/controllers/emoji_shortcodes.dart b/lib/controllers/emoji_shortcodes.dart new file mode 100644 index 0000000..a24ebb1 --- /dev/null +++ b/lib/controllers/emoji_shortcodes.dart @@ -0,0 +1,31 @@ +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"; + +/// Maps each unicode emoji, without variation selectors, to its shortcode. +class EmojiShortcodesController extends Notifier> { + static final _variationSelectors = RegExp("[\uFE0E\uFE0F]"); + + static String normalize(String emoji) => + emoji.replaceAll(_variationSelectors, ""); + + @override + IMap build() => .fromEntries( + ref + .watch(EmojiController.provider) + .requireValue + .map((category) => category.emojis) + .flattened + .where((emoji) => emoji.aliases.isNotEmpty) + .map( + (emoji) => + MapEntry(normalize(emoji.value), ":${emoji.aliases.first}:"), + ), + ); + + static final provider = + NotifierProvider>( + EmojiShortcodesController.new, + ); +} diff --git a/lib/controllers/reactions.dart b/lib/controllers/reactions.dart index 0c30128..662661c 100644 --- a/lib/controllers/reactions.dart +++ b/lib/controllers/reactions.dart @@ -1,14 +1,18 @@ 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/emoji_shortcodes.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/models/configs/event.dart"; import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/reaction_group.dart"; class ReactionsController(final EventConfig config) - extends AsyncNotifier>> { + extends AsyncNotifier> { @override - Future>> build() async { + Future> build() async { + final unicodeShortcodes = ref.watch(EmojiShortcodesController.provider); + final eventInfo = ref.watch( RoomsController.provider.select((value) { final event = value[config.roomId]?.events[config.eventRowId]; @@ -30,12 +34,25 @@ class ReactionsController(final EventConfig config) return reactionEvents ?.where((event) => event.redactedBy == null) - .fold>>(.new(), (acc, event) { - if (event.content case ReactionContent(:final key?)) { + .fold>(.new(), (acc, event) { + if (event.content case ReactionContent( + :final key?, + :final shortcode, + )) { return acc.update( key, - (list) => list.add(event.sender), - ifAbsent: () => .new([event.sender]), + (group) => group.copyWith( + senders: group.senders.add(event.sender), + shortcode: group.shortcode ?? shortcode, + ), + ifAbsent: () => .new( + senders: .new([event.sender]), + shortcode: + shortcode ?? + unicodeShortcodes[EmojiShortcodesController.normalize( + key, + )], + ), ); } @@ -47,7 +64,7 @@ class ReactionsController(final EventConfig config) static final provider = AsyncNotifierProvider.family< ReactionsController, - IMap>, + IMap, EventConfig >(ReactionsController.new); } diff --git a/lib/models/reaction_group.dart b/lib/models/reaction_group.dart new file mode 100644 index 0000000..b3ea32c --- /dev/null +++ b/lib/models/reaction_group.dart @@ -0,0 +1,10 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "reaction_group.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class const ReactionGroup({ + required final IList senders, + final String? shortcode, +}) with _$ReactionGroup; diff --git a/lib/widgets/reactions/reaction_row.dart b/lib/widgets/reactions/reaction_row.dart index 6020358..f8336c2 100644 --- a/lib/widgets/reactions/reaction_row.dart +++ b/lib/widgets/reactions/reaction_row.dart @@ -7,6 +7,7 @@ import "package:nexus/controllers/reactions.dart"; import "package:nexus/controllers/room_chat.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/reaction_group.dart"; import "package:nexus/widgets/common/error_dialog.dart"; import "package:nexus/widgets/reactions/reaction_key.dart"; import "package:nexus/widgets/reactions/reactions_popover.dart"; @@ -25,7 +26,7 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { .new(roomId: event.roomId, eventRowId: event.rowId), ), )) { - AsyncData(value: final IMap>? reactors) || + AsyncData(value: final IMap? reactors) || AsyncLoading(value: final reactors) => Wrap( spacing: 4, runSpacing: 4, @@ -37,7 +38,9 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { final enabled = useState(true); final selected = - reactors?[reaction]?.contains(clientState!.userId) ?? + reactors?[reaction]?.senders.contains( + clientState!.userId, + ) ?? false; void showReactions(PositionedGestureDetails details) => context.showAdaptivePopover( @@ -52,7 +55,7 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { onSecondaryTapUp: showReactions, onLongPressStart: showReactions, child: Tooltip( - message: reactors?[reaction]?.join(", ") ?? "", + message: reactors?[reaction]?.shortcode ?? reaction, child: ChoiceChip( showCheckmark: false, selected: selected, diff --git a/lib/widgets/reactions/reactions_popover.dart b/lib/widgets/reactions/reactions_popover.dart index 0c7660b..44c455b 100644 --- a/lib/widgets/reactions/reactions_popover.dart +++ b/lib/widgets/reactions/reactions_popover.dart @@ -81,7 +81,7 @@ class const ReactionsPopover( spacing: 8, children: [ ReactionKey(reaction, height: 20), - Text(reactions[reaction]!.length.toString()), + Text(reactions[reaction]!.senders.length.toString()), ], ), ), @@ -98,7 +98,7 @@ class const ReactionsPopover( padding: .symmetric(vertical: 8), child: Column( crossAxisAlignment: .stretch, - children: reactions[reaction]! + children: reactions[reaction]!.senders .map( (userId) => Consumer( builder: (context, ref, _) { From 32b641c281d0f10d39b21fb9b722904b7f448858 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 6 Nov 2026 20:14:14 -0500 Subject: [PATCH 09/13] add infra for e2e testing --- flake.lock | 194 ++++++++++++++++++++++++++++++++++++++-- flake.nix | 2 + linux/nix/build_env.nix | 30 +++++++ linux/nix/default.nix | 23 +++++ linux/nix/devshell.nix | 43 +++------ linux/nix/e2e.sh | 77 ++++++++++++++++ pubspec.lock | 37 +++++++- pubspec.yaml | 6 ++ 8 files changed, 371 insertions(+), 41 deletions(-) create mode 100644 linux/nix/build_env.nix create mode 100755 linux/nix/e2e.sh diff --git a/flake.lock b/flake.lock index db5f957..6508ef8 100644 --- a/flake.lock +++ b/flake.lock @@ -1,9 +1,121 @@ { "nodes": { + "advisory-db": { + "flake": false, + "locked": { + "lastModified": 1789329924, + "narHash": "sha256-mykGMFabQ7yP6G373M3VHkrac9PJPKJstrhQz2D/Rdc=", + "owner": "rustsec", + "repo": "advisory-db", + "rev": "455fd4bac659b5f1fca3810661c2d8b3c25dad05", + "type": "github" + }, + "original": { + "owner": "rustsec", + "repo": "advisory-db", + "type": "github" + } + }, + "continuwuity": { + "inputs": { + "advisory-db": "advisory-db", + "crane": "crane", + "fenix": "fenix", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1794004794, + "narHash": "sha256-fNmKraRLE963JuV3K4giays4KcOVCGpjvFuEG2E0hmE=", + "ref": "refs/heads/quad/fix/latest-event-in-thread-aggregation", + "rev": "35813f52748c31b0b10cc72e69dabef6a838c46a", + "revCount": 8067, + "type": "git", + "url": "file:///home/quadradical/Documents/Code/continuwuity" + }, + "original": { + "type": "git", + "url": "file:///home/quadradical/Documents/Code/continuwuity" + } + }, + "crane": { + "locked": { + "lastModified": 1788465171, + "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "continuwuity", + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1789298866, + "narHash": "sha256-6eaV5+MdWy1tAlN4HAFr64pTnYc9jiKGXBsu18cyVjE=", + "owner": "nix-community", + "repo": "fenix", + "rev": "1fc6f36a23fa16e3740987957266f6e4b915c3cd", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "edolstra", + "ref": "master", + "repo": "flake-compat", + "type": "github" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" }, + "locked": { + "lastModified": 1788450739, + "narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_2": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib_2" + }, "locked": { "lastModified": 1793576659, "narHash": "sha256-V1h85lUaJBQoHFrB2vpXZOE0Ju1MYSnIpsehoVZYlDo=", @@ -39,7 +151,7 @@ "nix2flatpak": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs_2" }, "locked": { "lastModified": 1791751882, @@ -57,11 +169,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1773389992, - "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", + "lastModified": 1789149629, + "narHash": "sha256-H6GwaZzZf+4npqv0tph94w9tZddSjFjmQrVsW0z78uk=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", + "rev": "eaad089433ca2bb662274377d33df3d0e51ef28b", "type": "github" }, "original": { @@ -72,6 +184,21 @@ } }, "nixpkgs-lib": { + "locked": { + "lastModified": 1788057806, + "narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs-lib_2": { "locked": { "lastModified": 1793504054, "narHash": "sha256-ThkfL3D/FhCiZFc9+3BtxwuMmpuK+/bGtXCSo1+yVew=", @@ -87,6 +214,22 @@ } }, "nixpkgs_2": { + "locked": { + "lastModified": 1773389992, + "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { "locked": { "lastModified": 1793540411, "narHash": "sha256-fiPJ88izTVucjeo2EQ3jk4aWmLg03I1D4vxnX2QjEgE=", @@ -104,9 +247,27 @@ }, "root": { "inputs": { - "flake-parts": "flake-parts", + "continuwuity": "continuwuity", + "flake-parts": "flake-parts_2", "nix2flatpak": "nix2flatpak", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_3" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1789128286, + "narHash": "sha256-KRxxgHiKIBp7R2n41IJuE5K4IUiLOpg47EEi3AxuSp8=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "6aeeb8cf02741e5da07a1615a05faa506cecb21a", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" } }, "systems": { @@ -123,6 +284,27 @@ "repo": "default", "type": "github" } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "continuwuity", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786901030, + "narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index e4cae8a..2074aa6 100644 --- a/flake.nix +++ b/flake.nix @@ -6,6 +6,8 @@ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; nix2flatpak.url = "github:neobrain/nix2flatpak"; + # TEMPORARY: the e2e tests against a local Continuwuity checkout + continuwuity.url = "git+file:///home/quadradical/Documents/Code/continuwuity"; }; outputs = diff --git a/linux/nix/build_env.nix b/linux/nix/build_env.nix new file mode 100644 index 0000000..cac5fbd --- /dev/null +++ b/linux/nix/build_env.nix @@ -0,0 +1,30 @@ +{ pkgs, lib }: +# What building Nexus needs, shared by the dev shell and the e2e tests +{ + packages = + with pkgs; + [ + stdenv.cc + go + git + + libGL + (flutter.override { + extraPkgConfigPackages = [ + mpv-unwrapped + libass + ]; + }) + ] + ++ lib.optionals pkgs.stdenv.isLinux [ + wayland + # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately + libxext + libxfixes + (callPackage ./pkg/hook_pkg_config.nix { }) + ]; + + env = lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; + }; +} diff --git a/linux/nix/default.nix b/linux/nix/default.nix index 3c0a301..3d2f7b2 100644 --- a/linux/nix/default.nix +++ b/linux/nix/default.nix @@ -54,6 +54,29 @@ }; devShells.default = pkgs.callPackage ./devshell.nix { }; + + apps.e2e = + let + buildEnv = import ./build_env.nix { inherit pkgs lib; }; + in + { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "nexus-e2e"; + runtimeInputs = + buildEnv.packages + ++ (with pkgs; [ + # TEMPORARY: matrix-continuwuity, once the local fixes are released + inputs.continuwuity.packages.${system}.default + curl + dbus + ]); + runtimeEnv = buildEnv.env; + text = builtins.readFile ./e2e.sh; + } + ); + }; }; flake.nixosModules.default = import ./module.nix self; diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index f4bf1c6..cc2223e 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -20,41 +20,20 @@ let includeNDK = true; ndkVersions = [ "28.2.13676358" ]; }; + buildEnv = import ./build_env.nix { inherit pkgs lib; }; in pkgs.mkShell { - packages = - with pkgs; - [ - go - git + packages = buildEnv.packages ++ [ android.platform-tools ]; - libGL - (flutter.override { - extraPkgConfigPackages = [ - mpv-unwrapped - libass - ]; - }) - android.platform-tools - ] - ++ lib.optionals pkgs.stdenv.isLinux [ - wayland - # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately - libxext - libxfixes - (callPackage ./pkg/hook_pkg_config.nix { }) - ]; + env = + rec { + LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; - env = rec { - LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; + ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; + ANDROID_SDK_ROOT = ANDROID_HOME; + JAVA_HOME = pkgs.jdk21; - ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; - ANDROID_SDK_ROOT = ANDROID_HOME; - JAVA_HOME = pkgs.jdk21; - - GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; - } - // lib.optionalAttrs pkgs.stdenv.isLinux { - LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; - }; + GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; + } + // buildEnv.env; } diff --git a/linux/nix/e2e.sh b/linux/nix/e2e.sh new file mode 100755 index 0000000..e57e812 --- /dev/null +++ b/linux/nix/e2e.sh @@ -0,0 +1,77 @@ +# shellcheck shell=bash + +files=() +flutter_args=() +for arg in "$@"; do + if [[ "$arg" == *.dart ]]; then files+=("$arg"); else flutter_args+=("$arg"); fi +done +if [[ ${#files[@]} -eq 0 ]]; then files=(integration_test/*_test.dart); fi + +homeserver="http://localhost:8448" + +if curl -s -o /dev/null "$homeserver"; then + echo "Something is already using port 8448, like another e2e run." >&2 + exit 1 +fi + +work="$(mktemp -d)" +# shellcheck disable=SC2329 # Run by the trap +cleanup() { + [[ -n "${server_pid:-}" ]] && kill "$server_pid" 2>/dev/null && wait "$server_pid" 2>/dev/null + rm -rf "$work" +} +trap cleanup EXIT + +mkdir -p "$work/db" +cat > "$work/continuwuity.toml" < "$work/continuwuity.log" 2>&1 & +server_pid=$! + +all_can_log_in() { + for user in "${users[@]}"; do + curl -sf -XPOST "$homeserver/_matrix/client/v3/login" \ + -d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"$user\"},\"password\":\"${user}pass\"}" \ + > /dev/null || return 1 + done +} + +# Wait until login succeeds, otherwise you might try to log in before the account is created +attempts=60 +until all_can_log_in; do + kill -0 "$server_pid" 2>/dev/null || { cat "$work/continuwuity.log"; exit 1; } + (( --attempts )) || { echo "Test users weren't created" >&2; exit 1; } + sleep 0.5 +done + +GOCACHE="$(go env GOCACHE)" +export GOCACHE + +status=0 +for file in "${files[@]}"; do + name="$(basename "$file" .dart)" + mkdir -p "$work/$name/data" "$work/$name/cache" + XDG_DATA_HOME="$work/$name/data" XDG_CACHE_HOME="$work/$name/cache" \ + dbus-run-session -- \ + flutter test "$file" -d linux \ + --dart-define="NEXUS_E2E_HOMESERVER=$homeserver" "${flutter_args[@]}" || + status=$? +done +exit "$status" diff --git a/pubspec.lock b/pubspec.lock index 9ea2664..c4ccd9b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -58,7 +58,7 @@ packages: source: hosted version: "1.0.4" app_links_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: app_links_platform_interface sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" @@ -471,6 +471,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_file_dialog: dependency: "direct main" description: @@ -581,7 +586,7 @@ packages: source: hosted version: "2.3.2" flutter_test: - dependency: transitive + dependency: "direct dev" description: flutter source: sdk version: "0.0.0" @@ -623,6 +628,11 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" geoclue: dependency: transitive description: @@ -839,6 +849,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -1552,6 +1567,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: @@ -1715,7 +1738,7 @@ packages: source: hosted version: "3.2.6" url_launcher_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" @@ -1843,6 +1866,14 @@ packages: url: "https://github.com/DavidClaudeAI/webcrypto.dart" source: git version: "0.6.2-wip" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" webpush_encryption: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a1dbdf5..145a55c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -122,6 +122,12 @@ dependencies: latlong2: 0.10.1 geolocator: 14.1.2 dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + app_links_platform_interface: 2.0.4 + url_launcher_platform_interface: 2.3.2 build_runner: 2.17.1 flutter_lints: 6.0.0 freezed: 4.0.3 From d3a6b5141098675ee77fa32b74b9427d80d9069d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 6 Nov 2026 21:17:24 -0500 Subject: [PATCH 10/13] add thread e2e tests --- integration_test/helpers/fakes.dart | 47 ++++ integration_test/helpers/matrix_user.dart | 174 +++++++++++++++ integration_test/helpers/oauth.dart | 68 ++++++ integration_test/helpers/pump_until.dart | 56 +++++ integration_test/helpers/test_app.dart | 115 ++++++++++ integration_test/helpers/threads.dart | 34 +++ integration_test/long_thread_test.dart | 115 ++++++++++ integration_test/threads_test.dart | 258 ++++++++++++++++++++++ 8 files changed, 867 insertions(+) create mode 100644 integration_test/helpers/fakes.dart create mode 100644 integration_test/helpers/matrix_user.dart create mode 100644 integration_test/helpers/oauth.dart create mode 100644 integration_test/helpers/pump_until.dart create mode 100644 integration_test/helpers/test_app.dart create mode 100644 integration_test/helpers/threads.dart create mode 100644 integration_test/long_thread_test.dart create mode 100644 integration_test/threads_test.dart diff --git a/integration_test/helpers/fakes.dart b/integration_test/helpers/fakes.dart new file mode 100644 index 0000000..050eb99 --- /dev/null +++ b/integration_test/helpers/fakes.dart @@ -0,0 +1,47 @@ +import "dart:async"; + +import "package:app_links_platform_interface/app_links_platform_interface.dart"; +import "package:url_launcher_platform_interface/link.dart"; +import "package:url_launcher_platform_interface/url_launcher_platform_interface.dart"; + +/// Delivers links to the app as if the OS had opened them. +class FakeAppLinks extends AppLinksPlatform { + final _links = StreamController.broadcast(); + + void open(Uri uri) => _links.add(uri); + + @override + Future getInitialLink() async => null; + + @override + Future getInitialLinkString() async => null; + + @override + Future getLatestLink() async => null; + + @override + Future getLatestLinkString() async => null; + + @override + Stream get uriLinkStream => _links.stream; + + @override + Stream get stringLinkStream => + _links.stream.map((uri) => uri.toString()); +} + +/// Hands launched URLs to [onLaunch] instead of opening a browser. +class FakeUrlLauncher(final void Function(Uri url) onLaunch) + extends UrlLauncherPlatform { + @override + LinkDelegate? get linkDelegate => null; + + @override + Future canLaunch(String url) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + onLaunch(.parse(url)); + return true; + } +} diff --git a/integration_test/helpers/matrix_user.dart b/integration_test/helpers/matrix_user.dart new file mode 100644 index 0000000..bd2692c --- /dev/null +++ b/integration_test/helpers/matrix_user.dart @@ -0,0 +1,174 @@ +import "dart:convert"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:http/http.dart" as http; + +const homeserver = String.fromEnvironment("NEXUS_E2E_HOMESERVER"); + +/// A user acting over the client-server API directly, as a control for the +/// tests: it doesn't go through gomuks or any other Nexus code, so it sets +/// things up and checks the server's side independently of what's being +/// tested. That includes Nexus' models, so the JSON it sends is written out +/// here rather than built the way Nexus builds it. +class const MatrixUser(final String userId, final String accessToken) { + static Future login(String username, String password) async { + final json = await request( + "POST", + "/_matrix/client/v3/login", + body: { + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": username}, + "password": password, + }, + ); + return MatrixUser(json["user_id"], json["access_token"]); + } + + static Future> request( + String method, + String path, { + String? accessToken, + Object? body, + }) async { + final httpRequest = + http.Request(method, Uri.parse(homeserver).resolve(path)) + ..headers.addAll({ + "Content-Type": "application/json", + if (accessToken != null) "Authorization": "Bearer $accessToken", + }); + if (method != "GET") httpRequest.body = jsonEncode(body ?? {}); + final response = await http.Response.fromStream(await httpRequest.send()); + + if (response.statusCode == 429) { + final retryAfter = jsonDecode(response.body)["retry_after_ms"] ?? 1000; + await Future.delayed(.new(milliseconds: retryAfter)); + return request(method, path, accessToken: accessToken, body: body); + } + if (response.statusCode >= 300) { + throw StateError( + "$method $path failed with ${response.statusCode}: ${response.body}", + ); + } + return jsonDecode(response.body); + } + + Future createRoom(String name) async => (await request( + "POST", + "/_matrix/client/v3/createRoom", + accessToken: accessToken, + body: {"name": name, "preset": "private_chat"}, + ))["room_id"]; + + Future invite(String roomId, String userId) => request( + "POST", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/invite", + accessToken: accessToken, + body: {"user_id": userId}, + ); + + Future join(String roomId) => request( + "POST", + "/_matrix/client/v3/join/${Uri.encodeComponent(roomId)}", + accessToken: accessToken, + ); + + Future logout() => + request("POST", "/_matrix/client/v3/logout", accessToken: accessToken); + + Future sendEvent( + String roomId, + String type, + Map content, + ) async => (await request( + "PUT", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/send/$type/" + "${DateTime.now().microsecondsSinceEpoch}", + accessToken: accessToken, + body: content, + ))["event_id"]; + + /// Sends a text message. With [threadRoot] it's a reply in that thread, and + /// otherwise with [replyTo] it's a reply to that event. + Future send( + String roomId, + String body, { + String? threadRoot, + String? replyTo, + }) => sendEvent(roomId, "m.room.message", { + "msgtype": "m.text", + "body": body, + "m.relates_to": ?switch ((threadRoot, replyTo)) { + (final threadRoot?, _) => { + "rel_type": "m.thread", + "event_id": threadRoot, + "is_falling_back": true, + "m.in_reply_to": {"event_id": threadRoot}, + }, + (null, final replyTo?) => { + "m.in_reply_to": {"event_id": replyTo}, + }, + (null, null) => null, + }, + }); + + /// Sends [rootBody], then [replies] in a thread from it, oldest first. + Future<({String rootId, IList replyIds})> sendThread( + String roomId, + String rootBody, + IList replies, + ) async { + final rootId = await send(roomId, rootBody); + // One at a time, so they're in order (oops earlier tests weren't reproducable) + final replyIds = await Stream.fromIterable(replies) + .asyncMap((body) => send(roomId, body, threadRoot: rootId)) + .toList(); + return (rootId: rootId, replyIds: replyIds.toIList()); + } + + /// Edits the text message [eventId] to say [body]. + Future edit(String roomId, String eventId, String body) => + sendEvent(roomId, "m.room.message", { + "msgtype": "m.text", + "body": "* $body", + "m.new_content": {"msgtype": "m.text", "body": body}, + "m.relates_to": {"rel_type": "m.replace", "event_id": eventId}, + }); + + Future redact(String roomId, String eventId) => request( + "PUT", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/redact/" + "${Uri.encodeComponent(eventId)}/${DateTime.now().microsecondsSinceEpoch}", + accessToken: accessToken, + ); + + /// The bodies of the replies in the thread from [threadRoot], oldest first. + Future> threadReplies(String roomId, String threadRoot) async { + final json = await request( + "GET", + "/_matrix/client/v1/rooms/${Uri.encodeComponent(roomId)}/relations/" + "${Uri.encodeComponent(threadRoot)}/m.thread?dir=f&limit=100", + accessToken: accessToken, + ); + return (json["chunk"] as List) + .map((event) => event["content"]["body"] as String) + .toIList(); + } + + Future> threadRepliesIncluding( + String roomId, + String threadRoot, + String body, { + int attempts = 20, + }) async { + final replies = await threadReplies(roomId, threadRoot); + if (replies.contains(body) || attempts <= 1) return replies; + + await Future.delayed(const .new(milliseconds: 500)); + return threadRepliesIncluding( + roomId, + threadRoot, + body, + attempts: attempts - 1, + ); + } +} diff --git a/integration_test/helpers/oauth.dart b/integration_test/helpers/oauth.dart new file mode 100644 index 0000000..be652d7 --- /dev/null +++ b/integration_test/helpers/oauth.dart @@ -0,0 +1,68 @@ +import "package:http/http.dart" as http; + +import "fakes.dart"; + +/// Fake platforms that do the browser's part of OAuth on Continuwuity, logging +/// in as [username], then hand the redirect back to Nexus. +class OAuthFakes(final String username, final String password) { + final links = FakeAppLinks(); + late final launcher = FakeUrlLauncher(onLaunch); + + Future onLaunch(Uri url) async { + if (!url.queryParameters.containsKey("code_challenge")) return; + + final redirect = await authorize(url); + // Nexus listens for the redirect after the launch returns + await Future.delayed(const .new(milliseconds: 500)); + links.open(redirect); + } + + /// Logs in on [authorizationUrl], then confirms the grant, returning the URI + /// Continuwuity redirects back to the app with. + Future authorize(Uri authorizationUrl) async { + final client = http.Client(); + String? cookie; + + Future followRedirect( + String method, + Uri url, [ + Map? form, + ]) async { + final request = http.Request(method, url) + ..followRedirects = false + ..headers.addAll({ + // Continuwuity only accepts its forms from top-level navigations + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + "Cookie": ?cookie, + }); + if (form != null) request.bodyFields = form; + + final response = await http.Response.fromStream( + await client.send(request), + ); + if (response.headers["set-cookie"] case final setCookie?) { + cookie = setCookie.split(";").first; + } + + return switch ((response.statusCode, response.headers["location"])) { + (302 || 303 || 307, final location?) => url.resolve(location), + (final status, _) => throw StateError( + "Expected $method $url to redirect, got $status: ${response.body}", + ), + }; + } + + try { + final loginUrl = await followRedirect("GET", authorizationUrl); + final grantUrl = await followRedirect("POST", loginUrl, { + "identifier": username, + "password": password, + }); + return await followRedirect("POST", grantUrl, {"stage": "confirm"}); + } finally { + client.close(); + } + } +} diff --git a/integration_test/helpers/pump_until.dart b/integration_test/helpers/pump_until.dart new file mode 100644 index 0000000..5f278f4 --- /dev/null +++ b/integration_test/helpers/pump_until.dart @@ -0,0 +1,56 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/error_dialog.dart"; + +// Custom because `patrol_finder` gives me jQuery vibes, and that's Not GoodTM +extension PumpUntil on WidgetTester { + /// Pumps until [finder] finds something, failing if it doesn't within 30 + /// seconds, or if the app shows an error. + Future pumpUntil(Finder finder) async { + const timeout = Duration(seconds: 30); + + String textsIn(Finder finder) => widgetList( + find.descendant(of: finder, matching: find.byType(Text)), + ).map((text) => text.data).nonNulls.join("\n"); + + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsed < timeout) { + await pump(const .new(milliseconds: 100)); + + if (finder.evaluate().isNotEmpty) return; + + final errorDialog = find.byType(ErrorDialog); + if (errorDialog.evaluate().isNotEmpty) { + fail( + "The app showed an error while waiting for $finder:\n" + "${textsIn(errorDialog)}", + ); + } + } + + final snackBar = find.byType(SnackBar); + final shown = find + .byType(RichText) + .hitTestable() + .evaluate() + .map((element) => (element.widget as RichText).text.toPlainText()) + .where((text) => text.trim().isNotEmpty) + .toIList() + .removeDuplicates() + .join(" | "); + fail( + "Timed out after $timeout waiting for $finder" + "${snackBar.evaluate().isEmpty ? "" : "\nSnack bar: ${textsIn(snackBar)}"}" + "\nShown: $shown", + ); + } + + /// Taps [finder] once it can be tapped, like after animating in. + Future tapWhenShown(Finder finder) async { + final tappable = finder.hitTestable(); + await pumpUntil(tappable); + await tap(tappable.first); + await pump(); + } +} diff --git a/integration_test/helpers/test_app.dart b/integration_test/helpers/test_app.dart new file mode 100644 index 0000000..d6724c0 --- /dev/null +++ b/integration_test/helpers/test_app.dart @@ -0,0 +1,115 @@ +import "package:app_links_platform_interface/app_links_platform_interface.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:media_kit/media_kit.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/main.dart" as nexus; +import "package:nexus/widgets/account_scope.dart"; +import "package:nexus/widgets/pages/chat.dart"; +import "package:nexus/widgets/pages/select_server.dart"; +import "package:nexus/widgets/pages/verify.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:url_launcher_platform_interface/url_launcher_platform_interface.dart"; + +import "matrix_user.dart"; +import "oauth.dart"; +import "pump_until.dart"; + +/// Nexus, run as the account [username], which is set up through the UI the +/// first time it's opened. +class TestApp(final String username, final String password) { + late final fakes = OAuthFakes(username, password); + + void setUp() { + nexus.isInBackground = false; + MediaKit.ensureInitialized(); + AppLinksPlatform.instance = fakes.links; + UrlLauncherPlatform.instance = fakes.launcher; + + // A tap that misses would otherwise only warn + WidgetController.hitTestWarningShouldBeFatal = true; + } + + Future join(MatrixUser inviter, String roomId) async { + final user = await MatrixUser.login(username, password); + await inviter.invite(roomId, user.userId); + await user.join(roomId); + await user.logout(); + } + + /// Starts Nexus like [open], then opens [roomName]. + Future openRoom(WidgetTester tester, String roomName) async { + await open(tester); + await selectRoom(tester, roomName); + } + + /// Taps [roomName] in the room list, opening the drawer it's in first when + /// the window is narrow enough for the mobile layout. + Future selectRoom(WidgetTester tester, String roomName) async { + // Shown once the first sync is done, as is the drawer button on mobile + await tester.pumpUntil(find.byType(RoomChat)); + + final drawerButton = find + .byTooltip(const DefaultMaterialLocalizations().openAppDrawerTooltip) + .hitTestable(); + if (drawerButton.evaluate().isNotEmpty) await tester.tap(drawerButton); + await tester.tapWhenShown(find.text(roomName)); + } + + /// Starts Nexus, logged in and verified, logging in and setting up + /// encryption through the UI first if it isn't. + Future open(WidgetTester tester) async { + await tester.pumpWidget( + AccountScope( + initialAccountId: await ActiveAccountController.stored(), + retry: (_, _) => null, + child: nexus.App(), + ), + ); + + await tester.pumpUntil( + find.byWidgetPredicate( + (widget) => + widget is SelectServerPage || + widget is VerifyPage || + widget is ChatPage, + ), + ); + if (find.byType(SelectServerPage).evaluate().isNotEmpty) { + await logIn(tester); + } + if (find.byType(VerifyPage).evaluate().isNotEmpty) { + await setUpEncryption(tester); + } + await tester.pumpUntil(find.byType(ChatPage)); + } + + Future logIn(WidgetTester tester) async { + final homeserverField = find.widgetWithText(TextField, "Homeserver URL"); + await tester.pumpUntil(homeserverField); + await tester.enterText(homeserverField, homeserver); + await tester.testTextInput.receiveAction(.done); + + // Discovery only uses HTTPS, which the test homeserver doesn't serve, so + // Nexus offers to log in with the URL as typed + await tester.tapWhenShown(find.text("Attempt log in anyways")); + + // Nexus opens the authorization page, which fakes authorizes + await tester.pumpUntil( + find.byWidgetPredicate( + (widget) => widget is VerifyPage || widget is ChatPage, + ), + ); + } + + /// Sets up encryption from the verify page, for an account without it. + Future setUpEncryption(WidgetTester tester) async { + await tester.pumpUntil(find.text("Set Up Encryption")); + await tester.tapWhenShown(find.widgetWithText(TextButton, "Set Up")); + + await tester.tapWhenShown(find.text("I've saved my recovery key")); + await tester.tapWhenShown(find.widgetWithText(TextButton, "Set Up")); + + await tester.tapWhenShown(find.widgetWithText(TextButton, "Done")); + } +} diff --git a/integration_test/helpers/threads.dart b/integration_test/helpers/threads.dart new file mode 100644 index 0000000..d2eee0d --- /dev/null +++ b/integration_test/helpers/threads.dart @@ -0,0 +1,34 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; + +import "pump_until.dart"; + +extension MessageFinders on CommonFinders { + Finder message(String body) => textContaining(body, findRichText: true); +} + +extension RelativeFinders on Finder { + Finder inside(Type type) => + find.descendant(of: find.byType(type), matching: this); + + /// The [InkWell] ancestor around what this finds + Finder get inkWell => find.ancestor(of: this, matching: find.byType(InkWell)); +} + +extension ReplyCounts on WidgetTester { + /// Waits for a reply count in widgets of [type], then checks it, so a wrong + /// count fails straight away with what was shown. + Future expectReplyCount(Type type, int expected) async { + final repliesLabel = RegExp(r"^(\d+) repl(?:y|ies)$"); + final labels = find.textContaining(repliesLabel).inside(type); + await pumpUntil(labels); + + final shown = widgetList(labels) + .map((label) => label.data) + .nonNulls + .map(repliesLabel.firstMatch) + .nonNulls + .map((match) => int.parse(match[1]!)); + expect(shown, everyElement(expected), reason: "Reply count in $type"); + } +} diff --git a/integration_test/long_thread_test.dart b/integration_test/long_thread_test.dart new file mode 100644 index 0000000..b292c12 --- /dev/null +++ b/integration_test/long_thread_test.dart @@ -0,0 +1,115 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/thread_drawer.dart"; +import "package:nexus/widgets/threads_drawer.dart"; + +import "helpers/matrix_user.dart"; +import "helpers/test_app.dart"; +import "helpers/pump_until.dart"; +import "helpers/threads.dart"; + +/// A thread longer than gomuks' initial sync, which gets the latest 100 +/// events, so its start only reaches Nexus through `/relations`. That needs +/// an account whose first sync is after the thread exists, so it's not the one +/// other tests use. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final app = TestApp("carol", "carolpass"); + setUpAll(app.setUp); + final run = DateTime.now().millisecondsSinceEpoch; + final roomName = "Long thread $run"; + final rootBody = "Long thread root $run"; + const replyCount = 120; + // Among the first replies, which aren't synced + const jumpedTo = 10; + // Padded, so no reply's text contains another's (oops those failures were confusing when I didn't do that) + String reply(int number) => "Long reply ${number.toString().padLeft(3, "0")}"; + + setUpAll(() async { + final bob = await MatrixUser.login("bob", "bobpass"); + final roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + Iterable.generate(replyCount, (index) => reply(index + 1)).toIList(), + ); + // In the room's timeline, so there's something to jump from, to a reply + // with others on both sides that aren't synced either + await bob.send( + roomId, + "Quoting an old reply", + replyTo: thread.replyIds[jumpedTo - 1], + ); + }); + + /// Drags the open thread by [offset] until [finder] is shown in it, loading + /// more replies as it goes, for up to 30 seconds. + Future scrollThread( + WidgetTester tester, + Finder finder, + Offset offset, + ) => tester.dragUntilVisible( + finder.inside(ThreadDrawer), + find.byType(CustomScrollView).inside(ThreadDrawer).first, + offset, + maxIteration: 60, + duration: const .new(milliseconds: 500), + ); + + Future openThreadsDrawer(WidgetTester tester) async { + await app.openRoom(tester, roomName); + await tester.tapWhenShown(find.byTooltip("Open threads")); + await tester.pumpUntil(find.message(rootBody).inside(ThreadsDrawer)); + } + + testWidgets("lists the thread with all of its replies", (tester) async { + await openThreadsDrawer(tester); + + await tester.expectReplyCount(ThreadsDrawer, replyCount); + }); + + // Before the whole thread is loaded by scrolling through it below + testWidgets("jumps to a reply in the thread that isn't loaded", ( + tester, + ) async { + await app.openRoom(tester, roomName); + + // The quote of the reply is fetched on its own, then tapped + await tester.tapWhenShown( + find.message(reply(jumpedTo)).inside(RoomChat).inkWell, + ); + + // The thread opens around the reply, with replies on both sides + await tester.pumpUntil( + find.message(reply(jumpedTo)).inside(ThreadDrawer).hitTestable(), + ); + await tester.pumpUntil( + find.message(reply(jumpedTo - 1)).inside(ThreadDrawer), + ); + await tester.pumpUntil( + find.message(reply(jumpedTo + 1)).inside(ThreadDrawer), + ); + + await scrollThread(tester, find.message(reply(60)), const .new(0, -400)); + }); + + testWidgets("loads older replies back to the root", (tester) async { + await openThreadsDrawer(tester); + await tester.tapWhenShown( + find.message(rootBody).inside(ThreadsDrawer).inkWell, + ); + await tester.pumpUntil( + find.message(reply(replyCount)).inside(ThreadDrawer), + ); + + // Dragging down scrolls up the reversed timeline, to older replies + await scrollThread(tester, find.message(rootBody), const .new(0, 400)); + expect(find.message(reply(1)).inside(ThreadDrawer), findsWidgets); + }); +} diff --git a/integration_test/threads_test.dart b/integration_test/threads_test.dart new file mode 100644 index 0000000..6eba136 --- /dev/null +++ b/integration_test/threads_test.dart @@ -0,0 +1,258 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/thread_drawer.dart"; +import "package:nexus/widgets/thread_summary.dart"; +import "package:nexus/widgets/threads_drawer.dart"; + +import "helpers/matrix_user.dart"; +import "helpers/test_app.dart"; +import "helpers/pump_until.dart"; +import "helpers/threads.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final app = TestApp("alice", "alicepass"); + setUpAll(app.setUp); + final run = DateTime.now().millisecondsSinceEpoch; + late MatrixUser bob; + + setUpAll(() async => bob = await MatrixUser.login("bob", "bobpass")); + + /// Opens [roomName], waiting for the thread from [rootBody] in its timeline. + Future openRoom( + WidgetTester tester, + String roomName, + String rootBody, + ) async { + await app.openRoom(tester, roomName); + await tester.pumpUntil( + find.message(rootBody).inside(RoomChat).hitTestable(), + ); + } + + /// Opens the thread from the room's summary, waiting for [reply] in it. + Future openThread(WidgetTester tester, String reply) async { + await tester.tapWhenShown(find.byType(ThreadSummary)); + await tester.pumpUntil(find.message(reply).inside(ThreadDrawer)); + } + + group("a thread", () { + final roomName = "Threads $run"; + final rootBody = "Thread root $run"; + const firstReply = "First reply"; + const secondReply = "Second reply"; + + late String roomId; + late String rootId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + // Joined before Nexus logs in, so the room is there once it does + await app.join(bob, roomId); + + rootId = (await bob.sendThread( + roomId, + rootBody, + .new([firstReply, secondReply]), + )).rootId; + }); + + Future replyCount() async => + (await bob.threadReplies(roomId, rootId)).length; + + testWidgets("logs in with OAuth and sets up encryption", (tester) async { + await app.open(tester); + + await app.selectRoom(tester, roomName); + }); + + testWidgets("summarises the thread under its root", (tester) async { + await openRoom(tester, roomName, rootBody); + + await tester.expectReplyCount(ThreadSummary, 2); + expect(find.message(secondReply).inside(ThreadSummary), findsWidgets); + // Replies are in the thread, not the room's timeline + expect(find.message(firstReply), findsNothing); + }); + + testWidgets("lists the thread in the threads drawer", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.tapWhenShown(find.byTooltip("Open threads")); + + await tester.pumpUntil(find.message(rootBody).inside(ThreadsDrawer)); + await tester.expectReplyCount(ThreadsDrawer, 2); + expect(find.text("Last active:").inside(ThreadsDrawer), findsOneWidget); + }); + + testWidgets("opens the thread from the threads drawer", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.tapWhenShown(find.byTooltip("Open threads")); + await tester.tapWhenShown( + find.message(rootBody).inside(ThreadsDrawer).inkWell, + ); + + await tester.pumpUntil(find.message(secondReply).inside(ThreadDrawer)); + }); + + testWidgets("shows the root and its replies, oldest first", (tester) async { + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final root = find.message(rootBody).inside(ThreadDrawer).first; + final first = find.message(firstReply).inside(ThreadDrawer).first; + final second = find.message(secondReply).inside(ThreadDrawer).first; + expect(root, findsOneWidget); + expect(first, findsOneWidget); + expect( + tester.getTopLeft(root).dy, + lessThan(tester.getTopLeft(first).dy), + reason: "The root should be above the replies", + ); + expect( + tester.getTopLeft(first).dy, + lessThan(tester.getTopLeft(second).dy), + reason: "Older replies should be above newer ones", + ); + }); + + testWidgets("sends replies to the thread", (tester) async { + final body = "Reply from Nexus $run"; + final before = await replyCount(); + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final composer = find + .widgetWithText(TextField, "Your message here...") + .inside(ThreadDrawer); + await tester.pumpUntil(composer); + await tester.enterText(composer, body); + await tester.tapWhenShown( + find.byTooltip("Send message").inside(ThreadDrawer), + ); + await tester.pumpUntil(find.message(body).inside(ThreadDrawer)); + + // It's in the thread for other clients too + final replies = await bob.threadRepliesIncluding(roomId, rootId, body); + expect(replies.last, body); + + // Shown as the latest reply once the summary has caught up + await tester.pumpUntil(find.message(body).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, before + 1); + expect( + find.text(body).inside(Composer), + findsNothing, + reason: "The composer should be cleared once sent", + ); + }); + + testWidgets("shows replies from others as they arrive", (tester) async { + final body = "Live reply $run"; + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final before = await replyCount(); + await bob.send(roomId, body, threadRoot: rootId); + + await tester.pumpUntil(find.message(body).inside(ThreadDrawer)); + await tester.pumpUntil(find.message(body).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, before + 1); + }); + }); + + group("redactions", () { + final roomName = "Redactions $run"; + final rootBody = "Redaction root $run"; + const keptReply = "Kept reply"; + const redactedReply = "Redacted reply"; + + late String roomId; + late String rootId; + late String redactedId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + .new([keptReply, redactedReply]), + ); + rootId = thread.rootId; + redactedId = thread.replyIds.last; + }); + + testWidgets("drops a redacted reply from its thread", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.expectReplyCount(ThreadSummary, 2); + + await bob.redact(roomId, redactedId); + + // The reply before it becomes the latest + await tester.pumpUntil(find.message(keptReply).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, 1); + + await openThread(tester, keptReply); + expect(find.message(redactedReply).inside(ThreadDrawer), findsNothing); + }); + + testWidgets("still opens the thread of a redacted root", (tester) async { + await bob.redact(roomId, rootId); + await app.openRoom(tester, roomName); + await tester.tapWhenShown(find.byTooltip("Open threads")); + + // The root's preview is redacted, so the card is found by its count + await tester.tapWhenShown( + find.text("1 reply").inside(ThreadsDrawer).inkWell, + ); + + final root = find.text("Redacted event").inside(ThreadDrawer); + final reply = find.message(keptReply).inside(ThreadDrawer); + await tester.pumpUntil(root); + await tester.pumpUntil(reply); + expect( + tester.getTopLeft(root.first).dy, + lessThan(tester.getTopLeft(reply.first).dy), + reason: "The redacted root should still be above the replies", + ); + }); + }); + + group("edits", () { + final roomName = "Edits $run"; + final rootBody = "Edit root $run"; + const originalReply = "Reply before editing"; + const editedReply = "Reply after editing"; + + late String roomId; + late String replyId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + .new([originalReply]), + ); + replyId = thread.replyIds.single; + }); + + testWidgets("shows an edited reply's new text", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.pumpUntil(find.message(originalReply).inside(ThreadSummary)); + + await bob.edit(roomId, replyId, editedReply); + + await tester.pumpUntil(find.message(editedReply).inside(ThreadSummary)); + await openThread(tester, editedReply); + expect(find.message(originalReply).inside(ThreadDrawer), findsNothing); + expect(find.text("(edited)").inside(ThreadDrawer), findsOneWidget); + }); + }); +} From 91c954da146129c794bed9d2bcae02b4c0afb5b5 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 6 Nov 2026 21:44:03 -0500 Subject: [PATCH 11/13] add headless app for tests on CI --- .github/workflows/e2e.yml | 28 +++++++++++++++++++ flake.lock | 8 +++--- linux/nix/default.nix | 57 +++++++++++++++++++++++++++------------ 3 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..c374e64 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,28 @@ +name: "End-to-end tests" + +on: + push: + branches: ["main"] + pull_request: + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + + 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: Generate code + run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build" + + - name: Run end-to-end tests + run: nix run .#e2e-headless diff --git a/flake.lock b/flake.lock index 6508ef8..5dc84b3 100644 --- a/flake.lock +++ b/flake.lock @@ -27,11 +27,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1794004794, - "narHash": "sha256-fNmKraRLE963JuV3K4giays4KcOVCGpjvFuEG2E0hmE=", + "lastModified": 1794063651, + "narHash": "sha256-EXBj8a1aBO99CpMZWGqf6z6D/LFdsW/aB2xfDDrJuHw=", "ref": "refs/heads/quad/fix/latest-event-in-thread-aggregation", - "rev": "35813f52748c31b0b10cc72e69dabef6a838c46a", - "revCount": 8067, + "rev": "40a4100ec8734d18f8baf8b0dcfea80b6944e90e", + "revCount": 8068, "type": "git", "url": "file:///home/quadradical/Documents/Code/continuwuity" }, diff --git a/linux/nix/default.nix b/linux/nix/default.nix index 3d2f7b2..3d3b395 100644 --- a/linux/nix/default.nix +++ b/linux/nix/default.nix @@ -55,27 +55,50 @@ devShells.default = pkgs.callPackage ./devshell.nix { }; - apps.e2e = + apps = let buildEnv = import ./build_env.nix { inherit pkgs lib; }; + e2e = pkgs.writeShellApplication { + name = "nexus-e2e"; + runtimeInputs = + buildEnv.packages + ++ (with pkgs; [ + # TEMPORARY: matrix-continuwuity, once the local fixes are released + inputs.continuwuity.packages.${system}.default + curl + dbus + ]); + runtimeEnv = buildEnv.env; + text = builtins.readFile ./e2e.sh; + }; in { - type = "app"; - program = lib.getExe ( - pkgs.writeShellApplication { - name = "nexus-e2e"; - runtimeInputs = - buildEnv.packages - ++ (with pkgs; [ - # TEMPORARY: matrix-continuwuity, once the local fixes are released - inputs.continuwuity.packages.${system}.default - curl - dbus - ]); - runtimeEnv = buildEnv.env; - text = builtins.readFile ./e2e.sh; - } - ); + e2e = { + type = "app"; + program = lib.getExe e2e; + }; + + e2e-headless = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "nexus-e2e-headless"; + runtimeInputs = [ pkgs.cage ]; + runtimeEnv = { + WLR_BACKENDS = "headless"; + __EGL_VENDOR_LIBRARY_FILENAMES = "${pkgs.mesa}/share/glvnd/egl_vendor.d/50_mesa.json"; + }; + text = '' + unset DISPLAY WAYLAND_DISPLAY + # Wayland needs one, which CI may not have + XDG_RUNTIME_DIR="''${XDG_RUNTIME_DIR:-$(mktemp -d)}" + export XDG_RUNTIME_DIR + + exec cage -- ${lib.getExe e2e} "$@" + ''; + } + ); + }; }; }; From a87379d2ebb9fa814bd56828cf0d26c58052f558 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 9 Nov 2026 15:20:29 -0500 Subject: [PATCH 12/13] bump lock --- flake.lock | 58 +++++++++++++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/flake.lock b/flake.lock index 5dc84b3..be46f01 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "advisory-db": { "flake": false, "locked": { - "lastModified": 1789329924, - "narHash": "sha256-mykGMFabQ7yP6G373M3VHkrac9PJPKJstrhQz2D/Rdc=", + "lastModified": 1793979272, + "narHash": "sha256-oUcBtPbtS7PlEMuSrgTAN4wv49cXYbwKbQqhH2pah5w=", "owner": "rustsec", "repo": "advisory-db", - "rev": "455fd4bac659b5f1fca3810661c2d8b3c25dad05", + "rev": "309392b18f6b17022f82c701f300433126901068", "type": "github" }, "original": { @@ -27,11 +27,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1794063651, - "narHash": "sha256-EXBj8a1aBO99CpMZWGqf6z6D/LFdsW/aB2xfDDrJuHw=", - "ref": "refs/heads/quad/fix/latest-event-in-thread-aggregation", - "rev": "40a4100ec8734d18f8baf8b0dcfea80b6944e90e", - "revCount": 8068, + "lastModified": 1794250216, + "narHash": "sha256-IQcTJbLuxL9BxtTp9tjhC5NM8YvSfODbEP4wKXzc9gM=", + "ref": "refs/heads/quad/nexus", + "rev": "62b78e20b0f28a7a3c847cc4baa6223eeeed53f1", + "revCount": 8090, "type": "git", "url": "file:///home/quadradical/Documents/Code/continuwuity" }, @@ -42,11 +42,11 @@ }, "crane": { "locked": { - "lastModified": 1788465171, - "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=", + "lastModified": 1793836618, + "narHash": "sha256-TREenflwEjxOSq292FYwbc3kEm7TDRtXCZRNWEdjBmg=", "owner": "ipetkov", "repo": "crane", - "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54", + "rev": "79fd63528501446749c09f08cad486a58c7d173e", "type": "github" }, "original": { @@ -64,11 +64,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1789298866, - "narHash": "sha256-6eaV5+MdWy1tAlN4HAFr64pTnYc9jiKGXBsu18cyVjE=", + "lastModified": 1794142885, + "narHash": "sha256-xWFSr7q8IerDAVRk4W7/CgTmi05hMy2qjZ0z3VYpOVk=", "owner": "nix-community", "repo": "fenix", - "rev": "1fc6f36a23fa16e3740987957266f6e4b915c3cd", + "rev": "f7d416b5073152c7f2e4de1a28d1a4330dd30a26", "type": "github" }, "original": { @@ -99,11 +99,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1788450739, - "narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=", + "lastModified": 1793576659, + "narHash": "sha256-V1h85lUaJBQoHFrB2vpXZOE0Ju1MYSnIpsehoVZYlDo=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993", + "rev": "ea2630b7470978622c7689a1ff75d2b389175399", "type": "github" }, "original": { @@ -169,11 +169,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1789149629, - "narHash": "sha256-H6GwaZzZf+4npqv0tph94w9tZddSjFjmQrVsW0z78uk=", + "lastModified": 1794042290, + "narHash": "sha256-ziZn2PvCJE5JEEpqTdxXwJpPB+L3YqoCpTAMCzyaP3U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "eaad089433ca2bb662274377d33df3d0e51ef28b", + "rev": "f4a03c008967841f2a0b3cb7c436fc6fc31671f6", "type": "github" }, "original": { @@ -185,11 +185,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1788057806, - "narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=", + "lastModified": 1793504054, + "narHash": "sha256-ThkfL3D/FhCiZFc9+3BtxwuMmpuK+/bGtXCSo1+yVew=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c", + "rev": "7851ba61fd862b277cb6a9cef37394bc18bb94e4", "type": "github" }, "original": { @@ -256,11 +256,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1789128286, - "narHash": "sha256-KRxxgHiKIBp7R2n41IJuE5K4IUiLOpg47EEi3AxuSp8=", + "lastModified": 1794096810, + "narHash": "sha256-dWC+YtMX+cwH3e9ndiFB77ijTsKtO5I2OxfcvUY4j3k=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "6aeeb8cf02741e5da07a1615a05faa506cecb21a", + "rev": "03ae6be57aa063bb373f69eead3b8e15168a45d2", "type": "github" }, "original": { @@ -293,11 +293,11 @@ ] }, "locked": { - "lastModified": 1786901030, - "narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=", + "lastModified": 1791885023, + "narHash": "sha256-C3alQ2MEt0zVrUSho5NUDo3jmhbff72++bfwoqT9Omo=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa", + "rev": "0d6d739a833ba9a9c93034862e4a7b061a062a1c", "type": "github" }, "original": { From bf5c6996b446a257d6436d75dd55121f9aef112b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 9 Nov 2026 16:04:55 -0500 Subject: [PATCH 13/13] add a matrix: link test --- integration_test/links_test.dart | 56 ++++++++++++++++++++++++++++++++ linux/nix/e2e.sh | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 integration_test/links_test.dart diff --git a/integration_test/links_test.dart b/integration_test/links_test.dart new file mode 100644 index 0000000..7690cd7 --- /dev/null +++ b/integration_test/links_test.dart @@ -0,0 +1,56 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/room_summary_sheet.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; + +import "helpers/matrix_user.dart"; +import "helpers/test_app.dart"; +import "helpers/pump_until.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final app = TestApp("dave", "davepass"); + setUpAll(app.setUp); + final run = DateTime.now().millisecondsSinceEpoch; + Future bob() => MatrixUser.login("bob", "bobpass"); + + Future openLink(WidgetTester tester, String link) async { + await app.open(tester); + app.fakes.links.open(.parse(link)); + } + + group("a link opened by the OS", () { + testWidgets("to a user opens their popover", (tester) async { + await openLink(tester, "matrix:u/bob:localhost"); + + await tester.pumpUntil(find.byType(UserBottomSheet)); + }); + + testWidgets("to a joined room opens it", (tester) async { + final inviter = await bob(); + final roomId = await inviter.createRoom("Linked $run"); + // Joined before Nexus starts, so the room is there once it syncs + await app.join(inviter, roomId); + + await openLink(tester, "matrix:roomid/${roomId.substring(1)}"); + + await tester.pumpUntil( + find.byWidgetPredicate( + (widget) => widget is RoomChat && widget.roomId == roomId, + ), + ); + }); + + testWidgets("to a room that isn't joined shows its summary", ( + tester, + ) async { + final roomId = await (await bob()).createRoom("Unjoined $run"); + + await openLink(tester, "matrix:roomid/${roomId.substring(1)}"); + + await tester.pumpUntil(find.byType(RoomSummarySheet)); + }); + }); +} diff --git a/linux/nix/e2e.sh b/linux/nix/e2e.sh index e57e812..6cc9f09 100755 --- a/linux/nix/e2e.sh +++ b/linux/nix/e2e.sh @@ -34,7 +34,7 @@ port = 8448 client = "$homeserver" EOF -users=(alice bob carol) +users=(alice bob carol dave) create_users=() for user in "${users[@]}"; do create_users+=(--execute "users create-user $user ${user}pass")