From f5371382d703c87118d0b7cb261c4c5c31ff95a7 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 00:28:43 -0400 Subject: [PATCH 01/27] use media_kit from main Has some perf improvements, especially for Linux --- linux/flutter/generated_plugins.cmake | 1 + linux/nix/devshell.nix | 1 + linux/nix/pkg/default.nix | 8 + pubspec.lock | 198 +++++++++++++----------- pubspec.yaml | 33 ++++ windows/flutter/generated_plugins.cmake | 1 + 6 files changed, 155 insertions(+), 87 deletions(-) diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 66d7f77..89cfca2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -13,6 +13,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index a47f824..f4bf1c6 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -10,6 +10,7 @@ let cmakeVersions = [ "3.22.1" ]; platformVersions = [ "34" + "35" "36" ]; abiVersions = [ diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 47bf2d7..6ee606e 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -47,6 +47,14 @@ flutter.buildFlutterApplication { linkify = "sha256-TpMD6+0zyY6i9l+6d8ErnVufmepCv362rCtnbOht/z4="; fab_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; m3e_design = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; + media_kit = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; + media_kit_libs_android_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; + media_kit_libs_ios_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; + media_kit_libs_linux = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; + media_kit_libs_macos_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; + media_kit_libs_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; + media_kit_libs_windows_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; + media_kit_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; native_toolchain_cmake = "sha256-/drnHZebmTnsCnHgC1xmlAe+U4PSrjw5Sm9axicuTrY="; navigation_rail_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; unifiedpush_linux = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4="; diff --git a/pubspec.lock b/pubspec.lock index 670b2ca..034e164 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "48e48f73a431eb269eec66d243a63a07ed90ef9417a74047a3b98d52338626ad" + sha256: "9bfb8122e2505aca17219018593c384a87166cfe860d11c7ae927e7f978eb05f" url: "https://pub.dev" source: hosted - version: "109.0.0" + version: "110.0.0" analysis_server_plugin: dependency: transitive description: name: analysis_server_plugin - sha256: "9f293f95cf41cb513a9a4f322354e2fa5483125e0f18bbeca254543ac9c3dda6" + sha256: b94419fc81828edb7870340cb64b8648e9515613d7647309d3d0ea605c6d1f95 url: "https://pub.dev" source: hosted - version: "0.3.24" + version: "0.3.25" analyzer: dependency: transitive description: name: analyzer - sha256: "6d377901d198536b648172974d9d4f277910502cad92193466869cac0ff34990" + sha256: "74c36179c4de542efea24c9dff78324375e4b118a2c1e14e8a7659f457947f8d" url: "https://pub.dev" source: hosted - version: "14.5.0" + version: "14.6.0" analyzer_buffer: dependency: transitive description: @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: analyzer_plugin - sha256: "2c59e6fba4971dc9ece97acd55b4f95b67a98e6ae0a55bff6f9dfc2bb59e8b1a" + sha256: d218f0916ff564cfd9dd032d11e5a990059cb66f88cc741ec6e5e55abdebefcc url: "https://pub.dev" source: hosted - version: "0.14.18" + version: "0.14.19" app_links: dependency: "direct main" description: @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: archive - sha256: f773153144ce0456ae77d96bd247f2efd58efe4f3e5ad42616d0385c031aea11 + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.2" args: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: build_daemon - sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 + sha256: "39ab65f7c60c19af64b9ef96e6a5d3ab48355f7dddfd98a9079d55a8cb6628fc" url: "https://pub.dev" source: hosted - version: "4.1.6" + version: "4.1.7" build_runner: dependency: "direct dev" description: @@ -293,10 +293,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" + sha256: a0e4f65339b31eb6a9e6a12e14b4682a48d46fbc54a82f4a702396fbdbac63a7 url: "https://pub.dev" source: hosted - version: "3.1.13" + version: "3.1.14" dbus: dependency: transitive description: @@ -414,10 +414,10 @@ packages: dependency: transitive description: name: file_selector_ios - sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" + sha256: "5b42ec0ef7626124b4624ea244341e9df780406793c204e89fa68a2e71af115c" url: "https://pub.dev" source: hosted - version: "0.5.3+6" + version: "0.5.3+7" file_selector_linux: dependency: transitive description: @@ -430,10 +430,10 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba url: "https://pub.dev" source: hosted - version: "0.9.5+1" + version: "0.9.5+2" file_selector_platform_interface: dependency: transitive description: @@ -531,10 +531,10 @@ packages: dependency: transitive description: name: flutter_local_notifications_linux - sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" + sha256: "3b89a4bf095fa5f8a99beeabf982c5bd506c3ad25e7b9e61da945e994dc5b615" url: "https://pub.dev" source: hosted - version: "8.0.1" + version: "8.0.2" flutter_local_notifications_platform_interface: dependency: transitive description: @@ -690,10 +690,10 @@ packages: dependency: transitive description: name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + sha256: "361668bdd71f0ae2dc6d17b75a593520f3c414af6534f9eed1aa72049dd71d18" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" http_parser: dependency: transitive description: @@ -746,10 +746,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "4fb88d4350ffc208c2a09fc321db1f80282688716b8cfe10c8c80acc5ae8934a" + sha256: "57255225702ace8c81b8d92c1de6a04b60ee9befa6dab242b10a7612116886fd" url: "https://pub.dev" source: hosted - version: "0.8.13+10" + version: "0.8.13+13" image_picker_linux: dependency: transitive description: @@ -798,6 +798,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" js: dependency: transitive description: @@ -947,66 +963,74 @@ packages: media_kit: dependency: "direct main" description: - name: media_kit - sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 - url: "https://pub.dev" - source: hosted + path: media_kit + ref: HEAD + resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 + url: "https://github.com/media-kit/media-kit" + source: git version: "1.2.6" media_kit_libs_android_video: - dependency: transitive + dependency: "direct overridden" description: - name: media_kit_libs_android_video - sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" - url: "https://pub.dev" - source: hosted + path: "libs/android/media_kit_libs_android_video" + ref: HEAD + resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 + url: "https://github.com/media-kit/media-kit" + source: git version: "1.3.8" media_kit_libs_ios_video: - dependency: transitive + dependency: "direct overridden" description: - name: media_kit_libs_ios_video - sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 - url: "https://pub.dev" - source: hosted - version: "1.1.4" + path: "libs/ios/media_kit_libs_ios_video" + ref: HEAD + resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 + url: "https://github.com/media-kit/media-kit" + source: git + version: "1.1.5" media_kit_libs_linux: - dependency: transitive + dependency: "direct overridden" description: - name: media_kit_libs_linux - sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" - url: "https://pub.dev" - source: hosted + path: "libs/linux/media_kit_libs_linux" + ref: HEAD + resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 + url: "https://github.com/media-kit/media-kit" + source: git version: "1.2.1" media_kit_libs_macos_video: - dependency: transitive + dependency: "direct overridden" description: - name: media_kit_libs_macos_video - sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d - url: "https://pub.dev" - source: hosted - version: "1.1.4" + path: "libs/macos/media_kit_libs_macos_video" + ref: HEAD + resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 + url: "https://github.com/media-kit/media-kit" + source: git + version: "1.1.5" media_kit_libs_video: dependency: "direct main" description: - name: media_kit_libs_video - sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" - url: "https://pub.dev" - source: hosted - version: "1.0.7" + path: "libs/universal/media_kit_libs_video" + ref: HEAD + resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 + url: "https://github.com/media-kit/media-kit" + source: git + version: "1.0.8" media_kit_libs_windows_video: - dependency: transitive + dependency: "direct overridden" description: - name: media_kit_libs_windows_video - sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab - url: "https://pub.dev" - source: hosted - version: "1.0.11" + path: "libs/windows/media_kit_libs_windows_video" + ref: HEAD + resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 + url: "https://github.com/media-kit/media-kit" + source: git + version: "1.0.12" media_kit_video: dependency: "direct main" description: - name: media_kit_video - sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d - url: "https://pub.dev" - source: hosted + path: media_kit_video + ref: HEAD + resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 + url: "https://github.com/media-kit/media-kit" + source: git version: "2.0.1" meta: dependency: transitive @@ -1246,10 +1270,10 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 + sha256: "9ac81c01ffd6a9cf9b2713288834fbaaf6aebde05ffdf02d380e68b8e06f27d8" url: "https://pub.dev" source: hosted - version: "1.6.0" + version: "1.7.0" quiver: dependency: transitive description: @@ -1318,18 +1342,18 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + sha256: "593034a16e82953ca31145db6c85174286262715f9c5e6a9d8c65ffd9fcdce09" url: "https://pub.dev" source: hosted - version: "2.4.28" + version: "2.5.3" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + sha256: c2c02dd59bd7b3bb6bfb77aa7ad4a7886b24ee40e95dcaea754b83274b567ff5 url: "https://pub.dev" source: hosted - version: "2.5.7" + version: "2.6.0" shared_preferences_linux: dependency: transitive description: @@ -1366,10 +1390,10 @@ packages: dependency: transitive description: name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + sha256: e75e9cf311d7fd712048038cc17c25199c38536c20330d53e7db058b543d5a48 url: "https://pub.dev" source: hosted - version: "1.4.2" + version: "1.4.3" shelf_web_socket: dependency: transitive description: @@ -1387,18 +1411,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "11b6047da8e4eb6c643ccac3d0fda3f9c9e11651695f7da24a85ade8aff15a44" + sha256: "833ea7d15b5be79949d3d6c077da0267a0bd93c16897b36a771395a91e63086e" url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.3.1" source_helper: dependency: transitive description: name: source_helper - sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c" + sha256: "279126bce72de3f2b7d4756408a4157988cf6bb4815be551bf55851aa00e9984" url: "https://pub.dev" source: hosted - version: "1.3.13" + version: "1.4.0" source_span: dependency: transitive description: @@ -1459,10 +1483,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "397b146f97613b83d84bdccb439bc8d0f3aebb6c1d14a9641e6f24201c490b2d" + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 url: "https://pub.dev" source: hosted - version: "3.4.2" + version: "3.5.0" term_glyph: dependency: transitive description: @@ -1645,10 +1669,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + sha256: "0964703d02677ac8636b4aef95de0b7e816224ff0082908c2c950529cb9a2f5e" url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.2.4" vector_graphics_codec: dependency: transitive description: @@ -1661,10 +1685,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + sha256: a8e2c19796144da2f81bcc96dad1bb19c4677b561c452081b97ee737b9faed65 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.2" vector_math: dependency: transitive description: @@ -1677,10 +1701,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" url: "https://pub.dev" source: hosted - version: "15.3.0" + version: "15.4.0" wakelock_plus: dependency: transitive description: @@ -1709,10 +1733,10 @@ packages: dependency: transitive description: name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" web_socket: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 11c4808..970b920 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,39 @@ dependency_overrides: window_manager: git: url: https://github.com/leanflutter/window_manager + # media_kit's main, for fixes it hasn't released yet, like Linux raster thread blocking + media_kit: + git: + url: https://github.com/media-kit/media-kit + path: media_kit + media_kit_video: + git: + url: https://github.com/media-kit/media-kit + path: media_kit_video + media_kit_libs_video: + git: + url: https://github.com/media-kit/media-kit + path: libs/universal/media_kit_libs_video + media_kit_libs_android_video: + git: + url: https://github.com/media-kit/media-kit + path: libs/android/media_kit_libs_android_video + media_kit_libs_ios_video: + git: + url: https://github.com/media-kit/media-kit + path: libs/ios/media_kit_libs_ios_video + media_kit_libs_linux: + git: + url: https://github.com/media-kit/media-kit + path: libs/linux/media_kit_libs_linux + media_kit_libs_macos_video: + git: + url: https://github.com/media-kit/media-kit + path: libs/macos/media_kit_libs_macos_video + media_kit_libs_windows_video: + git: + url: https://github.com/media-kit/media-kit + path: libs/windows/media_kit_libs_windows_video dependencies: flutter: diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 68413e7..3f39d22 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -14,6 +14,7 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_local_notifications_windows + jni ) set(PLUGIN_BUNDLED_LIBRARIES) From ee36a9b1c8c522e3277acd4afb6dc8bf4f39a17d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 00:36:31 -0400 Subject: [PATCH 02/27] Revert "use media_kit from main" This reverts commit f5371382d703c87118d0b7cb261c4c5c31ff95a7. --- linux/flutter/generated_plugins.cmake | 1 - linux/nix/devshell.nix | 1 - linux/nix/pkg/default.nix | 8 - pubspec.lock | 198 +++++++++++------------- pubspec.yaml | 33 ---- windows/flutter/generated_plugins.cmake | 1 - 6 files changed, 87 insertions(+), 155 deletions(-) diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 89cfca2..66d7f77 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -13,7 +13,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index f4bf1c6..a47f824 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -10,7 +10,6 @@ let cmakeVersions = [ "3.22.1" ]; platformVersions = [ "34" - "35" "36" ]; abiVersions = [ diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 6ee606e..47bf2d7 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -47,14 +47,6 @@ flutter.buildFlutterApplication { linkify = "sha256-TpMD6+0zyY6i9l+6d8ErnVufmepCv362rCtnbOht/z4="; fab_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; m3e_design = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; - media_kit = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; - media_kit_libs_android_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; - media_kit_libs_ios_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; - media_kit_libs_linux = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; - media_kit_libs_macos_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; - media_kit_libs_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; - media_kit_libs_windows_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; - media_kit_video = "sha256-twu/4NxTc9RumYh8R0sIfSoo24c/OZtiwXa0fWkxWmg="; native_toolchain_cmake = "sha256-/drnHZebmTnsCnHgC1xmlAe+U4PSrjw5Sm9axicuTrY="; navigation_rail_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; unifiedpush_linux = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4="; diff --git a/pubspec.lock b/pubspec.lock index 034e164..670b2ca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "9bfb8122e2505aca17219018593c384a87166cfe860d11c7ae927e7f978eb05f" + sha256: "48e48f73a431eb269eec66d243a63a07ed90ef9417a74047a3b98d52338626ad" url: "https://pub.dev" source: hosted - version: "110.0.0" + version: "109.0.0" analysis_server_plugin: dependency: transitive description: name: analysis_server_plugin - sha256: b94419fc81828edb7870340cb64b8648e9515613d7647309d3d0ea605c6d1f95 + sha256: "9f293f95cf41cb513a9a4f322354e2fa5483125e0f18bbeca254543ac9c3dda6" url: "https://pub.dev" source: hosted - version: "0.3.25" + version: "0.3.24" analyzer: dependency: transitive description: name: analyzer - sha256: "74c36179c4de542efea24c9dff78324375e4b118a2c1e14e8a7659f457947f8d" + sha256: "6d377901d198536b648172974d9d4f277910502cad92193466869cac0ff34990" url: "https://pub.dev" source: hosted - version: "14.6.0" + version: "14.5.0" analyzer_buffer: dependency: transitive description: @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: analyzer_plugin - sha256: d218f0916ff564cfd9dd032d11e5a990059cb66f88cc741ec6e5e55abdebefcc + sha256: "2c59e6fba4971dc9ece97acd55b4f95b67a98e6ae0a55bff6f9dfc2bb59e8b1a" url: "https://pub.dev" source: hosted - version: "0.14.19" + version: "0.14.18" app_links: dependency: "direct main" description: @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: archive - sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" + sha256: f773153144ce0456ae77d96bd247f2efd58efe4f3e5ad42616d0385c031aea11 url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.1" args: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: build_daemon - sha256: "39ab65f7c60c19af64b9ef96e6a5d3ab48355f7dddfd98a9079d55a8cb6628fc" + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 url: "https://pub.dev" source: hosted - version: "4.1.7" + version: "4.1.6" build_runner: dependency: "direct dev" description: @@ -293,10 +293,10 @@ packages: dependency: transitive description: name: dart_style - sha256: a0e4f65339b31eb6a9e6a12e14b4682a48d46fbc54a82f4a702396fbdbac63a7 + sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" url: "https://pub.dev" source: hosted - version: "3.1.14" + version: "3.1.13" dbus: dependency: transitive description: @@ -414,10 +414,10 @@ packages: dependency: transitive description: name: file_selector_ios - sha256: "5b42ec0ef7626124b4624ea244341e9df780406793c204e89fa68a2e71af115c" + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" url: "https://pub.dev" source: hosted - version: "0.5.3+7" + version: "0.5.3+6" file_selector_linux: dependency: transitive description: @@ -430,10 +430,10 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 url: "https://pub.dev" source: hosted - version: "0.9.5+2" + version: "0.9.5+1" file_selector_platform_interface: dependency: transitive description: @@ -531,10 +531,10 @@ packages: dependency: transitive description: name: flutter_local_notifications_linux - sha256: "3b89a4bf095fa5f8a99beeabf982c5bd506c3ad25e7b9e61da945e994dc5b615" + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" url: "https://pub.dev" source: hosted - version: "8.0.2" + version: "8.0.1" flutter_local_notifications_platform_interface: dependency: transitive description: @@ -690,10 +690,10 @@ packages: dependency: transitive description: name: http_multi_server - sha256: "361668bdd71f0ae2dc6d17b75a593520f3c414af6534f9eed1aa72049dd71d18" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.2.2" http_parser: dependency: transitive description: @@ -746,10 +746,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "57255225702ace8c81b8d92c1de6a04b60ee9befa6dab242b10a7612116886fd" + sha256: "4fb88d4350ffc208c2a09fc321db1f80282688716b8cfe10c8c80acc5ae8934a" url: "https://pub.dev" source: hosted - version: "0.8.13+13" + version: "0.8.13+10" image_picker_linux: dependency: transitive description: @@ -798,22 +798,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - jni: - dependency: transitive - description: - name: jni - sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - jni_util: - dependency: transitive - description: - name: jni_util - sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" - url: "https://pub.dev" - source: hosted - version: "1.0.0" js: dependency: transitive description: @@ -963,74 +947,66 @@ packages: media_kit: dependency: "direct main" description: - path: media_kit - ref: HEAD - resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 - url: "https://github.com/media-kit/media-kit" - source: git + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted version: "1.2.6" media_kit_libs_android_video: - dependency: "direct overridden" + dependency: transitive description: - path: "libs/android/media_kit_libs_android_video" - ref: HEAD - resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 - url: "https://github.com/media-kit/media-kit" - source: git + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted version: "1.3.8" media_kit_libs_ios_video: - dependency: "direct overridden" + dependency: transitive description: - path: "libs/ios/media_kit_libs_ios_video" - ref: HEAD - resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 - url: "https://github.com/media-kit/media-kit" - source: git - version: "1.1.5" + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" media_kit_libs_linux: - dependency: "direct overridden" + dependency: transitive description: - path: "libs/linux/media_kit_libs_linux" - ref: HEAD - resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 - url: "https://github.com/media-kit/media-kit" - source: git + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted version: "1.2.1" media_kit_libs_macos_video: - dependency: "direct overridden" + dependency: transitive description: - path: "libs/macos/media_kit_libs_macos_video" - ref: HEAD - resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 - url: "https://github.com/media-kit/media-kit" - source: git - version: "1.1.5" + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" media_kit_libs_video: dependency: "direct main" description: - path: "libs/universal/media_kit_libs_video" - ref: HEAD - resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 - url: "https://github.com/media-kit/media-kit" - source: git - version: "1.0.8" + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" media_kit_libs_windows_video: - dependency: "direct overridden" + dependency: transitive description: - path: "libs/windows/media_kit_libs_windows_video" - ref: HEAD - resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 - url: "https://github.com/media-kit/media-kit" - source: git - version: "1.0.12" + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" media_kit_video: dependency: "direct main" description: - path: media_kit_video - ref: HEAD - resolved-ref: c533e446755f51cf53c7e57aea873f2aa5355f81 - url: "https://github.com/media-kit/media-kit" - source: git + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted version: "2.0.1" meta: dependency: transitive @@ -1270,10 +1246,10 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: "9ac81c01ffd6a9cf9b2713288834fbaaf6aebde05ffdf02d380e68b8e06f27d8" + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.6.0" quiver: dependency: transitive description: @@ -1342,18 +1318,18 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "593034a16e82953ca31145db6c85174286262715f9c5e6a9d8c65ffd9fcdce09" + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.4.28" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: c2c02dd59bd7b3bb6bfb77aa7ad4a7886b24ee40e95dcaea754b83274b567ff5 + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.5.7" shared_preferences_linux: dependency: transitive description: @@ -1390,10 +1366,10 @@ packages: dependency: transitive description: name: shelf - sha256: e75e9cf311d7fd712048038cc17c25199c38536c20330d53e7db058b543d5a48 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.3" + version: "1.4.2" shelf_web_socket: dependency: transitive description: @@ -1411,18 +1387,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "833ea7d15b5be79949d3d6c077da0267a0bd93c16897b36a771395a91e63086e" + sha256: "11b6047da8e4eb6c643ccac3d0fda3f9c9e11651695f7da24a85ade8aff15a44" url: "https://pub.dev" source: hosted - version: "4.3.1" + version: "4.3.0" source_helper: dependency: transitive description: name: source_helper - sha256: "279126bce72de3f2b7d4756408a4157988cf6bb4815be551bf55851aa00e9984" + sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.3.13" source_span: dependency: transitive description: @@ -1483,10 +1459,10 @@ packages: dependency: transitive description: name: synchronized - sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 + sha256: "397b146f97613b83d84bdccb439bc8d0f3aebb6c1d14a9641e6f24201c490b2d" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "3.4.2" term_glyph: dependency: transitive description: @@ -1669,10 +1645,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "0964703d02677ac8636b4aef95de0b7e816224ff0082908c2c950529cb9a2f5e" + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" url: "https://pub.dev" source: hosted - version: "1.2.4" + version: "1.2.3" vector_graphics_codec: dependency: transitive description: @@ -1685,10 +1661,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: a8e2c19796144da2f81bcc96dad1bb19c4677b561c452081b97ee737b9faed65 + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.0" vector_math: dependency: transitive description: @@ -1701,10 +1677,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.4.0" + version: "15.3.0" wakelock_plus: dependency: transitive description: @@ -1733,10 +1709,10 @@ packages: dependency: transitive description: name: web - sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.1" web_socket: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 970b920..11c4808 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,39 +55,6 @@ dependency_overrides: window_manager: git: url: https://github.com/leanflutter/window_manager - # media_kit's main, for fixes it hasn't released yet, like Linux raster thread blocking - media_kit: - git: - url: https://github.com/media-kit/media-kit - path: media_kit - media_kit_video: - git: - url: https://github.com/media-kit/media-kit - path: media_kit_video - media_kit_libs_video: - git: - url: https://github.com/media-kit/media-kit - path: libs/universal/media_kit_libs_video - media_kit_libs_android_video: - git: - url: https://github.com/media-kit/media-kit - path: libs/android/media_kit_libs_android_video - media_kit_libs_ios_video: - git: - url: https://github.com/media-kit/media-kit - path: libs/ios/media_kit_libs_ios_video - media_kit_libs_linux: - git: - url: https://github.com/media-kit/media-kit - path: libs/linux/media_kit_libs_linux - media_kit_libs_macos_video: - git: - url: https://github.com/media-kit/media-kit - path: libs/macos/media_kit_libs_macos_video - media_kit_libs_windows_video: - git: - url: https://github.com/media-kit/media-kit - path: libs/windows/media_kit_libs_windows_video dependencies: flutter: diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 3f39d22..68413e7 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -14,7 +14,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_local_notifications_windows - jni ) set(PLUGIN_BUNDLED_LIBRARIES) From c0f4c053b00543a5498bd8fdfe772445daa46f07 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 01:10:01 -0400 Subject: [PATCH 03/27] Make displayname colors based off of power level Fixes #128 --- lib/helpers/extensions/displayname_color.dart | 13 ++++ .../lazy_loading/message_displayname.dart | 73 ++++++++++--------- lib/widgets/member_list.dart | 70 +++++++++--------- lib/widgets/renderers/membership.dart | 12 ++- 4 files changed, 94 insertions(+), 74 deletions(-) create mode 100644 lib/helpers/extensions/displayname_color.dart diff --git a/lib/helpers/extensions/displayname_color.dart b/lib/helpers/extensions/displayname_color.dart new file mode 100644 index 0000000..6eab735 --- /dev/null +++ b/lib/helpers/extensions/displayname_color.dart @@ -0,0 +1,13 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/room_power_levels.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; + +extension DisplaynameColor on WidgetRef { + /// The color of [userId]'s name in [roomId], hashed from their power level, + /// so everyone with the same level shares one. + Color displaynameColor(String roomId, String userId) => watch( + RoomPowerLevelsController.provider(roomId) + .select((levels) => levels.levelOf(userId)), + ).toString().colorHash; +} diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index e8e4791..5d3bf19 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -3,7 +3,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; 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/helpers/extensions/string_to_color.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/content/membership.dart"; @@ -15,43 +15,44 @@ class const MessageDisplayname( super.key, }) extends ConsumerWidget { @override - Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( - AuthorController.provider(event), - )) { - AsyncData(:final MembershipContent? value) || - AsyncLoading(:final value) || - AsyncError(:final value) => InkWell( - onTap: clickable - ? () => context.showUserPopover(event.sender, roomId: event.roomId) - : null, - child: switch ([ - Text( - value?.displayName ?? event.sender.localpart, - style: - style ?? .new(color: event.sender.colorHash, fontWeight: .bold), - maxLines: 1, - overflow: .ellipsis, - ), - if (event.pmp != null) + Widget build(BuildContext context, WidgetRef ref) { + final color = ref.displaynameColor(event.roomId, event.sender); + + return switch (ref.watch(AuthorController.provider(event))) { + AsyncData(:final MembershipContent? value) || + AsyncLoading(:final value) || + AsyncError(:final value) => InkWell( + onTap: clickable + ? () => context.showUserPopover(event.sender, roomId: event.roomId) + : null, + child: switch ([ Text( - "(via ${event.sender})", - style: Theme.of(context).textTheme.labelSmall - ?.copyWith(color: event.sender.colorHash, fontWeight: .bold), + value?.displayName ?? event.sender.localpart, + style: style ?? .new(color: color, fontWeight: .bold), maxLines: 1, overflow: .ellipsis, ), - ]) { - final children when shouldWrap => Wrap( - spacing: 4, - crossAxisAlignment: .center, - children: children, - ), - final children => Row( - spacing: 4, - mainAxisSize: .min, - children: [for (final child in children) Flexible(child: child)], - ), - }, - ), - }; + if (event.pmp != null) + Text( + "(via ${event.sender})", + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: color, fontWeight: .bold), + maxLines: 1, + overflow: .ellipsis, + ), + ]) { + final children when shouldWrap => Wrap( + spacing: 4, + crossAxisAlignment: .center, + children: children, + ), + final children => Row( + spacing: 4, + mainAxisSize: .min, + children: [for (final child in children) Flexible(child: child)], + ), + }, + ), + }; + } } diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index 731270d..02cc0c6 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -8,7 +8,7 @@ import "package:nexus/controllers/members_by_status.dart"; import "package:nexus/controllers/members_grouped.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/helpers/extensions/string_to_color.dart"; +import "package:nexus/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"; @@ -108,42 +108,44 @@ class const MemberList(final String roomId, {super.key}) :final avatarUrl, :final displayName, ) => - Builder( - builder: (context) => ListTile( - onTap: () => - context.showUserPopover( + Consumer( + builder: (context, ref, _) => + ListTile( + onTap: () => + context.showUserPopover( + members[index].stateKey!, + roomId: roomId, + ), + title: Text( + displayName ?? + members[index] + .stateKey! + .localpart, + overflow: .ellipsis, + style: .new( + color: ref.displaynameColor( + roomId, + members[index].stateKey!, + ), + fontWeight: .bold, + ), + ), + subtitle: Text( members[index].stateKey!, - roomId: roomId, + overflow: .ellipsis, + ), + leading: Transform.translate( + offset: .new(0, 2), + child: AvatarOrHash( + avatarUrl, + dimension: 40, + displayName ?? + members[index] + .stateKey! + .localpart, + ), ), - title: Text( - displayName ?? - members[index] - .stateKey! - .localpart, - overflow: .ellipsis, - style: .new( - color: members[index] - .stateKey! - .colorHash, - fontWeight: .bold, ), - ), - subtitle: Text( - members[index].stateKey!, - overflow: .ellipsis, - ), - leading: Transform.translate( - offset: .new(0, 2), - child: AvatarOrHash( - avatarUrl, - dimension: 40, - displayName ?? - members[index] - .stateKey! - .localpart, - ), - ), - ), ), _ => throw Exception( "Member content was not MembershipContent", diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index b1811f0..a2cd8cc 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -1,7 +1,8 @@ import "package:material_ui/material_ui.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/helpers/extensions/string_to_color.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; @@ -11,9 +12,9 @@ class const MembershipRenderer( final Event event, { final int? maxLines, super.key, -}) extends StatelessWidget { +}) extends ConsumerWidget { @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { assert( event.content is MembershipContent, "Make sure to only pass membership events to MembershipRenderer", @@ -40,7 +41,10 @@ class const MembershipRenderer( overflow: .ellipsis, content.displayName ?? event.stateKey!.localpart, maxLines: 1, - style: .new(color: event.sender.colorHash, fontWeight: .bold), + style: .new( + color: ref.displaynameColor(event.roomId, event.stateKey!), + fontWeight: .bold, + ), ), ), ), From e5dd53ed6cd9de4faf01136d278796ea98580768 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 10:23:49 -0400 Subject: [PATCH 04/27] tweak color picking algorithm for power levels, and introduce setting for per PL or per user colors --- lib/controllers/settings_sections.dart | 21 ++++++++++++++ lib/helpers/extensions/displayname_color.dart | 28 +++++++++++++++---- lib/models/name_color.dart | 2 ++ lib/models/settings.dart | 2 ++ 4 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 lib/models/name_color.dart diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 2cd804c..5ca7231 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -19,6 +19,7 @@ import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/helpers/extensions/distributor_name.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/client_state.dart"; +import "package:nexus/models/name_color.dart"; import "package:nexus/models/room_order.dart"; import "package:nexus/models/settings.dart"; import "package:nexus/models/settings_category.dart"; @@ -83,6 +84,26 @@ class SettingsSectionsController .onError(showError), ), ), + .new( + title: "Name Colors", + description: "Color names by power level, so moderators and admins stand out, or by user, so everyone has their own.", + icon: Icons.format_color_text, + builder: (title, description, icon) => DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: settings.nameColor, + options: NameColor.values, + getName: (option) => switch (option) { + .powerLevel => "By power level", + .user => "By user", + }, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(nameColor: value)) + .onError(showError), + ), + ), .new( title: "Use Dynamic Theme", icon: Icons.palette, diff --git a/lib/helpers/extensions/displayname_color.dart b/lib/helpers/extensions/displayname_color.dart index 6eab735..5b431e6 100644 --- a/lib/helpers/extensions/displayname_color.dart +++ b/lib/helpers/extensions/displayname_color.dart @@ -1,13 +1,29 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/room_power_levels.dart"; +import "package:nexus/controllers/settings.dart"; import "package:nexus/helpers/extensions/string_to_color.dart"; extension DisplaynameColor on WidgetRef { - /// The color of [userId]'s name in [roomId], hashed from their power level, - /// so everyone with the same level shares one. - Color displaynameColor(String roomId, String userId) => watch( - RoomPowerLevelsController.provider(roomId) - .select((levels) => levels.levelOf(userId)), - ).toString().colorHash; + /// The color of [userId]'s name in [roomId], from their power level + /// (so everyone with the same level shares one) or their user ID, depending + /// on the user's settings. + Color displaynameColor(String roomId, String userId) => switch (watch( + SettingsController.provider.select((settings) => settings.value?.nameColor), + )) { + .user => userId.colorHash, + .powerLevel || null => powerLevelColor( + watch( + RoomPowerLevelsController.provider(roomId) + .select((levels) => levels.levelOf(userId)), + ), + ), + }; + + static Color powerLevelColor(double level) => HSLColor.fromAHSL( + 1, + level.isInfinite ? 30 : (210 + level / 50 * 137.50776) % 360, + 0.7, + 0.5, + ).toColor(); } diff --git a/lib/models/name_color.dart b/lib/models/name_color.dart new file mode 100644 index 0000000..f67429e --- /dev/null +++ b/lib/models/name_color.dart @@ -0,0 +1,2 @@ +/// What users' name colors are hashed from. +enum NameColor { powerLevel, user } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 7ea61d9..9b26f14 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,5 +1,6 @@ import "package:material_ui/material_ui.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/name_color.dart"; import "package:nexus/models/room_order.dart"; import "package:nexus/models/show_and_send.dart"; @@ -17,6 +18,7 @@ class const Settings({ final bool swipeToReply = false, final bool hideMembershipEvents = false, final RoomOrder roomOrder = .unreadFirst, + final NameColor nameColor = .powerLevel, final ShowAndSend typingIndicators = .showAndSend, /// When not sending, read receipts are still sent privately, so that the From 4e140e9fc7ea7750610a9af0ecba3774b9a67899 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 11:28:44 -0400 Subject: [PATCH 05/27] add pinned event and server ACL diffing --- lib/helpers/extensions/success_scheme.dart | 11 ++ lib/widgets/renderers/event.dart | 74 +++++++++--- lib/widgets/renderers/generic_event.dart | 81 +++++++++---- lib/widgets/renderers/pinned_events_diff.dart | 110 ++++++++++++++++++ lib/widgets/renderers/server_acl_diff.dart | 98 ++++++++++++++++ lib/widgets/user_bottom_sheet.dart | 12 +- 6 files changed, 340 insertions(+), 46 deletions(-) create mode 100644 lib/helpers/extensions/success_scheme.dart create mode 100644 lib/widgets/renderers/pinned_events_diff.dart create mode 100644 lib/widgets/renderers/server_acl_diff.dart diff --git a/lib/helpers/extensions/success_scheme.dart b/lib/helpers/extensions/success_scheme.dart new file mode 100644 index 0000000..77f83ae --- /dev/null +++ b/lib/helpers/extensions/success_scheme.dart @@ -0,0 +1,11 @@ +import "package:dynamic_color/dynamic_color.dart"; +import "package:material_ui/material_ui.dart"; + +extension SuccessScheme on ThemeData { + /// Green colors for positive things, like accepting knocks or new pins, + /// harmonized so they fit the current theme. + ColorScheme get successScheme => ColorScheme.fromSeed( + seedColor: Colors.green.harmonizeWith(colorScheme.primary), + brightness: brightness, + ); +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 47750fa..b007862 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -29,6 +29,8 @@ import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/read_receipts_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/generic_event.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/timestamp.dart"; @@ -103,14 +105,23 @@ class const EventRenderer( event.sender == previousEvent?.sender && event.pmp?.id == previousEvent?.pmp?.id; - GenericEventRenderer generic(IconData icon, String text) => - .new(icon, maxLines: maxLines, [ - WidgetSpan( - alignment: .middle, - child: MessageDisplayname(event, shouldWrap: maxLines == null), - ), - TextSpan(text: text), - ]); + GenericEventRenderer generic( + IconData icon, + String text, { + List details = const [], + }) => .new( + icon, + maxLines: maxLines, + details: details, + trailing: Timestamp(event.timestamp), + [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname(event, shouldWrap: maxLines == null), + ), + TextSpan(text: text), + ], + ); final child = event.isShownAsMessage ? MessageRenderer( @@ -177,13 +188,47 @@ class const EventRenderer( .worldReadable => "all history visible (world readable)", }}", ), - PinnedEventsContent() => generic( - Icons.push_pin, - "pinned/unpinned some events", - ), - ServerACLContent() => generic( + PinnedEventsContent(:final pinnedEvents) => () { + final before = switch (event.previousContent) { + PinnedEventsContent(:final pinnedEvents) => pinnedEvents, + _ => const IList.empty(), + }; + final pinned = pinnedEvents + .where((id) => !before.contains(id)) + .toIList(); + final unpinned = before + .where((id) => !pinnedEvents.contains(id)) + .toIList(); + + return generic( + Icons.push_pin, + switch ((pinned.length, unpinned.length)) { + (1, 0) => "pinned a message", + (0, 1) => "unpinned a message", + _ => "changed the pinned messages", + }, + details: [ + PinnedEventsDiff( + roomId, + pinned: pinned, + unpinned: unpinned, + jumpToEvent: jumpToEvent, + ), + ], + ); + }(), + final ServerACLContent content => generic( Icons.list, "updated the server ban list", + details: [ + ServerAclDiff( + content, + previous: switch (event.previousContent) { + final ServerACLContent previous => previous, + _ => null, + }, + ), + ], ), CanonicalAliasContent(:final alias, :final altAliases) => generic( Icons.numbers, @@ -270,7 +315,8 @@ class const EventRenderer( crossAxisAlignment: .center, children: [ child, - if (child is! MessageRenderer) + if (child is! MessageRenderer && + child is! GenericEventRenderer) Timestamp(event.timestamp), ], ), diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart index 101913c..3922aed 100644 --- a/lib/widgets/renderers/generic_event.dart +++ b/lib/widgets/renderers/generic_event.dart @@ -5,33 +5,68 @@ class const GenericEventRenderer( final IconData icon, final List children, { final int? maxLines, + + /// Shown at the end of the text, like the event's timestamp. + final Widget? trailing, + + /// Shown under the text, like what changed in a state event. + final List details = const [], super.key, }) extends StatelessWidget { @override - Widget build(BuildContext context) => Padding( - padding: .symmetric(vertical: 4), - child: Row( - spacing: 8, - mainAxisSize: .min, - children: [ - Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)), - Flexible( - child: Text.rich( - TextSpan( - children: children - .expandIndexed( - (index, child) => [ - if (index > 0) TextSpan(text: " "), - child, + Widget build(BuildContext context) { + // Only previewing the event, so it should stay short + final previewing = maxLines != null; + final iconSize = IconTheme.of(context).size ?? 24; + + return Padding( + padding: .symmetric(vertical: 4), + child: Row( + spacing: 8, + mainAxisSize: .min, + // At the top, by the text, rather than the middle of any details + crossAxisAlignment: .start, + children: [ + Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)), + Flexible( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + spacing: 4, + children: [ + // At least as tall as the icon, so they're centered together + ConstrainedBox( + constraints: .new(minHeight: iconSize), + child: Row( + spacing: 8, + mainAxisSize: .min, + children: [ + Flexible( + child: Text.rich( + TextSpan( + children: children + .expandIndexed( + (index, child) => [ + if (index > 0) TextSpan(text: " "), + child, + ], + ) + .toList(), + ), + maxLines: maxLines ?? 999, + overflow: .ellipsis, + ), + ), + if (!previewing) ?trailing, ], - ) - .toList(), + ), + ), + if (!previewing) ...details, + ], ), - maxLines: maxLines ?? 999, - overflow: .ellipsis, ), - ), - ], - ), - ); + ], + ), + ); + } } diff --git a/lib/widgets/renderers/pinned_events_diff.dart b/lib/widgets/renderers/pinned_events_diff.dart new file mode 100644 index 0000000..2aa1a3d --- /dev/null +++ b/lib/widgets/renderers/pinned_events_diff.dart @@ -0,0 +1,110 @@ +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:nexus/controllers/event.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +/// A line for each message a pinned events event pinned, in green, and each one +/// it unpinned, in red and struck through. +class const PinnedEventsDiff( + final String roomId, { + required final IList pinned, + required final IList unpinned, + + /// If set, tapping a message jumps to it. + final JumpToEvent? jumpToEvent, + super.key, +}) extends ConsumerWidget { + static const maxShown = 5; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final style = theme.textTheme.labelMedium; + final changes = [ + ...pinned.map((eventId) => (eventId, unpinned: false)), + ...unpinned.map((eventId) => (eventId, unpinned: true)), + ]; + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + ...changes.take(maxShown).map((change) { + final color = change.unpinned + ? theme.colorScheme.error + : theme.successScheme.primary; + final decoration = change.unpinned + ? TextDecoration.lineThrough + : null; + + return DefaultTextStyle.merge( + style: style?.copyWith( + color: color, + decoration: decoration, + decorationColor: color, + ), + child: switch (ref.watch( + EventController.provider( + .new(roomId: roomId, eventId: change.$1), + ), + )) { + AsyncData(value: final event?) => InkWell( + borderRadius: .circular(4), + hoverColor: color.withValues(alpha: 0.12), + onTap: jumpToEvent == null + ? null + : () => jumpToEvent!(event.rowId), + child: Row( + mainAxisSize: .min, + children: [ + // Other events already say who sent them + if (event.content is MessageContent) ...[ + MessageDisplayname( + event, + clickable: false, + shouldWrap: false, + style: .new( + color: color, + fontWeight: .bold, + decoration: decoration, + decorationColor: color, + ), + ), + Text(": "), + ], + Flexible( + child: IgnorePointer( + child: EventRenderer( + event.rowId, + roomId: event.roomId, + textOnly: true, + maxLines: 1, + ), + ), + ), + ], + ), + ), + AsyncLoading() => SizedBox.square( + dimension: 12, + child: CircularProgressIndicator(strokeWidth: 2, color: color), + ), + // Like if it was deleted, or the server won't share it + _ => Text("Message not found"), + }, + ); + }), + if (changes.length > maxShown) + Text( + "+${changes.length - maxShown} more", + style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/server_acl_diff.dart b/lib/widgets/renderers/server_acl_diff.dart new file mode 100644 index 0000000..a61e868 --- /dev/null +++ b/lib/widgets/renderers/server_acl_diff.dart @@ -0,0 +1,98 @@ +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"; + +/// The servers newly denied (in red) and allowed (in green) by a server ACL +/// event, and those no longer denied or allowed, struck through. +class const ServerAclDiff( + final ServerACLContent content, { + final ServerACLContent? previous, + super.key, +}) extends StatelessWidget { + static const maxShown = 5; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = theme.textTheme.labelMedium; + final colorScheme = theme.colorScheme; + + final ipLiteralsAllowed = switch (previous) { + final previous? + when previous.allowIpLiterals != content.allowIpLiterals => + content.allowIpLiterals == true, + _ => null, + }; + + Widget? listDiff( + String label, + Color color, + IList before, + IList after, { + required bool ipLiterals, + }) { + final beforeSet = before.toISet(); + final afterSet = after.toISet(); + final servers = [ + ...after + .where((server) => !beforeSet.contains(server)) + .map((server) => (server, removed: false)), + ...before + .where((server) => !afterSet.contains(server)) + .map((server) => (server, removed: true)), + ]; + if (servers.isEmpty && !ipLiterals) return null; + + return Wrap( + spacing: 8, + runSpacing: 2, + children: [ + Text(label, style: style?.copyWith(fontWeight: .bold)), + ...servers + .take(maxShown) + .map( + (server) => Text( + "\"${server.$1}\"", + style: style?.copyWith( + color: color, + decoration: server.removed ? .lineThrough : null, + ), + ), + ), + if (servers.length > maxShown) + Text( + "+${servers.length - maxShown} more", + style: style?.copyWith(color: colorScheme.onSurfaceVariant), + ), + if (ipLiterals) + Text( + "servers using IP addresses", + style: style?.copyWith(color: color), + ), + ], + ); + } + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + ?listDiff( + "Denied", + colorScheme.error, + previous?.deny ?? const .empty(), + content.deny, + ipLiterals: ipLiteralsAllowed == false, + ), + ?listDiff( + "Allowed", + theme.successScheme.primary, + previous?.allow ?? const .empty(), + content.allow, + ipLiterals: ipLiteralsAllowed == true, + ), + ], + ); + } +} diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 58e29d2..1e21581 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -1,6 +1,5 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:dynamic_color/dynamic_color.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/widgets/sheet_layout.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -17,6 +16,7 @@ import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/create_room.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; @@ -80,12 +80,6 @@ final class const UserBottomSheet( ), ); - // Green for accepting knocks, harmonized so it fits the current theme - final acceptScheme = ColorScheme.fromSeed( - seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary), - brightness: theme.brightness, - ); - void showMembershipSheet(MembershipAction action) => context.showAdaptiveSheet( (context) => HookBuilder( @@ -407,8 +401,8 @@ final class const UserBottomSheet( icon: Icon(Icons.check), label: Text("Accept knock"), decoration: .styleFrom( - backgroundColor: acceptScheme.primary, - foregroundColor: acceptScheme.onPrimary, + backgroundColor: theme.successScheme.primary, + foregroundColor: theme.successScheme.onPrimary, ), ), ), From 6e2d56ec75e8e01bf2c1b117409bf8116b082fd7 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 12:01:38 -0400 Subject: [PATCH 06/27] add diffing for a lot of events --- lib/controllers/room_settings_sections.dart | 139 +++++----------- lib/helpers/extensions/displayname_color.dart | 9 ++ lib/helpers/extensions/join_rule_name.dart | 13 ++ lib/models/power_level_permission.dart | 115 ++++++++++++++ lib/widgets/renderers/change_line.dart | 72 +++++++++ lib/widgets/renderers/event.dart | 148 ++++++++++++++---- lib/widgets/renderers/join_rules_diff.dart | 72 +++++++++ lib/widgets/renderers/power_levels_diff.dart | 141 +++++++++++++++++ lib/widgets/renderers/server_acl_diff.dart | 50 ++---- 9 files changed, 591 insertions(+), 168 deletions(-) create mode 100644 lib/helpers/extensions/join_rule_name.dart create mode 100644 lib/models/power_level_permission.dart create mode 100644 lib/widgets/renderers/change_line.dart create mode 100644 lib/widgets/renderers/join_rules_diff.dart create mode 100644 lib/widgets/renderers/power_levels_diff.dart diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index 583f0f9..42da22c 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -11,6 +11,7 @@ import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/join_rule_name.dart"; import "package:nexus/helpers/file_dialog_helper.dart"; import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/main.dart"; @@ -27,6 +28,7 @@ import "package:nexus/models/content/name.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/topic.dart"; 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"; @@ -288,22 +290,29 @@ class RoomSettingsSectionsController(final String roomId) ), ); - /// A setting for the level needed to send [type], which otherwise needs [fallback]. - Setting eventSetting( - EventType type, { - required String title, - required String description, - required IconData icon, - required int fallback, - }) => powerLevelSetting( - title: title, - description: description, - icon: icon, - value: powerLevels.events[type.type] ?? fallback, - update: (level) => powerLevels.copyWith( - events: powerLevels.events.add(type.type, level), - ), - ); + Setting permissionSetting(PowerLevelPermission permission) => + powerLevelSetting( + title: permission.title, + description: permission.description, + icon: permission.icon, + value: permission.levelIn(powerLevels), + update: (level) => permission.withLevel(powerLevels, level), + ); + + /// A setting for the level needed to send [event], which otherwise needs [fallback]. + Setting eventSetting(PowerLevelEvent event, {required int fallback}) { + final type = event == .message ? messageType : event.types.first; + + return powerLevelSetting( + title: event.title, + description: event.description, + icon: event.icon, + value: powerLevels.events[type.type] ?? fallback, + update: (level) => powerLevels.copyWith( + events: powerLevels.events.add(type.type, level), + ), + ); + } return .new({ "Room": .new([ @@ -431,14 +440,7 @@ class RoomSettingsSectionsController(final String roomId) subtitle: Text(description), value: joinRules.joinRule, options: joinRuleOptions, - getName: (rule) => switch (rule) { - .public => "Public", - .invite => "Invite only", - .knock => "Knock", - .restricted => "Restricted", - .knockRestricted => "Restricted or knock", - .private => "Private", - }, + getName: (rule) => rule.displayName, onChanged: canSetJoinRules ? (rule) => setState( .joinRules, @@ -615,13 +617,7 @@ class RoomSettingsSectionsController(final String roomId) title: "Permissions", icon: Icons.admin_panel_settings, settings: .new([ - powerLevelSetting( - title: "Default Power Level", - description: "The power level of users who aren't given one.", - icon: Icons.person, - value: powerLevels.usersDefault, - update: (level) => powerLevels.copyWith(usersDefault: level), - ), + permissionSetting(.usersDefault), header("Users", "Power levels of specific users.", Icons.group), ...users.map((entry) { final member = memberOf(entry.key); @@ -687,82 +683,21 @@ class RoomSettingsSectionsController(final String roomId) "Power levels needed to send messages and other timeline events.", Icons.chat, ), - eventSetting( - messageType, - title: "Send Messages", - description: "The power level needed to send messages.", - icon: Icons.message, - fallback: powerLevels.eventsDefault, - ), - eventSetting( - .sticker, - title: "Send Stickers", - description: "The power level needed to send stickers.", - icon: Icons.sticky_note_2, - fallback: powerLevels.eventsDefault, - ), - eventSetting( - .reaction, - title: "Send Reactions", - description: "The power level needed to react to messages.", - icon: Icons.add_reaction, - fallback: powerLevels.eventsDefault, - ), - powerLevelSetting( - title: "Default for Events", - description: - "The power level needed to send events without their own.", - icon: Icons.more_horiz, - value: powerLevels.eventsDefault, - update: (level) => powerLevels.copyWith(eventsDefault: level), - ), + eventSetting(.message, fallback: powerLevels.eventsDefault), + eventSetting(.sticker, fallback: powerLevels.eventsDefault), + eventSetting(.reaction, fallback: powerLevels.eventsDefault), + permissionSetting(.eventsDefault), header( "State Events", "Power levels needed to manage members and change the room's state.", Icons.settings, ), - eventSetting( - .pinnedEvents, - title: "Pin Messages", - description: "The power level needed to pin messages.", - icon: Icons.push_pin, - fallback: powerLevels.stateDefault, - ), - powerLevelSetting( - title: "Invite Users", - description: "The power level needed to invite users.", - icon: Icons.person_add, - value: powerLevels.invite, - update: (level) => powerLevels.copyWith(invite: level), - ), - powerLevelSetting( - title: "Kick Users", - description: "The power level needed to kick users.", - icon: Icons.sports_martial_arts, - value: powerLevels.kick, - update: (level) => powerLevels.copyWith(kick: level), - ), - powerLevelSetting( - title: "Ban Users", - description: "The power level needed to ban and unban users.", - icon: Icons.gavel, - value: powerLevels.ban, - update: (level) => powerLevels.copyWith(ban: level), - ), - powerLevelSetting( - title: "Delete Others' Messages", - description: "The power level needed to remove others' messages.", - icon: Icons.delete, - value: powerLevels.redact, - update: (level) => powerLevels.copyWith(redact: level), - ), - powerLevelSetting( - title: "Default for State Events", - description: "The power level needed to send state events without their own.", - icon: Icons.tune, - value: powerLevels.stateDefault, - update: (level) => powerLevels.copyWith(stateDefault: level), - ), + eventSetting(.pinnedEvents, fallback: powerLevels.stateDefault), + permissionSetting(.invite), + permissionSetting(.kick), + permissionSetting(.ban), + permissionSetting(.redact), + permissionSetting(.stateDefault), header( "Other Events", "Power levels for specific event types.", diff --git a/lib/helpers/extensions/displayname_color.dart b/lib/helpers/extensions/displayname_color.dart index 5b431e6..eb81d16 100644 --- a/lib/helpers/extensions/displayname_color.dart +++ b/lib/helpers/extensions/displayname_color.dart @@ -20,6 +20,15 @@ extension DisplaynameColor on WidgetRef { ), }; + /// Like [displaynameColor], for when [userId]'s [level] is already known, + /// like a level they're being given. + Color displaynameColorAt(String userId, double level) => switch (watch( + SettingsController.provider.select((settings) => settings.value?.nameColor), + )) { + .user => userId.colorHash, + .powerLevel || null => powerLevelColor(level), + }; + static Color powerLevelColor(double level) => HSLColor.fromAHSL( 1, level.isInfinite ? 30 : (210 + level / 50 * 137.50776) % 360, diff --git a/lib/helpers/extensions/join_rule_name.dart b/lib/helpers/extensions/join_rule_name.dart new file mode 100644 index 0000000..d2cf4b5 --- /dev/null +++ b/lib/helpers/extensions/join_rule_name.dart @@ -0,0 +1,13 @@ +import "package:nexus/models/join_rule.dart"; + +extension JoinRuleName on JoinRule { + /// What the rule is called in the UI, e.g. "Invite only". + String get displayName => switch (this) { + .public => "Public", + .invite => "Invite only", + .knock => "Knock", + .restricted => "Restricted", + .knockRestricted => "Restricted or knock", + .private => "Private", + }; +} diff --git a/lib/models/power_level_permission.dart b/lib/models/power_level_permission.dart new file mode 100644 index 0000000..8abff82 --- /dev/null +++ b/lib/models/power_level_permission.dart @@ -0,0 +1,115 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/power_levels.dart"; + +enum PowerLevelPermission( + final String title, + final String description, + final IconData icon, +) { + usersDefault( + "Default Power Level", + "The power level of users who aren't given one.", + Icons.person, + ), + eventsDefault( + "Default for Events", + "The power level needed to send events without their own.", + Icons.more_horiz, + ), + stateDefault( + "Default for State Events", + "The power level needed to send state events without their own.", + Icons.tune, + ), + invite( + "Invite Users", + "The power level needed to invite users.", + Icons.person_add, + ), + kick( + "Kick Users", + "The power level needed to kick users.", + Icons.sports_martial_arts, + ), + ban( + "Ban Users", + "The power level needed to ban and unban users.", + Icons.gavel, + ), + redact( + "Delete Others' Messages", + "The power level needed to remove others' messages.", + Icons.delete, + ), + notifyRoom( + "Notify Everyone", + "The power level needed to notify everyone in the room with @room.", + Icons.campaign, + ); + + int levelIn(PowerLevelsContent content) => switch (this) { + .usersDefault => content.usersDefault, + .eventsDefault => content.eventsDefault, + .stateDefault => content.stateDefault, + .invite => content.invite, + .kick => content.kick, + .ban => content.ban, + .redact => content.redact, + .notifyRoom => content.notifications?.room ?? 50, + }; + + PowerLevelsContent withLevel(PowerLevelsContent content, int level) => + switch (this) { + .usersDefault => content.copyWith(usersDefault: level), + .eventsDefault => content.copyWith(eventsDefault: level), + .stateDefault => content.copyWith(stateDefault: level), + .invite => content.copyWith(invite: level), + .kick => content.copyWith(kick: level), + .ban => content.copyWith(ban: level), + .redact => content.copyWith(redact: level), + .notifyRoom => content.copyWith( + notifications: (content.notifications ?? const Notifications()) + .copyWith(room: level), + ), + }; +} + +enum PowerLevelEvent( + final String title, + final String description, + final IconData icon, +) { + message( + "Send Messages", + "The power level needed to send messages.", + Icons.message, + ), + sticker( + "Send Stickers", + "The power level needed to send stickers.", + Icons.sticky_note_2, + ), + reaction( + "Send Reactions", + "The power level needed to react to messages.", + Icons.add_reaction, + ), + pinnedEvents( + "Pin Messages", + "The power level needed to pin messages.", + Icons.push_pin, + ); + + IList get types => .new(switch (this) { + .message => [.message, .encrypted], + .sticker => [.sticker], + .reaction => [.reaction], + .pinnedEvents => [.pinnedEvents], + }); + + static PowerLevelEvent? of(String type) => values + .where((event) => event.types.any((eventType) => eventType.type == type)) + .firstOrNull; +} diff --git a/lib/widgets/renderers/change_line.dart b/lib/widgets/renderers/change_line.dart new file mode 100644 index 0000000..7195882 --- /dev/null +++ b/lib/widgets/renderers/change_line.dart @@ -0,0 +1,72 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; + +/// A line of what a state event changed, like a room's old name, struck +/// through, then its new one. +class const ChangeLine({ + /// What changed, e.g. `Denied`, in bold before the items. + final String? label, + final IList removed = const .empty(), + final IList added = const .empty(), + required final Color removedColor, + required final Color addedColor, + + /// Whether to quote the items, like server names, but not whole sentences. + final bool quoted = true, + + /// Shown after the items, like a change that isn't one of them. + final List trailing = const [], + + /// How many removed and how many added items to show, as some events, like + /// a room's first server ACL, can change hundreds. + final int maxShown = 5, + + /// The most lines each item can take, as some, like topics, can be long. + final int maxLines = 2, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = theme.textTheme.labelMedium; + + Iterable items( + IList items, { + required Color color, + required bool struck, + }) => [ + ...items + .take(maxShown) + .map( + (item) => Text( + quoted ? "\"$item\"" : item, + maxLines: maxLines, + overflow: .ellipsis, + style: style?.copyWith( + color: color, + decoration: struck ? .lineThrough : null, + decorationColor: color, + ), + ), + ), + if (items.length > maxShown) + Text( + "+${items.length - maxShown} more", + style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ]; + + return Wrap( + spacing: 8, + runSpacing: 2, + crossAxisAlignment: .center, + children: [ + if (label case final label?) + Text(label, style: style?.copyWith(fontWeight: .bold)), + ...items(removed, color: removedColor, struck: true), + ...items(added, color: addedColor, struck: false), + ...trailing, + ], + ); + } +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index b007862..87637c9 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -9,14 +9,17 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/helpers/extensions/is_shown_as_message.dart"; import "package:nexus/helpers/extensions/show_adaptive_menu.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/name.dart"; import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/server_acl.dart"; @@ -28,7 +31,10 @@ 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/renderers/membership.dart"; +import "package:nexus/widgets/renderers/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"; @@ -105,6 +111,10 @@ class const EventRenderer( event.sender == previousEvent?.sender && event.pmp?.id == previousEvent?.pmp?.id; + // For what a state event changed, from and to + final removedColor = colorScheme.error; + final addedColor = theme.successScheme.primary; + GenericEventRenderer generic( IconData icon, String text, { @@ -167,18 +177,57 @@ class const EventRenderer( "changed the room avatar", ), CreateContent() => generic(Icons.add, "created the room"), - PowerLevelsContent() => generic( + final PowerLevelsContent content => generic( Icons.power, "changed the room's power levels", + details: [ + PowerLevelsDiff( + roomId, + content, + previous: switch (event.previousContent) { + final PowerLevelsContent previous => previous, + _ => null, + }, + ), + ], ), - JoinRulesContent() => generic( + final JoinRulesContent content => generic( Icons.rule, "changed the room's join rules", + details: [ + JoinRulesDiff( + content, + previous: switch (event.previousContent) { + final JoinRulesContent previous => previous, + _ => null, + }, + ), + ], ), - TopicContent() => generic( - Icons.description, - "updated the room topic", - ), + TopicContent(:final topic) => () { + final previous = switch (event.previousContent) { + TopicContent(:final topic) when topic.isNotEmpty => topic, + _ => null, + }; + + return generic( + Icons.description, + topic.isEmpty + ? "removed the room topic" + : previous == null + ? "set the room topic" + : "changed the room topic", + details: [ + ChangeLine( + removed: [?previous].lock, + added: [if (topic.isNotEmpty) topic].lock, + removedColor: removedColor, + addedColor: addedColor, + quoted: false, + ), + ], + ); + }(), HistoryVisibilityContent(:final historyVisibility) => generic( Icons.history, "changed the room's history visibility to ${switch (historyVisibility) { @@ -230,32 +279,71 @@ class const EventRenderer( ), ], ), - CanonicalAliasContent(:final alias, :final altAliases) => generic( - Icons.numbers, - switch ([ - if (event.previousContent case CanonicalAliasContent( - alias: final prevAlias, - altAliases: final prevAltAliases, - )) ...[ - if (prevAlias != alias) - if (alias == null) - "removed the room's canonical alias" - else - "changed the room's canonical alias to $alias", + CanonicalAliasContent(:final alias, :final altAliases) => () { + final previous = switch (event.previousContent) { + final CanonicalAliasContent previous => previous, + _ => null, + }; + final previousAltAliases = + previous?.altAliases ?? const ISet.empty(); - if (prevAltAliases - .remove(alias ?? "") - .remove(prevAlias ?? "") != - altAliases.remove(alias ?? "").remove(prevAlias ?? "")) - "changed the room's aliases", - ] else ...[ - if (alias != null) "set the room's canonical alias", - if (altAliases.isNotEmpty) "set the room's aliases", + return generic( + Icons.numbers, + previous == null + ? "set the room's aliases" + : "changed the room's aliases", + details: [ + if (previous?.alias != alias) + ChangeLine( + label: "Canonical alias", + removed: [?previous?.alias].lock, + added: [?alias].lock, + removedColor: removedColor, + addedColor: addedColor, + ), + if (previousAltAliases != altAliases) + ChangeLine( + label: "Aliases", + removed: previousAltAliases + .difference(altAliases) + .toIList(), + added: altAliases + .difference(previousAltAliases) + .toIList(), + removedColor: removedColor, + addedColor: addedColor, + ), ], - ]) { - [] => "did something related to room aliases", - List prev => prev.join(" and "), - }, + ); + }(), + NameContent(:final name) => () { + final previous = switch (event.previousContent) { + NameContent(:final name) when name.isNotEmpty => name, + _ => null, + }; + + return generic( + Icons.title, + name.isEmpty + ? "removed the room name" + : previous == null + ? "set the room name" + : "changed the room name", + details: [ + ChangeLine( + removed: [?previous].lock, + added: [if (name.isNotEmpty) name].lock, + removedColor: removedColor, + addedColor: addedColor, + ), + ], + ); + }(), + EncryptionContent() => generic( + Icons.lock, + event.previousContent is EncryptionContent + ? "changed the room's encryption settings" + : "enabled encryption", ), _ => null, }; diff --git a/lib/widgets/renderers/join_rules_diff.dart b/lib/widgets/renderers/join_rules_diff.dart new file mode 100644 index 0000000..6d41c0b --- /dev/null +++ b/lib/widgets/renderers/join_rules_diff.dart @@ -0,0 +1,72 @@ +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: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"; + +/// The join rule a join rules event changed to, and for restricted rooms, the +/// rooms whose members it let join, or stopped letting join. +class const JoinRulesDiff( + final JoinRulesContent content, { + final JoinRulesContent? previous, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final removedColor = theme.colorScheme.error; + final addedColor = theme.successScheme.primary; + + ISet roomIdsOf(JoinRulesContent? content) => + (content?.allow ?? const ISet.empty()) + .map((condition) => condition.roomId) + .nonNulls + .toISet(); + final before = roomIdsOf(previous); + final after = roomIdsOf(content); + final removed = before.difference(after); + final added = after.difference(before); + + // Rooms the user isn't in are shown by ID + final names = ref.watch( + RoomsController.provider.select( + (rooms) => IMap.fromEntries( + removed + .addAll(added) + .map( + (roomId) => + MapEntry(roomId, rooms[roomId]?.metadata?.name ?? roomId), + ), + ), + ), + ); + IList named(ISet roomIds) => + roomIds.map((roomId) => names[roomId] ?? roomId).toIList(); + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + if (previous?.joinRule != content.joinRule) + ChangeLine( + removed: [?previous?.joinRule.displayName].lock, + added: [content.joinRule.displayName].lock, + removedColor: removedColor, + addedColor: addedColor, + quoted: false, + ), + if (removed.isNotEmpty || added.isNotEmpty) + ChangeLine( + label: "Members of", + removed: named(removed), + added: named(added), + removedColor: removedColor, + addedColor: addedColor, + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/power_levels_diff.dart b/lib/widgets/renderers/power_levels_diff.dart new file mode 100644 index 0000000..f90c917 --- /dev/null +++ b/lib/widgets/renderers/power_levels_diff.dart @@ -0,0 +1,141 @@ +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:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/power_level_permission.dart"; + +/// Whose power levels, and which permissions' levels, a power levels event +/// changed, in green when raised, and red when lowered. +class const PowerLevelsDiff( + final String roomId, + final PowerLevelsContent content, { + final PowerLevelsContent? previous, + super.key, +}) extends ConsumerWidget { + static const maxShown = 5; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final style = theme.textTheme.labelMedium; + final before = previous ?? PowerLevelsContent(); + + Text levels(int? from, int? to, {int? fromLevel, int? toLevel}) => Text( + "${from ?? "Default"} → ${to ?? "Default"}", + style: style?.copyWith( + color: switch ((fromLevel ?? from, toLevel ?? to)) { + (final from?, final to?) when to > from => + theme.successScheme.primary, + (final from?, final to?) when to < from => theme.colorScheme.error, + _ => theme.colorScheme.onSurfaceVariant, + }, + ), + ); + + Iterable capped(Iterable lines) { + final list = lines.toList(); + return [ + ...list.take(maxShown), + if (list.length > maxShown) + Text( + "+${list.length - maxShown} more", + style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ]; + } + + final users = before.users.keys + .toISet() + .addAll(content.users.keys) + .map( + (userId) => ( + userId, + from: before.users[userId], + to: content.users[userId], + fromLevel: before.users[userId] ?? before.usersDefault, + toLevel: content.users[userId] ?? content.usersDefault, + ), + ) + .where((change) => change.from != change.to) + // The most powerful first + .toIList() + .sort((a, b) => b.toLevel.compareTo(a.toLevel)); + + final permissions = <(String, int?, int?)>[ + ...PowerLevelPermission.values.map( + (permission) => ( + permission.title, + permission.levelIn(before), + permission.levelIn(content), + ), + ), + ...before.events.keys + .toISet() + .addAll(content.events.keys) + .map( + (type) => ( + PowerLevelEvent.of(type)?.title ?? type, + before.events[type], + content.events[type], + ), + ), + ].where((permission) => permission.$2 != permission.$3); + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + ...capped( + users.map( + (change) => Wrap( + spacing: 8, + crossAxisAlignment: .center, + children: [ + Text( + ref + .watch( + UserController.provider( + .new(roomId: roomId, userId: change.$1), + ), + ) + .value + ?.displayName ?? + change.$1.localpart, + style: style?.copyWith( + fontWeight: .bold, + color: ref.displaynameColorAt( + change.$1, + change.toLevel.toDouble(), + ), + ), + ), + levels( + change.from, + change.to, + fromLevel: change.fromLevel, + toLevel: change.toLevel, + ), + ], + ), + ), + ), + ...capped( + permissions.map( + (permission) => Wrap( + spacing: 8, + crossAxisAlignment: .center, + children: [ + Text(permission.$1, style: style?.copyWith(fontWeight: .bold)), + levels(permission.$2, permission.$3), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/server_acl_diff.dart b/lib/widgets/renderers/server_acl_diff.dart index a61e868..e232614 100644 --- a/lib/widgets/renderers/server_acl_diff.dart +++ b/lib/widgets/renderers/server_acl_diff.dart @@ -2,6 +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"; /// The servers newly denied (in red) and allowed (in green) by a server ACL /// event, and those no longer denied or allowed, struck through. @@ -10,13 +11,9 @@ class const ServerAclDiff( final ServerACLContent? previous, super.key, }) extends StatelessWidget { - static const maxShown = 5; - @override Widget build(BuildContext context) { final theme = Theme.of(context); - final style = theme.textTheme.labelMedium; - final colorScheme = theme.colorScheme; final ipLiteralsAllowed = switch (previous) { final previous? @@ -32,43 +29,24 @@ class const ServerAclDiff( IList after, { required bool ipLiterals, }) { + // Sets, as an ACL can list hundreds of servers final beforeSet = before.toISet(); final afterSet = after.toISet(); - final servers = [ - ...after - .where((server) => !beforeSet.contains(server)) - .map((server) => (server, removed: false)), - ...before - .where((server) => !afterSet.contains(server)) - .map((server) => (server, removed: true)), - ]; - if (servers.isEmpty && !ipLiterals) return null; + final removed = before.where((server) => !afterSet.contains(server)); + final added = after.where((server) => !beforeSet.contains(server)); + if (removed.isEmpty && added.isEmpty && !ipLiterals) return null; - return Wrap( - spacing: 8, - runSpacing: 2, - children: [ - Text(label, style: style?.copyWith(fontWeight: .bold)), - ...servers - .take(maxShown) - .map( - (server) => Text( - "\"${server.$1}\"", - style: style?.copyWith( - color: color, - decoration: server.removed ? .lineThrough : null, - ), - ), - ), - if (servers.length > maxShown) - Text( - "+${servers.length - maxShown} more", - style: style?.copyWith(color: colorScheme.onSurfaceVariant), - ), + return ChangeLine( + label: label, + removed: .new(removed), + added: .new(added), + removedColor: color, + addedColor: color, + trailing: [ if (ipLiterals) Text( "servers using IP addresses", - style: style?.copyWith(color: color), + style: theme.textTheme.labelMedium?.copyWith(color: color), ), ], ); @@ -80,7 +58,7 @@ class const ServerAclDiff( children: [ ?listDiff( "Denied", - colorScheme.error, + theme.colorScheme.error, previous?.deny ?? const .empty(), content.deny, ipLiterals: ipLiteralsAllowed == false, From 3a39f1c5ac2aecd61f4f8b6d95dee60616edccff Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 12:12:20 -0400 Subject: [PATCH 07/27] more intuitive permissions in encrypted rooms --- lib/controllers/room_settings_sections.dart | 45 +++++++++------------ lib/models/power_level_permission.dart | 29 ++++++++----- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index 42da22c..8ce08f8 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -158,10 +158,8 @@ class RoomSettingsSectionsController(final String roomId) ClientStateController.provider.select((state) => state?.userId), ); final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId); - // Encrypted rooms send messages as m.room.encrypted, so that's what limits them - final messageType = stateContent(.encryption) is EncryptionContent - ? EventType.encrypted - : EventType.message; + final encrypted = stateContent(.encryption) is EncryptionContent; + final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted); Future setState(EventType type, Content content) async { final eventId = await client.setState( @@ -220,12 +218,7 @@ class RoomSettingsSectionsController(final String roomId) .run(() => setState(.powerLevels, content).onError(showError)); // Event types with their own level that don't have a setting of their own - final listedEvents = [ - messageType, - EventType.sticker, - EventType.reaction, - EventType.pinnedEvents, - ].map((type) => type.type).toISet(); + final listedEvents = shownEvents.map((event) => event.type.type).toISet(); final otherEvents = powerLevels.events.entries .where((entry) => !listedEvents.contains(entry.key)) .toIList() @@ -300,19 +293,16 @@ class RoomSettingsSectionsController(final String roomId) ); /// A setting for the level needed to send [event], which otherwise needs [fallback]. - Setting eventSetting(PowerLevelEvent event, {required int fallback}) { - final type = event == .message ? messageType : event.types.first; - - return powerLevelSetting( - title: event.title, - description: event.description, - icon: event.icon, - value: powerLevels.events[type.type] ?? fallback, - update: (level) => powerLevels.copyWith( - events: powerLevels.events.add(type.type, level), - ), - ); - } + Setting eventSetting(PowerLevelEvent event, {required int fallback}) => + powerLevelSetting( + title: event.title, + description: event.description, + icon: event.icon, + value: powerLevels.events[event.type.type] ?? fallback, + update: (level) => powerLevels.copyWith( + events: powerLevels.events.add(event.type.type, level), + ), + ); return .new({ "Room": .new([ @@ -683,9 +673,12 @@ class RoomSettingsSectionsController(final String roomId) "Power levels needed to send messages and other timeline events.", Icons.chat, ), - eventSetting(.message, fallback: powerLevels.eventsDefault), - eventSetting(.sticker, fallback: powerLevels.eventsDefault), - eventSetting(.reaction, fallback: powerLevels.eventsDefault), + ...shownEvents + .where((event) => event != .pinnedEvents) + .map( + (event) => + eventSetting(event, fallback: powerLevels.eventsDefault), + ), permissionSetting(.eventsDefault), header( "State Events", diff --git a/lib/models/power_level_permission.dart b/lib/models/power_level_permission.dart index 8abff82..355623b 100644 --- a/lib/models/power_level_permission.dart +++ b/lib/models/power_level_permission.dart @@ -77,39 +77,50 @@ enum PowerLevelPermission( } enum PowerLevelEvent( + final EventType type, final String title, final String description, final IconData icon, ) { message( + .message, "Send Messages", "The power level needed to send messages.", Icons.message, ), + // Everything a client encrypts is sent as this, so its level is what limits + // messages, stickers, and the like in encrypted rooms + encrypted( + .encrypted, + "Send Encrypted Events", + "The power level needed to send messages, stickers, files and anything else encrypted.", + Icons.lock, + ), sticker( + .sticker, "Send Stickers", "The power level needed to send stickers.", Icons.sticky_note_2, ), reaction( + .reaction, "Send Reactions", "The power level needed to react to messages.", Icons.add_reaction, ), pinnedEvents( + .pinnedEvents, "Pin Messages", "The power level needed to pin messages.", Icons.push_pin, ); - IList get types => .new(switch (this) { - .message => [.message, .encrypted], - .sticker => [.sticker], - .reaction => [.reaction], - .pinnedEvents => [.pinnedEvents], - }); + static IList shownIn({required bool encrypted}) => .new([ + if (encrypted) .encrypted else ...[.message, .sticker], + .reaction, + .pinnedEvents, + ]); - static PowerLevelEvent? of(String type) => values - .where((event) => event.types.any((eventType) => eventType.type == type)) - .firstOrNull; + static PowerLevelEvent? of(String type) => + values.where((event) => event.type.type == type).firstOrNull; } From 6336764da0323efda6198b481e80ffbfa5e5b63d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 12:24:22 -0400 Subject: [PATCH 08/27] add power level groups --- lib/controllers/room_settings_sections.dart | 87 +++++++++++++- lib/models/content/content.dart | 1 + lib/models/power_level_permission.dart | 123 +++++++++++++++++++- lib/widgets/settings/power_level_tile.dart | 16 ++- 4 files changed, 216 insertions(+), 11 deletions(-) diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index 8ce08f8..e6ea31e 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -218,7 +218,10 @@ class RoomSettingsSectionsController(final String roomId) .run(() => setState(.powerLevels, content).onError(showError)); // Event types with their own level that don't have a setting of their own - final listedEvents = shownEvents.map((event) => event.type.type).toISet(); + final listedEvents = shownEvents + .addAll(PowerLevelGroup.values.expand((group) => group.events)) + .map((event) => event.type.type) + .toISet(); final otherEvents = powerLevels.events.entries .where((entry) => !listedEvents.contains(entry.key)) .toIList() @@ -304,6 +307,76 @@ class RoomSettingsSectionsController(final String roomId) ), ); + Widget tileOf(Setting setting) => + setting.builder(setting.title, setting.description, setting.icon); + + /// A setting for all of [group]'s levels at once, which expands to set each. + Setting groupSetting(PowerLevelGroup group) => .new( + title: group.title, + description: group.description, + icon: group.icon, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final expanded = useState(false); + + return Column( + mainAxisSize: .min, + children: [ + PowerLevelTile( + leading: Icon(icon), + title: title, + description: description, + value: group.levelIn(powerLevels), + ownLevel: ownLevel, + // Changing it would change levels above the user's own + onChanged: + canSetPowerLevels && + group.highestIn(powerLevels) <= ownLevel + ? (level) => + setPowerLevels(group.withLevel(powerLevels, level)) + : null, + trailing: IconButton( + tooltip: expanded.value ? "Collapse" : "Set each", + icon: AnimatedRotation( + turns: expanded.value ? 0.5 : 0, + duration: Durations.medium2, + child: Icon(Icons.expand_more), + ), + onPressed: () => expanded.value = !expanded.value, + ), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: expanded.value + ? Padding( + padding: .only(left: 16), + child: Column( + mainAxisSize: .min, + children: [ + ...group.permissions + .map(permissionSetting) + .map(tileOf), + ...group.events + .map( + (event) => eventSetting( + event, + fallback: powerLevels.stateDefault, + ), + ) + .map(tileOf), + ], + ), + ) + : const SizedBox(width: .infinity), + ), + ], + ); + }, + ), + ); + return .new({ "Room": .new([ .new( @@ -668,6 +741,12 @@ class RoomSettingsSectionsController(final String roomId) ), ), ), + header( + "Groups", + "Power levels set together, which can each be set on their own.", + Icons.workspaces, + ), + ...PowerLevelGroup.values.map(groupSetting), header( "Regular Events", "Power levels needed to send messages and other timeline events.", @@ -682,14 +761,10 @@ class RoomSettingsSectionsController(final String roomId) permissionSetting(.eventsDefault), header( "State Events", - "Power levels needed to manage members and change the room's state.", + "Power levels needed to change the room's state.", Icons.settings, ), - eventSetting(.pinnedEvents, fallback: powerLevels.stateDefault), permissionSetting(.invite), - permissionSetting(.kick), - permissionSetting(.ban), - permissionSetting(.redact), permissionSetting(.stateDefault), header( "Other Events", diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 37a87b7..e6061d1 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -58,6 +58,7 @@ enum EventType( avatar("m.room.avatar", AvatarContent.fromJson), topic("m.room.topic", TopicContent.fromJson), name("m.room.name", NameContent.fromJson), + tombstone("m.room.tombstone", Content.fromJson), reaction("m.reaction", ReactionContent.fromJson), pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), message("m.room.message", MessageContent.fromJson), diff --git a/lib/models/power_level_permission.dart b/lib/models/power_level_permission.dart index 355623b..8c26ee2 100644 --- a/lib/models/power_level_permission.dart +++ b/lib/models/power_level_permission.dart @@ -1,3 +1,5 @@ +import "dart:math"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/models/content/content.dart"; @@ -113,14 +115,133 @@ enum PowerLevelEvent( "Pin Messages", "The power level needed to pin messages.", Icons.push_pin, + ), + name( + .name, + "Change Name", + "The power level needed to change the room's name.", + Icons.title, + ), + topic( + .topic, + "Change Topic", + "The power level needed to change the room's topic.", + Icons.description, + ), + avatar( + .avatar, + "Change Avatar", + "The power level needed to change the room's avatar.", + Icons.image, + ), + canonicalAlias( + .canonicalAlias, + "Change Aliases", + "The power level needed to change the room's canonical alias and published aliases.", + Icons.numbers, + ), + powerLevels( + .powerLevels, + "Change Power Levels", + "The power level needed to change power levels, including these.", + Icons.admin_panel_settings, + ), + joinRules( + .joinRules, + "Change Join Rule", + "The power level needed to change who can join the room.", + Icons.login, + ), + historyVisibility( + .historyVisibility, + "Change History Visibility", + "The power level needed to change who can read the room's history.", + Icons.history, + ), + serverACL( + .serverACL, + "Change Server ACL", + "The power level needed to change which servers can take part in the room.", + Icons.dns, + ), + encryption( + .encryption, + "Enable Encryption", + "The power level needed to enable encryption, which can't be turned off.", + Icons.lock, + ), + tombstone( + .tombstone, + "Upgrade Room", + "The power level needed to replace the room with an upgraded one.", + Icons.upgrade, ); static IList shownIn({required bool encrypted}) => .new([ if (encrypted) .encrypted else ...[.message, .sticker], .reaction, - .pinnedEvents, ]); static PowerLevelEvent? of(String type) => values.where((event) => event.type.type == type).firstOrNull; } + +/// Power levels set together, like everything moderators need, to save +/// setting each one. Each can still be set on its own, by expanding it. +enum PowerLevelGroup(final String title, final IconData icon) { + moderation("Moderation", Icons.shield), + roomDetails("Room Details", Icons.edit), + administration("Administration", Icons.admin_panel_settings); + + IList get permissions => .new(switch (this) { + .moderation => [.kick, .ban, .redact, .notifyRoom], + .roomDetails || .administration => [], + }); + + IList get events => .new(switch (this) { + .moderation => [], + .roomDetails => [.name, .topic, .avatar, .canonicalAlias, .pinnedEvents], + .administration => [ + .powerLevels, + .joinRules, + .historyVisibility, + .serverACL, + .encryption, + .tombstone, + ], + }); + + String get description => [ + ...permissions.map((permission) => permission.title), + ...events.map((event) => event.title), + ].join(", "); + + int? levelIn(PowerLevelsContent content) { + final levels = { + ...permissions.map((permission) => permission.levelIn(content)), + ...events.map( + (event) => content.events[event.type.type] ?? content.stateDefault, + ), + }; + return levels.length == 1 ? levels.single : null; + } + + int highestIn(PowerLevelsContent content) => [ + ...permissions.map((permission) => permission.levelIn(content)), + ...events.map( + (event) => content.events[event.type.type] ?? content.stateDefault, + ), + ].reduce(max); + + PowerLevelsContent withLevel(PowerLevelsContent content, int level) => + permissions + .fold( + content, + (content, permission) => permission.withLevel(content, level), + ) + .copyWith( + events: content.events.addEntries( + events.map((event) => MapEntry(event.type.type, level)), + ), + ); +} diff --git a/lib/widgets/settings/power_level_tile.dart b/lib/widgets/settings/power_level_tile.dart index 5b7d4e6..a553c62 100644 --- a/lib/widgets/settings/power_level_tile.dart +++ b/lib/widgets/settings/power_level_tile.dart @@ -9,7 +9,9 @@ class const PowerLevelTile({ required final Widget leading, required final String title, required final String? description, - required final int value, + + /// Null for several levels that differ, shown as custom. + required final int? value, /// The user's own power level, which they can't set or change levels above. required final double ownLevel, @@ -22,11 +24,14 @@ class const PowerLevelTile({ /// If given, asked before removing the level. final String? removeConfirmation, + + /// Shown after the level, like a button to expand what it sets. + final Widget? trailing, super.key, }) extends StatelessWidget { @override Widget build(BuildContext context) { - final enabled = onChanged != null && value <= ownLevel; + final enabled = onChanged != null && (value ?? 0) <= ownLevel; Future remove() async { if (removeConfirmation case final confirmation?) { @@ -65,7 +70,7 @@ class const PowerLevelTile({ children: [ Chip( label: Text( - "$value", + value?.toString() ?? "Custom", style: enabled ? null : .new(color: Colors.grey), ), ), @@ -75,12 +80,15 @@ class const PowerLevelTile({ icon: Icon(Icons.delete), onPressed: enabled ? remove : null, ), + ?trailing, ], ), onTap: () => context.showAdaptiveSheet( (context) => HookBuilder( builder: (context) { - final controller = useTextEditingController(text: "$value"); + final controller = useTextEditingController( + text: value?.toString(), + ); final level = PowerLevelField.levelFrom( useValueListenable(controller).text, ownLevel, From 0743c4cfc608309e0a6f594df785bcbc0e257e0e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 14:26:26 -0400 Subject: [PATCH 09/27] reorder appearance settings --- lib/controllers/settings_sections.dart | 40 +++++++++++++------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 5ca7231..6fa42dc 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -84,26 +84,6 @@ class SettingsSectionsController .onError(showError), ), ), - .new( - title: "Name Colors", - description: "Color names by power level, so moderators and admins stand out, or by user, so everyone has their own.", - icon: Icons.format_color_text, - builder: (title, description, icon) => DialogListTile( - icon: Icon(icon), - title: title, - subtitle: Text(description), - value: settings.nameColor, - options: NameColor.values, - getName: (option) => switch (option) { - .powerLevel => "By power level", - .user => "By user", - }, - onChanged: (value) => ref - .read(SettingsController.provider.notifier) - .set(settings.copyWith(nameColor: value)) - .onError(showError), - ), - ), .new( title: "Use Dynamic Theme", icon: Icons.palette, @@ -125,6 +105,26 @@ class SettingsSectionsController : null, ), ), + .new( + title: "Name Colors", + description: "Color names by power level, so moderators and admins stand out, or by user, so everyone has their own.", + icon: Icons.format_color_text, + builder: (title, description, icon) => DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: settings.nameColor, + options: NameColor.values, + getName: (option) => switch (option) { + .powerLevel => "By power level", + .user => "By user", + }, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(nameColor: value)) + .onError(showError), + ), + ), ]), ), .new( From 9e6951818a20703c2595cb3b3efa2b48b64b46e3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 14:29:37 -0400 Subject: [PATCH 10/27] hide server ACL events from timeline by default, introduce setting to show them --- lib/controllers/settings_sections.dart | 15 +++++++++++++++ lib/models/settings.dart | 1 + lib/widgets/renderers/event.dart | 13 +++++++++---- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 6fa42dc..1a98a43 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -230,6 +230,21 @@ class SettingsSectionsController .onError(showError), ), ), + .new( + title: "Hide Server ACL Events", + description: "Hides server ACL (server ban list) changes from appearing in conversations.", + icon: Icons.dns, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.hideServerAclEvents, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(hideServerAclEvents: value)) + .onError(showError), + ), + ), ]), ), .new( diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 9b26f14..e3b95a2 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -17,6 +17,7 @@ class const Settings({ final bool showMinimizeButton = false, final bool swipeToReply = false, final bool hideMembershipEvents = false, + final bool hideServerAclEvents = true, final RoomOrder roomOrder = .unreadFirst, final NameColor nameColor = .powerLevel, final ShowAndSend typingIndicators = .showAndSend, diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 87637c9..6ed2ffd 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -90,12 +90,13 @@ class const EventRenderer( if (event == null) return SizedBox.shrink(); - final hideMembershipEvents = (ref + final (hideMembershipEvents, hideServerAclEvents) = ref .watch(SettingsController.provider) .maybeWhen( - orElse: () => false, - data: (settings) => settings.hideMembershipEvents, - )); + orElse: () => (false, true), + data: (settings) => + (settings.hideMembershipEvents, settings.hideServerAclEvents), + ); // Knocks and their outcomes stay visible, as moderators need to act on them if (hideMembershipEvents && @@ -106,6 +107,10 @@ class const EventRenderer( return SizedBox.shrink(); } + if (hideServerAclEvents && event.content is ServerACLContent) { + return SizedBox.shrink(); + } + final isGrouped = (previousEvent?.isShownAsMessage ?? false) && event.sender == previousEvent?.sender && From 868023a4a1cbf53eac8a76f5dc3efd6b532f5d19 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 17:02:41 -0400 Subject: [PATCH 11/27] avoid un-needed reloads for diffs --- lib/controllers/event.dart | 11 ++++++----- lib/widgets/renderers/pinned_events_diff.dart | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/controllers/event.dart b/lib/controllers/event.dart index a41d6b6..44a44f0 100644 --- a/lib/controllers/event.dart +++ b/lib/controllers/event.dart @@ -9,11 +9,12 @@ class EventController(final GetEventRequest request) extends AsyncNotifier { @override Future build() async { - final room = ref.watch( - RoomsController.provider.select((value) => value[request.roomId]), - ); - final event = room?.events.values.firstWhereOrNull( - (event) => event.eventId == request.eventId, + final event = ref.watch( + RoomsController.provider.select( + (rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull( + (event) => event.eventId == request.eventId, + ), + ), ); if (event != null) return event; diff --git a/lib/widgets/renderers/pinned_events_diff.dart b/lib/widgets/renderers/pinned_events_diff.dart index 2aa1a3d..9f3d34c 100644 --- a/lib/widgets/renderers/pinned_events_diff.dart +++ b/lib/widgets/renderers/pinned_events_diff.dart @@ -53,7 +53,8 @@ class const PinnedEventsDiff( .new(roomId: roomId, eventId: change.$1), ), )) { - AsyncData(value: final event?) => InkWell( + // Keeps showing the event while it reloads + AsyncValue(value: final event?) => InkWell( borderRadius: .circular(4), hoverColor: color.withValues(alpha: 0.12), onTap: jumpToEvent == null From 3754bc2feb8edef15213d216aed707e6b2b87038 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 18:16:45 -0400 Subject: [PATCH 12/27] allow sending emoji/stickers from emote packs --- lib/controllers/all_emoji.dart | 25 ++++ lib/controllers/client.dart | 21 +++- lib/controllers/image_pack_sources.dart | 112 ++++++++++++++++++ lib/controllers/image_packs.dart | 72 +++++++++++ lib/controllers/recent_emoji.dart | 8 +- lib/controllers/room_chat.dart | 43 ++++++- lib/controllers/rooms.dart | 52 ++++---- .../extensions/build_event_options.dart | 21 +++- lib/helpers/extensions/image_pack_emoji.dart | 29 +++++ lib/models/account_data.dart | 36 ++++++ lib/models/content/content.dart | 3 + lib/models/content/image_pack.dart | 68 +++++++++++ lib/models/content/reaction.dart | 4 + lib/models/image_pack.dart | 33 ++++++ .../requests/get_specific_room_state.dart | 29 +++++ lib/widgets/composer/composer.dart | 58 ++++++++- .../composer/overlays/emoji_overlay.dart | 12 +- lib/widgets/emoji_picker.dart | 76 +++++++----- lib/widgets/mxc_emoji.dart | 16 +++ lib/widgets/room_chat/room_chat.dart | 4 + lib/widgets/sticker_picker.dart | 47 ++++++++ pubspec.lock | 4 +- pubspec.yaml | 2 +- 23 files changed, 701 insertions(+), 74 deletions(-) create mode 100644 lib/controllers/all_emoji.dart create mode 100644 lib/controllers/image_pack_sources.dart create mode 100644 lib/controllers/image_packs.dart create mode 100644 lib/helpers/extensions/image_pack_emoji.dart create mode 100644 lib/models/content/image_pack.dart create mode 100644 lib/models/image_pack.dart create mode 100644 lib/models/requests/get_specific_room_state.dart create mode 100644 lib/widgets/mxc_emoji.dart create mode 100644 lib/widgets/sticker_picker.dart diff --git a/lib/controllers/all_emoji.dart b/lib/controllers/all_emoji.dart new file mode 100644 index 0000000..5703356 --- /dev/null +++ b/lib/controllers/all_emoji.dart @@ -0,0 +1,25 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; +import "package:nexus/controllers/image_packs.dart"; +import "package:nexus/helpers/extensions/image_pack_emoji.dart"; + +class AllEmojiController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final packs = await ref.watch(ImagePacksController.provider(roomId).future); + final unicode = await ref.watch(EmojiController.provider.future); + + return packs + .map((pack) => pack.emojiCategory) + .nonNulls + .toIList() + .addAll(unicode); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + AllEmojiController.new, + ); +} diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 569138f..3c16024 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -29,6 +29,7 @@ import "package:nexus/models/requests/get_event_context.dart"; import "package:nexus/models/requests/get_mentions.dart"; import "package:nexus/models/requests/get_related_events.dart"; import "package:nexus/models/requests/get_room_state.dart"; +import "package:nexus/models/requests/get_specific_room_state.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart"; import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/profile_response.dart"; @@ -185,11 +186,12 @@ class ClientController extends AsyncNotifier { ...json, "content": { ...content, - "m.relates_to": { - ...((content["m.relates_to"] as Map?) ?? {}), - "event_id": request.relatesTo, - "rel_type": request.relationType, - }, + if (request.relatesTo != null) + "m.relates_to": { + ...((content["m.relates_to"] as Map?) ?? {}), + "event_id": request.relatesTo, + "rel_type": request.relationType, + }, }, }), ); @@ -265,6 +267,15 @@ class ClientController extends AsyncNotifier { ); } + Future> getSpecificRoomState( + GetSpecificRoomStateRequest request, + ) async => .new( + ((await _sendCommand("get_specific_room_state", request.toJson())) + as List? ?? + []) + .map((event) => .fromJson(event)), + ); + Future?> getRelatedEvents( GetRelatedEventsRequest request, ) async { diff --git a/lib/controllers/image_pack_sources.dart b/lib/controllers/image_pack_sources.dart new file mode 100644 index 0000000..db704f3 --- /dev/null +++ b/lib/controllers/image_pack_sources.dart @@ -0,0 +1,112 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_edges.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/requests/get_specific_room_state.dart"; + +typedef ImagePackSource = ({String roomId, String? stateKey}); + +class ImagePackSourcesController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final enabledPacks = ref.watch( + AccountDataController.provider.select( + (data) => + (data.imagePackRooms ?? data.legacyImagePackRooms)?.rooms.map( + (roomId, packs) => .new(roomId, packs.keys.toISet()), + ) ?? + const .empty(), + ), + ); + final spaceEdges = ref.watch(SpaceEdgesController.provider); + final directParents = ref.watch( + RoomsController.provider.select( + (rooms) => + rooms[roomId]?.state["m.space.parent"]?.keys.toISet() ?? + const .empty(), + ), + ); + + Iterable parentsOf(String childId) => spaceEdges.entries + .where((entry) => entry.value.any((edge) => edge.childId == childId)) + .map((entry) => entry.key) + .followedBy(childId == roomId ? directParents : const []); + + ISet withAncestors(ISet spaces, String childId) => + parentsOf(childId).fold( + spaces, + (spaces, parentId) => spaces.contains(parentId) + ? spaces + : withAncestors(spaces.add(parentId), parentId), + ); + + final rooms = ref.read(RoomsController.provider); + final spaces = withAncestors( + const .empty(), + roomId, + ).remove(roomId).where(rooms.containsKey).toIList(); + + final client = ref.read(ClientController.provider.notifier); + final roomsNotifier = ref.read(RoomsController.provider.notifier); + + await Future.wait( + spaces + .where((spaceId) => !rooms[spaceId]!.hasFetchedState) + .map( + (spaceId) async => roomsNotifier.addState( + spaceId, + await client.getRoomState(.new(roomId: spaceId)), + ), + ), + ); + + const packTypes = [.imagePack, .legacyImagePack]; + // Rooms we aren't in can't be fetched, and fetched rooms have every pack + final missingKeys = enabledPacks.entries + .where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true)) + .expand( + (entry) => entry.value + .where( + (stateKey) => packTypes.every( + (type) => + rooms[entry.key]?.state[type.type]?[stateKey] == null, + ), + ) + .expand( + (stateKey) => packTypes.map( + (type) => RoomStateKey( + roomId: entry.key, + type: type.type, + stateKey: stateKey, + ), + ), + ), + ) + .toIList(); + + if (missingKeys.isNotEmpty) { + roomsNotifier.addSpecificState( + await client.getSpecificRoomState(.new(keys: missingKeys)), + ); + } + + return .new([ + (roomId: roomId, stateKey: null), + ...spaces.map((spaceId) => (roomId: spaceId, stateKey: null)), + ...enabledPacks.entries.expand( + (entry) => entry.value.map( + (stateKey) => (roomId: entry.key, stateKey: stateKey), + ), + ), + ]); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + ImagePackSourcesController.new, + ); +} diff --git a/lib/controllers/image_packs.dart b/lib/controllers/image_packs.dart new file mode 100644 index 0000000..f59f491 --- /dev/null +++ b/lib/controllers/image_packs.dart @@ -0,0 +1,72 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/image_pack_sources.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/image_pack.dart"; + +class ImagePacksController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final sources = await ref.watch( + ImagePackSourcesController.provider(roomId).future, + ); + + final packs = ref.watch( + RoomsController.provider.select( + (rooms) => sources.expand((source) { + final room = rooms[source.roomId]; + if (room == null) return const []; + + final newPacks = room.state[EventType.imagePack.type]; + final legacyPacks = room.state[EventType.legacyImagePack.type]; + final roomName = room.metadata?.name ?? source.roomId; + + return switch (source.stateKey) { + final stateKey? => ISet([stateKey]), + null => ISet( + newPacks?.keys ?? const [], + ).addAll(legacyPacks?.keys ?? const []), + } + .map( + (stateKey) => + switch (newPacks?[stateKey] ?? legacyPacks?[stateKey]) { + final rowId? => switch (room.events[rowId]) { + Event( + redactedBy: null, + content: final ImagePackContent content, + ) => + ImagePack.fromContent( + content, + id: "${source.roomId}/$stateKey", + fallbackName: stateKey.isEmpty + ? roomName + : "$roomName - $stateKey", + ), + _ => null, + }, + null => null, + }, + ) + .nonNulls; + }).toIList(), + ), + ); + + return .new( + packs + .whereNoDuplicates(by: (pack) => pack.id) + .where( + (pack) => pack.emoticons.isNotEmpty || pack.stickers.isNotEmpty, + ), + ); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + ImagePacksController.new, + ); +} diff --git a/lib/controllers/recent_emoji.dart b/lib/controllers/recent_emoji.dart index 8dd3e59..a091afb 100644 --- a/lib/controllers/recent_emoji.dart +++ b/lib/controllers/recent_emoji.dart @@ -23,7 +23,13 @@ class RecentEmojiController extends Notifier> { ), ); - return .new(recents.where((recent) => emoji.contains(recent.emoji))); + return .new( + recents.where( + (recent) => + // Is unicode or custom emote + emoji.contains(recent.emoji) || recent.emoji.startsWith("mxc://"), + ), + ); } Future add(String emoji) => ref diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index b8b688d..2860a60 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -9,7 +9,9 @@ import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/direction.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/redact_event.dart"; @@ -315,14 +317,24 @@ class RoomChatController(final ChatParams info) } } - Future sendReaction(String reaction, Event event) async { + /// Reacts to [event] with [reaction], which is an mxc URL for custom emoji, + /// with their [shortcode]. + Future sendReaction( + String reaction, + Event event, { + String? shortcode, + }) async { final client = ref.read(ClientController.provider.notifier); await client.sendEvent( .new( roomId: info.roomId, type: EventType.reaction.type, - content: ReactionContent(key: reaction), + // Wrapped in colons, like other clients send it + content: ReactionContent( + key: reaction, + shortcode: shortcode == null ? null : ":$shortcode:", + ), synchronous: true, disableEncryption: true, relatesTo: event.eventId, @@ -331,6 +343,33 @@ class RoomChatController(final ChatParams info) ); } + Future sendSticker(String shortcode, PackImage image) async { + final event = await ref + .read(ClientController.provider.notifier) + .sendEvent( + .new( + roomId: info.roomId, + type: EventType.sticker.type, + content: StickerContent( + body: image.body ?? shortcode, + info: image.info ?? const .new(), + url: image.url, + ), + ), + ); + + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.roomId: .new( + events: .new({event.rowId: event}), + clientSticky: .new({event.rowId}), + ), + }), + ); + } + static final provider = AsyncNotifierProvider.family .autoDispose( RoomChatController.new, diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart index 5370bcf..d52b317 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -11,29 +11,35 @@ class RoomsController extends Notifier> { @override IMap build() => .new(); - void addState( - String roomId, - IList state, { - bool isMembers = false, - }) => update( - .new({ - roomId: Room( - events: .fromEntries(state.map((event) => .new(event.rowId, event))), - hasFetchedState: true, - hasFetchedMembers: isMembers, - state: .new( - state - .groupListsBy((event) => event.type) - .map( - (type, group) => .new( - type, - .fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))), - ), - ), - ), - ), - }), - .new(), + void addState(String roomId, IList state, {bool isMembers = false}) => + update( + .new({ + roomId: stateRoom(state) + .copyWith(hasFetchedState: true, hasFetchedMembers: isMembers), + }), + .new(), + ); + + void addSpecificState(Iterable state) => update( + .new( + state + .groupListsBy((event) => event.roomId) + .map((roomId, roomState) => .new(roomId, stateRoom(roomState))), + ), + ); + + static Room stateRoom(Iterable state) => Room( + events: .fromEntries(state.map((event) => .new(event.rowId, event))), + state: .new( + state + .groupListsBy((event) => event.type) + .map( + (type, group) => .new( + type, + .fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))), + ), + ), + ), ); static IMap> mergeReceipts( diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index 72ca2d2..54b952d 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -18,6 +18,7 @@ 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/main.dart"; extension BuildEventOptions on Event { @@ -97,6 +98,8 @@ extension BuildEventOptions on Event { mainAxisAlignment: isDesktop ? .center : .spaceEvenly, children: [ ...{ + // Custom emoji are sent without their shortcode, as only + // their mxc URL is recorded ...ref .read(RecentEmojiController.provider) .map((entry) => entry.emoji), @@ -109,10 +112,15 @@ extension BuildEventOptions on Event { .take(isDesktop ? 3 : 5) .map( (emoji) => IconButton( - icon: Text( - emoji, - style: .new(fontSize: isDesktop ? null : 18), - ), + icon: emoji.startsWith("mxc://") + ? SizedBox.square( + dimension: isDesktop ? 18 : 24, + child: MxcEmoji(Uri.parse(emoji)), + ) + : Text( + emoji, + style: .new(fontSize: isDesktop ? null : 18), + ), onPressed: () async { Navigator.of(context).pop(); await ref @@ -134,12 +142,13 @@ extension BuildEventOptions on Event { context.showAdaptiveSheet( (context) => EmojiPicker( + roomId: roomId, allowFreeText: true, - onSelection: (emoji) { + onSelection: (emoji, {shortcode}) { Navigator.of(context).pop(); ref .read(notifier) - .sendReaction(emoji, this) + .sendReaction(emoji, this, shortcode: shortcode) .onError(showError); }, ), diff --git a/lib/helpers/extensions/image_pack_emoji.dart b/lib/helpers/extensions/image_pack_emoji.dart new file mode 100644 index 0000000..2b04cf1 --- /dev/null +++ b/lib/helpers/extensions/image_pack_emoji.dart @@ -0,0 +1,29 @@ +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"; + +extension ImagePackEmoji on ImagePack { + EmojiCategory? get emojiCategory => _category(emoticons); + EmojiCategory? get stickerCategory => _category(stickers); + + EmojiCategory? _category(IMap images) => images.isEmpty + ? null + : .new( + icon: MxcEmoji(avatar ?? images.values.first.url), + name: name, + emojis: images + .mapTo( + (shortcode, image) => Emoji( + widget: MxcEmoji(image.url, label: shortcode), + value: image.url.toString(), + aliases: .new([shortcode]), + description: image.body ?? shortcode, + tags: const .empty(), + data: image, + ), + ) + .toIList(), + ); +} diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index 0ab5990..f4dee1a 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -16,10 +16,24 @@ class const AccountData({ @JsonKey(name: AccountData.recentEmojiKey) final RecentEmojiData recentEmoji = const RecentEmojiData(), + + /// Room image packs the user has enabled everywhere + @JsonKey( + name: AccountData.imagePackRoomsKey, + fromJson: ImagePackRooms.tryFromJson, + ) + final ImagePackRooms? imagePackRooms, + @JsonKey( + name: AccountData.legacyImagePackRoomsKey, + fromJson: ImagePackRooms.tryFromJson, + ) + final ImagePackRooms? legacyImagePackRooms, }) with _$AccountData { static const invitePermissionConfigKey = "m.invite_permission_config"; static const directKey = "m.direct"; static const recentEmojiKey = "m.recent_emoji"; + static const imagePackRoomsKey = "m.image_pack.rooms"; + static const legacyImagePackRoomsKey = "im.ponies.emote_rooms"; Map toJson() => _$AccountDataToJson(this); @@ -38,6 +52,28 @@ class const RecentEmojiData({ _$RecentEmojiDataFromJson(json); } +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ImagePackRooms({ + /// Room IDs to the state keys of their enabled packs, each to a (currently + /// empty) object + final IMap>> rooms = const .empty(), +}) with _$ImagePackRooms { + static ImagePackRooms? tryFromJson(Map? json) { + if (json == null) return null; + try { + return .fromJson(json); + } catch (_) { + return null; + } + } + + Map toJson() => _$ImagePackRoomsToJson(this); + + factory ImagePackRooms.fromJson(Map json) => + _$ImagePackRoomsFromJson(json); +} + @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const InvitePermissionConfig({ diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index e6061d1..2809245 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -16,6 +16,7 @@ import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/history_visibility.dart"; +import "package:nexus/models/content/image_pack.dart"; class const Content({final String? parseError, final StackTrace? stackTrace}) { factory Content.fromJson(Map json) => const Content(); @@ -61,5 +62,7 @@ enum EventType( tombstone("m.room.tombstone", Content.fromJson), reaction("m.reaction", ReactionContent.fromJson), pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), + imagePack("m.room.image_pack", ImagePackContent.fromJson), + legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson), message("m.room.message", MessageContent.fromJson), } diff --git a/lib/models/content/image_pack.dart b/lib/models/content/image_pack.dart new file mode 100644 index 0000000..5a62fc2 --- /dev/null +++ b/lib/models/content/image_pack.dart @@ -0,0 +1,68 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/image.dart"; +part "image_pack.freezed.dart"; +part "image_pack.g.dart"; + +/// An image pack (MSC2545), from a room's state. +@freezed +sealed class ImagePackContent extends Content with _$ImagePackContent { + ImagePackContent._(); + factory ImagePackContent({ + @Default(IMap.empty()) IMap images, + PackInfo? pack, + }) = _ImagePackContent; + + IMap imagesFor(ImageUsage usage) => images.where( + (_, image) => switch ((image.usage, pack?.usage ?? const .empty())) { + (final usages, _) when usages.isNotEmpty => usages.contains(usage), + (_, final packUsages) when packUsages.isNotEmpty => packUsages.contains( + usage, + ), + _ => true, + }, + ); + + factory ImagePackContent.fromJson(Map json) => + _$ImagePackContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PackImage({ + required final Uri url, + final String? body, + final ImageInfo? info, + @JsonKey(fromJson: ImageUsage.setFromJson) + final ISet usage = const .empty(), +}) with _$PackImage { + Map toJson() => _$PackImageToJson(this); + + factory PackImage.fromJson(Map json) => + _$PackImageFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PackInfo({ + final String? displayName, + final Uri? avatarUrl, + final String? attribution, + @JsonKey(fromJson: ImageUsage.setFromJson) + final ISet usage = const .empty(), +}) with _$PackInfo { + Map toJson() => _$PackInfoToJson(this); + + factory PackInfo.fromJson(Map json) => + _$PackInfoFromJson(json); +} + +enum ImageUsage { + emoticon, + sticker; + + /// Ignores usages we don't know about. + static ISet setFromJson(List? json) => + ISet(values.where((value) => json?.contains(value.name) ?? false)); +} diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart index 93c563d..347350d 100644 --- a/lib/models/content/reaction.dart +++ b/lib/models/content/reaction.dart @@ -11,11 +11,15 @@ sealed class ReactionContent extends Content with _$ReactionContent { factory ReactionContent({ @JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key, + + /// For custom emoji reactions + @JsonKey(name: "com.beeper.reaction.shortcode") String? shortcode, }) = _ReactionContent; @override Map toJson() => { "m.relates_to": {"key": key}, + "com.beeper.reaction.shortcode": ?shortcode, }; factory ReactionContent.fromJson(Map json) => diff --git a/lib/models/image_pack.dart b/lib/models/image_pack.dart new file mode 100644 index 0000000..bfe1af0 --- /dev/null +++ b/lib/models/image_pack.dart @@ -0,0 +1,33 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/image_pack.dart"; + +part "image_pack.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class const ImagePack({ + required final String id, + required final String name, + final Uri? avatar, + + /// Map of shortcode to images + required final IMap emoticons, + required final IMap stickers, +}) with _$ImagePack { + static String emojiMarkdown(String shortcode, String url) { + final title = ":$shortcode:".replaceAll(r"\", r"\\").replaceAll('"', r'\"'); + return '![:$shortcode:]($url "Emoji: $title")'; + } + + factory ImagePack.fromContent( + ImagePackContent content, { + required String id, + required String fallbackName, + }) => .new( + id: id, + name: content.pack?.displayName ?? fallbackName, + avatar: content.pack?.avatarUrl, + emoticons: content.imagesFor(.emoticon), + stickers: content.imagesFor(.sticker), + ); +} diff --git a/lib/models/requests/get_specific_room_state.dart b/lib/models/requests/get_specific_room_state.dart new file mode 100644 index 0000000..1559bc0 --- /dev/null +++ b/lib/models/requests/get_specific_room_state.dart @@ -0,0 +1,29 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_specific_room_state.freezed.dart"; +part "get_specific_room_state.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetSpecificRoomStateRequest({ + required final IList keys, +}) with _$GetSpecificRoomStateRequest { + Map toJson() => _$GetSpecificRoomStateRequestToJson(this); + + factory GetSpecificRoomStateRequest.fromJson(Map json) => + _$GetSpecificRoomStateRequestFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomStateKey({ + required final String roomId, + required final String type, + required final String stateKey, +}) with _$RoomStateKey { + Map toJson() => _$RoomStateKeyToJson(this); + + factory RoomStateKey.fromJson(Map json) => + _$RoomStateKeyFromJson(json); +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 2648347..5f79960 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -17,7 +17,11 @@ import "package:nexus/controllers/image_picker.dart"; import "package:nexus/helpers/file_dialog_helper.dart"; import "package:image_picker/image_picker.dart"; import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/image_packs.dart"; +import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/message.dart"; +import "package:nexus/models/image_pack.dart"; +import "package:nexus/widgets/sticker_picker.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; import "package:nexus/widgets/composer/paste_files_action.dart"; @@ -38,6 +42,8 @@ class const Composer( required IList tags, }) onSend, + required final Future Function(String shortcode, PackImage image) + onSendSticker, final FocusNode? node, super.key, }) extends HookConsumerWidget { @@ -48,6 +54,17 @@ class const Composer( final triggerCharacter = useState(""); final shouldMention = useState(true); final query = useState(""); + final canSendStickers = + ref.watch( + PowerLevelController.provider( + .new(eventType: .sticker, roomId: roomId), + ), + ) && + (ref + .watch(ImagePacksController.provider(roomId)) + .value + ?.any((pack) => pack.stickers.isNotEmpty) ?? + false); final relatedEvent = this.relatedEvent == null ? null : ref.watch( @@ -253,9 +270,27 @@ class const Composer( IconButton( onPressed: () => context.showAdaptiveSheet( (context) => EmojiPicker( - onSelection: (value) { + roomId: roomId, + onSelection: (value, {shortcode}) { Navigator.of(context).pop(); - controller.text += value; + if (shortcode == null) { + controller.text += value; + } else { + final text = "${controller.text}:"; + controller.value = .new( + text: text, + selection: .collapsed( + offset: text.length, + ), + ); + controller.addTag( + id: ImagePack.emojiMarkdown( + shortcode, + value, + ), + name: shortcode, + ); + } node?.requestFocus(); }, ), @@ -308,6 +343,22 @@ class const Composer( leading: Icon(Icons.attachment), ), ), + if (canSendStickers) + PopupMenuItem( + onTap: () => context.showAdaptiveSheet( + (context) => StickerPicker( + roomId: roomId, + onSelection: (shortcode, image) { + Navigator.of(context).pop(); + onSendSticker(shortcode, image); + }, + ), + ), + child: ListTile( + title: Text("Sticker"), + leading: Icon(Icons.sticky_note_2), + ), + ), ], icon: Icon(Icons.add), ), @@ -315,6 +366,9 @@ class const Composer( Expanded( child: FlutterTagger( triggerStrategy: .eager, + // Characters that continue a search, for + // e.g. :+1, :custom_emoji or @user.name + searchRegex: .new(r"^[\w.+-]*$"), overlay: TaggerOverlay( query.value, roomId: roomId, diff --git a/lib/widgets/composer/overlays/emoji_overlay.dart b/lib/widgets/composer/overlays/emoji_overlay.dart index 0e762c0..a360604 100644 --- a/lib/widgets/composer/overlays/emoji_overlay.dart +++ b/lib/widgets/composer/overlays/emoji_overlay.dart @@ -1,8 +1,9 @@ import "package:collection/collection.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:material_emoji_picker/material_emoji_picker.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/all_emoji.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/image_pack.dart"; class const EmojiOverlay( final String query, { @@ -13,7 +14,7 @@ class const EmojiOverlay( }) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => ref - .watch(EmojiController.provider) + .watch(AllEmojiController.provider(roomId)) .betterWhen( data: (emojis) => ListView( children: emojis @@ -34,8 +35,11 @@ class const EmojiOverlay( title: Text(emoji.aliases.first), subtitle: Text(emoji.description), onTap: () => addTag( - id: Uri.tryParse(emoji.value)?.scheme == "mxc" - ? "" // TODO: Handle custom emotes + id: emoji.value.startsWith("mxc://") + ? ImagePack.emojiMarkdown( + emoji.aliases.first, + emoji.value, + ) : emoji.value, name: emoji.aliases.first, ), diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart index 7e49836..4a64f38 100644 --- a/lib/widgets/emoji_picker.dart +++ b/lib/widgets/emoji_picker.dart @@ -1,15 +1,23 @@ import "dart:async"; import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_emoji_picker/material_emoji_picker.dart" as upstream; import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/all_emoji.dart"; +import "package:nexus/controllers/image_packs.dart"; import "package:nexus/controllers/recent_emoji.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/image_pack_emoji.dart"; import "package:nexus/main.dart"; +import "package:nexus/models/content/image_pack.dart"; class const EmojiPicker({ - required final FutureOr Function(String value) onSelection, + required final String roomId, + + required final FutureOr Function(String value, {String? shortcode}) + onSelection, final bool allowFreeText = false, super.key, }) extends ConsumerWidget { @@ -18,39 +26,51 @@ class const EmojiPicker({ final recentEmoji = ref.watch(RecentEmojiController.provider); return ref - .watch(upstream.EmojiController.provider) + .watch(AllEmojiController.provider(roomId)) .betterWhen( - data: (categories) => upstream.EmojiPicker( - onSelection: (emoji, isFreeText) async { - await onSelection(emoji); + data: (categories) { + final allEmoji = categories.expand((category) => category.emojis); + final customCategories = + ref + .watch(ImagePacksController.provider(roomId)) + .value + ?.map((pack) => pack.emojiCategory) + .nonNulls + .toIList() ?? + const .empty(); + + return upstream.EmojiPicker( + onSelection: (emoji) async { + await onSelection( + emoji.value, + shortcode: emoji.data is PackImage + ? emoji.aliases.first + : null, + ); - if (!isFreeText) { await ref .read(RecentEmojiController.provider.notifier) - .add(emoji) + .add(emoji.value) .onError(showError); - } - }, - allowFreeText: allowFreeText, - prependCategories: .new([ - .new( - icon: Icon(Icons.history), - name: "Recent", - emojis: .new( - recentEmoji - .map( - (recent) => categories - .map((element) => element.emojis) - .flattened - .firstWhereOrNull( - (element) => element.value == recent.emoji, - ), - ) - .nonNulls, + }, + onFreeText: allowFreeText ? (text) => onSelection(text) : null, + prependCategories: IList([ + .new( + icon: Icon(Icons.history), + name: "Recent", + emojis: .new( + recentEmoji + .map( + (recent) => allEmoji.firstWhereOrNull( + (element) => element.value == recent.emoji, + ), + ) + .nonNulls, + ), ), - ), - ]), - ), + ]).addAll(customCategories), + ); + }, ); } } diff --git a/lib/widgets/mxc_emoji.dart b/lib/widgets/mxc_emoji.dart new file mode 100644 index 0000000..e8c4916 --- /dev/null +++ b/lib/widgets/mxc_emoji.dart @@ -0,0 +1,16 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/mxc_image.dart"; + +/// A custom emoji or sticker from an image pack, scaled to fit its parent. +class const MxcEmoji(final Uri url, {final String? label, super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => Image( + image: MxcImage(ref, .new(mxc: url)), + semanticLabel: label, + fit: .contain, + errorBuilder: (context, _, _) => + Icon(Icons.broken_image, color: Theme.of(context).colorScheme.error), + ); +} diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index e7fd6e0..db7120c 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -323,6 +323,10 @@ final class const RoomChat({ relatedEvent.value, ) .onError(showError), + onSendSticker: (shortcode, image) => + notifier + .sendSticker(shortcode, image) + .onError(showError), relationType: relationType.value, relatedEvent: relatedEvent.value, onDismiss: () { diff --git a/lib/widgets/sticker_picker.dart b/lib/widgets/sticker_picker.dart new file mode 100644 index 0000000..3222399 --- /dev/null +++ b/lib/widgets/sticker_picker.dart @@ -0,0 +1,47 @@ +import "dart:async"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/image_packs.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/image_pack_emoji.dart"; +import "package:nexus/models/content/image_pack.dart"; + +/// Stickers from the image packs available in [roomId], grouped by pack. +class const StickerPicker({ + required final String roomId, + required final FutureOr Function(String shortcode, PackImage image) + onSelection, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch(ImagePacksController.provider(roomId)) + .betterWhen( + data: (packs) { + final categories = packs + .map((pack) => pack.stickerCategory) + .nonNulls + .toIList(); + + return categories.isEmpty + ? Padding( + padding: .all(16), + child: Center(child: Text("No sticker packs available")), + ) + : EmojiPicker( + prependCategories: categories, + includeDefaultEmoji: false, + itemSize: 96, + searchHint: "Search stickers", + onSelection: (sticker) { + if (sticker.data case final PackImage image) { + return onSelection(sticker.aliases.first, image); + } + }, + ); + }, + ); +} diff --git a/pubspec.lock b/pubspec.lock index 670b2ca..fef5cfa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -924,10 +924,10 @@ packages: dependency: "direct main" description: name: material_emoji_picker - sha256: "738e70a8d2f592be05dd9889037cc6098a63e0d8c48723125848865ab305f138" + sha256: "563acfa4dbfd023c2e1ff3b4ebf1083dbe2fc5f848f300682164245ad5fc5464" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "2.0.1" material_ui: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 11c4808..c49c98e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -111,7 +111,7 @@ dependencies: unifiedpush_storage_shared_preferences: 1.0.0 flutter_local_notifications: 22.3.1 timezone: 0.11.1 - material_emoji_picker: 1.2.0 + material_emoji_picker: 2.0.1 xdg_desktop_portal: git: url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart From 401b527714cfb21ebfd670cee9296c8f8327fa11 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 20:05:23 -0400 Subject: [PATCH 13/27] ignore invalid account data --- lib/controllers/account_data.dart | 16 +++++++++++----- lib/models/account_data.dart | 19 ++----------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/lib/controllers/account_data.dart b/lib/controllers/account_data.dart index c595565..551c0cc 100644 --- a/lib/controllers/account_data.dart +++ b/lib/controllers/account_data.dart @@ -1,6 +1,7 @@ import "dart:convert"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/account_data.dart"; @@ -9,11 +10,16 @@ class AccountDataController extends Notifier { AccountData build() => .new(); void update(IMap> newAccountData) => - state = .fromJson({ - ...json.decode(json.encode(state.toJson())), - ...newAccountData - .map((key, value) => MapEntry(key, value["content"])) - .unlock, + state = newAccountData.entries.fold(state, (data, entry) { + try { + return .fromJson({ + ...json.decode(json.encode(data.toJson())), + entry.key: entry.value["content"], + }); + } catch (error) { + debugPrint("Ignoring invalid ${entry.key} account data: $error"); + return data; + } }); static final provider = NotifierProvider( diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index f4dee1a..2e21937 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -18,15 +18,9 @@ class const AccountData({ final RecentEmojiData recentEmoji = const RecentEmojiData(), /// Room image packs the user has enabled everywhere - @JsonKey( - name: AccountData.imagePackRoomsKey, - fromJson: ImagePackRooms.tryFromJson, - ) + @JsonKey(name: AccountData.imagePackRoomsKey) final ImagePackRooms? imagePackRooms, - @JsonKey( - name: AccountData.legacyImagePackRoomsKey, - fromJson: ImagePackRooms.tryFromJson, - ) + @JsonKey(name: AccountData.legacyImagePackRoomsKey) final ImagePackRooms? legacyImagePackRooms, }) with _$AccountData { static const invitePermissionConfigKey = "m.invite_permission_config"; @@ -59,15 +53,6 @@ class const ImagePackRooms({ /// empty) object final IMap>> rooms = const .empty(), }) with _$ImagePackRooms { - static ImagePackRooms? tryFromJson(Map? json) { - if (json == null) return null; - try { - return .fromJson(json); - } catch (_) { - return null; - } - } - Map toJson() => _$ImagePackRoomsToJson(this); factory ImagePackRooms.fromJson(Map json) => From 53d9c2b1e6d7f95e11c4a7223c4754fc7b776e9d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 20:12:54 -0400 Subject: [PATCH 14/27] make enums fall back to unknown or null where possible Futureproofing. --- lib/controllers/room_settings_sections.dart | 1 + lib/helpers/extensions/join_rule_name.dart | 1 + lib/models/content/history_visibility.dart | 3 ++- lib/models/content/join_rules.dart | 2 +- lib/models/content/membership.dart | 3 ++- lib/models/content/message.dart | 7 +++++++ lib/models/join_rule.dart | 11 ++++++++++- lib/models/membership_status.dart | 2 +- lib/models/room_summary.dart | 2 +- lib/widgets/renderers/event.dart | 1 + lib/widgets/renderers/membership.dart | 1 + 11 files changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index e6ea31e..6c266a9 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -663,6 +663,7 @@ class RoomSettingsSectionsController(final String roomId) .invited => "After invite", .joined => "After join", .worldReadable => "Anyone", + .unknown => "Unknown", }, onChanged: canSetHistoryVisibility ? (visibility) => setState( diff --git a/lib/helpers/extensions/join_rule_name.dart b/lib/helpers/extensions/join_rule_name.dart index d2cf4b5..94c6001 100644 --- a/lib/helpers/extensions/join_rule_name.dart +++ b/lib/helpers/extensions/join_rule_name.dart @@ -9,5 +9,6 @@ extension JoinRuleName on JoinRule { .restricted => "Restricted", .knockRestricted => "Restricted or knock", .private => "Private", + .unknown => "Unknown", }; } diff --git a/lib/models/content/history_visibility.dart b/lib/models/content/history_visibility.dart index 3cfc011..65b6160 100644 --- a/lib/models/content/history_visibility.dart +++ b/lib/models/content/history_visibility.dart @@ -8,6 +8,7 @@ sealed class HistoryVisibilityContent extends Content with _$HistoryVisibilityContent { HistoryVisibilityContent._(); factory HistoryVisibilityContent({ + @JsonKey(unknownEnumValue: HistoryVisibility.unknown) required HistoryVisibility historyVisibility, }) = _HistoryVisibilityContent; @@ -16,4 +17,4 @@ sealed class HistoryVisibilityContent extends Content } @JsonEnum(fieldRename: FieldRename.snake) -enum HistoryVisibility { invited, joined, shared, worldReadable } +enum HistoryVisibility { invited, joined, shared, worldReadable, unknown } diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart index 948a762..20d2a60 100644 --- a/lib/models/content/join_rules.dart +++ b/lib/models/content/join_rules.dart @@ -9,7 +9,7 @@ part "join_rules.g.dart"; sealed class JoinRulesContent extends Content with _$JoinRulesContent { JoinRulesContent._(); factory JoinRulesContent({ - required JoinRule joinRule, + @JsonKey(unknownEnumValue: JoinRule.unknown) required JoinRule joinRule, @Default(ISet.empty()) ISet allow, }) = _JoinRulesContent; diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index b1a5508..90e6c9f 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -17,7 +17,8 @@ sealed class MembershipContent extends Content with _$MembershipContent { fromJson: MembershipContent.displaynameFromJson, ) required String? displayName, - @JsonKey(name: "membership") required MembershipStatus status, + @JsonKey(name: "membership", unknownEnumValue: MembershipStatus.unknown) + required MembershipStatus status, Uri? avatarUrl, String? reason, @Default(false) bool isDirect, diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 36bd725..5212cd8 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -20,6 +20,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.text") factory MessageContent.text({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, }) = TextMessageContent; @@ -27,6 +28,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.notice") factory MessageContent.notice({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, }) = NoticeMessageContent; @@ -34,6 +36,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.emote") factory MessageContent.emote({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, }) = EmoteMessageContent; @@ -41,6 +44,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.image") factory MessageContent.image({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, EncryptedFile? file, @@ -52,6 +56,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.file") factory MessageContent.file({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, EncryptedFile? file, @@ -63,6 +68,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.audio") factory MessageContent.audio({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, EncryptedFile? file, @@ -74,6 +80,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.video") factory MessageContent.video({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, EncryptedFile? file, diff --git a/lib/models/join_rule.dart b/lib/models/join_rule.dart index 44fb9bb..a82c62a 100644 --- a/lib/models/join_rule.dart +++ b/lib/models/join_rule.dart @@ -1,4 +1,13 @@ import "package:freezed_annotation/freezed_annotation.dart"; @JsonEnum(fieldRename: .snake) -enum JoinRule { public, knock, invite, private, restricted, knockRestricted } +enum JoinRule { + public, + knock, + invite, + private, + restricted, + knockRestricted, + + unknown, +} diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart index 7eb5f72..f8197cb 100644 --- a/lib/models/membership_status.dart +++ b/lib/models/membership_status.dart @@ -1 +1 @@ -enum MembershipStatus { leave, invite, ban, join, knock } +enum MembershipStatus { leave, invite, ban, join, knock, unknown } diff --git a/lib/models/room_summary.dart b/lib/models/room_summary.dart index 05eaf43..2688ca3 100644 --- a/lib/models/room_summary.dart +++ b/lib/models/room_summary.dart @@ -14,7 +14,7 @@ part "room_summary.g.dart"; class const RoomSummary({ required final String roomId, @JsonKey(name: "num_joined_members") final int? joinedMembers, - final JoinRule? joinRule, + @JsonKey(unknownEnumValue: JoinRule.unknown) final JoinRule? joinRule, /// Rooms whose members may join without an invite, for restricted rooms. final IList allowedRoomIds = const .empty(), diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 6ed2ffd..6fd3310 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -240,6 +240,7 @@ class const EventRenderer( .joined => "since joined", .shared => "all history visible (shared)", .worldReadable => "all history visible (world readable)", + .unknown => "something Nexus doesn't know about", }}", ), PinnedEventsContent(:final pinnedEvents) => () { diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index a2cd8cc..fe845cf 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -57,6 +57,7 @@ class const MembershipRenderer( .leave => event.sender == event.stateKey ? "left" : (previous == .ban ? "was unbanned from" : "was kicked from"), .ban => "was banned from", .knock => "asked to join", + .unknown => "changed their membership in", }} the room${event.sender == event.stateKey ? "" : " by"}", ), if (event.sender != event.stateKey) From 853116ab2f7a3b5425533cfc7d5b227e18015737 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 20:26:37 -0400 Subject: [PATCH 15/27] treewide cleanups --- lib/controllers/room_chat.dart | 17 ++--- lib/controllers/spaces.dart | 75 +++++++++---------- lib/controllers/via.dart | 69 ++++++++--------- lib/main.dart | 2 +- lib/models/emoji.dart | 25 ------- lib/models/emoji_category.dart | 12 --- lib/widgets/composer/composer.dart | 4 +- lib/widgets/composer/relation_preview.dart | 2 +- lib/widgets/html/html.dart | 2 +- .../lazy_loading/message_displayname.dart | 2 +- lib/widgets/message_image.dart | 2 +- lib/widgets/notifications_list.dart | 2 +- lib/widgets/room_menu_button.dart | 2 +- lib/widgets/settings/dialog_list_tile.dart | 2 +- lib/widgets/settings/radio_sheet.dart | 2 +- lib/widgets/sync_status_banner.dart | 2 +- 16 files changed, 86 insertions(+), 136 deletions(-) delete mode 100644 lib/models/emoji.dart delete mode 100644 lib/models/emoji_category.dart diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 2860a60..94fb7a3 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -243,17 +243,14 @@ class RoomChatController(final ChatParams info) ref.invalidate(provider); } - var taggedMessage = text; - - for (final tag in tags) { - final escaped = RegExp.escape(tag.id); - final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?"); - - taggedMessage = taggedMessage.replaceAllMapped( - pattern, + // Replaces each formatted tag, like "@id#name#", with its ID + final taggedMessage = tags.fold( + text, + (message, tag) => message.replaceAllMapped( + RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"), (match) => match.group(1)!, - ); - } + ), + ); final client = ref.read(ClientController.provider.notifier); final event = await client.sendMessage( diff --git a/lib/controllers/spaces.dart b/lib/controllers/spaces.dart index 8af045c..4d6dffe 100644 --- a/lib/controllers/spaces.dart +++ b/lib/controllers/spaces.dart @@ -25,55 +25,50 @@ class SpacesController extends AsyncNotifier> { MapEntry(spaceId, edges.map((edge) => edge.childId).toList()), ); - Set collectDescendants(String startId) { - final visited = {}; - final stack = [startId]; - - while (stack.isNotEmpty) { - final current = stack.removeLast(); - final children = childrenById[current] ?? const []; - - for (final child in children) { - if (visited.add(child)) { - stack.add(child); - } - } - } - - return visited; - } + /// Everything under [spaceId], however deep, skipping any already + /// [visited] so cycles end. + ISet collectDescendants( + String spaceId, [ + ISet visited = const .empty(), + ]) => (childrenById[spaceId] ?? const []).fold( + visited, + (visited, childId) => visited.contains(childId) + ? visited + : collectDescendants(childId, visited.add(childId)), + ); Space buildSpace(String spaceId) { final space = rooms[spaceId]; final directChildrenIds = childrenById[spaceId] ?? const []; - final directRooms = []; - final subSpaces = []; - - for (final childId in directChildrenIds) { - final room = rooms[childId]; - if (room == null) continue; - - if (childrenById.containsKey(childId)) { - final descendants = collectDescendants(childId); - - subSpaces.add( - .new( - room: room, - children: .new(descendants.map((id) => rooms[id]).nonNulls), - ), - ); - } else { - directRooms.add(room); - } - } - return .new( id: spaceId, room: space, title: space?.metadata?.name ?? "Unnamed Space", - children: .new(directRooms), - subSpaces: .new(subSpaces), + children: .new( + directChildrenIds + .where((childId) => !childrenById.containsKey(childId)) + .map((childId) => rooms[childId]) + .nonNulls, + ), + subSpaces: .new( + directChildrenIds + .where(childrenById.containsKey) + .map( + (childId) => switch (rooms[childId]) { + final room? => Subspace( + room: room, + children: .new( + collectDescendants(childId) + .map((id) => rooms[id]) + .nonNulls, + ), + ), + null => null, + }, + ) + .nonNulls, + ), ); } diff --git a/lib/controllers/via.dart b/lib/controllers/via.dart index 39db682..8c7f0db 100644 --- a/lib/controllers/via.dart +++ b/lib/controllers/via.dart @@ -1,53 +1,48 @@ -import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/room.dart"; class ViaController(final Room room) extends Notifier { @override String build() { - final servers = {}; + final powerLevelUsers = + switch (room.state[EventType.powerLevels.type]?[""]) { + final rowId? => switch (room.events[rowId]?.content) { + PowerLevelsContent(:final users) => users.keys.toIList(), + _ => null, + }, + null => null, + } ?? + .new([]); - void addUserId(String? userId) { - final server = userId?.split(":").lastOrNull; - if (server != null) { - servers.add(server); - } - } + final joinedMembers = + (room.state[EventType.membership.type]?.values ?? const []) + .map( + (rowId) => switch (room.events[rowId]) { + Event( + stateKey: final userId?, + content: MembershipContent(status: .join), + ) => + userId, + _ => null, + }, + ) + .nonNulls + .toIList(); - addUserId(ref.watch(ClientStateController.provider)?.userId); - - final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""]; - final powerLevels = powerLevelsEventId == null - ? null - : room.events[powerLevelsEventId]; - - if (powerLevels?.content case PowerLevelsContent(:final users)) { - for (final userId in users.keys) { - addUserId(userId); - if (servers.length >= 5) break; - } - } - - final members = room.state[EventType.membership.type]?.values.toIList(); - for (var i = 0; servers.length < 5; i++) { - final membershipEventId = members?.getOrNull(i); - final member = membershipEventId == null - ? null - : room.events[membershipEventId]; - - if (member?.content case MembershipContent(:final status)) { - if (status == .join) { - addUserId(member?.stateKey); - } - } - - if (members?.getOrNull(i) == null) break; - } + final servers = [?ref.watch(ClientStateController.provider)?.userId] + .toIList() + .addAll(powerLevelUsers) + .addAll(joinedMembers) + .map((userId) => userId.substring(userId.indexOf(":") + 1)) + .whereNoDuplicates() + .take(5) + .toISet(); return servers.isEmpty ? "" diff --git a/lib/main.dart b/lib/main.dart index 5cd2129..8915339 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -147,7 +147,7 @@ class const App({super.key}) extends StatelessWidget { .watch(SettingsController.provider) .maybeWhen( data: (settings) => settings.theme, - orElse: () => ThemeMode.system, + orElse: () => .system, ), home: child, ); diff --git a/lib/models/emoji.dart b/lib/models/emoji.dart deleted file mode 100644 index 75faf08..0000000 --- a/lib/models/emoji.dart +++ /dev/null @@ -1,25 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:flutter/widgets.dart"; -part "emoji.freezed.dart"; -part "emoji.g.dart"; - -@Freezed(toJson: false, fromJson: false) -@JsonSerializable(createToJson: false) -class Emoji({ - @JsonKey( - fromJson: Emoji.widgetFromJson, - readValue: Emoji.readWidgetValueFromJson, - ) - required final Widget widget, - @JsonKey(name: "emoji") required final String value, - required final IList aliases, - required final String description, - required final IList tags, -}) with _$Emoji { - static Widget widgetFromJson(String emoji) => Text(emoji); - - static String readWidgetValueFromJson(Map json, _) => - json["emoji"]; - factory Emoji.fromJson(Map json) => _$EmojiFromJson(json); -} diff --git a/lib/models/emoji_category.dart b/lib/models/emoji_category.dart deleted file mode 100644 index cc39bc3..0000000 --- a/lib/models/emoji_category.dart +++ /dev/null @@ -1,12 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/emoji.dart"; -import "package:flutter/widgets.dart"; -part "emoji_category.freezed.dart"; - -@Freezed(toJson: false, fromJson: false) -class EmojiCategory({ - required final Widget icon, - required final String name, - required final IList emojis, -}) with _$EmojiCategory; diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 5f79960..720fa58 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -399,11 +399,11 @@ class const Composer( if (!shiftPressed) { send(); - return KeyEventResult.handled; + return .handled; } } - return KeyEventResult.ignored; + return .ignored; }, // Overrides the keyboard shortcut for pasting child: Actions( diff --git a/lib/widgets/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart index fca9de6..aaf1f5c 100644 --- a/lib/widgets/composer/relation_preview.dart +++ b/lib/widgets/composer/relation_preview.dart @@ -38,7 +38,7 @@ class const RelationPreview( onPressed: toggleShouldMention, child: Text( shouldMention ? "@On" : "@Off", - style: TextStyle( + style: .new( fontWeight: .w900, color: shouldMention ? null : Theme.of(context).disabledColor, ), diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart index 5ea584f..d356f6e 100644 --- a/lib/widgets/html/html.dart +++ b/lib/widgets/html/html.dart @@ -76,7 +76,7 @@ class const Html( src == null ? SizedBox.shrink() : InlineCustomWidget( - alignment: PlaceholderAlignment.middle, + alignment: .middle, child: ExpandableImage( .new(mxc: src), downloadFilename: src.pathSegments.last, diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index 5d3bf19..d7c91bd 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -49,7 +49,7 @@ class const MessageDisplayname( final children => Row( spacing: 4, mainAxisSize: .min, - children: [for (final child in children) Flexible(child: child)], + children: children.map((child) => Flexible(child: child)).toList(), ), }, ), diff --git a/lib/widgets/message_image.dart b/lib/widgets/message_image.dart index 969dcd3..70aedd9 100644 --- a/lib/widgets/message_image.dart +++ b/lib/widgets/message_image.dart @@ -27,7 +27,7 @@ final class const MessageImage( child: Image( image: MxcImage(ref, request), width: info?.width?.toDouble(), - fit: BoxFit.fitWidth, + fit: .fitWidth, loadingBuilder: (_, child, loadingProgress) => loadingProgress == null ? child diff --git a/lib/widgets/notifications_list.dart b/lib/widgets/notifications_list.dart index 0b0741f..23ff676 100644 --- a/lib/widgets/notifications_list.dart +++ b/lib/widgets/notifications_list.dart @@ -151,7 +151,7 @@ class const NotificationsList({super.key}) listController: listController.value, controller: scrollController, itemCount: value.length, - padding: const EdgeInsets.symmetric( + padding: const .symmetric( horizontal: 8, vertical: 12, ), diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index 38ad847..358c8d8 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -146,7 +146,7 @@ final class const RoomMenuButton( ), child: ListTile( leading: Icon(Icons.logout, color: danger), - title: Text("Leave", style: TextStyle(color: danger)), + title: Text("Leave", style: .new(color: danger)), ), ), ], diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart index b31182f..f884f31 100644 --- a/lib/widgets/settings/dialog_list_tile.dart +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -42,7 +42,7 @@ final class const DialogListTile({ child: Chip( label: Text( value is T ? getName(value as T) : "None", - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: onChanged == null || saving.active ? .new(color: Colors.grey) : null, diff --git a/lib/widgets/settings/radio_sheet.dart b/lib/widgets/settings/radio_sheet.dart index 9f9512e..5572d48 100644 --- a/lib/widgets/settings/radio_sheet.dart +++ b/lib/widgets/settings/radio_sheet.dart @@ -36,7 +36,7 @@ final class const RadioSheet({ onChanged: (value) => chosen.value = (value as T,), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: options .map( (option) => RadioListTile( diff --git a/lib/widgets/sync_status_banner.dart b/lib/widgets/sync_status_banner.dart index 3f1e47e..10aec7e 100644 --- a/lib/widgets/sync_status_banner.dart +++ b/lib/widgets/sync_status_banner.dart @@ -67,7 +67,7 @@ class const SyncStatusBanner({super.key}) extends ConsumerWidget { banner.title, maxLines: 1, overflow: .ellipsis, - style: TextStyle( + style: .new( color: banner.foreground, fontWeight: .bold, ), From dd01ad9d744ddedd93a36b606b7ab4d3fd9c0a91 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 23 Oct 2026 20:44:29 -0400 Subject: [PATCH 16/27] migrate Content classes to primary constructors The Riverpod bug preventing us from doing this has been fixed. --- lib/models/content/avatar.dart | 10 ++++---- lib/models/content/canonical_alias.dart | 15 +++++------ lib/models/content/create.dart | 26 ++++++++----------- lib/models/content/encrypted.dart | 9 ++++--- lib/models/content/encryption.dart | 22 +++++----------- lib/models/content/history_visibility.dart | 15 +++++------ lib/models/content/image_pack.dart | 14 +++++----- lib/models/content/join_rules.dart | 14 +++++----- lib/models/content/membership.dart | 28 ++++++++------------ lib/models/content/name.dart | 10 ++++---- lib/models/content/pinned_events.dart | 12 ++++----- lib/models/content/power_levels.dart | 30 ++++++++++------------ lib/models/content/reaction.dart | 18 ++++++------- lib/models/content/redaction.dart | 11 ++++---- lib/models/content/server_acl.dart | 16 +++++------- lib/models/content/sticker.dart | 16 +++++------- lib/models/content/topic.dart | 16 +++++------- 17 files changed, 120 insertions(+), 162 deletions(-) diff --git a/lib/models/content/avatar.dart b/lib/models/content/avatar.dart index 25167e9..a5ce877 100644 --- a/lib/models/content/avatar.dart +++ b/lib/models/content/avatar.dart @@ -4,11 +4,11 @@ import "package:nexus/models/info/image.dart"; part "avatar.freezed.dart"; part "avatar.g.dart"; -@freezed -sealed class AvatarContent extends Content with _$AvatarContent { - AvatarContent._(); - factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const AvatarContent({final ImageInfo? info, final Uri? url}) + extends Content + with _$AvatarContent { factory AvatarContent.fromJson(Map json) => _$AvatarContentFromJson(json); } diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart index 6fb2829..f8bca3a 100644 --- a/lib/models/content/canonical_alias.dart +++ b/lib/models/content/canonical_alias.dart @@ -4,15 +4,12 @@ import "package:nexus/models/content/content.dart"; part "canonical_alias.freezed.dart"; part "canonical_alias.g.dart"; -@freezed -sealed class CanonicalAliasContent extends Content - with _$CanonicalAliasContent { - CanonicalAliasContent._(); - factory CanonicalAliasContent({ - @JsonKey(includeIfNull: false) String? alias, - @Default(ISet.empty()) ISet altAliases, - }) = _CanonicalAliasContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const CanonicalAliasContent({ + @JsonKey(includeIfNull: false) final String? alias, + final ISet altAliases = const .empty(), +}) extends Content with _$CanonicalAliasContent { factory CanonicalAliasContent.fromJson(Map json) => _$CanonicalAliasContentFromJson(json); } diff --git a/lib/models/content/create.dart b/lib/models/content/create.dart index b937a94..18353df 100644 --- a/lib/models/content/create.dart +++ b/lib/models/content/create.dart @@ -4,22 +4,16 @@ import "package:nexus/models/content/content.dart"; part "create.freezed.dart"; part "create.g.dart"; -@freezed -sealed class CreateContent extends Content with _$CreateContent { - CreateContent._(); - factory CreateContent({ - @JsonKey(name: "additional_creators") - @Default(IList.empty()) - IList additionalCreatorIds, - - PreviousRoom? predecessor, - - @JsonKey(name: "m.federate") @Default(true) bool federated, - - @Default("1") String roomVersion, - @JsonKey(unknownEnumValue: RoomType.room) RoomType? type, - }) = _CreateContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const CreateContent({ + @JsonKey(name: "additional_creators") + final IList additionalCreatorIds = const .empty(), + final PreviousRoom? predecessor, + @JsonKey(name: "m.federate") final bool federated = true, + final String roomVersion = "1", + @JsonKey(unknownEnumValue: RoomType.room) final RoomType? type, +}) extends Content with _$CreateContent { factory CreateContent.fromJson(Map json) => _$CreateContentFromJson(json); } diff --git a/lib/models/content/encrypted.dart b/lib/models/content/encrypted.dart index 8658a71..2fd7e4a 100644 --- a/lib/models/content/encrypted.dart +++ b/lib/models/content/encrypted.dart @@ -3,10 +3,11 @@ import "package:nexus/models/content/content.dart"; part "encrypted.freezed.dart"; part "encrypted.g.dart"; -@freezed -sealed class EncryptedContent extends Content with _$EncryptedContent { - EncryptedContent._(); - factory EncryptedContent() = _EncryptedContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const EncryptedContent() extends Content with _$EncryptedContent { + @override + Map toJson() => _$EncryptedContentToJson(this); factory EncryptedContent.fromJson(Map json) => _$EncryptedContentFromJson(json); diff --git a/lib/models/content/encryption.dart b/lib/models/content/encryption.dart index 52c7b34..5839132 100644 --- a/lib/models/content/encryption.dart +++ b/lib/models/content/encryption.dart @@ -3,21 +3,13 @@ import "package:nexus/models/content/content.dart"; part "encryption.freezed.dart"; part "encryption.g.dart"; -@freezed -sealed class EncryptionContent extends Content with _$EncryptionContent { - EncryptionContent._(); - factory EncryptionContent({ - required String algorithm, - - @JsonKey(name: "rotation_period_ms") - @Default(604800000) - int rotationPeriodMS, - - @JsonKey(name: "rotation_period_msgs") - @Default(100) - int rotationPeriodMessages, - }) = _EncryptionContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const EncryptionContent({ + required final String algorithm, + @JsonKey(name: "rotation_period_ms") final int rotationPeriodMS = 604800000, + @JsonKey(name: "rotation_period_msgs") final int rotationPeriodMessages = 100, +}) extends Content with _$EncryptionContent { factory EncryptionContent.fromJson(Map json) => _$EncryptionContentFromJson(json); } diff --git a/lib/models/content/history_visibility.dart b/lib/models/content/history_visibility.dart index 65b6160..f44fc91 100644 --- a/lib/models/content/history_visibility.dart +++ b/lib/models/content/history_visibility.dart @@ -3,15 +3,12 @@ import "package:nexus/models/content/content.dart"; part "history_visibility.freezed.dart"; part "history_visibility.g.dart"; -@freezed -sealed class HistoryVisibilityContent extends Content - with _$HistoryVisibilityContent { - HistoryVisibilityContent._(); - factory HistoryVisibilityContent({ - @JsonKey(unknownEnumValue: HistoryVisibility.unknown) - required HistoryVisibility historyVisibility, - }) = _HistoryVisibilityContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const HistoryVisibilityContent({ + @JsonKey(unknownEnumValue: HistoryVisibility.unknown) + required final HistoryVisibility historyVisibility, +}) extends Content with _$HistoryVisibilityContent { factory HistoryVisibilityContent.fromJson(Map json) => _$HistoryVisibilityContentFromJson(json); } diff --git a/lib/models/content/image_pack.dart b/lib/models/content/image_pack.dart index 5a62fc2..93e9835 100644 --- a/lib/models/content/image_pack.dart +++ b/lib/models/content/image_pack.dart @@ -6,14 +6,12 @@ part "image_pack.freezed.dart"; part "image_pack.g.dart"; /// An image pack (MSC2545), from a room's state. -@freezed -sealed class ImagePackContent extends Content with _$ImagePackContent { - ImagePackContent._(); - factory ImagePackContent({ - @Default(IMap.empty()) IMap images, - PackInfo? pack, - }) = _ImagePackContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ImagePackContent({ + final IMap images = const .empty(), + final PackInfo? pack, +}) extends Content with _$ImagePackContent { IMap imagesFor(ImageUsage usage) => images.where( (_, image) => switch ((image.usage, pack?.usage ?? const .empty())) { (final usages, _) when usages.isNotEmpty => usages.contains(usage), diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart index 20d2a60..4eb09f7 100644 --- a/lib/models/content/join_rules.dart +++ b/lib/models/content/join_rules.dart @@ -5,14 +5,12 @@ import "package:nexus/models/join_rule.dart"; part "join_rules.freezed.dart"; part "join_rules.g.dart"; -@freezed -sealed class JoinRulesContent extends Content with _$JoinRulesContent { - JoinRulesContent._(); - factory JoinRulesContent({ - @JsonKey(unknownEnumValue: JoinRule.unknown) required JoinRule joinRule, - @Default(ISet.empty()) ISet allow, - }) = _JoinRulesContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const JoinRulesContent({ + @JsonKey(unknownEnumValue: JoinRule.unknown) required final JoinRule joinRule, + final ISet allow = const .empty(), +}) extends Content with _$JoinRulesContent { factory JoinRulesContent.fromJson(Map json) => _$JoinRulesContentFromJson(json); } diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index 90e6c9f..3e8d181 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -4,26 +4,20 @@ import "package:nexus/models/membership_status.dart"; part "membership.freezed.dart"; part "membership.g.dart"; -@freezed -sealed class MembershipContent extends Content with _$MembershipContent { - MembershipContent._(); - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const MembershipContent({ + @JsonKey(name: "displayname", fromJson: MembershipContent.displaynameFromJson) + required final String? displayName, + @JsonKey(name: "membership", unknownEnumValue: MembershipStatus.unknown) + required final MembershipStatus status, + final Uri? avatarUrl, + final String? reason, + final bool isDirect = false, +}) extends Content with _$MembershipContent { static String? displaynameFromJson(String? displayName) => displayName?.isEmpty == true ? null : displayName; - factory MembershipContent({ - @JsonKey( - name: "displayname", - fromJson: MembershipContent.displaynameFromJson, - ) - required String? displayName, - @JsonKey(name: "membership", unknownEnumValue: MembershipStatus.unknown) - required MembershipStatus status, - Uri? avatarUrl, - String? reason, - @Default(false) bool isDirect, - }) = _MembershipContent; - factory MembershipContent.fromJson(Map json) => _$MembershipContentFromJson(json); } diff --git a/lib/models/content/name.dart b/lib/models/content/name.dart index c451d64..6f53eb5 100644 --- a/lib/models/content/name.dart +++ b/lib/models/content/name.dart @@ -3,11 +3,11 @@ import "package:nexus/models/content/content.dart"; part "name.freezed.dart"; part "name.g.dart"; -@freezed -sealed class NameContent extends Content with _$NameContent { - NameContent._(); - factory NameContent({required String name}) = _NameContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const NameContent({required final String name}) + extends Content + with _$NameContent { factory NameContent.fromJson(Map json) => _$NameContentFromJson(json); } diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart index 9dac5de..7f309c4 100644 --- a/lib/models/content/pinned_events.dart +++ b/lib/models/content/pinned_events.dart @@ -4,13 +4,11 @@ import "package:nexus/models/content/content.dart"; part "pinned_events.freezed.dart"; part "pinned_events.g.dart"; -@freezed -sealed class PinnedEventsContent extends Content with _$PinnedEventsContent { - PinnedEventsContent._(); - factory PinnedEventsContent({ - @Default(IList.empty()) @JsonKey(name: "pinned") IList pinnedEvents, - }) = _PinnedEventsContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PinnedEventsContent({ + @JsonKey(name: "pinned") final IList pinnedEvents = const .empty(), +}) extends Content with _$PinnedEventsContent { factory PinnedEventsContent.fromJson(Map json) => _$PinnedEventsContentFromJson(json); } diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart index afa0e23..8b1dba9 100644 --- a/lib/models/content/power_levels.dart +++ b/lib/models/content/power_levels.dart @@ -4,22 +4,20 @@ import "package:nexus/models/content/content.dart"; part "power_levels.freezed.dart"; part "power_levels.g.dart"; -@freezed -sealed class PowerLevelsContent extends Content with _$PowerLevelsContent { - PowerLevelsContent._(); - factory PowerLevelsContent({ - @Default(IMap.empty()) IMap events, - @Default(IMap.empty()) IMap users, - @JsonKey(includeIfNull: false) Notifications? notifications, - @Default(50) int ban, - @Default(0) int eventsDefault, - @Default(0) int invite, - @Default(50) int kick, - @Default(50) int redact, - @Default(50) int stateDefault, - @Default(0) int usersDefault, - }) = _PowerLevelsContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PowerLevelsContent({ + final IMap events = const .empty(), + final IMap users = const .empty(), + @JsonKey(includeIfNull: false) final Notifications? notifications, + final int ban = 50, + final int eventsDefault = 0, + final int invite = 0, + final int kick = 50, + final int redact = 50, + final int stateDefault = 50, + final int usersDefault = 0, +}) extends Content with _$PowerLevelsContent { factory PowerLevelsContent.fromJson(Map json) => _$PowerLevelsContentFromJson(json); } diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart index 347350d..3990788 100644 --- a/lib/models/content/reaction.dart +++ b/lib/models/content/reaction.dart @@ -3,19 +3,17 @@ import "package:nexus/models/content/content.dart"; part "reaction.freezed.dart"; part "reaction.g.dart"; -@Freezed(toJson: false) -sealed class ReactionContent extends Content with _$ReactionContent { - ReactionContent._(); +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ReactionContent({ + @JsonKey(readValue: ReactionContent.keyJsonFromJson) final String? key, + + /// For custom emoji reactions + @JsonKey(name: "com.beeper.reaction.shortcode") final String? shortcode, +}) extends Content with _$ReactionContent { static String? keyJsonFromJson(Map json, String key) => json["m.relates_to"]?["key"]; - factory ReactionContent({ - @JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key, - - /// For custom emoji reactions - @JsonKey(name: "com.beeper.reaction.shortcode") String? shortcode, - }) = _ReactionContent; - @override Map toJson() => { "m.relates_to": {"key": key}, diff --git a/lib/models/content/redaction.dart b/lib/models/content/redaction.dart index 2ed6436..9b855f8 100644 --- a/lib/models/content/redaction.dart +++ b/lib/models/content/redaction.dart @@ -3,12 +3,11 @@ import "package:nexus/models/content/content.dart"; part "redaction.freezed.dart"; part "redaction.g.dart"; -@freezed -sealed class RedactionContent extends Content with _$RedactionContent { - RedactionContent._(); - factory RedactionContent({String? reason, String? redacts}) = - _RedactionContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RedactionContent({final String? reason, final String? redacts}) + extends Content + with _$RedactionContent { factory RedactionContent.fromJson(Map json) => _$RedactionContentFromJson(json); } diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart index 3873bf3..1bfcfdb 100644 --- a/lib/models/content/server_acl.dart +++ b/lib/models/content/server_acl.dart @@ -4,15 +4,13 @@ import "package:nexus/models/content/content.dart"; part "server_acl.freezed.dart"; part "server_acl.g.dart"; -@freezed -sealed class ServerACLContent extends Content with _$ServerACLContent { - ServerACLContent._(); - factory ServerACLContent({ - @Default(IList.empty()) IList allow, - @Default(IList.empty()) IList deny, - @Default(true) allowIpLiterals, - }) = _ServerACLContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ServerACLContent({ + final IList allow = const .empty(), + final IList deny = const .empty(), + final bool allowIpLiterals = true, +}) extends Content with _$ServerACLContent { factory ServerACLContent.fromJson(Map json) => _$ServerACLContentFromJson(json); } diff --git a/lib/models/content/sticker.dart b/lib/models/content/sticker.dart index c6fa893..2b3e0fa 100644 --- a/lib/models/content/sticker.dart +++ b/lib/models/content/sticker.dart @@ -4,15 +4,13 @@ import "package:nexus/models/info/image.dart"; part "sticker.freezed.dart"; part "sticker.g.dart"; -@freezed -sealed class StickerContent extends Content with _$StickerContent { - StickerContent._(); - factory StickerContent({ - required String body, - required ImageInfo info, - required Uri url, - }) = _StickerContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const StickerContent({ + required final String body, + required final ImageInfo info, + required final Uri url, +}) extends Content with _$StickerContent { factory StickerContent.fromJson(Map json) => _$StickerContentFromJson(json); } diff --git a/lib/models/content/topic.dart b/lib/models/content/topic.dart index c8256e3..13370f4 100644 --- a/lib/models/content/topic.dart +++ b/lib/models/content/topic.dart @@ -5,14 +5,12 @@ import "package:nexus/models/content/content.dart"; part "topic.freezed.dart"; part "topic.g.dart"; -@freezed -sealed class TopicContent extends Content with _$TopicContent { - TopicContent._(); - factory TopicContent({ - required String topic, - @JsonKey(name: "m.topic") TopicContentBlock? content, - }) = _TopicContent; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const TopicContent({ + required final String topic, + @JsonKey(name: "m.topic") final TopicContentBlock? content, +}) extends Content with _$TopicContent { factory TopicContent.fromJson(Map json) => _$TopicContentFromJson(json); } @@ -20,7 +18,7 @@ sealed class TopicContent extends Content with _$TopicContent { @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const TopicContentBlock({ - final IList representations = const IList.empty(), + final IList representations = const .empty(), }) with _$TopicContentBlock { Map toJson() => _$TopicContentBlockToJson(this); From 62e200f30580f832b7f2f3246871bb97a894b5e2 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 24 Oct 2026 11:30:14 -0400 Subject: [PATCH 17/27] fix typing indicators not ellipsizing --- lib/widgets/composer/typing_indicator.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/widgets/composer/typing_indicator.dart b/lib/widgets/composer/typing_indicator.dart index 2a30c4b..e9eda3f 100644 --- a/lib/widgets/composer/typing_indicator.dart +++ b/lib/widgets/composer/typing_indicator.dart @@ -37,6 +37,9 @@ class const TypingIndicator(final String roomId, {super.key}) .value ?.displayName ?? typingUserId.localpart, + maxLines: 1, + overflow: .ellipsis, + softWrap: false, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: .bold, ), From 0a09a4ee0a557dfd52cdd8f0a2d2b4027431bcba Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 24 Oct 2026 12:02:23 -0400 Subject: [PATCH 18/27] general cleanups and mounted guards --- lib/controllers/client.dart | 10 ++------ lib/controllers/notification.dart | 2 +- lib/main.dart | 2 +- lib/widgets/pages/select_server.dart | 4 ++- lib/widgets/pages/verify.dart | 37 +++++++++++++++++++--------- 5 files changed, 33 insertions(+), 22 deletions(-) diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 3c16024..a044d25 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -200,14 +200,8 @@ class ClientController extends AsyncNotifier { Future setState(SetStateRequest request) async => await _sendCommand("set_state", request.toJson()); - Future verify(String recoveryKey) async { - try { - await _sendCommand("verify", {"recovery_key": recoveryKey}); - return null; - } catch (error) { - return error.toString(); - } - } + Future verify(String recoveryKey) => + _sendCommand("verify", {"recovery_key": recoveryKey}); Future generateRecoveryKey(String passphrase) async => .fromJson( await _sendCommand("generate_recovery_key", {"passphrase": passphrase}), diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart index 47ecdf0..b251e26 100644 --- a/lib/controllers/notification.dart +++ b/lib/controllers/notification.dart @@ -169,7 +169,7 @@ class NotificationController if (Platform.isLinux) { final portal = await ref.read(PortalController.provider.future); - const escape = HtmlEscape(HtmlEscapeMode.element); + const escape = HtmlEscape(.element); await portal.notification.addNotification( id.toString(), diff --git a/lib/main.dart b/lib/main.dart index 8915339..66b253a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -160,8 +160,8 @@ class const App({super.key}) extends StatelessWidget { MultiProviderController.provider( .new([ GomuksListenerController.provider, - NotificationController.provider, UnifiedPushController.provider, + NotificationController.provider, MemberListOpenedController.provider, KeyController.provider(KeyController.roomKey), KeyController.provider(KeyController.spaceKey), diff --git a/lib/widgets/pages/select_server.dart b/lib/widgets/pages/select_server.dart index 66c40a5..4a35a6c 100644 --- a/lib/widgets/pages/select_server.dart +++ b/lib/widgets/pages/select_server.dart @@ -60,6 +60,8 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget { return; } + if (!context.mounted) return; + await ref.read(LaunchHelper.provider).launchUrl(codeResponse.url); AppLinks().uriLinkStream.listen((encodedUri) async { @@ -70,7 +72,7 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget { try { final code = encodedUri.queryParameters["code"]; - if (code != null) { + if (code != null && context.mounted) { await ref .read(ClientController.provider.notifier) .exchangeToken( diff --git a/lib/widgets/pages/verify.dart b/lib/widgets/pages/verify.dart index 76bd94d..4858f8c 100644 --- a/lib/widgets/pages/verify.dart +++ b/lib/widgets/pages/verify.dart @@ -18,6 +18,7 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { final isLoading = useState(false); final inputError = useState(null); final formKey = useRef(GlobalKey()); + final verificationState = ref.watch( ClientStateController.provider.select( (state) => state?.verificationState, @@ -25,18 +26,24 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { ); Future verify() async { + if (isLoading.value) return; + + if (formKey.value.currentState?.validate() != true) { + return; + } + isLoading.value = true; try { - if (formKey.value.currentState?.validate() != true) { - return; - } - - inputError.value = await ref + await ref .read(ClientController.provider.notifier) .verify(passphraseController.text); + } catch (error) { + inputError.value = error.toString(); } finally { - isLoading.value = false; + if (context.mounted) { + isLoading.value = false; + } } } @@ -52,7 +59,8 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { ), body: switch (verificationState) { null || VerificationState(stateChecked: false) => const Loading(), - // Nothing to verify with yet, so go straight to setting it up. + + // Nothing to verify yet, so go straight to setup. VerificationState(hasSecretStorage: false) => Dialog( insetPadding: const .symmetric(vertical: 12, horizontal: 32), child: ConstrainedBox( @@ -60,6 +68,7 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { child: const ResetEncryptionSheet(isPage: true), ), ), + _ => AlertDialog( insetPadding: .symmetric(vertical: 12, horizontal: 32), scrollable: true, @@ -71,7 +80,9 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { crossAxisAlignment: .start, children: [ Text( - "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", + "Enter your recovery key or passphrase below to " + "unlock encrypted events.\nYour passphrase is usually " + "not the same as your password.", ), SizedBox(height: 12), TextFormField( @@ -94,9 +105,13 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { ), actions: [ TextButton( - onPressed: () => context.showAdaptiveSheet( - (_) => const ResetEncryptionSheet(), - ), + onPressed: isLoading.value + ? null + : () { + context.showAdaptiveSheet( + (_) => const ResetEncryptionSheet(), + ); + }, style: TextButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.error, ), From a3ef4f813575d847c3bcdde526a04f65d61fd91b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 25 Oct 2026 17:04:21 -0400 Subject: [PATCH 19/27] fix invalid power level creation oops, misread spec and now this breaks rooms on Synapse --- lib/models/content/power_levels.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart index 8b1dba9..c902534 100644 --- a/lib/models/content/power_levels.dart +++ b/lib/models/content/power_levels.dart @@ -24,10 +24,7 @@ class const PowerLevelsContent({ @Freezed(toJson: false, fromJson: false) @JsonSerializable() -class const Notifications({ - final int room = 50, - final IMap other = const .empty(), -}) with _$Notifications { +class const Notifications({final int room = 50}) with _$Notifications { Map toJson() => _$NotificationsToJson(this); factory Notifications.fromJson(Map json) => From 2041b81141fafe3a27c90640812e8e49c2753f5a Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 25 Oct 2026 18:30:28 -0400 Subject: [PATCH 20/27] add toJson to all content models Fixes a regression from the primary constructor content migration --- lib/models/content/avatar.dart | 3 +++ lib/models/content/canonical_alias.dart | 3 +++ lib/models/content/create.dart | 3 +++ lib/models/content/encryption.dart | 3 +++ lib/models/content/history_visibility.dart | 3 +++ lib/models/content/image_pack.dart | 3 +++ lib/models/content/join_rules.dart | 3 +++ lib/models/content/membership.dart | 3 +++ lib/models/content/name.dart | 3 +++ lib/models/content/pinned_events.dart | 3 +++ lib/models/content/power_levels.dart | 3 +++ lib/models/content/redaction.dart | 3 +++ lib/models/content/server_acl.dart | 3 +++ lib/models/content/sticker.dart | 3 +++ lib/models/content/topic.dart | 3 +++ 15 files changed, 45 insertions(+) diff --git a/lib/models/content/avatar.dart b/lib/models/content/avatar.dart index a5ce877..9efdb64 100644 --- a/lib/models/content/avatar.dart +++ b/lib/models/content/avatar.dart @@ -9,6 +9,9 @@ part "avatar.g.dart"; class const AvatarContent({final ImageInfo? info, final Uri? url}) extends Content with _$AvatarContent { + @override + Map toJson() => _$AvatarContentToJson(this); + factory AvatarContent.fromJson(Map json) => _$AvatarContentFromJson(json); } diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart index f8bca3a..058c007 100644 --- a/lib/models/content/canonical_alias.dart +++ b/lib/models/content/canonical_alias.dart @@ -10,6 +10,9 @@ class const CanonicalAliasContent({ @JsonKey(includeIfNull: false) final String? alias, final ISet altAliases = const .empty(), }) extends Content with _$CanonicalAliasContent { + @override + Map toJson() => _$CanonicalAliasContentToJson(this); + factory CanonicalAliasContent.fromJson(Map json) => _$CanonicalAliasContentFromJson(json); } diff --git a/lib/models/content/create.dart b/lib/models/content/create.dart index 18353df..1f9f125 100644 --- a/lib/models/content/create.dart +++ b/lib/models/content/create.dart @@ -14,6 +14,9 @@ class const CreateContent({ final String roomVersion = "1", @JsonKey(unknownEnumValue: RoomType.room) final RoomType? type, }) extends Content with _$CreateContent { + @override + Map toJson() => _$CreateContentToJson(this); + factory CreateContent.fromJson(Map json) => _$CreateContentFromJson(json); } diff --git a/lib/models/content/encryption.dart b/lib/models/content/encryption.dart index 5839132..cb7f1cd 100644 --- a/lib/models/content/encryption.dart +++ b/lib/models/content/encryption.dart @@ -10,6 +10,9 @@ class const EncryptionContent({ @JsonKey(name: "rotation_period_ms") final int rotationPeriodMS = 604800000, @JsonKey(name: "rotation_period_msgs") final int rotationPeriodMessages = 100, }) extends Content with _$EncryptionContent { + @override + Map toJson() => _$EncryptionContentToJson(this); + factory EncryptionContent.fromJson(Map json) => _$EncryptionContentFromJson(json); } diff --git a/lib/models/content/history_visibility.dart b/lib/models/content/history_visibility.dart index f44fc91..516cac8 100644 --- a/lib/models/content/history_visibility.dart +++ b/lib/models/content/history_visibility.dart @@ -9,6 +9,9 @@ class const HistoryVisibilityContent({ @JsonKey(unknownEnumValue: HistoryVisibility.unknown) required final HistoryVisibility historyVisibility, }) extends Content with _$HistoryVisibilityContent { + @override + Map toJson() => _$HistoryVisibilityContentToJson(this); + factory HistoryVisibilityContent.fromJson(Map json) => _$HistoryVisibilityContentFromJson(json); } diff --git a/lib/models/content/image_pack.dart b/lib/models/content/image_pack.dart index 93e9835..9668fbe 100644 --- a/lib/models/content/image_pack.dart +++ b/lib/models/content/image_pack.dart @@ -22,6 +22,9 @@ class const ImagePackContent({ }, ); + @override + Map toJson() => _$ImagePackContentToJson(this); + factory ImagePackContent.fromJson(Map json) => _$ImagePackContentFromJson(json); } diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart index 4eb09f7..4f1a929 100644 --- a/lib/models/content/join_rules.dart +++ b/lib/models/content/join_rules.dart @@ -11,6 +11,9 @@ class const JoinRulesContent({ @JsonKey(unknownEnumValue: JoinRule.unknown) required final JoinRule joinRule, final ISet allow = const .empty(), }) extends Content with _$JoinRulesContent { + @override + Map toJson() => _$JoinRulesContentToJson(this); + factory JoinRulesContent.fromJson(Map json) => _$JoinRulesContentFromJson(json); } diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index 3e8d181..e082bf6 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -18,6 +18,9 @@ class const MembershipContent({ static String? displaynameFromJson(String? displayName) => displayName?.isEmpty == true ? null : displayName; + @override + Map toJson() => _$MembershipContentToJson(this); + factory MembershipContent.fromJson(Map json) => _$MembershipContentFromJson(json); } diff --git a/lib/models/content/name.dart b/lib/models/content/name.dart index 6f53eb5..74f583d 100644 --- a/lib/models/content/name.dart +++ b/lib/models/content/name.dart @@ -8,6 +8,9 @@ part "name.g.dart"; class const NameContent({required final String name}) extends Content with _$NameContent { + @override + Map toJson() => _$NameContentToJson(this); + factory NameContent.fromJson(Map json) => _$NameContentFromJson(json); } diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart index 7f309c4..cf322af 100644 --- a/lib/models/content/pinned_events.dart +++ b/lib/models/content/pinned_events.dart @@ -9,6 +9,9 @@ part "pinned_events.g.dart"; class const PinnedEventsContent({ @JsonKey(name: "pinned") final IList pinnedEvents = const .empty(), }) extends Content with _$PinnedEventsContent { + @override + Map toJson() => _$PinnedEventsContentToJson(this); + factory PinnedEventsContent.fromJson(Map json) => _$PinnedEventsContentFromJson(json); } diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart index c902534..cdffd68 100644 --- a/lib/models/content/power_levels.dart +++ b/lib/models/content/power_levels.dart @@ -18,6 +18,9 @@ class const PowerLevelsContent({ final int stateDefault = 50, final int usersDefault = 0, }) extends Content with _$PowerLevelsContent { + @override + Map toJson() => _$PowerLevelsContentToJson(this); + factory PowerLevelsContent.fromJson(Map json) => _$PowerLevelsContentFromJson(json); } diff --git a/lib/models/content/redaction.dart b/lib/models/content/redaction.dart index 9b855f8..9864c1f 100644 --- a/lib/models/content/redaction.dart +++ b/lib/models/content/redaction.dart @@ -8,6 +8,9 @@ part "redaction.g.dart"; class const RedactionContent({final String? reason, final String? redacts}) extends Content with _$RedactionContent { + @override + Map toJson() => _$RedactionContentToJson(this); + factory RedactionContent.fromJson(Map json) => _$RedactionContentFromJson(json); } diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart index 1bfcfdb..3c33072 100644 --- a/lib/models/content/server_acl.dart +++ b/lib/models/content/server_acl.dart @@ -11,6 +11,9 @@ class const ServerACLContent({ final IList deny = const .empty(), final bool allowIpLiterals = true, }) extends Content with _$ServerACLContent { + @override + Map toJson() => _$ServerACLContentToJson(this); + factory ServerACLContent.fromJson(Map json) => _$ServerACLContentFromJson(json); } diff --git a/lib/models/content/sticker.dart b/lib/models/content/sticker.dart index 2b3e0fa..a47a576 100644 --- a/lib/models/content/sticker.dart +++ b/lib/models/content/sticker.dart @@ -11,6 +11,9 @@ class const StickerContent({ required final ImageInfo info, required final Uri url, }) extends Content with _$StickerContent { + @override + Map toJson() => _$StickerContentToJson(this); + factory StickerContent.fromJson(Map json) => _$StickerContentFromJson(json); } diff --git a/lib/models/content/topic.dart b/lib/models/content/topic.dart index 13370f4..cd187fa 100644 --- a/lib/models/content/topic.dart +++ b/lib/models/content/topic.dart @@ -11,6 +11,9 @@ class const TopicContent({ required final String topic, @JsonKey(name: "m.topic") final TopicContentBlock? content, }) extends Content with _$TopicContent { + @override + Map toJson() => _$TopicContentToJson(this); + factory TopicContent.fromJson(Map json) => _$TopicContentFromJson(json); } From 4b329b4c6f51b91099e13fda2ca4ccf0075e7046 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 26 Oct 2026 12:05:45 -0400 Subject: [PATCH 21/27] add settings for image packs Closes #131 --- lib/controllers/account_data.dart | 40 +++ lib/controllers/enabled_image_packs.dart | 69 +++++ lib/controllers/image_pack_sources.dart | 45 +--- lib/controllers/image_packs.dart | 41 +-- lib/controllers/room_image_packs.dart | 37 +++ lib/controllers/room_settings_sections.dart | 158 ++++++++++- lib/controllers/settings_sections.dart | 107 ++++++++ .../extensions/build_event_options.dart | 2 +- lib/helpers/extensions/room_image_packs.dart | 34 +++ lib/models/account_data.dart | 26 ++ lib/models/content/image_pack.dart | 17 +- lib/models/image_pack.dart | 37 ++- lib/widgets/composer/composer.dart | 46 ++-- .../composer/overlays/emoji_overlay.dart | 5 + lib/widgets/emoji_picker.dart | 16 +- lib/widgets/image_pack_avatar.dart | 14 + .../settings/enable_image_packs_sheet.dart | 118 +++++++++ lib/widgets/settings/image_pack_sheet.dart | 250 ++++++++++++++++++ lib/widgets/settings/image_usage_chips.dart | 38 +++ lib/widgets/settings/pack_image_sheet.dart | 134 ++++++++++ 20 files changed, 1115 insertions(+), 119 deletions(-) create mode 100644 lib/controllers/enabled_image_packs.dart create mode 100644 lib/controllers/room_image_packs.dart create mode 100644 lib/helpers/extensions/room_image_packs.dart create mode 100644 lib/widgets/image_pack_avatar.dart create mode 100644 lib/widgets/settings/enable_image_packs_sheet.dart create mode 100644 lib/widgets/settings/image_pack_sheet.dart create mode 100644 lib/widgets/settings/image_usage_chips.dart create mode 100644 lib/widgets/settings/pack_image_sheet.dart diff --git a/lib/controllers/account_data.dart b/lib/controllers/account_data.dart index 551c0cc..d98e4ca 100644 --- a/lib/controllers/account_data.dart +++ b/lib/controllers/account_data.dart @@ -1,8 +1,10 @@ +import "dart:async"; import "dart:convert"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; import "package:nexus/models/account_data.dart"; class AccountDataController extends Notifier { @@ -22,6 +24,44 @@ class AccountDataController extends Notifier { } }); + Future setEnabledImagePacks(ImagePackRooms packs) async { + ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries + .expand((entry) => entry.value.keys.map((key) => (entry.key, key))) + .toISet(); + final keys = keysOf(packs); + bool arrived(AccountData data) { + final current = keysOf(data.enabledImagePacks); + return current.length == keys.length && current.containsAll(keys); + } + + final completer = Completer(); + final removeListener = listenSelf((_, data) { + if (arrived(data) && !completer.isCompleted) completer.complete(); + }); + + try { + final client = ref.read(ClientController.provider.notifier); + await Future.wait([ + if (state.imagePackRooms != null) + client.setAccountData( + .new(type: AccountData.imagePackRoomsKey, content: packs), + ), + client.setAccountData( + .new(type: AccountData.legacyImagePackRoomsKey, content: packs), + ), + ]); + + if (!arrived(state)) { + await completer.future.timeout( + const .new(seconds: 10), + onTimeout: () {}, + ); + } + } finally { + removeListener(); + } + } + static final provider = NotifierProvider( AccountDataController.new, ); diff --git a/lib/controllers/enabled_image_packs.dart b/lib/controllers/enabled_image_packs.dart new file mode 100644 index 0000000..c16799c --- /dev/null +++ b/lib/controllers/enabled_image_packs.dart @@ -0,0 +1,69 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/requests/get_specific_room_state.dart"; + +/// The image packs the user has enabled everywhere, as room IDs to their +/// packs' state keys, once the packs' state has been loaded. +class EnabledImagePacksController + extends AsyncNotifier>> { + @override + Future>> build() async { + final enabledPacks = ref.watch( + AccountDataController.provider.select( + (data) => data.enabledImagePacks.rooms.map( + (roomId, packs) => .new(roomId, packs.keys.toISet()), + ), + ), + ); + + const packTypes = [.imagePack, .legacyImagePack]; + // Rooms we aren't in can't be fetched, and fetched rooms have every pack. + final missingKeys = ref.watch( + RoomsController.provider.select( + (rooms) => enabledPacks.entries + .where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true)) + .expand( + (entry) => entry.value + .where( + (stateKey) => packTypes.every( + (type) => + rooms[entry.key]?.state[type.type]?[stateKey] == null, + ), + ) + .expand( + (stateKey) => packTypes.map( + (type) => RoomStateKey( + roomId: entry.key, + type: type.type, + stateKey: stateKey, + ), + ), + ), + ) + .toIList(), + ), + ); + + if (missingKeys.isNotEmpty) { + ref + .read(RoomsController.provider.notifier) + .addSpecificState( + await ref + .read(ClientController.provider.notifier) + .getSpecificRoomState(.new(keys: missingKeys)), + ); + } + + return enabledPacks; + } + + static final provider = + AsyncNotifierProvider.autoDispose< + EnabledImagePacksController, + IMap> + >(EnabledImagePacksController.new); +} diff --git a/lib/controllers/image_pack_sources.dart b/lib/controllers/image_pack_sources.dart index db704f3..61e4a2e 100644 --- a/lib/controllers/image_pack_sources.dart +++ b/lib/controllers/image_pack_sources.dart @@ -1,11 +1,9 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/enabled_image_packs.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.dart"; -import "package:nexus/models/content/content.dart"; -import "package:nexus/models/requests/get_specific_room_state.dart"; typedef ImagePackSource = ({String roomId, String? stateKey}); @@ -13,14 +11,8 @@ class ImagePackSourcesController(final String roomId) extends AsyncNotifier> { @override Future> build() async { - final enabledPacks = ref.watch( - AccountDataController.provider.select( - (data) => - (data.imagePackRooms ?? data.legacyImagePackRooms)?.rooms.map( - (roomId, packs) => .new(roomId, packs.keys.toISet()), - ) ?? - const .empty(), - ), + final enabledPacksFuture = ref.watch( + EnabledImagePacksController.provider.future, ); final spaceEdges = ref.watch(SpaceEdgesController.provider); final directParents = ref.watch( @@ -63,36 +55,7 @@ class ImagePackSourcesController(final String roomId) ), ), ); - - const packTypes = [.imagePack, .legacyImagePack]; - // Rooms we aren't in can't be fetched, and fetched rooms have every pack - final missingKeys = enabledPacks.entries - .where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true)) - .expand( - (entry) => entry.value - .where( - (stateKey) => packTypes.every( - (type) => - rooms[entry.key]?.state[type.type]?[stateKey] == null, - ), - ) - .expand( - (stateKey) => packTypes.map( - (type) => RoomStateKey( - roomId: entry.key, - type: type.type, - stateKey: stateKey, - ), - ), - ), - ) - .toIList(); - - if (missingKeys.isNotEmpty) { - roomsNotifier.addSpecificState( - await client.getSpecificRoomState(.new(keys: missingKeys)), - ); - } + final enabledPacks = await enabledPacksFuture; return .new([ (roomId: roomId, stateKey: null), diff --git a/lib/controllers/image_packs.dart b/lib/controllers/image_packs.dart index f59f491..9248211 100644 --- a/lib/controllers/image_packs.dart +++ b/lib/controllers/image_packs.dart @@ -2,9 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/image_pack_sources.dart"; import "package:nexus/controllers/rooms.dart"; -import "package:nexus/models/content/content.dart"; -import "package:nexus/models/content/image_pack.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/helpers/extensions/room_image_packs.dart"; import "package:nexus/models/image_pack.dart"; class ImagePacksController(final String roomId) @@ -18,40 +16,13 @@ class ImagePacksController(final String roomId) final packs = ref.watch( RoomsController.provider.select( (rooms) => sources.expand((source) { - final room = rooms[source.roomId]; - if (room == null) return const []; - - final newPacks = room.state[EventType.imagePack.type]; - final legacyPacks = room.state[EventType.legacyImagePack.type]; - final roomName = room.metadata?.name ?? source.roomId; + final packs = + rooms[source.roomId]?.imagePacks(source.roomId) ?? const .empty(); return switch (source.stateKey) { - final stateKey? => ISet([stateKey]), - null => ISet( - newPacks?.keys ?? const [], - ).addAll(legacyPacks?.keys ?? const []), - } - .map( - (stateKey) => - switch (newPacks?[stateKey] ?? legacyPacks?[stateKey]) { - final rowId? => switch (room.events[rowId]) { - Event( - redactedBy: null, - content: final ImagePackContent content, - ) => - ImagePack.fromContent( - content, - id: "${source.roomId}/$stateKey", - fallbackName: stateKey.isEmpty - ? roomName - : "$roomName - $stateKey", - ), - _ => null, - }, - null => null, - }, - ) - .nonNulls; + final stateKey? => [?packs[stateKey]], + null => packs.values, + }; }).toIList(), ), ); diff --git a/lib/controllers/room_image_packs.dart b/lib/controllers/room_image_packs.dart new file mode 100644 index 0000000..318d388 --- /dev/null +++ b/lib/controllers/room_image_packs.dart @@ -0,0 +1,37 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/room_image_packs.dart"; +import "package:nexus/models/image_pack.dart"; + +/// The image packs in [roomId]'s own state by state key, loading its state if +/// it hasn't been yet. +class RoomImagePacksController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + if (ref.read(RoomsController.provider)[roomId] case final room? + when !room.hasFetchedState) { + ref + .read(RoomsController.provider.notifier) + .addState( + roomId, + await ref + .read(ClientController.provider.notifier) + .getRoomState(.new(roomId: roomId)), + ); + } + + return ref.watch( + RoomsController.provider.select( + (rooms) => rooms[roomId]?.imagePacks(roomId) ?? const .empty(), + ), + ); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + RoomImagePacksController.new, + ); +} diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index 6c266a9..a821164 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -7,6 +7,7 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/local_aliases.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/power_levels_saving.dart"; +import "package:nexus/controllers/room_image_packs.dart"; import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.dart"; @@ -21,6 +22,7 @@ import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/content/history_visibility.dart"; +import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; @@ -33,10 +35,12 @@ 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/settings/add_users_power_level_sheet.dart"; import "package:nexus/widgets/settings/aliases_sheet.dart"; import "package:nexus/widgets/settings/allowed_rooms_sheet.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/settings/image_pack_sheet.dart"; import "package:nexus/widgets/settings/local_aliases_sheet.dart"; import "package:nexus/widgets/settings/power_level_entry_sheet.dart"; import "package:nexus/widgets/settings/power_level_tile.dart"; @@ -91,6 +95,7 @@ class RoomSettingsSectionsController(final String roomId) final canSetJoinRules = canSet(.joinRules); final canSetHistoryVisibility = canSet(.historyVisibility); final canSetAliases = canSet(.canonicalAlias); + final canCreatePacks = canSet(.imagePack); final canSetPowerLevels = canSet(.powerLevels) && !ref.watch(PowerLevelsSavingController.provider(roomId)); @@ -161,15 +166,78 @@ class RoomSettingsSectionsController(final String roomId) final encrypted = stateContent(.encryption) is EncryptionContent; final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted); - Future setState(EventType type, Content content) async { + Future setState( + EventType type, + Content content, { + String stateKey = "", + }) async { final eventId = await client.setState( - .new(roomId: roomId, type: type.type, stateKey: "", content: content), + .new( + roomId: roomId, + type: type.type, + stateKey: stateKey, + content: content, + ), ); if (eventId case final eventId?) { - await roomsController.awaitState(roomId, type.type, eventId); + await roomsController.awaitState( + roomId, + type.type, + eventId, + stateKey: stateKey, + ); } } + final imagePacks = + ref.watch(RoomImagePacksController.provider(roomId)).value ?? + const .empty(); + + // Pack sheets can stay open while the state changes, so these read it when + // called, rather than using the state from building + IMap> currentState() => + ref.read(RoomsController.provider)[roomId]?.state ?? const .empty(); + + // Whether the legacy type still has a pack at [stateKey], to clear it + bool hasLegacyPack(String stateKey) => + switch (currentState()[EventType.legacyImagePack.type]?[stateKey]) { + final rowId? => switch (contentOf(rowId)) { + final ImagePackContent content => content.hasContent, + _ => false, + }, + null => false, + }; + final canSetLegacyPacks = canSet(.legacyImagePack); + + /// Saves the pack at [stateKey] as the stable type, migrating it by + /// clearing its legacy event if it has one. + Future setPack(String stateKey, Content content) async { + await setState(.imagePack, content, stateKey: stateKey); + if (hasLegacyPack(stateKey)) { + await setState(.legacyImagePack, const .new(), stateKey: stateKey); + } + } + + /// A state key for a new pack named [name]. It's one no pack has ever + /// had, even deleted ones, as users may still have those enabled. + String newPackKey(String name) { + final state = currentState(); + final usedKeys = [ + EventType.imagePack, + EventType.legacyImagePack, + ].expand((type) => state[type.type]?.keys ?? []).toISet(); + final slug = name + .toLowerCase() + .replaceAll(RegExp(r"[^\p{L}\p{N}_-]+", unicode: true), "_") + .replaceAll(RegExp(r"^_+|_+$"), ""); + final base = slug.isEmpty ? "pack" : slug; + + return Iterable.generate( + usedKeys.length + 1, + (index) => index == 0 ? base : "${base}_${index + 1}", + ).firstWhere((key) => !usedKeys.contains(key)); + } + Future setAliases( String? canonical, ISet alternatives, @@ -817,6 +885,90 @@ class RoomSettingsSectionsController(final String roomId) ), ]), ), + .new( + title: "Image Packs", + icon: Icons.emoji_emotions, + settings: .new([ + ...imagePacks.entries.map((entry) { + final stateKey = entry.key; + final pack = entry.value; + final canEdit = + canCreatePacks && + (canSetLegacyPacks || !hasLegacyPack(stateKey)); + + return Setting( + title: pack.name, + description: pack.imageCountLabel, + icon: Icons.emoji_emotions, + builder: (title, description, _) => HookBuilder( + key: ValueKey(stateKey), + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: ImagePackAvatar(pack.icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => ImagePackSheet( + title: canEdit ? "Edit Image Pack" : title, + content: pack.content, + onSave: canEdit + ? (content) => saving.run( + () => setPack( + stateKey, + content, + ).onError(showError), + ) + : null, + onDeleted: canEdit + ? () => saving.run( + () => setPack( + stateKey, + const .new(), + ).onError(showError), + ) + : null, + ), + ), + ); + }, + ), + ); + }), + .new( + title: "Create Pack", + description: "Add a pack of emoticons and stickers to the room.", + icon: Icons.add, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: canCreatePacks && !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => ImagePackSheet( + title: "Create Pack", + content: const .new(), + onSave: (content) => saving.run( + () => setState( + .imagePack, + content, + stateKey: newPackKey(content.pack!.displayName!), + ).onError(showError), + ), + ), + ), + ); + }, + ), + ), + ]), + ), ]), }); } diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 1a98a43..b74f50d 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -9,23 +9,31 @@ import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/enabled_image_packs.dart"; import "package:nexus/controllers/init_complete.dart"; import "package:nexus/controllers/notification.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/spec_versions.dart"; import "package:nexus/controllers/unified_push_allowed.dart"; import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/helpers/extensions/distributor_name.dart"; +import "package:nexus/helpers/extensions/room_image_packs.dart"; +import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/client_state.dart"; +import "package:nexus/models/image_pack.dart"; import "package:nexus/models/name_color.dart"; import "package:nexus/models/room_order.dart"; +import "package:nexus/models/setting.dart"; 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/image_pack_avatar.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/settings/enable_image_packs_sheet.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; @@ -36,6 +44,33 @@ class SettingsSectionsController Future>> build() async { final settings = await ref.watch(SettingsController.provider.future); + // Loads the enabled packs' state, so they can be shown + ref.watch(EnabledImagePacksController.provider); + final enabledPacks = ref.watch( + AccountDataController.provider.select((data) => data.enabledImagePacks), + ); + final enabledPackInfo = ref.watch( + RoomsController.provider.select( + (rooms) => enabledPacks.rooms.entries.expand((entry) { + final room = rooms[entry.key]; + final packs = room?.imagePacks(entry.key) ?? const .empty(); + + return entry.value.keys.map( + (stateKey) => ( + roomId: entry.key, + stateKey: stateKey, + roomName: room?.metadata?.name ?? entry.key, + pack: packs[stateKey], + ), + ); + }).toIList(), + ), + ); + + Future setEnabledPacks(ImagePackRooms packs) => ref + .read(AccountDataController.provider.notifier) + .setEnabledImagePacks(packs); + /// A tile for a [ShowAndSend] setting, saving the settings from [update]. Widget showAndSendTile( String title, @@ -300,6 +335,78 @@ class SettingsSectionsController ), ]), ), + .new( + title: "Image Packs", + icon: Icons.emoji_emotions, + settings: .new([ + ...enabledPackInfo.map( + (info) => Setting( + title: + info.pack?.name ?? + ImagePack.fallbackName(info.roomName, info.stateKey), + description: info.roomName, + icon: Icons.emoji_emotions, + builder: (title, description, _) => HookBuilder( + key: ValueKey((info.roomId, info.stateKey)), + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: ImagePackAvatar(info.pack?.icon), + title: Text(title), + subtitle: Text(description), + trailing: IconButton( + tooltip: "Remove pack", + icon: Icon(Icons.delete), + onPressed: saving.active + ? null + : () => saving.run( + () => setEnabledPacks( + ref + .read(AccountDataController.provider) + .enabledImagePacks + .withPack( + info.roomId, + info.stateKey, + enabled: false, + ), + ).onError(showError), + ), + ), + ); + }, + ), + ), + ), + .new( + title: "Add Packs", + description: + "Use image packs from any of your rooms in every room.", + icon: Icons.add, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => EnableImagePacksSheet( + enabled: enabledPacks, + onSave: (packs) => saving.run( + () => setEnabledPacks(packs).onError(showError), + ), + ), + ), + ); + }, + ), + ), + ]), + ), .new( title: "Notifications", icon: Icons.notifications, diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index 54b952d..e96284b 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -144,7 +144,7 @@ extension BuildEventOptions on Event { (context) => EmojiPicker( roomId: roomId, allowFreeText: true, - onSelection: (emoji, {shortcode}) { + onSelection: (emoji, {shortcode, description}) { Navigator.of(context).pop(); ref .read(notifier) diff --git a/lib/helpers/extensions/room_image_packs.dart b/lib/helpers/extensions/room_image_packs.dart new file mode 100644 index 0000000..9b9d9ce --- /dev/null +++ b/lib/helpers/extensions/room_image_packs.dart @@ -0,0 +1,34 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/image_pack.dart"; +import "package:nexus/models/room.dart"; + +extension RoomImagePacks on Room { + /// The room's image packs by state key, preferring the stable type when a + /// pack has both, and leaving out deleted ones. [roomId] names those without + /// a name when the room has none either. + IMap imagePacks(String roomId) => .fromEntries( + const [EventType.legacyImagePack, EventType.imagePack].expand( + (type) => (state[type.type] ?? .new()).entries + .map( + (entry) => switch (events[entry.value]) { + Event(redactedBy: null, content: final ImagePackContent content) + when content.hasContent => + MapEntry( + entry.key, + ImagePack.fromContent( + content, + roomId: roomId, + roomName: metadata?.name ?? roomId, + stateKey: entry.key, + ), + ), + _ => null, + }, + ) + .nonNulls, + ), + ); +} diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index 2e21937..88da5a0 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -29,6 +29,9 @@ class const AccountData({ static const imagePackRoomsKey = "m.image_pack.rooms"; static const legacyImagePackRoomsKey = "im.ponies.emote_rooms"; + ImagePackRooms get enabledImagePacks => + imagePackRooms ?? legacyImagePackRooms ?? const .new(); + Map toJson() => _$AccountDataToJson(this); factory AccountData.fromJson(Map json) => @@ -53,6 +56,29 @@ class const ImagePackRooms({ /// empty) object final IMap>> rooms = const .empty(), }) with _$ImagePackRooms { + bool contains(String roomId, String stateKey) => + rooms[roomId]?.containsKey(stateKey) ?? false; + + ImagePackRooms withPack( + String roomId, + String stateKey, { + required bool enabled, + }) { + final packs = + rooms[roomId] ?? const IMap>.empty(); + final updated = switch ((enabled, packs.containsKey(stateKey))) { + (true, false) => packs.add(stateKey, const {}), + (false, true) => packs.remove(stateKey), + _ => packs, + }; + + return copyWith( + rooms: updated.isEmpty + ? rooms.remove(roomId) + : rooms.add(roomId, updated), + ); + } + Map toJson() => _$ImagePackRoomsToJson(this); factory ImagePackRooms.fromJson(Map json) => diff --git a/lib/models/content/image_pack.dart b/lib/models/content/image_pack.dart index 9668fbe..5bcbb54 100644 --- a/lib/models/content/image_pack.dart +++ b/lib/models/content/image_pack.dart @@ -7,11 +7,14 @@ part "image_pack.g.dart"; /// An image pack (MSC2545), from a room's state. @Freezed(toJson: false, fromJson: false) -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class const ImagePackContent({ final IMap images = const .empty(), final PackInfo? pack, }) extends Content with _$ImagePackContent { + /// Whether the pack has images or a name, as deleted packs are left empty. + bool get hasContent => images.isNotEmpty || pack?.displayName != null; + IMap imagesFor(ImageUsage usage) => images.where( (_, image) => switch ((image.usage, pack?.usage ?? const .empty())) { (final usages, _) when usages.isNotEmpty => usages.contains(usage), @@ -30,12 +33,12 @@ class const ImagePackContent({ } @Freezed(toJson: false, fromJson: false) -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class const PackImage({ required final Uri url, final String? body, final ImageInfo? info, - @JsonKey(fromJson: ImageUsage.setFromJson) + @JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson) final ISet usage = const .empty(), }) with _$PackImage { Map toJson() => _$PackImageToJson(this); @@ -45,12 +48,12 @@ class const PackImage({ } @Freezed(toJson: false, fromJson: false) -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class const PackInfo({ final String? displayName, final Uri? avatarUrl, final String? attribution, - @JsonKey(fromJson: ImageUsage.setFromJson) + @JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson) final ISet usage = const .empty(), }) with _$PackInfo { Map toJson() => _$PackInfoToJson(this); @@ -66,4 +69,8 @@ enum ImageUsage { /// Ignores usages we don't know about. static ISet setFromJson(List? json) => ISet(values.where((value) => json?.contains(value.name) ?? false)); + + /// Omits an empty set, which means every usage. + static List? setToJson(ISet usages) => + usages.isEmpty ? null : usages.map((usage) => usage.name).toList(); } diff --git a/lib/models/image_pack.dart b/lib/models/image_pack.dart index bfe1af0..33528c2 100644 --- a/lib/models/image_pack.dart +++ b/lib/models/image_pack.dart @@ -13,21 +13,42 @@ class const ImagePack({ /// Map of shortcode to images required final IMap emoticons, required final IMap stickers, + + /// The state event the pack is from, for editing it. + required final ImagePackContent content, }) with _$ImagePack { - static String emojiMarkdown(String shortcode, String url) { - final title = ":$shortcode:".replaceAll(r"\", r"\\").replaceAll('"', r'\"'); - return '![:$shortcode:]($url "Emoji: $title")'; - } + /// The pack's avatar, or its first image if it has none. + Uri? get icon => avatar ?? content.images.values.firstOrNull?.url; + + String get imageCountLabel => content.images.length == 1 + ? "1 image" + : "${content.images.length} images"; + + /// The name of a pack without one, from its room's name and state key. + static String fallbackName(String roomName, String stateKey) => + stateKey.isEmpty ? roomName : "$roomName - $stateKey"; + + static String emojiMarkdown( + String shortcode, + String url, { + String? description, + }) => + '![:$shortcode:]($url "Emoji: ${switch (description) { + final description? when description.isNotEmpty => description, + _ => ":$shortcode:", + }.replaceAll(r"\", r"\\").replaceAll('"', r'\"')}")'; factory ImagePack.fromContent( ImagePackContent content, { - required String id, - required String fallbackName, + required String roomId, + required String roomName, + required String stateKey, }) => .new( - id: id, - name: content.pack?.displayName ?? fallbackName, + id: "$roomId/$stateKey", + name: content.pack?.displayName ?? fallbackName(roomName, stateKey), avatar: content.pack?.avatarUrl, emoticons: content.imagesFor(.emoticon), stickers: content.imagesFor(.sticker), + content: content, ); } diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 720fa58..18eebbf 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -271,28 +271,30 @@ class const Composer( onPressed: () => context.showAdaptiveSheet( (context) => EmojiPicker( roomId: roomId, - onSelection: (value, {shortcode}) { - Navigator.of(context).pop(); - if (shortcode == null) { - controller.text += value; - } else { - final text = "${controller.text}:"; - controller.value = .new( - text: text, - selection: .collapsed( - offset: text.length, - ), - ); - controller.addTag( - id: ImagePack.emojiMarkdown( - shortcode, - value, - ), - name: shortcode, - ); - } - node?.requestFocus(); - }, + onSelection: + (value, {shortcode, description}) { + Navigator.of(context).pop(); + if (shortcode == null) { + controller.text += value; + } else { + final text = "${controller.text}:"; + controller.value = .new( + text: text, + selection: .collapsed( + offset: text.length, + ), + ); + controller.addTag( + id: ImagePack.emojiMarkdown( + shortcode, + value, + description: description, + ), + name: shortcode, + ); + } + node?.requestFocus(); + }, ), ), icon: Icon(Icons.emoji_emotions), diff --git a/lib/widgets/composer/overlays/emoji_overlay.dart b/lib/widgets/composer/overlays/emoji_overlay.dart index a360604..3ff0d55 100644 --- a/lib/widgets/composer/overlays/emoji_overlay.dart +++ b/lib/widgets/composer/overlays/emoji_overlay.dart @@ -3,6 +3,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/all_emoji.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/image_pack.dart"; class const EmojiOverlay( @@ -39,6 +40,10 @@ class const EmojiOverlay( ? ImagePack.emojiMarkdown( emoji.aliases.first, emoji.value, + description: switch (emoji.data) { + PackImage(:final body) => body, + _ => null, + }, ) : emoji.value, name: emoji.aliases.first, diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart index 4a64f38..fb834c9 100644 --- a/lib/widgets/emoji_picker.dart +++ b/lib/widgets/emoji_picker.dart @@ -16,7 +16,12 @@ import "package:nexus/models/content/image_pack.dart"; class const EmojiPicker({ required final String roomId, - required final FutureOr Function(String value, {String? shortcode}) + /// Has the [shortcode] and [description] of images from packs. + required final FutureOr Function( + String value, { + String? shortcode, + String? description, + }) onSelection, final bool allowFreeText = false, super.key, @@ -41,11 +46,14 @@ class const EmojiPicker({ return upstream.EmojiPicker( onSelection: (emoji) async { + final image = switch (emoji.data) { + final PackImage image => image, + _ => null, + }; await onSelection( emoji.value, - shortcode: emoji.data is PackImage - ? emoji.aliases.first - : null, + shortcode: image == null ? null : emoji.aliases.first, + description: image?.body, ); await ref diff --git a/lib/widgets/image_pack_avatar.dart b/lib/widgets/image_pack_avatar.dart new file mode 100644 index 0000000..aaebca2 --- /dev/null +++ b/lib/widgets/image_pack_avatar.dart @@ -0,0 +1,14 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/mxc_emoji.dart"; + +class const ImagePackAvatar(final Uri? icon, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) => SizedBox.square( + dimension: 40, + child: switch (icon) { + final icon? => MxcEmoji(icon), + null => Icon(Icons.emoji_emotions), + }, + ); +} diff --git a/lib/widgets/settings/enable_image_packs_sheet.dart b/lib/widgets/settings/enable_image_packs_sheet.dart new file mode 100644 index 0000000..afd983b --- /dev/null +++ b/lib/widgets/settings/enable_image_packs_sheet.dart @@ -0,0 +1,118 @@ +import "package:collection/collection.dart"; +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/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/sheet_layout.dart"; + +/// Chooses a room, then which of its image packs to use in every room. +class const EnableImagePacksSheet({ + required final ImagePackRooms enabled, + required final void Function(ImagePackRooms enabled) onSave, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final chosen = useState(enabled); + final roomId = useState(null); + final rooms = ref.watch( + RoomsController.provider.select( + (rooms) => rooms.values + .map((room) => room.metadata) + .nonNulls + .map( + (metadata) => ( + id: metadata.id, + name: metadata.name ?? metadata.id, + avatar: metadata.avatar, + ), + ) + .sortedBy((room) => room.name.toLowerCase()) + .toIList(), + ), + ); + + return SheetLayout( + title: Text("Add Image Packs"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onSave(chosen.value); + }, + child: Text("Save"), + ), + ], + children: [ + Text("Packs you add can be used in every room."), + DropdownMenu( + expandedInsets: .zero, + menuHeight: 300, + enableFilter: true, + requestFocusOnTap: true, + label: Text("Room"), + onSelected: (id) => roomId.value = id, + dropdownMenuEntries: rooms + .map( + (room) => DropdownMenuEntry( + value: room.id, + label: room.name, + leadingIcon: AvatarOrHash( + room.avatar, + room.name, + fallback: Icon(Icons.numbers), + ), + ), + ) + .toList(), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: switch (roomId.value) { + final roomId? => + ref + .watch(RoomImagePacksController.provider(roomId)) + .betterWhen( + data: (packs) => packs.isEmpty + ? Text("This room has no image packs") + : Column( + mainAxisSize: .min, + children: packs.entries + .map( + (entry) => CheckboxListTile( + secondary: ImagePackAvatar( + entry.value.icon, + ), + title: Text(entry.value.name), + subtitle: Text(entry.value.imageCountLabel), + value: chosen.value.contains( + roomId, + entry.key, + ), + onChanged: (value) => + chosen.value = chosen.value.withPack( + roomId, + entry.key, + enabled: value ?? false, + ), + ), + ) + .toList(), + ), + ), + null => const SizedBox(width: .infinity), + }, + ), + ], + ); + } +} diff --git a/lib/widgets/settings/image_pack_sheet.dart b/lib/widgets/settings/image_pack_sheet.dart new file mode 100644 index 0000000..97f8599 --- /dev/null +++ b/lib/widgets/settings/image_pack_sheet.dart @@ -0,0 +1,250 @@ +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/client.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; +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/settings/image_usage_chips.dart"; +import "package:nexus/widgets/settings/pack_image_sheet.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Edits an image pack, or only shows it if [onSave] is null. +class const ImagePackSheet({ + required final String title, + required final ImagePackContent content, + required final void Function(ImagePackContent content)? onSave, + final VoidCallback? onDeleted, + + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final editable = onSave != null; + final nameController = useTextEditingController( + text: content.pack?.displayName, + ); + final name = useValueListenable(nameController).text.trim(); + final avatar = useState(content.pack?.avatarUrl); + final usage = useState(content.pack?.usage ?? const ISet.empty()); + final images = useState(content.images); + final uploading = Saving.use(); + final packUsage = usage.value.isEmpty + ? ISet(ImageUsage.values) + : usage.value; + final valid = !uploading.active && name.isNotEmpty; + + Future upload( + void Function(String filename, PackImage image) onUploaded, + ) async { + final file = await ref + .read(FileDialogHelper.provider) + .pickFile(images: true); + if (file == null || !context.mounted) return; + + await uploading.run(() async { + final content = await ref + .read(ClientController.provider.notifier) + .uploadMedia(.new(path: file.path, encrypt: false)); + if (!context.mounted) return; + + if (content case ImageMessageContent(:final url, :final info)) { + onUploaded(file.name, PackImage(url: url, info: info)); + } else { + throw Exception("The file isn't an image"); + } + }); + } + + void editImage(String shortcode, PackImage image, {bool isNew = false}) => + context.showAdaptiveSheet( + (_) => PackImageSheet( + title: isNew + ? "Add Image" + : editable + ? "Edit Image" + : ":$shortcode:", + shortcode: shortcode, + image: image, + taken: isNew + ? images.value.keys.toISet() + : images.value.keys.toISet().remove(shortcode), + packUsage: packUsage, + onSave: editable + ? (newShortcode, newImage) => images.value = isNew + ? images.value.add(newShortcode, newImage) + // Replaced in place, to keep the images' order + : .fromEntries( + images.value.entries.map( + (entry) => entry.key == shortcode + ? MapEntry(newShortcode, newImage) + : entry, + ), + ) + : null, + onRemoved: editable && !isNew + ? () => images.value = images.value.remove(shortcode) + : null, + ), + ); + + Future delete() async { + final confirmed = await context.showAdaptiveSheet( + (context) => SheetLayout( + title: Text("Delete Pack"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text("Cancel"), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Delete"), + ), + ], + children: [ + Text( + "Delete this pack? Its images will no longer be available here, or to anyone using it in other rooms.", + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + + Navigator.of(context).pop(); + onDeleted?.call(); + } + + void save() { + Navigator.of(context).pop(); + onSave!( + content.copyWith( + images: images.value, + pack: (content.pack ?? const PackInfo()).copyWith( + displayName: name, + avatarUrl: avatar.value, + usage: usage.value, + ), + ), + ); + } + + final textTheme = Theme.of(context).textTheme; + + return SheetLayout( + title: Text(title), + actions: [ + if (onDeleted != null) + TextButton( + onPressed: uploading.active ? null : delete, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Delete"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(editable ? "Cancel" : "Close"), + ), + if (editable) + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + TextField( + controller: nameController, + enabled: editable, + autofocus: true, + decoration: .new(labelText: "Name"), + ), + ListTile( + enabled: editable && !uploading.active, + leading: Icon(Icons.image), + title: Text("Avatar"), + subtitle: Text("The pack's first image if not set."), + trailing: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + ImagePackAvatar( + avatar.value ?? images.value.values.firstOrNull?.url, + ), + if (avatar.value != null && editable) + IconButton( + tooltip: "Remove avatar", + icon: Icon(Icons.delete), + onPressed: uploading.active + ? null + : () => avatar.value = null, + ), + ], + ), + onTap: () => + upload((_, image) => avatar.value = image.url).onError(showError), + ), + Text("Default usage", style: textTheme.titleSmall), + ImageUsageChips( + value: packUsage, + onChanged: editable + ? (value) => + usage.value = value.length == ImageUsage.values.length + ? const .empty() + : value + : null, + ), + Text("Images", style: textTheme.titleSmall), + if (images.value.isEmpty && !editable) Text("No images"), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + ...images.value.entries.map( + (entry) => Tooltip( + message: ":${entry.key}:", + child: InkWell( + borderRadius: .circular(8), + onTap: () => editImage(entry.key, entry.value), + child: Padding( + padding: .all(4), + child: SizedBox.square( + dimension: 56, + child: MxcEmoji(entry.value.url, label: entry.key), + ), + ), + ), + ), + ), + if (editable) + SizedBox.square( + dimension: 64, + child: uploading.active + ? Center(child: CircularProgressIndicator()) + : IconButton( + tooltip: "Add image", + icon: Icon(Icons.add_photo_alternate), + onPressed: () => upload( + (filename, image) => editImage( + filename + .split(".") + .first + .replaceAll(RegExp(r"[^\w-]"), ""), + image, + isNew: true, + ), + ).onError(showError), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/settings/image_usage_chips.dart b/lib/widgets/settings/image_usage_chips.dart new file mode 100644 index 0000000..e9d1fbb --- /dev/null +++ b/lib/widgets/settings/image_usage_chips.dart @@ -0,0 +1,38 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/image_pack.dart"; + +/// Chooses what images are used as, keeping at least one usage chosen. +class const ImageUsageChips({ + required final ISet value, + + /// Disables the chips if null. + required final void Function(ISet value)? onChanged, + super.key, +}) extends StatelessWidget { + static String nameOf(ImageUsage usage) => switch (usage) { + .emoticon => "Emoticons", + .sticker => "Stickers", + }; + + @override + Widget build(BuildContext context) => Wrap( + spacing: 8, + runSpacing: 8, + children: ImageUsage.values + .map( + (usage) => FilterChip( + label: Text(nameOf(usage)), + selected: value.contains(usage), + onSelected: + onChanged == null || + (value.length == 1 && value.contains(usage)) + ? null + : (selected) => onChanged!( + selected ? value.add(usage) : value.remove(usage), + ), + ), + ) + .toList(), + ); +} diff --git a/lib/widgets/settings/pack_image_sheet.dart b/lib/widgets/settings/pack_image_sheet.dart new file mode 100644 index 0000000..9ef70d6 --- /dev/null +++ b/lib/widgets/settings/pack_image_sheet.dart @@ -0,0 +1,134 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/widgets/mxc_emoji.dart"; +import "package:nexus/widgets/settings/image_usage_chips.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Edits an image in a pack, or only shows it if [onSave] is null. +class const PackImageSheet({ + required final String title, + required final String shortcode, + required final PackImage image, + + /// The other images' shortcodes, which this one can't also have. + required final ISet taken, + + /// The pack's default usage, which images have unless they set their own. + required final ISet packUsage, + required final void Function(String shortcode, PackImage image)? onSave, + final VoidCallback? onRemoved, + super.key, +}) extends HookWidget { + static final shortcodePattern = RegExp(r"^[\w-]+$"); + + @override + Widget build(BuildContext context) { + final shortcodeController = useTextEditingController(text: shortcode); + final bodyController = useTextEditingController(text: image.body); + final usage = useState(image.usage); + final newShortcode = useValueListenable(shortcodeController).text.trim(); + final error = taken.contains(newShortcode) + ? "Another image has this shortcode" + : newShortcode.isNotEmpty && !shortcodePattern.hasMatch(newShortcode) + ? "Only letters, numbers, - and _ are allowed" + : null; + final valid = newShortcode.isNotEmpty && error == null; + + void save() { + final body = bodyController.text.trim(); + Navigator.of(context).pop(); + onSave!( + newShortcode, + image.copyWith(body: body.isEmpty ? null : body, usage: usage.value), + ); + } + + return SheetLayout( + title: Text(title), + actions: [ + if (onRemoved case final onRemoved?) + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onRemoved(); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Remove"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(onSave == null ? "Close" : "Cancel"), + ), + if (onSave != null) + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + Center( + child: SizedBox.square( + dimension: 128, + child: MxcEmoji(image.url, label: shortcode), + ), + ), + TextField( + controller: shortcodeController, + enabled: onSave != null, + onSubmitted: valid ? (_) => save() : null, + decoration: .new( + labelText: "Shortcode", + prefixText: ":", + suffixText: ":", + errorText: error, + ), + ), + TextField( + controller: bodyController, + enabled: onSave != null, + onSubmitted: valid ? (_) => save() : null, + decoration: .new( + labelText: "Description", + hintText: "Read by screen readers", + ), + ), + Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + SwitchListTile( + title: Text("Custom usage"), + subtitle: Text( + usage.value.isEmpty + ? "Same as the pack's default (${packUsage.map(ImageUsageChips.nameOf).join(", ")})" + : "Used as chosen below, instead of the pack's default", + ), + value: usage.value.isNotEmpty, + onChanged: onSave == null + ? null + : (custom) => + usage.value = custom ? packUsage : const .empty(), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: usage.value.isEmpty + ? const SizedBox(width: .infinity) + : Padding( + padding: .only(top: 8), + child: ImageUsageChips( + value: usage.value, + onChanged: onSave == null + ? null + : (value) => usage.value = value, + ), + ), + ), + ], + ), + ], + ); + } +} From 1efbc131b4162cf0d24353f5b071b75a31667b46 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 26 Oct 2026 12:13:06 -0400 Subject: [PATCH 22/27] auto write desktop file on debug mode install on Linux Fixes #150 --- linux/CMakeLists.txt | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 2e0c766..7f5e185 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -126,3 +126,42 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() + +# On Debug builds, register this build as the handler for the app's URI +# scheme, so links like OAuth redirects open it, based on the packaged +# desktop file but running this bundle's binary. +if(CMAKE_BUILD_TYPE MATCHES "Debug") + install(CODE " + set(URI_DESKTOP_FILE \"${CMAKE_CURRENT_SOURCE_DIR}/${APPLICATION_ID}.uri.desktop\") + set(URI_DESKTOP_NAME \"${APPLICATION_ID}.uri.desktop\") + set(URI_BINARY_NAME \"${BINARY_NAME}\") + " COMPONENT Runtime) + install(CODE [[ + if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "") + set(applications_dir "$ENV{XDG_DATA_HOME}/applications") + else() + set(applications_dir "$ENV{HOME}/.local/share/applications") + endif() + + file(READ "${URI_DESKTOP_FILE}" desktop_entry) + string(REGEX REPLACE "(^|\n)Exec=${URI_BINARY_NAME} " + "\\1Exec=\"${CMAKE_INSTALL_PREFIX}/${URI_BINARY_NAME}\" " + desktop_entry "${desktop_entry}") + + # Only rewritten when it changes, so the desktop database isn't refreshed + # on every build + set(destination "${applications_dir}/${URI_DESKTOP_NAME}") + set(existing "") + if(EXISTS "${destination}") + file(READ "${destination}" existing) + endif() + if(NOT existing STREQUAL desktop_entry) + message(STATUS "Registering URI handler: ${destination}") + file(WRITE "${destination}" "${desktop_entry}") + execute_process( + COMMAND update-desktop-database "${applications_dir}" + RESULT_VARIABLE _ OUTPUT_QUIET ERROR_QUIET + ) + endif() + ]] COMPONENT Runtime) +endif() From f971c50df07105f15da06e34ca2c4472893586d4 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 26 Oct 2026 12:45:54 -0400 Subject: [PATCH 23/27] use a text span with a recognizer instead of a WidgetSpan Fixes displaynames not ellipsising properly --- lib/helpers/extensions/global_rect.dart | 8 +++ .../extensions/show_adaptive_popover.dart | 12 ++-- lib/helpers/extensions/show_user_popover.dart | 7 ++- lib/helpers/hooks/tap_recognizer.dart | 11 ++++ lib/widgets/composer/typing_indicator.dart | 54 ++++++++-------- lib/widgets/renderers/event.dart | 9 ++- lib/widgets/renderers/membership.dart | 42 +++++-------- lib/widgets/user_span.dart | 63 +++++++++++++++++++ 8 files changed, 139 insertions(+), 67 deletions(-) create mode 100644 lib/helpers/hooks/tap_recognizer.dart create mode 100644 lib/widgets/user_span.dart diff --git a/lib/helpers/extensions/global_rect.dart b/lib/helpers/extensions/global_rect.dart index 91c8305..4f2169f 100644 --- a/lib/helpers/extensions/global_rect.dart +++ b/lib/helpers/extensions/global_rect.dart @@ -12,4 +12,12 @@ extension GlobalRect on BuildContext { _ => Rect.zero, }; } + + /// [position] on screen as an empty rect, like where text was tapped, to + /// open a popover there. + Rect globalRectAt(Offset position) { + final overlay = Overlay.of(this).context.findRenderObject() as RenderBox; + + return overlay.globalToLocal(position) & .zero; + } } diff --git a/lib/helpers/extensions/show_adaptive_popover.dart b/lib/helpers/extensions/show_adaptive_popover.dart index 8a100e8..3d2a94c 100644 --- a/lib/helpers/extensions/show_adaptive_popover.dart +++ b/lib/helpers/extensions/show_adaptive_popover.dart @@ -5,20 +5,20 @@ import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/widgets/popover_layout.dart"; extension ShowAdaptivePopover on BuildContext { - /// Shows the widget from [builder] in a popover next to the widget this - /// context belongs to on desktop, or a bottom sheet on mobile. Use the - /// context passed to [builder] to close it. - Future showAdaptivePopover(WidgetBuilder builder) { + /// Shows the widget from [builder] in a popover next to [anchor], or the + /// widget this context belongs to, on desktop, or a bottom sheet on mobile. + /// Use the context passed to [builder] to close it. + Future showAdaptivePopover(WidgetBuilder builder, {Rect? anchor}) { if (!isDesktop) return showAdaptiveSheet(builder); - final anchor = globalRect; + final rect = anchor ?? globalRect; return showDialog( context: this, barrierColor: Colors.transparent, useSafeArea: false, builder: (context) => CustomSingleChildLayout( - delegate: PopoverLayout(anchor), + delegate: PopoverLayout(rect), child: ConstrainedBox( constraints: const .new(maxWidth: 420, maxHeight: 600), child: Material( diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index a56417e..ccf6898 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -3,6 +3,9 @@ import "package:nexus/helpers/extensions/show_adaptive_popover.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; extension ShowUserPopover on BuildContext { - void showUserPopover(String userId, {String? roomId}) => - showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId)); + void showUserPopover(String userId, {String? roomId, Rect? anchor}) => + showAdaptivePopover( + (context) => UserBottomSheet(userId, roomId: roomId), + anchor: anchor, + ); } diff --git a/lib/helpers/hooks/tap_recognizer.dart b/lib/helpers/hooks/tap_recognizer.dart new file mode 100644 index 0000000..97c5534 --- /dev/null +++ b/lib/helpers/hooks/tap_recognizer.dart @@ -0,0 +1,11 @@ +import "package:flutter/gestures.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +/// A recognizer for a tappable span in text, like a name, disposed with the +/// widget. +TapGestureRecognizer useTapRecognizer() { + final recognizer = useMemoized(TapGestureRecognizer.new); + useEffect(() => recognizer.dispose, [recognizer]); + + return recognizer; +} diff --git a/lib/widgets/composer/typing_indicator.dart b/lib/widgets/composer/typing_indicator.dart index e9eda3f..9624937 100644 --- a/lib/widgets/composer/typing_indicator.dart +++ b/lib/widgets/composer/typing_indicator.dart @@ -4,11 +4,12 @@ import "package:material_ui/material_ui.dart"; 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/extensions/show_user_popover.dart"; +import "package:nexus/helpers/hooks/tap_recognizer.dart"; import "package:nexus/widgets/bouncing_dots.dart"; +import "package:nexus/widgets/user_span.dart"; class const TypingIndicator(final String roomId, {super.key}) - extends ConsumerWidget { + extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); @@ -16,36 +17,31 @@ class const TypingIndicator(final String roomId, {super.key}) // Past three people, only the first two are named final named = typing.length > 3 ? 2 : typing.length; + // One for each person who can be named + final recognizers = [ + useTapRecognizer(), + useTapRecognizer(), + useTapRecognizer(), + ]; final subjects = [ ...typing .take(named) - .map( - (typingUserId) => WidgetSpan( - alignment: .baseline, - baseline: .alphabetic, - child: Builder( - builder: (context) => InkWell( - onTap: () => - context.showUserPopover(typingUserId, roomId: roomId), - child: Text( - ref - .watch( - UserController.provider( - .new(roomId: roomId, userId: typingUserId), - ), - ) - .value - ?.displayName ?? - typingUserId.localpart, - maxLines: 1, - overflow: .ellipsis, - softWrap: false, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: .bold, - ), - ), - ), - ), + .mapIndexed( + (index, typingUserId) => UserSpan( + context, + ref + .watch( + UserController.provider( + .new(roomId: roomId, userId: typingUserId), + ), + ) + .value + ?.displayName ?? + typingUserId.localpart, + userId: typingUserId, + roomId: roomId, + recognizer: recognizers[index], + style: TextStyle(fontWeight: .bold), ), ), if (typing.length > named) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 6fd3310..49c7b19 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -11,6 +11,7 @@ import "package:nexus/helpers/extensions/is_shown_as_message.dart"; import "package:nexus/helpers/extensions/show_adaptive_menu.dart"; import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/helpers/hooks/tap_recognizer.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/content.dart"; @@ -26,7 +27,6 @@ import "package:nexus/models/content/server_acl.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/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/read_receipts_row.dart"; @@ -39,6 +39,7 @@ 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/timestamp.dart"; +import "package:nexus/widgets/user_span.dart"; class const EventRenderer( final int eventRowId, { @@ -64,6 +65,7 @@ class const EventRenderer( final errorStyle = TextStyle(color: colorScheme.error); final focusNode = useFocusNode(); useListenable(focusNode); + final senderRecognizer = useTapRecognizer(); final (event, previousEvent) = ref.watch( RoomsController.provider.select((rooms) { @@ -130,10 +132,7 @@ class const EventRenderer( details: details, trailing: Timestamp(event.timestamp), [ - WidgetSpan( - alignment: .middle, - child: MessageDisplayname(event, shouldWrap: maxLines == null), - ), + UserSpan.sender(context, ref, event, recognizer: senderRecognizer), TextSpan(text: text), ], ); diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index fe845cf..17f8128 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -1,18 +1,18 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +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/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; +import "package:nexus/widgets/user_span.dart"; class const MembershipRenderer( final Event event, { final int? maxLines, super.key, -}) extends ConsumerWidget { +}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { assert( @@ -20,6 +20,8 @@ class const MembershipRenderer( "Make sure to only pass membership events to MembershipRenderer", ); + final targetRecognizer = useTapRecognizer(); + final senderRecognizer = useTapRecognizer(); final previous = switch (event.previousContent) { MembershipContent(:final status) => status, _ => null, @@ -30,22 +32,15 @@ class const MembershipRenderer( Icons.people, maxLines: maxLines, [ - WidgetSpan( - alignment: .middle, - child: InkWell( - onTap: () => context.showUserPopover( - event.stateKey!, - roomId: event.roomId, - ), - child: Text( - overflow: .ellipsis, - content.displayName ?? event.stateKey!.localpart, - maxLines: 1, - style: .new( - color: ref.displaynameColor(event.roomId, event.stateKey!), - fontWeight: .bold, - ), - ), + UserSpan( + context, + content.displayName ?? event.stateKey!.localpart, + userId: event.stateKey!, + roomId: event.roomId, + recognizer: targetRecognizer, + style: .new( + color: ref.displaynameColor(event.roomId, event.stateKey!), + fontWeight: .bold, ), ), TextSpan( @@ -61,10 +56,7 @@ class const MembershipRenderer( }} the room${event.sender == event.stateKey ? "" : " by"}", ), if (event.sender != event.stateKey) - WidgetSpan( - alignment: .middle, - child: MessageDisplayname(event, shouldWrap: maxLines == null), - ), + UserSpan.sender(context, ref, event, recognizer: senderRecognizer), if (content.reason != null) TextSpan(text: "for \"${content.reason}\""), ], diff --git a/lib/widgets/user_span.dart b/lib/widgets/user_span.dart new file mode 100644 index 0000000..4fb2f9c --- /dev/null +++ b/lib/widgets/user_span.dart @@ -0,0 +1,63 @@ +import "package:flutter/gestures.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/author.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/global_rect.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/event.dart"; + +/// A user's [name] in text, opening their popover when tapped. Unlike a widget +/// in text, it wraps and ellipsizes with the rest of it. +class UserSpan extends TextSpan { + UserSpan( + BuildContext context, + String name, { + required String userId, + required TapGestureRecognizer recognizer, + String? roomId, + super.style, + super.children, + }) : super( + text: name, + recognizer: recognizer + ..onTapUp = (details) { + if (!context.mounted) return; + + context.showUserPopover( + userId, + roomId: roomId, + anchor: context.globalRectAt(details.globalPosition), + ); + }, + ); + + factory UserSpan.sender( + BuildContext context, + WidgetRef ref, + Event event, { + required TapGestureRecognizer recognizer, + }) { + final color = ref.displaynameColor(event.roomId, event.sender); + + return UserSpan( + context, + ref.watch(AuthorController.provider(event)).value?.displayName ?? + event.sender.localpart, + userId: event.sender, + roomId: event.roomId, + recognizer: recognizer, + style: TextStyle(color: color, fontWeight: .bold), + children: [ + if (event.pmp != null) + TextSpan( + text: " (via ${event.sender})", + recognizer: recognizer, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: color, fontWeight: .bold), + ), + ], + ); + } +} From a2638d5c82722b9c0f97dfed13b6044cc90f7591 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 26 Oct 2026 17:32:06 -0400 Subject: [PATCH 24/27] bump gomuks, use new pause/resume commands --- gomuks | 2 +- lib/controllers/client.dart | 3 +++ lib/controllers/gomuks_listener.dart | 5 +++-- lib/helpers/extensions/gomuks_buffer.dart | 5 ++++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/gomuks b/gomuks index 53cae0e..081d5df 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310 +Subproject commit 081d5df14be6bf7131e955461207e26da7ad23c9 diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index a044d25..f158069 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -200,6 +200,9 @@ class ClientController extends AsyncNotifier { Future setState(SetStateRequest request) async => await _sendCommand("set_state", request.toJson()); + Future pauseSync() => _sendCommand("pause_sync"); + Future resumeSync() => _sendCommand("resume_sync"); + Future verify(String recoveryKey) => _sendCommand("verify", {"recovery_key": recoveryKey}); diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart index a97599b..320ac2d 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -156,11 +156,12 @@ class GomuksListenerController extends AsyncNotifier { // Mobile OSes block network access for backgrounded apps, so pause // syncing instead of letting it fail. ref.listen(AppPausedController.provider, (_, paused) { + final client = ref.read(ClientController.provider.notifier); if (paused) { - GomuksPauseSync(handle); + client.pauseSync().onError(showError); } else { ref.invalidate(TypingController.provider); - GomuksResumeSync(handle); + client.resumeSync().onError(showError); } }, fireImmediately: true); } diff --git a/lib/helpers/extensions/gomuks_buffer.dart b/lib/helpers/extensions/gomuks_buffer.dart index 57425be..9ab1307 100644 --- a/lib/helpers/extensions/gomuks_buffer.dart +++ b/lib/helpers/extensions/gomuks_buffer.dart @@ -15,7 +15,10 @@ extension GomuksOwnedBufferToX on GomuksOwnedBuffer { } } - dynamic toJsonAndFree() => jsonDecode(utf8.decode(toBytesAndFree())); + dynamic toJsonAndFree() { + final bytes = toBytesAndFree(); + return bytes.isEmpty ? null : jsonDecode(utf8.decode(bytes)); + } } extension JsonToGomuksBuffer on Map { From cd60163cafcca50b5a006c9fece24526c6748b0f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 26 Oct 2026 17:35:52 -0400 Subject: [PATCH 25/27] update gomuks vendorHash --- linux/nix/pkg/gomuks.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix index 6c383d2..37769dc 100644 --- a/linux/nix/pkg/gomuks.nix +++ b/linux/nix/pkg/gomuks.nix @@ -17,7 +17,7 @@ buildGoModule ( src = "${src}/gomuks"; - vendorHash = "sha256-nd0NEKXscgrFE/gBEW2aP+YeZNTOdbRa4QDLTRwIAZU="; + vendorHash = "sha256-XVuRmDvrn43heD/Jba5paPkY1cZnCsYvO+V4IXIuFYI="; buildPhase = '' runHook preBuild From 285e6ad76a8719f74c280989603f8c24779d10e8 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 26 Oct 2026 23:54:28 -0400 Subject: [PATCH 26/27] check that formattedBody exists before rendering a message as HTML In case people send HTML messages without HTML, which they do for some reason. --- lib/widgets/renderers/message.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index 598e9f9..b380577 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -73,11 +73,11 @@ class const MessageRenderer( ) => Column( crossAxisAlignment: .start, children: [ - format == .html && !textOnly + format == .html && formattedBody != null && !textOnly ? Html( roomId: event.roomId, textStyle: textStyle, - formattedBody!.replaceAllMapped( + formattedBody.replaceAllMapped( RegExp( r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)", caseSensitive: false, From cb983701ca6eb5aa3485f695a91117b8d04aec54 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 28 Oct 2026 10:47:17 -0400 Subject: [PATCH 27/27] make sdk bound less strict --- pubspec.lock | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index fef5cfa..36ccd36 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1805,5 +1805,5 @@ packages: source: hosted version: "2.2.4" sdks: - dart: "3.13.3" + dart: ">=3.13.3 <4.0.0" flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index c49c98e..bf1debb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ flutter: uses-material-design: true environment: - sdk: 3.13.3 + sdk: ^3.13.3 dependency_overrides: path_provider_android: 2.2.23 # Pinned to avoid JNI