v0.3.4: Comment edit/delete, native notifications, forward-compatible protocol, UI fixes

Comment edit & delete:
- EditComment/DeleteComment BlobHeaderDiffOps with upstream+downstream propagation
- Trust-based: comment author can edit/delete, post author can delete
- Storage: edit_comment(), delete_comment() methods
- Frontend: inline edit (Enter/Escape), delete with confirm

Native notifications:
- tauri-plugin-notification for system notifications on all platforms
- Triggers for messages, new posts, reactions, and comments
- notif_reacts setting added, button-group toggles replace dropdowns
- _notifReady flag prevents startup spam

Protocol hardening:
- BlobHeaderDiffOp::Unknown variant with #[serde(other)] for forward compatibility
- Old nodes silently skip unknown ops instead of crashing

UI fixes:
- Self removed from Following list
- Offline follows in lightbox popup (auto-show if no one online)
- Sent DMs filtered from My Posts
- Comment threading scoped to closest .post (fixes duplicate ID issue)
- Select dropdown text legible in WebKitGTK (black on white options)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-18 00:47:53 -04:00
parent ce176a2299
commit 0abc244ee9
18 changed files with 1616 additions and 67 deletions

435
Cargo.lock generated
View file

@ -102,6 +102,30 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]] [[package]]
name = "async-compat" name = "async-compat"
version = "0.2.5" version = "0.2.5"
@ -115,6 +139,102 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "async-signal"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@ -284,6 +404,19 @@ dependencies = [
"objc2", "objc2",
] ]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.2" version = "8.0.2"
@ -514,6 +647,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.10.2" version = "0.10.2"
@ -1135,6 +1277,12 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]] [[package]]
name = "enum-as-inner" name = "enum-as-inner"
version = "0.6.1" version = "0.6.1"
@ -1158,6 +1306,27 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -1185,6 +1354,27 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@ -1849,6 +2039,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@ -2550,7 +2746,7 @@ dependencies = [
[[package]] [[package]]
name = "itsgoin-desktop" name = "itsgoin-desktop"
version = "0.3.3" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@ -2562,6 +2758,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-notification",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@ -2813,6 +3010,18 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
[[package]]
name = "mac-notification-sys"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
dependencies = [
"cc",
"objc2",
"objc2-foundation",
"time",
]
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.14.1" version = "0.14.1"
@ -3146,6 +3355,20 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "notify-rust"
version = "4.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]] [[package]]
name = "ntimestamp" name = "ntimestamp"
version = "1.0.0" version = "1.0.0"
@ -3477,6 +3700,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@ -3744,6 +3977,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]] [[package]]
name = "pkarr" name = "pkarr"
version = "5.0.2" version = "5.0.2"
@ -3799,7 +4043,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.0", "indexmap 2.13.0",
"quick-xml", "quick-xml 0.38.4",
"serde", "serde",
"time", "time",
] ]
@ -3817,6 +4061,20 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "poly1305" name = "poly1305"
version = "0.8.0" version = "0.8.0"
@ -3987,6 +4245,15 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@ -5292,6 +5559,42 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"toml 0.9.11+spec-1.1.0",
"walkdir",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.2",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
"url",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.10.0" version = "2.10.0"
@ -5346,9 +5649,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.8.2" version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli",
@ -5393,6 +5696,18 @@ dependencies = [
"toml 0.9.11+spec-1.1.0", "toml 0.9.11+spec-1.1.0",
] ]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.25.0" version = "3.25.0"
@ -5854,6 +6169,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "unic-char-property" name = "unic-char-property"
version = "0.9.0" version = "0.9.0"
@ -7055,6 +7381,67 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f" checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f"
[[package]]
name = "zbus"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 0.7.14",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
dependencies = [
"serde",
"winnow 0.7.14",
"zvariant",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.39" version = "0.8.39"
@ -7154,3 +7541,43 @@ name = "zmij"
version = "1.0.19" version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
[[package]]
name = "zvariant"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 0.7.14",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.114",
"winnow 0.7.14",
]

View file

@ -5493,6 +5493,18 @@ impl ConnectionManager {
} }
let _ = storage.store_comment(comment); let _ = storage.store_comment(comment);
} }
BlobHeaderDiffOp::EditComment { author, post_id, timestamp_ms, new_content } => {
// Trust-based: only the comment author can edit
if *author == sender || sender == payload.author {
let _ = storage.edit_comment(author, post_id, *timestamp_ms, new_content);
}
}
BlobHeaderDiffOp::DeleteComment { author, post_id, timestamp_ms } => {
// Trust-based: comment author or post author can delete
if *author == sender || sender == payload.author {
let _ = storage.delete_comment(author, post_id, *timestamp_ms);
}
}
BlobHeaderDiffOp::SetPolicy(new_policy) => { BlobHeaderDiffOp::SetPolicy(new_policy) => {
if sender == payload.author { if sender == payload.author {
let _ = storage.set_comment_policy(&payload.post_id, new_policy); let _ = storage.set_comment_policy(&payload.post_id, new_policy);
@ -5504,6 +5516,7 @@ impl ConnectionManager {
parent_post_id: payload.post_id, parent_post_id: payload.post_id,
}); });
} }
BlobHeaderDiffOp::Unknown => {} // future ops — silently skip
} }
} }
} }

View file

@ -3229,6 +3229,88 @@ impl Node {
Ok(comment) Ok(comment)
} }
/// Edit one of your own comments on a post.
pub async fn edit_comment(
&self,
post_id: PostId,
timestamp_ms: u64,
new_content: String,
) -> anyhow::Result<()> {
let our_node_id = self.node_id;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
let storage = self.storage.lock().await;
storage.edit_comment(&our_node_id, &post_id, timestamp_ms, &new_content)?;
drop(storage);
// Propagate via BlobHeaderDiff
{
let network = &self.network;
let diff = crate::protocol::BlobHeaderDiffPayload {
post_id,
author: our_node_id,
ops: vec![crate::types::BlobHeaderDiffOp::EditComment {
author: our_node_id,
post_id,
timestamp_ms,
new_content,
}],
timestamp_ms: now,
};
network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await;
let upstream = {
let storage = self.storage.lock().await;
storage.get_post_upstream(&post_id).ok().flatten()
};
if let Some(up) = upstream {
let _ = network.send_to_peer_uni(&up, crate::protocol::MessageType::BlobHeaderDiff, &diff).await;
}
}
Ok(())
}
/// Delete one of your own comments on a post.
pub async fn delete_comment(
&self,
post_id: PostId,
timestamp_ms: u64,
) -> anyhow::Result<()> {
let our_node_id = self.node_id;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
let storage = self.storage.lock().await;
storage.delete_comment(&our_node_id, &post_id, timestamp_ms)?;
drop(storage);
// Propagate via BlobHeaderDiff
{
let network = &self.network;
let diff = crate::protocol::BlobHeaderDiffPayload {
post_id,
author: our_node_id,
ops: vec![crate::types::BlobHeaderDiffOp::DeleteComment {
author: our_node_id,
post_id,
timestamp_ms,
}],
timestamp_ms: now,
};
network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await;
let upstream = {
let storage = self.storage.lock().await;
storage.get_post_upstream(&post_id).ok().flatten()
};
if let Some(up) = upstream {
let _ = network.send_to_peer_uni(&up, crate::protocol::MessageType::BlobHeaderDiff, &diff).await;
}
}
Ok(())
}
/// Get all comments for a post. /// Get all comments for a post.
pub async fn get_comments(&self, post_id: PostId) -> anyhow::Result<Vec<crate::types::InlineComment>> { pub async fn get_comments(&self, post_id: PostId) -> anyhow::Result<Vec<crate::types::InlineComment>> {
let storage = self.storage.lock().await; let storage = self.storage.lock().await;

View file

@ -3918,6 +3918,24 @@ impl Storage {
Ok(()) Ok(())
} }
/// Edit a comment (must match author + post_id + timestamp_ms).
pub fn edit_comment(&self, author: &NodeId, post_id: &PostId, timestamp_ms: u64, new_content: &str) -> anyhow::Result<bool> {
let updated = self.conn.execute(
"UPDATE comments SET content = ?4 WHERE author = ?1 AND post_id = ?2 AND timestamp_ms = ?3",
params![author.as_slice(), post_id.as_slice(), timestamp_ms as i64, new_content],
)?;
Ok(updated > 0)
}
/// Delete a comment (must match author + post_id + timestamp_ms).
pub fn delete_comment(&self, author: &NodeId, post_id: &PostId, timestamp_ms: u64) -> anyhow::Result<bool> {
let deleted = self.conn.execute(
"DELETE FROM comments WHERE author = ?1 AND post_id = ?2 AND timestamp_ms = ?3",
params![author.as_slice(), post_id.as_slice(), timestamp_ms as i64],
)?;
Ok(deleted > 0)
}
/// Get all comments for a post, ordered by timestamp. /// Get all comments for a post, ordered by timestamp.
pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> { pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(

View file

@ -817,8 +817,13 @@ pub enum BlobHeaderDiffOp {
AddReaction(Reaction), AddReaction(Reaction),
RemoveReaction { reactor: NodeId, emoji: String, post_id: PostId }, RemoveReaction { reactor: NodeId, emoji: String, post_id: PostId },
AddComment(InlineComment), AddComment(InlineComment),
EditComment { author: NodeId, post_id: PostId, timestamp_ms: u64, new_content: String },
DeleteComment { author: NodeId, post_id: PostId, timestamp_ms: u64 },
SetPolicy(CommentPolicy), SetPolicy(CommentPolicy),
ThreadSplit { new_post_id: PostId }, ThreadSplit { new_post_id: PostId },
/// Unknown ops from newer protocol versions — silently ignored
#[serde(other)]
Unknown,
} }
/// Aggregated engagement header for a post (stored locally, propagated as diffs) /// Aggregated engagement header for a post (stored locally, propagated as diffs)

View file

@ -1,6 +1,6 @@
[package] [package]
name = "itsgoin-desktop" name = "itsgoin-desktop"
version = "0.3.3" version = "0.3.4"
edition = "2021" edition = "2021"
[lib] [lib]
@ -23,3 +23,4 @@ anyhow = "1"
base64 = "0.22" base64 = "0.22"
dirs = "5" dirs = "5"
open = "5" open = "5"
tauri-plugin-notification = "2"

View file

@ -3,6 +3,12 @@
"description": "Default capability for the main window", "description": "Default capability for the main window",
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default" "core:default",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"notification:allow-check-permissions",
"notification:allow-show"
] ]
} }

File diff suppressed because one or more lines are too long

View file

@ -2143,6 +2143,204 @@
"type": "string", "type": "string",
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
} }
] ]
}, },

View file

@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capability for the main window","local":true,"windows":["main"],"permissions":["core:default"]}} {"default":{"identifier":"default","description":"Default capability for the main window","local":true,"windows":["main"],"permissions":["core:default","notification:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-check-permissions","notification:allow-show"]}}

View file

@ -2143,6 +2143,204 @@
"type": "string", "type": "string",
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
} }
] ]
}, },

View file

@ -2143,6 +2143,204 @@
"type": "string", "type": "string",
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
} }
] ]
}, },

View file

@ -2143,6 +2143,204 @@
"type": "string", "type": "string",
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
} }
] ]
}, },

View file

@ -1574,6 +1574,29 @@ async fn comment_on_post(
}) })
} }
#[tauri::command]
async fn edit_comment(
state: State<'_, AppState>,
post_id: String,
timestamp_ms: u64,
new_content: String,
) -> Result<(), String> {
let node = state.inner();
let pid = hex_to_postid(&post_id)?;
node.edit_comment(pid, timestamp_ms, new_content).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn delete_comment(
state: State<'_, AppState>,
post_id: String,
timestamp_ms: u64,
) -> Result<(), String> {
let node = state.inner();
let pid = hex_to_postid(&post_id)?;
node.delete_comment(pid, timestamp_ms).await.map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
async fn get_comments( async fn get_comments(
state: State<'_, AppState>, state: State<'_, AppState>,
@ -1703,6 +1726,7 @@ pub fn run() {
info!("Starting Tauri app"); info!("Starting Tauri app");
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.setup(move |app| { .setup(move |app| {
// Desktop: store data next to the AppImage/executable so each copy // Desktop: store data next to the AppImage/executable so each copy
// gets its own identity. Mobile: use the standard app data dir. // gets its own identity. Mobile: use the standard app data dir.
@ -1818,6 +1842,8 @@ pub fn run() {
get_reactions, get_reactions,
get_reaction_counts, get_reaction_counts,
comment_on_post, comment_on_post,
edit_comment,
delete_comment,
get_comments, get_comments,
set_comment_policy, set_comment_policy,
get_comment_policy, get_comment_policy,

View file

@ -1,6 +1,6 @@
{ {
"productName": "itsgoin", "productName": "itsgoin",
"version": "0.3.3", "version": "0.3.4",
"identifier": "com.itsgoin.app", "identifier": "com.itsgoin.app",
"build": { "build": {
"frontendDist": "../../frontend", "frontendDist": "../../frontend",

View file

@ -357,16 +357,30 @@ function toast(msg) {
setTimeout(() => toastEl.classList.add('hidden'), 3000); setTimeout(() => toastEl.classList.add('hidden'), 3000);
} }
// --- Notifications --- // --- Notifications (Tauri plugin) ---
let _notifiedMessages = new Set(); let _notifiedMessages = new Set();
let _notifiedReacts = new Set();
let _notifiedComments = new Set();
let _notifiedPosts = new Set();
let _notifReady = false;
async function maybeNotify(title, body, tag) { async function maybeNotify(title, body, tag) {
if (!('Notification' in window)) return; try {
if (Notification.permission === 'default') { if (window.__TAURI__?.notification) {
await Notification.requestPermission(); const { isPermissionGranted, requestPermission, sendNotification } = window.__TAURI__.notification;
} let granted = await isPermissionGranted();
if (Notification.permission === 'granted') { if (!granted) {
new Notification(title, { body, tag, silent: false }); const perm = await requestPermission();
} granted = perm === 'granted';
}
if (granted) {
sendNotification({ title, body, channelId: 'default' });
}
} else if ('Notification' in window) {
// Fallback for browsers
if (Notification.permission === 'default') await Notification.requestPermission();
if (Notification.permission === 'granted') new Notification(title, { body, tag, silent: false });
}
} catch (_) {}
} }
// --- Popover helpers --- // --- Popover helpers ---
@ -618,7 +632,48 @@ async function loadFeed(force) {
// Fingerprint: post IDs + reaction counts + comment counts // Fingerprint: post IDs + reaction counts + comment counts
const fp = posts.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|'); const fp = posts.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
if (!force && fp === _feedFingerprint) return; if (!force && fp === _feedFingerprint) return;
const oldFp = _feedFingerprint;
_feedFingerprint = fp; _feedFingerprint = fp;
// Notify on new posts and engagement (skip first load)
if (_notifReady && oldFp) {
try {
const notifPosts = await invoke('get_setting', { key: 'notif_posts' }).catch(() => null) || 'off';
const notifReacts = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on';
for (const p of posts) {
// New post notifications
if (!p.isMe && notifPosts !== 'off' && !_notifiedPosts.has(p.id)) {
_notifiedPosts.add(p.id);
if (_notifiedPosts.size > posts.length) { // skip initial bulk
maybeNotify(`New post from ${p.authorName || p.author.slice(0,8)}`, (p.content || '').slice(0, 80), `post-${p.id}`);
}
}
// Reaction notifications on our posts
if (p.isMe && notifReacts !== 'off' && p.reactionCounts) {
for (const r of p.reactionCounts) {
const key = `${p.id}-${r.emoji}-${r.count}`;
if (!_notifiedReacts.has(key)) {
_notifiedReacts.add(key);
if (_notifiedReacts.size > 1) {
maybeNotify(`${r.emoji} on your post`, `${r.count} ${r.emoji} reactions`, `react-${key}`);
}
}
}
}
// Comment notifications on our posts
if (p.isMe && notifReacts !== 'off' && p.commentCount > 0) {
const key = `${p.id}-comments-${p.commentCount}`;
if (!_notifiedComments.has(key)) {
_notifiedComments.add(key);
if (_notifiedComments.size > 1) {
maybeNotify('New comment on your post', (p.content || '').slice(0, 40), `comment-${p.id}`);
}
}
}
}
} catch (_) {}
}
// Preserve expanded comment threads // Preserve expanded comment threads
const expandedComments = new Set(); const expandedComments = new Set();
feedList.querySelectorAll('.comment-thread:not(.hidden)').forEach(el => { feedList.querySelectorAll('.comment-thread:not(.hidden)').forEach(el => {
@ -650,7 +705,7 @@ async function loadFeed(force) {
async function loadMyPosts(force) { async function loadMyPosts(force) {
try { try {
const posts = await invoke('get_all_posts'); const posts = await invoke('get_all_posts');
const mine = posts.filter(p => p.isMe && p.visibility !== 'encrypted-for-me'); const mine = posts.filter(p => p.isMe && p.visibility !== 'encrypted-for-me' && !(p.recipients && p.recipients.length > 0));
const fp = mine.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|'); const fp = mine.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
if (!force && fp === _myPostsFingerprint) return; if (!force && fp === _myPostsFingerprint) return;
_myPostsFingerprint = fp; _myPostsFingerprint = fp;
@ -993,7 +1048,9 @@ async function loadFollows() {
const approvedSet = new Set(outbound.filter(r => r.status === 'approved').map(r => r.nodeId)); const approvedSet = new Set(outbound.filter(r => r.status === 'approved').map(r => r.nodeId));
const inboundApprovedSet = new Set(inbound.filter(r => r.status === 'approved').map(r => r.nodeId)); const inboundApprovedSet = new Set(inbound.filter(r => r.status === 'approved').map(r => r.nodeId));
if (follows.length === 0) { // Filter out self before rendering
const others = follows.filter(f => f.nodeId !== myNodeId);
if (others.length === 0) {
followsList.innerHTML = `<div>${renderEmptyState('Not following anyone', 'Follow suggested peers or connect manually.')}</div>`; followsList.innerHTML = `<div>${renderEmptyState('Not following anyone', 'Follow suggested peers or connect manually.')}</div>`;
} else { } else {
const now = Date.now(); const now = Date.now();
@ -1037,8 +1094,14 @@ async function loadFollows() {
</div>`; </div>`;
}; };
const online = follows.filter(f => f.isOnline || (f.lastActivityMs > 0 && (now - f.lastActivityMs) < ONLINE_THRESHOLD)); // If isOnline field isn't available (old build), show all as online
const offline = follows.filter(f => !online.includes(f)); const hasOnlineField = others.some(f => f.isOnline !== undefined);
const online = hasOnlineField
? others.filter(f => f.isOnline || (f.lastActivityMs > 0 && (now - f.lastActivityMs) < ONLINE_THRESHOLD))
: others;
const offline = hasOnlineField
? others.filter(f => !online.includes(f))
: [];
let html = ''; let html = '';
if (online.length > 0) { if (online.length > 0) {
@ -1046,11 +1109,36 @@ async function loadFollows() {
html += online.map(renderFollowCard).join(''); html += online.map(renderFollowCard).join('');
} }
if (offline.length > 0) { if (offline.length > 0) {
html += `<div class="follows-section-header">Following: Offline (${offline.length})</div>`; html += `<div class="follows-section-header follows-offline-header" style="cursor:pointer">Following: Offline (${offline.length})</div>`;
html += offline.map(renderFollowCard).join('');
} }
followsList.innerHTML = html; followsList.innerHTML = html;
// Open offline follows in lightbox
if (offline.length > 0) {
followsList.querySelectorAll('.follows-offline-header').forEach(hdr => {
hdr.addEventListener('click', () => {
const existing = document.querySelector('.offline-lightbox');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'offline-lightbox';
overlay.innerHTML = `
<div class="offline-lightbox-content">
<div class="offline-lightbox-header">
<h3>Following: Offline (${offline.length})</h3>
<button class="offline-lightbox-close">x</button>
</div>
<div class="offline-lightbox-list">${offline.map(renderFollowCard).join('')}</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('.offline-lightbox-close').onclick = () => overlay.remove();
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
// Wire up buttons inside the lightbox
attachFollowHandlers(overlay);
});
});
}
// Attach unfollow handlers // Attach unfollow handlers
followsList.querySelectorAll('.unfollow-btn').forEach(btn => { followsList.querySelectorAll('.unfollow-btn').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
@ -1924,7 +2012,8 @@ document.addEventListener('click', async (e) => {
// Comment toggle → expand/collapse thread // Comment toggle → expand/collapse thread
if (e.target.classList.contains('comment-toggle-btn')) { if (e.target.classList.contains('comment-toggle-btn')) {
const postId = e.target.dataset.postId; const postId = e.target.dataset.postId;
const threadEl = document.getElementById('comments-' + postId); const postEl = e.target.closest('.post');
const threadEl = postEl ? postEl.querySelector('.comment-thread') : document.getElementById('comments-' + postId);
if (!threadEl) return; if (!threadEl) return;
if (threadEl.classList.contains('hidden')) { if (threadEl.classList.contains('hidden')) {
threadEl.classList.remove('hidden'); threadEl.classList.remove('hidden');
@ -1945,7 +2034,8 @@ document.addEventListener('click', async (e) => {
try { try {
await invoke('comment_on_post', { postId, content }); await invoke('comment_on_post', { postId, content });
input.value = ''; input.value = '';
const threadEl = document.getElementById('comments-' + postId); const postEl = e.target.closest('.post');
const threadEl = postEl ? postEl.querySelector('.comment-thread') : document.getElementById('comments-' + postId);
if (threadEl) await loadCommentThread(postId, threadEl); if (threadEl) await loadCommentThread(postId, threadEl);
refreshPostEngagement(postId); refreshPostEngagement(postId);
} catch (err) { toast('Error: ' + err); } } catch (err) { toast('Error: ' + err); }
@ -1953,6 +2043,56 @@ document.addEventListener('click', async (e) => {
return; return;
} }
// Edit comment
if (e.target.classList.contains('comment-edit-btn')) {
const postId = e.target.dataset.postId;
const ts = parseInt(e.target.dataset.ts);
const bubble = e.target.closest('.comment-bubble');
const textEl = bubble.querySelector('.comment-text');
const oldContent = textEl.textContent;
textEl.innerHTML = `<input class="comment-edit-input" value="${escapeHtml(oldContent)}" style="width:100%;background:#1a1a2e;color:#e0e0e0;border:1px solid #444;border-radius:3px;padding:0.2rem;font-size:0.8rem" />`;
const input = textEl.querySelector('.comment-edit-input');
input.focus();
input.addEventListener('keydown', async (ev) => {
if (ev.key === 'Enter') {
const newContent = input.value.trim();
if (newContent && newContent !== oldContent) {
try {
await invoke('edit_comment', { postId, timestampMs: ts, newContent });
const postEl = bubble.closest('.post');
const threadEl = postEl ? postEl.querySelector('.comment-thread') : null;
if (threadEl) await loadCommentThread(postId, threadEl);
toast('Comment edited');
} catch (err) { toast('Error: ' + err); }
} else {
textEl.textContent = oldContent;
}
} else if (ev.key === 'Escape') {
textEl.textContent = oldContent;
}
});
input.addEventListener('blur', () => {
if (textEl.querySelector('.comment-edit-input')) textEl.textContent = oldContent;
});
return;
}
// Delete comment
if (e.target.classList.contains('comment-delete-btn')) {
const postId = e.target.dataset.postId;
const ts = parseInt(e.target.dataset.ts);
if (!confirm('Delete this comment?')) return;
try {
await invoke('delete_comment', { postId, timestampMs: ts });
const postEl = e.target.closest('.post');
const threadEl = postEl ? postEl.querySelector('.comment-thread') : null;
if (threadEl) await loadCommentThread(postId, threadEl);
refreshPostEngagement(postId);
toast('Comment deleted');
} catch (err) { toast('Error: ' + err); }
return;
}
// Close emoji picker on outside click // Close emoji picker on outside click
closeEmojiPicker(); closeEmojiPicker();
}); });
@ -1968,10 +2108,15 @@ async function loadCommentThread(postId, container) {
for (const c of comments) { for (const c of comments) {
const name = c.authorName || c.author.substring(0, 12); const name = c.authorName || c.author.substring(0, 12);
const time = relativeTime(c.timestampMs); const time = relativeTime(c.timestampMs);
html += `<div class="comment-bubble"> const isMyComment = c.author === myNodeId;
const editDeleteBtns = isMyComment
? `<button class="comment-edit-btn" data-post-id="${c.postId}" data-ts="${c.timestampMs}" title="Edit">edit</button>
<button class="comment-delete-btn" data-post-id="${c.postId}" data-ts="${c.timestampMs}" title="Delete">del</button>`
: '';
html += `<div class="comment-bubble" data-post-id="${c.postId}" data-ts="${c.timestampMs}">
<span class="comment-author">${escapeHtml(name)}</span> <span class="comment-author">${escapeHtml(name)}</span>
<span class="comment-text">${escapeHtml(c.content)}</span> <span class="comment-text">${escapeHtml(c.content)}</span>
<span class="comment-time">${time}</span> <span class="comment-time">${time} ${editDeleteBtns}</span>
</div>`; </div>`;
} }
html += `<div class="comment-compose"> html += `<div class="comment-compose">
@ -2593,38 +2738,36 @@ $('#notifications-btn').addEventListener('click', async () => {
// Load current settings // Load current settings
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on'; const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
const postVal = await invoke('get_setting', { key: 'notif_posts' }).catch(() => null) || 'off'; const postVal = await invoke('get_setting', { key: 'notif_posts' }).catch(() => null) || 'off';
const reactVal = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on';
const nearbyVal = await invoke('get_setting', { key: 'notif_nearby' }).catch(() => null) || 'on'; const nearbyVal = await invoke('get_setting', { key: 'notif_nearby' }).catch(() => null) || 'on';
function btnGroup(label, key, value, options) {
const btns = options.map(([v, text]) =>
`<button class="notif-opt${v === value ? ' active' : ''}" data-key="${key}" data-value="${v}">${text}</button>`
).join('');
return `<div class="notif-row"><span class="notif-label">${label}</span><div class="notif-opts">${btns}</div></div>`;
}
const html = ` const html = `
<label for="notif-messages">Messages</label> ${btnGroup('Messages', 'notif_messages', msgVal, [['off','Off'],['on','On'],['preview','Preview']])}
<select id="notif-messages"> ${btnGroup('Posts', 'notif_posts', postVal, [['off','Off'],['follows','Follows'],['recommended','Recommended'],['popular','Popular']])}
<option value="off"${msgVal === 'off' ? ' selected' : ''}>Off</option> ${btnGroup('Reactions &amp; Comments', 'notif_reacts', reactVal, [['off','Off'],['on','On']])}
<option value="on"${msgVal === 'on' ? ' selected' : ''}>On (no preview)</option> ${btnGroup('Nearby Users', 'notif_nearby', nearbyVal, [['off','Off'],['on','On']])}
<option value="preview"${msgVal === 'preview' ? ' selected' : ''}>On (with preview)</option> <p class="empty-hint" style="margin-top:0.75rem">Changes are saved automatically.</p>`;
</select>
<label for="notif-posts">Posts</label>
<select id="notif-posts">
<option value="off"${postVal === 'off' ? ' selected' : ''}>Off</option>
<option value="follows"${postVal === 'follows' ? ' selected' : ''}>From follows &amp; audience</option>
<option value="recommended"${postVal === 'recommended' ? ' selected' : ''}>Recommended by follows</option>
<option value="popular"${postVal === 'popular' ? ' selected' : ''}>Popular (100+)</option>
</select>
<label for="notif-nearby">Nearby Users</label>
<select id="notif-nearby">
<option value="off"${nearbyVal === 'off' ? ' selected' : ''}>Off</option>
<option value="on"${nearbyVal === 'on' ? ' selected' : ''}>On</option>
</select>
<p class="empty-hint" style="margin-top:0.5rem">Changes are saved automatically.</p>`;
openPopover('Notifications', html, { openPopover('Notifications', html, {
onOpen() { onOpen() {
for (const id of ['notif-messages', 'notif-posts', 'notif-nearby']) { document.querySelectorAll('.notif-opt').forEach(btn => {
$(`#${id}`).addEventListener('change', async (e) => { btn.addEventListener('click', async () => {
const key = id.replace('-', '_'); const key = btn.dataset.key;
await invoke('set_setting', { key, value: e.target.value }); const value = btn.dataset.value;
// Update active state
btn.closest('.notif-opts').querySelectorAll('.notif-opt').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
await invoke('set_setting', { key, value });
toast('Setting saved'); toast('Setting saved');
}); });
} });
} }
}); });
}); });
@ -2665,6 +2808,9 @@ async function init() {
const info = await loadNodeInfo(); const info = await loadNodeInfo();
await loadStats(); await loadStats();
await loadFeed(); await loadFeed();
await loadMessages();
// Now safe to fire notifications (initial data loaded, won't spam)
_notifReady = true;
// Show setup overlay if no profile exists // Show setup overlay if no profile exists
if (info && !info.hasProfile) { if (info && !info.hasProfile) {

View file

@ -1,5 +1,6 @@
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 0 auto; padding: 1rem; background: #1a1a2e; color: #e0e0e0; } select option { color: #000 !important; }
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 0 auto; padding: 1rem; background: #1a1a2e; color: #e0e0e0; color-scheme: dark; }
header { border-bottom: 1px solid #333; padding-bottom: 0.5rem; margin-bottom: 1rem; } header { border-bottom: 1px solid #333; padding-bottom: 0.5rem; margin-bottom: 1rem; }
header h1 { font-size: 1.4rem; color: #7fdbca; } header h1 { font-size: 1.4rem; color: #7fdbca; }
#stats-bar { font-size: 0.8rem; color: #bbc; margin-top: 0.25rem; } #stats-bar { font-size: 0.8rem; color: #bbc; margin-top: 0.25rem; }
@ -21,8 +22,15 @@ header h1 { font-size: 1.4rem; color: #7fdbca; }
.overlay-close { background: none; border: none; color: #889; font-size: 1.4rem; cursor: pointer; padding: 0 0.3rem; line-height: 1; transition: color 0.15s; } .overlay-close { background: none; border: none; color: #889; font-size: 1.4rem; cursor: pointer; padding: 0 0.3rem; line-height: 1; transition: color 0.15s; }
.overlay-close:hover { color: #e74c3c; } .overlay-close:hover { color: #e74c3c; }
.overlay-body { padding: 1rem; max-height: 70vh; overflow-y: auto; } .overlay-body { padding: 1rem; max-height: 70vh; overflow-y: auto; }
.overlay-body select { width: 100%; background: #1a1a2e; color: #e0e0e0; border: 1px solid #444; border-radius: 4px; padding: 0.4rem; font-size: 0.85rem; font-family: inherit; margin-bottom: 0.75rem; } .overlay-body select { width: 100%; background: #1a1a2e; border: 1px solid #444; border-radius: 4px; padding: 0.4rem; font-size: 0.85rem; font-family: inherit; margin-bottom: 0.75rem; }
.overlay-body select:focus { outline: none; border-color: #7fdbca; } .overlay-body select:focus { outline: none; border-color: #7fdbca; }
.overlay-body select option { background: #fff; color: #000; }
.notif-row { margin-bottom: 0.6rem; }
.notif-label { display: block; font-size: 0.8rem; color: #bbc; margin-bottom: 0.3rem; }
.notif-opts { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.notif-opt { background: #1a1a2e; color: #999; border: 1px solid #333; border-radius: 4px; padding: 0.25rem 0.6rem; font-size: 0.75rem; cursor: pointer; transition: all 0.15s; }
.notif-opt:hover { border-color: #555; color: #ccc; }
.notif-opt.active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
.overlay-body label { display: block; font-size: 0.8rem; color: #bbc; margin-bottom: 0.25rem; } .overlay-body label { display: block; font-size: 0.8rem; color: #bbc; margin-bottom: 0.25rem; }
/* Clickable activity peer IDs */ /* Clickable activity peer IDs */
@ -189,9 +197,9 @@ header h1 { font-size: 1.4rem; color: #7fdbca; }
/* Visibility selector */ /* Visibility selector */
#visibility-row { display: flex; gap: 0.4rem; margin-top: 0.35rem; align-items: center; } #visibility-row { display: flex; gap: 0.4rem; margin-top: 0.35rem; align-items: center; }
#visibility-select, #circle-select { background: #1a1a2e; color: #fff; border: 1px solid #444; border-radius: 3px; padding: 0.2rem 0.4rem; font-size: 0.75rem; font-family: inherit; -webkit-appearance: none; appearance: none; color-scheme: dark; } #visibility-select, #circle-select { background: #1a1a2e; border: 1px solid #444; border-radius: 3px; padding: 0.2rem 0.4rem; font-size: 0.75rem; font-family: inherit; -webkit-appearance: none; appearance: none; }
#visibility-select:focus, #circle-select:focus { outline: none; border-color: #7fdbca; } #visibility-select:focus, #circle-select:focus { outline: none; border-color: #7fdbca; }
#visibility-select option, #circle-select option { background: #1a1a2e; color: #fff; } #visibility-select option, #circle-select option { background: #fff; color: #000; }
/* Visibility badges on posts */ /* Visibility badges on posts */
.vis-badge { font-size: 0.6rem; padding: 0.1rem 0.35rem; border-radius: 3px; margin-left: 0.4rem; font-family: system-ui, sans-serif; vertical-align: middle; } .vis-badge { font-size: 0.6rem; padding: 0.1rem 0.35rem; border-radius: 3px; margin-left: 0.4rem; font-family: system-ui, sans-serif; vertical-align: middle; }
@ -237,7 +245,8 @@ header h1 { font-size: 1.4rem; color: #7fdbca; }
.circle-members { font-size: 0.75rem; font-family: monospace; color: #bbc; display: flex; flex-wrap: wrap; gap: 0.3rem; } .circle-members { font-size: 0.75rem; font-family: monospace; color: #bbc; display: flex; flex-wrap: wrap; gap: 0.3rem; }
.circle-member { background: #1e2040; padding: 0.2rem 0.5rem; border-radius: 3px; display: inline-flex; align-items: center; gap: 0.35rem; } .circle-member { background: #1e2040; padding: 0.2rem 0.5rem; border-radius: 3px; display: inline-flex; align-items: center; gap: 0.35rem; }
.circle-add-row { display: flex; gap: 0.3rem; margin-top: 0.4rem; } .circle-add-row { display: flex; gap: 0.3rem; margin-top: 0.4rem; }
.add-member-select { flex: 1; background: #1a1a2e; color: #e0e0e0; border: 1px solid #444; border-radius: 3px; padding: 0.2rem; font-size: 0.75rem; } .add-member-select { flex: 1; background: #1a1a2e; border: 1px solid #444; border-radius: 3px; padding: 0.2rem; font-size: 0.75rem; }
.add-member-select option { background: #fff; color: #000; }
.empty-hint { color: #667; font-size: 0.8rem; font-style: italic; } .empty-hint { color: #667; font-size: 0.8rem; font-style: italic; }
.hidden { display: none !important; } .hidden { display: none !important; }
@ -246,14 +255,16 @@ header h1 { font-size: 1.4rem; color: #7fdbca; }
.anchor-item { display: flex; align-items: center; gap: 0.35rem; padding: 0.3rem 0; border-bottom: 1px solid #1e2040; } .anchor-item { display: flex; align-items: center; gap: 0.35rem; padding: 0.3rem 0; border-bottom: 1px solid #1e2040; }
.anchor-item:last-child { border-bottom: none; } .anchor-item:last-child { border-bottom: none; }
.anchor-item .peer-label { flex: 1; display: flex; align-items: center; gap: 0.35rem; } .anchor-item .peer-label { flex: 1; display: flex; align-items: center; gap: 0.35rem; }
#anchor-add-select { flex: 1; background: #1a1a2e; color: #e0e0e0; border: 1px solid #444; border-radius: 3px; padding: 0.2rem; font-size: 0.75rem; } #anchor-add-select { flex: 1; background: #1a1a2e; border: 1px solid #444; border-radius: 3px; padding: 0.2rem; font-size: 0.75rem; }
#anchor-add-select option { background: #fff; color: #000; }
/* DM compose */ /* DM compose */
#dm-compose textarea { width: 100%; padding: 0.5rem; background: #1a1a2e; color: #e0e0e0; border: 1px solid #333; border-radius: 4px; resize: none; font-family: inherit; font-size: 0.9rem; min-height: 60px; line-height: 1.5; transition: border-color 0.15s; margin-top: 0.5rem; } #dm-compose textarea { width: 100%; padding: 0.5rem; background: #1a1a2e; color: #e0e0e0; border: 1px solid #333; border-radius: 4px; resize: none; font-family: inherit; font-size: 0.9rem; min-height: 60px; line-height: 1.5; transition: border-color 0.15s; margin-top: 0.5rem; }
#dm-compose textarea:focus { outline: none; border-color: #7fdbca; } #dm-compose textarea:focus { outline: none; border-color: #7fdbca; }
#dm-compose textarea::placeholder { color: #778; } #dm-compose textarea::placeholder { color: #778; }
.dm-compose-row { margin-bottom: 0; } .dm-compose-row { margin-bottom: 0; }
#dm-recipient-select { width: 100%; background: #1a1a2e; color: #e0e0e0; border: 1px solid #444; border-radius: 4px; padding: 0.4rem; font-size: 0.85rem; font-family: inherit; } #dm-recipient-select { width: 100%; background: #1a1a2e; border: 1px solid #444; border-radius: 4px; padding: 0.4rem; font-size: 0.85rem; font-family: inherit; }
#dm-recipient-select option { background: #fff; color: #000; }
#dm-recipient-select:focus { outline: none; border-color: #7fdbca; } #dm-recipient-select:focus { outline: none; border-color: #7fdbca; }
.dm-compose-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid #2a2a40; } .dm-compose-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid #2a2a40; }
@ -294,6 +305,14 @@ header h1 { font-size: 1.4rem; color: #7fdbca; }
.last-seen { color: #666; } .last-seen { color: #666; }
.follows-section-header { font-size: 0.8rem; font-weight: 600; color: #888; padding: 0.5rem 0 0.25rem; border-bottom: 1px solid #2a2a40; margin-bottom: 0.3rem; margin-top: 0.5rem; } .follows-section-header { font-size: 0.8rem; font-weight: 600; color: #888; padding: 0.5rem 0 0.25rem; border-bottom: 1px solid #2a2a40; margin-bottom: 0.3rem; margin-top: 0.5rem; }
.follows-section-header:first-child { margin-top: 0; } .follows-section-header:first-child { margin-top: 0; }
.follows-offline-header { cursor: pointer; color: #5b8def !important; }
.follows-offline-header:hover { color: #7fdbca !important; }
.offline-lightbox { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999999; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; }
.offline-lightbox-content { background: #1a1a2e; border: 1px solid #333; border-radius: 12px; padding: 1rem; max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; }
.offline-lightbox-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.offline-lightbox-header h3 { color: #e0e0e0; font-size: 1rem; margin: 0; }
.offline-lightbox-close { background: none; border: none; color: #889; font-size: 1.4rem; cursor: pointer; }
.offline-lightbox-close:hover { color: #e74c3c; }
.peer-card-actions { display: flex; gap: 0.3rem; margin-top: 0.35rem; } .peer-card-actions { display: flex; gap: 0.3rem; margin-top: 0.35rem; }
.peer-card-highlight { border-color: #7fdbca; animation: peerHighlight 2s ease-out; } .peer-card-highlight { border-color: #7fdbca; animation: peerHighlight 2s ease-out; }
@keyframes peerHighlight { 0% { border-color: #7fdbca; box-shadow: 0 0 8px rgba(127, 219, 202, 0.3); } 100% { border-color: #2a2a40; box-shadow: none; } } @keyframes peerHighlight { 0% { border-color: #7fdbca; box-shadow: 0 0 8px rgba(127, 219, 202, 0.3); } 100% { border-color: #2a2a40; box-shadow: none; } }
@ -390,7 +409,10 @@ header h1 { font-size: 1.4rem; color: #7fdbca; }
.comment-bubble { display: flex; gap: 0.35rem; align-items: baseline; padding: 0.2rem 0; font-size: 0.82rem; } .comment-bubble { display: flex; gap: 0.35rem; align-items: baseline; padding: 0.2rem 0; font-size: 0.82rem; }
.comment-author { color: #7fdbca; font-weight: 600; white-space: nowrap; font-size: 0.78rem; } .comment-author { color: #7fdbca; font-weight: 600; white-space: nowrap; font-size: 0.78rem; }
.comment-text { color: #ddd; } .comment-text { color: #ddd; }
.comment-time { color: #777; font-size: 0.7rem; white-space: nowrap; } .comment-time { color: #777; font-size: 0.7rem; white-space: nowrap; display: flex; align-items: center; gap: 0.3rem; }
.comment-edit-btn, .comment-delete-btn { background: none; border: none; color: #556; font-size: 0.65rem; cursor: pointer; padding: 0; }
.comment-edit-btn:hover { color: #5b8def; }
.comment-delete-btn:hover { color: #e74c3c; }
.comment-compose { display: flex; gap: 0.35rem; margin-top: 0.4rem; align-items: center; } .comment-compose { display: flex; gap: 0.35rem; margin-top: 0.4rem; align-items: center; }
.comment-input { flex: 1; background: #1a1a2e; border: 1px solid #3a3a5a; border-radius: 0.35rem; padding: 0.3rem 0.5rem; color: #e0e0e0; font-size: 0.82rem; } .comment-input { flex: 1; background: #1a1a2e; border: 1px solid #3a3a5a; border-radius: 0.35rem; padding: 0.3rem 0.5rem; color: #e0e0e0; font-size: 0.82rem; }
.comment-input:focus { border-color: #7fdbca; outline: none; } .comment-input:focus { border-color: #7fdbca; outline: none; }

View file

@ -24,16 +24,16 @@
<section> <section>
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1> <h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
<p>Available for Android and Linux. Free and open source.</p> <p>Available for Android and Linux. Free and open source.</p>
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.3.3 &mdash; March 15, 2026</p> <p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.3.4 &mdash; March 15, 2026</p>
<div class="downloads"> <div class="downloads">
<a href="itsgoin-0.3.3.apk" class="download-btn btn-android"> <a href="itsgoin-0.3.4.apk" class="download-btn btn-android">
Android APK Android APK
<span class="sub">v0.3.3</span> <span class="sub">v0.3.4</span>
</a> </a>
<a href="itsgoin_0.3.3_amd64.AppImage" class="download-btn btn-linux"> <a href="itsgoin_0.3.4_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage Linux AppImage
<span class="sub">v0.3.3</span> <span class="sub">v0.3.4</span>
</a> </a>
</div> </div>
</section> </section>
@ -45,7 +45,7 @@
<h3 style="color: var(--accent);">Android</h3> <h3 style="color: var(--accent);">Android</h3>
<ol class="steps"> <ol class="steps">
<li><strong>Download the APK</strong> &mdash; Tap the button above. Your browser may warn that this type of file can be harmful &mdash; tap <strong>Download anyway</strong>.</li> <li><strong>Download the APK</strong> &mdash; Tap the button above. Your browser may warn that this type of file can be harmful &mdash; tap <strong>Download anyway</strong>.</li>
<li><strong>Open the file</strong> &mdash; When the download finishes, tap the notification or find <code>itsgoin-0.3.3.apk</code> in your Downloads folder and tap it.</li> <li><strong>Open the file</strong> &mdash; When the download finishes, tap the notification or find <code>itsgoin-0.3.4.apk</code> in your Downloads folder and tap it.</li>
<li><strong>Allow installation</strong> &mdash; Android will ask you to allow installs from this source. Tap <strong>Settings</strong>, toggle <strong>"Allow from this source"</strong>, then go back and tap <strong>Install</strong>.</li> <li><strong>Allow installation</strong> &mdash; Android will ask you to allow installs from this source. Tap <strong>Settings</strong>, toggle <strong>"Allow from this source"</strong>, then go back and tap <strong>Install</strong>.</li>
<li><strong>Launch the app</strong> &mdash; Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li> <li><strong>Launch the app</strong> &mdash; Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li>
</ol> </ol>
@ -58,8 +58,8 @@
<h3 style="color: var(--green);">Linux (AppImage)</h3> <h3 style="color: var(--green);">Linux (AppImage)</h3>
<ol class="steps"> <ol class="steps">
<li><strong>Download the AppImage</strong> &mdash; Click the button above to download.</li> <li><strong>Download the AppImage</strong> &mdash; Click the button above to download.</li>
<li><strong>Make it executable</strong> &mdash; Open a terminal and run:<br><code>chmod +x itsgoin_0.3.3_amd64.AppImage</code></li> <li><strong>Make it executable</strong> &mdash; Open a terminal and run:<br><code>chmod +x itsgoin_0.3.4_amd64.AppImage</code></li>
<li><strong>Run it</strong> &mdash; Double-click the file, or from the terminal:<br><code>./itsgoin_0.3.3_amd64.AppImage</code></li> <li><strong>Run it</strong> &mdash; Double-click the file, or from the terminal:<br><code>./itsgoin_0.3.4_amd64.AppImage</code></li>
</ol> </ol>
<div class="note"> <div class="note">
<strong>Note:</strong> If it doesn't launch, you may need to install FUSE:<br><code>sudo apt install libfuse2</code> (Debian/Ubuntu) or <code>sudo dnf install fuse</code> (Fedora). <strong>Note:</strong> If it doesn't launch, you may need to install FUSE:<br><code>sudo apt install libfuse2</code> (Debian/Ubuntu) or <code>sudo dnf install fuse</code> (Fedora).
@ -70,6 +70,17 @@
<section> <section>
<h2>Changelog</h2> <h2>Changelog</h2>
<div class="changelog"> <div class="changelog">
<div class="changelog-date">v0.3.4 &mdash; March 18, 2026</div>
<ul>
<li><strong>Comment edit &amp; delete</strong> &mdash; Edit or delete your own comments. Trust-based: post authors can also delete comments on their posts. Propagates via BlobHeaderDiff to all holders.</li>
<li><strong>Native notifications</strong> &mdash; System notifications via Tauri plugin (works on all platforms). Notifications for messages, new posts, reactions, and comments. Configurable in Settings with button-group toggles.</li>
<li><strong>Forward-compatible protocol</strong> &mdash; Unknown BlobHeaderDiffOp variants are silently ignored instead of crashing deserialization. Prevents old nodes from breaking when new features are added.</li>
<li><strong>Following: Online/Offline</strong> &mdash; Self removed from following list. Offline follows hidden behind lightbox popup. Shows expanded if no one is online.</li>
<li><strong>DM filter fix</strong> &mdash; Sent direct messages no longer appear in My Posts tab.</li>
<li><strong>Comment threading fix</strong> &mdash; Comments now work correctly in My Posts tab (duplicate element ID scoping fix).</li>
<li><strong>Dropdown text fix</strong> &mdash; Select dropdowns across the app now have legible text in WebKitGTK native popups.</li>
</ul>
<div class="changelog-date">v0.3.3 &mdash; March 16, 2026</div> <div class="changelog-date">v0.3.3 &mdash; March 16, 2026</div>
<ul> <ul>
<li><strong>IPv6 HTTP address fix</strong> &mdash; Nodes with public IPv6 now correctly advertise their real address for direct browser access, instead of <code>0.0.0.0</code>. Fixes share link video/image serving for IPv6-reachable nodes.</li> <li><strong>IPv6 HTTP address fix</strong> &mdash; Nodes with public IPv6 now correctly advertise their real address for direct browser access, instead of <code>0.0.0.0</code>. Fixes share link video/image serving for IPv6-reachable nodes.</li>