From b7a020767ef67ed11a652b0cbfb7270cd5526547 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 10 Sep 2026 20:26:17 +0700 Subject: [PATCH 01/10] wip --- Cargo.lock | 348 +++++++++------- crates/signed_core/Cargo.toml | 4 + crates/signed_core/src/filters.rs | 190 ++++++++- crates/signed_core/src/inbox.rs | 645 ++++++++++++++++++++++++++++++ crates/signed_core/src/lib.rs | 5 + docs/inbox-plan.md | 515 ++++++++++++++++++++++++ 6 files changed, 1554 insertions(+), 153 deletions(-) create mode 100644 crates/signed_core/src/inbox.rs create mode 100644 docs/inbox-plan.md diff --git a/Cargo.lock b/Cargo.lock index 271f8bc..189ee38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,7 +127,6 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", - "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -321,9 +320,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.44" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844" +checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f" dependencies = [ "compression-codecs", "compression-core", @@ -630,9 +629,9 @@ dependencies = [ [[package]] name = "base62" -version = "2.2.4" +version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" +checksum = "a3a647dce9631f152a99ec16cfc09267ae55fe280d5dc288964027d458076323" [[package]] name = "base64" @@ -640,6 +639,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bech32" version = "0.12.0" @@ -661,7 +666,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools 0.13.0", @@ -778,9 +783,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -950,7 +955,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "polling", "rustix", "slab", @@ -1166,7 +1171,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block", "cocoa-foundation 0.2.1", "core-foundation 0.10.1", @@ -1196,7 +1201,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block", "core-foundation 0.10.1", "core-graphics-types 0.2.0", @@ -1217,7 +1222,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "gpui_util", "indexmap", @@ -1242,9 +1247,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.39" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df" +checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8" dependencies = [ "bzip2", "compression-core", @@ -1366,7 +1371,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types", @@ -1379,7 +1384,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", "foreign-types", @@ -1403,7 +1408,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "libc", ] @@ -1414,7 +1419,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block", "cfg-if", "core-foundation 0.10.1", @@ -1447,6 +1452,12 @@ dependencies = [ "metal", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -1462,7 +1473,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "fontdb", "harfrust", "linebender_resource_handle", @@ -1683,7 +1694,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "proc-macro2", "quote", @@ -1755,7 +1766,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -1861,11 +1872,17 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" dependencies = [ "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -2122,7 +2139,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "rustc_version", ] @@ -2216,9 +2233,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" dependencies = [ "bytemuck", ] @@ -2676,7 +2693,7 @@ version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f6af5321bfd3711a279d6b244d58532ba1cfabf9eb6374791f19929d8970082" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bstr", "gix-path", "libc", @@ -2841,7 +2858,7 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b417cf515fd8c91468b578071f76d6cba716f8a1eccd853906bff4908b2c1413" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bstr", "gix-features", "gix-path", @@ -2899,7 +2916,7 @@ version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "632e16cb48b0e88a747e106924cb2765194105d8070a7a961b0d080ed806c632" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bstr", "filetime", "fnv", @@ -2949,7 +2966,7 @@ version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91068ecee956121d663635b873ae87ad9c5d3f2d6788facfd12829140b39c897" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "gix-commitgraph", "gix-date", "gix-hash", @@ -3064,7 +3081,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c92e44c63ba53bb55aa88ee48847664beae9446417621582fb14dfc69b2f8c72" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bstr", "gix-attributes", "gix-config-value", @@ -3162,7 +3179,7 @@ version = "0.49.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577b864c3e134697e91564553e43230c2e401bb1bcc51cfa998edca21c95078" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bstr", "gix-commitgraph", "gix-date", @@ -3197,7 +3214,7 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "gix-path", "libc", "windows-sys 0.61.2", @@ -3281,7 +3298,7 @@ version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3f41a64939953ff49117df4eb91334d299abc59ff57a90fb2193d8ffa5886f1" dependencies = [ - "base64", + "base64 0.22.1", "bstr", "gix-command", "gix-credentials", @@ -3302,7 +3319,7 @@ version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9af3503668739f4de5dba57fe15cfd9adc443b08bcbbf195e5de16b648872245" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "gix-commitgraph", "gix-date", "gix-hash", @@ -3505,7 +3522,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -3516,13 +3533,13 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "accesskit", "anyhow", @@ -3530,7 +3547,7 @@ dependencies = [ "async-task", "backtrace", "bindgen", - "bitflags 2.13.1", + "bitflags 2.13.2", "chrono", "collections", "core-video", @@ -3745,7 +3762,7 @@ dependencies = [ [[package]] name = "gpui_apple" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "anyhow", "block", @@ -3768,14 +3785,14 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "accesskit", "accesskit_unix", "anyhow", "as-raw-xcb-connection", "ashpd", - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "calloop", "calloop-wayland-source", @@ -3814,7 +3831,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "accesskit", "accesskit_macos", @@ -3860,7 +3877,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3871,7 +3888,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "console_error_panic_hook", "gpui", @@ -3884,7 +3901,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "schemars", "serde", @@ -3894,7 +3911,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "anyhow", "log", @@ -3904,7 +3921,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3928,7 +3945,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "anyhow", "bytemuck", @@ -3954,7 +3971,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "accesskit", "accesskit_windows", @@ -3981,9 +3998,9 @@ dependencies = [ [[package]] name = "granit-parser" -version = "0.0.7" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d03f81ad4732830d85cfd417a9f62cde6dadda4354d37d078a6084a19560aa2d" +checksum = "65ec0d45986cd51c847c75c5b69a00852c4fc84d0e5e79f041173f73437d0cdf" dependencies = [ "arraydeque", "smallvec", @@ -4026,7 +4043,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "core_maths", "read-fonts 0.37.0", @@ -4127,7 +4144,7 @@ version = "0.20.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "heed-traits", "heed-types", @@ -4268,7 +4285,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "anyhow", "async-compression", @@ -4288,7 +4305,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "log", "rustls", @@ -4304,9 +4321,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] @@ -4354,7 +4371,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -4894,7 +4911,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", ] @@ -5244,7 +5261,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "anyhow", "bindgen", @@ -5285,7 +5302,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block", "core-graphics-types 0.2.0", "foreign-types", @@ -5358,6 +5375,33 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.5", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "29.0.4" @@ -5366,7 +5410,7 @@ checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", "bit-set 0.9.1", - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "codespan-reporting", @@ -5469,10 +5513,10 @@ dependencies = [ [[package]] name = "nostr" version = "0.45.4" -source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614" +source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73" dependencies = [ "aes", - "base64", + "base64 0.22.1", "bech32", "bip39", "bitcoin_hashes 1.2.0", @@ -5495,7 +5539,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614" +source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73" dependencies = [ "async-utility", "futures-core", @@ -5509,7 +5553,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614" +source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73" dependencies = [ "nostr", "opaquerr", @@ -5518,7 +5562,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614" +source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73" dependencies = [ "nostr", "opaquerr", @@ -5527,7 +5571,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614" +source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73" dependencies = [ "indexmap", "lru", @@ -5539,7 +5583,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614" +source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73" dependencies = [ "async-utility", "flatbuffers", @@ -5554,7 +5598,7 @@ dependencies = [ [[package]] name = "nostr-memory" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614" +source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73" dependencies = [ "btreecap", "nostr", @@ -5565,7 +5609,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#0c6fad2ac8ce934747096953f6dba355e3532614" +source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73" dependencies = [ "async-utility", "async-wsocket", @@ -5590,7 +5634,7 @@ version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "filetime", "fsevent-sys", "inotify", @@ -5814,7 +5858,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -5830,7 +5874,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -5851,7 +5895,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -5862,7 +5906,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -5874,7 +5918,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -5885,7 +5929,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2 0.6.4", ] @@ -5896,7 +5940,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -5941,7 +5985,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -5953,7 +5997,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -5972,7 +6016,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -5984,7 +6028,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -6007,7 +6051,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", ] @@ -6018,7 +6062,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -6030,7 +6074,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "objc2 0.6.4", "objc2-foundation 0.3.2", @@ -6042,7 +6086,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -6055,7 +6099,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -6068,7 +6112,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "objc2 0.6.4", "objc2-core-location", @@ -6157,9 +6201,9 @@ checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" [[package]] name = "open" -version = "5.4.3" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ "is-wsl", "libc", @@ -6302,7 +6346,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "collections", "serde", @@ -6472,7 +6516,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", @@ -6656,7 +6700,7 @@ source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags 2.13.1", + "bitflags 2.13.2", "num-traits", "proptest-macro", "rand 0.9.5", @@ -6978,7 +7022,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -7037,7 +7081,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.4", + "font-types 0.12.5", "once_cell", ] @@ -7053,7 +7097,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -7090,7 +7134,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "derive_refineable", ] @@ -7132,11 +7176,11 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "encoding_rs", "futures-channel", @@ -7173,7 +7217,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "anyhow", "bytes", @@ -7307,9 +7351,9 @@ dependencies = [ [[package]] name = "rust-i18n" -version = "4.2.1" +version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f10cee36dd3b1f7929ea12b759de9eea9eff83bfccbc71f387ef4d41a57c64a4" +checksum = "7c43fd69c20da13756643a5bf51ae799965465c78e05c57a1242a736aaa56527" dependencies = [ "globwalk", "regex", @@ -7320,9 +7364,9 @@ dependencies = [ [[package]] name = "rust-i18n-macro" -version = "4.2.1" +version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0bb1ed4e04fe26c2a2652cad1c6595efaf7196f4445c0d6e13c67154347fb7e" +checksum = "086ec8a9eaa6afda33919b9bc1661c1f4e017c40e99bbd33ca98f6e1cb6ca91f" dependencies = [ "glob", "proc-macro2", @@ -7335,9 +7379,9 @@ dependencies = [ [[package]] name = "rust-i18n-support" -version = "4.2.1" +version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1c083408a2733180ae0acf2897612f8ceec7b0c0dcd065a0a87103bfb3b1d9" +checksum = "ac02dcb9a01ec145d0a5534ea9554a966050ca250b346bb2a9ab0fcdafab5979" dependencies = [ "arc-swap", "base62", @@ -7385,7 +7429,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -7394,9 +7438,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "aws-lc-rs", "log", @@ -7502,7 +7546,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "core_maths", "log", @@ -7551,7 +7595,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "async-task", "backtrace", @@ -7669,7 +7713,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7714,15 +7758,13 @@ dependencies = [ [[package]] name = "serde-saphyr" -version = "0.0.29" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" +checksum = "3afb591f9cdb6223c88ba39269aff895620c7f0716dc42b705b5733d5c7c0823" dependencies = [ - "ahash", "annotate-snippets", - "base64", + "base64 0.23.1", "encoding_rs_io", - "getrandom 0.3.4", "granit-parser", "nohash-hasher", "num-traits", @@ -7960,6 +8002,8 @@ name = "signed_core" version = "0.1.0-alpha" dependencies = [ "nostr", + "serde", + "serde_json", ] [[package]] @@ -8108,9 +8152,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "smol" @@ -8173,7 +8217,7 @@ version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -8264,7 +8308,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "heapless 0.9.3", "log", @@ -8501,7 +8545,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8512,7 +8556,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "501336eb7ba9e417300a6a0fa985721065467aa83a6dcf0422a8e43e4c0328fa" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "system-configuration-sys", ] @@ -8949,7 +8993,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -9599,7 +9643,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -9626,7 +9670,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -9662,7 +9706,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "perf", "quote", @@ -9678,9 +9722,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -9917,7 +9961,7 @@ version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "rustix", "wayland-backend", "wayland-scanner", @@ -9940,7 +9984,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-scanner", @@ -9952,7 +9996,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -9965,7 +10009,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -10071,7 +10115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" dependencies = [ "arrayvec", - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "cfg-if", "cfg_aliases", @@ -10103,7 +10147,7 @@ dependencies = [ "arrayvec", "bit-set 0.9.1", "bit-vec 0.9.1", - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "cfg_aliases", "document-features", @@ -10174,7 +10218,7 @@ dependencies = [ "arrayvec", "ash", "bit-set 0.9.1", - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "bytemuck", "cfg-if", @@ -10234,7 +10278,7 @@ version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "js-sys", "log", @@ -10932,7 +10976,7 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", "quick-xml", "x11", @@ -10957,7 +11001,7 @@ name = "xim-parser" version = "0.2.1" source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -11158,7 +11202,7 @@ name = "zed-font-kit" version = "0.14.1-zed" source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "core-foundation 0.10.1", "core-graphics 0.24.0", @@ -11182,7 +11226,7 @@ name = "zed-reqwest" version = "0.12.15-zed" source = "git+https://github.com/zed-industries/reqwest.git?rev=33bc764aa15ff7b200bf7c93bd96e24878d53e14#33bc764aa15ff7b200bf7c93bd96e24878d53e14" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -11231,7 +11275,7 @@ name = "zed-reqwest" version = "0.12.15-zed" source = "git+https://github.com/zed-industries/reqwest.git?rev=c15662463bda39148ba154100dd44d3fba5873a4#c15662463bda39148ba154100dd44d3fba5873a4" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -11329,18 +11373,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", @@ -11430,7 +11474,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "anyhow", "chrono", @@ -11447,7 +11491,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" dependencies = [ "tracing", "tracing-subscriber", @@ -11458,7 +11502,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a" +source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c" [[package]] name = "zune-core" diff --git a/crates/signed_core/Cargo.toml b/crates/signed_core/Cargo.toml index 5b1e5ca..7328213 100644 --- a/crates/signed_core/Cargo.toml +++ b/crates/signed_core/Cargo.toml @@ -6,3 +6,7 @@ publish.workspace = true [dependencies] nostr.workspace = true +serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index e6485cf..5b040a3 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -2,7 +2,7 @@ use std::time::Duration; use nostr::prelude::*; -use crate::RepoAddr; +use crate::{COVER_NOTE_KIND, RepoAddr}; /// Kinds that make up the activity of a repository. pub const ACTIVITY_KINDS: [Kind; 9] = [ @@ -17,6 +17,41 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [ Kind::GitStatusDraft, ]; +/// Kinds that notify a user when they tag them via their `p` tag. +pub const NOTIFICATION_KINDS: [Kind; 9] = [ + Kind::GitIssue, + Kind::GitPullRequest, + Kind::GitPatch, + Kind::GitPullRequestUpdate, + COVER_NOTE_KIND, + Kind::GitStatusOpen, + Kind::GitStatusApplied, + Kind::GitStatusClosed, + Kind::GitStatusDraft, +]; + +/// Git root kinds that make a comment or cover note count as git activity. +const GIT_ROOT_KINDS: [Kind; 4] = [ + Kind::GitIssue, + Kind::GitPatch, + Kind::GitPullRequest, + Kind::GitRepoAnnouncement, +]; + +/// Value of the first tag named `name` on `event`. +fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> { + event + .tags + .iter() + .find(|tag| tag.kind() == name) + .and_then(|tag| tag.content()) +} + +/// Kind named by the first tag `name` on `event`. +fn tag_kind(event: &Event, name: &str) -> Option { + tag_value(event, name)?.parse::().ok() +} + /// Latest announcement event for a repository. pub fn announcement(addr: &RepoAddr) -> Filter { Filter::new() @@ -94,6 +129,68 @@ pub fn comments_for(roots: impl IntoIterator) -> Vec { ] } +/// NIP-22 comments on our issues, patches and pull requests. +/// They are matched via the uppercase `P` and `K` tags, not authorship. +pub fn notification_comments(me: PublicKey) -> Filter { + Filter::new() + .kind(Kind::Comment) + .custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()]) + .custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"]) +} + +/// Activity directed at us: comments on our roots, and git events tagging us +/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag. +pub fn notifications(me: PublicKey) -> Vec { + vec![ + notification_comments(me), + Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me), + ] +} + +/// Git activity authored by `me`, for "Continue where you left off". +/// +/// A comment on an unrelated kind is matched too, so results must be filtered +/// through [`is_git_activity`] before display. +pub fn authored_activity(me: PublicKey) -> Filter { + Filter::new() + .kinds( + ACTIVITY_KINDS + .into_iter() + .chain(std::iter::once(COVER_NOTE_KIND)), + ) + .author(me) +} + +/// Whether a kind-1111 comment targets a git root, checked via its `K` tag. +fn is_git_comment(event: &Event) -> bool { + event.kind == Kind::Comment + && tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind)) +} + +/// Whether a kind-1624 cover note targets a git root, checked via its `k` tag. +fn is_git_cover_note(event: &Event) -> bool { + event.kind == COVER_NOTE_KIND + && tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind)) +} + +/// Whether a status event references a git root, checked via its `k` tag. +fn is_git_status(event: &Event) -> bool { + tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind)) +} + +/// Whether `event` is git activity worth showing in the activity list. +pub fn is_git_activity(event: &Event) -> bool { + match event.kind { + Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true, + Kind::Comment => is_git_comment(event), + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => is_git_status(event), + kind => kind == COVER_NOTE_KIND && is_git_cover_note(event), + } +} + /// All repository announcements, for global discovery. pub fn all_announcements() -> Filter { Filter::new().kind(Kind::GitRepoAnnouncement) @@ -134,3 +231,94 @@ pub fn deletions_for_repo(addr: &RepoAddr) -> Vec { Filter::new().kind(Kind::EventDeletion).coordinate(addr), ] } + +#[cfg(test)] +mod tests { + use super::*; + + fn keys(seed: u8) -> Keys { + let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string(); + hex.push_str(&format!("{seed:02x}")); + Keys::new(SecretKey::from_hex(&hex).expect("valid secret key")) + } + + fn signed(author: &Keys, kind: Kind, tags: Vec) -> Event { + EventBuilder::new(kind, "") + .tags(tags) + .finalize(author) + .expect("signed event") + } + + fn kind_tag(name: &str, kind: Kind) -> Tag { + Tag::parse([name, &kind.as_u16().to_string()]).expect("valid kind tag") + } + + #[test] + fn root_git_kinds_are_activity() { + for kind in [Kind::GitIssue, Kind::GitPatch, Kind::GitPullRequest] { + assert!(is_git_activity(&signed(&keys(1), kind, Vec::new()))); + } + } + + #[test] + fn comment_activity_depends_on_the_uppercase_k_tag() { + let on_git = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::GitIssue)]); + let on_repo = signed( + &keys(1), + Kind::Comment, + vec![kind_tag("K", Kind::GitRepoAnnouncement)], + ); + let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]); + + assert!(is_git_activity(&on_git)); + assert!(is_git_activity(&on_repo)); + assert!(!is_git_activity(&on_note)); + assert!(!is_git_activity(&signed( + &keys(1), + Kind::Comment, + Vec::new() + ))); + } + + #[test] + fn status_and_cover_note_activity_depend_on_the_lowercase_k_tag() { + let status = signed( + &keys(1), + Kind::GitStatusClosed, + vec![kind_tag("k", Kind::GitPullRequest)], + ); + let cover = signed( + &keys(1), + COVER_NOTE_KIND, + vec![kind_tag("k", Kind::GitPatch)], + ); + let unrelated = signed( + &keys(1), + Kind::GitStatusClosed, + vec![kind_tag("k", Kind::Metadata)], + ); + + assert!(is_git_activity(&status)); + assert!(is_git_activity(&cover)); + assert!(!is_git_activity(&unrelated)); + assert!(!is_git_activity(&signed( + &keys(1), + Kind::GitStatusClosed, + Vec::new() + ))); + } + + #[test] + fn non_git_kinds_are_not_activity() { + assert!(!is_git_activity(&signed( + &keys(1), + Kind::TextNote, + Vec::new() + ))); + assert!(!is_git_activity(&signed( + &keys(1), + Kind::GitPullRequestUpdate, + Vec::new(), + ))); + } +} diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs new file mode 100644 index 0000000..4a07e24 --- /dev/null +++ b/crates/signed_core/src/inbox.rs @@ -0,0 +1,645 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use nostr::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::{COVER_NOTE_KIND, RepoAddr}; + +/// Window before `now` that an advanced cutoff retreats to. +const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60); + +/// Window before `now` that a mark-all cutoff retreats to. +const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60); + +/// A thread of notification events sharing one root +#[derive(Debug, Clone)] +pub struct InboxItem { + /// The root issue, patch or pull request the notifications belong to. + pub root: EventId, + /// Kind of the root event, when it is known locally. + pub root_kind: Option, + /// Repository the root belongs to, from the root's `a` tag. + pub address: Option, + /// Events in the group, newest first. + pub events: Vec, + /// Unread event ids, oldest first. + pub unread_ids: Vec, + /// Whether every event in the group is archived. + pub archived: bool, +} + +impl InboxItem { + /// Timestamp of the newest event in the group. + pub fn latest_activity(&self) -> Timestamp { + self.events + .first() + .map(|event| event.created_at) + .unwrap_or_default() + } + + /// Whether the group has an unread event still visible in the inbox. + pub fn is_unread(&self) -> bool { + !self.archived && !self.unread_ids.is_empty() + } +} + +/// Root issue, patch or pull request of a notification event. +/// +/// Returns `None` when the event is not git-related, or when its root is a +/// coordinate rather than an event. +/// +/// - issue (1621) / PR (1618): itself +/// - patch (1617): its `e` parent patch, else itself +/// - NIP-22 comment (1111): uppercase `E` root pointer +/// - PR update (1619): uppercase `E` +/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e` +pub fn notification_root(event: &Event, lookup: &L) -> Option +where + L: Fn(EventId) -> Option, +{ + if event.kind == COVER_NOTE_KIND { + return nip10_root_id(event).map(|root| resolve_thread_root(root, lookup)); + } + match event.kind { + Kind::GitIssue | Kind::GitPullRequest => Some(event.id), + Kind::GitPatch => Some(match first_e_id(event) { + Some(parent) => resolve_thread_root(parent, lookup), + None => event.id, + }), + Kind::Comment => match nip22::extract_root(event) { + Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)), + _ => None, + }, + Kind::GitPullRequestUpdate => { + first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup)) + } + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => { + nip10_root_id(event).map(|root| resolve_thread_root(root, lookup)) + } + _ => None, + } +} + +/// Group notification events by root, newest activity first. +pub fn group(events: E, me: PublicKey, state: &InboxReadState, lookup: &L) -> Vec +where + E: IntoIterator, + L: Fn(EventId) -> Option, +{ + let mut groups: HashMap> = HashMap::new(); + for event in events { + if event.pubkey == me { + continue; + } + let Some(root) = notification_root(&event, lookup) else { + continue; + }; + groups.entry(root).or_default().push(event); + } + + let mut items: Vec = groups + .into_iter() + .map(|(root, mut events)| { + events.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) + }); + + let root_event = lookup(root); + + let unread_ids = events + .iter() + .rev() + .filter(|event| !state.is_read(event)) + .map(|event| event.id) + .collect(); + + let archived = events.iter().all(|event| state.is_archived(event)); + + InboxItem { + root, + root_kind: root_event.as_ref().map(|event| event.kind), + address: root_event + .as_ref() + .and_then(|event| event.tags.coordinates().next()), + events, + unread_ids, + archived, + } + }) + .collect(); + + items.sort_by(|a, b| { + b.latest_activity() + .cmp(&a.latest_activity()) + .then_with(|| b.root.to_hex().cmp(&a.root.to_hex())) + }); + + items +} + +/// Read and archive state of the inbox, a high-water-mark model. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct InboxReadState { + #[serde(default)] + pub read_before: Timestamp, + #[serde(default)] + pub read_ids: HashSet, + #[serde(default)] + pub archived_before: Timestamp, + #[serde(default)] + pub archived_ids: HashSet, +} + +impl InboxReadState { + /// Whether `event` is at or before the read cutoff, or marked read. + pub fn is_read(&self, event: &Event) -> bool { + event.created_at <= self.read_before || self.read_ids.contains(&event.id) + } + + /// Whether `event` is at or before the archived cutoff, or marked archived. + pub fn is_archived(&self, event: &Event) -> bool { + event.created_at <= self.archived_before || self.archived_ids.contains(&event.id) + } + + /// Mark one event read. Events at or before the cutoff are already read. + pub fn mark_read(&mut self, event: &Event) { + if event.created_at > self.read_before { + self.read_ids.insert(event.id); + } + } + + /// Mark every non-self event read, anchoring the cutoff ten days back. + pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { + let cutoff = now - MARK_ALL_WINDOW; + self.read_before = cutoff; + self.read_ids = all + .iter() + .filter(|event| event.pubkey != me && event.created_at > cutoff) + .map(|event| event.id) + .collect(); + } + + /// Advance the read cutoff to the newest point that keeps unread events + /// unread, then prune the id set. + pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { + let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event)); + self.read_before = cutoff; + prune_ids(&mut self.read_ids, all, cutoff); + } + + /// Advance the archived cutoff, mirroring [`Self::advance_read`]. + pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { + let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| { + self.is_archived(event) + }); + self.archived_before = cutoff; + prune_ids(&mut self.archived_ids, all, cutoff); + } +} + +/// Newest cutoff that keeps unread events unread, never earlier than `current`. +fn advance_cutoff( + all: &[Event], + me: PublicKey, + now: Timestamp, + current: Timestamp, + is_marked: M, +) -> Timestamp +where + M: Fn(&Event) -> bool, +{ + let fallback = now - ADVANCE_WINDOW; + + let oldest = all + .iter() + .filter(|event| event.pubkey != me && !is_marked(event)) + .map(|event| event.created_at) + .min(); + + let candidate = match oldest { + Some(at) if at < fallback => at - 1, + _ => fallback, + }; + + candidate.max(current) +} + +/// Drop ids whose event is unknown or now covered by the cutoff. +fn prune_ids(ids: &mut HashSet, all: &[Event], cutoff: Timestamp) { + let created_at: HashMap = all + .iter() + .map(|event| (event.id, event.created_at)) + .collect(); + ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff)); +} + +/// Follow NIP-10/NIP-22 parent pointers until a root item is reached. +fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option) -> EventId { + let mut seen = HashSet::new(); + let mut root = id; + + loop { + if !seen.insert(root) { + return id; + } + + let Some(event) = lookup(root) else { + return root; + }; + + if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) { + return root; + } + + match parent_id(&event) { + Some(parent) => root = parent, + None => return root, + } + } +} + +/// Parent of a thread event, mirroring gitworkshop's `getParentId`. +fn parent_id(event: &Event) -> Option { + for marker in ["reply", "root"] { + if let Some(id) = event + .tags + .iter() + .find_map(|tag| e_tag_with_marker(tag, marker)) + { + return Some(id); + } + } + + if let Some(id) = event.tags.iter().find_map(|tag| { + if tag.kind() != "e" { + return None; + } + + let slice = tag.as_slice(); + let is_mention = slice.len() == 4 && slice[3] == "mention"; + + if is_mention { + return None; + } + + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) { + return Some(id); + } + + first_uppercase_e_id(event) +} + +/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag. +fn nip10_root_id(event: &Event) -> Option { + event + .tags + .iter() + .find_map(|tag| e_tag_with_marker(tag, "root")) + .or_else(|| first_e_id(event)) +} + +/// First `e` tag id, in document order. +fn first_e_id(event: &Event) -> Option { + first_tag_id(event, "e") +} + +/// First uppercase `E` tag id, in document order. +fn first_uppercase_e_id(event: &Event) -> Option { + first_tag_id(event, "E") +} + +fn first_tag_id(event: &Event, name: &str) -> Option { + event.tags.iter().find_map(|tag| { + if tag.kind() != name { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) +} + +/// Event id from a four-element `e` tag carrying `marker`. +fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option { + let slice = tag.as_slice(); + if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keys(seed: u8) -> Keys { + let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string(); + hex.push_str(&format!("{seed:02x}")); + Keys::new(SecretKey::from_hex(&hex).expect("valid secret key")) + } + + fn signed(author: &Keys, kind: Kind, tags: Vec, created_at: u64) -> Event { + EventBuilder::new(kind, "") + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at)) + .finalize(author) + .expect("signed event") + } + + fn e_tag(event: &Event) -> Tag { + Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag") + } + + fn marked_e_tag(event: &Event, marker: &str) -> Tag { + Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker]) + .expect("valid e tag") + } + + fn uppercase_e_tag(event: &Event) -> Tag { + Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag") + } + + fn a_tag(owner: &PublicKey, id: &str) -> Tag { + Tag::parse(["a", &format!("30617:{}:{id}", owner.to_hex())]).expect("valid a tag") + } + + fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option + '_ { + move |id| events.iter().find(|event| event.id == id).cloned() + } + + fn issue(author: &Keys, at: u64) -> Event { + signed(author, Kind::GitIssue, Vec::new(), at) + } + + #[test] + fn issue_and_pull_request_are_their_own_root() { + let events = [ + issue(&keys(1), 100), + signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100), + ]; + let lookup = lookup(&events); + for event in &events { + assert_eq!(notification_root(event, &lookup), Some(event.id)); + } + } + + #[test] + fn comment_resolves_to_its_uppercase_root() { + let issue = issue(&keys(1), 100); + let comment = signed( + &keys(2), + Kind::Comment, + vec![ + uppercase_e_tag(&issue), + Tag::parse(["K", "1621"]).expect("valid K tag"), + ], + 200, + ); + let events = [issue.clone(), comment.clone()]; + assert_eq!( + notification_root(&comment, &lookup(&events)), + Some(issue.id) + ); + } + + #[test] + fn comment_without_root_pointer_has_no_root() { + let comment = signed( + &keys(2), + Kind::Comment, + vec![e_tag(&issue(&keys(1), 100))], + 200, + ); + assert_eq!(notification_root(&comment, &lookup(&[])), None); + } + + #[test] + fn child_patch_resolves_to_the_root_patch() { + let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100); + let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200); + let events = [root_patch.clone(), child_patch.clone()]; + assert_eq!( + notification_root(&child_patch, &lookup(&events)), + Some(root_patch.id) + ); + } + + #[test] + fn status_resolves_via_the_root_marker() { + let issue = issue(&keys(1), 100); + let status = signed( + &keys(2), + Kind::GitStatusClosed, + vec![marked_e_tag(&issue, "root")], + 200, + ); + let events = [issue.clone(), status.clone()]; + assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id)); + } + + #[test] + fn pull_request_update_resolves_via_uppercase_e() { + let pr = signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100); + let update = signed( + &keys(2), + Kind::GitPullRequestUpdate, + vec![uppercase_e_tag(&pr)], + 200, + ); + let events = [pr.clone(), update.clone()]; + assert_eq!(notification_root(&update, &lookup(&events)), Some(pr.id)); + } + + #[test] + fn nested_comment_chain_follows_to_the_root() { + let issue = issue(&keys(1), 100); + let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200); + let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300); + let events = [issue.clone(), reply, nested.clone()]; + assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id)); + } + + #[test] + fn group_excludes_self_and_sorts_groups_newest_first() { + let me = keys(1); + let issue = issue(&keys(2), 100); + let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 300); + let other_issue = signed( + &keys(2), + Kind::GitIssue, + vec![Tag::parse(["p", &me.public_key().to_hex()]).expect("valid p tag")], + 200, + ); + let mine = signed(&keys(1), Kind::Comment, vec![uppercase_e_tag(&issue)], 400); + + let events = [issue.clone(), comment.clone(), other_issue.clone(), mine]; + let items = group( + events, + me.public_key(), + &InboxReadState::default(), + &lookup(&[]), + ); + + assert_eq!(items.len(), 2); + assert_eq!(items[0].root, issue.id); + // The issue itself plus the comment; the self-authored comment is out. + assert_eq!(items[0].events.len(), 2); + assert_eq!(items[1].root, other_issue.id); + } + + #[test] + fn group_reports_unread_oldest_first_and_archived() { + let me = keys(1); + let issue = issue(&keys(2), 100); + let older = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200); + let newer = signed(&keys(4), Kind::Comment, vec![uppercase_e_tag(&issue)], 300); + + let events = [issue.clone(), older.clone(), newer.clone()]; + let items = group( + events, + me.public_key(), + &InboxReadState::default(), + &lookup(&[]), + ); + assert_eq!(items[0].unread_ids, vec![issue.id, older.id, newer.id]); + assert!(!items[0].archived); + assert!(items[0].is_unread()); + + let state = InboxReadState { + archived_before: Timestamp::from_secs(1000), + ..Default::default() + }; + let items = group( + [issue.clone(), older, newer], + me.public_key(), + &state, + &lookup(&[]), + ); + assert!(items[0].archived); + assert!(!items[0].unread_ids.is_empty()); + assert!(!items[0].is_unread()); + } + + #[test] + fn group_reads_root_kind_and_address_from_the_root_event() { + let me = keys(1); + let owner_keys = keys(2); + let owner = owner_keys.public_key(); + let issue = signed( + &owner_keys, + Kind::GitIssue, + vec![a_tag(&owner, "my-repo")], + 100, + ); + let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200); + + let events = [issue.clone(), comment]; + let items = group( + events.clone(), + me.public_key(), + &InboxReadState::default(), + &lookup(&events), + ); + + assert_eq!(items[0].root_kind, Some(Kind::GitIssue)); + assert_eq!(items[0].address, issue.tags.coordinates().next()); + } + + #[test] + fn mark_all_read_marks_known_recent_events() { + let me = keys(1); + let now = Timestamp::from_secs(1_000_000_000); + let recent = issue(&keys(2), now.as_secs() - 1000); + let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60); + let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60); + let mine = issue(&keys(1), now.as_secs() - 100); + + let mut state = InboxReadState::default(); + state.mark_all_read( + &[recent.clone(), old.clone(), ancient.clone(), mine.clone()], + me.public_key(), + now, + ); + + assert_eq!(state.read_before, now - MARK_ALL_WINDOW); + assert_eq!(state.read_ids, HashSet::from([recent.id, old.id])); + assert!(state.is_read(&recent)); + assert!(state.is_read(&ancient)); + assert!(!state.is_read(&mine)); + } + + #[test] + fn advance_read_never_moves_the_cutoff_backwards() { + let me = keys(1); + let unread = issue(&keys(2), 1_000); + let all = [unread]; + let now = Timestamp::from_secs(1_000_000_000); + + let mut state = InboxReadState { + read_before: Timestamp::from_secs(999_999_999), + ..Default::default() + }; + state.advance_read(&all, me.public_key(), now); + + assert_eq!(state.read_before, Timestamp::from_secs(999_999_999)); + } + + #[test] + fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() { + let me = keys(1); + let now = Timestamp::from_secs(1_000_000_000); + let five_days = 5 * 24 * 60 * 60; + let old_unread = issue(&keys(2), now.as_secs() - five_days); + // Read ids that fall before and after the new cutoff. + let stale = signed( + &keys(2), + Kind::GitIssue, + Vec::new(), + now.as_secs() - five_days - 1000, + ); + let fresh = signed( + &keys(2), + Kind::GitIssue, + Vec::new(), + now.as_secs() - 100_000, + ); + + let mut state = InboxReadState { + read_ids: HashSet::from([stale.id, fresh.id]), + ..Default::default() + }; + state.advance_read( + &[old_unread.clone(), stale.clone(), fresh.clone()], + me.public_key(), + now, + ); + + assert_eq!(state.read_before, old_unread.created_at - 1); + assert_eq!(state.read_ids, HashSet::from([fresh.id])); + } + + #[test] + fn serde_round_trip_preserves_state() { + let first = issue(&keys(1), 100); + let second = issue(&keys(2), 200); + let state = InboxReadState { + read_before: Timestamp::from_secs(150), + read_ids: HashSet::from([second.id]), + archived_before: Timestamp::from_secs(50), + archived_ids: HashSet::from([first.id]), + }; + + let json = serde_json::to_string(&state).expect("serialized"); + let parsed: InboxReadState = serde_json::from_str(&json).expect("deserialized"); + + assert_eq!(parsed, state); + } +} diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index b909b86..e7ecb69 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -3,6 +3,7 @@ pub mod annotations; pub mod clone_url; pub mod deletions; pub mod filters; +pub mod inbox; pub mod model; pub mod state; pub mod status; @@ -11,6 +12,10 @@ pub use addr::{RepoAddr, identifier_from_name, repo_addr}; pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override}; pub use clone_url::{CloneTarget, parse_clone_url}; pub use deletions::Deletions; +pub use filters::{ + NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications, +}; +pub use inbox::{InboxItem, InboxReadState, group, notification_root}; pub use model::{ Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of, fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches, diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md new file mode 100644 index 0000000..ceefae6 --- /dev/null +++ b/docs/inbox-plan.md @@ -0,0 +1,515 @@ +# Inbox (home screen) implementation plan + +Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for a logged-in user. + +> **Correction to the first draft.** The first draft assumed the inbox was the `/notifications` +> page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `` when +> an account is active, and that home screen is the inbox. + +## 1. What the GitWorkshop home screen is + +`Index.tsx`: + +```tsx +if (account) return ; +return ; +``` + +`Dashboard.tsx` layout: + +- Desktop: two columns. + - **Left column**: `GreetingHeader`, `NotificationsPanel`, `RecentActivitySection`. + - **Right column**: `MyRepositoriesPanel`, `AccessiblePrivateRepositoriesPanel`, + `FollowedReposPanel`. +- Mobile: a single column in a different order. + +The panel that gives the screen its inbox identity is `NotificationsPanel`: + +- heading **Notifications** with a bell icon and an unread count badge, +- a **Mark all read** action and a **View all** link to `/notifications`, +- a compact list of the first 5 **non-archived** notification items, +- the empty state reads **"Your inbox is empty"** (with an `Inbox` icon). + +So in GitWorkshop's vocabulary, "inbox" is the non-archived activity directed at you, surfaced +inline on the home screen. The home screen also shows your own recent activity and your repositories. + +Data hooks: + +| Section | Hook | What it loads | +|---|---|---| +| Notifications (inbox) | `useNotifications()` | Notification model: grouped thread activity directed at you, read/archived state | +| Continue where you left off | `useUserActivity(pubkey)` | Git activity authored by you: kinds 1621/1617/1618/1111 (git `K`)/1624/1630-1633, newest first, limit 50 | +| My repositories | `useUserRepositories(pubkey)` | Kind 30617 announcements authored by you | +| Followed repositories | `useUserFollowedRepos(pubkey)` | Repos you follow | +| Accessible private repositories | `useAccessiblePrivateRepositories()` | Private repos from CI/services | + +## 2. Scope for Signed + +| Priority | Section | Notes | +|---|---|---| +| **P0** | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; top N + show all | +| **P0** | My repositories | Reuse `RepoListStore::announcements_of(me)`; search filter; existing New-repo dialog | +| **P0** | Continue where you left off | Your own recent git activity, newest first | +| **P1** | Unread / Archived sub-views | Open as **bottom-dock panels**, not tabs inside the inbox panel | +| **P1** | Click-through | Open the repo panel at the relevant PR/issue | +| **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns | +| **Out of scope** | Greeting header, followed repositories, private repositories, pinned repositories | Not needed in Signed | + +Notes: + +- There is **no greeting header**. The screen starts with the inbox panel. +- Unread and Archived are separate panels opened in the bottom dock, not tabs in the inbox panel. + +## 3. The Signed screen + +`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item (currently a +placeholder that opens Explore). It is one scrollable two-column flex row: + +``` ++--------------------------------------------------+-----------------------+ +| Inbox (3 unread) [Unread] [Archived] [Mark all read] | +| [avatar] issue opened on you/repo 2m | +| [avatar] commented on "Fix parser" 1h | +| [avatar] PR update on you/repo 3h | +| [Show all] | +| | +| Continue where you left off | +| [icon] "Fix parser bug" you/repo opened 3d | +| [icon] "Add retry" you/repo PR 5d | ++--------------------------------------------------+-----------------------+ +| bottom dock: Unread or Archived list (opened by the header buttons) | ++--------------------------------------------------+-----------------------+ +``` + +The right column is **My repositories**, mirroring the sidebar's signed-in repo list: + +``` +| My repositories | +| [search] [New] | +| repo row | +| repo row | +``` + +## 4. Data layer + +### 4.1 `signed_core`: pure logic + +**`filters.rs`** (extend, next to `activity`/`comments_for`): + +```rust +/// Kinds that notify a user when they tag them directly. +pub const NOTIFICATION_KINDS: [Kind; 9] = [ + Kind::GitIssue, + Kind::GitPullRequest, + Kind::GitPatch, + Kind::GitPullRequestUpdate, + COVER_NOTE_KIND, + Kind::GitStatusOpen, + Kind::GitStatusApplied, + Kind::GitStatusClosed, + Kind::GitStatusDraft, +]; + +/// Comments on our issues/PRs/patches. +pub fn notification_comments(me: PublicKey) -> Filter { + Filter::new() + .kind(Kind::Comment) + .custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()]) + .custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"]) +} + +/// Activity directed at us: comments on our roots, and git events tagging us. +pub fn notifications(me: PublicKey) -> Vec { + vec![ + notification_comments(me), + Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me), + ] +} + +/// Git activity authored by `me`, for "Continue where you left off". +pub fn authored_activity(me: PublicKey) -> Filter { + Filter::new() + .kinds([ACTIVITY_KINDS.as_slice(), &[COVER_NOTE_KIND]].concat()) + .author(me) +} +``` + +`ACTIVITY_KINDS` already exists in this file. All builders use existing SDK APIs +(`Filter::kind/kinds/pubkey/custom_tags`, `SingleLetterTag::{UPPERCASE_P, UPPERCASE_K}`). + +Comments authored by `me` are not all git comments, so the activity query needs a post-filter: +keep kind 1111 only when its uppercase `K` tag is a git root kind (1621/1617/1618/30617), matching +gitworkshop's `isGitComment`. + +**`inbox.rs`** (new file): + +```rust +pub struct InboxItem { + pub root: EventId, + pub root_kind: Option, + pub address: Option, + /// Events in the group, newest first. + pub events: Vec, + /// Unread event ids, oldest first. + pub unread_ids: Vec, + pub archived: bool, +} + +/// The thread root of a notification event, or `None` if it isn't git-related. +pub fn notification_root( + event: &Event, + lookup: &impl Fn(EventId) -> Option, +) -> Option; + +/// Group notification events by root, newest activity first, self excluded. +pub fn group( + events: impl IntoIterator, + me: PublicKey, + state: &InboxReadState, + lookup: &impl Fn(EventId) -> Option, +) -> Vec; +``` + +Root resolution, ported from `getNotificationRootId`: + +- issue (1621) / PR (1618): itself +- patch (1617): its `e` parent patch, else itself +- NIP-22 comment (1111): uppercase `E` root pointer (SDK `nip22::extract_root`) +- PR update (1619): uppercase `E` +- statuses (1630-1633) / cover note (1624): NIP-10 root `e` +- self-authored events are excluded + +Read/archive state, the compact high-water-mark model: + +```rust +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct InboxReadState { + #[serde(default)] pub read_before: Timestamp, + #[serde(default)] pub read_ids: HashSet, + #[serde(default)] pub archived_before: Timestamp, + #[serde(default)] pub archived_ids: HashSet, +} + +impl InboxReadState { + pub fn is_read(&self, event: &Event) -> bool; + pub fn is_archived(&self, event: &Event) -> bool; + pub fn mark_read(&mut self, event: &Event); + pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey); + /// Move the cutoff to `min(oldest unread - 1, now - 3 days)` and prune ids. + pub fn advance_read(&mut self, all: &[Event], me: PublicKey); + pub fn advance_archived(&mut self, all: &[Event], me: PublicKey); +} +``` + +`activity_subject` in `model.rs` already gives an issue/PR title from the `subject` tag or first +line; reuse it for the home screen rows. + +### 4.2 Persistence: NIP-78 in the local database, never published + +Read state is a normal NIP-78 (kind `30078`, `Kind::ApplicationSpecificData`) addressable event +**written to LMDB only**. It is never broadcast to a relay, so the read state stays on this device. + +It is signed with a **random keypair**, never the user's signer. The event is local application +storage, so its author carries no identity; this avoids a signing round-trip and does not depend on +the signer type. The `d` tag identifies the owning user, so state does not leak across identities +when the signed-in key changes. + +```rust +/// d tag identifying the inbox read/archive state event of `me`. +fn inbox_state_d_tag(me: PublicKey) -> String { + format!("signed-inbox-state:{me}") +} + +/// Newest stored read state for `me`, with the id of the event it came from. +async fn load_state( + client: &Client, + me: PublicKey, +) -> Result> { + // No author filter: the signing key is random per session. + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(inbox_state_d_tag(me)); + let events = client.database().query(filter).await?; + let Some(event) = events.into_iter().max_by_key(|e| e.created_at) else { + return Ok(None); + }; + Ok(serde_json::from_str(&event.content) + .ok() + .map(|state| (state, event.id))) +} + +/// Sign with the random `keys` and store locally. +async fn save_state( + client: &Client, + keys: &Keys, + me: PublicKey, + state: &InboxReadState, +) -> Result { + let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) + .tags([Tag::identifier(inbox_state_d_tag(me))]) + .finalize(keys)?; // synchronous: random keys, no user signer + // Local-only: no `send_event`, no broadcast. The event lives in LMDB. + client.database().save_event(&event).await?; + Ok(event.id) +} +``` + +Kind 30078 is addressable, so saves signed by the same `keys` replace the previous event. Because +`keys` is random per session, the first save of a session creates a new coordinate; the store then +deletes the event it loaded (its id is kept from `load_state`) so exactly one state event remains. +`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs. + +### 4.3 `signed_state`: one `InboxStore` + +One store backs the whole screen, modelled on `RepoListStore` (`repos.rs`): + +```rust +pub struct InboxStore { + /// Activity directed at the user, grouped by thread root, newest first. + pub notifications: Arc>, + /// The user's own recent git activity, newest first. + pub activity: Arc>, + /// Unread notification count (non-archived). + pub unread_count: usize, + state: InboxReadState, + user: Option, + /// Random keypair signing the local NIP-78 storage event. + keys: Keys, + /// Event id of the loaded state event, pruned on the next save. + loaded_state: Option, + refresh: RefreshGate, + _subscription: Subscription, +} +``` + +**Lifespan: idle until a signer exists.** The store is created in `signed_state::init` like the +other stores, but it does nothing until the user has a signer. It is never wired from the `desktop` +crate, and `signed_state::init` gains no parameters. + +- `new` schedules `cx.defer`, like the other stores. The deferred bootstrap checks + `Backend::current_user()`: + - signer already present (session restored before the store was created): activate now; + - no signer: do nothing, wait for the event. +- `BackendEvent::SignerChanged`: activate. +- `BackendEvent::SignerRequired` (logout / no credential): clear `user`, `notifications`, + `activity`, `unread_count`. +- `BackendEvent::NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`, + is `Kind::Comment`, or is a deletion. +- `BackendEvent::Synced`: refresh. +- `BackendEvent::Published`: refresh. + +**Activation** (only on signer): + +```rust +fn activate(&mut self, me: PublicKey, cx: &mut Context) { + self.user = Some(me); + // Load the NIP-78 state from LMDB, then subscribe and refresh. + // All run on background tasks; only plain data crosses back. + self.load_state(me, cx); + self.subscribe_remote(cx); + self.refresh(cx); +} +``` + +Loading uses the random `keys` only for signing on save; reading the state event needs no signer at +all. Activation itself is still gated on the signer because the fetch filters need the user's pubkey. + +**Fetch** (reuses `Backend::subscribe_bootstrap` / `connect_repo_relays`): + +```rust +fn subscribe_remote(&mut self, cx: &mut Context) { + let Some(me) = self.user else { return }; + let backend = Backend::global(cx); + backend.update(cx, |backend, cx| { + backend.subscribe_bootstrap(filters::notifications(me), cx); + backend.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); + }); + // Relays of the user's own repos, so their activity there is found too. + let relays = own_repo_relays(me, cx); + backend.update(cx, |backend, cx| { + backend.connect_repo_relays(relays.clone(), filters::notifications(me), cx); + backend.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx); + }); +} +``` + +`own_repo_relays` reads `RepoListStore::global(cx).read(cx).announcements_of(&me)` and unions their +`relays`. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind 10002 yet.) + +**Refresh** (mirrors `RepoListStore::run_refresh`): + +- `cx.background_spawn`: query the notification filters and the activity filter from + `client.database()`. +- Query `filters::deletions()`, build `Deletions`, skip deleted events. +- Build `HashMap` for root walking; group the notification events with + `inbox::group`. +- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when + their `K` tag is a git kind; sort newest first; take the top N. +- Cross back to the main thread: set `notifications`, `activity`, `unread_count`, `cx.notify()`, + `refresh.finish()`. + +**Actions**: `mark_read(root)`, `mark_all_read()`, plus `advance_read` after marking to bound the +`read_ids` set. After each change, sign with the random `keys` and save the NIP-78 event to LMDB, +then delete the previously loaded copy (see 4.2). + +**My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes +`announcements_of(user)`. + +### 4.4 `Cargo.toml` + +- `signed_core`: add `serde.workspace` for the `InboxReadState` derives. +- `signed_state`: add `serde_json.workspace` for the NIP-78 content. + +## 5. UI + +### 5.1 `InboxView` center panel + +New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`. +One scrollable two-column flex row. + +- **Inbox column**: header with the unread count badge and actions **Unread**, **Archived**, + **Mark all read**; then the non-archived notification items (top 5, with a **Show all** toggle + expanding inline). Rows show the actor avatar, a kind badge, the subject, the repo name, a + relative time, and an unread dot. Empty state: "You're all caught up." with `IconName::Inbox`. +- **Continue where you left off**: `InboxStore::activity`, top 15, each row a kind icon, subject, + repo name, and relative time. +- **My repositories**: `RepoListStore::announcements_of(me)` with a small search `InputState` (same + pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows + open `open_repo_panel`. + +No greeting header. + +### 5.2 Unread / Archived as bottom-dock panels + +Add a bottom-panel helper next to `add_center_panel` in `crates/dock/src/lib.rs`: + +```rust +/// Add an already-wrapped panel handle to the bottom dock of `area`. +pub fn add_bottom_panel( + area: &mut DockArea, + panel: Arc, + window: &mut Window, + cx: &mut Context, +) { + area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx); +} +``` + +The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then: + +- New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the + `InboxStore`. It renders the matching subset of `InboxStore::notifications` as a list. +- The **Unread** and **Archived** header buttons in `InboxView` call `add_bottom_panel` with the + requested mode. `InboxView` keeps `filter_view: Option>`; when it + already exists, update its mode and focus instead of adding a duplicate. + +### 5.3 Sidebar + +In `views/sidebar/mod.rs`: + +- Add `inbox: Option>` (mirrors `explore`). +- Add `fn open_inbox(&mut self, window, cx)` that focuses the existing panel or adds a center panel. +- Point the existing nav item at it and add an unread suffix: + + ```rust + NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) + .when(unread > 0, |this| this.suffix(...badge...)) + .on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))), + ``` + +- `cx.observe` the `InboxStore` so the badge updates. + +### 5.4 Click-through (P1) + +Reuse the single `RepoStore` that `RepoDetailView` already creates instead of making a second one: + +1. In `repo_detail/mod.rs`, add: + + ```rust + pub(crate) enum RepoItem { + Issue(EventId), + PullRequest(EventId), + Patch(EventId), + } + + impl RepoDetailView { + pub(crate) fn open_item( + &mut self, + item: RepoItem, + window: &mut Window, + cx: &mut Context, + ) { /* open IssueDetailView / PullRequestDetailView in the dock */ } + } + ``` + +2. `open_repo_panel` already returns `Entity`; the caller invokes + `detail.update_in(window, cx, |detail, window, cx| detail.open_item(...))`. +3. `InboxView` resolves `item.address` to an `Announcement` from `RepoListStore`, opens the repo + panel, then calls `open_item` with the root id and kind. + +Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a +patch-root click opens the repo panel. Note as a known limitation. + +## 6. File-by-file change list + +| File | Change | +|---|---| +| `crates/signed_core/Cargo.toml` | add `serde` | +| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity` | +| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests | +| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports | +| `crates/signed_state/Cargo.toml` | add `serde_json` | +| `crates/signed_state/src/inbox.rs` | **new**: `InboxStore`, global, signer-gated activation, NIP-78 load/save, actions | +| `crates/signed_state/src/lib.rs` | `mod inbox;`, set global in `init` | +| `crates/dock/src/lib.rs` | `add_bottom_panel` helper | +| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel and `InboxFilterView` | +| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` | +| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring and badge | +| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) | + +No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; the store +bootstraps itself via `cx.defer` once a signer is present. + +## 7. Phasing + +1. **Phase 0 - pure logic**: `signed_core` filters and `inbox.rs` plus tests. **DONE.** + Implemented as `filters::{NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity}` + and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch: + the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable, + and `authored_activity` results must pass through `is_git_activity` before display (comments on + non-git roots are matched by the filter). `cargo test -p signed_core` passes (62 tests). +2. **Phase 1 - store**: `InboxStore` with signer-gated activation, both queries, unread count, and + NIP-78 load/save to LMDB, global install. +3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge. +4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. +5. **Phase 4 - click-through**: `open_item` and announcement lookup. +6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail + view. + +Each phase compiles and is usable on its own. + +## 8. Validation + +- `cargo test -p signed_core`: root resolution, grouping, read-state cutoff, serde round-trip. +- `cargo test -p signed_state`: NIP-78 content round-trip (`serde_json`), if a non-GPUI path is + factored out. +- `cargo check --workspace` after each phase. +- Manual: log in with a repo-owning identity; confirm the inbox panel populates from another + identity's issue/comment, the activity list shows your own items, the repositories panel matches + the sidebar, and that no kind-30078 event is broadcast (watch the relays / `Published` events). + Restart to confirm the read state is read back from LMDB. + +## 9. SDK APIs used (verified in the pinned `5c669a4` checkout) + +- `Kind::{Comment, GitIssue, GitPullRequest, GitPatch, GitPullRequestUpdate,` + `GitStatusOpen/Applied/Closed/Draft, ApplicationSpecificData, EventDeletion, RequestToVanish}` +- `Filter::{kind, kinds, pubkey, pubkeys, custom_tags, limit, since, events, coordinate, identifier}` + - Non-obvious: `Filter::pubkey`/`pubkeys` set the lowercase **`p` tag**, not `authors`. Use + `Filter::author`/`authors` for authorship. The `notifications` filter relies on this. +- `SingleLetterTag::{LOWERCASE_P, LOWERCASE_E, UPPERCASE_P, UPPERCASE_K, UPPERCASE_E}` +- `nostr::nips::nip22::{extract_root, extract_parent, CommentTarget}`: NIP-22 root/parent pointers +- `Tags::{event_ids, public_keys, coordinates, identifier, hashtags}` iterators +- `Client::{database, subscribe, sync, notifications, send_event, add_relay}`; + `NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory` +- `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate` +- `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate` -- 2.54.0 From b7221ef8147689596655fda900929fe599495761 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 11 Sep 2026 08:52:04 +0700 Subject: [PATCH 02/10] refacotr --- Cargo.lock | 1 + crates/signed_core/src/inbox.rs | 80 +++++- crates/signed_state/Cargo.toml | 1 + crates/signed_state/src/backend.rs | 212 +++++++++----- crates/signed_state/src/inbox.rs | 431 +++++++++++++++++++++++++++++ crates/signed_state/src/lib.rs | 18 +- crates/signed_state/src/refresh.rs | 5 - docs/inbox-plan.md | 337 +++++++++++++++------- 8 files changed, 895 insertions(+), 190 deletions(-) create mode 100644 crates/signed_state/src/inbox.rs diff --git a/Cargo.lock b/Cargo.lock index 189ee38..9d23ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8048,6 +8048,7 @@ dependencies = [ "nostr-connect", "nostr-sdk", "rustls", + "serde_json", "settings", "signed_core", "signed_git", diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs index 4a07e24..57aed3f 100644 --- a/crates/signed_core/src/inbox.rs +++ b/crates/signed_core/src/inbox.rs @@ -42,6 +42,19 @@ impl InboxItem { pub fn is_unread(&self) -> bool { !self.archived && !self.unread_ids.is_empty() } + + /// Recompute the unread and archived flags from `state`. + pub fn apply_state(&mut self, state: &InboxReadState) { + self.unread_ids = self + .events + .iter() + .rev() + .filter(|event| !state.is_read(event)) + .map(|event| event.id) + .collect(); + + self.archived = self.events.iter().all(|event| state.is_archived(event)); + } } /// Root issue, patch or pull request of a notification event. @@ -112,25 +125,18 @@ where let root_event = lookup(root); - let unread_ids = events - .iter() - .rev() - .filter(|event| !state.is_read(event)) - .map(|event| event.id) - .collect(); - - let archived = events.iter().all(|event| state.is_archived(event)); - - InboxItem { + let mut item = InboxItem { root, root_kind: root_event.as_ref().map(|event| event.kind), address: root_event .as_ref() .and_then(|event| event.tags.coordinates().next()), events, - unread_ids, - archived, - } + unread_ids: Vec::new(), + archived: false, + }; + item.apply_state(state); + item }) .collect(); @@ -174,6 +180,13 @@ impl InboxReadState { } } + /// Mark one event archived. Events at or before the cutoff are already archived. + pub fn mark_archived(&mut self, event: &Event) { + if event.created_at > self.archived_before { + self.archived_ids.insert(event.id); + } + } + /// Mark every non-self event read, anchoring the cutoff ten days back. pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { let cutoff = now - MARK_ALL_WINDOW; @@ -626,6 +639,47 @@ mod tests { assert_eq!(state.read_ids, HashSet::from([fresh.id])); } + #[test] + fn mark_archived_skips_events_at_or_before_the_cutoff() { + let now = Timestamp::from_secs(1_000_000_000); + let event = issue(&keys(2), now.as_secs() - 1000); + + let mut state = InboxReadState { + archived_before: now, + ..Default::default() + }; + state.mark_archived(&event); + assert!(state.archived_ids.is_empty()); + + let mut state = InboxReadState::default(); + state.mark_archived(&event); + assert_eq!(state.archived_ids, HashSet::from([event.id])); + } + + #[test] + fn apply_state_recomputes_unread_and_archived() { + let now = Timestamp::from_secs(1_000_000_000); + let first = issue(&keys(2), now.as_secs() - 2000); + let second = issue(&keys(2), now.as_secs() - 1000); + let mut item = InboxItem { + root: first.id, + root_kind: None, + address: None, + events: vec![second.clone(), first.clone()], + unread_ids: Vec::new(), + archived: false, + }; + + let state = InboxReadState { + read_before: first.created_at, + ..Default::default() + }; + item.apply_state(&state); + + assert_eq!(item.unread_ids, vec![second.id]); + assert!(!item.archived); + } + #[test] fn serde_round_trip_preserves_state() { let first = issue(&keys(1), 100); diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index dc60179..554c38b 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -22,6 +22,7 @@ flume.workspace = true futures.workspace = true anyhow.workspace = true log.workspace = true +serde_json.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rustls = "0.23" diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 43765a2..1d8cbc8 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -14,6 +14,8 @@ use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_ use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use crate::git_store::GitStore; +use crate::inbox::Inbox; +use crate::repos::RepoListStore; /// Keyring entry for the user credential. pub const USER_KEYRING: &str = "Signed Safe Storage"; @@ -79,19 +81,18 @@ impl BackendEvent { /// The global backend entity. /// -/// Owns the nostr client, the signer and the notification pump. +/// Owns the nostr client, the signer, the notification pump and the inbox. pub struct Backend { client: Client, signer: UniversalSigner, current_user: Option, + /// User's inbox, including notifications and recent activity. + inbox: Entity, + /// The progress of the current sync operation, if any. sync_progress: Option<(u64, u64)>, /// True when the stored credential is NIP-49 encrypted. passphrase_required: bool, /// Repositories with a push in flight, mirror or checkout based. - /// - /// A child entity: views that only care whether one repository is - /// pushing can `cx.observe` it without being invoked on unrelated - /// `Backend` changes (a `sync_progress` tick, a new relay connecting). pushing_repos: Entity>, } @@ -112,6 +113,7 @@ impl Backend { } pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context) -> Self { + let weak = cx.entity().downgrade(); let pump_client = client.clone(); let pump: Task> = cx.spawn(async move |this, cx| { @@ -133,13 +135,17 @@ impl Backend { loop { let now = Instant::now(); + if now >= deadline { break; } + let timer = cx.background_executor().timer(deadline - now); futures::pin_mut!(timer); + let next = notifications.next(); futures::pin_mut!(next); + match futures::future::select(next, timer).await { futures::future::Either::Left(( Some(ClientNotification::Event { event, .. }), @@ -156,7 +162,9 @@ impl Backend { // Collect and emit the collected events. let batch = std::mem::take(&mut pending); - if let Err(e) = this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(batch))) { + if let Err(e) = this.update(cx, |this, cx| { + this.emit(BackendEvent::NostrUpdate(batch), cx) + }) { log::warn!("failed to emit nostr update: {e}"); } } @@ -167,7 +175,6 @@ impl Backend { pump.detach(); // Bootstrap the client. - let weak = cx.entity().downgrade(); cx.defer(move |cx| { if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) { log::warn!("backend dropped before bootstrap could run: {error}"); @@ -178,46 +185,50 @@ impl Backend { client, signer, current_user: None, + inbox: cx.new(|_| Inbox::default()), sync_progress: None, passphrase_required: false, pushing_repos: cx.new(|_| HashSet::new()), } } - /// Bootstrap the client. - /// - /// Restore the saved session, if any. + /// Bootstrap the client and restore the saved session, if any. fn bootstrap(&mut self, cx: &mut Context) { let client = self.client.clone(); let task = cx.background_spawn(async move { for url in BOOTSTRAP_RELAYS { - client.add_relay(url).and_connect().await?; + client.add_relay(url).await?; } + for url in INDEXER_RELAYS { client .add_relay(url) .capabilities(RelayCapabilities::DISCOVERY) - .and_connect() .await?; } + + client.connect().await; + Ok::<(), Error>(()) }); let notify_task: Task> = cx.spawn(async move |this, cx| { match task.await { Ok(()) => { - this.update(cx, |_this, cx| cx.notify())?; + this.update(cx, |this, cx| { + this.restore_session(cx); + })?; } Err(e) => { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; + this.update(cx, |this, cx| { + this.emit(BackendEvent::error(e.to_string()), cx) + })?; } } - Ok(()) + Ok::<(), Error>(()) }); notify_task.detach(); - - self.restore_session(cx); } /// Restore the saved session from the keyring. @@ -227,7 +238,7 @@ impl Backend { /// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity. pub fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { - cx.emit(BackendEvent::SignerRequired); + self.emit(BackendEvent::SignerRequired, cx); return; } @@ -237,7 +248,7 @@ impl Backend { let content = match user.await { Ok(Some((_username, secret))) => String::from_utf8(secret)?, _ => { - this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; + this.update(cx, |this, cx| this.emit(BackendEvent::SignerRequired, cx))?; return Ok(()); } }; @@ -258,15 +269,13 @@ impl Backend { signer.auth_url_handler(SignedAuthUrlHandler); this.update(cx, |this, cx| this.set_signer(signer, cx))?; } else if content.starts_with("ncryptsec1") { - // Encrypted identity. // A passphrase is required to decrypt it before the session can resume. - log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase"); this.update(cx, |this, cx| { this.passphrase_required = true; - cx.emit(BackendEvent::PassphraseRequired); + this.emit(BackendEvent::PassphraseRequired, cx); })?; } else { - this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; + this.update(cx, |this, cx| this.emit(BackendEvent::SignerRequired, cx))?; } Ok::<_, Error>(()) @@ -274,9 +283,9 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |_, cx| { - cx.emit(BackendEvent::error(e.to_string())); - cx.emit(BackendEvent::SignerRequired); + this.update(cx, |this, cx| { + this.emit(BackendEvent::error(e.to_string()), cx); + this.emit(BackendEvent::SignerRequired, cx); })?; } @@ -360,7 +369,8 @@ impl Backend { this.signer.swap_inner(keys); this.current_user = Some(public_key); this.bootstrap_user(public_key, cx); - cx.emit(BackendEvent::SignerChanged); + this.emit(BackendEvent::SignerChanged, cx); + this.sync_inbox(cx); cx.notify(); let relays: Vec<(RelayUrl, Option)> = [ @@ -968,9 +978,7 @@ impl Backend { } else if credential.starts_with("bunker://") { self.login_with_bunker(credential, cx); } else { - cx.emit(BackendEvent::error( - "Unsupported credential, expected nsec1... or bunker://...", - )); + self.emit(BackendEvent::error("Unsupported credential."), cx); } } @@ -988,7 +996,7 @@ impl Backend { let keys = match SecretKey::parse(nsec) { Ok(secret) => Keys::new(secret), Err(e) => { - cx.emit(BackendEvent::error(e.to_string())); + self.emit(BackendEvent::error(e.to_string()), cx); return; } }; @@ -999,7 +1007,9 @@ impl Backend { let task: Task> = cx.spawn(async move |this, cx| { if let Err(e) = write.await { - this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; + this.update(cx, |this, cx| { + this.emit(BackendEvent::error(e.to_string()), cx) + })?; return Ok(()); } this.update(cx, |this, cx| this.set_signer(keys, cx))?; @@ -1015,7 +1025,7 @@ impl Backend { let connect_uri = match NostrConnectUri::parse(&uri_string) { Ok(uri) => uri, Err(e) => { - cx.emit(BackendEvent::error(e.to_string())); + self.emit(BackendEvent::error(e.to_string()), cx); return; } }; @@ -1045,7 +1055,9 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; + this.update(cx, |this, cx| { + this.emit(BackendEvent::error(e.to_string()), cx) + })?; } Ok(()) @@ -1064,8 +1076,9 @@ impl Backend { this.signer.swap_inner(Keys::generate()); this.current_user = None; this.passphrase_required = false; - cx.emit(BackendEvent::SignerChanged); - cx.emit(BackendEvent::SignerRequired); + this.emit(BackendEvent::SignerChanged, cx); + this.emit(BackendEvent::SignerRequired, cx); + this.sync_inbox(cx); cx.notify(); })?; @@ -1096,7 +1109,9 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; + this.update(cx, |this, cx| { + this.emit(BackendEvent::error(e.to_string()), cx) + })?; } Ok(()) @@ -1121,6 +1136,13 @@ impl Backend { self.pushing_repos.clone() } + /// The inbox child entity backing the home screen. + /// + /// A child entity: `cx.observe` it to react only to inbox changes. + pub fn inbox(&self) -> Entity { + self.inbox.clone() + } + /// Get the current user's public key. pub fn current_user(&self) -> Option { self.current_user @@ -1133,7 +1155,63 @@ impl Backend { /// Surface an error message through [`BackendEvent::Error`]. pub fn emit_error(&mut self, message: impl Into, cx: &mut Context) { - cx.emit(BackendEvent::error(message)); + self.emit(BackendEvent::error(message), cx); + } + + /// Update the inbox, then emit `event` to the other stores. + fn emit(&self, event: BackendEvent, cx: &mut Context) { + let inbox = self.inbox.downgrade(); + let inbox_event = event.clone(); + + cx.defer(move |cx| { + if let Err(error) = inbox.update(cx, |inbox, cx| { + inbox.handle_backend_event(&inbox_event, cx); + }) { + log::warn!("inbox dropped before handling backend event: {error}"); + } + }); + + cx.emit(event); + } + + /// Attach the inbox to the current signer and activate or clear it. + /// + /// The inbox's own update is deferred because activating reads `Backend`, + /// which every call site is in the middle of updating. + fn sync_inbox(&mut self, cx: &mut Context) { + if let Some(me) = self.current_user { + self.subscribe_bootstrap(filters::notifications(me), cx); + self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); + + let relays: HashSet = RepoListStore::global(cx) + .read(cx) + .announcements_of(&me) + .into_iter() + .flat_map(|announcement| announcement.relays) + .collect(); + + if !relays.is_empty() { + let relays: Vec = relays.into_iter().collect(); + self.connect_repo_relays(relays.clone(), filters::notifications(me), cx); + self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx); + } + } + + let inbox = self.inbox.downgrade(); + + cx.defer(move |cx| { + let updated = inbox.update(cx, |inbox, cx| { + if Backend::global(cx).read(cx).current_user().is_some() { + inbox.activate(cx); + } else { + inbox.reset(cx); + } + }); + + if let Err(error) = updated { + log::warn!("inbox dropped before syncing with the signer: {error}"); + } + }); } /// Progress of the in-flight negentropy sync, if any. @@ -1149,7 +1227,7 @@ impl Backend { ::Error: std::error::Error + Send + Sync + 'static, ::Error: std::error::Error + Send + Sync + 'static, { - let task: Task> = cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { match new_signer.get_public_key_async().await { Ok(public_key) => { this.update(cx, |this, cx| { @@ -1157,26 +1235,24 @@ impl Backend { this.current_user = Some(public_key); this.passphrase_required = false; this.bootstrap_user(public_key, cx); - cx.emit(BackendEvent::SignerChanged); + this.emit(BackendEvent::SignerChanged, cx); + this.sync_inbox(cx); cx.notify(); })?; } Err(e) => { - this.update(cx, |_this, cx| { - cx.emit(BackendEvent::error(e.to_string())); + this.update(cx, |this, cx| { + this.emit(BackendEvent::error(e.to_string()), cx); })?; } } - Ok(()) - }); - task.detach(); + Ok::<(), Error>(()) + }) + .detach(); } /// Connect to a repository's announced relays, its NIP-34 `relays` tag. - /// - /// Callers are responsible for not repeating this for relays they already - /// connected, e.g. `RepoStore::repo_relays`. pub fn connect_repo_relays( &mut self, relays: Vec, @@ -1185,13 +1261,13 @@ impl Backend { ) { let client = self.client.clone(); - let task: Task> = cx.spawn(async move |_this, _cx| { + cx.spawn(async move |_this, _cx| { if let Err(e) = connect_repo_relays(&client, relays, filters).await { log::warn!("repo relay fetch failed: {e}"); } - Ok(()) - }); - task.detach(); + Ok::<(), Error>(()) + }) + .detach(); } /// One-shot subscription on the bootstrap relays only. @@ -1201,24 +1277,25 @@ impl Backend { let fetch = cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await }); - let task: Task> = cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { if let Err(e) = fetch.await { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; + this.update(cx, |this, cx| { + this.emit(BackendEvent::error(e.to_string()), cx); + })?; } - Ok(()) - }); - task.detach(); + Ok::<(), Error>(()) + }) + .detach(); } /// Negentropy-sync the given filter against the bootstrap relays. pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context) { let client = self.client.clone(); + let (tx, mut rx) = SyncProgress::channel(); self.sync_progress = Some((0, 0)); cx.notify(); - let (tx, mut rx) = SyncProgress::channel(); - let progress_task: Task> = cx.spawn(async move |this, cx| { let mut last_percent: u64 = 0; @@ -1231,10 +1308,13 @@ impl Backend { let alive = this.update(cx, |this, cx| { this.sync_progress = Some((progress.total, progress.current)); - cx.emit(BackendEvent::SyncProgress { - total: progress.total, - current: progress.current, - }); + this.emit( + BackendEvent::SyncProgress { + total: progress.total, + current: progress.current, + }, + cx, + ); cx.notify(); }); @@ -1263,14 +1343,14 @@ impl Backend { ); this.update(cx, |this, cx| { this.sync_progress = None; - cx.emit(BackendEvent::Synced); + this.emit(BackendEvent::Synced, cx); cx.notify(); })?; } Err(e) => { this.update(cx, |this, cx| { this.sync_progress = None; - cx.emit(BackendEvent::error(e.to_string())) + this.emit(BackendEvent::error(e.to_string()), cx) })?; } } @@ -1284,7 +1364,7 @@ impl Backend { /// Callers publish with `client.send_event(...)` directly, then call this /// so stores like `RepoListStore` refresh without re-querying the relays. pub fn announce_published(&self, event: Event, cx: &mut Context) { - cx.emit(BackendEvent::Published(Box::new(event))); + self.emit(BackendEvent::Published(Box::new(event)), cx); } /// Publish a NIP-09 deletion for each of `events`, best-effort. diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs new file mode 100644 index 0000000..67539de --- /dev/null +++ b/crates/signed_state/src/inbox.rs @@ -0,0 +1,431 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Error; +use gpui::{AppContext, Context, Task}; +use nostr_sdk::prelude::*; +use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox}; + +use crate::backend::{Backend, BackendEvent}; +use crate::refresh::{RefreshGate, RefreshRequest}; + +/// Delay between a refresh request and the actual re-query. +const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); + +/// Maximum number of "continue where you left off" activity events kept. +const ACTIVITY_LIMIT: usize = 50; + +/// State backing the inbox home screen. +#[derive(Default)] +pub struct Inbox { + /// Notifications grouped by thread root, newest activity first. + pub notifications: Arc>, + /// The user's own recent git activity, newest first. + pub activity: Arc>, + /// Number of non-archived groups with an unread event. + pub unread_count: usize, + state: InboxReadState, + /// Set once the stored state has been read for the current user. + state_loaded: bool, + refresh: RefreshGate, +} + +impl Inbox { + /// Mark every event in the group rooted at `root` as read. + pub fn mark_read(&mut self, root: EventId, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let Some(events) = self.group_events(root) else { + return; + }; + + for event in &events { + self.state.mark_read(event); + } + + let all = self.all_notification_events(); + self.state.advance_read(&all, me, Timestamp::now()); + self.after_state_change(cx); + } + + /// Archive the group rooted at `root`. Archived events are always read too. + pub fn mark_archived(&mut self, root: EventId, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let Some(events) = self.group_events(root) else { + return; + }; + + for event in &events { + self.state.mark_archived(event); + self.state.mark_read(event); + } + + let all = self.all_notification_events(); + let now = Timestamp::now(); + + self.state.advance_archived(&all, me, now); + self.state.advance_read(&all, me, now); + self.after_state_change(cx); + } + + /// Mark every known notification read. + pub fn mark_all_read(&mut self, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let all = self.all_notification_events(); + self.state.mark_all_read(&all, me, Timestamp::now()); + self.after_state_change(cx); + } + + /// Handle a backend event that can change the inbox contents. + pub(crate) fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { + match event { + BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx), + BackendEvent::NostrUpdate(updates) => { + let relevant = updates.iter().any(|update| { + let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind); + let is_comment = update.kind == Kind::Comment; + let is_event_deletion = update.kind == Kind::EventDeletion; + let is_request_to_vanish = update.kind == Kind::RequestToVanish; + + is_notification || is_comment || is_event_deletion || is_request_to_vanish + }); + if relevant { + self.refresh(cx); + } + } + _ => {} + } + } + + /// Activate the inbox for the backend's current user. + pub(crate) fn activate(&mut self, cx: &mut Context) { + let backend = Backend::global(cx); + let Some(me) = backend.read(cx).current_user() else { + return; + }; + + self.notifications = Arc::new(Vec::new()); + self.activity = Arc::new(Vec::new()); + self.unread_count = 0; + self.state = InboxReadState::default(); + self.state_loaded = false; + // Drop any in-flight or pending run belonging to the previous user. + self.refresh = RefreshGate::default(); + cx.notify(); + + self.load_state(me, cx); + } + + /// Forget everything for the current user. + pub(crate) fn reset(&mut self, cx: &mut Context) { + self.notifications = Arc::new(Vec::new()); + self.activity = Arc::new(Vec::new()); + self.unread_count = 0; + self.state = InboxReadState::default(); + self.state_loaded = false; + self.refresh = RefreshGate::default(); + cx.notify(); + } + + /// Read the stored state, then run the first refresh. + fn load_state(&mut self, me: PublicKey, cx: &mut Context) { + let backend = Backend::global(cx); + let client = backend.read(cx).client(); + + let work = cx.background_spawn(async move { load_state(&client, me).await }); + + cx.spawn(async move |this, cx| { + let loaded = work.await; + + this.update(cx, |this, cx| { + if backend.read(cx).current_user() != Some(me) { + return; + } + + match loaded { + Ok(Some(state)) => this.state = state, + Ok(None) => this.state = InboxReadState::default(), + Err(error) => log::warn!("failed to load inbox state: {error}"), + } + + this.state_loaded = true; + this.refresh_initial(cx); + })?; + + Ok::<(), Error>(()) + }) + .detach(); + } + + /// One-shot initial load, no debounce. + fn refresh_initial(&mut self, cx: &mut Context) { + debug_assert!(!self.refresh.debouncing()); + if self.refresh.running() { + self.refresh.request(); + return; + } + + self.run_refresh(cx); + } + + /// Re-query the local database. + pub fn refresh(&mut self, cx: &mut Context) { + if !self.state_loaded { + return; + } + + if self.refresh.request() != RefreshRequest::Schedule { + return; + } + + cx.spawn(async move |this, cx| { + cx.background_executor().timer(REFRESH_DEBOUNCE).await; + this.update(cx, |this, cx| this.run_refresh(cx)) + }) + .detach(); + } + + /// One query and apply cycle, the debounced entry point. + fn run_refresh(&mut self, cx: &mut Context) { + self.refresh.begin(); + + let Some(me) = Backend::global(cx).read(cx).current_user() else { + self.refresh.abort(); + return; + }; + + let client = Backend::global(cx).read(cx).client(); + let state = self.state.clone(); + + let work = cx.background_spawn(async move { + let deletion_events = client.database().query(filters::deletions()).await?; + let deletions = Deletions::from_events(deletion_events); + + let (notification_events, by_id) = fetch_notifications(&client, me, &deletions).await?; + let notifications = inbox::group(notification_events, me, &state, &|id| { + by_id.get(&id).cloned() + }); + let unread_count = notifications.iter().filter(|item| item.is_unread()).count(); + + let mut activity = Vec::new(); + for event in client + .database() + .query(filters::authored_activity(me)) + .await? + { + if deletions.is_deleted(&event) || !filters::is_git_activity(&event) { + continue; + } + activity.push(event); + } + + activity.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) + }); + activity.truncate(ACTIVITY_LIMIT); + + Ok::<_, Error>((notifications, activity, unread_count)) + }); + + let task: Task> = cx.spawn(async move |this, cx| { + let (notifications, activity, unread_count) = match work.await { + Ok(results) => results, + // Database errors are transient, keep the last lists. + Err(error) => { + log::warn!("inbox refresh failed: {error}"); + return this.update(cx, |this, _cx| this.refresh.abort()); + } + }; + + let again = this.update(cx, |this, cx| { + // The signer may have changed while the query ran, making + // these results belong to the previous user. + if Backend::global(cx).read(cx).current_user() != Some(me) { + this.refresh.abort(); + return false; + } + + this.notifications = Arc::new(notifications); + this.activity = Arc::new(activity); + this.unread_count = unread_count; + cx.notify(); + + this.refresh.finish() + })?; + + if again { + this.update(cx, |this, cx| this.refresh(cx))?; + } + + Ok(()) + }); + + task.detach(); + } + + /// Advance the cutoffs, re-derive the groups and persist the state. + fn after_state_change(&mut self, cx: &mut Context) { + self.regroup(); + self.persist(cx); + cx.notify(); + } + + /// Recompute the unread and archived flags from the current state. + fn regroup(&mut self) { + let mut items = (*self.notifications).clone(); + + for item in items.iter_mut() { + item.apply_state(&self.state); + } + + self.unread_count = items.iter().filter(|item| item.is_unread()).count(); + self.notifications = Arc::new(items); + } + + /// Sign the state with a random key and store it locally. + fn persist(&mut self, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let client = Backend::global(cx).read(cx).client(); + let state = self.state.clone(); + + let task: Task> = cx.spawn(async move |_this, _cx| { + if let Err(error) = save_state(&client, me, &state).await { + log::warn!("failed to save inbox state: {error}"); + } + Ok(()) + }); + + task.detach(); + } + + /// Events of the group rooted at `root`. + fn group_events(&self, root: EventId) -> Option> { + self.notifications + .iter() + .find(|item| item.root == root) + .map(|item| item.events.clone()) + } + + /// Every event in every group, archived groups included. + fn all_notification_events(&self) -> Vec { + self.notifications + .iter() + .flat_map(|item| item.events.iter().cloned()) + .collect() + } +} + +/// `d` tag identifying the inbox state event of `me`. +fn inbox_state_d_tag(me: PublicKey) -> String { + format!("signed-inbox-state:{}", me.to_hex()) +} + +/// Newest stored state for `me`. +async fn load_state(client: &Client, me: PublicKey) -> Result, Error> { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(inbox_state_d_tag(me)); + + let events = client.database().query(filter).await?; + + let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else { + return Ok(None); + }; + + match serde_json::from_str(&event.content) { + Ok(state) => Ok(Some(state)), + Err(error) => { + log::warn!("ignoring unreadable inbox state {}: {error}", event.id); + Ok(None) + } + } +} + +/// Sign with a random key and store locally. +async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> { + let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) + .tags([Tag::identifier(inbox_state_d_tag(me))]) + .finalize(&Keys::generate())?; + + client.database().save_event(&event).await?; + + Ok(()) +} + +/// Notification events and a lookup of every ancestor they reference. +async fn fetch_notifications( + client: &Client, + me: PublicKey, + deletions: &Deletions, +) -> Result<(Vec, HashMap), Error> { + let mut notifications: Vec = Vec::new(); + let mut by_id: HashMap = HashMap::new(); + + for filter in filters::notifications(me) { + for event in client.database().query(filter).await? { + if deletions.is_deleted(&event) { + continue; + } + + if by_id.insert(event.id, event.clone()).is_none() { + notifications.push(event); + } + } + } + + let mut pending: Vec = notifications.iter().flat_map(event_references).collect(); + let mut seen: HashSet = by_id.keys().copied().collect(); + + loop { + // Keep only ids not walked yet, and remember them. + pending.retain(|id| seen.insert(*id)); + + if pending.is_empty() { + break; + } + + let ancestors = client + .database() + .query(Filter::new().ids(pending.iter().copied())) + .await?; + + let mut next = Vec::new(); + + for event in ancestors { + if deletions.is_deleted(&event) { + continue; + } + next.extend(event_references(&event).filter(|id| !seen.contains(id))); + by_id.entry(event.id).or_insert(event); + } + + pending = next; + } + + Ok((notifications, by_id)) +} + +/// Event ids referenced by `event` through its `e` and `E` tags. +fn event_references(event: &Event) -> impl Iterator + '_ { + event.tags.iter().filter_map(|tag| { + if tag.kind() != "e" && tag.kind() != "E" { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) +} diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index f7b4bcc..061c70a 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -1,6 +1,7 @@ mod backend; mod checkouts; mod git_store; +mod inbox; mod profile; mod refresh; mod repo; @@ -11,7 +12,8 @@ use std::path::{Path, PathBuf}; pub use backend::{Backend, BackendEvent, user_grasp_list_servers}; pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; pub use git_store::GitStore; -use gpui::{App, AppContext, Entity}; +use gpui::{App, AppContext}; +pub use inbox::Inbox; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; pub use repo::RepoStore; @@ -19,14 +21,13 @@ pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore}; use signed_nostr::new_backend; /// Initialize the backend and stores, and install them as globals. -/// Call once at startup, before opening any window that uses the stores. #[cfg(not(target_arch = "wasm32"))] pub fn init( db_path: impl AsRef, repos_root: impl Into, scan_paths: Vec, cx: &mut App, -) -> Entity { +) { // rustls uses the `aws_lc_rs` provider by default. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); @@ -37,27 +38,22 @@ pub fn init( .expect("failed to initialize nostr backend") }); - let entity = cx.new(|cx| Backend::new(client, signer, cx)); - Backend::set_global(entity.clone(), cx); + Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx); RepoListStore::set_global(cx.new(RepoListStore::new), cx); GitStore::set_global(repos_root, cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); - - entity } /// Initialize the backend with an in-memory database on wasm. #[cfg(target_arch = "wasm32")] -pub fn init(cx: &mut App) -> Entity { +pub fn init(cx: &mut App) { let (client, signer) = new_backend().expect("failed to initialize nostr backend"); - let entity = cx.new(|cx| Backend::new(client, signer, cx)); - Backend::set_global(entity.clone(), cx); + Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx); RepoListStore::set_global(cx.new(RepoListStore::new), cx); GitStore::set_global(PathBuf::new(), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx); CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx); - entity } diff --git a/crates/signed_state/src/refresh.rs b/crates/signed_state/src/refresh.rs index e0aa024..4c11981 100644 --- a/crates/signed_state/src/refresh.rs +++ b/crates/signed_state/src/refresh.rs @@ -1,9 +1,4 @@ /// Refresh coalescing shared by the event stores. -/// -/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`] -/// re-query their inputs on a debounce timer with the same policy: -/// a request arriving while a run is in flight is folded into a follow-up run, -/// a request arriving while the debounce timer is pending is dropped by it. #[derive(Debug, Default)] pub struct RefreshGate { /// A run is in flight. diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md index ceefae6..044981f 100644 --- a/docs/inbox-plan.md +++ b/docs/inbox-plan.md @@ -6,6 +6,12 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for > page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `` when > an account is active, and that home screen is the inbox. +> **Status.** Phases 0 and 1 are implemented and green on `feat/inbox`: +> `cargo test -p signed_core` (68), `cargo test -p signed_state` (24), +> `cargo clippy -p signed_state --all-targets` clean, `cargo check --workspace` succeeds. +> Phases 2-5 are not started. This document reflects the implementation as it stands, including +> the Phase 1 refactors (§4.3). + ## 1. What the GitWorkshop home screen is `Index.tsx`: @@ -217,54 +223,66 @@ when the signed-in key changes. ```rust /// d tag identifying the inbox read/archive state event of `me`. fn inbox_state_d_tag(me: PublicKey) -> String { - format!("signed-inbox-state:{me}") + format!("signed-inbox-state:{}", me.to_hex()) } -/// Newest stored read state for `me`, with the id of the event it came from. -async fn load_state( - client: &Client, - me: PublicKey, -) -> Result> { - // No author filter: the signing key is random per session. +/// Newest stored read state for `me`. +async fn load_state(client: &Client, me: PublicKey) -> Result, Error> { + // No author filter: the signing key is random per save. let filter = Filter::new() .kind(Kind::ApplicationSpecificData) .identifier(inbox_state_d_tag(me)); + let events = client.database().query(filter).await?; - let Some(event) = events.into_iter().max_by_key(|e| e.created_at) else { + + let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else { return Ok(None); }; - Ok(serde_json::from_str(&event.content) - .ok() - .map(|state| (state, event.id))) + + match serde_json::from_str(&event.content) { + Ok(state) => Ok(Some(state)), + Err(error) => { + log::warn!("ignoring unreadable inbox state {}: {error}", event.id); + Ok(None) + } + } } -/// Sign with the random `keys` and store locally. -async fn save_state( - client: &Client, - keys: &Keys, - me: PublicKey, - state: &InboxReadState, -) -> Result { +/// Sign with a fresh random key and store locally. +async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> { let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) .tags([Tag::identifier(inbox_state_d_tag(me))]) - .finalize(keys)?; // synchronous: random keys, no user signer + .finalize(&Keys::generate())?; // synchronous: random key, no user signer // Local-only: no `send_event`, no broadcast. The event lives in LMDB. client.database().save_event(&event).await?; - Ok(event.id) + Ok(()) } ``` -Kind 30078 is addressable, so saves signed by the same `keys` replace the previous event. Because -`keys` is random per session, the first save of a session creates a new coordinate; the store then -deletes the event it loaded (its id is kept from `load_state`) so exactly one state event remains. +A fresh random key is generated on every save, so each save writes a new event rather than +replacing the previous one. LMDB only auto-replaces an addressable event when the incoming event +has the **same pubkey**, so old copies accumulate. Nothing prunes them; `load_state` reads the +newest by `created_at`, so the behavior is correct. This is a deliberate trade for not caching a +key in the store (see §4.3). An earlier implementation deleted the previous event by tracking its +id across saves; that was removed as more derived state than it was worth. `NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs. -### 4.3 `signed_state`: one `InboxStore` +### 4.3 `signed_state`: `Inbox`, a child entity of `Backend` -One store backs the whole screen, modelled on `RepoListStore` (`repos.rs`): +The inbox is not an app-wide global. It is a child `Entity` owned by `Backend` +(`inbox: Entity`), following the project's child-entity pattern +(`docs/backend-rearchitecture.md` §11): its observer set (the inbox screen, the sidebar badge) is a +strict subset of the backend's, so it is observed independently. ```rust -pub struct InboxStore { +// backend.rs +pub struct Backend { + ... + inbox: Entity, +} + +// inbox.rs +pub struct Inbox { /// Activity directed at the user, grouped by thread root, newest first. pub notifications: Arc>, /// The user's own recent git activity, newest first. @@ -272,69 +290,132 @@ pub struct InboxStore { /// Unread notification count (non-archived). pub unread_count: usize, state: InboxReadState, - user: Option, - /// Random keypair signing the local NIP-78 storage event. - keys: Keys, - /// Event id of the loaded state event, pruned on the next save. - loaded_state: Option, + /// Set once the stored state has been read for the current user. + state_loaded: bool, refresh: RefreshGate, - _subscription: Subscription, } ``` -**Lifespan: idle until a signer exists.** The store is created in `signed_state::init` like the -other stores, but it does nothing until the user has a signer. It is never wired from the `desktop` -crate, and `signed_state::init` gains no parameters. +`Backend::new` builds it with `cx.new(|_| Inbox::default())`, and callers reach it through +`Backend::global(cx).read(cx).inbox()` or `Backend::inbox()`. All inbox operations (`mark_read`, +`mark_archived`, `mark_all_read`, `refresh`) live on `Inbox`. -- `new` schedules `cx.defer`, like the other stores. The deferred bootstrap checks - `Backend::current_user()`: - - signer already present (session restored before the store was created): activate now; - - no signer: do nothing, wait for the event. -- `BackendEvent::SignerChanged`: activate. -- `BackendEvent::SignerRequired` (logout / no credential): clear `user`, `notifications`, - `activity`, `unread_count`. -- `BackendEvent::NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`, - is `Kind::Comment`, or is a deletion. -- `BackendEvent::Synced`: refresh. -- `BackendEvent::Published`: refresh. +**The store holds no derived state.** The current user is read from `Backend::current_user()` at +each use site, repo relays are queried from `RepoListStore` in `Backend::sync_inbox`, and the +signing key is random per save rather than cached. This follows the project rule against caching +derived state. -**Activation** (only on signer): +**Lifespan: idle until a signer exists.** `Inbox` is created with the backend but does nothing +until the user has a signer. It is never wired from the `desktop` crate, and `signed_state::init` +gains no parameters (the `InboxStore::set_global` idea was dropped). + +The dependency is strictly one-way: **`Backend` → `Inbox`**. `Inbox` holds no `Backend` handle, so +there is no reference cycle and no `cx.subscribe`. Two mechanisms connect them. + +**`Backend::emit`** is the single funnel for every `BackendEvent`. It updates the inbox on a +deferred effect and then emits to the other subscribers: ```rust -fn activate(&mut self, me: PublicKey, cx: &mut Context) { - self.user = Some(me); - // Load the NIP-78 state from LMDB, then subscribe and refresh. - // All run on background tasks; only plain data crosses back. +/// Update the inbox, then emit `event` to the other stores. +fn emit(&self, event: BackendEvent, cx: &mut Context) { + let inbox = self.inbox.downgrade(); + let inbox_event = event.clone(); + + cx.defer(move |cx| { + if let Err(error) = inbox.update(cx, |inbox, cx| { + inbox.handle_backend_event(&inbox_event, cx); + }) { + log::warn!("inbox dropped before handling backend event: {error}"); + } + }); + + cx.emit(event); +} +``` + +The `cx.defer` is load-bearing: every emit site runs inside `Backend::update`, and the inbox +handlers read `Backend`, so a synchronous call would re-enter the borrowed entity and panic. All +`cx.emit(...)` sites route through `self.emit(...)`. + +`Inbox::handle_backend_event` reacts to only three shapes: + +- `NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`, is + `Kind::Comment`, or is a deletion (`EventDeletion` / `RequestToVanish`). +- `Synced` / `Published`: refresh. +- everything else: ignored. + +`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore` +and `SidebarPanel` consume them. They no longer drive the inbox. + +**Signer lifecycle: `Backend::sync_inbox`.** The inbox does not match `SignerChanged` / +`SignerRequired`. `Backend` owns the wiring and calls `sync_inbox` from the three real signer +transitions: `create_identity`, `set_signer` (covers nsec, bunker and passphrase restore) and +`logout`. The fetch work that used to live in `Inbox::activate` moved here, because the filters and +repo relays need `Backend`'s state: + +```rust +fn sync_inbox(&mut self, cx: &mut Context) { + if let Some(me) = self.current_user { + self.subscribe_bootstrap(filters::notifications(me), cx); + self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); + + let relays: HashSet = RepoListStore::global(cx) + .read(cx) + .announcements_of(&me) + .into_iter() + .flat_map(|announcement| announcement.relays) + .collect(); + + if !relays.is_empty() { + let relays: Vec = relays.into_iter().collect(); + self.connect_repo_relays(relays.clone(), filters::notifications(me), cx); + self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx); + } + } + + let inbox = self.inbox.downgrade(); + cx.defer(move |cx| { + let updated = inbox.update(cx, |inbox, cx| { + if Backend::global(cx).read(cx).current_user().is_some() { + inbox.activate(cx); + } else { + inbox.reset(cx); + } + }); + if let Err(error) = updated { + log::warn!("inbox dropped before syncing with the signer: {error}"); + } + }); +} +``` + +The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call +time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind +10002 yet.) The deferred `update` is required because `activate` reads `Backend`, which every +caller is mid-update on. `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` no longer +has `subscribe_remote` / `connect_own_repo_relays`. + +**Activation** (`activate`) clears the user's data, drops any in-flight or pending run belonging to +the previous user (`self.refresh = RefreshGate::default()`), then loads the NIP-78 state from LMDB +and chains the first refresh once it is loaded: + +```rust +pub(crate) fn activate(&mut self, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { return }; + // clear notifications, activity, unread_count; state = default; state_loaded = false; + // self.refresh = RefreshGate::default(); self.load_state(me, cx); - self.subscribe_remote(cx); - self.refresh(cx); } ``` -Loading uses the random `keys` only for signing on save; reading the state event needs no signer at -all. Activation itself is still gated on the signer because the fetch filters need the user's pubkey. +`reset` performs the same clearing without a state load, and is used on logout. -**Fetch** (reuses `Backend::subscribe_bootstrap` / `connect_repo_relays`): +Reading the state event needs no signer at all (the `d` tag carries the identity); activation is +still gated on the signer because the fetch filters need the user's pubkey. -```rust -fn subscribe_remote(&mut self, cx: &mut Context) { - let Some(me) = self.user else { return }; - let backend = Backend::global(cx); - backend.update(cx, |backend, cx| { - backend.subscribe_bootstrap(filters::notifications(me), cx); - backend.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); - }); - // Relays of the user's own repos, so their activity there is found too. - let relays = own_repo_relays(me, cx); - backend.update(cx, |backend, cx| { - backend.connect_repo_relays(relays.clone(), filters::notifications(me), cx); - backend.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx); - }); -} -``` - -`own_repo_relays` reads `RepoListStore::global(cx).read(cx).announcements_of(&me)` and unions their -`relays`. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind 10002 yet.) +**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays`, as shown in +`sync_inbox` above. The query that follows is intentionally the offline-first cache read, not a +wait on the network; see the note below. **Refresh** (mirrors `RepoListStore::run_refresh`): @@ -345,12 +426,23 @@ fn subscribe_remote(&mut self, cx: &mut Context) { `inbox::group`. - Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when their `K` tag is a git kind; sort newest first; take the top N. -- Cross back to the main thread: set `notifications`, `activity`, `unread_count`, `cx.notify()`, - `refresh.finish()`. +- Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() == + Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a + previous user's results never land. Then set `notifications`, `activity`, `unread_count`, + `cx.notify()`, `refresh.finish()`. -**Actions**: `mark_read(root)`, `mark_all_read()`, plus `advance_read` after marking to bound the -`read_ids` set. After each change, sign with the random `keys` and save the NIP-78 event to LMDB, -then delete the previously loaded copy (see 4.2). +**Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately, +so the query that follows them reads the local cache rather than waiting for the relays. That is +deliberate offline-first behavior: cached content appears at once on a warm start and with no +network, instead of blocking the home screen on the network. The gap is closed by the SDK, not by +timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so +`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the inbox refreshes. This was +reviewed and left as-is. + +**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each group's events are +marked, then the cutoffs are advanced against *all* notification events to bound the id sets. After +each change the groups are re-derived (`InboxItem::apply_state`) and the state is saved to LMDB, +signed with a fresh random key (see 4.2). **My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes `announcements_of(user)`. @@ -371,7 +463,7 @@ One scrollable two-column flex row. **Mark all read**; then the non-archived notification items (top 5, with a **Show all** toggle expanding inline). Rows show the actor avatar, a kind badge, the subject, the repo name, a relative time, and an unread dot. Empty state: "You're all caught up." with `IconName::Inbox`. -- **Continue where you left off**: `InboxStore::activity`, top 15, each row a kind icon, subject, +- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject, repo name, and relative time. - **My repositories**: `RepoListStore::announcements_of(me)` with a small search `InputState` (same pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows @@ -398,7 +490,7 @@ pub fn add_bottom_panel( The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then: - New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the - `InboxStore`. It renders the matching subset of `InboxStore::notifications` as a list. + `Entity`. It renders the matching subset of `Inbox::notifications` as a list. - The **Unread** and **Archived** header buttons in `InboxView` call `add_bottom_panel` with the requested mode. `InboxView` keeps `filter_view: Option>`; when it already exists, update its mode and focus instead of adding a duplicate. @@ -417,7 +509,7 @@ In `views/sidebar/mod.rs`: .on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))), ``` -- `cx.observe` the `InboxStore` so the badge updates. +- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge updates. ### 5.4 Click-through (P1) @@ -455,20 +547,22 @@ patch-root click opens the repo panel. Note as a known limitation. | File | Change | |---|---| | `crates/signed_core/Cargo.toml` | add `serde` | -| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity` | +| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity`, `is_git_activity`, `deletions` | | `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests | | `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports | | `crates/signed_state/Cargo.toml` | add `serde_json` | -| `crates/signed_state/src/inbox.rs` | **new**: `InboxStore`, global, signer-gated activation, NIP-78 load/save, actions | -| `crates/signed_state/src/lib.rs` | `mod inbox;`, set global in `init` | +| `crates/signed_state/src/inbox.rs` | **new**: `Inbox` child entity, NIP-78 load/save, refresh, actions; no `Backend` handle, no `cx.subscribe` | +| `crates/signed_state/src/backend.rs` | `inbox: Entity` field, construction, `inbox()` accessor, private `emit` funnel, `sync_inbox`, `RepoListStore` import | +| `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users | +| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` (no global install) | | `crates/dock/src/lib.rs` | `add_bottom_panel` helper | | `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel and `InboxFilterView` | | `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` | | `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring and badge | | `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) | -No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; the store -bootstraps itself via `cx.defer` once a signer is present. +No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox` +activates the `Inbox` child entity at each signer transition. ## 7. Phasing @@ -477,9 +571,11 @@ bootstraps itself via `cx.defer` once a signer is present. and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch: the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable, and `authored_activity` results must pass through `is_git_activity` before display (comments on - non-git roots are matched by the filter). `cargo test -p signed_core` passes (62 tests). -2. **Phase 1 - store**: `InboxStore` with signer-gated activation, both queries, unread count, and - NIP-78 load/save to LMDB, global install. + non-git roots are matched by the filter). `cargo test -p signed_core` passes (66 tests at the + end of Phase 0; 68 after the two Phase 1 additions). +2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer + exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the + implementation notes below. 3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge. 4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. 5. **Phase 4 - click-through**: `open_item` and announcement lookup. @@ -488,12 +584,59 @@ bootstraps itself via `cx.defer` once a signer is present. Each phase compiles and is usable on its own. +### Phase 1 implementation notes + +Files: `crates/signed_state/{Cargo.toml, src/inbox.rs, src/lib.rs, src/backend.rs, src/refresh.rs}` +and two additions to `crates/signed_core/src/inbox.rs`. + +- `Inbox` is a child entity of `Backend` (`inbox: Entity`), created in `Backend::new` and + reached via `Backend::inbox()`. Nothing in `desktop` is wired and `signed_state::init` gains no + parameters. The dependency is strictly one-way: `Inbox` holds no `Backend` handle. +- `Backend::emit` is the single funnel for every `BackendEvent`. It updates the inbox through + `cx.defer` and then emits to the other subscribers. The defer is required: every emit site runs + inside `Backend::update`, and the inbox handlers read `Backend`, so a synchronous call panics on + a re-entrant entity access. +- The signer lifecycle lives in `Backend::sync_inbox`, called from `create_identity`, `set_signer` + and `logout`. It starts the subscriptions and repo-relay connects, then defers `inbox.activate` + / `inbox.reset`. `SignerChanged` / `SignerRequired` are still emitted for `CheckoutsStore` and + `SidebarPanel`, but no longer drive the inbox. +- `Inbox` mirrors `RepoListStore`: `RefreshGate` coalescing, `cx.background_spawn` for the + database work, plain data applied on the main thread, refresh-on-`NostrUpdate`/`Synced`/`Published`. +- Added `state_loaded: bool`, not in the sketch. Groups are derived from the read state, so a refresh + before the stored state is read would briefly mark everything unread. The first refresh is chained + after `load_state`, and later `refresh` calls are ignored until `state_loaded` is set. +- Account switches are guarded. `activate` and `reset` both replace `self.refresh` with a fresh + `RefreshGate`, dropping any in-flight or pending run of the previous user, and the apply step of + `run_refresh` aborts instead of applying when `Backend::current_user()` no longer matches the + user the query was started for. +- Two additions to `signed_core::inbox` that Phase 1 needs: `InboxReadState::mark_archived` (mirrors + `mark_read`) and `InboxItem::apply_state` (recomputes `unread_ids`/`archived`; `group` now uses it). + Both are covered by tests. +- The thread-root lookup is built by walking every `e`/`E` ancestor transitively (`fetch_notifications`) + rather than a single hop, because a patch series chains through parent patches. Only the notification + events are grouped; ancestors are used solely as the lookup, so a root authored by someone else is + not mistaken for a notification. +- The read/archive state event is written to LMDB only (`database().save_event`), signed with a fresh + `Keys::generate()` on each save and never published. Filtering is by `d` tag only, no author, so the + random key is irrelevant across sessions. `d` tag uses `me.to_hex()` rather than `Display`. +- Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances + the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`), + re-derives the groups locally so the UI updates immediately, then persists in the background. +- The store keeps no derived state. The signing key is generated per save, the current user is read + from `Backend::current_user()` where needed, and the relays of the user's own repositories are + queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic + either: the newest state event is selected by `created_at`. +- `Inbox::activate` / `Inbox::reset` are `pub(crate)`; the former `subscribe_remote` and + `connect_own_repo_relays` methods were deleted once their work moved into `Backend::sync_inbox`. +- `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests); + `cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds. + ## 8. Validation -- `cargo test -p signed_core`: root resolution, grouping, read-state cutoff, serde round-trip. -- `cargo test -p signed_state`: NIP-78 content round-trip (`serde_json`), if a non-GPUI path is - factored out. -- `cargo check --workspace` after each phase. +- `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip. +- `cargo test -p signed_state` (24 tests): the `Inbox` store paths that do not need GPUI (state + round-trip, grouping helpers). +- `cargo clippy -p signed_state --all-targets` and `cargo check --workspace` after each phase. - Manual: log in with a repo-owning identity; confirm the inbox panel populates from another identity's issue/comment, the activity list shows your own items, the repositories panel matches the sidebar, and that no kind-30078 event is broadcast (watch the relays / `Published` events). @@ -513,3 +656,7 @@ Each phase compiles and is usable on its own. `NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory` - `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate` - `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate` +- Fetch paths converge on the same notification: `client.subscribe(...)` and negentropy + `client.sync(...)` both persist received events to LMDB and surface them as + `ClientNotification::Event`, which `Backend`'s pump batches into `BackendEvent::NostrUpdate`. + This is why the query right after a fetch is a cache read, not a race. -- 2.54.0 From e63e58c1252a9de8974ec52c7c2bd398cbc1f451 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 11 Sep 2026 09:35:38 +0700 Subject: [PATCH 03/10] add inbox view --- crates/signed_state/src/backend.rs | 134 ++--- crates/signed_state/src/inbox.rs | 357 ++++++++----- crates/signed_state/src/lib.rs | 2 +- crates/workspace/src/views/inbox.rs | 610 ++++++++++++++++++++++ crates/workspace/src/views/mod.rs | 2 + crates/workspace/src/views/sidebar/mod.rs | 40 +- docs/inbox-plan.md | 320 +++++++----- 7 files changed, 1119 insertions(+), 346 deletions(-) create mode 100644 crates/workspace/src/views/inbox.rs diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 1d8cbc8..f1722d8 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -34,10 +34,6 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [ pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; /// Delay the notification pump waits for more events before emitting a batch. -/// -/// A negentropy sync can deliver hundreds of events in a burst; batching -/// them here means every subscriber debounces the burst once, not once per -/// subscriber. const PUMP_DEBOUNCE: Duration = Duration::from_millis(200); #[derive(Debug, Clone)] @@ -79,9 +75,6 @@ impl BackendEvent { } } -/// The global backend entity. -/// -/// Owns the nostr client, the signer, the notification pump and the inbox. pub struct Backend { client: Client, signer: UniversalSigner, @@ -162,9 +155,9 @@ impl Backend { // Collect and emit the collected events. let batch = std::mem::take(&mut pending); - if let Err(e) = this.update(cx, |this, cx| { - this.emit(BackendEvent::NostrUpdate(batch), cx) - }) { + if let Err(e) = + this.update(cx, |_this, cx| cx.emit(BackendEvent::NostrUpdate(batch))) + { log::warn!("failed to emit nostr update: {e}"); } } @@ -221,9 +214,7 @@ impl Backend { })?; } Err(e) => { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } } Ok::<(), Error>(()) @@ -231,14 +222,13 @@ impl Backend { notify_task.detach(); } - /// Restore the saved session from the keyring. + /// Restore the saved session from the Keyring. /// - /// Emits [`BackendEvent::SignerRequired`] when no credential is stored. - /// - /// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity. + /// - Emits [`BackendEvent::SignerRequired`] when no credential is stored. + /// - Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity. pub fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { - self.emit(BackendEvent::SignerRequired, cx); + cx.emit(BackendEvent::SignerRequired); return; } @@ -248,7 +238,7 @@ impl Backend { let content = match user.await { Ok(Some((_username, secret))) => String::from_utf8(secret)?, _ => { - this.update(cx, |this, cx| this.emit(BackendEvent::SignerRequired, cx))?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?; return Ok(()); } }; @@ -272,10 +262,10 @@ impl Backend { // A passphrase is required to decrypt it before the session can resume. this.update(cx, |this, cx| { this.passphrase_required = true; - this.emit(BackendEvent::PassphraseRequired, cx); + cx.emit(BackendEvent::PassphraseRequired); })?; } else { - this.update(cx, |this, cx| this.emit(BackendEvent::SignerRequired, cx))?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?; } Ok::<_, Error>(()) @@ -283,9 +273,9 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx); - this.emit(BackendEvent::SignerRequired, cx); + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())); + cx.emit(BackendEvent::SignerRequired); })?; } @@ -369,8 +359,10 @@ impl Backend { this.signer.swap_inner(keys); this.current_user = Some(public_key); this.bootstrap_user(public_key, cx); - this.emit(BackendEvent::SignerChanged, cx); + + cx.emit(BackendEvent::SignerChanged); this.sync_inbox(cx); + cx.notify(); let relays: Vec<(RelayUrl, Option)> = [ @@ -978,7 +970,7 @@ impl Backend { } else if credential.starts_with("bunker://") { self.login_with_bunker(credential, cx); } else { - self.emit(BackendEvent::error("Unsupported credential."), cx); + cx.emit(BackendEvent::error("Unsupported credential.")); } } @@ -996,7 +988,7 @@ impl Backend { let keys = match SecretKey::parse(nsec) { Ok(secret) => Keys::new(secret), Err(e) => { - self.emit(BackendEvent::error(e.to_string()), cx); + cx.emit(BackendEvent::error(e.to_string())); return; } }; @@ -1007,9 +999,7 @@ impl Backend { let task: Task> = cx.spawn(async move |this, cx| { if let Err(e) = write.await { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; return Ok(()); } this.update(cx, |this, cx| this.set_signer(keys, cx))?; @@ -1025,7 +1015,7 @@ impl Backend { let connect_uri = match NostrConnectUri::parse(&uri_string) { Ok(uri) => uri, Err(e) => { - self.emit(BackendEvent::error(e.to_string()), cx); + cx.emit(BackendEvent::error(e.to_string())); return; } }; @@ -1055,9 +1045,7 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) @@ -1076,8 +1064,8 @@ impl Backend { this.signer.swap_inner(Keys::generate()); this.current_user = None; this.passphrase_required = false; - this.emit(BackendEvent::SignerChanged, cx); - this.emit(BackendEvent::SignerRequired, cx); + cx.emit(BackendEvent::SignerChanged); + cx.emit(BackendEvent::SignerRequired); this.sync_inbox(cx); cx.notify(); })?; @@ -1109,9 +1097,7 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) @@ -1155,31 +1141,15 @@ impl Backend { /// Surface an error message through [`BackendEvent::Error`]. pub fn emit_error(&mut self, message: impl Into, cx: &mut Context) { - self.emit(BackendEvent::error(message), cx); - } - - /// Update the inbox, then emit `event` to the other stores. - fn emit(&self, event: BackendEvent, cx: &mut Context) { - let inbox = self.inbox.downgrade(); - let inbox_event = event.clone(); - - cx.defer(move |cx| { - if let Err(error) = inbox.update(cx, |inbox, cx| { - inbox.handle_backend_event(&inbox_event, cx); - }) { - log::warn!("inbox dropped before handling backend event: {error}"); - } - }); - - cx.emit(event); + cx.emit(BackendEvent::error(message)); } /// Attach the inbox to the current signer and activate or clear it. - /// - /// The inbox's own update is deferred because activating reads `Backend`, - /// which every call site is in the middle of updating. fn sync_inbox(&mut self, cx: &mut Context) { - if let Some(me) = self.current_user { + let client = self.client.clone(); + let me = self.current_user; + + if let Some(me) = me { self.subscribe_bootstrap(filters::notifications(me), cx); self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); @@ -1197,20 +1167,9 @@ impl Backend { } } - let inbox = self.inbox.downgrade(); - - cx.defer(move |cx| { - let updated = inbox.update(cx, |inbox, cx| { - if Backend::global(cx).read(cx).current_user().is_some() { - inbox.activate(cx); - } else { - inbox.reset(cx); - } - }); - - if let Err(error) = updated { - log::warn!("inbox dropped before syncing with the signer: {error}"); - } + self.inbox.update(cx, |inbox, cx| match me { + Some(me) => inbox.activate(me, client, cx), + None => inbox.reset(cx), }); } @@ -1235,14 +1194,14 @@ impl Backend { this.current_user = Some(public_key); this.passphrase_required = false; this.bootstrap_user(public_key, cx); - this.emit(BackendEvent::SignerChanged, cx); + cx.emit(BackendEvent::SignerChanged); this.sync_inbox(cx); cx.notify(); })?; } Err(e) => { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx); + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())); })?; } } @@ -1279,8 +1238,8 @@ impl Backend { cx.spawn(async move |this, cx| { if let Err(e) = fetch.await { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx); + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())); })?; } Ok::<(), Error>(()) @@ -1308,13 +1267,10 @@ impl Backend { let alive = this.update(cx, |this, cx| { this.sync_progress = Some((progress.total, progress.current)); - this.emit( - BackendEvent::SyncProgress { - total: progress.total, - current: progress.current, - }, - cx, - ); + cx.emit(BackendEvent::SyncProgress { + total: progress.total, + current: progress.current, + }); cx.notify(); }); @@ -1343,14 +1299,14 @@ impl Backend { ); this.update(cx, |this, cx| { this.sync_progress = None; - this.emit(BackendEvent::Synced, cx); + cx.emit(BackendEvent::Synced); cx.notify(); })?; } Err(e) => { this.update(cx, |this, cx| { this.sync_progress = None; - this.emit(BackendEvent::error(e.to_string()), cx) + cx.emit(BackendEvent::error(e.to_string())) })?; } } @@ -1364,7 +1320,7 @@ impl Backend { /// Callers publish with `client.send_event(...)` directly, then call this /// so stores like `RepoListStore` refresh without re-querying the relays. pub fn announce_published(&self, event: Event, cx: &mut Context) { - self.emit(BackendEvent::Published(Box::new(event)), cx); + cx.emit(BackendEvent::Published(Box::new(event))); } /// Publish a NIP-09 deletion for each of `events`, best-effort. diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index 67539de..9b58dda 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -16,131 +16,87 @@ const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// Maximum number of "continue where you left off" activity events kept. const ACTIVITY_LIMIT: usize = 50; -/// State backing the inbox home screen. +/// The user's persisted inbox read state. #[derive(Default)] pub struct Inbox { - /// Notifications grouped by thread root, newest activity first. - pub notifications: Arc>, - /// The user's own recent git activity, newest first. - pub activity: Arc>, - /// Number of non-archived groups with an unread event. - pub unread_count: usize, state: InboxReadState, /// Set once the stored state has been read for the current user. state_loaded: bool, - refresh: RefreshGate, + /// Unread notification groups, published by [`InboxStore`] for the sidebar badge. + pub unread_count: usize, } impl Inbox { - /// Mark every event in the group rooted at `root` as read. - pub fn mark_read(&mut self, root: EventId, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; - - let Some(events) = self.group_events(root) else { - return; - }; - - for event in &events { - self.state.mark_read(event); - } - - let all = self.all_notification_events(); - self.state.advance_read(&all, me, Timestamp::now()); - self.after_state_change(cx); + /// The current read/archive cutoffs. + pub fn state(&self) -> &InboxReadState { + &self.state } - /// Archive the group rooted at `root`. Archived events are always read too. - pub fn mark_archived(&mut self, root: EventId, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; + /// Whether the stored state has been read for the current user. + pub fn is_loaded(&self) -> bool { + self.state_loaded + } - let Some(events) = self.group_events(root) else { + /// Publish the unread count derived by [`InboxStore`]. + pub fn set_unread_count(&mut self, count: usize, cx: &mut Context) { + if self.unread_count == count { return; - }; + } + self.unread_count = count; + cx.notify(); + } - for event in &events { + /// Mark the events of one notification group read, then bound the id sets. + pub fn mark_read( + &mut self, + group: &[Event], + all: &[Event], + me: PublicKey, + cx: &mut Context, + ) { + for event in group { + self.state.mark_read(event); + } + self.state.advance_read(all, me, Timestamp::now()); + self.persist(cx); + cx.notify(); + } + + /// Archive one notification group. Archived events are always read too. + pub fn mark_archived( + &mut self, + group: &[Event], + all: &[Event], + me: PublicKey, + cx: &mut Context, + ) { + for event in group { self.state.mark_archived(event); self.state.mark_read(event); } - let all = self.all_notification_events(); let now = Timestamp::now(); - - self.state.advance_archived(&all, me, now); - self.state.advance_read(&all, me, now); - self.after_state_change(cx); + self.state.advance_archived(all, me, now); + self.state.advance_read(all, me, now); + self.persist(cx); + cx.notify(); } /// Mark every known notification read. - pub fn mark_all_read(&mut self, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; - - let all = self.all_notification_events(); - self.state.mark_all_read(&all, me, Timestamp::now()); - self.after_state_change(cx); - } - - /// Handle a backend event that can change the inbox contents. - pub(crate) fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { - match event { - BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx), - BackendEvent::NostrUpdate(updates) => { - let relevant = updates.iter().any(|update| { - let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind); - let is_comment = update.kind == Kind::Comment; - let is_event_deletion = update.kind == Kind::EventDeletion; - let is_request_to_vanish = update.kind == Kind::RequestToVanish; - - is_notification || is_comment || is_event_deletion || is_request_to_vanish - }); - if relevant { - self.refresh(cx); - } - } - _ => {} - } - } - - /// Activate the inbox for the backend's current user. - pub(crate) fn activate(&mut self, cx: &mut Context) { - let backend = Backend::global(cx); - let Some(me) = backend.read(cx).current_user() else { - return; - }; - - self.notifications = Arc::new(Vec::new()); - self.activity = Arc::new(Vec::new()); - self.unread_count = 0; - self.state = InboxReadState::default(); - self.state_loaded = false; - // Drop any in-flight or pending run belonging to the previous user. - self.refresh = RefreshGate::default(); - cx.notify(); - - self.load_state(me, cx); - } - - /// Forget everything for the current user. - pub(crate) fn reset(&mut self, cx: &mut Context) { - self.notifications = Arc::new(Vec::new()); - self.activity = Arc::new(Vec::new()); - self.unread_count = 0; - self.state = InboxReadState::default(); - self.state_loaded = false; - self.refresh = RefreshGate::default(); + pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context) { + self.state.mark_all_read(all, me, Timestamp::now()); + self.persist(cx); cx.notify(); } - /// Read the stored state, then run the first refresh. - fn load_state(&mut self, me: PublicKey, cx: &mut Context) { - let backend = Backend::global(cx); - let client = backend.read(cx).client(); + /// Load the stored state for current user. + pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context) { + self.state = InboxReadState::default(); + self.state_loaded = false; + self.unread_count = 0; + cx.notify(); + let backend = Backend::global(cx); let work = cx.background_spawn(async move { load_state(&client, me).await }); cx.spawn(async move |this, cx| { @@ -158,7 +114,7 @@ impl Inbox { } this.state_loaded = true; - this.refresh_initial(cx); + cx.notify(); })?; Ok::<(), Error>(()) @@ -166,6 +122,161 @@ impl Inbox { .detach(); } + /// Clear the state of the signed-out user. + pub(crate) fn reset(&mut self, cx: &mut Context) { + self.state = InboxReadState::default(); + self.state_loaded = false; + self.unread_count = 0; + cx.notify(); + } + + /// Sign the state with a random key and store it locally. + fn persist(&mut self, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let client = Backend::global(cx).read(cx).client(); + let state = self.state.clone(); + + let task: Task> = cx.background_spawn(async move { + if let Err(error) = save_state(&client, me, &state).await { + log::warn!("failed to save inbox state: {error}"); + } + Ok(()) + }); + + task.detach(); + } +} + +/// Derives the inbox home screen's notification and activity lists. +/// +/// Created by the inbox panel, so the database work only happens while the +/// panel is open. The persisted read state stays in the global [`Inbox`]. +#[derive(Default)] +pub struct InboxStore { + /// Notifications grouped by thread root, newest activity first. + pub notifications: Arc>, + /// The user's own recent git activity, newest first. + pub activity: Arc>, + /// Number of non-archived groups with an unread event. + pub unread_count: usize, + /// Copy of the global read state the current lists were derived with. + state: InboxReadState, + /// Set once the global state has been read for the current user. + state_loaded: bool, + refresh: RefreshGate, +} + +impl InboxStore { + /// Create the store and derive the lists from the current global state. + pub fn new(cx: &mut Context) -> Self { + let weak = cx.entity().downgrade(); + cx.defer(move |cx| { + if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) { + log::warn!("inbox store dropped before bootstrap could run: {error}"); + } + }); + + Self::default() + } + + /// Mark every event in the group rooted at `root` read. + pub fn mark_read(&mut self, root: EventId, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let Some(group) = self.group_events(root) else { + return; + }; + + let all = self.all_notification_events(); + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_read(&group, &all, me, cx)); + } + + /// Archive the group rooted at `root`. + pub fn mark_archived(&mut self, root: EventId, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let Some(group) = self.group_events(root) else { + return; + }; + + let all = self.all_notification_events(); + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_archived(&group, &all, me, cx)); + } + + /// Mark every known notification read. + pub fn mark_all_read(&mut self, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let all = self.all_notification_events(); + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx)); + } + + /// Re-derive from the global state when it is loaded or changes. + pub fn sync_state(&mut self, cx: &mut Context) { + let inbox = Backend::global(cx).read(cx).inbox(); + let (loaded, state) = { + let inbox = inbox.read(cx); + (inbox.is_loaded(), inbox.state().clone()) + }; + + if !loaded { + let was_present = + self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty(); + self.clear(); + if was_present { + cx.notify(); + } + return; + } + + if !self.state_loaded { + self.state_loaded = true; + self.state = state; + self.refresh_initial(cx); + return; + } + + if self.state != state { + self.state = state; + self.regroup(); + self.publish_unread_count(cx); + cx.notify(); + } + } + + /// Handle a backend event that can change the derived lists. + pub fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { + match event { + BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx), + BackendEvent::NostrUpdate(updates) => { + let relevant = updates.iter().any(|update| { + let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind); + let is_comment = update.kind == Kind::Comment; + let is_event_deletion = update.kind == Kind::EventDeletion; + let is_request_to_vanish = update.kind == Kind::RequestToVanish; + + is_notification || is_comment || is_event_deletion || is_request_to_vanish + }); + if relevant { + self.refresh(cx); + } + } + _ => {} + } + } + /// One-shot initial load, no debounce. fn refresh_initial(&mut self, cx: &mut Context) { debug_assert!(!self.refresh.debouncing()); @@ -198,12 +309,13 @@ impl Inbox { fn run_refresh(&mut self, cx: &mut Context) { self.refresh.begin(); - let Some(me) = Backend::global(cx).read(cx).current_user() else { + let backend = Backend::global(cx); + let Some(me) = backend.read(cx).current_user() else { self.refresh.abort(); return; }; - let client = Backend::global(cx).read(cx).client(); + let client = backend.read(cx).client(); let state = self.state.clone(); let work = cx.background_spawn(async move { @@ -259,6 +371,7 @@ impl Inbox { this.notifications = Arc::new(notifications); this.activity = Arc::new(activity); this.unread_count = unread_count; + this.publish_unread_count(cx); cx.notify(); this.refresh.finish() @@ -274,13 +387,6 @@ impl Inbox { task.detach(); } - /// Advance the cutoffs, re-derive the groups and persist the state. - fn after_state_change(&mut self, cx: &mut Context) { - self.regroup(); - self.persist(cx); - cx.notify(); - } - /// Recompute the unread and archived flags from the current state. fn regroup(&mut self) { let mut items = (*self.notifications).clone(); @@ -293,23 +399,22 @@ impl Inbox { self.notifications = Arc::new(items); } - /// Sign the state with a random key and store it locally. - fn persist(&mut self, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; + /// Publish the derived unread count for the sidebar badge. + fn publish_unread_count(&self, cx: &mut Context) { + let count = self.unread_count; + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.set_unread_count(count, cx)); + } - let client = Backend::global(cx).read(cx).client(); - let state = self.state.clone(); - - let task: Task> = cx.spawn(async move |_this, _cx| { - if let Err(error) = save_state(&client, me, &state).await { - log::warn!("failed to save inbox state: {error}"); - } - Ok(()) - }); - - task.detach(); + /// Forget everything derived for the current user. + fn clear(&mut self) { + self.notifications = Arc::new(Vec::new()); + self.activity = Arc::new(Vec::new()); + self.unread_count = 0; + self.state = InboxReadState::default(); + self.state_loaded = false; + // Drop any in-flight or pending run belonging to the previous user. + self.refresh = RefreshGate::default(); } /// Events of the group rooted at `root`. diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 061c70a..f325ad5 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -13,7 +13,7 @@ pub use backend::{Backend, BackendEvent, user_grasp_list_servers}; pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; pub use git_store::GitStore; use gpui::{App, AppContext}; -pub use inbox::Inbox; +pub use inbox::{Inbox, InboxStore}; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; pub use repo::RepoStore; diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs new file mode 100644 index 0000000..d90b4eb --- /dev/null +++ b/crates/workspace/src/views/inbox.rs @@ -0,0 +1,610 @@ +use assets::CustomIconName; +use dock::{BasePanel, DockArea, Panel, PanelEvent}; +use gpui::prelude::*; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, + Subscription, WeakEntity, Window, div, px, +}; +use gpui_component::input::{Input, InputState}; +use gpui_component::scroll::ScrollableElement; +use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; +use nostr::prelude::{Event, Kind}; +use signed_core::{Announcement, COVER_NOTE_KIND, InboxItem, RepoAddr, activity_subject}; +use signed_state::{Backend, BackendEvent, InboxStore, ProfileStore, RepoListStore}; +use signed_ui::{CountBadge, SegmentButton, UserAvatar}; +use utils::relative_time; + +use super::open_repo_panel; +use super::sidebar::create_repo_dialog; + +/// Notification groups shown before the `Show all` toggle is used. +const NOTIFICATION_PREVIEW: usize = 5; + +/// Activity rows shown in `Continue where you left off`. +const ACTIVITY_SHOWN: usize = 15; + +pub struct InboxView { + focus_handle: FocusHandle, + dock_area: WeakEntity, + /// Derives the notification and activity lists while the panel is open. + store: Entity, + /// Search box filtering the `My repositories` column. + search: Entity, + /// Whether the inbox list is expanded past [`NOTIFICATION_PREVIEW`]. + show_all: bool, + _subscriptions: Vec, +} + +impl InboxView { + pub fn new( + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let backend = Backend::global(cx); + let inbox = backend.read(cx).inbox(); + + let store = cx.new(InboxStore::new); + let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search...")); + + // Drive the store from the global inbox state and from backend events. + let _subscriptions = vec![ + cx.observe(&inbox, |this, _inbox, cx| { + this.store.update(cx, |store, cx| store.sync_state(cx)); + }), + cx.subscribe(&backend, |this, _backend, event, cx| { + if matches!( + event, + BackendEvent::SignerChanged | BackendEvent::SignerRequired + ) { + this.show_all = false; + cx.notify(); + } + + this.store + .update(cx, |store, cx| store.handle_backend_event(event, cx)); + }), + ]; + + Self { + focus_handle: cx.focus_handle(), + dock_area, + store, + search, + show_all: false, + _subscriptions, + } + } + + /// Mark every known notification read. + fn mark_all_read(&mut self, cx: &mut Context) { + self.store.update(cx, |store, cx| store.mark_all_read(cx)); + } + + /// Open a repository's detail panel. + fn open_repo( + &mut self, + announcement: &Announcement, + window: &mut Window, + cx: &mut Context, + ) { + open_repo_panel(&self.dock_area, announcement, window, &mut *cx); + } + + /// Show the existing Create Repository dialog. + fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context) { + create_repo_dialog::open(self.dock_area.clone(), window, cx); + } + + /// Display name of the repository at `addr`, from the announcement store. + fn repo_name(&self, addr: Option<&RepoAddr>, cx: &App) -> Option { + let addr = addr?; + RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|announcement| announcement.addr() == *addr) + .map(display_name) + } + + /// Bordered card with a header bar and a body. + fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement { + v_flex() + .w_full() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .overflow_hidden() + .child( + div() + .px_3() + .py_2() + .bg(cx.theme().muted.opacity(0.5)) + .border_b_1() + .border_color(cx.theme().border) + .child(header), + ) + .child(body) + .into_any_element() + } + + fn render_inbox_panel( + &self, + unread: usize, + visible: &[&InboxItem], + cx: &mut Context, + ) -> AnyElement { + let shown = if self.show_all { + visible.len() + } else { + visible.len().min(NOTIFICATION_PREVIEW) + }; + + let mut body = v_flex().w_full(); + + if visible.is_empty() { + body = body.child(empty_state(IconName::Inbox, "You're all caught up.", cx)); + } else { + for (ix, item) in visible.iter().take(shown).enumerate() { + body = body.child(self.render_notification_row(ix, item, cx)); + } + + if visible.len() > NOTIFICATION_PREVIEW { + let label = if self.show_all { + "Show less" + } else { + "Show all" + }; + body = body.child(div().px_3().py_2().child( + SegmentButton::new("show-all", label).on_click(cx.listener( + |this, _event, _window, cx| { + this.show_all = !this.show_all; + cx.notify(); + }, + )), + )); + } + } + + let header = h_flex() + .w_full() + .gap_2() + .child(Icon::new(IconName::Inbox).small()) + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::from("Inbox")), + ) + .when(unread > 0, |this| this.child(CountBadge::new(unread))) + .child(div().flex_1()) + .child( + SegmentButton::new("mark-all-read", "Mark all read") + .on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))), + ); + + self.section(header, body, cx) + } + + fn render_notification_row( + &self, + ix: usize, + item: &InboxItem, + cx: &mut Context, + ) -> AnyElement { + let Some(newest) = item.events.first() else { + return div().into_any_element(); + }; + + let profiles = ProfileStore::global(cx); + let profile = profiles.read(cx).get(&newest.pubkey); + let kind = item.root_kind.unwrap_or(newest.kind); + let subject = SharedString::from(activity_subject(newest)); + let repo = self.repo_name(item.address.as_ref(), cx); + let age = relative_time(item.latest_activity()); + let unread = item.is_unread(); + + h_flex() + .id(ix) + .w_full() + .gap_3() + .px_3() + .py_2() + .items_center() + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().list_hover)) + .child(UserAvatar::new(profile.name()).picture(profile.picture())) + .child(div().flex_shrink_0().child(kind_icon(kind))) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + div() + .text_sm() + .when(unread, |this| this.font_semibold()) + .whitespace_nowrap() + .text_ellipsis() + .child(subject), + ) + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(kind_label(kind))) + .when_some(repo, |this, repo| { + this.child(SharedString::from("on")).child(repo) + }) + .child(SharedString::from("·")) + .child(SharedString::from(age)), + ), + ) + .when(unread, |this| { + this.child( + div() + .flex_shrink_0() + .size(px(8.)) + .rounded(px(4.)) + .bg(cx.theme().primary), + ) + }) + .into_any_element() + } + + fn render_activity_panel(&self, activity: &[Event], cx: &mut Context) -> AnyElement { + let mut body = v_flex().w_full(); + + if activity.is_empty() { + body = body.child(empty_state( + CustomIconName::Recent, + "No recent activity.", + cx, + )); + } else { + for (ix, event) in activity.iter().take(ACTIVITY_SHOWN).enumerate() { + body = body.child(self.render_activity_row(ix, event, cx)); + } + } + + let header = h_flex() + .w_full() + .gap_2() + .child(Icon::new(CustomIconName::Recent).small()) + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::from("Continue where you left off")), + ); + + self.section(header, body, cx) + } + + fn render_activity_row(&self, ix: usize, event: &Event, cx: &mut Context) -> AnyElement { + let kind = event.kind; + let subject = SharedString::from(activity_subject(event)); + let repo = self.repo_name(event.tags.coordinates().next().as_ref(), cx); + let age = relative_time(event.created_at); + + h_flex() + .id(ix) + .w_full() + .gap_3() + .px_3() + .py_2() + .items_center() + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().list_hover)) + .child(div().flex_shrink_0().child(kind_icon(kind))) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + div() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() + .child(subject), + ) + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(kind_label(kind))) + .when_some(repo, |this, repo| { + this.child(SharedString::from("on")).child(repo) + }) + .child(SharedString::from("·")) + .child(SharedString::from(age)), + ), + ) + .into_any_element() + } + + fn render_repos_panel(&self, cx: &mut Context) -> AnyElement { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + let header = h_flex() + .w_full() + .gap_2() + .child(Icon::new(CustomIconName::GitBranch).small()) + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::from("My repositories")), + ); + let body = v_flex().w_full().child(empty_state( + CustomIconName::GitBranch, + "Sign in to see your repositories.", + cx, + )); + return self.section(header, body, cx); + }; + + let query = self.search.read(cx).value().trim().to_lowercase(); + let repos: Vec = RepoListStore::global(cx) + .read(cx) + .announcements_of(&me) + .into_iter() + .filter(|announcement| { + query.is_empty() + || display_name(announcement).to_lowercase().contains(&query) + || announcement.id.to_lowercase().contains(&query) + }) + .collect(); + + let mut body = v_flex().w_full().child( + div().px_3().py_2().child( + Input::new(&self.search) + .cleanable(true) + .w_full() + .text_sm() + .border_color(cx.theme().muted) + .bg(cx.theme().muted) + .prefix(Icon::new(IconName::Search).small()), + ), + ); + + if repos.is_empty() { + body = body.child(empty_state( + CustomIconName::GitBranch, + "No repositories yet.", + cx, + )); + } else { + for (ix, announcement) in repos.iter().enumerate() { + body = body.child(self.render_repo_row(ix, announcement, cx)); + } + } + + let header = h_flex() + .w_full() + .gap_2() + .child(Icon::new(CustomIconName::GitBranch).small()) + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::from("My repositories")), + ) + .when(!repos.is_empty(), |this| { + this.child(CountBadge::new(repos.len())) + }) + .child(div().flex_1()) + .child( + SegmentButton::new("new-repo", "New") + .icon(Icon::new(CustomIconName::CirclePlus)) + .primary() + .on_click( + cx.listener(|this, _event, window, cx| this.open_create_repo(window, cx)), + ), + ); + + self.section(header, body, cx) + } + + fn render_repo_row( + &self, + ix: usize, + announcement: &Announcement, + cx: &mut Context, + ) -> AnyElement { + let name = display_name(announcement); + let description = announcement.description.clone().unwrap_or_default(); + let activity = RepoListStore::global(cx) + .read(cx) + .last_activity + .get(&announcement.addr()) + .copied(); + + h_flex() + .id(ix) + .w_full() + .gap_3() + .px_3() + .py_2() + .items_center() + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().list_hover)) + .child( + Icon::new(CustomIconName::GitBranch) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() + .child(name), + ) + .when(!description.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .text_ellipsis() + .child(SharedString::from(description)), + ) + }), + ) + .when_some(activity, |this, activity| { + this.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(relative_time(activity))), + ) + }) + .on_click(cx.listener({ + let announcement = announcement.clone(); + move |this, _event, window, cx| this.open_repo(&announcement, window, cx) + })) + .into_any_element() + } +} + +/// Name to show for a repository, its `name` tag or its id. +fn display_name(announcement: &Announcement) -> SharedString { + announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) +} + +/// Leading icon for a notification or activity kind. +fn kind_icon(kind: Kind) -> Icon { + if kind == COVER_NOTE_KIND { + return Icon::new(IconName::FileText).small(); + } + + match kind { + Kind::GitIssue => Icon::new(CustomIconName::GitIssueOpen), + Kind::GitPullRequest | Kind::GitPullRequestUpdate => { + Icon::new(CustomIconName::GitPullRequest) + } + Kind::GitPatch => Icon::new(CustomIconName::GitCommit), + Kind::Comment => Icon::new(IconName::FileText), + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => Icon::new(IconName::CircleCheck), + _ => Icon::new(IconName::Bell), + } + .small() +} + +/// Short noun for a notification or activity kind. +fn kind_label(kind: Kind) -> &'static str { + if kind == COVER_NOTE_KIND { + return "note"; + } + + match kind { + Kind::GitIssue => "issue", + Kind::GitPullRequest => "PR", + Kind::GitPullRequestUpdate => "PR update", + Kind::GitPatch => "patch", + Kind::Comment => "comment", + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => "status", + _ => "activity", + } +} + +/// Centered muted icon and message filling its container. +fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement { + v_flex() + .w_full() + .items_center() + .justify_center() + .gap_2() + .py_8() + .child( + Icon::new(icon) + .large() + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(message)), + ) + .into_any_element() +} + +impl BasePanel for InboxView { + fn panel_name(&self) -> &'static str { + "inbox" + } +} + +impl Panel for InboxView { + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().text_sm().child(SharedString::from("Inbox")) + } +} + +impl EventEmitter for InboxView {} + +impl Focusable for InboxView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for InboxView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let (unread, notifications, activity) = { + let store = self.store.read(cx); + ( + store.unread_count, + store.notifications.clone(), + store.activity.clone(), + ) + }; + + let visible: Vec<&InboxItem> = notifications.iter().filter(|item| !item.archived).collect(); + + v_flex() + .size_full() + .image_cache(gpui::retain_all("inbox")) + .child( + v_flex().size_full().overflow_y_scrollbar().child( + h_flex() + .items_start() + .gap_4() + .p_4() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_4() + .child(self.render_inbox_panel(unread, &visible, cx)) + .child(self.render_activity_panel(&activity, cx)), + ) + .child( + v_flex() + .w(px(300.)) + .flex_shrink_0() + .child(self.render_repos_panel(cx)), + ), + ), + ) + } +} diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 830a84a..6659761 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,8 +1,10 @@ mod dialog_state; +mod inbox; mod repo_detail; mod repo_list; pub(crate) mod sidebar; +pub use inbox::InboxView; pub use repo_detail::RepoDetailView; pub(crate) use repo_detail::open_repo_panel; pub use repo_list::RepoListView; diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 523bc10..3af536d 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -21,11 +21,11 @@ use signed_core::{Announcement, RepoAddr, identifier_from_name}; use signed_state::{ Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore, }; -use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; +use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; -use super::{RepoDetailView, RepoListView, open_repo_panel}; +use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel}; -mod create_repo_dialog; +pub(crate) mod create_repo_dialog; pub(crate) mod grasp_servers; mod import_dialog; mod onboarding_dialog; @@ -37,7 +37,10 @@ use self::onboarding_dialog::OnboardingState; pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, + inbox: Option>, explore: Option>, + /// Unread notification groups, shown as the inbox nav item's badge. + unread: usize, /// Artwork for the sign-in screen. banner: SharedString, /// The signed-in user's announced repositories, newest first. @@ -99,10 +102,22 @@ impl SidebarPanel { } })); + // The inbox nav item shows the unread notification count as a badge. + let inbox = backend.read(cx).inbox(); + subscriptions.push(cx.observe(&inbox, |this, inbox, cx| { + let unread = inbox.read(cx).unread_count; + if this.unread != unread { + this.unread = unread; + cx.notify(); + } + })); + let mut this = Self { focus_handle: cx.focus_handle(), dock_area, + inbox: None, explore: None, + unread: inbox.read(cx).unread_count, banner: pick_banner(), announcements: Arc::new(Vec::new()), local_repos: Arc::new(Vec::new()), @@ -203,6 +218,20 @@ impl SidebarPanel { }); } + /// Open the inbox home panel in the dock area's center. + pub fn open_inbox(&mut self, window: &mut Window, cx: &mut Context) { + if self.inbox.as_ref().and_then(WeakEntity::upgrade).is_some() { + return; + } + + let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), window, cx)); + self.inbox = Some(panel.downgrade()); + + let _ = self.dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } + /// Open the Explore repository list panel in the dock area's center. pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { if self @@ -615,8 +644,11 @@ impl Render for SidebarPanel { .justify_start() .child( NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) + .when(self.unread > 0, |this| { + this.suffix(CountBadge::new(self.unread)) + }) .on_click(cx.listener(|this, _ev, window, cx| { - this.open_explore(window, cx) + this.open_inbox(window, cx) })), ) .child( diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md index 044981f..13a22ad 100644 --- a/docs/inbox-plan.md +++ b/docs/inbox-plan.md @@ -6,11 +6,13 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for > page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `` when > an account is active, and that home screen is the inbox. -> **Status.** Phases 0 and 1 are implemented and green on `feat/inbox`: +> **Status.** Phases 0, 1 and 2 are implemented and green on `feat/inbox`: > `cargo test -p signed_core` (68), `cargo test -p signed_state` (24), -> `cargo clippy -p signed_state --all-targets` clean, `cargo check --workspace` succeeds. -> Phases 2-5 are not started. This document reflects the implementation as it stands, including -> the Phase 1 refactors (§4.3). +> `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean, +> `cargo check --workspace --all-targets` succeeds. +> Phases 3-5 are not started. This document reflects the implementation as it stands, including the +> Phase 1 refactors and the §4.3 split of the inbox into a thin global `Inbox` and a panel-scoped +> `InboxStore`. ## 1. What the GitWorkshop home screen is @@ -267,12 +269,14 @@ key in the store (see §4.3). An earlier implementation deleted the previous eve id across saves; that was removed as more derived state than it was worth. `NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs. -### 4.3 `signed_state`: `Inbox`, a child entity of `Backend` +### 4.3 `signed_state`: a thin global `Inbox` and a panel-scoped `InboxStore` -The inbox is not an app-wide global. It is a child `Entity` owned by `Backend` -(`inbox: Entity`), following the project's child-entity pattern -(`docs/backend-rearchitecture.md` §11): its observer set (the inbox screen, the sidebar badge) is a -strict subset of the backend's, so it is observed independently. +The inbox is split in two, because the expensive derivation is only needed while the home screen is +open. + +**`Inbox`** is a child `Entity` owned by `Backend` (`inbox: Entity`) and is +deliberately thin: it owns only the read/archive state that must outlive the panel, the NIP-78 +load/save, and the unread count the sidebar badge reads. ```rust // backend.rs @@ -282,80 +286,90 @@ pub struct Backend { } // inbox.rs +#[derive(Default)] pub struct Inbox { - /// Activity directed at the user, grouped by thread root, newest first. - pub notifications: Arc>, - /// The user's own recent git activity, newest first. - pub activity: Arc>, - /// Unread notification count (non-archived). - pub unread_count: usize, state: InboxReadState, - /// Set once the stored state has been read for the current user. state_loaded: bool, - refresh: RefreshGate, + /// Published by `InboxStore` for the sidebar badge. + pub unread_count: usize, +} + +impl Inbox { + pub fn state(&self) -> &InboxReadState; + pub fn is_loaded(&self) -> bool; + pub fn set_unread_count(&mut self, count: usize, cx: &mut Context); + pub fn mark_read(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx); + pub fn mark_archived(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx); + pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx); + pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx); + pub(crate) fn reset(&mut self, cx); } ``` -`Backend::new` builds it with `cx.new(|_| Inbox::default())`, and callers reach it through -`Backend::global(cx).read(cx).inbox()` or `Backend::inbox()`. All inbox operations (`mark_read`, -`mark_archived`, `mark_all_read`, `refresh`) live on `Inbox`. - -**The store holds no derived state.** The current user is read from `Backend::current_user()` at -each use site, repo relays are queried from `RepoListStore` in `Backend::sync_inbox`, and the -signing key is random per save rather than cached. This follows the project rule against caching -derived state. - -**Lifespan: idle until a signer exists.** `Inbox` is created with the backend but does nothing -until the user has a signer. It is never wired from the `desktop` crate, and `signed_state::init` -gains no parameters (the `InboxStore::set_global` idea was dropped). - -The dependency is strictly one-way: **`Backend` → `Inbox`**. `Inbox` holds no `Backend` handle, so -there is no reference cycle and no `cx.subscribe`. Two mechanisms connect them. - -**`Backend::emit`** is the single funnel for every `BackendEvent`. It updates the inbox on a -deferred effect and then emits to the other subscribers: +**`InboxStore`** is created by `InboxView` and therefore only exists while the panel is open. It +derives the notification groups and the activity list, coalesces refreshes and applies the read +state for rendering. ```rust -/// Update the inbox, then emit `event` to the other stores. -fn emit(&self, event: BackendEvent, cx: &mut Context) { - let inbox = self.inbox.downgrade(); - let inbox_event = event.clone(); +pub struct InboxStore { + pub notifications: Arc>, + pub activity: Arc>, + pub unread_count: usize, + state: InboxReadState, + state_loaded: bool, + refresh: RefreshGate, + _subscriptions: Vec, +} - cx.defer(move |cx| { - if let Err(error) = inbox.update(cx, |inbox, cx| { - inbox.handle_backend_event(&inbox_event, cx); - }) { - log::warn!("inbox dropped before handling backend event: {error}"); - } - }); - - cx.emit(event); +impl InboxStore { + pub fn new(cx: &mut Context) -> Self; + pub fn refresh(&mut self, cx: &mut Context); + pub fn mark_read(&mut self, root: EventId, cx); + pub fn mark_archived(&mut self, root: EventId, cx); + pub fn mark_all_read(&mut self, cx); } ``` -The `cx.defer` is load-bearing: every emit site runs inside `Backend::update`, and the inbox -handlers read `Backend`, so a synchronous call would re-enter the borrowed entity and panic. All -`cx.emit(...)` sites route through `self.emit(...)`. +`InboxStore` has no subscriptions of its own. The panel owns the two subscriptions that carry +logic: it observes the global `Inbox` (`InboxStore::sync_state`) and subscribes to `Backend` +(`InboxStore::handle_backend_event`, plus resetting `show_all` on a signer change). Re-rendering +needs no subscription: GPUI invalidates a window for every entity it read during render, so the +panel tracks the store, the search `InputState` and `RepoListStore` just by reading them in +`render`. The store never writes derived data back to `Backend` except the unread count. -`Inbox::handle_backend_event` reacts to only three shapes: +**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. `InboxStore` +is created and dropped with the panel. Neither is wired from the `desktop` crate and +`signed_state::init` gains no parameters. -- `NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`, is - `Kind::Comment`, or is a deletion (`EventDeletion` / `RequestToVanish`). -- `Synced` / `Published`: refresh. +**Badge trade-off.** The unread count is derived by the store, so the sidebar badge is only current +after the inbox has been opened once in the session. Keeping it always live would require the +expensive derivation to run globally, which is exactly what this split avoids. + +The dependency chain is `Backend` → `Inbox` and `InboxView` → `InboxStore`; the store reaches back +only to publish the unread count. + +`Backend` no longer funnels its events through the inbox: `InboxStore` subscribes to `Backend` +directly. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: +`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox. + +`InboxStore::handle_backend_event` refreshes on: + +- `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is + a deletion (`EventDeletion` / `RequestToVanish`). +- `Synced` / `Published`. - everything else: ignored. -`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore` -and `SidebarPanel` consume them. They no longer drive the inbox. - -**Signer lifecycle: `Backend::sync_inbox`.** The inbox does not match `SignerChanged` / -`SignerRequired`. `Backend` owns the wiring and calls `sync_inbox` from the three real signer -transitions: `create_identity`, `set_signer` (covers nsec, bunker and passphrase restore) and -`logout`. The fetch work that used to live in `Inbox::activate` moved here, because the filters and -repo relays need `Backend`'s state: +**Signer lifecycle: `Backend::sync_inbox`.** `Backend` owns the wiring and calls `sync_inbox` from the +three real signer transitions: `create_identity`, `set_signer` (nsec, bunker and passphrase restore) +and `logout`. It starts the subscriptions and repo-relay connects, then calls `Inbox::activate` or +`Inbox::reset`. The client is passed into `activate`, so the global inbox never reads `Backend` +while `sync_inbox` is mid-update: ```rust fn sync_inbox(&mut self, cx: &mut Context) { - if let Some(me) = self.current_user { + let me = self.current_user; + + if let Some(me) = me { self.subscribe_bootstrap(filters::notifications(me), cx); self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); @@ -373,38 +387,26 @@ fn sync_inbox(&mut self, cx: &mut Context) { } } - let inbox = self.inbox.downgrade(); - cx.defer(move |cx| { - let updated = inbox.update(cx, |inbox, cx| { - if Backend::global(cx).read(cx).current_user().is_some() { - inbox.activate(cx); - } else { - inbox.reset(cx); - } - }); - if let Err(error) = updated { - log::warn!("inbox dropped before syncing with the signer: {error}"); - } + let client = self.client.clone(); + self.inbox.update(cx, |inbox, cx| match me { + Some(me) => inbox.activate(me, client, cx), + None => inbox.reset(cx), }); } ``` The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind -10002 yet.) The deferred `update` is required because `activate` reads `Backend`, which every -caller is mid-update on. `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` no longer -has `subscribe_remote` / `connect_own_repo_relays`. +10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote` +/ `connect_own_repo_relays`. -**Activation** (`activate`) clears the user's data, drops any in-flight or pending run belonging to -the previous user (`self.refresh = RefreshGate::default()`), then loads the NIP-78 state from LMDB -and chains the first refresh once it is loaded: +**Activation** clears the state and loads the NIP-78 state from LMDB. `InboxStore` clears its own +lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded: ```rust -pub(crate) fn activate(&mut self, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { return }; - // clear notifications, activity, unread_count; state = default; state_loaded = false; - // self.refresh = RefreshGate::default(); - self.load_state(me, cx); +pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context) { + // state = default; state_loaded = false; unread_count = 0; cx.notify(); + // spawn load_state(client, me), then set state and state_loaded = true } ``` @@ -413,11 +415,11 @@ pub(crate) fn activate(&mut self, cx: &mut Context) { Reading the state event needs no signer at all (the `d` tag carries the identity); activation is still gated on the signer because the fetch filters need the user's pubkey. -**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays`, as shown in -`sync_inbox` above. The query that follows is intentionally the offline-first cache read, not a -wait on the network; see the note below. +**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through +`Backend::sync_inbox` (see above). The query the store runs is intentionally the offline-first cache +read, not a wait on the network; see the note below. -**Refresh** (mirrors `RepoListStore::run_refresh`): +**Refresh** (`InboxStore::run_refresh`, mirrors `RepoListStore::run_refresh`): - `cx.background_spawn`: query the notification filters and the activity filter from `client.database()`. @@ -428,21 +430,26 @@ wait on the network; see the note below. their `K` tag is a git kind; sort newest first; take the top N. - Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() == Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a - previous user's results never land. Then set `notifications`, `activity`, `unread_count`, - `cx.notify()`, `refresh.finish()`. + previous user's results never land. Then set `notifications`, `activity`, `unread_count`, publish + the unread count to the global `Inbox`, `cx.notify()`, `refresh.finish()`. + +The store's `sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the +lists, on the first load it runs the initial refresh, and on a state change (a mark action) it +re-derives the flags (`InboxItem::apply_state`) and publishes the new unread count. **Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately, so the query that follows them reads the local cache rather than waiting for the relays. That is deliberate offline-first behavior: cached content appears at once on a warm start and with no network, instead of blocking the home screen on the network. The gap is closed by the SDK, not by timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so -`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the inbox refreshes. This was +`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was reviewed and left as-is. -**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each group's events are -marked, then the cutoffs are advanced against *all* notification events to bound the id sets. After -each change the groups are re-derived (`InboxItem::apply_state`) and the state is saved to LMDB, -signed with a fresh random key (see 4.2). +**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()` live on `InboxStore`, which +passes the group and every known notification event to the global `Inbox`. The global marks the +group, advances the cutoffs against *all* notification events to bound the id sets, saves the state +to LMDB (signed with a fresh random key, see 4.2), and notifies. The store then re-derives and +publishes the unread count. **My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes `announcements_of(user)`. @@ -457,19 +464,24 @@ signed with a fresh random key (see 4.2). ### 5.1 `InboxView` center panel New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`. -One scrollable two-column flex row. +It owns an `Entity`; GPUI re-renders the panel when the store changes because the panel +reads it during render. One `overflow_y_scrollbar` container holding a two-column flex row, left +column flexible and right column fixed at 300px. Both columns are bordered cards with a header bar. -- **Inbox column**: header with the unread count badge and actions **Unread**, **Archived**, - **Mark all read**; then the non-archived notification items (top 5, with a **Show all** toggle - expanding inline). Rows show the actor avatar, a kind badge, the subject, the repo name, a - relative time, and an unread dot. Empty state: "You're all caught up." with `IconName::Inbox`. -- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject, - repo name, and relative time. -- **My repositories**: `RepoListStore::announcements_of(me)` with a small search `InputState` (same +- **Inbox column**: header with the unread count badge and **Mark all read**; then the non-archived + notification items (top 5, with a **Show all** toggle expanding inline). Rows show the actor + avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread + dot (the subject is semibold while unread). Empty state: "You're all caught up." with + `IconName::Inbox`. +- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject, kind + label, repo name, and relative time. +- **My repositories**: `RepoListStore::announcements_of(me)` with a search `InputState` (same pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows - open `open_repo_panel`. + open `open_repo_panel`. Empty state "No repositories yet."; without a signer it says + "Sign in to see your repositories.". -No greeting header. +No greeting header. The **Unread** and **Archived** header buttons belong to Phase 3 and are not +rendered until `add_bottom_panel` / `InboxFilterView` exist (see 5.2). ### 5.2 Unread / Archived as bottom-dock panels @@ -490,17 +502,19 @@ pub fn add_bottom_panel( The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then: - New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the - `Entity`. It renders the matching subset of `Inbox::notifications` as a list. -- The **Unread** and **Archived** header buttons in `InboxView` call `add_bottom_panel` with the - requested mode. `InboxView` keeps `filter_view: Option>`; when it - already exists, update its mode and focus instead of adding a duplicate. + `Entity`. It renders the matching subset of `InboxStore::notifications` as a list. +- The **Unread** and **Archived** header buttons in `InboxView` (added in Phase 3) call + `add_bottom_panel` with the requested mode. `InboxView` keeps + `filter_view: Option>`; when it already exists, update its mode and + focus instead of adding a duplicate. Until then the inbox header has only **Mark all read**. ### 5.3 Sidebar In `views/sidebar/mod.rs`: -- Add `inbox: Option>` (mirrors `explore`). -- Add `fn open_inbox(&mut self, window, cx)` that focuses the existing panel or adds a center panel. +- Add `inbox: Option>` (mirrors `explore`) and `unread: usize`. +- Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds + a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab). - Point the existing nav item at it and add an unread suffix: ```rust @@ -509,7 +523,8 @@ In `views/sidebar/mod.rs`: .on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))), ``` -- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge updates. +- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge follows the count the store + publishes. ### 5.4 Click-through (P1) @@ -551,16 +566,19 @@ patch-root click opens the repo panel. Note as a known limitation. | `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests | | `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports | | `crates/signed_state/Cargo.toml` | add `serde_json` | -| `crates/signed_state/src/inbox.rs` | **new**: `Inbox` child entity, NIP-78 load/save, refresh, actions; no `Backend` handle, no `cx.subscribe` | -| `crates/signed_state/src/backend.rs` | `inbox: Entity` field, construction, `inbox()` accessor, private `emit` funnel, `sync_inbox`, `RepoListStore` import | +| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state) and panel-scoped `InboxStore` (query, grouping, activity, actions) | +| `crates/signed_state/src/backend.rs` | `inbox: Entity` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import | | `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users | | `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` (no global install) | | `crates/dock/src/lib.rs` | `add_bottom_panel` helper | -| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel and `InboxFilterView` | +| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning `Entity` (`InboxFilterView` is Phase 3) | | `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` | -| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring and badge | +| `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge, `create_repo_dialog` visibility | | `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) | +`create_repo_dialog` changes from private (`mod`) to `pub(crate) mod` inside `sidebar`, so the inbox's +New button can open it. + No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox` activates the `Inbox` child entity at each signer transition. @@ -577,6 +595,7 @@ activates the `Inbox` child entity at each signer transition. exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the implementation notes below. 3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge. + **DONE.** See the implementation notes below. 4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. 5. **Phase 4 - click-through**: `open_item` and announcement lookup. 6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail @@ -631,16 +650,65 @@ and two additions to `crates/signed_core/src/inbox.rs`. - `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests); `cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds. +### Phase 2 implementation notes + +Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes. + +- `InboxView` is a plain center panel like `RepoListView`. It owns an `Entity` and + drives it; the sidebar holds a `WeakEntity` so there is no cycle. The panel's `Backend` + subscription also resets `show_all` on a signer change. Re-rendering relies on GPUI's render-time + entity tracking rather than explicit observations. +- The layout is one `overflow_y_scrollbar` container with a two-column `h_flex`. The left column + (`flex_1`, `min_w_0`) stacks the inbox card over the activity card; the right column is fixed at + 300px. Each card is a bordered rounded `v_flex` with a header bar (`section`). +- Notification rows read the newest event of each group for the actor, subject and time, and the + root's kind for the icon. The repo name is resolved from `item.address` through a linear scan of + `RepoListStore::announcements` (`repo_name`); the list is small and this keeps the store unchanged. +- The **Unread** / **Archived** header buttons are intentionally absent: they need + `add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is **Mark all read** plus the + inline **Show all** toggle, so the panel is fully usable on its own. +- `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The + cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm. +- Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox + nav item is repointed and carries a `CountBadge` suffix driven by the observed unread count. The + screen is still opened by the nav item, not on app startup, matching the "idle until signer" rule; + auto-opening it as the post-login home is a possible follow-up. +- `create_repo_dialog` became `pub(crate) mod` in `sidebar` so the inbox New button reuses it. +- `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets` + succeeds. `cargo test -p signed_core` (68) and `cargo test -p signed_state` (24) still pass. + +### Architecture refactor (after Phase 2) + +Phases 0-2 kept all derivation in the global `Inbox`, so every notification and activity query ran +whether or not the home screen was open, and `Backend::emit` carried a deferred side effect just to +feed it. + +- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, and the `unread_count` the + sidebar badge reads, plus the NIP-78 load/save and the mark actions. +- `InboxStore` is created by `InboxView` and owns the query, grouping, activity list, refresh gate + and actions. It holds no subscriptions: the panel observes the global `Inbox` and subscribes to + `Backend`, driving the store. Re-renders rely on GPUI's render-time entity tracking. +- `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox` + updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update. +- `InboxView` observes only its store; the two observations it used to hold moved into the store. +- `signed_core` is unchanged. `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and + `cargo test -p workspace` (7) pass; clippy and `cargo check --workspace --all-targets` are clean. + +Trade-off: the sidebar badge is only current after the inbox is opened once, because the unread +count is derived by the panel-scoped store. + ## 8. Validation - `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip. -- `cargo test -p signed_state` (24 tests): the `Inbox` store paths that do not need GPUI (state - round-trip, grouping helpers). -- `cargo clippy -p signed_state --all-targets` and `cargo check --workspace` after each phase. -- Manual: log in with a repo-owning identity; confirm the inbox panel populates from another - identity's issue/comment, the activity list shows your own items, the repositories panel matches - the sidebar, and that no kind-30078 event is broadcast (watch the relays / `Published` events). - Restart to confirm the read state is read back from LMDB. +- `cargo test -p signed_state` (24 tests): the `Inbox` / `InboxStore` paths that do not need GPUI + (state round-trip, grouping helpers). +- `cargo test -p workspace` (7 tests): repository-detail helpers. +- `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and + `cargo check --workspace --all-targets` after each phase. +- Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel + populates from another identity's issue/comment, the activity list shows your own items, the + repositories panel matches the sidebar, and that no kind-30078 event is broadcast (watch the + relays / `Published` events). Restart to confirm the read state is read back from LMDB. ## 9. SDK APIs used (verified in the pinned `5c669a4` checkout) -- 2.54.0 From 1e7cf0020c873c54cd3b014d555892ea3fe8aa61 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 11 Sep 2026 10:22:13 +0700 Subject: [PATCH 04/10] update --- crates/signed_state/src/inbox.rs | 320 +------- crates/signed_state/src/lib.rs | 3 +- crates/workspace/src/views/inbox.rs | 864 ++++++++++++---------- crates/workspace/src/views/sidebar/mod.rs | 4 +- docs/inbox-plan.md | 229 +++--- 5 files changed, 633 insertions(+), 787 deletions(-) diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index 9b58dda..950ba05 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -1,20 +1,11 @@ use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use std::time::Duration; use anyhow::Error; use gpui::{AppContext, Context, Task}; use nostr_sdk::prelude::*; use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox}; -use crate::backend::{Backend, BackendEvent}; -use crate::refresh::{RefreshGate, RefreshRequest}; - -/// Delay between a refresh request and the actual re-query. -const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); - -/// Maximum number of "continue where you left off" activity events kept. -const ACTIVITY_LIMIT: usize = 50; +use crate::backend::Backend; /// The user's persisted inbox read state. #[derive(Default)] @@ -22,7 +13,7 @@ pub struct Inbox { state: InboxReadState, /// Set once the stored state has been read for the current user. state_loaded: bool, - /// Unread notification groups, published by [`InboxStore`] for the sidebar badge. + /// Unread notification groups, published by the inbox panel for the sidebar badge. pub unread_count: usize, } @@ -37,7 +28,7 @@ impl Inbox { self.state_loaded } - /// Publish the unread count derived by [`InboxStore`]. + /// Publish the unread count derived by the inbox panel. pub fn set_unread_count(&mut self, count: usize, cx: &mut Context) { if self.unread_count == count { return; @@ -150,288 +141,43 @@ impl Inbox { } } -/// Derives the inbox home screen's notification and activity lists. +/// Derive the inbox home screen's lists for `me` from the local database. /// -/// Created by the inbox panel, so the database work only happens while the -/// panel is open. The persisted read state stays in the global [`Inbox`]. -#[derive(Default)] -pub struct InboxStore { - /// Notifications grouped by thread root, newest activity first. - pub notifications: Arc>, - /// The user's own recent git activity, newest first. - pub activity: Arc>, - /// Number of non-archived groups with an unread event. - pub unread_count: usize, - /// Copy of the global read state the current lists were derived with. - state: InboxReadState, - /// Set once the global state has been read for the current user. - state_loaded: bool, - refresh: RefreshGate, -} +/// Returns the notification groups, the user's own git activity and the number +/// of non-archived groups with an unread event. +pub async fn query_inbox( + client: &Client, + me: PublicKey, + state: &InboxReadState, +) -> Result<(Vec, Vec, usize), Error> { + let deletion_events = client.database().query(filters::deletions()).await?; + let deletions = Deletions::from_events(deletion_events); -impl InboxStore { - /// Create the store and derive the lists from the current global state. - pub fn new(cx: &mut Context) -> Self { - let weak = cx.entity().downgrade(); - cx.defer(move |cx| { - if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) { - log::warn!("inbox store dropped before bootstrap could run: {error}"); - } - }); + let (notification_events, by_id) = fetch_notifications(client, me, &deletions).await?; + let notifications = inbox::group(notification_events, me, state, &|id| { + by_id.get(&id).cloned() + }); + let unread_count = notifications.iter().filter(|item| item.is_unread()).count(); - Self::default() - } - - /// Mark every event in the group rooted at `root` read. - pub fn mark_read(&mut self, root: EventId, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; - - let Some(group) = self.group_events(root) else { - return; - }; - - let all = self.all_notification_events(); - let inbox = Backend::global(cx).read(cx).inbox(); - inbox.update(cx, |inbox, cx| inbox.mark_read(&group, &all, me, cx)); - } - - /// Archive the group rooted at `root`. - pub fn mark_archived(&mut self, root: EventId, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; - - let Some(group) = self.group_events(root) else { - return; - }; - - let all = self.all_notification_events(); - let inbox = Backend::global(cx).read(cx).inbox(); - inbox.update(cx, |inbox, cx| inbox.mark_archived(&group, &all, me, cx)); - } - - /// Mark every known notification read. - pub fn mark_all_read(&mut self, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; - - let all = self.all_notification_events(); - let inbox = Backend::global(cx).read(cx).inbox(); - inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx)); - } - - /// Re-derive from the global state when it is loaded or changes. - pub fn sync_state(&mut self, cx: &mut Context) { - let inbox = Backend::global(cx).read(cx).inbox(); - let (loaded, state) = { - let inbox = inbox.read(cx); - (inbox.is_loaded(), inbox.state().clone()) - }; - - if !loaded { - let was_present = - self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty(); - self.clear(); - if was_present { - cx.notify(); - } - return; - } - - if !self.state_loaded { - self.state_loaded = true; - self.state = state; - self.refresh_initial(cx); - return; - } - - if self.state != state { - self.state = state; - self.regroup(); - self.publish_unread_count(cx); - cx.notify(); + let mut activity = Vec::new(); + for event in client + .database() + .query(filters::authored_activity(me)) + .await? + { + if deletions.is_deleted(&event) || !filters::is_git_activity(&event) { + continue; } + activity.push(event); } - /// Handle a backend event that can change the derived lists. - pub fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { - match event { - BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx), - BackendEvent::NostrUpdate(updates) => { - let relevant = updates.iter().any(|update| { - let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind); - let is_comment = update.kind == Kind::Comment; - let is_event_deletion = update.kind == Kind::EventDeletion; - let is_request_to_vanish = update.kind == Kind::RequestToVanish; + activity.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) + }); - is_notification || is_comment || is_event_deletion || is_request_to_vanish - }); - if relevant { - self.refresh(cx); - } - } - _ => {} - } - } - - /// One-shot initial load, no debounce. - fn refresh_initial(&mut self, cx: &mut Context) { - debug_assert!(!self.refresh.debouncing()); - if self.refresh.running() { - self.refresh.request(); - return; - } - - self.run_refresh(cx); - } - - /// Re-query the local database. - pub fn refresh(&mut self, cx: &mut Context) { - if !self.state_loaded { - return; - } - - if self.refresh.request() != RefreshRequest::Schedule { - return; - } - - cx.spawn(async move |this, cx| { - cx.background_executor().timer(REFRESH_DEBOUNCE).await; - this.update(cx, |this, cx| this.run_refresh(cx)) - }) - .detach(); - } - - /// One query and apply cycle, the debounced entry point. - fn run_refresh(&mut self, cx: &mut Context) { - self.refresh.begin(); - - let backend = Backend::global(cx); - let Some(me) = backend.read(cx).current_user() else { - self.refresh.abort(); - return; - }; - - let client = backend.read(cx).client(); - let state = self.state.clone(); - - let work = cx.background_spawn(async move { - let deletion_events = client.database().query(filters::deletions()).await?; - let deletions = Deletions::from_events(deletion_events); - - let (notification_events, by_id) = fetch_notifications(&client, me, &deletions).await?; - let notifications = inbox::group(notification_events, me, &state, &|id| { - by_id.get(&id).cloned() - }); - let unread_count = notifications.iter().filter(|item| item.is_unread()).count(); - - let mut activity = Vec::new(); - for event in client - .database() - .query(filters::authored_activity(me)) - .await? - { - if deletions.is_deleted(&event) || !filters::is_git_activity(&event) { - continue; - } - activity.push(event); - } - - activity.sort_by(|a, b| { - b.created_at - .cmp(&a.created_at) - .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) - }); - activity.truncate(ACTIVITY_LIMIT); - - Ok::<_, Error>((notifications, activity, unread_count)) - }); - - let task: Task> = cx.spawn(async move |this, cx| { - let (notifications, activity, unread_count) = match work.await { - Ok(results) => results, - // Database errors are transient, keep the last lists. - Err(error) => { - log::warn!("inbox refresh failed: {error}"); - return this.update(cx, |this, _cx| this.refresh.abort()); - } - }; - - let again = this.update(cx, |this, cx| { - // The signer may have changed while the query ran, making - // these results belong to the previous user. - if Backend::global(cx).read(cx).current_user() != Some(me) { - this.refresh.abort(); - return false; - } - - this.notifications = Arc::new(notifications); - this.activity = Arc::new(activity); - this.unread_count = unread_count; - this.publish_unread_count(cx); - cx.notify(); - - this.refresh.finish() - })?; - - if again { - this.update(cx, |this, cx| this.refresh(cx))?; - } - - Ok(()) - }); - - task.detach(); - } - - /// Recompute the unread and archived flags from the current state. - fn regroup(&mut self) { - let mut items = (*self.notifications).clone(); - - for item in items.iter_mut() { - item.apply_state(&self.state); - } - - self.unread_count = items.iter().filter(|item| item.is_unread()).count(); - self.notifications = Arc::new(items); - } - - /// Publish the derived unread count for the sidebar badge. - fn publish_unread_count(&self, cx: &mut Context) { - let count = self.unread_count; - let inbox = Backend::global(cx).read(cx).inbox(); - inbox.update(cx, |inbox, cx| inbox.set_unread_count(count, cx)); - } - - /// Forget everything derived for the current user. - fn clear(&mut self) { - self.notifications = Arc::new(Vec::new()); - self.activity = Arc::new(Vec::new()); - self.unread_count = 0; - self.state = InboxReadState::default(); - self.state_loaded = false; - // Drop any in-flight or pending run belonging to the previous user. - self.refresh = RefreshGate::default(); - } - - /// Events of the group rooted at `root`. - fn group_events(&self, root: EventId) -> Option> { - self.notifications - .iter() - .find(|item| item.root == root) - .map(|item| item.events.clone()) - } - - /// Every event in every group, archived groups included. - fn all_notification_events(&self) -> Vec { - self.notifications - .iter() - .flat_map(|item| item.events.iter().cloned()) - .collect() - } + Ok((notifications, activity, unread_count)) } /// `d` tag identifying the inbox state event of `me`. diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index f325ad5..6d57e37 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -13,9 +13,10 @@ pub use backend::{Backend, BackendEvent, user_grasp_list_servers}; pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; pub use git_store::GitStore; use gpui::{App, AppContext}; -pub use inbox::{Inbox, InboxStore}; +pub use inbox::{Inbox, query_inbox}; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; +pub use refresh::{RefreshGate, RefreshRequest}; pub use repo::RepoStore; pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore}; use signed_nostr::new_backend; diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index d90b4eb..acd6826 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -1,116 +1,337 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Error; use assets::CustomIconName; -use dock::{BasePanel, DockArea, Panel, PanelEvent}; +use dock::{BasePanel, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, - Subscription, WeakEntity, Window, div, px, + AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState, + Pixels, Render, SharedString, Subscription, Task, Window, div, list, px, }; -use gpui_component::input::{Input, InputState}; use gpui_component::scroll::ScrollableElement; use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; -use nostr::prelude::{Event, Kind}; -use signed_core::{Announcement, COVER_NOTE_KIND, InboxItem, RepoAddr, activity_subject}; -use signed_state::{Backend, BackendEvent, InboxStore, ProfileStore, RepoListStore}; +use nostr::prelude::{Event, EventId, Kind}; +use signed_core::{ + Announcement, COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters, +}; +use signed_state::{ + Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox, +}; use signed_ui::{CountBadge, SegmentButton, UserAvatar}; use utils::relative_time; -use super::open_repo_panel; -use super::sidebar::create_repo_dialog; +/// Delay between a refresh request and the actual re-query. +const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); -/// Notification groups shown before the `Show all` toggle is used. -const NOTIFICATION_PREVIEW: usize = 5; - -/// Activity rows shown in `Continue where you left off`. -const ACTIVITY_SHOWN: usize = 15; +/// Extra list rows measured above and below the visible area. +const LIST_OVERDRAW: Pixels = px(400.); pub struct InboxView { focus_handle: FocusHandle, - dock_area: WeakEntity, - /// Derives the notification and activity lists while the panel is open. - store: Entity, - /// Search box filtering the `My repositories` column. - search: Entity, - /// Whether the inbox list is expanded past [`NOTIFICATION_PREVIEW`]. - show_all: bool, + /// Notifications grouped by thread root, newest activity first. + notifications: Arc>, + /// The user's own recent git activity, newest first. + activity: Arc>, + /// Number of non-archived groups with an unread event. + unread_count: usize, + /// Copy of the global read state the current lists were derived with. + state: InboxReadState, + /// Set once the global state has been read for the current user. + state_loaded: bool, + refresh: RefreshGate, + /// Virtual-list state of the notification list, kept in sync with the + /// rendered (non-archived) notifications. + notifications_list: ListState, + /// Virtual-list state of the activity list. + activity_list: ListState, _subscriptions: Vec, } impl InboxView { - pub fn new( - dock_area: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { + pub fn new(cx: &mut Context) -> Self { let backend = Backend::global(cx); let inbox = backend.read(cx).inbox(); + let weak = cx.entity().downgrade(); - let store = cx.new(InboxStore::new); - let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search...")); + let notifications_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); + let activity_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); - // Drive the store from the global inbox state and from backend events. + for list_state in [¬ifications_list, &activity_list] { + let weak = weak.clone(); + list_state.set_scroll_handler(move |_, _, cx| { + let weak = weak.clone(); + cx.defer(move |cx| { + let _ = weak.update(cx, |_, cx| cx.notify()); + }); + }); + } + + // Drive the lists from the global inbox state and from backend events. let _subscriptions = vec![ - cx.observe(&inbox, |this, _inbox, cx| { - this.store.update(cx, |store, cx| store.sync_state(cx)); - }), + cx.observe(&inbox, |this, _inbox, cx| this.sync_state(cx)), cx.subscribe(&backend, |this, _backend, event, cx| { - if matches!( - event, - BackendEvent::SignerChanged | BackendEvent::SignerRequired - ) { - this.show_all = false; - cx.notify(); - } - - this.store - .update(cx, |store, cx| store.handle_backend_event(event, cx)); + this.handle_backend_event(event, cx); }), ]; + // Derive the lists once the panel exists. + cx.defer({ + let weak = weak.clone(); + move |cx| { + if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) { + log::warn!("inbox dropped before bootstrap could run: {error}"); + } + } + }); + Self { focus_handle: cx.focus_handle(), - dock_area, - store, - search, - show_all: false, + notifications: Arc::new(Vec::new()), + activity: Arc::new(Vec::new()), + unread_count: 0, + state: InboxReadState::default(), + state_loaded: false, + refresh: RefreshGate::default(), + notifications_list, + activity_list, _subscriptions, } } + /// Mark every event in the group rooted at `root` read. + #[allow(dead_code)] // Wired up by the Phase 3 Unread/Archived panels. + pub fn mark_read(&mut self, root: EventId, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let Some(group) = self.group_events(root) else { + return; + }; + + let all = self.all_notification_events(); + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_read(&group, &all, me, cx)); + } + + /// Archive the group rooted at `root`. + #[allow(dead_code)] // Wired up by the Phase 3 Unread/Archived panels. + pub fn mark_archived(&mut self, root: EventId, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let Some(group) = self.group_events(root) else { + return; + }; + + let all = self.all_notification_events(); + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_archived(&group, &all, me, cx)); + } + /// Mark every known notification read. - fn mark_all_read(&mut self, cx: &mut Context) { - self.store.update(cx, |store, cx| store.mark_all_read(cx)); + pub fn mark_all_read(&mut self, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let all = self.all_notification_events(); + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx)); } - /// Open a repository's detail panel. - fn open_repo( - &mut self, - announcement: &Announcement, - window: &mut Window, - cx: &mut Context, - ) { - open_repo_panel(&self.dock_area, announcement, window, &mut *cx); + /// Re-derive from the global state when it is loaded or changes. + pub fn sync_state(&mut self, cx: &mut Context) { + let inbox = Backend::global(cx).read(cx).inbox(); + let (loaded, state) = { + let inbox = inbox.read(cx); + (inbox.is_loaded(), inbox.state().clone()) + }; + + if !loaded { + let was_present = + self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty(); + self.clear(); + if was_present { + cx.notify(); + } + return; + } + + if !self.state_loaded { + self.state_loaded = true; + self.state = state; + self.refresh_initial(cx); + return; + } + + if self.state != state { + self.state = state; + self.regroup(); + self.publish_unread_count(cx); + cx.notify(); + } } - /// Show the existing Create Repository dialog. - fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context) { - create_repo_dialog::open(self.dock_area.clone(), window, cx); + /// Handle a backend event that can change the derived lists. + fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { + match event { + BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx), + BackendEvent::NostrUpdate(updates) => { + let relevant = updates.iter().any(|update| { + let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind); + let is_comment = update.kind == Kind::Comment; + let is_event_deletion = update.kind == Kind::EventDeletion; + let is_request_to_vanish = update.kind == Kind::RequestToVanish; + + is_notification || is_comment || is_event_deletion || is_request_to_vanish + }); + if relevant { + self.refresh(cx); + } + } + _ => {} + } } - /// Display name of the repository at `addr`, from the announcement store. - fn repo_name(&self, addr: Option<&RepoAddr>, cx: &App) -> Option { - let addr = addr?; - RepoListStore::global(cx) - .read(cx) - .announcements + /// One-shot initial load, no debounce. + fn refresh_initial(&mut self, cx: &mut Context) { + debug_assert!(!self.refresh.debouncing()); + if self.refresh.running() { + self.refresh.request(); + return; + } + + self.run_refresh(cx); + } + + /// Re-query the local database. + fn refresh(&mut self, cx: &mut Context) { + if !self.state_loaded { + return; + } + + if self.refresh.request() != RefreshRequest::Schedule { + return; + } + + cx.spawn(async move |this, cx| { + cx.background_executor().timer(REFRESH_DEBOUNCE).await; + this.update(cx, |this, cx| this.run_refresh(cx)) + }) + .detach(); + } + + /// One query and apply cycle, the debounced entry point. + fn run_refresh(&mut self, cx: &mut Context) { + self.refresh.begin(); + + let backend = Backend::global(cx); + let Some(me) = backend.read(cx).current_user() else { + self.refresh.abort(); + return; + }; + + let client = backend.read(cx).client(); + let state = self.state.clone(); + + let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await }); + + let task: Task> = cx.spawn(async move |this, cx| { + let (notifications, activity, unread_count) = match work.await { + Ok(results) => results, + // Database errors are transient, keep the last lists. + Err(error) => { + log::warn!("inbox refresh failed: {error}"); + return this.update(cx, |this, _cx| this.refresh.abort()); + } + }; + + let again = this.update(cx, |this, cx| { + // The signer may have changed while the query ran, making + // these results belong to the previous user. + if Backend::global(cx).read(cx).current_user() != Some(me) { + this.refresh.abort(); + return false; + } + + this.notifications = Arc::new(notifications); + this.activity = Arc::new(activity); + this.unread_count = unread_count; + this.publish_unread_count(cx); + cx.notify(); + + this.refresh.finish() + })?; + + if again { + this.update(cx, |this, cx| this.refresh(cx))?; + } + + Ok(()) + }); + + task.detach(); + } + + /// Recompute the unread and archived flags from the current state. + fn regroup(&mut self) { + let mut items = (*self.notifications).clone(); + + for item in items.iter_mut() { + item.apply_state(&self.state); + } + + self.unread_count = items.iter().filter(|item| item.is_unread()).count(); + self.notifications = Arc::new(items); + } + + /// Publish the derived unread count for the sidebar badge. + fn publish_unread_count(&self, cx: &mut Context) { + let count = self.unread_count; + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.set_unread_count(count, cx)); + } + + /// Forget everything derived for the current user. + fn clear(&mut self) { + self.notifications = Arc::new(Vec::new()); + self.activity = Arc::new(Vec::new()); + self.unread_count = 0; + self.state = InboxReadState::default(); + self.state_loaded = false; + // Drop any in-flight or pending run belonging to the previous user. + self.refresh = RefreshGate::default(); + } + + /// Events of the group rooted at `root`. + #[allow(dead_code)] // Only used by the Phase 3 mark actions. + fn group_events(&self, root: EventId) -> Option> { + self.notifications .iter() - .find(|announcement| announcement.addr() == *addr) - .map(display_name) + .find(|item| item.root == root) + .map(|item| item.events.clone()) } - /// Bordered card with a header bar and a body. + /// Every event in every group, archived groups included. + fn all_notification_events(&self) -> Vec { + self.notifications + .iter() + .flat_map(|item| item.events.iter().cloned()) + .collect() + } + + /// Bordered card with a header bar and a scrolling body. + /// + /// Flexible so its body gets a definite height, which the virtual list + /// needs to know which rows to render. fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement { v_flex() .w_full() + .flex_1() + .min_h_0() .rounded(cx.theme().radius) .border_1() .border_color(cx.theme().border) @@ -131,41 +352,10 @@ impl InboxView { fn render_inbox_panel( &self, unread: usize, - visible: &[&InboxItem], + notifications: Arc>, + visible: Vec, cx: &mut Context, ) -> AnyElement { - let shown = if self.show_all { - visible.len() - } else { - visible.len().min(NOTIFICATION_PREVIEW) - }; - - let mut body = v_flex().w_full(); - - if visible.is_empty() { - body = body.child(empty_state(IconName::Inbox, "You're all caught up.", cx)); - } else { - for (ix, item) in visible.iter().take(shown).enumerate() { - body = body.child(self.render_notification_row(ix, item, cx)); - } - - if visible.len() > NOTIFICATION_PREVIEW { - let label = if self.show_all { - "Show less" - } else { - "Show all" - }; - body = body.child(div().px_3().py_2().child( - SegmentButton::new("show-all", label).on_click(cx.listener( - |this, _event, _window, cx| { - this.show_all = !this.show_all; - cx.notify(); - }, - )), - )); - } - } - let header = h_flex() .w_full() .gap_2() @@ -183,91 +373,36 @@ impl InboxView { .on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))), ); + let body = if visible.is_empty() { + empty_state(IconName::Inbox, "You're all caught up.", cx) + } else { + let list_state = self.notifications_list.clone(); + let rows = list(list_state.clone(), move |ix, _window, cx| { + let Some(item) = visible.get(ix).and_then(|&ix| notifications.get(ix)) else { + return div().into_any_element(); + }; + notification_row(ix, item, cx) + }) + .size_full() + .min_h_0(); + + div() + .relative() + .flex_1() + .min_h_0() + .child(rows) + .vertical_scrollbar(&list_state) + .into_any_element() + }; + self.section(header, body, cx) } - fn render_notification_row( + fn render_activity_panel( &self, - ix: usize, - item: &InboxItem, + activity: Arc>, cx: &mut Context, ) -> AnyElement { - let Some(newest) = item.events.first() else { - return div().into_any_element(); - }; - - let profiles = ProfileStore::global(cx); - let profile = profiles.read(cx).get(&newest.pubkey); - let kind = item.root_kind.unwrap_or(newest.kind); - let subject = SharedString::from(activity_subject(newest)); - let repo = self.repo_name(item.address.as_ref(), cx); - let age = relative_time(item.latest_activity()); - let unread = item.is_unread(); - - h_flex() - .id(ix) - .w_full() - .gap_3() - .px_3() - .py_2() - .items_center() - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().list_hover)) - .child(UserAvatar::new(profile.name()).picture(profile.picture())) - .child(div().flex_shrink_0().child(kind_icon(kind))) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .child( - div() - .text_sm() - .when(unread, |this| this.font_semibold()) - .whitespace_nowrap() - .text_ellipsis() - .child(subject), - ) - .child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(kind_label(kind))) - .when_some(repo, |this, repo| { - this.child(SharedString::from("on")).child(repo) - }) - .child(SharedString::from("·")) - .child(SharedString::from(age)), - ), - ) - .when(unread, |this| { - this.child( - div() - .flex_shrink_0() - .size(px(8.)) - .rounded(px(4.)) - .bg(cx.theme().primary), - ) - }) - .into_any_element() - } - - fn render_activity_panel(&self, activity: &[Event], cx: &mut Context) -> AnyElement { - let mut body = v_flex().w_full(); - - if activity.is_empty() { - body = body.child(empty_state( - CustomIconName::Recent, - "No recent activity.", - cx, - )); - } else { - for (ix, event) in activity.iter().take(ACTIVITY_SHOWN).enumerate() { - body = body.child(self.render_activity_row(ix, event, cx)); - } - } - let header = h_flex() .w_full() .gap_2() @@ -279,200 +414,149 @@ impl InboxView { .child(SharedString::from("Continue where you left off")), ); + let body = if activity.is_empty() { + empty_state(CustomIconName::Recent, "No recent activity.", cx) + } else { + let list_state = self.activity_list.clone(); + let rows = list(list_state.clone(), move |ix, _window, cx| { + let Some(event) = activity.get(ix) else { + return div().into_any_element(); + }; + activity_row(ix, event, cx) + }) + .size_full() + .min_h_0(); + + div() + .relative() + .flex_1() + .min_h_0() + .child(rows) + .vertical_scrollbar(&list_state) + .into_any_element() + }; + self.section(header, body, cx) } +} - fn render_activity_row(&self, ix: usize, event: &Event, cx: &mut Context) -> AnyElement { - let kind = event.kind; - let subject = SharedString::from(activity_subject(event)); - let repo = self.repo_name(event.tags.coordinates().next().as_ref(), cx); - let age = relative_time(event.created_at); +/// Display name of the repository at `addr`, from the announcement store. +fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option { + let addr = addr?; + RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|announcement| announcement.addr() == *addr) + .map(display_name) +} - h_flex() - .id(ix) - .w_full() - .gap_3() - .px_3() - .py_2() - .items_center() - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().list_hover)) - .child(div().flex_shrink_0().child(kind_icon(kind))) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .child( - div() - .text_sm() - .whitespace_nowrap() - .text_ellipsis() - .child(subject), - ) - .child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(kind_label(kind))) - .when_some(repo, |this, repo| { - this.child(SharedString::from("on")).child(repo) - }) - .child(SharedString::from("·")) - .child(SharedString::from(age)), - ), - ) - .into_any_element() - } +/// Leading row of a notification group, newest event first. +fn notification_row(ix: usize, item: &InboxItem, cx: &App) -> AnyElement { + let Some(newest) = item.events.first() else { + return div().into_any_element(); + }; - fn render_repos_panel(&self, cx: &mut Context) -> AnyElement { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - let header = h_flex() - .w_full() - .gap_2() - .child(Icon::new(CustomIconName::GitBranch).small()) + let profiles = ProfileStore::global(cx); + let profile = profiles.read(cx).get(&newest.pubkey); + let kind = item.root_kind.unwrap_or(newest.kind); + let subject = SharedString::from(activity_subject(newest)); + let repo = repo_name(item.address.as_ref(), cx); + let age = relative_time(item.latest_activity()); + let unread = item.is_unread(); + + h_flex() + .id(("inbox-row", ix)) + .w_full() + .gap_3() + .px_3() + .py_2() + .items_center() + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().list_hover)) + .child(UserAvatar::new(profile.name()).picture(profile.picture())) + .child(div().flex_shrink_0().child(kind_icon(kind))) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() .child( div() .text_sm() - .font_semibold() - .child(SharedString::from("My repositories")), - ); - let body = v_flex().w_full().child(empty_state( - CustomIconName::GitBranch, - "Sign in to see your repositories.", - cx, - )); - return self.section(header, body, cx); - }; - - let query = self.search.read(cx).value().trim().to_lowercase(); - let repos: Vec = RepoListStore::global(cx) - .read(cx) - .announcements_of(&me) - .into_iter() - .filter(|announcement| { - query.is_empty() - || display_name(announcement).to_lowercase().contains(&query) - || announcement.id.to_lowercase().contains(&query) - }) - .collect(); - - let mut body = v_flex().w_full().child( - div().px_3().py_2().child( - Input::new(&self.search) - .cleanable(true) - .w_full() - .text_sm() - .border_color(cx.theme().muted) - .bg(cx.theme().muted) - .prefix(Icon::new(IconName::Search).small()), - ), - ); - - if repos.is_empty() { - body = body.child(empty_state( - CustomIconName::GitBranch, - "No repositories yet.", - cx, - )); - } else { - for (ix, announcement) in repos.iter().enumerate() { - body = body.child(self.render_repo_row(ix, announcement, cx)); - } - } - - let header = h_flex() - .w_full() - .gap_2() - .child(Icon::new(CustomIconName::GitBranch).small()) - .child( - div() - .text_sm() - .font_semibold() - .child(SharedString::from("My repositories")), - ) - .when(!repos.is_empty(), |this| { - this.child(CountBadge::new(repos.len())) - }) - .child(div().flex_1()) - .child( - SegmentButton::new("new-repo", "New") - .icon(Icon::new(CustomIconName::CirclePlus)) - .primary() - .on_click( - cx.listener(|this, _event, window, cx| this.open_create_repo(window, cx)), - ), - ); - - self.section(header, body, cx) - } - - fn render_repo_row( - &self, - ix: usize, - announcement: &Announcement, - cx: &mut Context, - ) -> AnyElement { - let name = display_name(announcement); - let description = announcement.description.clone().unwrap_or_default(); - let activity = RepoListStore::global(cx) - .read(cx) - .last_activity - .get(&announcement.addr()) - .copied(); - - h_flex() - .id(ix) - .w_full() - .gap_3() - .px_3() - .py_2() - .items_center() - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().list_hover)) - .child( - Icon::new(CustomIconName::GitBranch) - .small() - .text_color(cx.theme().muted_foreground), - ) - .child( - v_flex() - .flex_1() - .min_w_0() - .child( - div() - .text_sm() - .whitespace_nowrap() - .text_ellipsis() - .child(name), - ) - .when(!description.is_empty(), |this| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .whitespace_nowrap() - .text_ellipsis() - .child(SharedString::from(description)), - ) - }), - ) - .when_some(activity, |this, activity| { - this.child( - div() - .flex_shrink_0() + .when(unread, |this| this.font_semibold()) + .whitespace_nowrap() + .text_ellipsis() + .child(subject), + ) + .child( + h_flex() + .gap_1() .text_xs() .text_color(cx.theme().muted_foreground) - .child(SharedString::from(relative_time(activity))), + .child(SharedString::from(kind_label(kind))) + .when_some(repo, |this, repo| { + this.child(SharedString::from("on")).child(repo) + }) + .child(SharedString::from("·")) + .child(SharedString::from(age)), + ), + ) + .when(unread, |this| { + this.child( + div() + .flex_shrink_0() + .size(px(8.)) + .rounded(px(4.)) + .bg(cx.theme().primary), + ) + }) + .into_any_element() +} + +/// One row of the user's own recent git activity. +fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement { + let kind = event.kind; + let subject = SharedString::from(activity_subject(event)); + let repo = repo_name(event.tags.coordinates().next().as_ref(), cx); + let age = relative_time(event.created_at); + + h_flex() + .id(("activity-row", ix)) + .w_full() + .gap_3() + .px_3() + .py_2() + .items_center() + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().list_hover)) + .child(div().flex_shrink_0().child(kind_icon(kind))) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + div() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() + .child(subject), ) - }) - .on_click(cx.listener({ - let announcement = announcement.clone(); - move |this, _event, window, cx| this.open_repo(&announcement, window, cx) - })) - .into_any_element() - } + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(kind_label(kind))) + .when_some(repo, |this, repo| { + this.child(SharedString::from("on")).child(repo) + }) + .child(SharedString::from("·")) + .child(SharedString::from(age)), + ), + ) + .into_any_element() } /// Name to show for a repository, its `name` tag or its id. @@ -530,6 +614,8 @@ fn kind_label(kind: Kind) -> &'static str { fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement { v_flex() .w_full() + .flex_1() + .min_h_0() .items_center() .justify_center() .gap_2() @@ -570,41 +656,31 @@ impl Focusable for InboxView { impl Render for InboxView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let (unread, notifications, activity) = { - let store = self.store.read(cx); - ( - store.unread_count, - store.notifications.clone(), - store.activity.clone(), - ) - }; + let unread = self.unread_count; + let notifications = self.notifications.clone(); + let activity = self.activity.clone(); - let visible: Vec<&InboxItem> = notifications.iter().filter(|item| !item.archived).collect(); + let visible: Vec = notifications + .iter() + .enumerate() + .filter(|(_, item)| !item.archived) + .map(|(ix, _)| ix) + .collect(); + + if self.notifications_list.item_count() != visible.len() { + self.notifications_list.reset(visible.len()); + } + + if self.activity_list.item_count() != activity.len() { + self.activity_list.reset(activity.len()); + } v_flex() .size_full() .image_cache(gpui::retain_all("inbox")) - .child( - v_flex().size_full().overflow_y_scrollbar().child( - h_flex() - .items_start() - .gap_4() - .p_4() - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_4() - .child(self.render_inbox_panel(unread, &visible, cx)) - .child(self.render_activity_panel(&activity, cx)), - ) - .child( - v_flex() - .w(px(300.)) - .flex_shrink_0() - .child(self.render_repos_panel(cx)), - ), - ), - ) + .gap_4() + .p_4() + .child(self.render_inbox_panel(unread, notifications, visible, cx)) + .child(self.render_activity_panel(activity, cx)) } } diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 3af536d..766fc76 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -25,7 +25,7 @@ use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_han use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel}; -pub(crate) mod create_repo_dialog; +mod create_repo_dialog; pub(crate) mod grasp_servers; mod import_dialog; mod onboarding_dialog; @@ -224,7 +224,7 @@ impl SidebarPanel { return; } - let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), window, cx)); + let panel = cx.new(InboxView::new); self.inbox = Some(panel.downgrade()); let _ = self.dock_area.update(cx, |dock_area, cx| { diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md index 13a22ad..81ffb3a 100644 --- a/docs/inbox-plan.md +++ b/docs/inbox-plan.md @@ -11,8 +11,8 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for > `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean, > `cargo check --workspace --all-targets` succeeds. > Phases 3-5 are not started. This document reflects the implementation as it stands, including the -> Phase 1 refactors and the §4.3 split of the inbox into a thin global `Inbox` and a panel-scoped -> `InboxStore`. +> Phase 1 refactors and the §4.3 split of the inbox into a thin global `Inbox` and a +> panel-owned derivation. ## 1. What the GitWorkshop home screen is @@ -55,47 +55,37 @@ Data hooks: | Priority | Section | Notes | |---|---|---| -| **P0** | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; top N + show all | -| **P0** | My repositories | Reuse `RepoListStore::announcements_of(me)`; search filter; existing New-repo dialog | +| **P0** | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; all groups shown | | **P0** | Continue where you left off | Your own recent git activity, newest first | | **P1** | Unread / Archived sub-views | Open as **bottom-dock panels**, not tabs inside the inbox panel | | **P1** | Click-through | Open the repo panel at the relevant PR/issue | | **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns | -| **Out of scope** | Greeting header, followed repositories, private repositories, pinned repositories | Not needed in Signed | +| **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories | Not needed in Signed | Notes: - There is **no greeting header**. The screen starts with the inbox panel. +- There is **no My repositories column**. The sidebar already lists the signed-in user's repositories, so the inbox is a single column. - Unread and Archived are separate panels opened in the bottom dock, not tabs in the inbox panel. ## 3. The Signed screen `InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item (currently a -placeholder that opens Explore). It is one scrollable two-column flex row: +placeholder that opens Explore). It is two flexible bordered cards, each a virtual list: ``` -+--------------------------------------------------+-----------------------+ -| Inbox (3 unread) [Unread] [Archived] [Mark all read] | -| [avatar] issue opened on you/repo 2m | ++-------------------------------------------------------------------------+ +| Inbox (3 unread) [Unread] [Archived] [Mark all read] | +| [avatar] issue opened on you/repo 2m (scroll) | | [avatar] commented on "Fix parser" 1h | | [avatar] PR update on you/repo 3h | -| [Show all] | -| | -| Continue where you left off | ++-------------------------------------------------------------------------+ +| Continue where you left off (scroll)| | [icon] "Fix parser bug" you/repo opened 3d | | [icon] "Add retry" you/repo PR 5d | -+--------------------------------------------------+-----------------------+ ++-------------------------------------------------------------------------+ | bottom dock: Unread or Archived list (opened by the header buttons) | -+--------------------------------------------------+-----------------------+ -``` - -The right column is **My repositories**, mirroring the sidebar's signed-in repo list: - -``` -| My repositories | -| [search] [New] | -| repo row | -| repo row | ++-------------------------------------------------------------------------+ ``` ## 4. Data layer @@ -269,7 +259,7 @@ key in the store (see §4.3). An earlier implementation deleted the previous eve id across saves; that was removed as more derived state than it was worth. `NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs. -### 4.3 `signed_state`: a thin global `Inbox` and a panel-scoped `InboxStore` +### 4.3 Data layer: a thin global `Inbox`, a panel-owned derivation The inbox is split in two, because the expensive derivation is only needed while the home screen is open. @@ -290,7 +280,7 @@ pub struct Backend { pub struct Inbox { state: InboxReadState, state_loaded: bool, - /// Published by `InboxStore` for the sidebar badge. + /// Published by the inbox panel for the sidebar badge. pub unread_count: usize, } @@ -306,53 +296,70 @@ impl Inbox { } ``` -**`InboxStore`** is created by `InboxView` and therefore only exists while the panel is open. It -derives the notification groups and the activity list, coalesces refreshes and applies the read -state for rendering. +**The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read +state they were computed with, and the refresh coalescing. There is no separate store entity: the +panel is the only consumer, so an `Entity` would add an `update` indirection and a +forwarding subscription without buying any sharing. ```rust -pub struct InboxStore { - pub notifications: Arc>, - pub activity: Arc>, - pub unread_count: usize, +pub struct InboxView { + focus_handle: FocusHandle, + notifications: Arc>, + activity: Arc>, + unread_count: usize, state: InboxReadState, state_loaded: bool, refresh: RefreshGate, _subscriptions: Vec, } -impl InboxStore { - pub fn new(cx: &mut Context) -> Self; - pub fn refresh(&mut self, cx: &mut Context); - pub fn mark_read(&mut self, root: EventId, cx); - pub fn mark_archived(&mut self, root: EventId, cx); +impl InboxView { + pub fn new(cx: &mut Context) -> Self; // cx.defer(… sync_state) + pub fn sync_state(&mut self, cx); // observes the global Inbox + pub fn mark_read(&mut self, root: EventId, cx); // Phase 3 + pub fn mark_archived(&mut self, root: EventId, cx); // Phase 3 pub fn mark_all_read(&mut self, cx); + fn handle_backend_event(&mut self, event: &BackendEvent, cx); + fn refresh(&mut self, cx); + fn run_refresh(&mut self, cx); } ``` -`InboxStore` has no subscriptions of its own. The panel owns the two subscriptions that carry -logic: it observes the global `Inbox` (`InboxStore::sync_state`) and subscribes to `Backend` -(`InboxStore::handle_backend_event`, plus resetting `show_all` on a signer change). Re-rendering -needs no subscription: GPUI invalidates a window for every entity it read during render, so the -panel tracks the store, the search `InputState` and `RepoListStore` just by reading them in -`render`. The store never writes derived data back to `Backend` except the unread count. +The panel owns the two subscriptions that carry logic: it observes the global `Inbox` +(`InboxView::sync_state`) and subscribes to `Backend` (`InboxView::handle_backend_event`). +Re-rendering needs no subscription: GPUI invalidates a window for every entity it read during +render, so the panel tracks `RepoListStore` and `ProfileStore` just by reading them in `render`. +The panel writes back to `Backend` only to publish the unread count for the badge. -**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. `InboxStore` -is created and dropped with the panel. Neither is wired from the `desktop` crate and +**`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never +queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git +activity and the unread count; the panel applies the results on the main thread. `RefreshGate` is +re-exported for the panel's debounce. + +```rust +pub async fn query_inbox( + client: &Client, + me: PublicKey, + state: &InboxReadState, +) -> Result<(Vec, Vec, usize), Error>; +``` + +**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. The +derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and `signed_state::init` gains no parameters. -**Badge trade-off.** The unread count is derived by the store, so the sidebar badge is only current +**Badge trade-off.** The unread count is derived by the panel, so the sidebar badge is only current after the inbox has been opened once in the session. Keeping it always live would require the expensive derivation to run globally, which is exactly what this split avoids. -The dependency chain is `Backend` → `Inbox` and `InboxView` → `InboxStore`; the store reaches back +The dependency chain is `Backend` → `Inbox` and `InboxView` → `query_inbox`; the panel reaches back only to publish the unread count. -`Backend` no longer funnels its events through the inbox: `InboxStore` subscribes to `Backend` -directly. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: -`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox. +`Backend` does not funnel its events through the inbox: the panel subscribes to `Backend` directly. +`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore` +and `SidebarPanel` consume them. They no longer drive the inbox. -`InboxStore::handle_backend_event` refreshes on: +`InboxView::handle_backend_event` refreshes on: - `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is a deletion (`EventDeletion` / `RequestToVanish`). @@ -400,7 +407,7 @@ time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does n 10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote` / `connect_own_repo_relays`. -**Activation** clears the state and loads the NIP-78 state from LMDB. `InboxStore` clears its own +**Activation** clears the state and loads the NIP-78 state from LMDB. The panel clears its own lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded: ```rust @@ -416,10 +423,10 @@ Reading the state event needs no signer at all (the `d` tag carries the identity still gated on the signer because the fetch filters need the user's pubkey. **Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through -`Backend::sync_inbox` (see above). The query the store runs is intentionally the offline-first cache +`Backend::sync_inbox` (see above). The query the panel runs is intentionally the offline-first cache read, not a wait on the network; see the note below. -**Refresh** (`InboxStore::run_refresh`, mirrors `RepoListStore::run_refresh`): +**Refresh** (`InboxView::run_refresh`, mirrors `RepoListStore::run_refresh`): - `cx.background_spawn`: query the notification filters and the activity filter from `client.database()`. @@ -427,13 +434,13 @@ read, not a wait on the network; see the note below. - Build `HashMap` for root walking; group the notification events with `inbox::group`. - Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when - their `K` tag is a git kind; sort newest first; take the top N. + their `K` tag is a git kind; sort newest first. - Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() == Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a previous user's results never land. Then set `notifications`, `activity`, `unread_count`, publish the unread count to the global `Inbox`, `cx.notify()`, `refresh.finish()`. -The store's `sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the +`InboxView::sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the lists, on the first load it runs the initial refresh, and on a state change (a mark action) it re-derives the flags (`InboxItem::apply_state`) and publishes the new unread count. @@ -445,14 +452,14 @@ timing: received events are written to LMDB and surfaced as `ClientNotification: `Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was reviewed and left as-is. -**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()` live on `InboxStore`, which +**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()` live on the panel, which passes the group and every known notification event to the global `Inbox`. The global marks the group, advances the cutoffs against *all* notification events to bound the id sets, saves the state -to LMDB (signed with a fresh random key, see 4.2), and notifies. The store then re-derives and +to LMDB (signed with a fresh random key, see 4.2), and notifies. The panel then re-derives and publishes the unread count. -**My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes -`announcements_of(user)`. +**Repository names need no new store**: `RepoListStore` already holds every announcement and +`repo_name` resolves an address to a display name. ### 4.4 `Cargo.toml` @@ -464,23 +471,23 @@ publishes the unread count. ### 5.1 `InboxView` center panel New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`. -It owns an `Entity`; GPUI re-renders the panel when the store changes because the panel -reads it during render. One `overflow_y_scrollbar` container holding a two-column flex row, left -column flexible and right column fixed at 300px. Both columns are bordered cards with a header bar. +It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is a +column of two flexible bordered cards (`flex_1`, `min_h_0`), each with a header bar and a scrolling +body. Each body is a `gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px +overdraw) with a `vertical_scrollbar`; the panel itself does not scroll, so both lists get a +definite viewport height. The list counts are reset from `render` whenever the rendered item count +changes. Row ids are prefixed (`("inbox-row", ix)` / `("activity-row", ix)`) so the two lists do not +collide. -- **Inbox column**: header with the unread count badge and **Mark all read**; then the non-archived - notification items (top 5, with a **Show all** toggle expanding inline). Rows show the actor - avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread - dot (the subject is semibold while unread). Empty state: "You're all caught up." with - `IconName::Inbox`. -- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject, kind - label, repo name, and relative time. -- **My repositories**: `RepoListStore::announcements_of(me)` with a search `InputState` (same - pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows - open `open_repo_panel`. Empty state "No repositories yet."; without a signer it says - "Sign in to see your repositories.". +- **Inbox card**: header with the unread count badge and **Mark all read**; then every non-archived + notification item. Rows show the actor avatar, a kind icon, the subject, the kind label, the repo + name, a relative time, and an unread dot (the subject is semibold while unread). Empty state: + "You're all caught up." with `IconName::Inbox`. +- **Continue where you left off**: every event in the activity list, each row a kind icon, subject, + kind label, repo name, and relative time. -No greeting header. The **Unread** and **Archived** header buttons belong to Phase 3 and are not +No greeting header, and no **My repositories** column - the sidebar already lists the user's +repositories. The **Unread** and **Archived** header buttons belong to Phase 3 and are not rendered until `add_bottom_panel` / `InboxFilterView` exist (see 5.2). ### 5.2 Unread / Archived as bottom-dock panels @@ -502,7 +509,7 @@ pub fn add_bottom_panel( The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then: - New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the - `Entity`. It renders the matching subset of `InboxStore::notifications` as a list. + `Entity`. It renders the matching subset of the panel's notifications as a list. - The **Unread** and **Archived** header buttons in `InboxView` (added in Phase 3) call `add_bottom_panel` with the requested mode. `InboxView` keeps `filter_view: Option>`; when it already exists, update its mode and @@ -523,7 +530,7 @@ In `views/sidebar/mod.rs`: .on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))), ``` -- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge follows the count the store +- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge follows the count the panel publishes. ### 5.4 Click-through (P1) @@ -566,12 +573,12 @@ patch-root click opens the repo panel. Note as a known limitation. | `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests | | `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports | | `crates/signed_state/Cargo.toml` | add `serde_json` | -| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state) and panel-scoped `InboxStore` (query, grouping, activity, actions) | +| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state, mark actions) and `query_inbox` (query, grouping, activity) | | `crates/signed_state/src/backend.rs` | `inbox: Entity` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import | | `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users | -| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` (no global install) | +| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) | | `crates/dock/src/lib.rs` | `add_bottom_panel` helper | -| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning `Entity` (`InboxFilterView` is Phase 3) | +| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning the derived lists directly (`InboxFilterView` is Phase 3) | | `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` | | `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge, `create_repo_dialog` visibility | | `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) | @@ -594,7 +601,7 @@ activates the `Inbox` child entity at each signer transition. 2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the implementation notes below. -3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge. +3. **Phase 2 - screen**: `InboxView` (inbox + activity), sidebar nav and badge. **DONE.** See the implementation notes below. 4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. 5. **Phase 4 - click-through**: `open_item` and announcement lookup. @@ -641,7 +648,7 @@ and two additions to `crates/signed_core/src/inbox.rs`. - Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`), re-derives the groups locally so the UI updates immediately, then persists in the background. -- The store keeps no derived state. The signing key is generated per save, the current user is read +- The global `Inbox` keeps no derived state. The signing key is generated per save, the current user is read from `Backend::current_user()` where needed, and the relays of the user's own repositories are queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic either: the newest state event is selected by `created_at`. @@ -654,26 +661,30 @@ and two additions to `crates/signed_core/src/inbox.rs`. Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes. -- `InboxView` is a plain center panel like `RepoListView`. It owns an `Entity` and - drives it; the sidebar holds a `WeakEntity` so there is no cycle. The panel's `Backend` - subscription also resets `show_all` on a signer change. Re-rendering relies on GPUI's render-time - entity tracking rather than explicit observations. -- The layout is one `overflow_y_scrollbar` container with a two-column `h_flex`. The left column - (`flex_1`, `min_w_0`) stacks the inbox card over the activity card; the right column is fixed at - 300px. Each card is a bordered rounded `v_flex` with a header bar (`section`). +- `InboxView` is a plain center panel like `RepoListView`; the sidebar holds a + `WeakEntity` so there is no cycle. Re-rendering relies on GPUI's render-time entity + tracking rather than explicit observations. (Phase 2 introduced an `Entity` here; it + was later folded into the panel - see the store-merge note below.) +- The layout is a column of two flexible bordered cards (`gap_4`, `p_4`, each `flex_1`/`min_h_0`), + inbox over activity. Each card is a rounded `v_flex` with a header bar (`section`) and a + `gpui::list` body. There is no **My repositories** column: the sidebar already lists the user's + repositories, so the panel is a single column. - Notification rows read the newest event of each group for the actor, subject and time, and the root's kind for the icon. The repo name is resolved from `item.address` through a linear scan of `RepoListStore::announcements` (`repo_name`); the list is small and this keeps the store unchanged. - The **Unread** / **Archived** header buttons are intentionally absent: they need - `add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is **Mark all read** plus the - inline **Show all** toggle, so the panel is fully usable on its own. + `add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is only **Mark all read**, + so the panel is fully usable on its own. - `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm. - Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox nav item is repointed and carries a `CountBadge` suffix driven by the observed unread count. The screen is still opened by the nav item, not on app startup, matching the "idle until signer" rule; auto-opening it as the post-login home is a possible follow-up. -- `create_repo_dialog` became `pub(crate) mod` in `sidebar` so the inbox New button reuses it. +- The **My repositories** column (search `InputState`, **New** button, `open_repo_panel` rows) was + removed after Phase 2 as redundant with the sidebar, along with the panel's `dock_area`, + `open_repo` / `open_create_repo` helpers and the `create_repo_dialog` / `open_repo_panel` imports. + `InboxView::new` now takes only `cx`. `create_repo_dialog` is private again. - `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets` succeeds. `cargo test -p signed_core` (68) and `cargo test -p signed_state` (24) still pass. @@ -685,30 +696,42 @@ feed it. - The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, and the `unread_count` the sidebar badge reads, plus the NIP-78 load/save and the mark actions. -- `InboxStore` is created by `InboxView` and owns the query, grouping, activity list, refresh gate - and actions. It holds no subscriptions: the panel observes the global `Inbox` and subscribes to - `Backend`, driving the store. Re-renders rely on GPUI's render-time entity tracking. - `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox` updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update. -- `InboxView` observes only its store; the two observations it used to hold moved into the store. -- `signed_core` is unchanged. `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and - `cargo test -p workspace` (7) pass; clippy and `cargo check --workspace --all-targets` are clean. +- The panel became the client-side owner of the derivation, initially through a panel-scoped + `Entity`. +- `signed_core` is unchanged. + +### Store merged into the panel (after Phase 2) + +The `InboxStore` entity was then folded into `InboxView`, since the panel was its only consumer. + +- `InboxView` holds `notifications`, `activity`, `unread_count`, `state`, `state_loaded` and + `RefreshGate` as fields, and the store's methods (`sync_state`, `handle_backend_event`, + `refresh`/`run_refresh`, `regroup`, `publish_unread_count`, `clear`, the mark actions) became panel + methods. The two subscriptions call them directly, with no `update` indirection. +- The database work stayed in `signed_state` as `pub async fn query_inbox(...)`; `RefreshGate` and + `RefreshRequest` are re-exported. The UI crate never queries LMDB directly. +- `mark_read`, `mark_archived` and their `group_events` helper carry a scoped `#[allow(dead_code)]` + until the Phase 3 sub-views wire them up. +- `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and `cargo test -p workspace` + (7) pass; clippy and `cargo check --workspace --all-targets` are clean. Trade-off: the sidebar badge is only current after the inbox is opened once, because the unread -count is derived by the panel-scoped store. +count is derived by the panel. ## 8. Validation - `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip. -- `cargo test -p signed_state` (24 tests): the `Inbox` / `InboxStore` paths that do not need GPUI +- `cargo test -p signed_state` (24 tests): the `Inbox` / `query_inbox` paths that do not need GPUI (state round-trip, grouping helpers). - `cargo test -p workspace` (7 tests): repository-detail helpers. - `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and `cargo check --workspace --all-targets` after each phase. - Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel - populates from another identity's issue/comment, the activity list shows your own items, the - repositories panel matches the sidebar, and that no kind-30078 event is broadcast (watch the - relays / `Published` events). Restart to confirm the read state is read back from LMDB. + populates from another identity's issue/comment, the activity list shows your own items, and that no + kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read + state is read back from LMDB. ## 9. SDK APIs used (verified in the pinned `5c669a4` checkout) -- 2.54.0 From 568b6c0e415438cefa9c1c7e185c6d09ca19e96a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 11 Sep 2026 10:46:31 +0700 Subject: [PATCH 05/10] update --- crates/dock/src/lib.rs | 13 + crates/workspace/src/views/inbox.rs | 291 +++++++++++++++++- crates/workspace/src/views/mod.rs | 2 +- crates/workspace/src/views/repo_detail/mod.rs | 50 ++- crates/workspace/src/views/sidebar/mod.rs | 2 +- docs/inbox-plan.md | 165 +++++++--- 6 files changed, 461 insertions(+), 62 deletions(-) diff --git a/crates/dock/src/lib.rs b/crates/dock/src/lib.rs index 6c262af..005b376 100644 --- a/crates/dock/src/lib.rs +++ b/crates/dock/src/lib.rs @@ -26,6 +26,19 @@ pub fn add_center_panel( area.add_panel_view(panel, DockPlacement::Center, None, window, cx); } +/// Add an already-wrapped panel handle to the bottom dock of `area`. +/// +/// Used for sub-views that hang under the center, such as the inbox's Unread +/// and Archived lists. +pub fn add_bottom_panel( + area: &mut DockArea, + panel: Arc, + window: &mut Window, + cx: &mut Context, +) { + area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx); +} + /// The fixed height of the tab bar, which doubles as the window title bar. pub const TAB_BAR_HEIGHT: Pixels = px(44.); diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index acd6826..7a39724 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -3,12 +3,14 @@ use std::time::Duration; use anyhow::Error; use assets::CustomIconName; -use dock::{BasePanel, Panel, PanelEvent}; +use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, add_bottom_panel, panel_handle}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState, - Pixels, Render, SharedString, Subscription, Task, Window, div, list, px, + AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, + ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, + list, px, }; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::scroll::ScrollableElement; use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; use nostr::prelude::{Event, EventId, Kind}; @@ -21,6 +23,8 @@ use signed_state::{ use signed_ui::{CountBadge, SegmentButton, UserAvatar}; use utils::relative_time; +use super::{RepoItem, open_repo_item, open_repo_panel}; + /// Delay between a refresh request and the actual re-query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); @@ -29,6 +33,11 @@ const LIST_OVERDRAW: Pixels = px(400.); pub struct InboxView { focus_handle: FocusHandle, + /// The dock area the Unread / Archived sub-view is added to. + dock_area: WeakEntity, + /// The open Unread / Archived sub-view, if any. Reused instead of adding a + /// duplicate panel on every header click. + filter_view: Option>, /// Notifications grouped by thread root, newest activity first. notifications: Arc>, /// The user's own recent git activity, newest first. @@ -49,7 +58,7 @@ pub struct InboxView { } impl InboxView { - pub fn new(cx: &mut Context) -> Self { + pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { let backend = Backend::global(cx); let inbox = backend.read(cx).inbox(); let weak = cx.entity().downgrade(); @@ -87,6 +96,8 @@ impl InboxView { Self { focus_handle: cx.focus_handle(), + dock_area, + filter_view: None, notifications: Arc::new(Vec::new()), activity: Arc::new(Vec::new()), unread_count: 0, @@ -100,7 +111,6 @@ impl InboxView { } /// Mark every event in the group rooted at `root` read. - #[allow(dead_code)] // Wired up by the Phase 3 Unread/Archived panels. pub fn mark_read(&mut self, root: EventId, cx: &mut Context) { let Some(me) = Backend::global(cx).read(cx).current_user() else { return; @@ -116,7 +126,6 @@ impl InboxView { } /// Archive the group rooted at `root`. - #[allow(dead_code)] // Wired up by the Phase 3 Unread/Archived panels. pub fn mark_archived(&mut self, root: EventId, cx: &mut Context) { let Some(me) = Backend::global(cx).read(cx).current_user() else { return; @@ -142,6 +151,35 @@ impl InboxView { inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx)); } + /// Show `mode` in the bottom dock, reusing the panel when it is already open. + fn open_filter(&mut self, mode: InboxFilter, window: &mut Window, cx: &mut Context) { + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + if let Some(filter) = self.filter_view.as_ref().and_then(WeakEntity::upgrade) { + filter.update(cx, |filter, cx| filter.set_mode(mode, cx)); + + let handle = filter.read(cx).focus_handle.clone(); + window.focus(&handle, cx); + + dock_area.update(cx, |dock_area, cx| { + if !dock_area.is_dock_open(DockPlacement::Bottom) { + dock_area.toggle_dock(DockPlacement::Bottom, window, cx); + } + }); + return; + } + + let inbox = cx.entity(); + let panel = cx.new(|cx| InboxFilterView::new(mode, inbox, cx)); + self.filter_view = Some(panel.downgrade()); + + dock_area.update(cx, |dock_area, cx| { + add_bottom_panel(dock_area, panel_handle(panel), window, cx); + }); + } + /// Re-derive from the global state when it is loaded or changes. pub fn sync_state(&mut self, cx: &mut Context) { let inbox = Backend::global(cx).read(cx).inbox(); @@ -307,7 +345,6 @@ impl InboxView { } /// Events of the group rooted at `root`. - #[allow(dead_code)] // Only used by the Phase 3 mark actions. fn group_events(&self, root: EventId) -> Option> { self.notifications .iter() @@ -368,6 +405,20 @@ impl InboxView { ) .when(unread > 0, |this| this.child(CountBadge::new(unread))) .child(div().flex_1()) + .child( + SegmentButton::new("inbox-unread", "Unread") + .icon(Icon::new(IconName::Inbox).small()) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_filter(InboxFilter::Unread, window, cx); + })), + ) + .child( + SegmentButton::new("inbox-archived", "Archived") + .icon(Icon::new(IconName::FolderClosed).small()) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_filter(InboxFilter::Archived, window, cx); + })), + ) .child( SegmentButton::new("mark-all-read", "Mark all read") .on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))), @@ -377,11 +428,22 @@ impl InboxView { empty_state(IconName::Inbox, "You're all caught up.", cx) } else { let list_state = self.notifications_list.clone(); + let dock_area = self.dock_area.clone(); let rows = list(list_state.clone(), move |ix, _window, cx| { let Some(item) = visible.get(ix).and_then(|&ix| notifications.get(ix)) else { return div().into_any_element(); }; - notification_row(ix, item, cx) + + let root = item.root; + let kind = item.root_kind; + let address = item.address.clone(); + let dock_area = dock_area.clone(); + + notification_row("inbox-row", ix, item, cx) + .on_click(move |_, window, cx| { + open_item(&dock_area, root, kind, address.clone(), window, cx); + }) + .into_any_element() }) .size_full() .min_h_0(); @@ -451,10 +513,56 @@ fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option { .map(display_name) } +/// Open the repository of a notification group, and the issue or pull request +/// detail when the group's root is one. +/// +/// The group carries only the repository coordinate, so the announcement is +/// looked up in the local list. A group whose repository is not known locally +/// opens nothing. +fn open_item( + dock_area: &WeakEntity, + root: EventId, + kind: Option, + address: Option, + window: &mut Window, + cx: &mut App, +) { + let Some(address) = address else { + return; + }; + + let Some(announcement) = RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|announcement| announcement.addr() == address) + .cloned() + else { + return; + }; + + let detail = open_repo_panel(dock_area, &announcement, window, cx); + let Some(store) = detail.read(cx).store() else { + return; + }; + + let item = match kind { + Some(Kind::GitIssue) => RepoItem::Issue(root), + Some(Kind::GitPullRequest) => RepoItem::PullRequest(root), + Some(Kind::GitPatch) => RepoItem::Patch, + _ => return, + }; + + open_repo_item(dock_area, store, item, window, cx); +} + /// Leading row of a notification group, newest event first. -fn notification_row(ix: usize, item: &InboxItem, cx: &App) -> AnyElement { +/// +/// `prefix` scopes the row's element id, so the inbox list and the Unread / +/// Archived list do not collide when both are on screen. +fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) -> Stateful
{ let Some(newest) = item.events.first() else { - return div().into_any_element(); + return div().id((prefix, ix)); }; let profiles = ProfileStore::global(cx); @@ -466,7 +574,7 @@ fn notification_row(ix: usize, item: &InboxItem, cx: &App) -> AnyElement { let unread = item.is_unread(); h_flex() - .id(("inbox-row", ix)) + .id((prefix, ix)) .w_full() .gap_3() .px_3() @@ -511,7 +619,6 @@ fn notification_row(ix: usize, item: &InboxItem, cx: &App) -> AnyElement { .bg(cx.theme().primary), ) }) - .into_any_element() } /// One row of the user's own recent git activity. @@ -634,6 +741,166 @@ fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement { .into_any_element() } +/// Which subset of the inbox a bottom-dock sub-view shows. +#[derive(Clone, Copy, PartialEq, Eq)] +enum InboxFilter { + Unread, + Archived, +} + +impl InboxFilter { + /// Tab and empty-state label of the sub-view. + fn label(self) -> &'static str { + match self { + Self::Unread => "Unread", + Self::Archived => "Archived", + } + } + + /// Whether `item` belongs in this sub-view. + fn matches(self, item: &InboxItem) -> bool { + match self { + Self::Unread => item.is_unread(), + Self::Archived => item.archived, + } + } +} + +/// The Unread / Archived sub-view of the inbox, opened in the bottom dock. +struct InboxFilterView { + focus_handle: FocusHandle, + /// The inbox panel whose groups are filtered. A strong handle: the panel + /// keeps only a weak one back, so the two entities do not form a cycle. + inbox: Entity, + mode: InboxFilter, + list: ListState, +} + +impl InboxFilterView { + fn new(mode: InboxFilter, inbox: Entity, cx: &mut Context) -> Self { + let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); + let weak = cx.entity().downgrade(); + list.set_scroll_handler(move |_, _, cx| { + let weak = weak.clone(); + cx.defer(move |cx| { + let _ = weak.update(cx, |_, cx| cx.notify()); + }); + }); + + Self { + focus_handle: cx.focus_handle(), + inbox, + mode, + list, + } + } + + /// Switch which subset is shown, when the header asks for another mode. + fn set_mode(&mut self, mode: InboxFilter, cx: &mut Context) { + if self.mode == mode { + return; + } + self.mode = mode; + cx.notify(); + } +} + +impl BasePanel for InboxFilterView { + fn panel_name(&self) -> &'static str { + "inbox_filter" + } +} + +impl Panel for InboxFilterView { + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().text_sm().child(SharedString::from(self.mode.label())) + } +} + +impl EventEmitter for InboxFilterView {} + +impl Focusable for InboxFilterView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for InboxFilterView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let mode = self.mode; + let notifications = self.inbox.read(cx).notifications.clone(); + + let visible: Vec = notifications + .iter() + .enumerate() + .filter(|(_, item)| mode.matches(item)) + .map(|(ix, _)| ix) + .collect(); + + if self.list.item_count() != visible.len() { + self.list.reset(visible.len()); + } + + let list_state = self.list.clone(); + let inbox = self.inbox.clone(); + + let body: AnyElement = if visible.is_empty() { + let (icon, message) = match mode { + InboxFilter::Unread => (IconName::Inbox, "Nothing unread."), + InboxFilter::Archived => (IconName::FolderClosed, "Nothing archived."), + }; + empty_state(icon, message, cx) + } else { + let rows = list(list_state.clone(), move |ix, _window, cx| { + let Some(item) = visible.get(ix).and_then(|&ix| notifications.get(ix)) else { + return div().into_any_element(); + }; + + match mode { + InboxFilter::Unread => { + let root = item.root; + let open = inbox.clone(); + let archive = inbox.clone(); + + notification_row("inbox-filter-row", ix, item, cx) + .on_click(move |_, _, cx| { + open.update(cx, |view, cx| view.mark_read(root, cx)); + }) + .child( + Button::new(("inbox-filter-archive", ix)) + .icon(IconName::FolderClosed) + .small() + .ghost() + .tab_stop(false) + .tooltip("Archive") + .on_click(move |_, _, cx| { + cx.stop_propagation(); + archive.update(cx, |view, cx| view.mark_archived(root, cx)); + }), + ) + .into_any_element() + } + InboxFilter::Archived => { + notification_row("inbox-filter-row", ix, item, cx).into_any_element() + } + } + }) + .size_full() + .min_h_0(); + + div() + .relative() + .flex_1() + .min_h_0() + .child(rows) + .vertical_scrollbar(&list_state) + .into_any_element() + }; + + v_flex().size_full().min_h_0().p_2().child(body) + } +} + impl BasePanel for InboxView { fn panel_name(&self) -> &'static str { "inbox" diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 6659761..eb79c7e 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -6,6 +6,6 @@ pub(crate) mod sidebar; pub use inbox::InboxView; pub use repo_detail::RepoDetailView; -pub(crate) use repo_detail::open_repo_panel; +pub(crate) use repo_detail::{RepoItem, open_repo_item, open_repo_panel}; pub use repo_list::RepoListView; pub use sidebar::SidebarPanel; diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 8f4f4dc..575ef3c 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Component, Path, PathBuf}; use std::rc::Rc; +use std::sync::Arc; use std::time::Duration; use anyhow::Error; @@ -13,6 +14,7 @@ use gpui::{ Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size, transparent_white, }; +use gpui_base::dock::PanelView; use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_component::alert::Alert; use gpui_component::button::{Button, ButtonVariants}; @@ -24,7 +26,7 @@ use gpui_component::{ ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, VirtualListScrollHandle, h_flex, v_flex, }; -use nostr::prelude::{RelayUrl, ToBech32, Url}; +use nostr::prelude::{EventId, RelayUrl, ToBech32, Url}; use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; use signed_git::{CommitList, FileCommit}; use signed_state::{ @@ -57,7 +59,9 @@ use helpers::{ ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, ref_selector_trigger, tree_items, }; +use issue_detail::IssueDetailView; use issues::{IssuesView, open_new_issue_dialog}; +use pull_request_detail::PullRequestDetailView; use pull_requests::PullRequestsView; use send_patch::open_send_patch_panel; @@ -227,6 +231,12 @@ impl RepoDetailView { view } + /// The per-repository nostr store, so another panel can open one of its + /// items. `None` until a local repository is initialized to NIP-34. + pub(crate) fn store(&self) -> Option> { + self.store.clone() + } + /// Open a local repository discovered by the scan. pub fn new_local( dock_area: WeakEntity, @@ -2649,3 +2659,41 @@ pub(crate) fn open_repo_panel( detail } + +/// An item of a repository to open from outside its detail panel. +/// A patch has no detail view in Signed, so it opens nothing. +pub(crate) enum RepoItem { + Issue(EventId), + PullRequest(EventId), + Patch, +} + +/// Open the detail panel of `item` in `store`'s repository, in the dock's center. +/// +/// A patch opens nothing: patches are only consumed inside a pull request's +/// detail panel, and have no panel of their own. +pub(crate) fn open_repo_item( + dock_area: &WeakEntity, + store: Entity, + item: RepoItem, + window: &mut Window, + cx: &mut App, +) { + let panel: Arc = match item { + RepoItem::Issue(issue_id) => { + panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) + } + RepoItem::PullRequest(pr_id) => panel_handle( + cx.new(|cx| PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)), + ), + RepoItem::Patch => return, + }; + + let Some(dock_area) = dock_area.upgrade() else { + return; + }; + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel, window, cx); + }); +} diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 766fc76..502bf10 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -224,7 +224,7 @@ impl SidebarPanel { return; } - let panel = cx.new(InboxView::new); + let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), cx)); self.inbox = Some(panel.downgrade()); let _ = self.dock_area.update(cx, |dock_area, cx| { diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md index 81ffb3a..4e32f7d 100644 --- a/docs/inbox-plan.md +++ b/docs/inbox-plan.md @@ -6,13 +6,13 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for > page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `` when > an account is active, and that home screen is the inbox. -> **Status.** Phases 0, 1 and 2 are implemented and green on `feat/inbox`: +> **Status.** Phases 0-4 are implemented and green on `feat/inbox`: > `cargo test -p signed_core` (68), `cargo test -p signed_state` (24), > `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean, > `cargo check --workspace --all-targets` succeeds. -> Phases 3-5 are not started. This document reflects the implementation as it stands, including the -> Phase 1 refactors and the §4.3 split of the inbox into a thin global `Inbox` and a -> panel-owned derivation. +> Phase 5 is not started. This document reflects the implementation as it stands, including the +> Phase 1 refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned +> derivation, the Phase 3 bottom-dock sub-views, and the Phase 4 click-through. ## 1. What the GitWorkshop home screen is @@ -479,16 +479,17 @@ definite viewport height. The list counts are reset from `render` whenever the r changes. Row ids are prefixed (`("inbox-row", ix)` / `("activity-row", ix)`) so the two lists do not collide. -- **Inbox card**: header with the unread count badge and **Mark all read**; then every non-archived - notification item. Rows show the actor avatar, a kind icon, the subject, the kind label, the repo - name, a relative time, and an unread dot (the subject is semibold while unread). Empty state: - "You're all caught up." with `IconName::Inbox`. +- **Inbox card**: header with the unread count badge, the **Unread** and **Archived** sub-view buttons + (Phase 3) and **Mark all read**; then every non-archived notification item. Rows show the actor + avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread dot + (the subject is semibold while unread). Empty state: "You're all caught up." with + `IconName::Inbox`. - **Continue where you left off**: every event in the activity list, each row a kind icon, subject, kind label, repo name, and relative time. No greeting header, and no **My repositories** column - the sidebar already lists the user's -repositories. The **Unread** and **Archived** header buttons belong to Phase 3 and are not -rendered until `add_bottom_panel` / `InboxFilterView` exist (see 5.2). +repositories. The **Unread** and **Archived** header buttons open the sub-views of 5.2 in the bottom +dock. ### 5.2 Unread / Archived as bottom-dock panels @@ -508,12 +509,20 @@ pub fn add_bottom_panel( The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then: -- New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the - `Entity`. It renders the matching subset of the panel's notifications as a list. -- The **Unread** and **Archived** header buttons in `InboxView` (added in Phase 3) call - `add_bottom_panel` with the requested mode. `InboxView` keeps - `filter_view: Option>`; when it already exists, update its mode and - focus instead of adding a duplicate. Until then the inbox header has only **Mark all read**. +- `InboxFilterView` is a bottom-dock panel holding an `Entity` and a mode + `InboxFilter::Unread | InboxFilter::Archived`. It renders the matching subset of the panel's + notifications as a `gpui::list`, with the same rows as the inbox card. The mode filters on + `InboxItem::is_unread()` / `InboxItem::archived` and picks the tab title and empty state. It reads + the inbox entity during render, so GPUI's render-time tracking re-renders it whenever the inbox + re-derives; it needs no subscription of its own. +- The **Unread** and **Archived** header buttons in `InboxView` call `InboxView::open_filter`, which + keeps `filter_view: Option>`. When the panel already exists it updates + its mode, focuses it, and reopens the bottom dock if the user collapsed it, instead of adding a + duplicate. Otherwise it creates the panel and adds it to the bottom dock. +- **Unread rows**: clicking a row marks that group read (`InboxView::mark_read`); a trailing ghost + icon button archives it (`InboxView::mark_archived`). Both call back into the `InboxView` entity, + which updates the global `Inbox`. **Archived rows** are display-only, since the read state has no + un-archive operation. ### 5.3 Sidebar @@ -522,6 +531,7 @@ In `views/sidebar/mod.rs`: - Add `inbox: Option>` (mirrors `explore`) and `unread: usize`. - Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab). + `InboxView::new` takes the sidebar's `WeakEntity` so the panel can open its sub-view. - Point the existing nav item at it and add an unread suffix: ```rust @@ -535,34 +545,39 @@ In `views/sidebar/mod.rs`: ### 5.4 Click-through (P1) -Reuse the single `RepoStore` that `RepoDetailView` already creates instead of making a second one: +Reuse the single `RepoStore` that `RepoDetailView` already creates instead of making a second one. +The detail panels need a `Window`, and GPUI's `Entity::update_in` only exists on a `VisualContext`, +which a synchronous `App` + `Window` pair is not - so the entry point is a free function rather than +a `RepoDetailView::open_item` method. In `repo_detail/mod.rs`: -1. In `repo_detail/mod.rs`, add: +```rust +pub(crate) enum RepoItem { + Issue(EventId), + PullRequest(EventId), + Patch, +} - ```rust - pub(crate) enum RepoItem { - Issue(EventId), - PullRequest(EventId), - Patch(EventId), - } +pub(crate) fn open_repo_item( + dock_area: &WeakEntity, + store: Entity, + item: RepoItem, + window: &mut Window, + cx: &mut App, +) { /* new IssueDetailView / PullRequestDetailView, added to the center */ } +``` - impl RepoDetailView { - pub(crate) fn open_item( - &mut self, - item: RepoItem, - window: &mut Window, - cx: &mut Context, - ) { /* open IssueDetailView / PullRequestDetailView in the dock */ } - } - ``` - -2. `open_repo_panel` already returns `Entity`; the caller invokes - `detail.update_in(window, cx, |detail, window, cx| detail.open_item(...))`. -3. `InboxView` resolves `item.address` to an `Announcement` from `RepoListStore`, opens the repo - panel, then calls `open_item` with the root id and kind. +- `RepoDetailView::store()` exposes its `Option>`, so the caller reuses the repo + panel's store rather than building one. `views/mod.rs` re-exports `RepoItem` and `open_repo_item`. +- `InboxView` resolves `item.address` to an `Announcement` from `RepoListStore`, calls + `open_repo_panel` (which returns `Entity`), takes its store, and calls + `open_repo_item` with the root id and kind. The detail panel renders a "not found" placeholder + until the store's fetch lands, then re-renders. +- The repository panel and the detail panel are two tabs of the center group; the detail is + activated. This matches the sidebar, which also opens a fresh repo panel per click. Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a -patch-root click opens the repo panel. Note as a known limitation. +patch-root click opens the repo panel only. `RepoItem::Patch` carries no id for that reason. A group +whose root is not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing. ## 6. File-by-file change list @@ -578,13 +593,10 @@ patch-root click opens the repo panel. Note as a known limitation. | `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users | | `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) | | `crates/dock/src/lib.rs` | `add_bottom_panel` helper | -| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning the derived lists directly (`InboxFilterView` is Phase 3) | -| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` | -| `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge, `create_repo_dialog` visibility | -| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) | - -`create_repo_dialog` changes from private (`mod`) to `pub(crate) mod` inside `sidebar`, so the inbox's -New button can open it. +| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning the derived lists directly, the `InboxFilterView` bottom-dock sub-view for Unread / Archived, and the notification click-through | +| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` | +| `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge | +| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item`, `RepoDetailView::store()` | No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox` activates the `Inbox` child entity at each signer transition. @@ -604,7 +616,9 @@ activates the `Inbox` child entity at each signer transition. 3. **Phase 2 - screen**: `InboxView` (inbox + activity), sidebar nav and badge. **DONE.** See the implementation notes below. 4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. -5. **Phase 4 - click-through**: `open_item` and announcement lookup. + **DONE.** See the implementation notes below. +5. **Phase 4 - click-through**: `open_item` and announcement lookup. **DONE.** See the implementation + notes below. 6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail view. @@ -720,6 +734,63 @@ The `InboxStore` entity was then folded into `InboxView`, since the panel was it Trade-off: the sidebar badge is only current after the inbox is opened once, because the unread count is derived by the panel. +### Phase 3 implementation notes + +Files: `crates/dock/src/lib.rs` and `crates/workspace/src/views/{inbox.rs, sidebar/mod.rs}`. No +store changes. + +- `add_bottom_panel` sits next to `add_center_panel` and wraps + `DockArea::add_panel_view(panel, DockPlacement::Bottom, None, ...)`. A new bottom dock starts open, + and the workspace's existing `DockEvent::LayoutChanged` subscription removes an emptied bottom dock, + so a closed sub-view leaves no strip behind. +- `InboxFilterView` is private to `views/inbox.rs`. It holds an `Entity` (strong; the + panel keeps only the weak `filter_view` back, so there is no cycle), the mode, and its own + `ListState`. There is no subscription: it reads the inbox entity during render, which is enough for + GPUI to invalidate the window when the inbox notifies. +- `InboxFilter` is a private two-variant enum with `label()` and `matches(&InboxItem)`. The tab title + comes from `Panel::title`, so switching modes through `set_mode` retitles the same tab instead of + opening a second one. +- `InboxView` regained a `dock_area: WeakEntity` (removed with the My-repositories column) + and takes it in `new`. `open_filter` reuses the existing panel, focuses it, and reopens the bottom + dock when it is collapsed; otherwise it creates and adds the panel. `InboxView::new` is now called + as `InboxView::new(self.dock_area.clone(), cx)` from `SidebarPanel::open_inbox`. +- The three `#[allow(dead_code)]` markers on `mark_read`, `mark_archived` and `group_events` are gone: + Unread rows call `mark_read` on click and `mark_archived` from a trailing ghost icon button + (`Button` + `IconName::FolderClosed`, tooltip "Archive"). The button calls `cx.stop_propagation()` + so it does not also trigger the row's mark-read click. Archived rows are display-only; the read + state has no un-archive operation. +- `notification_row` takes an id `prefix` and returns `Stateful
` rather than `AnyElement`, so + callers can attach a click handler and a trailing action. The inbox list passes `"inbox-row"` and + the sub-view `"inbox-filter-row"`, because the two lists render in the same window and would + otherwise collide on `(str, ix)` ids. +- `cargo clippy -p workspace -p dock --all-targets` is clean, `cargo check --workspace --all-targets` + succeeds, and `cargo test -p signed_core -p signed_state -p workspace` passes (68 / 24 / 7). + +### Phase 4 implementation notes + +Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No store changes. + +- `RepoItem { Issue(EventId), PullRequest(EventId), Patch }` and `pub(crate) fn open_repo_item` live + in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` takes the store as a + parameter, avoiding a second `RepoStore`. +- It is a free function, not `RepoDetailView::open_item`: the detail constructors take a `Window`, and + a synchronous `&mut App` + `&mut Window` pair is not a `VisualContext`, so `Entity::update_in` is + not available. `InboxView` already has the window in the list's `on_click`, so it drives the free + function directly. The plan's original `detail.update_in(window, cx, ...)` sketch could not compile. +- `RepoDetailView::store()` (`pub(crate)`) exposes the panel's `Option>`. The repo + panel is opened first and its store reused, so the detail panel shares one store with the repo it + came from. +- `InboxView::open_item` is also a free function (it needs nothing but `dock_area`, which it captures + from the panel) because the `gpui::list` item closure only receives `&mut App`. It resolves + `item.address` through `RepoListStore`, returns silently when the repository is unknown, opens the + repo panel, then maps the root kind to a `RepoItem` and calls `open_repo_item`. +- Only the main notification list is clickable. Unread rows keep their Phase 3 behaviour (click marks + read, trailing button archives). Activity rows are unchanged. +- `RepoItem::Patch` is a unit variant because the id would be unused: patches have no detail panel, so + `open_repo_item` returns before doing anything and only the repository panel opens. +- `cargo clippy -p workspace --all-targets` is clean, `cargo check --workspace --all-targets` succeeds, + and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1). + ## 8. Validation - `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip. -- 2.54.0 From 625587fd523fd7e681be8f95148ed992a7166c9d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 11 Sep 2026 15:08:24 +0700 Subject: [PATCH 06/10] . --- crates/workspace/src/views/inbox.rs | 312 +++------------------------- 1 file changed, 27 insertions(+), 285 deletions(-) diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index 7a39724..fb7ee3e 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -3,14 +3,12 @@ use std::time::Duration; use anyhow::Error; use assets::CustomIconName; -use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, add_bottom_panel, panel_handle}; +use dock::{BasePanel, DockArea, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, - ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, - list, px, + AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState, + Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, list, px, }; -use gpui_component::button::{Button, ButtonVariants}; use gpui_component::scroll::ScrollableElement; use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; use nostr::prelude::{Event, EventId, Kind}; @@ -33,11 +31,7 @@ const LIST_OVERDRAW: Pixels = px(400.); pub struct InboxView { focus_handle: FocusHandle, - /// The dock area the Unread / Archived sub-view is added to. dock_area: WeakEntity, - /// The open Unread / Archived sub-view, if any. Reused instead of adding a - /// duplicate panel on every header click. - filter_view: Option>, /// Notifications grouped by thread root, newest activity first. notifications: Arc>, /// The user's own recent git activity, newest first. @@ -49,11 +43,11 @@ pub struct InboxView { /// Set once the global state has been read for the current user. state_loaded: bool, refresh: RefreshGate, - /// Virtual-list state of the notification list, kept in sync with the - /// rendered (non-archived) notifications. + /// Virtual-list state of the notification list. notifications_list: ListState, /// Virtual-list state of the activity list. activity_list: ListState, + tasks: Vec>>, _subscriptions: Vec, } @@ -77,27 +71,26 @@ impl InboxView { } // Drive the lists from the global inbox state and from backend events. - let _subscriptions = vec![ - cx.observe(&inbox, |this, _inbox, cx| this.sync_state(cx)), - cx.subscribe(&backend, |this, _backend, event, cx| { - this.handle_backend_event(event, cx); - }), - ]; + let mut subscriptions = vec![]; + + subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| { + this.sync_state(cx); + })); + + subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { + this.handle_backend_event(event, cx); + })); // Derive the lists once the panel exists. - cx.defer({ - let weak = weak.clone(); - move |cx| { - if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) { - log::warn!("inbox dropped before bootstrap could run: {error}"); - } + cx.defer(move |cx| { + if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) { + log::warn!("inbox dropped before bootstrap could run: {error}"); } }); Self { focus_handle: cx.focus_handle(), dock_area, - filter_view: None, notifications: Arc::new(Vec::new()), activity: Arc::new(Vec::new()), unread_count: 0, @@ -106,80 +99,21 @@ impl InboxView { refresh: RefreshGate::default(), notifications_list, activity_list, - _subscriptions, + tasks: vec![], + _subscriptions: subscriptions, } } - /// Mark every event in the group rooted at `root` read. - pub fn mark_read(&mut self, root: EventId, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; - - let Some(group) = self.group_events(root) else { - return; - }; - - let all = self.all_notification_events(); - let inbox = Backend::global(cx).read(cx).inbox(); - inbox.update(cx, |inbox, cx| inbox.mark_read(&group, &all, me, cx)); - } - - /// Archive the group rooted at `root`. - pub fn mark_archived(&mut self, root: EventId, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; - - let Some(group) = self.group_events(root) else { - return; - }; - - let all = self.all_notification_events(); - let inbox = Backend::global(cx).read(cx).inbox(); - inbox.update(cx, |inbox, cx| inbox.mark_archived(&group, &all, me, cx)); - } - /// Mark every known notification read. pub fn mark_all_read(&mut self, cx: &mut Context) { let Some(me) = Backend::global(cx).read(cx).current_user() else { return; }; - let all = self.all_notification_events(); let inbox = Backend::global(cx).read(cx).inbox(); inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx)); } - /// Show `mode` in the bottom dock, reusing the panel when it is already open. - fn open_filter(&mut self, mode: InboxFilter, window: &mut Window, cx: &mut Context) { - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - if let Some(filter) = self.filter_view.as_ref().and_then(WeakEntity::upgrade) { - filter.update(cx, |filter, cx| filter.set_mode(mode, cx)); - - let handle = filter.read(cx).focus_handle.clone(); - window.focus(&handle, cx); - - dock_area.update(cx, |dock_area, cx| { - if !dock_area.is_dock_open(DockPlacement::Bottom) { - dock_area.toggle_dock(DockPlacement::Bottom, window, cx); - } - }); - return; - } - - let inbox = cx.entity(); - let panel = cx.new(|cx| InboxFilterView::new(mode, inbox, cx)); - self.filter_view = Some(panel.downgrade()); - - dock_area.update(cx, |dock_area, cx| { - add_bottom_panel(dock_area, panel_handle(panel), window, cx); - }); - } - /// Re-derive from the global state when it is loaded or changes. pub fn sync_state(&mut self, cx: &mut Context) { let inbox = Backend::global(cx).read(cx).inbox(); @@ -216,7 +150,6 @@ impl InboxView { /// Handle a backend event that can change the derived lists. fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { match event { - BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx), BackendEvent::NostrUpdate(updates) => { let relevant = updates.iter().any(|update| { let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind); @@ -226,10 +159,12 @@ impl InboxView { is_notification || is_comment || is_event_deletion || is_request_to_vanish }); + if relevant { self.refresh(cx); } } + BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx), _ => {} } } @@ -241,7 +176,6 @@ impl InboxView { self.refresh.request(); return; } - self.run_refresh(cx); } @@ -255,11 +189,10 @@ impl InboxView { return; } - cx.spawn(async move |this, cx| { + self.tasks.push(cx.spawn(async move |this, cx| { cx.background_executor().timer(REFRESH_DEBOUNCE).await; this.update(cx, |this, cx| this.run_refresh(cx)) - }) - .detach(); + })); } /// One query and apply cycle, the debounced entry point. @@ -277,10 +210,9 @@ impl InboxView { let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await }); - let task: Task> = cx.spawn(async move |this, cx| { + self.tasks.push(cx.spawn(async move |this, cx| { let (notifications, activity, unread_count) = match work.await { Ok(results) => results, - // Database errors are transient, keep the last lists. Err(error) => { log::warn!("inbox refresh failed: {error}"); return this.update(cx, |this, _cx| this.refresh.abort()); @@ -288,9 +220,7 @@ impl InboxView { }; let again = this.update(cx, |this, cx| { - // The signer may have changed while the query ran, making - // these results belong to the previous user. - if Backend::global(cx).read(cx).current_user() != Some(me) { + if backend.read(cx).current_user() != Some(me) { this.refresh.abort(); return false; } @@ -309,9 +239,7 @@ impl InboxView { } Ok(()) - }); - - task.detach(); + })); } /// Recompute the unread and archived flags from the current state. @@ -344,14 +272,6 @@ impl InboxView { self.refresh = RefreshGate::default(); } - /// Events of the group rooted at `root`. - fn group_events(&self, root: EventId) -> Option> { - self.notifications - .iter() - .find(|item| item.root == root) - .map(|item| item.events.clone()) - } - /// Every event in every group, archived groups included. fn all_notification_events(&self) -> Vec { self.notifications @@ -360,10 +280,6 @@ impl InboxView { .collect() } - /// Bordered card with a header bar and a scrolling body. - /// - /// Flexible so its body gets a definite height, which the virtual list - /// needs to know which rows to render. fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement { v_flex() .w_full() @@ -405,20 +321,6 @@ impl InboxView { ) .when(unread > 0, |this| this.child(CountBadge::new(unread))) .child(div().flex_1()) - .child( - SegmentButton::new("inbox-unread", "Unread") - .icon(Icon::new(IconName::Inbox).small()) - .on_click(cx.listener(|this, _event, window, cx| { - this.open_filter(InboxFilter::Unread, window, cx); - })), - ) - .child( - SegmentButton::new("inbox-archived", "Archived") - .icon(Icon::new(IconName::FolderClosed).small()) - .on_click(cx.listener(|this, _event, window, cx| { - this.open_filter(InboxFilter::Archived, window, cx); - })), - ) .child( SegmentButton::new("mark-all-read", "Mark all read") .on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))), @@ -678,7 +580,7 @@ fn display_name(announcement: &Announcement) -> SharedString { /// Leading icon for a notification or activity kind. fn kind_icon(kind: Kind) -> Icon { if kind == COVER_NOTE_KIND { - return Icon::new(IconName::FileText).small(); + return Icon::new(IconName::File).small(); } match kind { @@ -741,166 +643,6 @@ fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement { .into_any_element() } -/// Which subset of the inbox a bottom-dock sub-view shows. -#[derive(Clone, Copy, PartialEq, Eq)] -enum InboxFilter { - Unread, - Archived, -} - -impl InboxFilter { - /// Tab and empty-state label of the sub-view. - fn label(self) -> &'static str { - match self { - Self::Unread => "Unread", - Self::Archived => "Archived", - } - } - - /// Whether `item` belongs in this sub-view. - fn matches(self, item: &InboxItem) -> bool { - match self { - Self::Unread => item.is_unread(), - Self::Archived => item.archived, - } - } -} - -/// The Unread / Archived sub-view of the inbox, opened in the bottom dock. -struct InboxFilterView { - focus_handle: FocusHandle, - /// The inbox panel whose groups are filtered. A strong handle: the panel - /// keeps only a weak one back, so the two entities do not form a cycle. - inbox: Entity, - mode: InboxFilter, - list: ListState, -} - -impl InboxFilterView { - fn new(mode: InboxFilter, inbox: Entity, cx: &mut Context) -> Self { - let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); - let weak = cx.entity().downgrade(); - list.set_scroll_handler(move |_, _, cx| { - let weak = weak.clone(); - cx.defer(move |cx| { - let _ = weak.update(cx, |_, cx| cx.notify()); - }); - }); - - Self { - focus_handle: cx.focus_handle(), - inbox, - mode, - list, - } - } - - /// Switch which subset is shown, when the header asks for another mode. - fn set_mode(&mut self, mode: InboxFilter, cx: &mut Context) { - if self.mode == mode { - return; - } - self.mode = mode; - cx.notify(); - } -} - -impl BasePanel for InboxFilterView { - fn panel_name(&self) -> &'static str { - "inbox_filter" - } -} - -impl Panel for InboxFilterView { - fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().text_sm().child(SharedString::from(self.mode.label())) - } -} - -impl EventEmitter for InboxFilterView {} - -impl Focusable for InboxFilterView { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for InboxFilterView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let mode = self.mode; - let notifications = self.inbox.read(cx).notifications.clone(); - - let visible: Vec = notifications - .iter() - .enumerate() - .filter(|(_, item)| mode.matches(item)) - .map(|(ix, _)| ix) - .collect(); - - if self.list.item_count() != visible.len() { - self.list.reset(visible.len()); - } - - let list_state = self.list.clone(); - let inbox = self.inbox.clone(); - - let body: AnyElement = if visible.is_empty() { - let (icon, message) = match mode { - InboxFilter::Unread => (IconName::Inbox, "Nothing unread."), - InboxFilter::Archived => (IconName::FolderClosed, "Nothing archived."), - }; - empty_state(icon, message, cx) - } else { - let rows = list(list_state.clone(), move |ix, _window, cx| { - let Some(item) = visible.get(ix).and_then(|&ix| notifications.get(ix)) else { - return div().into_any_element(); - }; - - match mode { - InboxFilter::Unread => { - let root = item.root; - let open = inbox.clone(); - let archive = inbox.clone(); - - notification_row("inbox-filter-row", ix, item, cx) - .on_click(move |_, _, cx| { - open.update(cx, |view, cx| view.mark_read(root, cx)); - }) - .child( - Button::new(("inbox-filter-archive", ix)) - .icon(IconName::FolderClosed) - .small() - .ghost() - .tab_stop(false) - .tooltip("Archive") - .on_click(move |_, _, cx| { - cx.stop_propagation(); - archive.update(cx, |view, cx| view.mark_archived(root, cx)); - }), - ) - .into_any_element() - } - InboxFilter::Archived => { - notification_row("inbox-filter-row", ix, item, cx).into_any_element() - } - } - }) - .size_full() - .min_h_0(); - - div() - .relative() - .flex_1() - .min_h_0() - .child(rows) - .vertical_scrollbar(&list_state) - .into_any_element() - }; - - v_flex().size_full().min_h_0().p_2().child(body) - } -} - impl BasePanel for InboxView { fn panel_name(&self) -> &'static str { "inbox" -- 2.54.0 From 81e421d1219a28a311feb2322b17b15f9131f8a8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 11 Sep 2026 21:35:03 +0700 Subject: [PATCH 07/10] update ui --- crates/signed_state/src/inbox.rs | 23 +- crates/workspace/src/views/inbox.rs | 646 +++++++++++++--------- crates/workspace/src/views/sidebar/mod.rs | 36 +- docs/inbox-plan.md | 302 ++++++---- 4 files changed, 578 insertions(+), 429 deletions(-) diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index 950ba05..51ccf62 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -12,9 +12,7 @@ use crate::backend::Backend; pub struct Inbox { state: InboxReadState, /// Set once the stored state has been read for the current user. - state_loaded: bool, - /// Unread notification groups, published by the inbox panel for the sidebar badge. - pub unread_count: usize, + loaded: bool, } impl Inbox { @@ -25,16 +23,7 @@ impl Inbox { /// Whether the stored state has been read for the current user. pub fn is_loaded(&self) -> bool { - self.state_loaded - } - - /// Publish the unread count derived by the inbox panel. - pub fn set_unread_count(&mut self, count: usize, cx: &mut Context) { - if self.unread_count == count { - return; - } - self.unread_count = count; - cx.notify(); + self.loaded } /// Mark the events of one notification group read, then bound the id sets. @@ -83,8 +72,7 @@ impl Inbox { /// Load the stored state for current user. pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context) { self.state = InboxReadState::default(); - self.state_loaded = false; - self.unread_count = 0; + self.loaded = false; cx.notify(); let backend = Backend::global(cx); @@ -104,7 +92,7 @@ impl Inbox { Err(error) => log::warn!("failed to load inbox state: {error}"), } - this.state_loaded = true; + this.loaded = true; cx.notify(); })?; @@ -116,8 +104,7 @@ impl Inbox { /// Clear the state of the signed-out user. pub(crate) fn reset(&mut self, cx: &mut Context) { self.state = InboxReadState::default(); - self.state_loaded = false; - self.unread_count = 0; + self.loaded = false; cx.notify(); } diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index fb7ee3e..d610a86 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -9,16 +10,16 @@ use gpui::{ AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, list, px, }; -use gpui_component::scroll::ScrollableElement; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; -use nostr::prelude::{Event, EventId, Kind}; +use nostr::prelude::{Event, EventId, Kind, Timestamp}; use signed_core::{ - Announcement, COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters, + COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters, }; use signed_state::{ - Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox, + Backend, BackendEvent, RefreshGate, RefreshRequest, RepoListStore, query_inbox, }; -use signed_ui::{CountBadge, SegmentButton, UserAvatar}; +use signed_ui::CountBadge; use utils::relative_time; use super::{RepoItem, open_repo_item, open_repo_panel}; @@ -29,6 +30,36 @@ const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// Extra list rows measured above and below the visible area. const LIST_OVERDRAW: Pixels = px(400.); +/// A repository's slice of the inbox: its notification groups and own activity. +struct InboxSection { + /// Repository the section groups, `None` for items without one. + address: Option, + /// Number of notification groups with an unread event. + unread: usize, + /// Notification and activity rows, newest first. + entries: Vec, + /// Timestamp of the newest entry, used to order the sections. + latest: Timestamp, +} + +/// One row inside a repository section, as an index into the inbox's own lists. +#[derive(Clone, Copy)] +enum InboxEntry { + /// Index into the notification groups. + Notification(usize), + /// Index into the user's own activity. + Activity(usize), +} + +/// One row of the flattened inbox: a repository header, one of its entries, or +/// the empty state of a repository without any. +#[derive(Clone, Copy)] +enum InboxRow { + Repo(usize), + Entry(usize, usize), + Empty, +} + pub struct InboxView { focus_handle: FocusHandle, dock_area: WeakEntity, @@ -36,6 +67,10 @@ pub struct InboxView { notifications: Arc>, /// The user's own recent git activity, newest first. activity: Arc>, + /// The notification and activity lists grouped by repository, newest first. + sections: Arc>, + /// The flattened repository headers and rows of the list. + rows: Arc>, /// Number of non-archived groups with an unread event. unread_count: usize, /// Copy of the global read state the current lists were derived with. @@ -43,10 +78,7 @@ pub struct InboxView { /// Set once the global state has been read for the current user. state_loaded: bool, refresh: RefreshGate, - /// Virtual-list state of the notification list. - notifications_list: ListState, - /// Virtual-list state of the activity list. - activity_list: ListState, + list: ListState, tasks: Vec>>, _subscriptions: Vec, } @@ -55,22 +87,10 @@ impl InboxView { pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { let backend = Backend::global(cx); let inbox = backend.read(cx).inbox(); + let repos = RepoListStore::global(cx); let weak = cx.entity().downgrade(); - let notifications_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); - let activity_list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); - - for list_state in [¬ifications_list, &activity_list] { - let weak = weak.clone(); - list_state.set_scroll_handler(move |_, _, cx| { - let weak = weak.clone(); - cx.defer(move |cx| { - let _ = weak.update(cx, |_, cx| cx.notify()); - }); - }); - } - - // Drive the lists from the global inbox state and from backend events. + let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); let mut subscriptions = vec![]; subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| { @@ -81,7 +101,14 @@ impl InboxView { this.handle_backend_event(event, cx); })); - // Derive the lists once the panel exists. + // Rebuild when the user's own repositories load or change, + // so a repository without any activity still gets an empty section. + subscriptions.push(cx.observe(&repos, |this, _repos, cx| { + this.rebuild(cx); + cx.notify(); + })); + + // Derive the sections once the panel exists. cx.defer(move |cx| { if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) { log::warn!("inbox dropped before bootstrap could run: {error}"); @@ -93,12 +120,13 @@ impl InboxView { dock_area, notifications: Arc::new(Vec::new()), activity: Arc::new(Vec::new()), + sections: Arc::new(Vec::new()), + rows: Arc::new(Vec::new()), unread_count: 0, state: InboxReadState::default(), state_loaded: false, refresh: RefreshGate::default(), - notifications_list, - activity_list, + list, tasks: vec![], _subscriptions: subscriptions, } @@ -123,8 +151,10 @@ impl InboxView { }; if !loaded { - let was_present = - self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty(); + let was_present = self.state_loaded + || !self.notifications.is_empty() + || !self.activity.is_empty() + || !self.sections.is_empty(); self.clear(); if was_present { cx.notify(); @@ -141,13 +171,12 @@ impl InboxView { if self.state != state { self.state = state; - self.regroup(); - self.publish_unread_count(cx); + self.regroup(cx); cx.notify(); } } - /// Handle a backend event that can change the derived lists. + /// Handle a backend event that can change the derived sections. fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { match event { BackendEvent::NostrUpdate(updates) => { @@ -228,7 +257,7 @@ impl InboxView { this.notifications = Arc::new(notifications); this.activity = Arc::new(activity); this.unread_count = unread_count; - this.publish_unread_count(cx); + this.rebuild(cx); cx.notify(); this.refresh.finish() @@ -243,7 +272,7 @@ impl InboxView { } /// Recompute the unread and archived flags from the current state. - fn regroup(&mut self) { + fn regroup(&mut self, cx: &mut Context) { let mut items = (*self.notifications).clone(); for item in items.iter_mut() { @@ -252,19 +281,46 @@ impl InboxView { self.unread_count = items.iter().filter(|item| item.is_unread()).count(); self.notifications = Arc::new(items); + self.rebuild(cx); } - /// Publish the derived unread count for the sidebar badge. - fn publish_unread_count(&self, cx: &mut Context) { - let count = self.unread_count; - let inbox = Backend::global(cx).read(cx).inbox(); - inbox.update(cx, |inbox, cx| inbox.set_unread_count(count, cx)); + /// Regroup the current lists by repository and flatten them into rows. + fn rebuild(&mut self, cx: &mut Context) { + let backend = Backend::global(cx); + let repo_list = RepoListStore::global(cx); + let mut sections = group_sections(&self.notifications, &self.activity); + + if let Some(me) = backend.read(cx).current_user() { + for announcement in repo_list.read(cx).announcements_of(&me) { + let address = announcement.addr(); + let known = sections + .iter() + .any(|section| section.address.as_ref() == Some(&address)); + + if !known { + sections.push(InboxSection { + address: Some(address), + unread: 0, + entries: Vec::new(), + latest: Timestamp::default(), + }); + } + } + } + + sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); + + let rows = flatten_rows(§ions); + self.sections = Arc::new(sections); + self.rows = Arc::new(rows); } /// Forget everything derived for the current user. fn clear(&mut self) { self.notifications = Arc::new(Vec::new()); self.activity = Arc::new(Vec::new()); + self.sections = Arc::new(Vec::new()); + self.rows = Arc::new(Vec::new()); self.unread_count = 0; self.state = InboxReadState::default(); self.state_loaded = false; @@ -280,147 +336,205 @@ impl InboxView { .collect() } - fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement { - v_flex() - .w_full() - .flex_1() - .min_h_0() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .overflow_hidden() - .child( - div() - .px_3() - .py_2() - .bg(cx.theme().muted.opacity(0.5)) - .border_b_1() - .border_color(cx.theme().border) - .child(header), - ) - .child(body) - .into_any_element() - } + fn render_entry(&self, ix: usize, cx: &App) -> AnyElement { + let Some(row) = self.rows.get(ix) else { + return div().into_any_element(); + }; - fn render_inbox_panel( - &self, - unread: usize, - notifications: Arc>, - visible: Vec, - cx: &mut Context, - ) -> AnyElement { - let header = h_flex() - .w_full() - .gap_2() - .child(Icon::new(IconName::Inbox).small()) - .child( - div() - .text_sm() - .font_semibold() - .child(SharedString::from("Inbox")), - ) - .when(unread > 0, |this| this.child(CountBadge::new(unread))) - .child(div().flex_1()) - .child( - SegmentButton::new("mark-all-read", "Mark all read") - .on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))), - ); - - let body = if visible.is_empty() { - empty_state(IconName::Inbox, "You're all caught up.", cx) - } else { - let list_state = self.notifications_list.clone(); - let dock_area = self.dock_area.clone(); - let rows = list(list_state.clone(), move |ix, _window, cx| { - let Some(item) = visible.get(ix).and_then(|&ix| notifications.get(ix)) else { + match *row { + InboxRow::Empty => empty_section_row(cx), + InboxRow::Repo(section_ix) => { + let Some(section) = self.sections.get(section_ix) else { + return div().into_any_element(); + }; + repo_header(section, cx) + } + InboxRow::Entry(section_ix, entry_ix) => { + let Some(section) = self.sections.get(section_ix) else { return div().into_any_element(); }; - let root = item.root; - let kind = item.root_kind; - let address = item.address.clone(); - let dock_area = dock_area.clone(); - - notification_row("inbox-row", ix, item, cx) - .on_click(move |_, window, cx| { - open_item(&dock_area, root, kind, address.clone(), window, cx); - }) - .into_any_element() - }) - .size_full() - .min_h_0(); - - div() - .relative() - .flex_1() - .min_h_0() - .child(rows) - .vertical_scrollbar(&list_state) - .into_any_element() - }; - - self.section(header, body, cx) - } - - fn render_activity_panel( - &self, - activity: Arc>, - cx: &mut Context, - ) -> AnyElement { - let header = h_flex() - .w_full() - .gap_2() - .child(Icon::new(CustomIconName::Recent).small()) - .child( - div() - .text_sm() - .font_semibold() - .child(SharedString::from("Continue where you left off")), - ); - - let body = if activity.is_empty() { - empty_state(CustomIconName::Recent, "No recent activity.", cx) - } else { - let list_state = self.activity_list.clone(); - let rows = list(list_state.clone(), move |ix, _window, cx| { - let Some(event) = activity.get(ix) else { + let Some(entry) = section.entries.get(entry_ix) else { return div().into_any_element(); }; - activity_row(ix, event, cx) - }) - .size_full() - .min_h_0(); - div() - .relative() - .flex_1() - .min_h_0() - .child(rows) - .vertical_scrollbar(&list_state) - .into_any_element() - }; + match *entry { + InboxEntry::Notification(item_ix) => { + let Some(item) = self.notifications.get(item_ix) else { + return div().into_any_element(); + }; - self.section(header, body, cx) + let root = item.root; + let kind = item.root_kind; + let address = section.address.clone(); + let dock_area = self.dock_area.clone(); + + notification_row("inbox-row", ix, item, cx) + .on_click(move |_, window, cx| { + open_item(&dock_area, root, kind, address.clone(), window, cx); + }) + .into_any_element() + } + InboxEntry::Activity(event_ix) => { + let Some(event) = self.activity.get(event_ix) else { + return div().into_any_element(); + }; + activity_row(ix, event, cx) + } + } + } + } } } +/// Group the notification groups and own activity into one section per repository. +fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec { + let mut by_repo: HashMap, InboxSection> = HashMap::new(); + + for (ix, item) in notifications.iter().enumerate() { + if item.archived { + continue; + } + + let address = item.address.clone(); + let section = by_repo + .entry(address.clone()) + .or_insert_with(move || InboxSection { + address, + unread: 0, + entries: Vec::new(), + latest: Timestamp::default(), + }); + + if item.is_unread() { + section.unread += 1; + } + + section.latest = section.latest.max(item.latest_activity()); + section.entries.push(InboxEntry::Notification(ix)); + } + + for (ix, event) in activity.iter().enumerate() { + let address = repo_address(event); + let section = by_repo + .entry(address.clone()) + .or_insert_with(move || InboxSection { + address, + unread: 0, + entries: Vec::new(), + latest: Timestamp::default(), + }); + + section.latest = section.latest.max(event.created_at); + section.entries.push(InboxEntry::Activity(ix)); + } + + let mut sections: Vec = by_repo.into_values().collect(); + + for section in &mut sections { + section.entries.sort_by(|a, b| { + entry_time(b, notifications, activity).cmp(&entry_time(a, notifications, activity)) + }); + } + + sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); + sections +} + +/// Timestamp an entry is ordered by. +fn entry_time(entry: &InboxEntry, notifications: &[InboxItem], activity: &[Event]) -> Timestamp { + match entry { + InboxEntry::Notification(ix) => notifications + .get(*ix) + .map(InboxItem::latest_activity) + .unwrap_or_default(), + InboxEntry::Activity(ix) => activity + .get(*ix) + .map(|event| event.created_at) + .unwrap_or_default(), + } +} + +/// Flatten the sections into the list of repository headers and their rows. +fn flatten_rows(sections: &[InboxSection]) -> Vec { + let mut rows = Vec::new(); + + for (section_ix, section) in sections.iter().enumerate() { + rows.push(InboxRow::Repo(section_ix)); + + if section.entries.is_empty() { + rows.push(InboxRow::Empty); + continue; + } + + rows.extend( + (0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)), + ); + } + + rows +} + +/// Repository an activity event belongs to, from its `a` tag. +fn repo_address(event: &Event) -> Option { + event + .tags + .coordinates() + .find(|address| address.kind == Kind::GitRepoAnnouncement) +} + /// Display name of the repository at `addr`, from the announcement store. fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option { + let repo_list = RepoListStore::global(cx); let addr = addr?; - RepoListStore::global(cx) + repo_list .read(cx) .announcements .iter() .find(|announcement| announcement.addr() == *addr) - .map(display_name) + .map(|announcement| announcement.name().map(SharedString::from)) } -/// Open the repository of a notification group, and the issue or pull request -/// detail when the group's root is one. -/// -/// The group carries only the repository coordinate, so the announcement is -/// looked up in the local list. A group whose repository is not known locally -/// opens nothing. +/// Header of a repository section. +fn repo_header(section: &InboxSection, cx: &App) -> AnyElement { + let name = + repo_name(section.address.as_ref(), cx).unwrap_or_else(|| SharedString::from("Untitled")); + + h_flex() + .h_12() + .w_full() + .gap_1() + .items_center() + .child( + div() + .min_w_0() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() + .child(name), + ) + .when(section.unread > 0, |this| { + this.child(CountBadge::new(section.unread)) + }) + .into_any_element() +} + +/// Placeholder under a repository header that has nothing to show. +fn empty_section_row(cx: &App) -> AnyElement { + h_flex() + .h_12() + .w_full() + .px_3() + .text_xs() + .text_color(cx.theme().muted_foreground) + .bg(cx.theme().secondary) + .rounded(cx.theme().radius) + .child(SharedString::from("No activity yet.")) + .into_any_element() +} + +/// Open the repository of a notification group, +/// and the issue or pull request detail when the group's root is one. fn open_item( dock_area: &WeakEntity, root: EventId, @@ -458,125 +572,110 @@ fn open_item( open_repo_item(dock_area, store, item, window, cx); } -/// Leading row of a notification group, newest event first. -/// -/// `prefix` scopes the row's element id, so the inbox list and the Unread / -/// Archived list do not collide when both are on screen. fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) -> Stateful
{ let Some(newest) = item.events.first() else { return div().id((prefix, ix)); }; - let profiles = ProfileStore::global(cx); - let profile = profiles.read(cx).get(&newest.pubkey); let kind = item.root_kind.unwrap_or(newest.kind); let subject = SharedString::from(activity_subject(newest)); - let repo = repo_name(item.address.as_ref(), cx); let age = relative_time(item.latest_activity()); let unread = item.is_unread(); - h_flex() + v_flex() .id((prefix, ix)) + .h_16() .w_full() - .gap_3() .px_3() - .py_2() - .items_center() - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().list_hover)) - .child(UserAvatar::new(profile.name()).picture(profile.picture())) - .child(div().flex_shrink_0().child(kind_icon(kind))) + .justify_center() + .bg(cx.theme().secondary) + .hover(|this| this.bg(cx.theme().secondary_hover)) .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() + h_flex() + .gap_2() + .text_sm() + .child( + h_flex() + .size_6() + .flex_shrink_0() + .items_center() + .justify_center() + .child(kind_icon(kind)), + ) .child( div() - .text_sm() - .when(unread, |this| this.font_semibold()) + .min_w_0() .whitespace_nowrap() .text_ellipsis() .child(subject), ) - .child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(kind_label(kind))) - .when_some(repo, |this, repo| { - this.child(SharedString::from("on")).child(repo) - }) - .child(SharedString::from("·")) - .child(SharedString::from(age)), - ), + .child(div().flex_1()) + .when(unread, |this| { + this.child( + div() + .flex_shrink_0() + .size_2() + .rounded_full() + .bg(cx.theme().primary), + ) + }), + ) + .child( + h_flex().gap_2().child(div().w_6().flex_shrink_0()).child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(kind_label(kind))) + .child("·") + .child(SharedString::from(age)), + ), ) - .when(unread, |this| { - this.child( - div() - .flex_shrink_0() - .size(px(8.)) - .rounded(px(4.)) - .bg(cx.theme().primary), - ) - }) } -/// One row of the user's own recent git activity. fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement { let kind = event.kind; let subject = SharedString::from(activity_subject(event)); - let repo = repo_name(event.tags.coordinates().next().as_ref(), cx); let age = relative_time(event.created_at); - h_flex() + v_flex() .id(("activity-row", ix)) + .h_16() .w_full() - .gap_3() .px_3() - .py_2() - .items_center() - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().list_hover)) - .child(div().flex_shrink_0().child(kind_icon(kind))) + .justify_center() + .bg(cx.theme().secondary) + .hover(|this| this.bg(cx.theme().secondary_hover)) .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .child( - div() - .text_sm() - .whitespace_nowrap() - .text_ellipsis() - .child(subject), - ) + h_flex() + .gap_2() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() .child( h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(kind_label(kind))) - .when_some(repo, |this, repo| { - this.child(SharedString::from("on")).child(repo) - }) - .child(SharedString::from("·")) - .child(SharedString::from(age)), - ), + .size_6() + .flex_shrink_0() + .items_center() + .justify_center() + .child(kind_icon(kind)), + ) + .child(subject), + ) + .child( + h_flex().gap_2().child(div().w_6().flex_shrink_0()).child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(kind_label(kind))) + .child("·") + .child(SharedString::from(age)), + ), ) .into_any_element() } -/// Name to show for a repository, its `name` tag or its id. -fn display_name(announcement: &Announcement) -> SharedString { - announcement - .name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) -} - /// Leading icon for a notification or activity kind. fn kind_icon(kind: Kind) -> Icon { if kind == COVER_NOTE_KIND { @@ -589,7 +688,7 @@ fn kind_icon(kind: Kind) -> Icon { Icon::new(CustomIconName::GitPullRequest) } Kind::GitPatch => Icon::new(CustomIconName::GitCommit), - Kind::Comment => Icon::new(IconName::FileText), + Kind::Comment => Icon::new(IconName::File), Kind::GitStatusOpen | Kind::GitStatusApplied | Kind::GitStatusClosed @@ -665,31 +764,54 @@ impl Focusable for InboxView { impl Render for InboxView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let unread = self.unread_count; - let notifications = self.notifications.clone(); - let activity = self.activity.clone(); + let rows = self.rows.clone(); - let visible: Vec = notifications - .iter() - .enumerate() - .filter(|(_, item)| !item.archived) - .map(|(ix, _)| ix) - .collect(); - - if self.notifications_list.item_count() != visible.len() { - self.notifications_list.reset(visible.len()); - } - - if self.activity_list.item_count() != activity.len() { - self.activity_list.reset(activity.len()); + if self.list.item_count() != rows.len() { + self.list.reset(rows.len()); } v_flex() - .size_full() .image_cache(gpui::retain_all("inbox")) - .gap_4() - .p_4() - .child(self.render_inbox_panel(unread, notifications, visible, cx)) - .child(self.render_activity_panel(activity, cx)) + .size_full() + .gap_2() + .child( + h_flex() + .px_4() + .h_12() + .w_full() + .gap_1() + .items_center() + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::from("Inbox")), + ) + .child(div().flex_1()) + .child( + Button::new("mark-all") + .icon(IconName::CircleCheck) + .secondary() + .tooltip("Mark all as read") + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.mark_all_read(cx); + })), + ), + ) + .child(div().relative().flex_1().min_h_0().px_4().pb_4().when_else( + rows.is_empty(), + |this| this.child(empty_state(IconName::Inbox, "You're all caught up.", cx)), + |this| { + this.child( + list( + self.list.clone(), + cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)), + ) + .size_full() + .min_h_0() + .into_any_element(), + ) + }, + )) } } diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 502bf10..303471f 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -21,7 +21,7 @@ use signed_core::{Announcement, RepoAddr, identifier_from_name}; use signed_state::{ Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore, }; -use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; +use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel}; @@ -39,8 +39,6 @@ pub struct SidebarPanel { dock_area: WeakEntity, inbox: Option>, explore: Option>, - /// Unread notification groups, shown as the inbox nav item's badge. - unread: usize, /// Artwork for the sign-in screen. banner: SharedString, /// The signed-in user's announced repositories, newest first. @@ -74,6 +72,7 @@ impl SidebarPanel { if signer_required { this.banner = pick_banner(); + cx.notify(); } if this.refresh(cx) || signer_required { @@ -102,44 +101,20 @@ impl SidebarPanel { } })); - // The inbox nav item shows the unread notification count as a badge. - let inbox = backend.read(cx).inbox(); - subscriptions.push(cx.observe(&inbox, |this, inbox, cx| { - let unread = inbox.read(cx).unread_count; - if this.unread != unread { - this.unread = unread; - cx.notify(); - } - })); - - let mut this = Self { + Self { focus_handle: cx.focus_handle(), dock_area, inbox: None, explore: None, - unread: inbox.read(cx).unread_count, banner: pick_banner(), announcements: Arc::new(Vec::new()), local_repos: Arc::new(Vec::new()), scanning: false, unpushed: HashMap::new(), _subscriptions: subscriptions, - }; - - // Seed the snapshot right away. - // The stores may already hold data from before the panel opened. - // The first render must not depend on a later store update. - this.refresh(cx); - - this + } } - /// The sidebar renders only its own derived fields, never the stores - /// directly. Because the panel is a cached view, a store update alone does - /// not re-render it: the observers notify this panel, which re-runs - /// `render` over the fresh snapshot. - /// - /// Returns `true` when a rendered field changed. fn refresh(&mut self, cx: &mut Context) -> bool { let backend = Backend::global(cx); let user = backend.read(cx).current_user(); @@ -644,9 +619,6 @@ impl Render for SidebarPanel { .justify_start() .child( NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) - .when(self.unread > 0, |this| { - this.suffix(CountBadge::new(self.unread)) - }) .on_click(cx.listener(|this, _ev, window, cx| { this.open_inbox(window, cx) })), diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md index 4e32f7d..5a7fae8 100644 --- a/docs/inbox-plan.md +++ b/docs/inbox-plan.md @@ -6,13 +6,15 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for > page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `` when > an account is active, and that home screen is the inbox. -> **Status.** Phases 0-4 are implemented and green on `feat/inbox`: +> **Status.** Phases 0-4 are implemented and green on `feat/inbox`, then the screen was redesigned to +> group notifications and activity **by repository** (see the repository-grouping note in §7). > `cargo test -p signed_core` (68), `cargo test -p signed_state` (24), -> `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean, -> `cargo check --workspace --all-targets` succeeds. -> Phase 5 is not started. This document reflects the implementation as it stands, including the -> Phase 1 refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned -> derivation, the Phase 3 bottom-dock sub-views, and the Phase 4 click-through. +> `cargo test -p workspace` (7), `cargo test -p dock` (1), `cargo clippy -p workspace --all-targets` +> clean, `cargo check --workspace --all-targets` succeeds. +> Phase 5 is not started. This document reflects the implementation as it stands: the Phase 1 +> refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned derivation, the +> Phase 4 click-through, and the repository-grouped list. The Phase 3 bottom-dock sub-views were +> removed before the redesign; their implementation notes in §7 are historical. ## 1. What the GitWorkshop home screen is @@ -55,39 +57,48 @@ Data hooks: | Priority | Section | Notes | |---|---|---| -| **P0** | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; all groups shown | -| **P0** | Continue where you left off | Your own recent git activity, newest first | -| **P1** | Unread / Archived sub-views | Open as **bottom-dock panels**, not tabs inside the inbox panel | +| **P0** | Inbox panel | Activity directed at you and your own activity, **grouped by repository**; unread badge; mark all read; all groups shown | | **P1** | Click-through | Open the repo panel at the relevant PR/issue | | **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns | -| **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories | Not needed in Signed | +| **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories, Unread/Archived sub-views | Not needed in Signed | Notes: - There is **no greeting header**. The screen starts with the inbox panel. - There is **no My repositories column**. The sidebar already lists the signed-in user's repositories, so the inbox is a single column. -- Unread and Archived are separate panels opened in the bottom dock, not tabs in the inbox panel. +- The Unread/Archived sub-view panels were removed: the panel is a single repository-grouped list instead. ## 3. The Signed screen -`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item (currently a -placeholder that opens Explore). It is two flexible bordered cards, each a virtual list: +`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item. It is one bordered +card holding a single virtual list. Every row is either a **repository header** or one of that +repository's **notifications / own activity**, newest first: +The sections are **all of the user's own repositories**, seeded from `RepoListStore`, plus any other +repository that has notifications or activity. Owned repositories with nothing to show render an +empty state ("No activity yet.") under their header, and sort after the ones with activity (newest +announcement first). Items with no repository address fall into a single "Other repository" section. ``` +-------------------------------------------------------------------------+ -| Inbox (3 unread) [Unread] [Archived] [Mark all read] | -| [avatar] issue opened on you/repo 2m (scroll) | -| [avatar] commented on "Fix parser" 1h | -| [avatar] PR update on you/repo 3h | -+-------------------------------------------------------------------------+ -| Continue where you left off (scroll)| -| [icon] "Fix parser bug" you/repo opened 3d | -| [icon] "Add retry" you/repo PR 5d | -+-------------------------------------------------------------------------+ -| bottom dock: Unread or Archived list (opened by the header buttons) | +| Inbox (3 unread) [Mark all read] | +|-------------------------------------------------------------------------| +| [repo] you/repo-a (2) | +| [avatar] issue opened issue 2m | +| [avatar] commented on "..." comment 1h | +| [icon] "Add retry" patch 3d | +|-------------------------------------------------------------------------| +| [repo] you/repo-b | +| No activity yet. | +|-------------------------------------------------------------------------| +| [repo] you/repo-c | +| No activity yet. | +-------------------------------------------------------------------------+ ``` +The sections are the repositories that actually have notifications or activity, ordered by their +newest row. A repository the user owns but that has no items is not shown. Items with no repository +address fall into a single "Other repository" section. + ## 4. Data layer ### 4.1 `signed_core`: pure logic @@ -265,8 +276,8 @@ The inbox is split in two, because the expensive derivation is only needed while open. **`Inbox`** is a child `Entity` owned by `Backend` (`inbox: Entity`) and is -deliberately thin: it owns only the read/archive state that must outlive the panel, the NIP-78 -load/save, and the unread count the sidebar badge reads. +deliberately thin: it owns only the read/archive state that must outlive the panel and the NIP-78 +load/save. ```rust // backend.rs @@ -280,14 +291,11 @@ pub struct Backend { pub struct Inbox { state: InboxReadState, state_loaded: bool, - /// Published by the inbox panel for the sidebar badge. - pub unread_count: usize, } impl Inbox { pub fn state(&self) -> &InboxReadState; pub fn is_loaded(&self) -> bool; - pub fn set_unread_count(&mut self, count: usize, cx: &mut Context); pub fn mark_read(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx); pub fn mark_archived(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx); pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx); @@ -299,37 +307,44 @@ impl Inbox { **The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read state they were computed with, and the refresh coalescing. There is no separate store entity: the panel is the only consumer, so an `Entity` would add an `update` indirection and a -forwarding subscription without buying any sharing. +forwarding subscription without buying any sharing. The panel's own `unread_count` feeds its header +badge only; there is no global count and no sidebar badge. ```rust pub struct InboxView { focus_handle: FocusHandle, + dock_area: WeakEntity, notifications: Arc>, activity: Arc>, + sections: Arc>, // grouped by repository + rows: Arc>, // flattened list unread_count: usize, state: InboxReadState, state_loaded: bool, refresh: RefreshGate, + list: ListState, _subscriptions: Vec, } impl InboxView { - pub fn new(cx: &mut Context) -> Self; // cx.defer(… sync_state) + pub fn new(dock_area: WeakEntity, cx: &mut Context); // cx.defer(… sync_state) pub fn sync_state(&mut self, cx); // observes the global Inbox - pub fn mark_read(&mut self, root: EventId, cx); // Phase 3 - pub fn mark_archived(&mut self, root: EventId, cx); // Phase 3 pub fn mark_all_read(&mut self, cx); fn handle_backend_event(&mut self, event: &BackendEvent, cx); fn refresh(&mut self, cx); fn run_refresh(&mut self, cx); + fn regroup(&mut self, cx); // re-apply read state + fn rebuild(&mut self, cx); // group by repository, seed owned, flatten + fn clear(&mut self); } ``` -The panel owns the two subscriptions that carry logic: it observes the global `Inbox` -(`InboxView::sync_state`) and subscribes to `Backend` (`InboxView::handle_backend_event`). -Re-rendering needs no subscription: GPUI invalidates a window for every entity it read during -render, so the panel tracks `RepoListStore` and `ProfileStore` just by reading them in `render`. -The panel writes back to `Backend` only to publish the unread count for the badge. +The panel owns three subscriptions that carry logic: it observes the global `Inbox` +(`InboxView::sync_state`), subscribes to `Backend` (`InboxView::handle_backend_event`), and observes +`RepoListStore` to rebuild when the user's own repositories load. Re-rendering itself needs no +subscription: GPUI invalidates a window for every entity it read during render, so the panel tracks +`RepoListStore` and `ProfileStore` just by reading them in `render`. The panel does not write back +to the global. **`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git @@ -348,16 +363,17 @@ pub async fn query_inbox( derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and `signed_state::init` gains no parameters. -**Badge trade-off.** The unread count is derived by the panel, so the sidebar badge is only current -after the inbox has been opened once in the session. Keeping it always live would require the -expensive derivation to run globally, which is exactly what this split avoids. +**No sidebar badge.** The sidebar's inbox nav item has no unread suffix (an earlier global count +derivation was removed with it). The unread count lives entirely in the panel, which shows it in +its header and per repository section. The trade-off is that the count is only current while the +panel is open, which is acceptable now that nothing outside it displays one. -The dependency chain is `Backend` → `Inbox` and `InboxView` → `query_inbox`; the panel reaches back -only to publish the unread count. +The dependency chain is `Backend` → `Inbox` and `InboxView` → `query_inbox`. -`Backend` does not funnel its events through the inbox: the panel subscribes to `Backend` directly. -`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore` -and `SidebarPanel` consume them. They no longer drive the inbox. +`Backend` owns the inbox lifecycle (`sync_inbox`); the panel subscribes to `Backend` directly for +its lists. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: +`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox's activation +directly. `InboxView::handle_backend_event` refreshes on: @@ -412,7 +428,7 @@ lists and in-flight refresh when it sees the unloaded state, then refreshes once ```rust pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context) { - // state = default; state_loaded = false; unread_count = 0; cx.notify(); + // state = default; state_loaded = false; cx.notify(); // spawn load_state(client, me), then set state and state_loaded = true } ``` @@ -437,12 +453,12 @@ read, not a wait on the network; see the note below. their `K` tag is a git kind; sort newest first. - Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() == Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a - previous user's results never land. Then set `notifications`, `activity`, `unread_count`, publish - the unread count to the global `Inbox`, `cx.notify()`, `refresh.finish()`. + previous user's results never land. Then set `notifications`, `activity`, `unread_count`, rebuild the + repository sections (`rebuild`), `cx.notify()`, `refresh.finish()`. `InboxView::sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the lists, on the first load it runs the initial refresh, and on a state change (a mark action) it -re-derives the flags (`InboxItem::apply_state`) and publishes the new unread count. +re-derives the flags (`InboxItem::apply_state`). **Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately, so the query that follows them reads the local cache rather than waiting for the relays. That is @@ -452,11 +468,10 @@ timing: received events are written to LMDB and surfaced as `ClientNotification: `Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was reviewed and left as-is. -**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()` live on the panel, which -passes the group and every known notification event to the global `Inbox`. The global marks the -group, advances the cutoffs against *all* notification events to bound the id sets, saves the state -to LMDB (signed with a fresh random key, see 4.2), and notifies. The panel then re-derives and -publishes the unread count. +**Actions**: `mark_all_read()` lives on the panel, which passes every known notification event to the +global `Inbox`. The global marks them, advances the cutoffs against *all* notification events to bound +the id sets, saves the state to LMDB (signed with a fresh random key, see 4.2), and notifies. The +panel then re-derives and publishes the unread count. **Repository names need no new store**: `RepoListStore` already holds every announcement and `repo_name` resolves an address to a display name. @@ -470,78 +485,77 @@ publishes the unread count. ### 5.1 `InboxView` center panel -New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`. -It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is a -column of two flexible bordered cards (`flex_1`, `min_h_0`), each with a header bar and a scrolling -body. Each body is a `gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px -overdraw) with a `vertical_scrollbar`; the panel itself does not scroll, so both lists get a -definite viewport height. The list counts are reset from `render` whenever the rendered item count -changes. Row ids are prefixed (`("inbox-row", ix)` / `("activity-row", ix)`) so the two lists do not -collide. +`crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`. +It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is one +bordered card (`flex_1`, `min_h_0`) with a header bar and a scrolling body. The body is a single +`gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px overdraw) with a +`vertical_scrollbar`; the panel itself does not scroll, so the list gets a definite viewport height. +The list count is reset from `render` whenever the rendered row count changes. -- **Inbox card**: header with the unread count badge, the **Unread** and **Archived** sub-view buttons - (Phase 3) and **Mark all read**; then every non-archived notification item. Rows show the actor - avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread dot - (the subject is semibold while unread). Empty state: "You're all caught up." with - `IconName::Inbox`. -- **Continue where you left off**: every event in the activity list, each row a kind icon, subject, - kind label, repo name, and relative time. +- **Header**: the unread count badge and **Mark all read**. +- **Body**: the flattened repository-grouped rows. A repository header is a muted bar with a git icon, + the repository name (or "Other repository" when the address is unknown) and its unread badge. Rows + under it show the actor avatar, a kind icon, the subject, the kind label, a relative time, and an + unread dot (the subject is semibold while unread). A repository with nothing to show renders + "No activity yet."; the panel-level "You're all caught up." empty state appears only when there are + no sections at all (no owned repositories and no items). No greeting header, and no **My repositories** column - the sidebar already lists the user's -repositories. The **Unread** and **Archived** header buttons open the sub-views of 5.2 in the bottom -dock. +repositories. -### 5.2 Unread / Archived as bottom-dock panels +### 5.2 Grouping by repository -Add a bottom-panel helper next to `add_center_panel` in `crates/dock/src/lib.rs`: +The grouping is panel-owned derivation, done once per data change in `InboxView::rebuild` (called +from `run_refresh` and `regroup`), never per frame: ```rust -/// Add an already-wrapped panel handle to the bottom dock of `area`. -pub fn add_bottom_panel( - area: &mut DockArea, - panel: Arc, - window: &mut Window, - cx: &mut Context, -) { - area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx); +struct InboxSection { + address: Option, // repository, None for items without one + unread: usize, // unread notification groups + entries: Vec, // newest first + latest: Timestamp, // orders the sections +} + +enum InboxEntry { // indices into the panel's own lists + Notification(usize), + Activity(usize), +} + +enum InboxRow { // the flattened list + Repo(usize), + Entry(usize, usize), + Empty, // "No activity yet." under an empty section } ``` -The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then: +The section list and the flattened rows are stored as `Arc`s and cloned into the `gpui::list` +closure, which indexes the panel's `notifications` / `activity` lists - no per-frame deep copies. +Notification groups carry their repository in `InboxItem::address`; activity events carry it in a +`GitRepoAnnouncement` `a` tag (`repo_address`). Archived notification groups are left out. -- `InboxFilterView` is a bottom-dock panel holding an `Entity` and a mode - `InboxFilter::Unread | InboxFilter::Archived`. It renders the matching subset of the panel's - notifications as a `gpui::list`, with the same rows as the inbox card. The mode filters on - `InboxItem::is_unread()` / `InboxItem::archived` and picks the tab title and empty state. It reads - the inbox entity during render, so GPUI's render-time tracking re-renders it whenever the inbox - re-derives; it needs no subscription of its own. -- The **Unread** and **Archived** header buttons in `InboxView` call `InboxView::open_filter`, which - keeps `filter_view: Option>`. When the panel already exists it updates - its mode, focuses it, and reopens the bottom dock if the user collapsed it, instead of adding a - duplicate. Otherwise it creates the panel and adds it to the bottom dock. -- **Unread rows**: clicking a row marks that group read (`InboxView::mark_read`); a trailing ghost - icon button archives it (`InboxView::mark_archived`). Both call back into the `InboxView` entity, - which updates the global `Inbox`. **Archived rows** are display-only, since the read state has no - un-archive operation. +`rebuild` also seeds a section for every repository in `announcements_of(me)`. The panel observes +`RepoListStore` so a repository that loads after the last refresh still appears (its own empty +section, or with items if any arrived); this is the one logic subscription beyond the `Inbox` and +`Backend` ones. Repository names are resolved per render through `repo_name` -> `RepoListStore`, so a +late announcement still labels its section without re-deriving the grouping. ### 5.3 Sidebar In `views/sidebar/mod.rs`: -- Add `inbox: Option>` (mirrors `explore`) and `unread: usize`. +- Add `inbox: Option>` (mirrors `explore`). - Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab). - `InboxView::new` takes the sidebar's `WeakEntity` so the panel can open its sub-view. -- Point the existing nav item at it and add an unread suffix: + `InboxView::new` takes the sidebar's `WeakEntity` so the panel can open a repo for a row. +- Point the existing nav item at it: ```rust NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) - .when(unread > 0, |this| this.suffix(...badge...)) .on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))), ``` -- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge follows the count the panel - publishes. +- No unread badge. The nav item carries no suffix, and the sidebar does not observe the global + `Inbox`. The unread count lives in the panel only. ### 5.4 Click-through (P1) @@ -592,10 +606,10 @@ whose root is not an issue/PR/patch, or whose repository is not in `RepoListStor | `crates/signed_state/src/backend.rs` | `inbox: Entity` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import | | `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users | | `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) | -| `crates/dock/src/lib.rs` | `add_bottom_panel` helper | -| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning the derived lists directly, the `InboxFilterView` bottom-dock sub-view for Unread / Archived, and the notification click-through | +| `crates/dock/src/lib.rs` | `add_bottom_panel` helper (currently unused; left over from the removed sub-views) | +| `crates/workspace/src/views/inbox.rs` | `InboxView` home panel owning the derived lists, the repository grouping, and the notification click-through | | `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` | -| `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge | +| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring | | `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item`, `RepoDetailView::store()` | No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox` @@ -613,10 +627,11 @@ activates the `Inbox` child entity at each signer transition. 2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the implementation notes below. -3. **Phase 2 - screen**: `InboxView` (inbox + activity), sidebar nav and badge. +3. **Phase 2 - screen**: `InboxView` (inbox + activity) and the sidebar nav item. **DONE.** See the implementation notes below. 4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. - **DONE.** See the implementation notes below. + **Done, then reverted.** The sub-views were removed before the repository-grouping redesign; the + notes below are historical. 5. **Phase 4 - click-through**: `open_item` and announcement lookup. **DONE.** See the implementation notes below. 6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail @@ -692,9 +707,8 @@ Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store - `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm. - Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox - nav item is repointed and carries a `CountBadge` suffix driven by the observed unread count. The - screen is still opened by the nav item, not on app startup, matching the "idle until signer" rule; - auto-opening it as the post-login home is a possible follow-up. + nav item is repointed. The screen is still opened by the nav item, not on app startup, matching the + "idle until signer" rule; auto-opening it as the post-login home is a possible follow-up. - The **My repositories** column (search `InputState`, **New** button, `open_repo_panel` rows) was removed after Phase 2 as redundant with the sidebar, along with the panel's `dock_area`, `open_repo` / `open_create_repo` helpers and the `create_repo_dialog` / `open_repo_panel` imports. @@ -708,8 +722,8 @@ Phases 0-2 kept all derivation in the global `Inbox`, so every notification and whether or not the home screen was open, and `Backend::emit` carried a deferred side effect just to feed it. -- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, and the `unread_count` the - sidebar badge reads, plus the NIP-78 load/save and the mark actions. +- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, plus the NIP-78 load/save + and the mark actions. - `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox` updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update. - The panel became the client-side owner of the derivation, initially through a panel-scoped @@ -731,11 +745,15 @@ The `InboxStore` entity was then folded into `InboxView`, since the panel was it - `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and `cargo test -p workspace` (7) pass; clippy and `cargo check --workspace --all-targets` are clean. -Trade-off: the sidebar badge is only current after the inbox is opened once, because the unread -count is derived by the panel. +Trade-off: the unread count is derived by the panel, so it is only current while the panel is open. +(`publish_unread_count` fed a sidebar badge at the time; both were removed later - see "Sidebar +badge removed" below.) ### Phase 3 implementation notes +> Historical: the Unread/Archived sub-views below were later removed; the panel is now a single +> repository-grouped list. Kept for the `add_bottom_panel` / sub-view rationale. + Files: `crates/dock/src/lib.rs` and `crates/workspace/src/views/{inbox.rs, sidebar/mod.rs}`. No store changes. @@ -784,13 +802,63 @@ Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No s from the panel) because the `gpui::list` item closure only receives `&mut App`. It resolves `item.address` through `RepoListStore`, returns silently when the repository is unknown, opens the repo panel, then maps the root kind to a `RepoItem` and calls `open_repo_item`. -- Only the main notification list is clickable. Unread rows keep their Phase 3 behaviour (click marks - read, trailing button archives). Activity rows are unchanged. +- Only the notification rows are clickable. Activity rows are display-only. The Phase 3 mark-read / + archive row behaviour is gone with the sub-views. - `RepoItem::Patch` is a unit variant because the id would be unused: patches have no detail panel, so `open_repo_item` returns before doing anything and only the repository panel opens. - `cargo clippy -p workspace --all-targets` is clean, `cargo check --workspace --all-targets` succeeds, and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1). +### Repository grouping redesign (after Phase 4) + +Files: `crates/workspace/src/views/inbox.rs`. No store, no `signed_core` changes. + +The two-card layout (notifications over activity) was replaced by a single repository-grouped list. + +- The panel now derives `sections: Vec` and a flattened `rows: Vec` in + `rebuild`, called from `run_refresh` and `regroup`. Both are stored as `Arc`s and cloned into the + `gpui::list` closure, which indexes `notifications` / `activity` - no deep copies per frame and no + data duplicated between the section list and the source lists. +- `InboxSection` groups a repository's non-archived notification groups and the user's own activity, + newest first; sections are ordered by their newest entry. `InboxEntry` holds indices into the + panel's lists; `InboxRow::Repo` / `InboxRow::Entry` / `InboxRow::Empty` is the flattened shape the + list renders. +- All of the user's own repositories are seeded as sections from `RepoListStore::announcements_of`, + so an owned repository with nothing to show gets an empty section ("No activity yet.") and sorts + after the sections with activity. The panel observes `RepoListStore` to rebuild when the user's + repositories load or change. +- Activity is matched to a repository through a `GitRepoAnnouncement` `a` tag (`repo_address`). + Items without an address share the "Other repository" section. +- `notification_row` / `activity_row` no longer render the repository name - the section header does. + That also drops one `RepoListStore` scan per row. +- The single card has one `ListState`; the old `notifications_list` / `activity_list` and the + `render_inbox_panel` / `render_activity_panel` / `section` helpers are gone. `notification_row` still + takes an id prefix so rows stay unique within the list. +- `cargo clippy -p workspace --all-targets` is clean and `cargo test -p signed_core -p signed_state + -p workspace -p dock` passes (68 / 24 / 7 / 1). + +### Sidebar badge removed (after the repository grouping redesign) + +Files: `crates/signed_core/src/filters.rs`, `crates/signed_state/src/{inbox.rs,backend.rs}`, +`crates/workspace/src/views/{inbox.rs,sidebar/mod.rs}`. + +An intermediate change made the sidebar badge live by moving the unread count into the global +`Inbox` (a `refresh_unread_count` driven by `Backend`). That was then reverted along with the badge +itself, so the global is thin again. + +- The sidebar nav item no longer renders a `CountBadge`; `SidebarPanel` lost its `unread` field and + its observe of the global `Inbox`. +- The global `Inbox` no longer stores an `unread_count` and has no `set_unread_count` / + `refresh_unread_count`. `Backend` has no `refresh_inbox_unread` and no per-batch or per-sync count + refresh. `filters::affects_inbox` and the `query_inbox` helper split were reverted with it. +- `InboxView` keeps its local `unread_count` for its header badge and the per-section `unread` for + the repository headers; `publish_unread_count` stays deleted. +- Consequence: the unread count is only current while the panel is open, and there is no unread + indication anywhere else in the app. +- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean, + `cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and + `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1). + ## 8. Validation - `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip. @@ -802,7 +870,7 @@ Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No s - Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel populates from another identity's issue/comment, the activity list shows your own items, and that no kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read - state is read back from LMDB. + state is read back from LMDB. Confirm the sidebar has no unread badge. ## 9. SDK APIs used (verified in the pinned `5c669a4` checkout) -- 2.54.0 From f62fa9884d2aaf02e132fd5f81be2cf85bb00b24 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 11 Sep 2026 21:52:56 +0700 Subject: [PATCH 08/10] update --- crates/workspace/src/views/inbox.rs | 48 ++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index d610a86..4ce8b2b 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -17,9 +17,9 @@ use signed_core::{ COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters, }; use signed_state::{ - Backend, BackendEvent, RefreshGate, RefreshRequest, RepoListStore, query_inbox, + Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox, }; -use signed_ui::CountBadge; +use signed_ui::{CountBadge, UserAvatar}; use utils::relative_time; use super::{RepoItem, open_repo_item, open_repo_panel}; @@ -577,6 +577,11 @@ fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) return div().id((prefix, ix)); }; + let profile_store = ProfileStore::global(cx); + let profile = profile_store.read(cx).get(&newest.pubkey); + let author = profile.name(); + + let picture = profile.picture(); let kind = item.root_kind.unwrap_or(newest.kind); let subject = SharedString::from(activity_subject(newest)); let age = relative_time(item.latest_activity()); @@ -587,6 +592,7 @@ fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) .h_16() .w_full() .px_3() + .gap_1() .justify_center() .bg(cx.theme().secondary) .hover(|this| this.bg(cx.theme().secondary_hover)) @@ -621,15 +627,28 @@ fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) }), ) .child( - h_flex().gap_2().child(div().w_6().flex_shrink_0()).child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(kind_label(kind))) - .child("·") - .child(SharedString::from(age)), - ), + h_flex() + .w_full() + .gap_2() + .child(div().w_6().flex_shrink_0()) + .child( + h_flex() + .flex_1() + .gap_1() + .items_center() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child( + h_flex() + .gap_1() + .child(UserAvatar::new(author.clone()).picture(picture)) + .child(author.clone()), + ) + .child("opened") + .child(SharedString::from(kind_label(kind))) + .child(div().flex_1()) + .child(SharedString::from(age)), + ), ) } @@ -643,6 +662,7 @@ fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement { .h_16() .w_full() .px_3() + .gap_1() .justify_center() .bg(cx.theme().secondary) .hover(|this| this.bg(cx.theme().secondary_hover)) @@ -683,16 +703,16 @@ fn kind_icon(kind: Kind) -> Icon { } match kind { - Kind::GitIssue => Icon::new(CustomIconName::GitIssueOpen), + Kind::GitIssue => Icon::new(CustomIconName::GitIssueOngoing), Kind::GitPullRequest | Kind::GitPullRequestUpdate => { Icon::new(CustomIconName::GitPullRequest) } Kind::GitPatch => Icon::new(CustomIconName::GitCommit), - Kind::Comment => Icon::new(IconName::File), + Kind::Comment => Icon::new(IconName::Star), Kind::GitStatusOpen | Kind::GitStatusApplied | Kind::GitStatusClosed - | Kind::GitStatusDraft => Icon::new(IconName::CircleCheck), + | Kind::GitStatusDraft => Icon::new(CustomIconName::GitIssueOpen), _ => Icon::new(IconName::Bell), } .small() -- 2.54.0 From 4684e01e89d18c8ce25dd7971106554810c8908f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 12 Sep 2026 09:55:27 +0700 Subject: [PATCH 09/10] update --- crates/signed_core/src/inbox.rs | 194 +++++++++++++-- crates/signed_state/src/inbox.rs | 24 +- crates/signed_ui/src/user_avatar.rs | 13 +- crates/workspace/src/views/inbox.rs | 364 +++++++++++----------------- docs/inbox-plan.md | 121 +++++++-- 5 files changed, 443 insertions(+), 273 deletions(-) diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs index 57aed3f..463ad48 100644 --- a/crates/signed_core/src/inbox.rs +++ b/crates/signed_core/src/inbox.rs @@ -4,7 +4,7 @@ use std::time::Duration; use nostr::prelude::*; use serde::{Deserialize, Serialize}; -use crate::{COVER_NOTE_KIND, RepoAddr}; +use crate::{COVER_NOTE_KIND, RepoAddr, activity_subject}; /// Window before `now` that an advanced cutoff retreats to. const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60); @@ -12,33 +12,92 @@ const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60); /// Window before `now` that a mark-all cutoff retreats to. const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60); -/// A thread of notification events sharing one root +/// A thread of notification and own-activity events sharing one root. #[derive(Debug, Clone)] pub struct InboxItem { - /// The root issue, patch or pull request the notifications belong to. + /// The root issue, patch or pull request the events belong to. pub root: EventId, + /// The root event itself, when it is known locally. + pub root_event: Option, /// Kind of the root event, when it is known locally. pub root_kind: Option, /// Repository the root belongs to, from the root's `a` tag. pub address: Option, - /// Events in the group, newest first. + /// Notification events directed at the user, newest first. pub events: Vec, + /// The user's own events in the thread, newest first. + pub own_events: Vec, /// Unread event ids, oldest first. pub unread_ids: Vec, - /// Whether every event in the group is archived. + /// Whether every notification event in the thread is archived. pub archived: bool, } impl InboxItem { - /// Timestamp of the newest event in the group. + /// Title of the thread, read from its root issue/patch/PR when known. + pub fn title(&self) -> String { + self.root_event + .as_ref() + .or_else(|| self.own_events.first()) + .or_else(|| self.events.first()) + .map(activity_subject) + .unwrap_or_else(|| "Untitled".to_string()) + } + + /// Kind shown for the thread. + pub fn kind(&self) -> Option { + self.root_kind.or_else(|| { + self.root_event + .as_ref() + .or_else(|| self.own_events.first()) + .or_else(|| self.events.first()) + .map(|event| event.kind) + }) + } + + /// Timestamp of the newest event in the thread. pub fn latest_activity(&self) -> Timestamp { - self.events - .first() + self.root_event + .as_ref() + .into_iter() + .chain(self.own_events.first()) + .chain(self.events.first()) .map(|event| event.created_at) + .max() .unwrap_or_default() } - /// Whether the group has an unread event still visible in the inbox. + /// Up to `limit` events of the thread, oldest first. + pub fn timeline(&self, limit: usize) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut events: Vec = Vec::new(); + + if let Some(root) = &self.root_event { + seen.insert(root.id); + events.push(root.clone()); + } + + let mut rest: Vec = self + .own_events + .iter() + .chain(self.events.iter()) + .filter(|event| seen.insert(event.id)) + .cloned() + .collect(); + + rest.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) + }); + rest.truncate(limit.saturating_sub(events.len())); + events.extend(rest); + + events.sort_by_key(|event| event.created_at); + events + } + + /// Whether the thread has an unread event still visible in the inbox. pub fn is_unread(&self) -> bool { !self.archived && !self.unread_ids.is_empty() } @@ -53,7 +112,9 @@ impl InboxItem { .map(|event| event.id) .collect(); - self.archived = self.events.iter().all(|event| state.is_archived(event)); + // A thread without notification events is never archived. + self.archived = + !self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event)); } } @@ -97,10 +158,17 @@ where } } -/// Group notification events by root, newest activity first. -pub fn group(events: E, me: PublicKey, state: &InboxReadState, lookup: &L) -> Vec +/// Group notification events and the user's own events into one item per thread. +pub fn group( + events: E, + own: O, + me: PublicKey, + state: &InboxReadState, + lookup: &L, +) -> Vec where E: IntoIterator, + O: IntoIterator, L: Fn(EventId) -> Option, { let mut groups: HashMap> = HashMap::new(); @@ -114,14 +182,23 @@ where groups.entry(root).or_default().push(event); } - let mut items: Vec = groups + let mut own_groups: HashMap> = HashMap::new(); + for event in own { + let root = notification_root(&event, lookup).unwrap_or(event.id); + own_groups.entry(root).or_default().push(event); + } + + let mut roots: Vec = groups.keys().chain(own_groups.keys()).copied().collect(); + roots.sort(); + roots.dedup(); + + let mut items: Vec = roots .into_iter() - .map(|(root, mut events)| { - events.sort_by(|a, b| { - b.created_at - .cmp(&a.created_at) - .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) - }); + .map(|root| { + let mut events = groups.remove(&root).unwrap_or_default(); + let mut own_events = own_groups.remove(&root).unwrap_or_default(); + sort_newest_first(&mut events); + sort_newest_first(&mut own_events); let root_event = lookup(root); @@ -131,7 +208,9 @@ where address: root_event .as_ref() .and_then(|event| event.tags.coordinates().next()), + root_event, events, + own_events, unread_ids: Vec::new(), archived: false, }; @@ -149,6 +228,15 @@ where items } +/// Sort thread events newest first, ties broken by id. +fn sort_newest_first(events: &mut [Event]) { + events.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) + }); +} + /// Read and archive state of the inbox, a high-water-mark model. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct InboxReadState { @@ -392,6 +480,15 @@ mod tests { signed(author, Kind::GitIssue, Vec::new(), at) } + fn titled_issue(author: &Keys, title: &str, at: u64) -> Event { + signed( + author, + Kind::GitIssue, + vec![Tag::parse(["subject", title]).expect("valid subject tag")], + at, + ) + } + #[test] fn issue_and_pull_request_are_their_own_root() { let events = [ @@ -496,6 +593,7 @@ mod tests { let events = [issue.clone(), comment.clone(), other_issue.clone(), mine]; let items = group( events, + Vec::new(), me.public_key(), &InboxReadState::default(), &lookup(&[]), @@ -518,6 +616,7 @@ mod tests { let events = [issue.clone(), older.clone(), newer.clone()]; let items = group( events, + Vec::new(), me.public_key(), &InboxReadState::default(), &lookup(&[]), @@ -532,6 +631,7 @@ mod tests { }; let items = group( [issue.clone(), older, newer], + Vec::new(), me.public_key(), &state, &lookup(&[]), @@ -557,6 +657,7 @@ mod tests { let events = [issue.clone(), comment]; let items = group( events.clone(), + Vec::new(), me.public_key(), &InboxReadState::default(), &lookup(&events), @@ -566,6 +667,59 @@ mod tests { assert_eq!(items[0].address, issue.tags.coordinates().next()); } + #[test] + fn group_merges_own_events_into_the_matching_thread() { + let me = keys(1); + let issue = titled_issue(&me, "Add retry logic", 100); + let mine = signed( + &me, + Kind::Comment, + vec![ + uppercase_e_tag(&issue), + Tag::parse(["K", "1621"]).expect("K tag"), + ], + 150, + ); + let reply = signed( + &keys(2), + Kind::Comment, + vec![ + uppercase_e_tag(&issue), + Tag::parse(["K", "1621"]).expect("K tag"), + ], + 200, + ); + + let context = [issue.clone(), mine.clone(), reply.clone()]; + let items = group( + [reply.clone()], + [issue.clone(), mine.clone()], + me.public_key(), + &InboxReadState::default(), + &lookup(&context), + ); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].root, issue.id); + assert_eq!( + items[0].root_event.as_ref().map(|event| event.id), + Some(issue.id) + ); + assert_eq!(items[0].kind(), Some(Kind::GitIssue)); + assert_eq!(items[0].title(), "Add retry logic"); + assert_eq!(items[0].events, vec![reply.clone()]); + // The own events are kept apart from the notifications, newest first. + assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]); + assert_eq!( + items[0] + .timeline(5) + .iter() + .map(|event| event.id) + .collect::>(), + vec![issue.id, mine.id, reply.id] + ); + } + #[test] fn mark_all_read_marks_known_recent_events() { let me = keys(1); @@ -663,9 +817,11 @@ mod tests { let second = issue(&keys(2), now.as_secs() - 1000); let mut item = InboxItem { root: first.id, + root_event: None, root_kind: None, address: None, events: vec![second.clone(), first.clone()], + own_events: Vec::new(), unread_ids: Vec::new(), archived: false, }; diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index 51ccf62..011dba1 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -128,23 +128,16 @@ impl Inbox { } } -/// Derive the inbox home screen's lists for `me` from the local database. -/// -/// Returns the notification groups, the user's own git activity and the number -/// of non-archived groups with an unread event. +/// Derive the inbox home screen's threads for `me` from the local database. pub async fn query_inbox( client: &Client, me: PublicKey, state: &InboxReadState, -) -> Result<(Vec, Vec, usize), Error> { +) -> Result<(Vec, usize), Error> { let deletion_events = client.database().query(filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); - let (notification_events, by_id) = fetch_notifications(client, me, &deletions).await?; - let notifications = inbox::group(notification_events, me, state, &|id| { - by_id.get(&id).cloned() - }); - let unread_count = notifications.iter().filter(|item| item.is_unread()).count(); + let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?; let mut activity = Vec::new(); for event in client @@ -155,16 +148,17 @@ pub async fn query_inbox( if deletions.is_deleted(&event) || !filters::is_git_activity(&event) { continue; } + by_id.entry(event.id).or_insert_with(|| event.clone()); activity.push(event); } - activity.sort_by(|a, b| { - b.created_at - .cmp(&a.created_at) - .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) + let items = inbox::group(notification_events, activity, me, state, &|id| { + by_id.get(&id).cloned() }); - Ok((notifications, activity, unread_count)) + let unread_count = items.iter().filter(|item| item.is_unread()).count(); + + Ok((items, unread_count)) } /// `d` tag identifying the inbox state event of `me`. diff --git a/crates/signed_ui/src/user_avatar.rs b/crates/signed_ui/src/user_avatar.rs index 6d986a9..c080a56 100644 --- a/crates/signed_ui/src/user_avatar.rs +++ b/crates/signed_ui/src/user_avatar.rs @@ -1,7 +1,7 @@ use gpui::prelude::*; use gpui::{App, SharedString, StyleRefinement, Window}; use gpui_component::avatar::Avatar; -use gpui_component::{ActiveTheme, Sizable, StyledExt}; +use gpui_component::{ActiveTheme, Sizable, Size, StyledExt}; /// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius. /// It shows the user's picture or falls back to name initials. @@ -9,6 +9,7 @@ use gpui_component::{ActiveTheme, Sizable, StyledExt}; pub struct UserAvatar { name: SharedString, picture: Option, + size: Size, style: StyleRefinement, } @@ -19,6 +20,7 @@ impl UserAvatar { Self { name: name.into(), picture: None, + size: Size::Small, style: StyleRefinement::default(), } } @@ -30,6 +32,13 @@ impl UserAvatar { } } +impl Sizable for UserAvatar { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + impl Styled for UserAvatar { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -43,6 +52,6 @@ impl RenderOnce for UserAvatar { .when_some(self.picture, |this, url| this.src(url)) .rounded(cx.theme().radius) .refine_style(&self.style) - .small() + .with_size(self.size) } } diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index 4ce8b2b..109f661 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -12,10 +12,8 @@ use gpui::{ }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; -use nostr::prelude::{Event, EventId, Kind, Timestamp}; -use signed_core::{ - COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters, -}; +use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp}; +use signed_core::{COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, filters}; use signed_state::{ Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox, }; @@ -30,29 +28,21 @@ const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// Extra list rows measured above and below the visible area. const LIST_OVERDRAW: Pixels = px(400.); -/// A repository's slice of the inbox: its notification groups and own activity. +/// Maximum number of sub-activity lines shown under a thread row. +const MAX_SUB_ACTIVITIES: usize = 5; + +/// A repository's slice of the inbox: the threads that belong to it. struct InboxSection { /// Repository the section groups, `None` for items without one. address: Option, - /// Number of notification groups with an unread event. + /// Number of threads with an unread event. unread: usize, - /// Notification and activity rows, newest first. - entries: Vec, + /// Indices into the threads, newest activity first. + entries: Vec, /// Timestamp of the newest entry, used to order the sections. latest: Timestamp, } -/// One row inside a repository section, as an index into the inbox's own lists. -#[derive(Clone, Copy)] -enum InboxEntry { - /// Index into the notification groups. - Notification(usize), - /// Index into the user's own activity. - Activity(usize), -} - -/// One row of the flattened inbox: a repository header, one of its entries, or -/// the empty state of a repository without any. #[derive(Clone, Copy)] enum InboxRow { Repo(usize), @@ -63,15 +53,13 @@ enum InboxRow { pub struct InboxView { focus_handle: FocusHandle, dock_area: WeakEntity, - /// Notifications grouped by thread root, newest activity first. - notifications: Arc>, - /// The user's own recent git activity, newest first. - activity: Arc>, - /// The notification and activity lists grouped by repository, newest first. + /// One row per thread, merging notifications and own activity, newest first. + threads: Arc>, + /// The threads grouped by repository, newest first. sections: Arc>, /// The flattened repository headers and rows of the list. rows: Arc>, - /// Number of non-archived groups with an unread event. + /// Number of non-archived threads with an unread event. unread_count: usize, /// Copy of the global read state the current lists were derived with. state: InboxReadState, @@ -118,8 +106,7 @@ impl InboxView { Self { focus_handle: cx.focus_handle(), dock_area, - notifications: Arc::new(Vec::new()), - activity: Arc::new(Vec::new()), + threads: Arc::new(Vec::new()), sections: Arc::new(Vec::new()), rows: Arc::new(Vec::new()), unread_count: 0, @@ -144,17 +131,17 @@ impl InboxView { /// Re-derive from the global state when it is loaded or changes. pub fn sync_state(&mut self, cx: &mut Context) { - let inbox = Backend::global(cx).read(cx).inbox(); + let backend = Backend::global(cx); + let inbox = backend.read(cx).inbox(); + let (loaded, state) = { let inbox = inbox.read(cx); (inbox.is_loaded(), inbox.state().clone()) }; if !loaded { - let was_present = self.state_loaded - || !self.notifications.is_empty() - || !self.activity.is_empty() - || !self.sections.is_empty(); + let was_present = + self.state_loaded || !self.threads.is_empty() || !self.sections.is_empty(); self.clear(); if was_present { cx.notify(); @@ -240,7 +227,7 @@ impl InboxView { let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await }); self.tasks.push(cx.spawn(async move |this, cx| { - let (notifications, activity, unread_count) = match work.await { + let (threads, unread_count) = match work.await { Ok(results) => results, Err(error) => { log::warn!("inbox refresh failed: {error}"); @@ -254,8 +241,7 @@ impl InboxView { return false; } - this.notifications = Arc::new(notifications); - this.activity = Arc::new(activity); + this.threads = Arc::new(threads); this.unread_count = unread_count; this.rebuild(cx); cx.notify(); @@ -273,22 +259,22 @@ impl InboxView { /// Recompute the unread and archived flags from the current state. fn regroup(&mut self, cx: &mut Context) { - let mut items = (*self.notifications).clone(); + let mut items = (*self.threads).clone(); for item in items.iter_mut() { item.apply_state(&self.state); } self.unread_count = items.iter().filter(|item| item.is_unread()).count(); - self.notifications = Arc::new(items); + self.threads = Arc::new(items); self.rebuild(cx); } - /// Regroup the current lists by repository and flatten them into rows. + /// Regroup the current threads by repository and flatten them into rows. fn rebuild(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let repo_list = RepoListStore::global(cx); - let mut sections = group_sections(&self.notifications, &self.activity); + let mut sections = group_sections(&self.threads); if let Some(me) = backend.read(cx).current_user() { for announcement in repo_list.read(cx).announcements_of(&me) { @@ -317,8 +303,7 @@ impl InboxView { /// Forget everything derived for the current user. fn clear(&mut self) { - self.notifications = Arc::new(Vec::new()); - self.activity = Arc::new(Vec::new()); + self.threads = Arc::new(Vec::new()); self.sections = Arc::new(Vec::new()); self.rows = Arc::new(Vec::new()); self.unread_count = 0; @@ -328,9 +313,9 @@ impl InboxView { self.refresh = RefreshGate::default(); } - /// Every event in every group, archived groups included. + /// Every notification event in every thread, archived threads included. fn all_notification_events(&self) -> Vec { - self.notifications + self.threads .iter() .flat_map(|item| item.events.iter().cloned()) .collect() @@ -354,44 +339,36 @@ impl InboxView { return div().into_any_element(); }; - let Some(entry) = section.entries.get(entry_ix) else { + let Some(&thread_ix) = section.entries.get(entry_ix) else { return div().into_any_element(); }; - match *entry { - InboxEntry::Notification(item_ix) => { - let Some(item) = self.notifications.get(item_ix) else { - return div().into_any_element(); - }; + let Some(item) = self.threads.get(thread_ix) else { + return div().into_any_element(); + }; - let root = item.root; - let kind = item.root_kind; - let address = section.address.clone(); - let dock_area = self.dock_area.clone(); + let root = item.root; + let kind = item.root_kind; + let address = section.address.clone(); + let dock_area = self.dock_area.clone(); + let first = entry_ix == 0; + let last = entry_ix + 1 == section.entries.len(); - notification_row("inbox-row", ix, item, cx) - .on_click(move |_, window, cx| { - open_item(&dock_area, root, kind, address.clone(), window, cx); - }) - .into_any_element() - } - InboxEntry::Activity(event_ix) => { - let Some(event) = self.activity.get(event_ix) else { - return div().into_any_element(); - }; - activity_row(ix, event, cx) - } - } + thread("inbox-row", ix, item, first, last, cx) + .on_click(move |_, window, cx| { + open_item(&dock_area, root, kind, address.clone(), window, cx); + }) + .into_any_element() } } } } -/// Group the notification groups and own activity into one section per repository. -fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec { +/// Group the threads into one section per repository. +fn group_sections(threads: &[InboxItem]) -> Vec { let mut by_repo: HashMap, InboxSection> = HashMap::new(); - for (ix, item) in notifications.iter().enumerate() { + for (ix, item) in threads.iter().enumerate() { if item.archived { continue; } @@ -411,29 +388,16 @@ fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec = by_repo.into_values().collect(); for section in &mut sections { section.entries.sort_by(|a, b| { - entry_time(b, notifications, activity).cmp(&entry_time(a, notifications, activity)) + threads[*b] + .latest_activity() + .cmp(&threads[*a].latest_activity()) }); } @@ -441,20 +405,6 @@ fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec Timestamp { - match entry { - InboxEntry::Notification(ix) => notifications - .get(*ix) - .map(InboxItem::latest_activity) - .unwrap_or_default(), - InboxEntry::Activity(ix) => activity - .get(*ix) - .map(|event| event.created_at) - .unwrap_or_default(), - } -} - /// Flatten the sections into the list of repository headers and their rows. fn flatten_rows(sections: &[InboxSection]) -> Vec { let mut rows = Vec::new(); @@ -475,14 +425,6 @@ fn flatten_rows(sections: &[InboxSection]) -> Vec { rows } -/// Repository an activity event belongs to, from its `a` tag. -fn repo_address(event: &Event) -> Option { - event - .tags - .coordinates() - .find(|address| address.kind == Kind::GitRepoAnnouncement) -} - /// Display name of the repository at `addr`, from the announcement store. fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option { let repo_list = RepoListStore::global(cx); @@ -526,15 +468,13 @@ fn empty_section_row(cx: &App) -> AnyElement { .w_full() .px_3() .text_xs() - .text_color(cx.theme().muted_foreground) - .bg(cx.theme().secondary) + .text_color(cx.theme().secondary_foreground) + .bg(cx.theme().secondary.alpha(0.6)) .rounded(cx.theme().radius) .child(SharedString::from("No activity yet.")) .into_any_element() } -/// Open the repository of a notification group, -/// and the issue or pull request detail when the group's root is one. fn open_item( dock_area: &WeakEntity, root: EventId, @@ -572,30 +512,40 @@ fn open_item( open_repo_item(dock_area, store, item, window, cx); } -fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) -> Stateful
{ - let Some(newest) = item.events.first() else { - return div().id((prefix, ix)); - }; - - let profile_store = ProfileStore::global(cx); - let profile = profile_store.read(cx).get(&newest.pubkey); - let author = profile.name(); - - let picture = profile.picture(); - let kind = item.root_kind.unwrap_or(newest.kind); - let subject = SharedString::from(activity_subject(newest)); - let age = relative_time(item.latest_activity()); +fn thread( + prefix: &'static str, + ix: usize, + item: &InboxItem, + first: bool, + last: bool, + cx: &App, +) -> Stateful
{ + let title = SharedString::from(item.title()); + let kind = item.kind().unwrap_or(Kind::Comment); let unread = item.is_unread(); + let backend = Backend::global(cx); + let me = backend.read(cx).current_user(); + + let mut timeline = v_flex().gap_2().w_full(); + + for event in item.timeline(MAX_SUB_ACTIVITIES) { + timeline = timeline.child(sub_activity_line(&event, me, cx)); + } + v_flex() .id((prefix, ix)) - .h_16() .w_full() .px_3() - .gap_1() - .justify_center() - .bg(cx.theme().secondary) - .hover(|this| this.bg(cx.theme().secondary_hover)) + .py_2() + .gap_2() + .bg(cx.theme().secondary.alpha(0.6)) + .when(first, |this| this.rounded_t(cx.theme().radius)) + .when(last, |this| this.rounded_b(cx.theme().radius)) + .when(!last, |this| { + this.border_b_1().border_color(cx.theme().border) + }) + .hover(|this| this.bg(cx.theme().secondary_hover.alpha(0.8))) .child( h_flex() .gap_2() @@ -613,7 +563,7 @@ fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) .min_w_0() .whitespace_nowrap() .text_ellipsis() - .child(subject), + .child(title), ) .child(div().flex_1()) .when(unread, |this| { @@ -626,72 +576,44 @@ fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) ) }), ) - .child( - h_flex() - .w_full() - .gap_2() - .child(div().w_6().flex_shrink_0()) - .child( - h_flex() - .flex_1() - .gap_1() - .items_center() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child( - h_flex() - .gap_1() - .child(UserAvatar::new(author.clone()).picture(picture)) - .child(author.clone()), - ) - .child("opened") - .child(SharedString::from(kind_label(kind))) - .child(div().flex_1()) - .child(SharedString::from(age)), - ), - ) + .child(timeline) } -fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement { - let kind = event.kind; - let subject = SharedString::from(activity_subject(event)); - let age = relative_time(event.created_at); +fn sub_activity_line(event: &Event, me: Option, cx: &App) -> AnyElement { + let profile_store = ProfileStore::global(cx).read(cx); + let profile = profile_store.get(&event.pubkey); - v_flex() - .id(("activity-row", ix)) - .h_16() + let name = if Some(event.pubkey) == me { + SharedString::from("You") + } else { + profile.name() + }; + + h_flex() .w_full() - .px_3() - .gap_1() - .justify_center() - .bg(cx.theme().secondary) - .hover(|this| this.bg(cx.theme().secondary_hover)) + .gap_2() + .items_center() + .child(div().w_6().flex_shrink_0()) .child( h_flex() - .gap_2() - .text_sm() - .whitespace_nowrap() - .text_ellipsis() + .flex_1() + .min_w_0() + .gap_1() + .items_center() + .text_xs() .child( - h_flex() - .size_6() - .flex_shrink_0() - .items_center() - .justify_center() - .child(kind_icon(kind)), + UserAvatar::new(name.clone()) + .picture(profile.picture()) + .xsmall(), ) - .child(subject), - ) - .child( - h_flex().gap_2().child(div().w_6().flex_shrink_0()).child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(kind_label(kind))) - .child("·") - .child(SharedString::from(age)), - ), + .child(name) + .child(SharedString::from(activity_phrase(event.kind))) + .child(div().flex_1()) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(relative_time(event.created_at))), + ), ) .into_any_element() } @@ -718,23 +640,23 @@ fn kind_icon(kind: Kind) -> Icon { .small() } -/// Short noun for a notification or activity kind. -fn kind_label(kind: Kind) -> &'static str { +/// Phrase describing an activity event, read as `[name] [phrase]`. +fn activity_phrase(kind: Kind) -> &'static str { if kind == COVER_NOTE_KIND { - return "note"; + return "added a note"; } match kind { - Kind::GitIssue => "issue", - Kind::GitPullRequest => "PR", - Kind::GitPullRequestUpdate => "PR update", - Kind::GitPatch => "patch", - Kind::Comment => "comment", - Kind::GitStatusOpen - | Kind::GitStatusApplied - | Kind::GitStatusClosed - | Kind::GitStatusDraft => "status", - _ => "activity", + Kind::GitIssue => "opened an issue", + Kind::GitPullRequest => "opened a PR", + Kind::GitPullRequestUpdate => "updated a PR", + Kind::GitPatch => "created a patch", + Kind::Comment => "commented", + Kind::GitStatusOpen => "opened a status", + Kind::GitStatusApplied => "applied a status", + Kind::GitStatusClosed => "closed a status", + Kind::GitStatusDraft => "drafted a status", + _ => "did something", } } @@ -818,20 +740,30 @@ impl Render for InboxView { })), ), ) - .child(div().relative().flex_1().min_h_0().px_4().pb_4().when_else( - rows.is_empty(), - |this| this.child(empty_state(IconName::Inbox, "You're all caught up.", cx)), - |this| { - this.child( - list( - self.list.clone(), - cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)), - ) - .size_full() - .min_h_0() - .into_any_element(), + .child( + div() + .relative() + .flex_1() + .min_h_0() + .px_4() + .when_else( + rows.is_empty(), + |this| { + this.child(empty_state(IconName::Inbox, "You're all caught up.", cx)) + }, + |this| { + this.child( + list( + self.list.clone(), + cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)), + ) + .size_full() + .min_h_0() + .into_any_element(), + ) + }, ) - }, - )) + .child(div().h_6().w_full().flex_shrink_0()), + ) } } diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md index 5a7fae8..91d34ad 100644 --- a/docs/inbox-plan.md +++ b/docs/inbox-plan.md @@ -7,13 +7,14 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for > an account is active, and that home screen is the inbox. > **Status.** Phases 0-4 are implemented and green on `feat/inbox`, then the screen was redesigned to -> group notifications and activity **by repository** (see the repository-grouping note in §7). -> `cargo test -p signed_core` (68), `cargo test -p signed_state` (24), +> group **threads by repository** and to merge notifications with own activity into one row per thread +> (see the repository-grouping and thread-merge notes in §7). +> `cargo test -p signed_core` (69), `cargo test -p signed_state` (24), > `cargo test -p workspace` (7), `cargo test -p dock` (1), `cargo clippy -p workspace --all-targets` > clean, `cargo check --workspace --all-targets` succeeds. > Phase 5 is not started. This document reflects the implementation as it stands: the Phase 1 > refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned derivation, the -> Phase 4 click-through, and the repository-grouped list. The Phase 3 bottom-dock sub-views were +> Phase 4 click-through, and the repository-grouped thread list. The Phase 3 bottom-dock sub-views were > removed before the redesign; their implementation notes in §7 are historical. ## 1. What the GitWorkshop home screen is @@ -72,20 +73,24 @@ Notes: `InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item. It is one bordered card holding a single virtual list. Every row is either a **repository header** or one of that -repository's **notifications / own activity**, newest first: +repository's **threads**, newest first. A thread merges the notifications directed at the user with +the user's own events in the same root, and shows the root's title plus up to five of its most recent +events: The sections are **all of the user's own repositories**, seeded from `RepoListStore`, plus any other -repository that has notifications or activity. Owned repositories with nothing to show render an +repository that has threads. Owned repositories with nothing to show render an empty state ("No activity yet.") under their header, and sort after the ones with activity (newest -announcement first). Items with no repository address fall into a single "Other repository" section. +announcement first). Threads with no repository address fall into a single "Other repository" section. ``` +-------------------------------------------------------------------------+ | Inbox (3 unread) [Mark all read] | |-------------------------------------------------------------------------| | [repo] you/repo-a (2) | -| [avatar] issue opened issue 2m | -| [avatar] commented on "..." comment 1h | -| [icon] "Add retry" patch 3d | +| [icon] Add retry logic (unread dot) | +| [avatar] You opened an issue 3d | +| [avatar] alice commented 2d | +| [icon] Fix flaky test | +| [avatar] You opened a PR 1h | |-------------------------------------------------------------------------| | [repo] you/repo-b | | No activity yet. | @@ -95,8 +100,8 @@ announcement first). Items with no repository address fall into a single "Other +-------------------------------------------------------------------------+ ``` -The sections are the repositories that actually have notifications or activity, ordered by their -newest row. A repository the user owns but that has no items is not shown. Items with no repository +The sections are the repositories that actually have threads, ordered by their +newest row. A repository the user owns but that has no items is not shown. Threads with no repository address fall into a single "Other repository" section. ## 4. Data layer @@ -155,24 +160,42 @@ gitworkshop's `isGitComment`. ```rust pub struct InboxItem { pub root: EventId, + /// The root event itself, when known locally; drives the row title. + pub root_event: Option, pub root_kind: Option, pub address: Option, - /// Events in the group, newest first. + /// Notification events directed at the user, newest first. pub events: Vec, + /// The user's own events in the same thread, newest first. + pub own_events: Vec, /// Unread event ids, oldest first. pub unread_ids: Vec, pub archived: bool, } +impl InboxItem { + /// Title of the thread root; falls back to the newest event it has. + pub fn title(&self) -> String; + /// Kind of the thread root; falls back to the newest event it has. + pub fn kind(&self) -> Option; + pub fn latest_activity(&self) -> Timestamp; + /// Up to `limit` most recent events of the thread, oldest first. + pub fn timeline(&self, limit: usize) -> Vec; + pub fn is_unread(&self) -> bool; + pub fn apply_state(&mut self, state: &InboxReadState); +} + /// The thread root of a notification event, or `None` if it isn't git-related. pub fn notification_root( event: &Event, lookup: &impl Fn(EventId) -> Option, ) -> Option; -/// Group notification events by root, newest activity first, self excluded. +/// Group the notifications directed at the user together with the user's own +/// events into one item per thread, newest activity first. pub fn group( events: impl IntoIterator, + own: impl IntoIterator, me: PublicKey, state: &InboxReadState, lookup: &impl Fn(EventId) -> Option, @@ -186,7 +209,10 @@ Root resolution, ported from `getNotificationRootId`: - NIP-22 comment (1111): uppercase `E` root pointer (SDK `nip22::extract_root`) - PR update (1619): uppercase `E` - statuses (1630-1633) / cover note (1624): NIP-10 root `e` -- self-authored events are excluded +- notification events authored by `me` are dropped; the user's own events are kept in + `own_events` instead, never in `events` +- `unread_ids` and `archived` are derived from `events` only, so the user's own activity is never + unread and a thread with only own events is never archived Read/archive state, the compact high-water-mark model: @@ -290,7 +316,7 @@ pub struct Backend { #[derive(Default)] pub struct Inbox { state: InboxReadState, - state_loaded: bool, + loaded: bool, } impl Inbox { @@ -302,6 +328,14 @@ impl Inbox { pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx); pub(crate) fn reset(&mut self, cx); } + +// inbox.rs (signed_state) +/// One item per thread, notifications and own activity merged. +pub async fn query_inbox( + client: &Client, + me: PublicKey, + state: &InboxReadState, +) -> Result<(Vec, usize), Error>; ``` **The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read @@ -314,8 +348,7 @@ badge only; there is no global count and no sidebar badge. pub struct InboxView { focus_handle: FocusHandle, dock_area: WeakEntity, - notifications: Arc>, - activity: Arc>, + threads: Arc>, // one row per thread, merged sections: Arc>, // grouped by repository rows: Arc>, // flattened list unread_count: usize, @@ -599,15 +632,15 @@ whose root is not an issue/PR/patch, or whose repository is not in `RepoListStor |---|---| | `crates/signed_core/Cargo.toml` | add `serde` | | `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity`, `is_git_activity`, `deletions` | -| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests | +| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem` (root event, notifications, own events), `notification_root`, `group`, `InboxReadState`, tests | | `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports | | `crates/signed_state/Cargo.toml` | add `serde_json` | -| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state, mark actions) and `query_inbox` (query, grouping, activity) | +| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state, mark actions) and `query_inbox` (query, merge notifications + activity into threads) | | `crates/signed_state/src/backend.rs` | `inbox: Entity` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import | | `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users | | `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) | | `crates/dock/src/lib.rs` | `add_bottom_panel` helper (currently unused; left over from the removed sub-views) | -| `crates/workspace/src/views/inbox.rs` | `InboxView` home panel owning the derived lists, the repository grouping, and the notification click-through | +| `crates/workspace/src/views/inbox.rs` | `InboxView` home panel owning the threads, the repository grouping, and the thread click-through | | `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` | | `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring | | `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item`, `RepoDetailView::store()` | @@ -859,9 +892,55 @@ itself, so the global is thin again. `cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1). +### Threads merged: notifications + activity (after the sidebar badge removal) + +Files: `crates/signed_core/src/inbox.rs`, `crates/signed_state/src/inbox.rs`, +`crates/workspace/src/views/inbox.rs`. + +Notifications and own activity were two separate row kinds that could describe the same thread. They +are now one item per thread: the notifications directed at the user and the user's own events in that +thread live in the same `InboxItem`. A row shows the thread root's title and up to five of the +thread's most recent events: + +``` +[icon] Add retry logic (unread dot) + [avatar] You opened an issue · 3d + [avatar] alice commented · 2d +``` + +- `InboxItem` gained `root_event: Option` and `own_events: Vec`. `events` keeps only the + notifications (others' events); `own_events` holds the user's own. `unread_ids`/`archived` are + derived from `events` alone, so own activity is never unread and a thread with only own events is + never archived (`apply_state` guards the empty case). +- New methods on `InboxItem`: `title()` (root event's subject, falling back to the newest event), + `kind()` (root kind, same fallback), and `timeline(limit)` (thread events deduplicated by id, + oldest first, always keeping the root event and filling the remaining slots with the most recent + others). +- `group` now takes both `events` (notifications) and `own` (the user's activity) and merges them on + the resolved root. Own events resolve through the same `notification_root`; an unresolved own event + becomes its own root. `query_inbox` returns `(Vec, usize)` - the separate activity list + is gone, and `by_id` is extended with the own events so a comment of ours resolves to its thread. +- The panel holds `threads: Arc>` instead of `notifications` + `activity`. The + `InboxEntry` enum, `entry_time`, `repo_address`, `related_activity`, `notification_row`, + `activity_row` and `kind_label` are gone. `thread_row` replaces both row kinds and is clickable like + the old notification row; `group_sections` now just buckets threads by `item.address`. +- `sub_activity_line` is unchanged and still renders `[avatar] [name] [phrase] · [ago]`, with `You` + for the signed-in user and `activity_phrase(kind)` for the verb. Rows are variable height + (`py_2`), which `gpui::list` auto-measures. +- Thread rows in a section are drawn as one stack: `render_entry` passes `first`/`last` within the + section (`entry_ix == 0` / `entry_ix + 1 == section.entries.len()`), and `thread_row` rounds the + outer edges (`rounded_t` on the first, `rounded_b` on the last, theme radius) and draws a + `border_b_1` divider on every row but the last. +- Trade-off: the row title is the thread root's, not the newest event's, so a comment thread no longer + previews the comment text. That is the point of the merge - the row identifies the thread. +- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean, + `cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and + `cargo test -p signed_core -p signed_state -p workspace` passes (69 / 24 / 7). + ## 8. Validation -- `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip. +- `cargo test -p signed_core` (69 tests): root resolution, grouping, merging, read-state cutoff, serde + round-trip. - `cargo test -p signed_state` (24 tests): the `Inbox` / `query_inbox` paths that do not need GPUI (state round-trip, grouping helpers). - `cargo test -p workspace` (7 tests): repository-detail helpers. -- 2.54.0 From 8a229cb212348bc157065923395ab1fe58741925 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 12 Sep 2026 10:27:36 +0700 Subject: [PATCH 10/10] update --- crates/workspace/src/views/inbox.rs | 252 ++++++++---------- crates/workspace/src/views/repo_detail/mod.rs | 41 +-- docs/inbox-plan.md | 44 +-- 3 files changed, 158 insertions(+), 179 deletions(-) diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index 109f661..01d7fee 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Error; -use assets::CustomIconName; use dock::{BasePanel, DockArea, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ @@ -20,7 +19,7 @@ use signed_state::{ use signed_ui::{CountBadge, UserAvatar}; use utils::relative_time; -use super::{RepoItem, open_repo_item, open_repo_panel}; +use super::{RepoItem, open_repo_item}; /// Delay between a refresh request and the actual re-query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); @@ -124,8 +123,16 @@ impl InboxView { let Some(me) = Backend::global(cx).read(cx).current_user() else { return; }; - let all = self.all_notification_events(); - let inbox = Backend::global(cx).read(cx).inbox(); + + let all: Vec = self + .threads + .iter() + .flat_map(|item| item.events.iter().cloned()) + .collect(); + + let backend = Backend::global(cx); + let inbox = backend.read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx)); } @@ -274,7 +281,7 @@ impl InboxView { fn rebuild(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let repo_list = RepoListStore::global(cx); - let mut sections = group_sections(&self.threads); + let mut sections = self.group_sections(); if let Some(me) = backend.read(cx).current_user() { for announcement in repo_list.read(cx).announcements_of(&me) { @@ -296,11 +303,72 @@ impl InboxView { sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); - let rows = flatten_rows(§ions); + let rows = self.flatten_rows(§ions); self.sections = Arc::new(sections); self.rows = Arc::new(rows); } + /// Group the threads into one section per repository. + fn group_sections(&self) -> Vec { + let mut by_repo: HashMap, InboxSection> = HashMap::new(); + + for (ix, item) in self.threads.iter().enumerate() { + if item.archived { + continue; + } + + let address = item.address.clone(); + let section = by_repo + .entry(address.clone()) + .or_insert_with(move || InboxSection { + address, + unread: 0, + entries: Vec::new(), + latest: Timestamp::default(), + }); + + if item.is_unread() { + section.unread += 1; + } + + section.latest = section.latest.max(item.latest_activity()); + section.entries.push(ix); + } + + let mut sections: Vec = by_repo.into_values().collect(); + + for section in &mut sections { + section.entries.sort_by(|a, b| { + self.threads[*b] + .latest_activity() + .cmp(&self.threads[*a].latest_activity()) + }); + } + + sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); + sections + } + + /// Flatten the sections into the list of repository headers and their rows. + fn flatten_rows(&self, sections: &[InboxSection]) -> Vec { + let mut rows = Vec::new(); + + for (section_ix, section) in sections.iter().enumerate() { + rows.push(InboxRow::Repo(section_ix)); + + if section.entries.is_empty() { + rows.push(InboxRow::Empty); + continue; + } + + rows.extend( + (0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)), + ); + } + + rows + } + /// Forget everything derived for the current user. fn clear(&mut self) { self.threads = Arc::new(Vec::new()); @@ -313,15 +381,39 @@ impl InboxView { self.refresh = RefreshGate::default(); } - /// Every notification event in every thread, archived threads included. - fn all_notification_events(&self) -> Vec { - self.threads + fn open( + &self, + root: EventId, + kind: Option, + address: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(address) = address else { + return; + }; + + let Some(announcement) = RepoListStore::global(cx) + .read(cx) + .announcements .iter() - .flat_map(|item| item.events.iter().cloned()) - .collect() + .find(|announcement| announcement.addr() == address) + .cloned() + else { + return; + }; + + let item = match kind { + Some(Kind::GitIssue) => RepoItem::Issue(root), + Some(Kind::GitPullRequest) => RepoItem::PullRequest(root), + Some(Kind::GitPatch) => RepoItem::Patch, + _ => return, + }; + + open_repo_item(&self.dock_area, &announcement, item, window, cx); } - fn render_entry(&self, ix: usize, cx: &App) -> AnyElement { + fn render_entry(&self, ix: usize, cx: &Context) -> AnyElement { let Some(row) = self.rows.get(ix) else { return div().into_any_element(); }; @@ -350,81 +442,19 @@ impl InboxView { let root = item.root; let kind = item.root_kind; let address = section.address.clone(); - let dock_area = self.dock_area.clone(); let first = entry_ix == 0; let last = entry_ix + 1 == section.entries.len(); thread("inbox-row", ix, item, first, last, cx) - .on_click(move |_, window, cx| { - open_item(&dock_area, root, kind, address.clone(), window, cx); - }) + .on_click(cx.listener(move |this, _ev, window, cx| { + this.open(root, kind, address.clone(), window, cx) + })) .into_any_element() } } } } -/// Group the threads into one section per repository. -fn group_sections(threads: &[InboxItem]) -> Vec { - let mut by_repo: HashMap, InboxSection> = HashMap::new(); - - for (ix, item) in threads.iter().enumerate() { - if item.archived { - continue; - } - - let address = item.address.clone(); - let section = by_repo - .entry(address.clone()) - .or_insert_with(move || InboxSection { - address, - unread: 0, - entries: Vec::new(), - latest: Timestamp::default(), - }); - - if item.is_unread() { - section.unread += 1; - } - - section.latest = section.latest.max(item.latest_activity()); - section.entries.push(ix); - } - - let mut sections: Vec = by_repo.into_values().collect(); - - for section in &mut sections { - section.entries.sort_by(|a, b| { - threads[*b] - .latest_activity() - .cmp(&threads[*a].latest_activity()) - }); - } - - sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); - sections -} - -/// Flatten the sections into the list of repository headers and their rows. -fn flatten_rows(sections: &[InboxSection]) -> Vec { - let mut rows = Vec::new(); - - for (section_ix, section) in sections.iter().enumerate() { - rows.push(InboxRow::Repo(section_ix)); - - if section.entries.is_empty() { - rows.push(InboxRow::Empty); - continue; - } - - rows.extend( - (0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)), - ); - } - - rows -} - /// Display name of the repository at `addr`, from the announcement store. fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option { let repo_list = RepoListStore::global(cx); @@ -475,43 +505,6 @@ fn empty_section_row(cx: &App) -> AnyElement { .into_any_element() } -fn open_item( - dock_area: &WeakEntity, - root: EventId, - kind: Option, - address: Option, - window: &mut Window, - cx: &mut App, -) { - let Some(address) = address else { - return; - }; - - let Some(announcement) = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|announcement| announcement.addr() == address) - .cloned() - else { - return; - }; - - let detail = open_repo_panel(dock_area, &announcement, window, cx); - let Some(store) = detail.read(cx).store() else { - return; - }; - - let item = match kind { - Some(Kind::GitIssue) => RepoItem::Issue(root), - Some(Kind::GitPullRequest) => RepoItem::PullRequest(root), - Some(Kind::GitPatch) => RepoItem::Patch, - _ => return, - }; - - open_repo_item(dock_area, store, item, window, cx); -} - fn thread( prefix: &'static str, ix: usize, @@ -521,7 +514,6 @@ fn thread( cx: &App, ) -> Stateful
{ let title = SharedString::from(item.title()); - let kind = item.kind().unwrap_or(Kind::Comment); let unread = item.is_unread(); let backend = Backend::global(cx); @@ -530,7 +522,7 @@ fn thread( let mut timeline = v_flex().gap_2().w_full(); for event in item.timeline(MAX_SUB_ACTIVITIES) { - timeline = timeline.child(sub_activity_line(&event, me, cx)); + timeline = timeline.child(sub_activity(&event, me, cx)); } v_flex() @@ -543,7 +535,7 @@ fn thread( .when(first, |this| this.rounded_t(cx.theme().radius)) .when(last, |this| this.rounded_b(cx.theme().radius)) .when(!last, |this| { - this.border_b_1().border_color(cx.theme().border) + this.border_b_1().border_color(cx.theme().background) }) .hover(|this| this.bg(cx.theme().secondary_hover.alpha(0.8))) .child( @@ -556,7 +548,7 @@ fn thread( .flex_shrink_0() .items_center() .justify_center() - .child(kind_icon(kind)), + .child(Icon::new(IconName::Bell)), ) .child( div() @@ -579,7 +571,7 @@ fn thread( .child(timeline) } -fn sub_activity_line(event: &Event, me: Option, cx: &App) -> AnyElement { +fn sub_activity(event: &Event, me: Option, cx: &App) -> AnyElement { let profile_store = ProfileStore::global(cx).read(cx); let profile = profile_store.get(&event.pubkey); @@ -618,28 +610,6 @@ fn sub_activity_line(event: &Event, me: Option, cx: &App) -> AnyEleme .into_any_element() } -/// Leading icon for a notification or activity kind. -fn kind_icon(kind: Kind) -> Icon { - if kind == COVER_NOTE_KIND { - return Icon::new(IconName::File).small(); - } - - match kind { - Kind::GitIssue => Icon::new(CustomIconName::GitIssueOngoing), - Kind::GitPullRequest | Kind::GitPullRequestUpdate => { - Icon::new(CustomIconName::GitPullRequest) - } - Kind::GitPatch => Icon::new(CustomIconName::GitCommit), - Kind::Comment => Icon::new(IconName::Star), - Kind::GitStatusOpen - | Kind::GitStatusApplied - | Kind::GitStatusClosed - | Kind::GitStatusDraft => Icon::new(CustomIconName::GitIssueOpen), - _ => Icon::new(IconName::Bell), - } - .small() -} - /// Phrase describing an activity event, read as `[name] [phrase]`. fn activity_phrase(kind: Kind) -> &'static str { if kind == COVER_NOTE_KIND { diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 575ef3c..1ff0b63 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -231,12 +231,6 @@ impl RepoDetailView { view } - /// The per-repository nostr store, so another panel can open one of its - /// items. `None` until a local repository is initialized to NIP-34. - pub(crate) fn store(&self) -> Option> { - self.store.clone() - } - /// Open a local repository discovered by the scan. pub fn new_local( dock_area: WeakEntity, @@ -2660,6 +2654,11 @@ pub(crate) fn open_repo_panel( detail } +/// The nostr store of `announcement`'s repository, without opening a repository panel. +fn repo_store(announcement: &Announcement, cx: &mut App) -> Entity { + cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)) +} + /// An item of a repository to open from outside its detail panel. /// A patch has no detail view in Signed, so it opens nothing. pub(crate) enum RepoItem { @@ -2668,26 +2667,34 @@ pub(crate) enum RepoItem { Patch, } -/// Open the detail panel of `item` in `store`'s repository, in the dock's center. +/// Open the detail panel of `item` in `announcement`'s repository, in the dock's center. +/// +/// The repository store is built here, not taken from a `RepoDetailView`, so the +/// item panel is the only panel docked. /// /// A patch opens nothing: patches are only consumed inside a pull request's /// detail panel, and have no panel of their own. pub(crate) fn open_repo_item( dock_area: &WeakEntity, - store: Entity, + announcement: &Announcement, item: RepoItem, window: &mut Window, cx: &mut App, ) { - let panel: Arc = match item { - RepoItem::Issue(issue_id) => { - panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) - } - RepoItem::PullRequest(pr_id) => panel_handle( - cx.new(|cx| PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)), - ), - RepoItem::Patch => return, - }; + let panel: Arc = + match item { + RepoItem::Issue(issue_id) => { + let store = repo_store(announcement, cx); + panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) + } + RepoItem::PullRequest(pr_id) => { + let store = repo_store(announcement, cx); + panel_handle(cx.new(|cx| { + PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx) + })) + } + RepoItem::Patch => return, + }; let Some(dock_area) = dock_area.upgrade() else { return; diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md index 91d34ad..a0e51ed 100644 --- a/docs/inbox-plan.md +++ b/docs/inbox-plan.md @@ -59,7 +59,7 @@ Data hooks: | Priority | Section | Notes | |---|---|---| | **P0** | Inbox panel | Activity directed at you and your own activity, **grouped by repository**; unread badge; mark all read; all groups shown | -| **P1** | Click-through | Open the repo panel at the relevant PR/issue | +| **P1** | Click-through | Open the issue/PR detail panel at the relevant thread root | | **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns | | **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories, Unread/Archived sub-views | Not needed in Signed | @@ -592,7 +592,6 @@ In `views/sidebar/mod.rs`: ### 5.4 Click-through (P1) -Reuse the single `RepoStore` that `RepoDetailView` already creates instead of making a second one. The detail panels need a `Window`, and GPUI's `Entity::update_in` only exists on a `VisualContext`, which a synchronous `App` + `Window` pair is not - so the entry point is a free function rather than a `RepoDetailView::open_item` method. In `repo_detail/mod.rs`: @@ -606,25 +605,28 @@ pub(crate) enum RepoItem { pub(crate) fn open_repo_item( dock_area: &WeakEntity, - store: Entity, + announcement: &Announcement, item: RepoItem, window: &mut Window, cx: &mut App, -) { /* new IssueDetailView / PullRequestDetailView, added to the center */ } +) { /* build the RepoStore here, then a new IssueDetailView / PullRequestDetailView, added to the center */ } ``` -- `RepoDetailView::store()` exposes its `Option>`, so the caller reuses the repo - panel's store rather than building one. `views/mod.rs` re-exports `RepoItem` and `open_repo_item`. -- `InboxView` resolves `item.address` to an `Announcement` from `RepoListStore`, calls - `open_repo_panel` (which returns `Entity`), takes its store, and calls +- `open_repo_item` builds its own `RepoStore` from `announcement` (a private `repo_store` helper calls + `RepoStore::new(addr, relays, cx)`), so the item panel is the **only** panel docked. An earlier + version opened `RepoDetailView` first and reused its store via `RepoDetailView::store()`; that + docked the repository panel too, which surfaced the repository load state (a `not found` error for + an announced repo with no local worktree) and left two center tabs. `RepoDetailView::store()` was + removed with it. +- `views/mod.rs` re-exports `RepoItem` and `open_repo_item`. +- `InboxView::open_item` resolves `item.address` to an `Announcement` from `RepoListStore`, and calls `open_repo_item` with the root id and kind. The detail panel renders a "not found" placeholder until the store's fetch lands, then re-renders. -- The repository panel and the detail panel are two tabs of the center group; the detail is - activated. This matches the sidebar, which also opens a fresh repo panel per click. +- The item panel is added to the center group and activated. Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a -patch-root click opens the repo panel only. `RepoItem::Patch` carries no id for that reason. A group -whose root is not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing. +patch-root click opens nothing. `RepoItem::Patch` carries no id for that reason. A group whose root is +not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing. ## 6. File-by-file change list @@ -643,7 +645,7 @@ whose root is not an issue/PR/patch, or whose repository is not in `RepoListStor | `crates/workspace/src/views/inbox.rs` | `InboxView` home panel owning the threads, the repository grouping, and the thread click-through | | `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` | | `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring | -| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item`, `RepoDetailView::store()` | +| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item` (builds its own `RepoStore` via the private `repo_store` helper) | No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox` activates the `Inbox` child entity at each signer transition. @@ -822,23 +824,23 @@ store changes. Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No store changes. - `RepoItem { Issue(EventId), PullRequest(EventId), Patch }` and `pub(crate) fn open_repo_item` live - in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` takes the store as a - parameter, avoiding a second `RepoStore`. + in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` builds its own `RepoStore` from + the announcement (private `repo_store` helper), so only the item panel is docked. - It is a free function, not `RepoDetailView::open_item`: the detail constructors take a `Window`, and a synchronous `&mut App` + `&mut Window` pair is not a `VisualContext`, so `Entity::update_in` is not available. `InboxView` already has the window in the list's `on_click`, so it drives the free function directly. The plan's original `detail.update_in(window, cx, ...)` sketch could not compile. -- `RepoDetailView::store()` (`pub(crate)`) exposes the panel's `Option>`. The repo - panel is opened first and its store reused, so the detail panel shares one store with the repo it - came from. - `InboxView::open_item` is also a free function (it needs nothing but `dock_area`, which it captures from the panel) because the `gpui::list` item closure only receives `&mut App`. It resolves - `item.address` through `RepoListStore`, returns silently when the repository is unknown, opens the - repo panel, then maps the root kind to a `RepoItem` and calls `open_repo_item`. + `item.address` through `RepoListStore`, returns silently when the repository is unknown, maps the + root kind to a `RepoItem`, and calls `open_repo_item`. +- Fixed: the first version opened `RepoDetailView` to borrow its store (`RepoDetailView::store()`), + which docked the repository panel alongside the item panel and showed its `not found` load error. + `open_repo_item` now builds the `RepoStore` itself and `RepoDetailView::store()` is gone. - Only the notification rows are clickable. Activity rows are display-only. The Phase 3 mark-read / archive row behaviour is gone with the sub-views. - `RepoItem::Patch` is a unit variant because the id would be unused: patches have no detail panel, so - `open_repo_item` returns before doing anything and only the repository panel opens. + `open_repo_item` returns before doing anything and nothing is docked. - `cargo clippy -p workspace --all-targets` is clean, `cargo check --workspace --all-targets` succeeds, and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1). -- 2.54.0