From 40deb9db66118b3e65b2b9230901dd7a49a39bbf Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 12 Sep 2026 03:34:51 +0000 Subject: [PATCH] feat: add inbox panel (#18) Reviewed-on: https://git.reya.info/reya/signed/pulls/18 --- Cargo.lock | 349 ++++--- crates/dock/src/lib.rs | 13 + crates/signed_core/Cargo.toml | 4 + crates/signed_core/src/filters.rs | 190 +++- crates/signed_core/src/inbox.rs | 855 +++++++++++++++ crates/signed_core/src/lib.rs | 5 + crates/signed_state/Cargo.toml | 1 + crates/signed_state/src/backend.rs | 146 ++- crates/signed_state/src/inbox.rs | 263 +++++ crates/signed_state/src/lib.rs | 19 +- crates/signed_state/src/refresh.rs | 5 - crates/signed_ui/src/user_avatar.rs | 13 +- crates/workspace/src/views/inbox.rs | 739 +++++++++++++ crates/workspace/src/views/mod.rs | 4 +- crates/workspace/src/views/repo_detail/mod.rs | 57 +- crates/workspace/src/views/sidebar/mod.rs | 38 +- docs/inbox-plan.md | 973 ++++++++++++++++++ 17 files changed, 3429 insertions(+), 245 deletions(-) create mode 100644 crates/signed_core/src/inbox.rs create mode 100644 crates/signed_state/src/inbox.rs create mode 100644 crates/workspace/src/views/inbox.rs create mode 100644 docs/inbox-plan.md diff --git a/Cargo.lock b/Cargo.lock index 271f8bc..9d23ca2 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]] @@ -8004,6 +8048,7 @@ dependencies = [ "nostr-connect", "nostr-sdk", "rustls", + "serde_json", "settings", "signed_core", "signed_git", @@ -8108,9 +8153,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 +8218,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 +8309,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 +8546,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 +8557,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 +8994,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 +9644,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 +9671,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 +9707,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 +9723,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 +9962,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 +9985,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 +9997,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 +10010,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 +10116,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 +10148,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 +10219,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 +10279,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 +10977,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 +11002,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 +11203,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 +11227,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 +11276,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 +11374,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 +11475,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 +11492,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 +11503,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/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/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..463ad48 --- /dev/null +++ b/crates/signed_core/src/inbox.rs @@ -0,0 +1,855 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use nostr::prelude::*; +use serde::{Deserialize, Serialize}; + +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); + +/// 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 and own-activity events sharing one root. +#[derive(Debug, Clone)] +pub struct InboxItem { + /// 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, + /// 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 notification event in the thread is archived. + pub archived: bool, +} + +impl InboxItem { + /// 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.root_event + .as_ref() + .into_iter() + .chain(self.own_events.first()) + .chain(self.events.first()) + .map(|event| event.created_at) + .max() + .unwrap_or_default() + } + + /// 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() + } + + /// 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(); + + // A thread without notification events is never archived. + self.archived = + !self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event)); + } +} + +/// 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 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(); + 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 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| { + 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); + + 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()), + root_event, + events, + own_events, + unread_ids: Vec::new(), + archived: false, + }; + item.apply_state(state); + item + }) + .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 +} + +/// 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 { + #[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 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; + 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) + } + + 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 = [ + 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, + Vec::new(), + 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, + Vec::new(), + 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], + Vec::new(), + 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(), + Vec::new(), + 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 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); + 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 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_event: None, + root_kind: None, + address: None, + events: vec![second.clone(), first.clone()], + own_events: Vec::new(), + 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); + 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/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..f1722d8 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"; @@ -32,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)] @@ -77,21 +75,17 @@ impl BackendEvent { } } -/// The global backend entity. -/// -/// Owns the nostr client, the signer and the notification pump. 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 +106,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 +128,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 +155,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| cx.emit(BackendEvent::NostrUpdate(batch))) + { log::warn!("failed to emit nostr update: {e}"); } } @@ -167,7 +168,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,53 +178,54 @@ 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())))?; } } - Ok(()) + Ok::<(), Error>(()) }); notify_task.detach(); - - self.restore_session(cx); } - /// 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") { cx.emit(BackendEvent::SignerRequired); @@ -237,7 +238,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| cx.emit(BackendEvent::SignerRequired))?; return Ok(()); } }; @@ -258,15 +259,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); })?; } else { - this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?; } Ok::<_, Error>(()) @@ -274,7 +273,7 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |_, cx| { + this.update(cx, |_this, cx| { cx.emit(BackendEvent::error(e.to_string())); cx.emit(BackendEvent::SignerRequired); })?; @@ -360,7 +359,10 @@ impl Backend { this.signer.swap_inner(keys); this.current_user = Some(public_key); this.bootstrap_user(public_key, cx); + cx.emit(BackendEvent::SignerChanged); + this.sync_inbox(cx); + cx.notify(); let relays: Vec<(RelayUrl, Option)> = [ @@ -968,9 +970,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://...", - )); + cx.emit(BackendEvent::error("Unsupported credential.")); } } @@ -999,7 +999,7 @@ 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| cx.emit(BackendEvent::error(e.to_string())))?; return Ok(()); } this.update(cx, |this, cx| this.set_signer(keys, cx))?; @@ -1045,7 +1045,7 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) @@ -1066,6 +1066,7 @@ impl Backend { this.passphrase_required = false; cx.emit(BackendEvent::SignerChanged); cx.emit(BackendEvent::SignerRequired); + this.sync_inbox(cx); cx.notify(); })?; @@ -1096,7 +1097,7 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) @@ -1121,6 +1122,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 @@ -1136,6 +1144,35 @@ impl Backend { cx.emit(BackendEvent::error(message)); } + /// Attach the inbox to the current signer and activate or clear it. + fn sync_inbox(&mut self, cx: &mut Context) { + 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); + + 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); + } + } + + self.inbox.update(cx, |inbox, cx| match me { + Some(me) => inbox.activate(me, client, cx), + None => inbox.reset(cx), + }); + } + /// Progress of the in-flight negentropy sync, if any. pub fn sync_progress(&self) -> Option<(u64, u64)> { self.sync_progress @@ -1149,7 +1186,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| { @@ -1158,6 +1195,7 @@ impl Backend { this.passphrase_required = false; this.bootstrap_user(public_key, cx); cx.emit(BackendEvent::SignerChanged); + this.sync_inbox(cx); cx.notify(); })?; } @@ -1168,15 +1206,12 @@ impl Backend { } } - 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 +1220,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 +1236,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| { + cx.emit(BackendEvent::error(e.to_string())); + })?; } - 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; diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs new file mode 100644 index 0000000..011dba1 --- /dev/null +++ b/crates/signed_state/src/inbox.rs @@ -0,0 +1,263 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::Error; +use gpui::{AppContext, Context, Task}; +use nostr_sdk::prelude::*; +use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox}; + +use crate::backend::Backend; + +/// The user's persisted inbox read state. +#[derive(Default)] +pub struct Inbox { + state: InboxReadState, + /// Set once the stored state has been read for the current user. + loaded: bool, +} + +impl Inbox { + /// The current read/archive cutoffs. + pub fn state(&self) -> &InboxReadState { + &self.state + } + + /// Whether the stored state has been read for the current user. + pub fn is_loaded(&self) -> bool { + self.loaded + } + + /// 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 now = Timestamp::now(); + 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, all: &[Event], me: PublicKey, cx: &mut Context) { + self.state.mark_all_read(all, me, Timestamp::now()); + self.persist(cx); + cx.notify(); + } + + /// 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.loaded = false; + 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| { + 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.loaded = true; + cx.notify(); + })?; + + Ok::<(), Error>(()) + }) + .detach(); + } + + /// Clear the state of the signed-out user. + pub(crate) fn reset(&mut self, cx: &mut Context) { + self.state = InboxReadState::default(); + self.loaded = false; + 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(); + } +} + +/// 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, usize), Error> { + let deletion_events = client.database().query(filters::deletions()).await?; + let deletions = Deletions::from_events(deletion_events); + + let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?; + + 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; + } + by_id.entry(event.id).or_insert_with(|| event.clone()); + activity.push(event); + } + + let items = inbox::group(notification_events, activity, me, state, &|id| { + by_id.get(&id).cloned() + }); + + let unread_count = items.iter().filter(|item| item.is_unread()).count(); + + Ok((items, unread_count)) +} + +/// `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..6d57e37 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,22 +12,23 @@ 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, 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; /// 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 +39,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/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 new file mode 100644 index 0000000..01d7fee --- /dev/null +++ b/crates/workspace/src/views/inbox.rs @@ -0,0 +1,739 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Error; +use dock::{BasePanel, DockArea, Panel, PanelEvent}; +use gpui::prelude::*; +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::button::{Button, ButtonVariants}; +use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; +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, +}; +use signed_ui::{CountBadge, UserAvatar}; +use utils::relative_time; + +use super::{RepoItem, open_repo_item}; + +/// Delay between a refresh request and the actual re-query. +const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); + +/// Extra list rows measured above and below the visible area. +const LIST_OVERDRAW: Pixels = px(400.); + +/// 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 threads with an unread event. + unread: usize, + /// Indices into the threads, newest activity first. + entries: Vec, + /// Timestamp of the newest entry, used to order the sections. + latest: Timestamp, +} + +#[derive(Clone, Copy)] +enum InboxRow { + Repo(usize), + Entry(usize, usize), + Empty, +} + +pub struct InboxView { + focus_handle: FocusHandle, + dock_area: WeakEntity, + /// 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 threads 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, + list: ListState, + tasks: Vec>>, + _subscriptions: Vec, +} + +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 list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); + 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); + })); + + // 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}"); + } + }); + + Self { + focus_handle: cx.focus_handle(), + dock_area, + threads: 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(), + list, + tasks: vec![], + _subscriptions: subscriptions, + } + } + + /// 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: 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)); + } + + /// Re-derive from the global state when it is loaded or changes. + pub fn sync_state(&mut self, cx: &mut Context) { + 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.threads.is_empty() || !self.sections.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(cx); + cx.notify(); + } + } + + /// 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) => { + 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); + } + } + BackendEvent::Synced | BackendEvent::Published(_) => 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. + fn refresh(&mut self, cx: &mut Context) { + if !self.state_loaded { + return; + } + + if self.refresh.request() != RefreshRequest::Schedule { + return; + } + + 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)) + })); + } + + /// 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 }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let (threads, unread_count) = match work.await { + Ok(results) => results, + Err(error) => { + log::warn!("inbox refresh failed: {error}"); + return this.update(cx, |this, _cx| this.refresh.abort()); + } + }; + + let again = this.update(cx, |this, cx| { + if backend.read(cx).current_user() != Some(me) { + this.refresh.abort(); + return false; + } + + this.threads = Arc::new(threads); + this.unread_count = unread_count; + this.rebuild(cx); + cx.notify(); + + this.refresh.finish() + })?; + + if again { + this.update(cx, |this, cx| this.refresh(cx))?; + } + + Ok(()) + })); + } + + /// Recompute the unread and archived flags from the current state. + fn regroup(&mut self, cx: &mut Context) { + 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.threads = Arc::new(items); + self.rebuild(cx); + } + + /// 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 = self.group_sections(); + + 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 = 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()); + self.sections = Arc::new(Vec::new()); + self.rows = 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(); + } + + 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() + .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: &Context) -> AnyElement { + let Some(row) = self.rows.get(ix) else { + return div().into_any_element(); + }; + + 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 Some(&thread_ix) = section.entries.get(entry_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 first = entry_ix == 0; + let last = entry_ix + 1 == section.entries.len(); + + thread("inbox-row", ix, item, first, last, cx) + .on_click(cx.listener(move |this, _ev, window, cx| { + this.open(root, kind, address.clone(), window, cx) + })) + .into_any_element() + } + } + } +} + +/// 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?; + repo_list + .read(cx) + .announcements + .iter() + .find(|announcement| announcement.addr() == *addr) + .map(|announcement| announcement.name().map(SharedString::from)) +} + +/// 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().secondary_foreground) + .bg(cx.theme().secondary.alpha(0.6)) + .rounded(cx.theme().radius) + .child(SharedString::from("No activity yet.")) + .into_any_element() +} + +fn thread( + prefix: &'static str, + ix: usize, + item: &InboxItem, + first: bool, + last: bool, + cx: &App, +) -> Stateful
{ + let title = SharedString::from(item.title()); + 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(&event, me, cx)); + } + + v_flex() + .id((prefix, ix)) + .w_full() + .px_3() + .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().background) + }) + .hover(|this| this.bg(cx.theme().secondary_hover.alpha(0.8))) + .child( + h_flex() + .gap_2() + .text_sm() + .child( + h_flex() + .size_6() + .flex_shrink_0() + .items_center() + .justify_center() + .child(Icon::new(IconName::Bell)), + ) + .child( + div() + .min_w_0() + .whitespace_nowrap() + .text_ellipsis() + .child(title), + ) + .child(div().flex_1()) + .when(unread, |this| { + this.child( + div() + .flex_shrink_0() + .size_2() + .rounded_full() + .bg(cx.theme().primary), + ) + }), + ) + .child(timeline) +} + +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); + + let name = if Some(event.pubkey) == me { + SharedString::from("You") + } else { + profile.name() + }; + + h_flex() + .w_full() + .gap_2() + .items_center() + .child(div().w_6().flex_shrink_0()) + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_1() + .items_center() + .text_xs() + .child( + UserAvatar::new(name.clone()) + .picture(profile.picture()) + .xsmall(), + ) + .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() +} + +/// Phrase describing an activity event, read as `[name] [phrase]`. +fn activity_phrase(kind: Kind) -> &'static str { + if kind == COVER_NOTE_KIND { + return "added a note"; + } + + match kind { + 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", + } +} + +/// Centered muted icon and message filling its container. +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() + .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 rows = self.rows.clone(); + + if self.list.item_count() != rows.len() { + self.list.reset(rows.len()); + } + + v_flex() + .image_cache(gpui::retain_all("inbox")) + .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() + .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/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 830a84a..eb79c7e 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,9 +1,11 @@ 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(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..1ff0b63 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; @@ -2649,3 +2653,54 @@ 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 { + Issue(EventId), + PullRequest(EventId), + Patch, +} + +/// 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, + announcement: &Announcement, + item: RepoItem, + window: &mut Window, + cx: &mut App, +) { + 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; + }; + + 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 523bc10..303471f 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -23,7 +23,7 @@ use signed_state::{ }; use signed_ui::{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 grasp_servers; @@ -37,6 +37,7 @@ use self::onboarding_dialog::OnboardingState; pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, + inbox: Option>, explore: Option>, /// Artwork for the sign-in screen. banner: SharedString, @@ -71,6 +72,7 @@ impl SidebarPanel { if signer_required { this.banner = pick_banner(); + cx.notify(); } if this.refresh(cx) || signer_required { @@ -99,9 +101,10 @@ impl SidebarPanel { } })); - let mut this = Self { + Self { focus_handle: cx.focus_handle(), dock_area, + inbox: None, explore: None, banner: pick_banner(), announcements: Arc::new(Vec::new()), @@ -109,22 +112,9 @@ impl SidebarPanel { 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(); @@ -203,6 +193,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(), 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 @@ -616,7 +620,7 @@ impl Render for SidebarPanel { .child( NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) .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 new file mode 100644 index 0000000..a0e51ed --- /dev/null +++ b/docs/inbox-plan.md @@ -0,0 +1,973 @@ +# 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. + +> **Status.** Phases 0-4 are implemented and green on `feat/inbox`, then the screen was redesigned to +> 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 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 + +`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 and your own activity, **grouped by repository**; unread badge; mark all read; all groups shown | +| **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 | + +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. +- 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. It is one bordered +card holding a single virtual list. Every row is either a **repository header** or one of that +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 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). Threads with no repository address fall into a single "Other repository" section. + +``` ++-------------------------------------------------------------------------+ +| Inbox (3 unread) [Mark all read] | +|-------------------------------------------------------------------------| +| [repo] you/repo-a (2) | +| [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. | +|-------------------------------------------------------------------------| +| [repo] you/repo-c | +| No activity yet. | ++-------------------------------------------------------------------------+ +``` + +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 + +### 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, + /// The root event itself, when known locally; drives the row title. + pub root_event: Option, + pub root_kind: Option, + pub address: Option, + /// 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 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, +) -> 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` +- 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: + +```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.to_hex()) +} + +/// 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(|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 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::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(()) +} +``` + +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 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. + +**`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 and the NIP-78 +load/save. + +```rust +// backend.rs +pub struct Backend { + ... + inbox: Entity, +} + +// inbox.rs +#[derive(Default)] +pub struct Inbox { + state: InboxReadState, + loaded: bool, +} + +impl Inbox { + pub fn state(&self) -> &InboxReadState; + pub fn is_loaded(&self) -> bool; + 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); +} + +// 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 +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. 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, + threads: Arc>, // one row per thread, merged + 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(dock_area: WeakEntity, cx: &mut Context); // cx.defer(… sync_state) + pub fn sync_state(&mut self, cx); // observes the global Inbox + 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 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 +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. + +**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`. + +`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: + +- `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is + a deletion (`EventDeletion` / `RequestToVanish`). +- `Synced` / `Published`. +- everything else: ignored. + +**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) { + 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); + + 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 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.) `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. The panel 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, me: PublicKey, client: Client, cx: &mut Context) { + // state = default; state_loaded = false; cx.notify(); + // spawn load_state(client, me), then set state and state_loaded = true +} +``` + +`reset` performs the same clearing without a state load, and is used on logout. + +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` through +`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** (`InboxView::run_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. +- 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`, 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`). + +**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 store refreshes. This was +reviewed and left as-is. + +**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. + +### 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 + +`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. + +- **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. + +### 5.2 Grouping by repository + +The grouping is panel-owned derivation, done once per data change in `InboxView::rebuild` (called +from `run_refresh` and `regroup`), never per frame: + +```rust +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 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. + +`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`). +- 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 a repo for a row. +- Point the existing nav item at it: + + ```rust + NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) + .on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))), + ``` + +- 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) + +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`: + +```rust +pub(crate) enum RepoItem { + Issue(EventId), + PullRequest(EventId), + Patch, +} + +pub(crate) fn open_repo_item( + dock_area: &WeakEntity, + announcement: &Announcement, + item: RepoItem, + window: &mut Window, + cx: &mut App, +) { /* build the RepoStore here, then a new IssueDetailView / PullRequestDetailView, added to the center */ } +``` + +- `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 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 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 + +| File | Change | +|---|---| +| `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` (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, 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 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` (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. + +## 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 (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) 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, 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 + view. + +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 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`. +- `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. + +### 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`; 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 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. 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. + `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. + +### 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`, 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 + `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 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. + +- `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` 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. +- `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, 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 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). + +### 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). + +### 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` (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. +- `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, and that no + kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read + state is read back from LMDB. Confirm the sidebar has no unread badge. + +## 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` +- 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.