1 Commits

Author SHA1 Message Date
tyiu c1d3be4c07 WIP add system locale detection
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-08-01 13:20:35 -04:00
129 changed files with 2323 additions and 7678 deletions
-3
View File
@@ -18,7 +18,4 @@ export OLLAMA_HOST=http://ollama.jb55.com
# simple todo reminders
export TODO_FILE=TODO
export RUST_LOG="egui=debug,egui-winit=debug,notedeck=debug,notedeck_columns=debug,notedeck_chrome=debug,enostr=debug,android_activity=debug,lnsocket=trace,notedeck_clndash=debug"
2>/dev/null todo.sh ls || :
Generated
+37 -83
View File
@@ -105,8 +105,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android-activity"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046"
source = "git+https://github.com/damus-io/android-activity?rev=a8948332c7c551303d32eb26a59d0abd676e47a5#a8948332c7c551303d32eb26a59d0abd676e47a5"
dependencies = [
"android-properties",
"bitflags 2.9.1",
@@ -126,7 +125,7 @@ dependencies = [
[[package]]
name = "android-activity"
version = "0.6.0"
source = "git+https://github.com/damus-io/android-activity?rev=4ee16f1585e4a75031dc10785163d4b920f95805#4ee16f1585e4a75031dc10785163d4b920f95805"
source = "git+https://github.com/damus-io/android-activity?rev=c3c0decc83c4d6c94d2c448391fc8dd51b13f3d9#c3c0decc83c4d6c94d2c448391fc8dd51b13f3d9"
dependencies = [
"android-properties",
"bitflags 2.9.1",
@@ -193,7 +192,7 @@ dependencies = [
"objc2-foundation 0.3.1",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
"x11rb",
]
@@ -1403,7 +1402,7 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.1"
source = "git+https://github.com/damus-io/winit?rev=a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d#a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d"
source = "git+https://github.com/damus-io/winit?rev=eaff639ab0a14fccf595241f687be883154b267c#eaff639ab0a14fccf595241f687be883154b267c"
[[package]]
name = "dpi"
@@ -1420,17 +1419,17 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecolor"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [
"bytemuck",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72)",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd)",
"serde",
]
[[package]]
name = "eframe"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [
"ahash",
"bytemuck",
@@ -1466,13 +1465,13 @@ dependencies = [
[[package]]
name = "egui"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [
"accesskit",
"ahash",
"backtrace",
"bitflags 2.9.1",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72)",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd)",
"epaint",
"log",
"nohash-hasher",
@@ -1484,7 +1483,7 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [
"ahash",
"bytemuck",
@@ -1503,7 +1502,7 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [
"ahash",
"arboard",
@@ -1521,7 +1520,7 @@ dependencies = [
[[package]]
name = "egui_extras"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [
"ahash",
"egui",
@@ -1538,7 +1537,7 @@ dependencies = [
[[package]]
name = "egui_glow"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [
"ahash",
"bytemuck",
@@ -1555,7 +1554,7 @@ dependencies = [
[[package]]
name = "egui_nav"
version = "0.2.0"
source = "git+https://github.com/damus-io/egui-nav?rev=de6e2d51892478fdd516df166f866e64dedbae07#de6e2d51892478fdd516df166f866e64dedbae07"
source = "git+https://github.com/damus-io/egui-nav?rev=3c67eb6298edbff36d46546897cfac33df4f04db#3c67eb6298edbff36d46546897cfac33df4f04db"
dependencies = [
"egui",
"egui_extras",
@@ -1617,7 +1616,7 @@ checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b"
[[package]]
name = "emath"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [
"bytemuck",
"serde",
@@ -1715,13 +1714,13 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [
"ab_glyph",
"ahash",
"bytemuck",
"ecolor",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72)",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd)",
"epaint_default_fonts",
"log",
"nohash-hasher",
@@ -1733,7 +1732,7 @@ dependencies = [
[[package]]
name = "epaint_default_fonts"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
[[package]]
name = "equator"
@@ -2337,12 +2336,6 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
[[package]]
name = "hashbrown"
version = "0.15.4"
@@ -2371,9 +2364,6 @@ name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
dependencies = [
"serde",
]
[[package]]
name = "hex-conservative"
@@ -3039,7 +3029,6 @@ dependencies = [
"bech32",
"bitcoin",
"lightning-types",
"serde",
]
[[package]]
@@ -3075,22 +3064,6 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
[[package]]
name = "lnsocket"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "724c7fba2188a49ab31316e52dd410d4d3168b8e6482aa2ac3889dd840d28712"
dependencies = [
"bitcoin",
"hashbrown 0.13.2",
"hex",
"lightning-types",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "lock_api"
version = "0.4.13"
@@ -3505,16 +3478,13 @@ dependencies = [
[[package]]
name = "notedeck"
version = "0.7.1"
version = "0.5.9"
dependencies = [
"android-activity 0.6.0 (git+https://github.com/damus-io/android-activity?rev=4ee16f1585e4a75031dc10785163d4b920f95805)",
"base32",
"bech32",
"bincode",
"bitflags 2.9.1",
"blurhash",
"chrono",
"crossbeam-channel",
"dirs",
"eframe",
"egui",
@@ -3528,12 +3498,10 @@ dependencies = [
"hashbrown 0.15.4",
"hex",
"image",
"indexmap 2.9.0",
"jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
"lightning-invoice",
"md5",
"mime_guess",
"ndk-context",
"nostr 0.37.0",
"nostrdb",
"nwc",
@@ -3549,6 +3517,7 @@ dependencies = [
"sha2",
"strum",
"strum_macros",
"sys-locale",
"tempfile",
"thiserror 2.0.12",
"tokenator",
@@ -3561,9 +3530,8 @@ dependencies = [
[[package]]
name = "notedeck_chrome"
version = "0.7.1"
version = "0.5.9"
dependencies = [
"bitflags 2.9.1",
"eframe",
"egui",
"egui-winit",
@@ -3571,7 +3539,6 @@ dependencies = [
"egui_tabs",
"nostrdb",
"notedeck",
"notedeck_clndash",
"notedeck_columns",
"notedeck_dave",
"notedeck_notebook",
@@ -3591,28 +3558,9 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "notedeck_clndash"
version = "0.7.1"
dependencies = [
"eframe",
"egui",
"egui_extras",
"hex",
"lightning-invoice",
"lnsocket",
"nostrdb",
"notedeck",
"notedeck_ui",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "notedeck_columns"
version = "0.7.1"
version = "0.5.9"
dependencies = [
"base64 0.22.1",
"bech32",
@@ -3632,8 +3580,6 @@ dependencies = [
"human_format",
"image",
"indexmap 2.9.0",
"jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
"ndk-context",
"nostrdb",
"notedeck",
"notedeck_ui",
@@ -3668,7 +3614,7 @@ dependencies = [
[[package]]
name = "notedeck_dave"
version = "0.7.1"
version = "0.5.9"
dependencies = [
"async-openai",
"bytemuck",
@@ -3676,7 +3622,6 @@ dependencies = [
"eframe",
"egui",
"egui-wgpu",
"egui_extras",
"enostr",
"futures",
"hex",
@@ -3693,7 +3638,7 @@ dependencies = [
[[package]]
name = "notedeck_notebook"
version = "0.7.1"
version = "0.5.9"
dependencies = [
"egui",
"jsoncanvas",
@@ -3702,7 +3647,7 @@ dependencies = [
[[package]]
name = "notedeck_ui"
version = "0.7.1"
version = "0.5.9"
dependencies = [
"bitflags 2.9.1",
"eframe",
@@ -5777,6 +5722,15 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "sysinfo"
version = "0.30.13"
@@ -7452,10 +7406,10 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winit"
version = "0.30.8"
source = "git+https://github.com/damus-io/winit?rev=a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d#a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d"
source = "git+https://github.com/damus-io/winit?rev=eaff639ab0a14fccf595241f687be883154b267c#eaff639ab0a14fccf595241f687be883154b267c"
dependencies = [
"ahash",
"android-activity 0.6.0 (git+https://github.com/damus-io/android-activity?rev=4ee16f1585e4a75031dc10785163d4b920f95805)",
"android-activity 0.6.0 (git+https://github.com/damus-io/android-activity?rev=c3c0decc83c4d6c94d2c448391fc8dd51b13f3d9)",
"atomic-waker",
"bitflags 2.9.1",
"block2 0.5.1",
@@ -7507,7 +7461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4409c10174df8779dc29a4788cac85ed84024ccbc1743b776b21a520ee1aaf4"
dependencies = [
"ahash",
"android-activity 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)",
"android-activity 0.6.0 (git+https://github.com/damus-io/android-activity?rev=a8948332c7c551303d32eb26a59d0abd676e47a5)",
"atomic-waker",
"bitflags 2.9.1",
"block2 0.5.1",
+15 -17
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
package.version = "0.7.1"
package.version = "0.5.9"
members = [
"crates/notedeck",
"crates/notedeck_chrome",
@@ -8,14 +8,12 @@ members = [
"crates/notedeck_dave",
"crates/notedeck_notebook",
"crates/notedeck_ui",
"crates/notedeck_clndash",
"crates/enostr", "crates/tokenator", "crates/notedeck_dave", "crates/notedeck_ui", "crates/notedeck_clndash",
"crates/enostr", "crates/tokenator", "crates/notedeck_dave", "crates/notedeck_ui",
]
[workspace.dependencies]
opener = "0.8.2"
chrono = "0.4.40"
base32 = "0.4.0"
base64 = "0.22.1"
rmpv = "1.3.0"
@@ -27,7 +25,7 @@ egui = { version = "0.31.1", features = ["serde"] }
egui-wgpu = "0.31.1"
egui_extras = { version = "0.31.1", features = ["all_loaders"] }
egui-winit = { version = "0.31.1", features = ["android-game-activity", "clipboard"] }
egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "de6e2d51892478fdd516df166f866e64dedbae07" }
egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "3c67eb6298edbff36d46546897cfac33df4f04db" }
egui_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "6eb91740577b374a8a6658c09c9a4181299734d0" }
#egui_virtual_list = "0.6.0"
egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b6794f5e707a2f4109633770e02b02fb722e1" }
@@ -37,7 +35,7 @@ ewebsock = { version = "0.2.0", features = ["tls"] }
fluent = "0.17.0"
fluent-resmgr = "0.0.8"
fluent-langneg = "0.13"
hex = { version = "0.4.3", features = ["serde"] }
hex = "0.4.3"
image = { version = "0.25", features = ["jpeg", "png", "webp"] }
indexmap = "2.6.0"
log = "0.4.17"
@@ -49,7 +47,6 @@ nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "2b2e5e43c019b
#nostrdb = "0.6.1"
notedeck = { path = "crates/notedeck" }
notedeck_chrome = { path = "crates/notedeck_chrome" }
notedeck_clndash = { path = "crates/notedeck_clndash" }
notedeck_columns = { path = "crates/notedeck_columns" }
notedeck_dave = { path = "crates/notedeck_dave" }
notedeck_notebook = { path = "crates/notedeck_notebook" }
@@ -72,6 +69,7 @@ tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tempfile = "3.13.0"
unic-langid = { version = "0.9.6", features = ["macros"] }
sys-locale = "0.3"
url = "2.5.2"
urlencoding = "2.1.3"
uuid = { version = "1.10.0", features = ["v4"] }
@@ -81,14 +79,14 @@ mime_guess = "2.0.5"
pretty_assertions = "1.4.1"
jni = "0.21.1"
profiling = "1.0"
lightning-invoice = { version = "0.33.1", features = ["serde"] }
lightning-invoice = "0.33.1"
secp256k1 = "0.30.0"
hashbrown = "0.15.2"
openai-api-rs = "6.0.3"
re_memory = "0.23.4"
oot_bitset = "0.1.1"
blurhash = "0.2.3"
android-activity = { git = "https://github.com/damus-io/android-activity", rev = "4ee16f1585e4a75031dc10785163d4b920f95805", features = [ "game-activity" ] }
[profile.small]
inherits = 'release'
@@ -106,15 +104,15 @@ strip = true # Strip symbols from binary*
#egui_extras = { path = "/home/jb55/dev/github/emilk/egui/crates/egui_extras" }
#epaint = { path = "/home/jb55/dev/github/emilk/egui/crates/epaint" }
egui = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
eframe = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
egui-winit = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
egui-wgpu = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
egui_extras = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
epaint = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
egui = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
eframe = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
egui-winit = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
egui-wgpu = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
egui_extras = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
epaint = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
puffin_egui = { git = "https://github.com/jb55/puffin", package = "puffin_egui", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
#winit = { git = "https://github.com/damus-io/winit", rev = "701a43d3c6479b0a3869acd2cebbfd410d399a59" }
#winit = { git = "https://github.com/damus-io/winit", rev = "14d61a74bee0c9863abe7ef28efae2c4d8bd3743" }
#winit = { path = "/home/jb55/dev/github/rust-windowing/winit" }
#android-activity = { git = "https://github.com/damus-io/android-activity", rev = "f56c974aa5182d5fbd361879f5899eb8f11a37ec" }
android-activity = { git = "https://github.com/damus-io/android-activity", rev = "a8948332c7c551303d32eb26a59d0abd676e47a5" }
#android-activity = { path = "/home/jb55/dev/github/rust-mobile/android-activity/android-activity" }
+1 -1
View File
@@ -27,4 +27,4 @@ push-android-config:
android: jni
cd $(ANDROID_DIR) && ./gradlew installDebug
adb shell am start -n com.damus.notedeck/.MainActivity
adb logcat -v color -s GameActivity -s RustStdoutStderr -s threaded_app | tee logcat.txt
adb logcat -v color -s RustStdoutStderr -s threaded_app | tee logcat.txt
-57
View File
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="256mm"
height="256mm"
viewBox="0 0 256 256"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="clnlogo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.078823"
inkscape:cx="396.72867"
inkscape:cy="561.25984"
inkscape:window-width="2020"
inkscape:window-height="1420"
inkscape:window-x="270"
inkscape:window-y="20"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="matrix(1.0800571,0,0,1.0347966,-2.6149197,-3.0116377)"
style="display:inline">
<g
id="g4"
transform="matrix(0.43515072,0,0,0.43515072,68.289343,9.0200629)">
<path
class="st1"
d="M 214.6,0 2.2,285.8 246.4,222.3 100.1,222.4 Z"
id="path3"
style="fill:#f0d003" />
<path
fill="#fffae6"
d="M 31.8,550.7 244.1,264.9 0,328.4 146.3,328.3 Z"
id="path4" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

+8 -54
View File
@@ -45,8 +45,6 @@ Algo_2452 = Algorithmus
Algorithmic_feeds_to_aid_in_note_discovery_d344 = Algorithmische Feeds zur Hilfe bei der Entdeckung von Notizen
# Label for zap amount input field
Amount_70f0 = Menge
# Label for appearance settings section
Appearance_4c7f = Darstellung
# Button to send message to Dave AI assistant
Ask_b7f4 = Fragen
# Placeholder text for Dave AI input field
@@ -61,18 +59,10 @@ Broadcast_fe43 = Senden
Broadcast_Local_7e50 = Lokal senden
# Button label to cancel an action
Cancel_ed3b = Abbrechen
# Label for cancel clear cache, Storage settings section
Cancel_fd8b = Abbrechen
# Label for clear cache button, Storage settings section
Clear_cache_dccb = Zwischenspeicher leeren
# Hover text for editable zap amount
Click_to_edit_0414 = Zum Bearbeiten anklicken
# Column title for note composition
Compose_Note_c094 = Notiz erstellen
# Label for configure relays, settings section
Configure_relays_d156 = Relays konfigurieren
# Label for confirm clear cache, Storage settings section
Confirm_9d9d = Bestätigen
# Button label to confirm an action
Confirm_f8a6 = Bestätigen
# Status label for connected relay
@@ -98,19 +88,19 @@ Copy_Pubkey_9cc4 = Pubkey kopieren
# Copy the text content of the note to clipboard
Copy_Text_f81c = Text kopieren
# Relative time in days
count_d_b9be = { $count }T
count_d_b9be = { $count }Tg.
# Relative time in hours
count_h_3ecb = { $count }h
count_h_3ecb = { $count }Std.
# Relative time in minutes
count_m_b41e = { $count }min
count_m_b41e = { $count }Min.
# Relative time in months
count_mo_7aba = { $count }M
count_mo_7aba = { $count }Mon.
# Relative time in seconds
count_s_aa26 = { $count }s
count_s_aa26 = { $count }Sek.
# Relative time in weeks
count_w_7468 = { $count }W
count_w_7468 = { $count }Wo.
# Relative time in years
count_y_9408 = { $count }J
count_y_9408 = { $count }J.
# Button to create a new account
Create_Account_6994 = Konto erstellen
# Button label to create a new deck
@@ -121,8 +111,6 @@ Custom_a69e = Benutzerdefiniert
Customize_Zap_Amount_cfc4 = Zap-Betrag anpassen
# Column title for support page
Damus_Support_27c0 = Damus Support
# Label for Theme Dark, Appearance settings section
Dark_85fe = Dunkel
# Label for deck name input field
Deck_name_cd32 = Deck-Name
# Label for decks section in side panel
@@ -163,16 +151,12 @@ Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__ns
Für das Veröffentlichen von Beiträgen und andere Aktionen ist dein privater Schlüssel erforderlich.
# Label for find user button
Find_User_bd12 = Profil finden
# Label for font size, Appearance settings section
Font_size_dd73 = Schriftgröße:
# Title for hashtags column
Hashtags_f8e0 = Hashtags
# Title for Home column
Home_8c19 = Startseite
# Label for deck icon selection
Icon_b0ab = Symbol
# Label for Image cache size, Storage settings section
Image_cache_size_3004 = Bildcache Größe:
# Title for individual user column
Individual_b776 = Individuell
# Error message for invalid zap amount
@@ -193,12 +177,8 @@ k_50K_c2dc = 50K
k_5K_f7e6 = 5K
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Behalte den Überblick über deine Notizen & Antworten
# Label for language, Appearance settings section
Language_e264 = Sprache:
# Title for last note per user column
Last_Note_per_User_17ad = Letzte Notiz pro Profil
# Label for Theme Light, Appearance settings section
Light_7475 = Hell
# Bitcoin Lightning network address field label
Lightning_network_address__lud16_ea51 = Lightning-Netzwerkadresse (lud16)
# Login page title
@@ -236,17 +216,11 @@ Notifications_d673 = Benachrichtigungen
# Title for notifications column
Notifications_ef56 = Benachrichtigungen
# Relative time for very recent events (less than 3 seconds)
now_2181 = Gerade eben
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = An
# Column title for finding users to follow
Onboarding_4a25 = Neue Leute finden
now_2181 = Jetzt
# Button label to open email client
Open_Email_25e9 = E-Mail öffnen
# Instruction to open email client
Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = Öffne deinen Standard-E-Mail-Client, um Hilfe vom Damus-Team zu erhalten
# Label for others settings section
Others_7267 = Andere
# Placeholder text for NWC URI input
Paste_your_NWC_URI_here_b471 = Füge hier deine NWC-URI ein...
# Error message for missing deck name
@@ -293,10 +267,6 @@ replying_to_a_note_e0bc = Antwort auf eine Notiz
Repost_this_note_8e56 = Diese Notiz teilen
# Label for reposted notes
Reposted_61c8 = Teilen
# Label for reset note body font size, Appearance settings section
Reset_4e60 = Zurücksetzen
# Label for reset zoom level, Appearance settings section
Reset_62d4 = Zurücksetzen
# Heading for support section
Running_into_a_bug_1796 = Ein Fehler aufgetreten?
# Label for satoshis (Bitcoin unit) for custom zap amount input field
@@ -317,12 +287,8 @@ Searching_for___query_5d18 = Suche nach '{ $query }'
See_notes_from_your_contacts_ac16 = Notizen von deinen Kontakten ansehen
# Description for universe column
See_the_whole_nostr_universe_7694 = Sieh dir das ganze Nostr-Universum an
# Button to select all profiles in follow pack
Select_All_a319 = Alle auswählen
# Button label to send a zap
Send_1ea4 = Senden
# Column title for app settings
Settings_7a4f = Einstellungen
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = Zeige die letzte Notiz für jedes Profil aus einer Liste
# Button label to sign out of account
@@ -331,8 +297,6 @@ Sign_out_337b = Abmelden
Someone_else_s_Notes_7e5f = Notizen anderer Profile
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Mitteilungen anderer Profile
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = Neueste Antworten zuerst sortieren:
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Die letzte Notiz für jedes Profil aus deiner Kontaktliste anzeigen
# Description for hashtags column
@@ -351,14 +315,10 @@ Stay_up_to_date_with_your_notifications_and_mentions_e73e = Bleib bei deinen Ben
Step_1_8656 = Schritt 1
# Step 2 label in support instructions
Step_2_d08d = Schritt 2
# Label for storage settings section
Storage_ed65 = Speicher
# Column title for subscribing to external user
Subscribe_to_someone_else_s_notes_d1e9 = Abonniere die Notizen eines anderen
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = Abonniere die Notizen von jemandem
# Support email address
Support_email_44d9 = E-Mail Support:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Zum Dunkelmodus wechseln
# Hover text for light mode toggle button
@@ -367,8 +327,6 @@ Switch_to_light_mode_72ce = Zum Hellmodus wechseln
Tap_to_Load_4b05 = Zum Laden antippen
# Message shown when Dave trial period has ended
The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = Die Testphase des Dave Nostr KI-Assistenten ist beendet :(. Vielen Dank fürs Ausprobieren! Zap-fähiger Dave kommt bald!
# Label for theme, Appearance settings section
Theme_4aac = Design:
# Column title for note thread view
Thread_0f20 = Unterhaltung
# Link text for thread references
@@ -383,8 +341,6 @@ Use_this_wallet_for_the_current_account_only_61dc = Diese Wallet nur für das ak
username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" bei "{ $domain }" wird für die Identifikation verwendet werden
# Profile username field label
Username_daa7 = Benutzername
# Label for view folder button, Storage settings section
View_folder_9742 = Ordner anzeigen
# Column title for wallet management
Wallet_5e50 = Wallet
# Hint for deck name input field
@@ -403,8 +359,6 @@ Your_Notifications_080d = Deine Benachrichtigungen
Zap_16b4 = Zap
# Hover text for zap button
Zap_this_note_42b2 = Zappe diese Notiz
# Label for zoom level, Appearance settings section
Zoom_Level_29a8 = Zoomstufe:
# Pluralized strings
+13 -22
View File
@@ -79,6 +79,9 @@ Banner_52ef = Banner
# Beta version label
BETA_8e5d = BETA
# Option in settings section to show the source client label at the bottom of the note
Bottom_33c8 = Bottom
# Broadcast the note to all connected relays
Broadcast_fe43 = Broadcast
@@ -238,12 +241,12 @@ Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__ns
# Label for find user button
Find_User_bd12 = Find User
# Label for font size, Appearance settings section
Font_size_dd73 = Font size:
# Title for hashtags column
Hashtags_f8e0 = Hashtags
# Option in settings section to hide the source client label in note display
Hide_281d = Hide
# Title for Home column
Home_8c19 = Home
@@ -349,12 +352,6 @@ Notifications_ef56 = Notifications
# Relative time for very recent events (less than 3 seconds)
now_2181 = now
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = On
# Column title for finding users to follow
Onboarding_4a25 = Onboarding
# Button label to open email client
Open_Email_25e9 = Open Email
@@ -433,9 +430,6 @@ Repost_this_note_8e56 = Repost this note
# Label for reposted notes
Reposted_61c8 = Reposted
# Label for reset note body font size, Appearance settings section
Reset_4e60 = Reset
# Label for reset zoom level, Appearance settings section
Reset_62d4 = Reset
@@ -469,15 +463,15 @@ See_notes_from_your_contacts_ac16 = See notes from your contacts
# Description for universe column
See_the_whole_nostr_universe_7694 = See the whole nostr universe
# Button to select all profiles in follow pack
Select_All_a319 = Select All
# Button label to send a zap
Send_1ea4 = Send
# Column title for app settings
Settings_7a4f = Settings
# Label for Show source client, others settings section
Show_source_client_9e31 = Show source client
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = Show the last note for each user from a list
@@ -490,9 +484,6 @@ Someone_else_s_Notes_7e5f = Someone else's Notes
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Someone else's Notifications
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = Sort replies newest first:
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Source the last note for each user in your contact list
@@ -529,9 +520,6 @@ Subscribe_to_someone_else_s_notes_d1e9 = Subscribe to someone else's notes
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = Subscribe to someone's notes
# Support email address
Support_email_44d9 = Support email:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Switch to dark mode
@@ -553,6 +541,9 @@ Thread_0f20 = Thread
# Link text for thread references
thread_ad1f = thread
# Option in settings section to show the source client label at the top of the note
Top_6aeb = Top
# Title for universe column
Universe_e01e = Universe
@@ -569,7 +560,7 @@ username___at___domain___will_be_used_for_identification_a4fd = "{$username}" at
Username_daa7 = Username
# Label for view folder button, Storage settings section
View_folder_9742 = View folder
View_folder_9742 = View folder:
# Column title for wallet management
Wallet_5e50 = Wallet
+13 -22
View File
@@ -79,6 +79,9 @@ Banner_52ef = {"["}Bàññér{"]"}
# Beta version label
BETA_8e5d = {"["}BÉTÀ{"]"}
# Option in settings section to show the source client label at the bottom of the note
Bottom_33c8 = {"["}Bóttóm{"]"}
# Broadcast the note to all connected relays
Broadcast_fe43 = {"["}Bróàdçàst{"]"}
@@ -238,12 +241,12 @@ Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__ns
# Label for find user button
Find_User_bd12 = {"["}Fíñd Úsér{"]"}
# Label for font size, Appearance settings section
Font_size_dd73 = {"["}Fóñt sízé:{"]"}
# Title for hashtags column
Hashtags_f8e0 = {"["}Hàshtàgs{"]"}
# Option in settings section to hide the source client label in note display
Hide_281d = {"["}Hídé{"]"}
# Title for Home column
Home_8c19 = {"["}Hómé{"]"}
@@ -349,12 +352,6 @@ Notifications_ef56 = {"["}Ñótífíçàtíóñs{"]"}
# Relative time for very recent events (less than 3 seconds)
now_2181 = {"["}ñów{"]"}
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = {"["}Óñ{"]"}
# Column title for finding users to follow
Onboarding_4a25 = {"["}Óñbóàrdíñg{"]"}
# Button label to open email client
Open_Email_25e9 = {"["}Ópéñ Émàíl{"]"}
@@ -433,9 +430,6 @@ Repost_this_note_8e56 = {"["}Répóst thís ñóté{"]"}
# Label for reposted notes
Reposted_61c8 = {"["}Répóstéd{"]"}
# Label for reset note body font size, Appearance settings section
Reset_4e60 = {"["}Rését{"]"}
# Label for reset zoom level, Appearance settings section
Reset_62d4 = {"["}Rését{"]"}
@@ -469,15 +463,15 @@ See_notes_from_your_contacts_ac16 = {"["}Séé ñótés fróm yóúr çóñtàç
# Description for universe column
See_the_whole_nostr_universe_7694 = {"["}Séé thé whólé ñóstr úñívérsé{"]"}
# Button to select all profiles in follow pack
Select_All_a319 = {"["}Séléçt Àll{"]"}
# Button label to send a zap
Send_1ea4 = {"["}Séñd{"]"}
# Column title for app settings
Settings_7a4f = {"["}Séttíñgs{"]"}
# Label for Show source client, others settings section
Show_source_client_9e31 = {"["}Shów sóúrçé çlíéñt{"]"}
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = {"["}Shów thé làst ñóté fór éàçh úsér fróm à líst{"]"}
@@ -490,9 +484,6 @@ Someone_else_s_Notes_7e5f = {"["}Sóméóñé élsé's Ñótés{"]"}
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = {"["}Sóméóñé élsé's Ñótífíçàtíóñs{"]"}
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = {"["}Sórt réplíés ñéwést fírst:{"]"}
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = {"["}Sóúrçé thé làst ñóté fór éàçh úsér íñ yóúr çóñtàçt líst{"]"}
@@ -529,9 +520,6 @@ Subscribe_to_someone_else_s_notes_d1e9 = {"["}Súbsçríbé tó sóméóñé él
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = {"["}Súbsçríbé tó sóméóñé's ñótés{"]"}
# Support email address
Support_email_44d9 = {"["}Súppórt émàíl:{"]"}
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = {"["}Swítçh tó dàrk módé{"]"}
@@ -553,6 +541,9 @@ Thread_0f20 = {"["}Thréàd{"]"}
# Link text for thread references
thread_ad1f = {"["}thréàd{"]"}
# Option in settings section to show the source client label at the top of the note
Top_6aeb = {"["}Tóp{"]"}
# Title for universe column
Universe_e01e = {"["}Úñívérsé{"]"}
@@ -569,7 +560,7 @@ username___at___domain___will_be_used_for_identification_a4fd = {"["}"{$username
Username_daa7 = {"["}Úsérñàmé{"]"}
# Label for view folder button, Storage settings section
View_folder_9742 = {"["}Víéw fóldér{"]"}
View_folder_9742 = {"["}Víéw fóldér:{"]"}
# Column title for wallet management
Wallet_5e50 = {"["}Wàllét{"]"}
-42
View File
@@ -45,8 +45,6 @@ Algo_2452 = Algo
Algorithmic_feeds_to_aid_in_note_discovery_d344 = Feeds algorítmicos para ayudar en el descubrimiento de notas
# Label for zap amount input field
Amount_70f0 = Cantidad
# Label for appearance settings section
Appearance_4c7f = Aspecto
# Button to send message to Dave AI assistant
Ask_b7f4 = Preguntar
# Placeholder text for Dave AI input field
@@ -61,18 +59,10 @@ Broadcast_fe43 = Transmitir
Broadcast_Local_7e50 = Transmitir localmente
# Button label to cancel an action
Cancel_ed3b = Cancelar
# Label for cancel clear cache, Storage settings section
Cancel_fd8b = Cancelar
# Label for clear cache button, Storage settings section
Clear_cache_dccb = Limpiar caché
# Hover text for editable zap amount
Click_to_edit_0414 = Haz clic para editar
# Column title for note composition
Compose_Note_c094 = Redactar nota
# Label for configure relays, settings section
Configure_relays_d156 = Configurar relés
# Label for confirm clear cache, Storage settings section
Confirm_9d9d = Confirmar
# Button label to confirm an action
Confirm_f8a6 = Confirmar
# Status label for connected relay
@@ -121,8 +111,6 @@ Custom_a69e = Personalizado
Customize_Zap_Amount_cfc4 = Personalizar cantidad de zap
# Column title for support page
Damus_Support_27c0 = Ayuda de Damus
# Label for Theme Dark, Appearance settings section
Dark_85fe = Oscuro
# Label for deck name input field
Deck_name_cd32 = Nombre del deck
# Label for decks section in side panel
@@ -161,16 +149,12 @@ Enter_your_key_0fca = Ingresa tu clave
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = Ingresa tu clave pública (npub), dirección de Nostr (por ejemplo, { $address }) o clave privada (nsec). Debes ingresar tu clave privada para poder publicar, responder, etc.
# Label for find user button
Find_User_bd12 = Buscar usuario
# Label for font size, Appearance settings section
Font_size_dd73 = Font size:
# Title for hashtags column
Hashtags_f8e0 = Hashtags
# Title for Home column
Home_8c19 = Inicio
# Label for deck icon selection
Icon_b0ab = Ícono
# Label for Image cache size, Storage settings section
Image_cache_size_3004 = Tamaño de caché de imágenes:
# Title for individual user column
Individual_b776 = Individual
# Error message for invalid zap amount
@@ -191,12 +175,8 @@ k_50K_c2dc = 50.000
k_5K_f7e6 = 5.000
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Haz seguimiento de tus notas y respuestas
# Label for language, Appearance settings section
Language_e264 = Idioma:
# Title for last note per user column
Last_Note_per_User_17ad = Última nota por usuario
# Label for Theme Light, Appearance settings section
Light_7475 = Claro
# Bitcoin Lightning network address field label
Lightning_network_address__lud16_ea51 = Dirección de la red Lightning (lud16)
# Login page title
@@ -235,14 +215,10 @@ Notifications_d673 = Notificaciones
Notifications_ef56 = Notificaciones
# Relative time for very recent events (less than 3 seconds)
now_2181 = ahora
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = On
# Button label to open email client
Open_Email_25e9 = Abrir correo electrónico
# Instruction to open email client
Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = Abre tu cliente de correo predeterminado para recibir ayuda del equipo de Damus
# Label for others settings section
Others_7267 = Otros
# Placeholder text for NWC URI input
Paste_your_NWC_URI_here_b471 = Pega tu NWC URI aquí...
# Error message for missing deck name
@@ -289,10 +265,6 @@ replying_to_a_note_e0bc = respondiendo a una nota
Repost_this_note_8e56 = Volver a publicar esta nota
# Label for reposted notes
Reposted_61c8 = Publicadas de nuevo
# Label for reset note body font size, Appearance settings section
Reset_4e60 = Reset
# Label for reset zoom level, Appearance settings section
Reset_62d4 = Restablecer
# Heading for support section
Running_into_a_bug_1796 = ¿Encontraste un error?
# Label for satoshis (Bitcoin unit) for custom zap amount input field
@@ -315,8 +287,6 @@ See_notes_from_your_contacts_ac16 = Ver notas de tus contactos
See_the_whole_nostr_universe_7694 = Ver todo el universo de nostr
# Button label to send a zap
Send_1ea4 = Enviar
# Column title for app settings
Settings_7a4f = Configuración
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = Mostrar la última nota para cada usuario de una lista
# Button label to sign out of account
@@ -325,8 +295,6 @@ Sign_out_337b = Cerrar sesión
Someone_else_s_Notes_7e5f = Notas de otra persona
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Notificaciones de otra persona
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = Sort replies newest first:
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Busca la última nota de cada usuario en tu lista de contactos
# Description for hashtags column
@@ -345,14 +313,10 @@ Stay_up_to_date_with_your_notifications_and_mentions_e73e = Mantente al día con
Step_1_8656 = Paso 1
# Step 2 label in support instructions
Step_2_d08d = Paso 2
# Label for storage settings section
Storage_ed65 = Almacenamiento
# Column title for subscribing to external user
Subscribe_to_someone_else_s_notes_d1e9 = Suscribirse a las notas de otra persona
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = Suscribirse a las notas de alguien
# Support email address
Support_email_44d9 = Support email:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Cambiar a modo oscuro
# Hover text for light mode toggle button
@@ -361,8 +325,6 @@ Switch_to_light_mode_72ce = Cambiar a modo claro
Tap_to_Load_4b05 = Toca para cargar
# Message shown when Dave trial period has ended
The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = La prueba del asistente de IA Dave de Nostr finalizó :(. ¡Gracias por probarlo! ¡Dave con zaps estará disponible muy pronto!
# Label for theme, Appearance settings section
Theme_4aac = Tema:
# Column title for note thread view
Thread_0f20 = Conversación
# Link text for thread references
@@ -377,8 +339,6 @@ Use_this_wallet_for_the_current_account_only_61dc = Usar esta billetera solo par
username___at___domain___will_be_used_for_identification_a4fd = Se utilizará "{ $username }" en "{ $domain }" para la identificación
# Profile username field label
Username_daa7 = Nombre de usuario
# Label for view folder button, Storage settings section
View_folder_9742 = Ver carpeta
# Column title for wallet management
Wallet_5e50 = Billetera
# Hint for deck name input field
@@ -397,8 +357,6 @@ Your_Notifications_080d = Tus notificaciones
Zap_16b4 = Zap
# Hover text for zap button
Zap_this_note_42b2 = Enviar un zap a esta nota
# Label for zoom level, Appearance settings section
Zoom_Level_29a8 = Nivel de zoom:
# Pluralized strings
-42
View File
@@ -45,8 +45,6 @@ Algo_2452 = Algo
Algorithmic_feeds_to_aid_in_note_discovery_d344 = Feeds algorítmicos para ayudar en el descubrimiento de notas
# Label for zap amount input field
Amount_70f0 = Cantidad
# Label for appearance settings section
Appearance_4c7f = Aspecto
# Button to send message to Dave AI assistant
Ask_b7f4 = Preguntar
# Placeholder text for Dave AI input field
@@ -61,18 +59,10 @@ Broadcast_fe43 = Transmitir
Broadcast_Local_7e50 = Transmitir localmente
# Button label to cancel an action
Cancel_ed3b = Cancelar
# Label for cancel clear cache, Storage settings section
Cancel_fd8b = Cancelar
# Label for clear cache button, Storage settings section
Clear_cache_dccb = Limpiar caché
# Hover text for editable zap amount
Click_to_edit_0414 = Haz clic para editar
# Column title for note composition
Compose_Note_c094 = Redactar nota
# Label for configure relays, settings section
Configure_relays_d156 = Configurar relés
# Label for confirm clear cache, Storage settings section
Confirm_9d9d = Confirmar
# Button label to confirm an action
Confirm_f8a6 = Confirmar
# Status label for connected relay
@@ -121,8 +111,6 @@ Custom_a69e = Personalizado
Customize_Zap_Amount_cfc4 = Personalizar cantidad de zap
# Column title for support page
Damus_Support_27c0 = Ayuda de Damus
# Label for Theme Dark, Appearance settings section
Dark_85fe = Oscuro
# Label for deck name input field
Deck_name_cd32 = Nombre del deck
# Label for decks section in side panel
@@ -161,16 +149,12 @@ Enter_your_key_0fca = Ingresa tu clave
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = Ingresa tu clave pública (npub), dirección de Nostr (por ejemplo, { $address }) o clave privada (nsec). Debes ingresar tu clave privada para poder publicar, responder, etc.
# Label for find user button
Find_User_bd12 = Buscar usuario
# Label for font size, Appearance settings section
Font_size_dd73 = Font size:
# Title for hashtags column
Hashtags_f8e0 = Hashtags
# Title for Home column
Home_8c19 = Inicio
# Label for deck icon selection
Icon_b0ab = Icono
# Label for Image cache size, Storage settings section
Image_cache_size_3004 = Tamaño de caché de imágenes:
# Title for individual user column
Individual_b776 = Individual
# Error message for invalid zap amount
@@ -191,12 +175,8 @@ k_50K_c2dc = 50.000
k_5K_f7e6 = 5.000
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Haz seguimiento de tus notas y respuestas
# Label for language, Appearance settings section
Language_e264 = Idioma:
# Title for last note per user column
Last_Note_per_User_17ad = Última nota por usuario
# Label for Theme Light, Appearance settings section
Light_7475 = Claro
# Bitcoin Lightning network address field label
Lightning_network_address__lud16_ea51 = Dirección de la red Lightning (lud16)
# Login page title
@@ -235,14 +215,10 @@ Notifications_d673 = Notificaciones
Notifications_ef56 = Notificaciones
# Relative time for very recent events (less than 3 seconds)
now_2181 = ahora
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = On
# Button label to open email client
Open_Email_25e9 = Abrir correo electrónico
# Instruction to open email client
Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = Abre tu cliente de correo predeterminado para recibir ayuda del equipo de Damus
# Label for others settings section
Others_7267 = Otros
# Placeholder text for NWC URI input
Paste_your_NWC_URI_here_b471 = Pega tu NWC URI aquí...
# Error message for missing deck name
@@ -289,10 +265,6 @@ replying_to_a_note_e0bc = respondiendo a una nota
Repost_this_note_8e56 = Volver a publicar esta nota
# Label for reposted notes
Reposted_61c8 = Publicadas de nuevo
# Label for reset note body font size, Appearance settings section
Reset_4e60 = Reset
# Label for reset zoom level, Appearance settings section
Reset_62d4 = Restablecer
# Heading for support section
Running_into_a_bug_1796 = ¿Has encontrado un error?
# Label for satoshis (Bitcoin unit) for custom zap amount input field
@@ -315,8 +287,6 @@ See_notes_from_your_contacts_ac16 = Ver notas de tus contactos
See_the_whole_nostr_universe_7694 = Ver todo el universo de nostr
# Button label to send a zap
Send_1ea4 = Enviar
# Column title for app settings
Settings_7a4f = Configuración
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = Mostrar la última nota para cada usuario de una lista
# Button label to sign out of account
@@ -325,8 +295,6 @@ Sign_out_337b = Cerrar sesión
Someone_else_s_Notes_7e5f = Notas de otra persona
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Notificaciones de otra persona
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = Sort replies newest first:
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Busca la última nota de cada usuario en tu lista de contactos
# Description for hashtags column
@@ -345,14 +313,10 @@ Stay_up_to_date_with_your_notifications_and_mentions_e73e = Mantente al día con
Step_1_8656 = Paso 1
# Step 2 label in support instructions
Step_2_d08d = Paso 2
# Label for storage settings section
Storage_ed65 = Almacenamiento
# Column title for subscribing to external user
Subscribe_to_someone_else_s_notes_d1e9 = Suscribirse a las notas de otra persona
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = Suscribirse a las notas de alguien
# Support email address
Support_email_44d9 = Support email:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Cambiar a modo oscuro
# Hover text for light mode toggle button
@@ -361,8 +325,6 @@ Switch_to_light_mode_72ce = Cambiar a modo claro
Tap_to_Load_4b05 = Toca para cargar
# Message shown when Dave trial period has ended
The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = La prueba del asistente de IA Dave de Nostr ha finalizado :(. ¡Gracias por probarlo! ¡Dave con zaps estará disponible muy pronto!
# Label for theme, Appearance settings section
Theme_4aac = Tema:
# Column title for note thread view
Thread_0f20 = Conversación
# Link text for thread references
@@ -377,8 +339,6 @@ Use_this_wallet_for_the_current_account_only_61dc = Usar este monedero solo para
username___at___domain___will_be_used_for_identification_a4fd = Se utilizará "{ $username }" en "{ $domain }" para la identificación
# Profile username field label
Username_daa7 = Nombre de usuario
# Label for view folder button, Storage settings section
View_folder_9742 = Ver carpeta
# Column title for wallet management
Wallet_5e50 = Monedero
# Hint for deck name input field
@@ -397,8 +357,6 @@ Your_Notifications_080d = Tus notificaciones
Zap_16b4 = Zap
# Hover text for zap button
Zap_this_note_42b2 = Enviar un zap a esta nota
# Label for zoom level, Appearance settings section
Zoom_Level_29a8 = Nivel de zoom:
# Pluralized strings
+9 -15
View File
@@ -55,6 +55,8 @@ Ask_dave_anything_33d1 = Demandez à Dave n'importe quoi...
Banner_52ef = Bannière
# Beta version label
BETA_8e5d = BETA
# Option in settings section to show the source client label at the bottom of the note
Bottom_33c8 = En bas
# Broadcast the note to all connected relays
Broadcast_fe43 = Diffusion
# Broadcast the note only to local network relays
@@ -161,10 +163,10 @@ Enter_your_key_0fca = Entrez votre clé
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = Entrez votre clé publique (npub), votre adresse nostr (par exemple { $address }), ou votre clé privée (nsec). Vous devez entrer votre clé privée pour pouvoir poster, répondre, etc.
# Label for find user button
Find_User_bd12 = Trouver un utilisateur
# Label for font size, Appearance settings section
Font_size_dd73 = Taille du texte :
# Title for hashtags column
Hashtags_f8e0 = Hashtags
# Option in settings section to hide the source client label in note display
Hide_281d = Masquer
# Title for Home column
Home_8c19 = Accueil
# Label for deck icon selection
@@ -235,10 +237,6 @@ Notifications_d673 = Notifications
Notifications_ef56 = Notifications
# Relative time for very recent events (less than 3 seconds)
now_2181 = maintenant
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = Activé
# Column title for finding users to follow
Onboarding_4a25 = Utilisateurs recommandés
# Button label to open email client
Open_Email_25e9 = Ouvrir Email
# Instruction to open email client
@@ -291,8 +289,6 @@ replying_to_a_note_e0bc = répondre à une note
Repost_this_note_8e56 = Republier cette note
# Label for reposted notes
Reposted_61c8 = Republier
# Label for reset note body font size, Appearance settings section
Reset_4e60 = Réinitialiser
# Label for reset zoom level, Appearance settings section
Reset_62d4 = Réinitialiser
# Heading for support section
@@ -315,12 +311,12 @@ Searching_for___query_5d18 = Recherche par '{ $query }'
See_notes_from_your_contacts_ac16 = Afficher les notes de vos contacts
# Description for universe column
See_the_whole_nostr_universe_7694 = Voir l'ensemble de l'univers nostr
# Button to select all profiles in follow pack
Select_All_a319 = Tout sélectionner
# Button label to send a zap
Send_1ea4 = Envoyer
# Column title for app settings
Settings_7a4f = Paramètres
# Label for Show source client, others settings section
Show_source_client_9e31 = Afficher le client source
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = Afficher la dernière note de chaque utilisateur à partir d'une liste
# Button label to sign out of account
@@ -329,8 +325,6 @@ Sign_out_337b = Se déconnecter
Someone_else_s_Notes_7e5f = Notes de quelqu'un d'autre
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Notifications de quelqu'un d'autre
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = Trier les réponses les plus récentes en premier :
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Source de la dernière note pour chaque utilisateur de votre liste de contacts
# Description for hashtags column
@@ -355,8 +349,6 @@ Storage_ed65 = Stockage
Subscribe_to_someone_else_s_notes_d1e9 = S'abonner aux notes de quelqu'un d'autre
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = S'abonner aux notes de quelqu'un
# Support email address
Support_email_44d9 = Adresse email de l'assistance :
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Passer en mode sombre
# Hover text for light mode toggle button
@@ -371,6 +363,8 @@ Theme_4aac = Thème :
Thread_0f20 = Fil
# Link text for thread references
thread_ad1f = fil
# Option in settings section to show the source client label at the top of the note
Top_6aeb = En haut
# Title for universe column
Universe_e01e = Universel
# Column title for universe feed
@@ -382,7 +376,7 @@ username___at___domain___will_be_used_for_identification_a4fd = "{ $username }"
# Profile username field label
Username_daa7 = Nom d'utilisateur
# Label for view folder button, Storage settings section
View_folder_9742 = Voir le dossier
View_folder_9742 = Voir le dossier :
# Column title for wallet management
Wallet_5e50 = Portefeuille
# Hint for deck name input field
-410
View File
@@ -1,410 +0,0 @@
# Main translation file for Notedeck
# This file contains common UI strings used throughout the application
# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
# Regular strings
# Profile about/bio field label
About_00c0 = 概要
# Column title for account management
Accounts_f018 = アカウント
# Button label to add a relay
Add_269d = 追加
# Label for add column button
Add_47df = 追加
# Button label to add a different wallet
Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = このアカウントでのみ使用される別のウォレットを追加
# Error message for missing wallet
Add_a_wallet_to_continue_d170 = 続行するにはウォレットを追加してください
# Button label to add a new account
Add_account_1cfc = アカウントを追加
# Column title for adding new account
Add_Account_d06c = アカウントの追加
# Column title for adding algorithm column
Add_Algo_Column_0d75 = アルゴカラムの追加
# Column title for adding new column
Add_Column_c764 = カラムの追加
# Column title for adding new deck
Add_Deck_fabf = デッキの追加
# Column title for adding external notifications column
Add_External_Notifications_Column_41ae = 外部通知カラムの追加
# Column title for adding hashtag column
Add_Hashtag_Column_ebf4 = ハッシュタグカラムの追加
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = 最後の投稿カラムの追加
# Column title for adding notifications column
Add_Notifications_Column_79f8 = 外部通知カラムの追加
# Button label to add a relay
Add_relay_269d = リレーを追加
# Button label to add a wallet
Add_Wallet_d1be = ウォレットを追加
# Title for algorithmic feeds column
Algo_2452 = アルゴ
# Description for algorithmic feeds column
Algorithmic_feeds_to_aid_in_note_discovery_d344 = 投稿の発見に役立つアルゴリズムフィードです
# Label for zap amount input field
Amount_70f0 = 金額
# Label for appearance settings section
Appearance_4c7f = 外観
# Button to send message to Dave AI assistant
Ask_b7f4 = 質問
# Placeholder text for Dave AI input field
Ask_dave_anything_33d1 = Dave に何でも質問してみましょう…
# Profile banner URL field label
Banner_52ef = バナー
# Beta version label
BETA_8e5d = ベータ
# Broadcast the note to all connected relays
Broadcast_fe43 = ブロードキャスト
# Broadcast the note only to local network relays
Broadcast_Local_7e50 = ローカルにブロードキャスト
# Button label to cancel an action
Cancel_ed3b = キャンセル
# Label for cancel clear cache, Storage settings section
Cancel_fd8b = キャンセル
# Label for clear cache button, Storage settings section
Clear_cache_dccb = キャッシュを消去
# Hover text for editable zap amount
Click_to_edit_0414 = クリックして編集
# Column title for note composition
Compose_Note_c094 = メモの作成
# Label for configure relays, settings section
Configure_relays_d156 = リレーを設定
# Label for confirm clear cache, Storage settings section
Confirm_9d9d = 決定
# Button label to confirm an action
Confirm_f8a6 = 決定
# Status label for connected relay
Connected_f8cc = 接続済
# Status label for connecting relay
Connecting_6b7e = 接続中…
# Title for contact list column
Contact_List_f85a = フォロイーリスト
# Column title for contact lists
Contacts_7533 = フォロー
# Column title for last notes per contact
Contacts__last_notes_3f84 = フォロー (最後の投稿)
# Button label to copy logs
Copy_a688 = コピー
# Button to copy media link to clipboard
Copy_Link_dc7c = リンクをコピー
# Copy the unique note identifier to clipboard
Copy_Note_ID_6b45 = 投稿 ID をコピー
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = 投稿の JSON をコピー
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = 公開鍵をコピー
# Copy the text content of the note to clipboard
Copy_Text_f81c = テキストをコピー
# Relative time in days
count_d_b9be = { $count }日
# Relative time in hours
count_h_3ecb = { $count }時間
# Relative time in minutes
count_m_b41e = { $count }分
# Relative time in months
count_mo_7aba = { $count }ヶ月
# Relative time in seconds
count_s_aa26 = { $count }秒
# Relative time in weeks
count_w_7468 = { $count }週間
# Relative time in years
count_y_9408 = { $count }年
# Button to create a new account
Create_Account_6994 = アカウントを作成
# Button label to create a new deck
Create_Deck_16b7 = デッキを作成
# Column title for custom timelines
Custom_a69e = カスタマイズ
# Column title for zap amount customization
Customize_Zap_Amount_cfc4 = Zap 金額をカスタマイズ
# Column title for support page
Damus_Support_27c0 = Damus サポート
# Label for Theme Dark, Appearance settings section
Dark_85fe = ダーク
# Label for deck name input field
Deck_name_cd32 = デッキ名
# Label for decks section in side panel
DECKS_1fad = デッキ
# Label for default zap amount input
Default_amount_per_zap_399d = Zap ごとのデフォルトの金額:
# Name of the default deck feed
Default_Deck_fcca = 既定のデッキ
# Button label to delete a deck
Delete_Deck_bb29 = デッキを削除
# Tooltip for deleting a column
Delete_this_column_8d5a = このカラムを削除します
# Button label to delete a wallet
Delete_Wallet_d1d4 = ウォレットを削除
# Profile display name field label
Display_name_f9d9 = 表示名
# Domain identification message
domain___will_be_used_for_identification_b67e = "{ $domain }" が識別に使用されます
# Column title for editing deck
Edit_Deck_4018 = デッキの編集
# Button label to edit a deck
Edit_Deck_fd93 = デッキを編集
# Button label to edit user profile
Edit_Profile_49e6 = プロファイルを編集
# Column title for profile editing
Edit_Profile_8ad4 = プロファイルの編集
# Placeholder for hashtag input field
Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = 必要なハッシュタグをここに入力してください (複数スペースで区切る場合)
# Placeholder for relay input field
Enter_the_relay_here_1c8b = ここにリレーを入力してください
# Hint text to prompt entering the user's public key.
Enter_the_user_s_key__npub__hex__nip05__here_650c = ユーザーの鍵 (npub, hex, nip05) を入力してください...
# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
Enter_your_key_0fca = 鍵を入力してください
# Instructions for entering Nostr credentials
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = 公開鍵 (npub)、nostr アドレス (例: { $address })、秘密鍵 (nsec) を入力してください。 投稿、返信などを行うには秘密鍵を入力する必要があります。
# Label for find user button
Find_User_bd12 = ユーザーを探す
# Label for font size, Appearance settings section
Font_size_dd73 = フォントサイズ:
# Title for hashtags column
Hashtags_f8e0 = ハッシュタグ
# Title for Home column
Home_8c19 = ホーム
# Label for deck icon selection
Icon_b0ab = アイコン
# Label for Image cache size, Storage settings section
Image_cache_size_3004 = 画像キャッシュのサイズ:
# Title for individual user column
Individual_b776 = 個人用
# Error message for invalid zap amount
Invalid_amount_6630 = 無効な金額です
# Error message for invalid key input
Invalid_key_4726 = 無効な鍵です。
# Error message for invalid Nostr Wallet Connect URI
Invalid_NWC_URI_031b = 無効な NWC URI です
# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
k_100K_686c = 100K
# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
k_10K_f7e6 = 10K
# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
k_20K_4977 = 20K
# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
k_50K_c2dc = 50K
# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
k_5K_f7e6 = 5K
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = 投稿と返信を記録します
# Label for language, Appearance settings section
Language_e264 = 言語:
# Title for last note per user column
Last_Note_per_User_17ad = ユーザーごとの最後の投稿
# Label for Theme Light, Appearance settings section
Light_7475 = ライト
# Bitcoin Lightning network address field label
Lightning_network_address__lud16_ea51 = ライトニングネットワークアドレス (lud16)
# Login page title
Login_9eef = ログイン
# Login button text
Login_now___let_s_do_this_5630 = 今すぐログイン — レッツゴー!
# Text shown on blurred media from unfollowed users
Media_from_someone_you_don_t_follow_5611 = フォローしていない人のメディアです
# Tooltip for moving a column
Moves_this_column_to_another_position_0d4b = このカラムを別の位置に移動します
# Title for the user's deck
My_Deck_4ac5 = あなたのデッキ
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = Nostr は初めてですか?
# NIP-05 identity field label
Nostr_address__NIP-05_identity_74a2 = Nostr アドレス (NIP-05)
# Default username when profile is not available
nostrich_df29 = ノス民
# Status label for disconnected relay
Not_Connected_6292 = 未接続
# Link text for note references
note_cad6 = 投稿
# Beta product warning message
Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck はベータ製品です。問題が発生した場合はサポートに問い合わせてください。
# Filter label for notes only view
Notes_03fb = 投稿
# Label for notes-only filter
Notes_60d2 = 投稿
# Filter label for notes and replies view
Notes___Replies_1ec2 = 投稿 & 返信
# Label for notes and replies filter
Notes___Replies_6e3b = 投稿 & 返信
# Column title for notifications
Notifications_d673 = 通知
# Title for notifications column
Notifications_ef56 = 通知
# Relative time for very recent events (less than 3 seconds)
now_2181 = たった今
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = 有効
# Button label to open email client
Open_Email_25e9 = メールを開く
# Instruction to open email client
Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = デフォルトのメールクライアントを開いて、Damus チームのヘルプを表示しましょう。
# Label for others settings section
Others_7267 = その他
# Placeholder text for NWC URI input
Paste_your_NWC_URI_here_b471 = ここに NWC の URI を貼り付けてください...
# Error message for missing deck name
Please_create_a_name_for_the_deck_38e7 = デッキの名前を作成してください。
# Error message for missing deck name and icon
Please_create_a_name_for_the_deck_and_select_an_icon_0add = デッキの名前を作成してアイコンを選択してください。
# Error message for missing deck icon
Please_select_an_icon_655b = アイコンを選択してください。
# Button label to post a note
Post_now_8a49 = すぐに投稿
# Instruction for copying logs
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = 下のボタンを押して、最新のログをシステムのクリップボードにコピーします。その後、メールに貼り付けてください。
# Profile picture URL field label
Profile_picture_81ff = プロフィール写真
# Column title for quote composition
Quote_475c = 引用
# Error message when quote note cannot be found
Quote_of_unknown_note_e4f0 = 不明な投稿の引用です
# Label for read-only profile mode
Read_only_82ff = 読み取り専用
# Column title for relay management
Relays_9d89 = リレー
# Label for relay list section
Relays_ad5e = リレー
# Column title for reply composition
Reply_3bf1 = 返信
# Hover text for reply button
Reply_to_this_note_f5de = この投稿に返信
# Error message when reply note cannot be found
Reply_to_unknown_note_4401 = 不明な投稿に返信しています
# Fallback template for replying to user
replying_to__user_15ab = { $user } に返信
# Template for replying to user in unknown thread
replying_to__user__in_someone_s_thread_e148 = 誰かのスレッドで { $user } に返信
# Template for replying to note in different user's thread
replying_to__user__s__note__in__thread_user__s__thread_daa8 = { $user }の { $note } の { $thread_user }の { $thread } に返信
# Template for replying to user's note
replying_to__user__s__note_ccba = { $user }の { $note } に返信
# Template for replying to root thread
replying_to__user__s__thread_444d = { $user }の { $thread } に返信
# Fallback text when reply note is not found
replying_to_a_note_e0bc = 投稿に返信
# Hover text for repost button
Repost_this_note_8e56 = このメモを再投稿
# Label for reposted notes
Reposted_61c8 = 再投稿
# Label for reset note body font size, Appearance settings section
Reset_4e60 = リセット
# Label for reset zoom level, Appearance settings section
Reset_62d4 = リセット
# Heading for support section
Running_into_a_bug_1796 = バグに遭遇しましたか?
# Label for satoshis (Bitcoin unit) for custom zap amount input field
SATS_45d7 = SATS
# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
sats_e5ec = sats
# Button to save default zap amount
Save_6f7c = 保存
# Button label to save profile changes
Save_changes_00db = 変更を保存
# Column title for search page
Search_c573 = 検索
# Placeholder for search notes input field
Search_notes_42a6 = 投稿を検索しましょう...
# Search in progress message
Searching_for___query_5d18 = 「{ $query }」を検索中
# Description for Home column
See_notes_from_your_contacts_ac16 = フォローしている人の投稿を表示
# Description for universe column
See_the_whole_nostr_universe_7694 = 全ユニバースを表示します
# Button label to send a zap
Send_1ea4 = 送信
# Column title for app settings
Settings_7a4f = 設定
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = 一覧から各ユーザーの最後の投稿を表示する
# Button label to sign out of account
Sign_out_337b = サインアウト
# Title for someone else's notes column
Someone_else_s_Notes_7e5f = 他の人の投稿
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = 他の人の通知
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = 最新の返信を最初に並べ替え:
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = フォローリストにある各ユーザーの最後の投稿を取得します
# Description for hashtags column
Stay_up_to_date_with_a_certain_hashtag_88e3 = 特定のハッシュタグで最新の情報を受け取ります
# Description for notifications column
Stay_up_to_date_with_notifications_and_mentions_6f4e = 通知とメンションの最新の情報を受け取ります
# Description for someone else's notes column
Stay_up_to_date_with_someone_else_s_notes___replies_464c = 他のユーザーの投稿と返信の最新の情報を受け取ります
# Description for someone else's notifications column
Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = 他のユーザーの投稿と返信の最新の情報を受け取ります
# Description for individual user column
Stay_up_to_date_with_someone_s_notes___replies_aa78 = 投稿と返信の最新の情報を受け取ります
# Description for your notifications column
Stay_up_to_date_with_your_notifications_and_mentions_e73e = あなたの通知とメンションの最新の情報を受け取ります
# Step 1 label in support instructions
Step_1_8656 = ステップ 1
# Step 2 label in support instructions
Step_2_d08d = ステップ 2
# Label for storage settings section
Storage_ed65 = ストレージ
# Column title for subscribing to external user
Subscribe_to_someone_else_s_notes_d1e9 = 他のユーザー投稿の購読
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = 投稿の購読
# Support email address
Support_email_44d9 = サポートメール:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = ダークモードに切り替える
# Hover text for light mode toggle button
Switch_to_light_mode_72ce = ライトモードに切り替える
# Button text to load blurred media
Tap_to_Load_4b05 = タップして読み込む
# Message shown when Dave trial period has ended
The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = Dave Nostr AI アシスタントトライアルが終了しました: (テストしていただきありがとうございます! Zap 対応デイブは近日公開予定です!
# Label for theme, Appearance settings section
Theme_4aac = テーマ:
# Column title for note thread view
Thread_0f20 = スレッド
# Link text for thread references
thread_ad1f = スレッド
# Title for universe column
Universe_e01e = ユニバース
# Column title for universe feed
Universe_ffaa = ユニバース
# Checkbox label for using wallet only for current account
Use_this_wallet_for_the_current_account_only_61dc = このウォレットを現在のアカウントにのみ使用する
# Username and domain identification message
username___at___domain___will_be_used_for_identification_a4fd = "{ $domain }" の "{ $username }" が識別に使用されます
# Profile username field label
Username_daa7 = ユーザー名
# Label for view folder button, Storage settings section
View_folder_9742 = フォルダを表示
# Column title for wallet management
Wallet_5e50 = ウォレット
# Hint for deck name input field
We_recommend_short_names_083e = 短い名前を推奨しています
# Profile website field label
Website_7980 = Web サイト
# Placeholder for note input field
Write_a_banger_note_here_bad2 = アツい一言をどうぞ...
# Placeholder text for key input field
Your_key_here_81bd = ここに鍵を入力...
# Title for your notes column
Your_Notes_f6db = 投稿
# Title for your notifications column
Your_Notifications_080d = 通知
# Heading for zap (tip) action
Zap_16b4 = Zap
# Hover text for zap button
Zap_this_note_42b2 = この投稿に Zap
# Label for zoom level, Appearance settings section
Zoom_Level_29a8 = 拡大率:
# Pluralized strings
# Search results count
Got__count__results_for___query_85fb =
{ $count ->
[one] { $query } の結果を '{ $count }' 件取得しました
*[other] ' { $query } の結果を '{ $count }' 件取得しました
}
+9 -15
View File
@@ -55,6 +55,8 @@ Ask_dave_anything_33d1 = Perguntar ao Dave
Banner_52ef = Destaque
# Beta version label
BETA_8e5d = Beta
# Option in settings section to show the source client label at the bottom of the note
Bottom_33c8 = Abaixo
# Broadcast the note to all connected relays
Broadcast_fe43 = Encaminhar
# Broadcast the note only to local network relays
@@ -161,10 +163,10 @@ Enter_your_key_0fca = Sua chave aqui
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = Insira sua chave pública (npub), endereço do Nostr (e.g. { $address }), ou chave privada (nsec). Você deve digitar sua chave privada para conseguir publicar, responder, etc.
# Label for find user button
Find_User_bd12 = Pesquisar usuário
# Label for font size, Appearance settings section
Font_size_dd73 = Tamanho da letra
# Title for hashtags column
Hashtags_f8e0 = #
# Option in settings section to hide the source client label in note display
Hide_281d = Ocultar
# Title for Home column
Home_8c19 = Início
# Label for deck icon selection
@@ -235,10 +237,6 @@ Notifications_d673 = Notificações
Notifications_ef56 = Notificações
# Relative time for very recent events (less than 3 seconds)
now_2181 = Agora
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = Ligar
# Column title for finding users to follow
Onboarding_4a25 = Interação
# Button label to open email client
Open_Email_25e9 = Abrir E-mail
# Instruction to open email client
@@ -291,8 +289,6 @@ replying_to_a_note_e0bc = Respondendo nota
Repost_this_note_8e56 = Republicar nota
# Label for reposted notes
Reposted_61c8 = Publicada
# Label for reset note body font size, Appearance settings section
Reset_4e60 = Redefinir
# Label for reset zoom level, Appearance settings section
Reset_62d4 = Resetar
# Heading for support section
@@ -315,12 +311,12 @@ Searching_for___query_5d18 = Pesquisando por '{ $query }'
See_notes_from_your_contacts_ac16 = Veja notas dos seus contatos
# Description for universe column
See_the_whole_nostr_universe_7694 = Veja todo o universo Nostr
# Button to select all profiles in follow pack
Select_All_a319 = Selecionar todos
# Button label to send a zap
Send_1ea4 = Enviar
# Column title for app settings
Settings_7a4f = Configurações
# Label for Show source client, others settings section
Show_source_client_9e31 = Mostrar cliente de origem
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = Mostrar a última nota para cada usuário de uma lista
# Button label to sign out of account
@@ -329,8 +325,6 @@ Sign_out_337b = Sair
Someone_else_s_Notes_7e5f = Notas de outra pessoa
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Notificações de outra pessoa
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = Ordenar respostas mais recentes primeiro:
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Fonte da última nota para cada usuário em sua lista de contatos
# Description for hashtags column
@@ -355,8 +349,6 @@ Storage_ed65 = Armazenamento
Subscribe_to_someone_else_s_notes_d1e9 = Inscrever-se em notas de outra pessoa
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = Inscrever-se nas notas de alguém
# Support email address
Support_email_44d9 = E-mail de suporte
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Mudar para modo escuro
# Hover text for light mode toggle button
@@ -371,6 +363,8 @@ Theme_4aac = Tema:
Thread_0f20 = Fio
# Link text for thread references
thread_ad1f = Fio
# Option in settings section to show the source client label at the top of the note
Top_6aeb = Topo
# Title for universe column
Universe_e01e = Universo
# Column title for universe feed
@@ -382,7 +376,7 @@ username___at___domain___will_be_used_for_identification_a4fd = d = "{ $username
# Profile username field label
Username_daa7 = Usuário
# Label for view folder button, Storage settings section
View_folder_9742 = Visualizar pasta
View_folder_9742 = Visualizar pasta:
# Column title for wallet management
Wallet_5e50 = Carteira
# Hint for deck name input field
-414
View File
@@ -1,414 +0,0 @@
# Main translation file for Notedeck
# This file contains common UI strings used throughout the application
# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
# Regular strings
# Profile about/bio field label
About_00c0 = Sobre
# Column title for account management
Accounts_f018 = Contas
# Button label to add a relay
Add_269d = Adicionar
# Label for add column button
Add_47df = Adicionar
# Button label to add a different wallet
Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = Adicionar uma carteira diferente que será usada apenas para esta conta
# Error message for missing wallet
Add_a_wallet_to_continue_d170 = Adicionar uma carteira para continuar
# Button label to add a new account
Add_account_1cfc = Adicionar conta
# Column title for adding new account
Add_Account_d06c = Adicionar conta
# Column title for adding algorithm column
Add_Algo_Column_0d75 = Adicionar coluna de algoritmo
# Column title for adding new column
Add_Column_c764 = Adicionar coluna
# Column title for adding new deck
Add_Deck_fabf = Adicionar aba
# Column title for adding external notifications column
Add_External_Notifications_Column_41ae = Adicionar coluna de notificações externas
# Column title for adding hashtag column
Add_Hashtag_Column_ebf4 = Adicionar coluna de marcadores
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = Adicionar coluna de últimas notas
# Column title for adding notifications column
Add_Notifications_Column_79f8 = Adicionar coluna de notificações
# Button label to add a relay
Add_relay_269d = Adicionar relay
# Button label to add a wallet
Add_Wallet_d1be = Adicionar carteira
# Title for algorithmic feeds column
Algo_2452 = Algoritmo
# Description for algorithmic feeds column
Algorithmic_feeds_to_aid_in_note_discovery_d344 = Fontes de algoritmo para ajudar na descoberta de notas
# Label for zap amount input field
Amount_70f0 = Quantia
# Label for appearance settings section
Appearance_4c7f = Aparência
# Button to send message to Dave AI assistant
Ask_b7f4 = Perguntar
# Placeholder text for Dave AI input field
Ask_dave_anything_33d1 = Perguntar qualquer coisa...
# Profile banner URL field label
Banner_52ef = Faixa
# Beta version label
BETA_8e5d = BETA
# Broadcast the note to all connected relays
Broadcast_fe43 = Transmissão
# Broadcast the note only to local network relays
Broadcast_Local_7e50 = Transmissão local
# Button label to cancel an action
Cancel_ed3b = Cancelar
# Label for cancel clear cache, Storage settings section
Cancel_fd8b = Cancelar
# Label for clear cache button, Storage settings section
Clear_cache_dccb = Limpar cache
# Hover text for editable zap amount
Click_to_edit_0414 = Clica para editar
# Column title for note composition
Compose_Note_c094 = Compor nota
# Label for configure relays, settings section
Configure_relays_d156 = Configurar relays
# Label for confirm clear cache, Storage settings section
Confirm_9d9d = Confirmar
# Button label to confirm an action
Confirm_f8a6 = Confirmar
# Status label for connected relay
Connected_f8cc = Conectado
# Status label for connecting relay
Connecting_6b7e = A conectar...
# Title for contact list column
Contact_List_f85a = Lista de contactos
# Column title for contact lists
Contacts_7533 = Contactos
# Column title for last notes per contact
Contacts__last_notes_3f84 = Contactos (últimas notas)
# Button label to copy logs
Copy_a688 = Copiar
# Button to copy media link to clipboard
Copy_Link_dc7c = Copiar link
# Copy the unique note identifier to clipboard
Copy_Note_ID_6b45 = Copiar ID da nota
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = Copiar JSON da nota
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = Copiar chave pública
# Copy the text content of the note to clipboard
Copy_Text_f81c = Copiar texto
# Relative time in days
count_d_b9be = { $count }d
# Relative time in hours
count_h_3ecb = { $count }h
# Relative time in minutes
count_m_b41e = { $count }m
# Relative time in months
count_mo_7aba = { $count } mês(es)
# Relative time in seconds
count_s_aa26 = { $count } s
# Relative time in weeks
count_w_7468 = { $count } semana(s)
# Relative time in years
count_y_9408 = { $count } ano(s)
# Button to create a new account
Create_Account_6994 = Criar conta
# Button label to create a new deck
Create_Deck_16b7 = Criar aba
# Column title for custom timelines
Custom_a69e = Personalizadas
# Column title for zap amount customization
Customize_Zap_Amount_cfc4 = Personalizar valor do zap
# Column title for support page
Damus_Support_27c0 = Suporte Damus
# Label for Theme Dark, Appearance settings section
Dark_85fe = Modo escuro
# Label for deck name input field
Deck_name_cd32 = Nome da aba
# Label for decks section in side panel
DECKS_1fad = ABAS
# Label for default zap amount input
Default_amount_per_zap_399d = Valor padrão por zap:
# Name of the default deck feed
Default_Deck_fcca = Aba padrão
# Button label to delete a deck
Delete_Deck_bb29 = Excluir aba
# Tooltip for deleting a column
Delete_this_column_8d5a = Apagar esta coluna
# Button label to delete a wallet
Delete_Wallet_d1d4 = Eliminar carteira
# Profile display name field label
Display_name_f9d9 = Nome a mostrar
# Domain identification message
domain___will_be_used_for_identification_b67e = "{ $domain }" será usado para identificação
# Column title for editing deck
Edit_Deck_4018 = Editar aba
# Button label to edit a deck
Edit_Deck_fd93 = Editar aba
# Button label to edit user profile
Edit_Profile_49e6 = Editar perfil
# Column title for profile editing
Edit_Profile_8ad4 = Editar perfil
# Placeholder for hashtag input field
Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = Insere aqui os marcadores desejados (para múltiplos com espaços separados)
# Placeholder for relay input field
Enter_the_relay_here_1c8b = Insere aqui o relay
# Hint text to prompt entering the user's public key.
Enter_the_user_s_key__npub__hex__nip05__here_650c = Insere aqui a chave de utilizador (npub, hex, nip05)
# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
Enter_your_key_0fca = Insere a tua chave
# Instructions for entering Nostr credentials
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = Insere a tua chave públca (npub), endereço nostr (por exemplo { $address }), ou chave privada (nsec). Tens de inserir a tua chave pública para publicar, responder, etc.
# Label for find user button
Find_User_bd12 = Encontrar utilizador
# Label for font size, Appearance settings section
Font_size_dd73 = Tamanho da letra:
# Title for hashtags column
Hashtags_f8e0 = Marcadores
# Title for Home column
Home_8c19 = Início
# Label for deck icon selection
Icon_b0ab = Ícone
# Label for Image cache size, Storage settings section
Image_cache_size_3004 = Tamanho do cache da imagem:
# Title for individual user column
Individual_b776 = Individual
# Error message for invalid zap amount
Invalid_amount_6630 = Quantia inválida
# Error message for invalid key input
Invalid_key_4726 = Chave inválida.
# Error message for invalid Nostr Wallet Connect URI
Invalid_NWC_URI_031b = NWC URI inválido.
# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
k_100K_686c = 100K
# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
k_10K_f7e6 = 10K
# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
k_20K_4977 = 20K
# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
k_50K_c2dc = 50K
# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
k_5K_f7e6 = 5K
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Acompanha as tuas notas e respostas
# Label for language, Appearance settings section
Language_e264 = Idioma:
# Title for last note per user column
Last_Note_per_User_17ad = Última nota por utilizador
# Label for Theme Light, Appearance settings section
Light_7475 = Modo claro
# Bitcoin Lightning network address field label
Lightning_network_address__lud16_ea51 = Endereço da rede Lightning (lud16)
# Login page title
Login_9eef = Iniciar sessão
# Login button text
Login_now___let_s_do_this_5630 = Entra agora — vamos fazer isto!
# Text shown on blurred media from unfollowed users
Media_from_someone_you_don_t_follow_5611 = Conteúdo de alguém que não segues
# Tooltip for moving a column
Moves_this_column_to_another_position_0d4b = Mover esta coluna para outra posição
# Title for the user's deck
My_Deck_4ac5 = Minha aba
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = Nov@ no Nostr?
# NIP-05 identity field label
Nostr_address__NIP-05_identity_74a2 = Endereço Nostr (identificação NIP-05)
# Default username when profile is not available
nostrich_df29 = nostrich
# Status label for disconnected relay
Not_Connected_6292 = Não conectado
# Link text for note references
note_cad6 = nota
# Beta product warning message
Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck é um produto beta. Espere bugs e contacte-nos quando tiver problemas.
# Filter label for notes only view
Notes_03fb = Notas
# Label for notes-only filter
Notes_60d2 = Notas
# Filter label for notes and replies view
Notes___Replies_1ec2 = Notas e respostas
# Label for notes and replies filter
Notes___Replies_6e3b = Notas e respostas
# Column title for notifications
Notifications_d673 = Notificações
# Title for notifications column
Notifications_ef56 = Notificações
# Relative time for very recent events (less than 3 seconds)
now_2181 = agora
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = Ativado
# Column title for finding users to follow
Onboarding_4a25 = Introdução
# Button label to open email client
Open_Email_25e9 = Abrir e-mail
# Instruction to open email client
Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = Abre o teu cliente de e-mail padrão para obteres ajuda da equipa Damus
# Label for others settings section
Others_7267 = Outros
# Placeholder text for NWC URI input
Paste_your_NWC_URI_here_b471 = Cola o teu NWC URI aqui...
# Error message for missing deck name
Please_create_a_name_for_the_deck_38e7 = Cria um nome para a aba.
# Error message for missing deck name and icon
Please_create_a_name_for_the_deck_and_select_an_icon_0add = Cria um nome para a aba e seleciona um ícone.
# Error message for missing deck icon
Please_select_an_icon_655b = Seleciona um ícone.
# Button label to post a note
Post_now_8a49 = Publicar agora
# Instruction for copying logs
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = Prime o botão abaixo para copiar os teus registos mais recentes para a área de transferência do teu sistema. Depois cola-os no teu e-mail.
# Profile picture URL field label
Profile_picture_81ff = Foto de perfil
# Column title for quote composition
Quote_475c = Citação
# Error message when quote note cannot be found
Quote_of_unknown_note_e4f0 = Citação de nota desconhecida
# Label for read-only profile mode
Read_only_82ff = Somente leitura
# Column title for relay management
Relays_9d89 = Relays
# Label for relay list section
Relays_ad5e = Relays
# Column title for reply composition
Reply_3bf1 = Responder
# Hover text for reply button
Reply_to_this_note_f5de = Responder a esta nota
# Error message when reply note cannot be found
Reply_to_unknown_note_4401 = Responder a nota desconhecida
# Fallback template for replying to user
replying_to__user_15ab = responder a { $user }
# Template for replying to user in unknown thread
replying_to__user__in_someone_s_thread_e148 = responder a { $user } no tópico de alguém
# Template for replying to note in different user's thread
replying_to__user__s__note__in__thread_user__s__thread_daa8 = respondendo à { $note } de { $user } no { $thread } de { $thread_user }
# Template for replying to user's note
replying_to__user__s__note_ccba = respondendo à { $note } de { $user }
# Template for replying to root thread
replying_to__user__s__thread_444d = respondendo ao { $thread } de { $user }
# Fallback text when reply note is not found
replying_to_a_note_e0bc = respondendo a uma nota
# Hover text for repost button
Repost_this_note_8e56 = Republicar esta nota
# Label for reposted notes
Reposted_61c8 = Republicado
# Label for reset note body font size, Appearance settings section
Reset_4e60 = Redefinir
# Label for reset zoom level, Appearance settings section
Reset_62d4 = Redefinir
# Heading for support section
Running_into_a_bug_1796 = Encontraste um bug?
# Label for satoshis (Bitcoin unit) for custom zap amount input field
SATS_45d7 = SATS
# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
sats_e5ec = sats
# Button to save default zap amount
Save_6f7c = Guardar
# Button label to save profile changes
Save_changes_00db = Guardar alterações
# Column title for search page
Search_c573 = Procurar
# Placeholder for search notes input field
Search_notes_42a6 = Procurar notas...
# Search in progress message
Searching_for___query_5d18 = Procurando por '{ $query }'
# Description for Home column
See_notes_from_your_contacts_ac16 = Ver notas dos meus contactos
# Description for universe column
See_the_whole_nostr_universe_7694 = Ver notas de todo o universo nostr
# Button to select all profiles in follow pack
Select_All_a319 = Selecionar todos
# Button label to send a zap
Send_1ea4 = Enviar
# Column title for app settings
Settings_7a4f = Configurações
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = Mostrar a última nota para cada utilizador a partir de uma lista
# Button label to sign out of account
Sign_out_337b = Terminar sessão
# Title for someone else's notes column
Someone_else_s_Notes_7e5f = Notas de outra pessoa
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Notificações de outra pessoa
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = Ordenar respostas mais recentes antes:
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Origem da última nota para cada utilizador na minha lista
# Description for hashtags column
Stay_up_to_date_with_a_certain_hashtag_88e3 = Atualizações com um dado marcador
# Description for notifications column
Stay_up_to_date_with_notifications_and_mentions_6f4e = Atualizações com notificações e menções
# Description for someone else's notes column
Stay_up_to_date_with_someone_else_s_notes___replies_464c = Atualizar-me de notas e respostas de outra pessoa
# Description for someone else's notifications column
Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = Atualizar-me de notificações e menções de outra pessoa
# Description for individual user column
Stay_up_to_date_with_someone_s_notes___replies_aa78 = Atualizar-me de notas e respostas de outra pessoa
# Description for your notifications column
Stay_up_to_date_with_your_notifications_and_mentions_e73e = Atualizar-me de notificações e menções
# Step 1 label in support instructions
Step_1_8656 = Passo 1
# Step 2 label in support instructions
Step_2_d08d = Passo 2
# Label for storage settings section
Storage_ed65 = Armazenamento
# Column title for subscribing to external user
Subscribe_to_someone_else_s_notes_d1e9 = Subscrever as notas de outra pessoa
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = Subscrever as notas de alguém
# Support email address
Support_email_44d9 = E-mail de suporte:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Mudar para o modo escuro
# Hover text for light mode toggle button
Switch_to_light_mode_72ce = Mudar para o modo claro
# Button text to load blurred media
Tap_to_Load_4b05 = Toca para carregar
# Message shown when Dave trial period has ended
The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = O teste do assistente de IA Dave Nost terminou :(. Obrigado por testares! Dave com ativação de ZAPS em breve!
# Label for theme, Appearance settings section
Theme_4aac = Tema:
# Column title for note thread view
Thread_0f20 = Tópico
# Link text for thread references
thread_ad1f = tópico
# Title for universe column
Universe_e01e = Universo
# Column title for universe feed
Universe_ffaa = Universo
# Checkbox label for using wallet only for current account
Use_this_wallet_for_the_current_account_only_61dc = Usar esta carteira apenas para a conta atual
# Username and domain identification message
username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" em "{ $domain }" será usado para identificação
# Profile username field label
Username_daa7 = Nome de utilizador
# Label for view folder button, Storage settings section
View_folder_9742 = Ver pasta
# Column title for wallet management
Wallet_5e50 = Carteira
# Hint for deck name input field
We_recommend_short_names_083e = Recomendamos nomes curtos
# Profile website field label
Website_7980 = Website
# Placeholder for note input field
Write_a_banger_note_here_bad2 = Escreve uma nota sonante aqui...
# Placeholder text for key input field
Your_key_here_81bd = A tua chave aqui...
# Title for your notes column
Your_Notes_f6db = Minhas notas
# Title for your notifications column
Your_Notifications_080d = Minhas notificações
# Heading for zap (tip) action
Zap_16b4 = Zap
# Hover text for zap button
Zap_this_note_42b2 = Enviar zaps a esta nota
# Label for zoom level, Appearance settings section
Zoom_Level_29a8 = Nível de zoom:
# Pluralized strings
# Search results count
Got__count__results_for___query_85fb =
{ $count ->
[one] { $count } resultado obtido para '{ $query }'
*[other] { $count } resultados obtidos para '{ $query }'
}
+15 -21
View File
@@ -46,7 +46,7 @@ Algorithmic_feeds_to_aid_in_note_discovery_d344 = ฟีดแบบอัลก
# Label for zap amount input field
Amount_70f0 = จำนวน
# Label for appearance settings section
Appearance_4c7f = ลักษณ
Appearance_4c7f = รูปลักษณ
# Button to send message to Dave AI assistant
Ask_b7f4 = ถาม
# Placeholder text for Dave AI input field
@@ -55,6 +55,8 @@ Ask_dave_anything_33d1 = ถามเดฟได้ทุกเรื่อง.
Banner_52ef = ภาพปก
# Beta version label
BETA_8e5d = เบต้า
# Option in settings section to show the source client label at the bottom of the note
Bottom_33c8 = ด้านล่าง
# Broadcast the note to all connected relays
Broadcast_fe43 = เผยแพร่
# Broadcast the note only to local network relays
@@ -90,11 +92,11 @@ Copy_a688 = คัดลอก
# Button to copy media link to clipboard
Copy_Link_dc7c = คัดลอกลิงก์
# Copy the unique note identifier to clipboard
Copy_Note_ID_6b45 = คัดลอก โน้ต ID
Copy_Note_ID_6b45 = คัดลอก ID โน้ต
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = คัดลอก JSON ของโน้ต
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = คัดลอก npub
Copy_Pubkey_9cc4 = คัดลอก Pubkey
# Copy the text content of the note to clipboard
Copy_Text_f81c = คัดลอกข้อความ
# Relative time in days
@@ -163,10 +165,10 @@ Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__ns
คุณจำเป็นต้องใส่คีย์ส่วนตัวเพื่อทำการโพสต์, ตอบกลับ และอื่นๆ
# Label for find user button
Find_User_bd12 = ค้นหาผู้ใช้
# Label for font size, Appearance settings section
Font_size_dd73 = ขนาดตัวอักษร:
# Title for hashtags column
Hashtags_f8e0 = แฮชแท็ก
# Option in settings section to hide the source client label in note display
Hide_281d = ซ่อน
# Title for Home column
Home_8c19 = หน้าแรก
# Label for deck icon selection
@@ -237,10 +239,6 @@ Notifications_d673 = การแจ้งเตือน
Notifications_ef56 = การแจ้งเตือน
# Relative time for very recent events (less than 3 seconds)
now_2181 = เมื่อสักครู่
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = เปิด
# Column title for finding users to follow
Onboarding_4a25 = เริ่มใช้
# Button label to open email client
Open_Email_25e9 = เปิดอีเมล
# Instruction to open email client
@@ -256,7 +254,7 @@ Please_create_a_name_for_the_deck_and_select_an_icon_0add = กรุณาต
# Error message for missing deck icon
Please_select_an_icon_655b = กรุณาเลือกไอคอน
# Button label to post a note
Post_now_8a49 = โพสต์
Post_now_8a49 = โพสต์เลย
# Instruction for copying logs
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = กดปุ่มด้านล่างเพื่อคัดลอกข้อมูลบันทึกล่าสุด ไปยังคลิปบอร์ด จากนั้นนำไปวางในอีเมลของคุณ
# Profile picture URL field label
@@ -293,8 +291,6 @@ replying_to_a_note_e0bc = ตอบกลับโน้ต
Repost_this_note_8e56 = รีโพสต์โน้ตนี้
# Label for reposted notes
Reposted_61c8 = รีโพสต์แล้ว
# Label for reset note body font size, Appearance settings section
Reset_4e60 = รีเซ็ต
# Label for reset zoom level, Appearance settings section
Reset_62d4 = รีเซ็ต
# Heading for support section
@@ -317,12 +313,12 @@ Searching_for___query_5d18 = กำลังค้นหา '{ $query }'
See_notes_from_your_contacts_ac16 = ดูโน้ตจากผู้ติดต่อของคุณ
# Description for universe column
See_the_whole_nostr_universe_7694 = ท่องจักรวาล Nostr ทั้งหมด
# Button to select all profiles in follow pack
Select_All_a319 = เลือกทั้งหมด
# Button label to send a zap
Send_1ea4 = ส่ง
# Column title for app settings
Settings_7a4f = การตั้งค่า
# Label for Show source client, others settings section
Show_source_client_9e31 = แสดงไคลเอนต์ต้นทาง
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = แสดงโน้ตล่าสุดของผู้ใช้แต่ละคนจากรายการ
# Button label to sign out of account
@@ -331,8 +327,6 @@ Sign_out_337b = ออกจากระบบ
Someone_else_s_Notes_7e5f = โน้ตของผู้อื่น
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = การแจ้งเตือนของผู้อื่น
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = เรียงการตอบกลับจากใหม่ที่สุดไปเก่าที่สุด
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = ดึงโน้ตล่าสุดของผู้ใช้แต่ละคนในรายชื่อผู้ติดต่อ
# Description for hashtags column
@@ -357,8 +351,6 @@ Storage_ed65 = พื้นที่จัดเก็บ
Subscribe_to_someone_else_s_notes_d1e9 = ติดตามโน้ตของผู้อื่น
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = ติดตามโน้ตของผู้อื่น
# Support email address
Support_email_44d9 = อีเมลฝ่ายสนับสนุน:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = เปลี่ยนเป็นโหมดมืด
# Hover text for light mode toggle button
@@ -373,6 +365,8 @@ Theme_4aac = ธีม:
Thread_0f20 = เธรด
# Link text for thread references
thread_ad1f = เธรด
# Option in settings section to show the source client label at the top of the note
Top_6aeb = ด้านบน
# Title for universe column
Universe_e01e = จักรวาล
# Column title for universe feed
@@ -380,11 +374,11 @@ Universe_ffaa = จักรวาล
# Checkbox label for using wallet only for current account
Use_this_wallet_for_the_current_account_only_61dc = ใช้วอลเล็ตนี้สำหรับบัญชีปัจจุบันเท่านั้น
# Username and domain identification message
username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" @ "{ $domain }" จะถูกใช้สำหรับการระบุตัวตน
username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" ที่ "{ $domain }" จะถูกใช้สำหรับการระบุตัวตน
# Profile username field label
Username_daa7 = ชื่อผู้ใช้
# Label for view folder button, Storage settings section
View_folder_9742 = ดูโฟลเดอร์
View_folder_9742 = ดูโฟลเดอร์:
# Column title for wallet management
Wallet_5e50 = วอลเล็ต
# Hint for deck name input field
@@ -392,7 +386,7 @@ We_recommend_short_names_083e = เราแนะนำให้ใช้ชื
# Profile website field label
Website_7980 = เว็บไซต์
# Placeholder for note input field
Write_a_banger_note_here_bad2 = เขียนโน้ตที่นี่...
Write_a_banger_note_here_bad2 = เขียนโน้ตปังๆ ที่นี่...
# Placeholder text for key input field
Your_key_here_81bd = ใส่คีย์ของคุณที่นี่...
# Title for your notes column
+9 -11
View File
@@ -55,6 +55,8 @@ Ask_dave_anything_33d1 = 向 Dave 提问任何问题…
Banner_52ef = 横幅
# Beta version label
BETA_8e5d = BETA
# Option in settings section to show the source client label at the bottom of the note
Bottom_33c8 = 底部
# Broadcast the note to all connected relays
Broadcast_fe43 = 广播
# Broadcast the note only to local network relays
@@ -161,10 +163,10 @@ Enter_your_key_0fca = 请输入你的密钥
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = 请输入你的公钥(npub)、nostr 地址(如 { $address })、或私钥(nsec)。 你必须输入你的私钥才能发帖、回复等等。
# Label for find user button
Find_User_bd12 = 查找用户
# Label for font size, Appearance settings section
Font_size_dd73 = 字体大小:
# Title for hashtags column
Hashtags_f8e0 = 标签
# Option in settings section to hide the source client label in note display
Hide_281d = 隐藏
# Title for Home column
Home_8c19 = 主页
# Label for deck icon selection
@@ -235,8 +237,6 @@ Notifications_d673 = 通知
Notifications_ef56 = 通知
# Relative time for very recent events (less than 3 seconds)
now_2181 = 刚刚
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = 开启
# Button label to open email client
Open_Email_25e9 = 打开电子邮箱
# Instruction to open email client
@@ -289,8 +289,6 @@ replying_to_a_note_e0bc = 正在回复笔记
Repost_this_note_8e56 = 转发此笔记
# Label for reposted notes
Reposted_61c8 = 已转发
# Label for reset note body font size, Appearance settings section
Reset_4e60 = 重置
# Label for reset zoom level, Appearance settings section
Reset_62d4 = 重置
# Heading for support section
@@ -317,6 +315,8 @@ See_the_whole_nostr_universe_7694 = 查看整个 nostr 宇宙
Send_1ea4 = 发送
# Column title for app settings
Settings_7a4f = 设置
# Label for Show source client, others settings section
Show_source_client_9e31 = 显示来源客户端
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = 显示列表中每个用户的最新一条笔记
# Button label to sign out of account
@@ -325,8 +325,6 @@ Sign_out_337b = 登出
Someone_else_s_Notes_7e5f = 其他人的笔记
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = 其他人的通知
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = 按最新排序回复:
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = 获取你的联系人列表中每个用户的最新一条笔记
# Description for hashtags column
@@ -351,8 +349,6 @@ Storage_ed65 = 存储
Subscribe_to_someone_else_s_notes_d1e9 = 订阅他人的笔记
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = 订阅某人的笔记
# Support email address
Support_email_44d9 = 支持电子邮件:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = 切换到暗色模式
# Hover text for light mode toggle button
@@ -367,6 +363,8 @@ Theme_4aac = 主题:
Thread_0f20 = 帖子
# Link text for thread references
thread_ad1f = 帖子
# Option in settings section to show the source client label at the top of the note
Top_6aeb = 顶部
# Title for universe column
Universe_e01e = 宇宙
# Column title for universe feed
@@ -378,7 +376,7 @@ username___at___domain___will_be_used_for_identification_a4fd = "{ $username }"
# Profile username field label
Username_daa7 = 用户名
# Label for view folder button, Storage settings section
View_folder_9742 = 查看文件夹
View_folder_9742 = 查看文件夹
# Column title for wallet management
Wallet_5e50 = 钱包
# Hint for deck name input field
+9 -11
View File
@@ -55,6 +55,8 @@ Ask_dave_anything_33d1 = 向 Dave 提問任何問題...
Banner_52ef = 橫幅
# Beta version label
BETA_8e5d = 測試版
# Option in settings section to show the source client label at the bottom of the note
Bottom_33c8 = 底部
# Broadcast the note to all connected relays
Broadcast_fe43 = 廣播
# Broadcast the note only to local network relays
@@ -161,10 +163,10 @@ Enter_your_key_0fca = 請輸入你的密鑰
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = 請輸入你的公鑰(npub)、nostr 地址(如 { $address })、或私鑰(nsec)。你必須輸入你的私鑰才能發貼、回覆等等。
# Label for find user button
Find_User_bd12 = 查找用戶
# Label for font size, Appearance settings section
Font_size_dd73 = 字體大小:
# Title for hashtags column
Hashtags_f8e0 = 標籤
# Option in settings section to hide the source client label in note display
Hide_281d = 隱藏
# Title for Home column
Home_8c19 = 主頁
# Label for deck icon selection
@@ -235,8 +237,6 @@ Notifications_d673 = 通知
Notifications_ef56 = 通知
# Relative time for very recent events (less than 3 seconds)
now_2181 = 剛剛
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = 開啟
# Button label to open email client
Open_Email_25e9 = 打開電子郵箱
# Instruction to open email client
@@ -289,8 +289,6 @@ replying_to_a_note_e0bc = 正在回覆筆記
Repost_this_note_8e56 = 轉發此筆記
# Label for reposted notes
Reposted_61c8 = 已轉發
# Label for reset note body font size, Appearance settings section
Reset_4e60 = 重置
# Label for reset zoom level, Appearance settings section
Reset_62d4 = 重置
# Heading for support section
@@ -317,6 +315,8 @@ See_the_whole_nostr_universe_7694 = 查看整個 nostr 宇宙
Send_1ea4 = 發送
# Column title for app settings
Settings_7a4f = 設置
# Label for Show source client, others settings section
Show_source_client_9e31 = 顯示來源客戶端
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = 顯示列表中每個用戶的最後一條筆記
# Button label to sign out of account
@@ -325,8 +325,6 @@ Sign_out_337b = 登出
Someone_else_s_Notes_7e5f = 其他人的筆記
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = 其他人的通知
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = 按最新排序回覆:
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = 獲取你的聯繫人列表中每個用戶的最新一條筆記
# Description for hashtags column
@@ -351,8 +349,6 @@ Storage_ed65 = 儲存
Subscribe_to_someone_else_s_notes_d1e9 = 訂閱他人的筆記
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = 訂閱某人的筆記
# Support email address
Support_email_44d9 = 支持電子郵件:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = 切換到暗色模式
# Hover text for light mode toggle button
@@ -367,6 +363,8 @@ Theme_4aac = 主題:
Thread_0f20 = 串文
# Link text for thread references
thread_ad1f = 串文
# Option in settings section to show the source client label at the top of the note
Top_6aeb = 頂部
# Title for universe column
Universe_e01e = 宇宙
# Column title for universe feed
@@ -378,7 +376,7 @@ username___at___domain___will_be_used_for_identification_a4fd = "{ $username }"
# Profile username field label
Username_daa7 = 用戶名
# Label for view folder button, Storage settings section
View_folder_9742 = 查看文件夾
View_folder_9742 = 查看文件夾
# Column title for wallet management
Wallet_5e50 = 錢包
# Hint for deck name input field
+1 -1
View File
@@ -135,7 +135,7 @@ pub fn setup_multicast_relay(
std::thread::spawn(move || {
let mut events = Events::with_capacity(1);
loop {
if let Err(err) = poll.poll(&mut events, None) {
if let Err(err) = poll.poll(&mut events, Some(Duration::from_millis(100))) {
error!("multicast socket poll error: {err}. ending multicast poller.");
return;
}
+1 -1
View File
@@ -68,7 +68,7 @@ impl From<RelayEvent<'_>> for OwnedRelayEvent {
}
#[derive(PartialEq, Eq, Hash, Clone)]
pub struct _RelaySub {
pub struct RelaySub {
pub(crate) subid: String,
pub(crate) filter: String,
}
+1 -5
View File
@@ -45,13 +45,11 @@ fluent = { workspace = true }
fluent-resmgr = { workspace = true }
fluent-langneg = { workspace = true }
unic-langid = { workspace = true }
sys-locale = { workspace = true }
once_cell = { workspace = true }
md5 = { workspace = true }
bitflags = { workspace = true }
regex = "1"
chrono = { workspace = true }
indexmap = {workspace = true}
crossbeam-channel = "0.5"
[dev-dependencies]
tempfile = { workspace = true }
@@ -59,8 +57,6 @@ tokio = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
android-activity = { workspace = true }
ndk-context = "0.1"
[features]
puffin = ["puffin_egui", "dep:puffin"]
-5
View File
@@ -267,11 +267,6 @@ impl Accounts {
Box::new(move |note: &Note, thread: &[u8; 32]| muted.is_muted(note, thread))
}
pub fn mute(&self) -> Box<Arc<crate::Muted>> {
let account_data = self.get_selected_account_data();
Box::new(Arc::clone(&account_data.muted.muted))
}
pub fn send_initial_filters(&mut self, pool: &mut RelayPool, relay_url: &str) {
let data = &self.get_selected_account().data;
// send the active account's relay list subscription
+1 -1
View File
@@ -42,7 +42,7 @@ impl Contacts {
pub(super) fn query(&mut self, ndb: &Ndb, txn: &Transaction) {
let binding = ndb
.query(txn, std::slice::from_ref(&self.filter), 1)
.query(txn, &[self.filter.clone()], 1)
.expect("query user relays results");
let Some(res) = binding.first() else {
+1 -1
View File
@@ -33,7 +33,7 @@ impl AccountMutedData {
.limit()
.unwrap_or(crate::filter::default_limit()) as i32;
let nks = ndb
.query(txn, std::slice::from_ref(&self.filter), lim)
.query(txn, &[self.filter.clone()], lim)
.expect("query user muted results")
.iter()
.map(|qr| qr.note_key)
+1 -1
View File
@@ -36,7 +36,7 @@ impl AccountRelayData {
.limit()
.unwrap_or(crate::filter::default_limit()) as i32;
let nks = ndb
.query(txn, std::slice::from_ref(&self.filter), lim)
.query(txn, &[self.filter.clone()], lim)
.expect("query user relays results")
.iter()
.map(|qr| qr.note_key)
+3 -18
View File
@@ -22,9 +22,6 @@ use std::rc::Rc;
use tracing::{error, info};
use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
#[cfg(target_os = "android")]
use android_activity::AndroidApp;
pub enum AppAction {
Note(NoteAction),
ToggleChrome,
@@ -54,9 +51,6 @@ pub struct Notedeck {
frame_history: FrameHistory,
job_pool: JobPool,
i18n: Localization,
#[cfg(target_os = "android")]
android_app: Option<AndroidApp>,
}
/// Our chrome, which is basically nothing
@@ -144,11 +138,6 @@ fn setup_puffin() {
}
impl Notedeck {
#[cfg(target_os = "android")]
pub fn set_android_context(&mut self, context: AndroidApp) {
self.android_app = Some(context);
}
pub fn new<P: AsRef<Path>>(ctx: &egui::Context, data_path: P, args: &[String]) -> Self {
#[cfg(feature = "puffin")]
setup_puffin();
@@ -252,8 +241,8 @@ impl Notedeck {
let setting_locale: Result<LanguageIdentifier, LanguageIdentifierError> =
settings.locale().parse();
if let Ok(setting_locale) = setting_locale {
if let Err(err) = i18n.set_locale(setting_locale) {
if setting_locale.is_ok() {
if let Err(err) = i18n.set_locale(setting_locale.unwrap()) {
error!("{err}");
}
}
@@ -283,8 +272,6 @@ impl Notedeck {
zaps,
job_pool,
i18n,
#[cfg(target_os = "android")]
android_app: None,
}
}
@@ -309,7 +296,7 @@ impl Notedeck {
.cloned()
.collect();
if !completely_unrecognized.is_empty() {
let err = format!("Unrecognized arguments: {completely_unrecognized:?}");
let err = format!("Unrecognized arguments: {:?}", completely_unrecognized);
tracing::error!("{}", &err);
return Err(Error::Generic(err));
}
@@ -348,8 +335,6 @@ impl Notedeck {
frame_history: &mut self.frame_history,
job_pool: &mut self.job_pool,
i18n: &mut self.i18n,
#[cfg(target_os = "android")]
android: self.android_app.as_ref().unwrap().clone(),
}
}
+2 -2
View File
@@ -124,10 +124,10 @@ impl Args {
res.options.set(NotedeckOptions::UseKeystore, true);
} else if arg == "--relay-debug" {
res.options.set(NotedeckOptions::RelayDebug, true);
} else if arg == "--show-client" {
res.options.set(NotedeckOptions::ShowClient, true);
} else if arg == "--notebook" {
res.options.set(NotedeckOptions::FeatureNotebook, true);
} else if arg == "--clndash" {
res.options.set(NotedeckOptions::FeatureClnDash, true);
} else {
unrecognized_args.insert(arg.clone());
}
-61
View File
@@ -8,9 +8,6 @@ use egui_winit::clipboard::Clipboard;
use enostr::RelayPool;
use nostrdb::Ndb;
#[cfg(target_os = "android")]
use android_activity::AndroidApp;
use egui::{Pos2, Rect};
// TODO: make this interface more sandboxed
pub struct AppContext<'a> {
@@ -29,62 +26,4 @@ pub struct AppContext<'a> {
pub frame_history: &'a mut FrameHistory,
pub job_pool: &'a mut JobPool,
pub i18n: &'a mut Localization,
#[cfg(target_os = "android")]
pub android: AndroidApp,
}
#[derive(Debug, Clone)]
pub enum SoftKeyboardContext {
Virtual,
Platform { ppp: f32 },
}
impl SoftKeyboardContext {
pub fn platform(context: &egui::Context) -> Self {
Self::Platform {
ppp: context.pixels_per_point(),
}
}
}
impl<'a> AppContext<'a> {
pub fn soft_keyboard_rect(&self, screen_rect: Rect, ctx: SoftKeyboardContext) -> Option<Rect> {
match ctx {
SoftKeyboardContext::Virtual => {
let height = 400.0;
skb_rect_from_screen_rect(screen_rect, height)
}
#[allow(unused_variables)]
SoftKeyboardContext::Platform { ppp } => {
#[cfg(target_os = "android")]
{
use android_activity::InsetType;
// not sure why I need this, it seems to be consistently off by some amount of
// pixels ?
let fudge = 0.0;
let inset = self.android.get_window_insets(InsetType::Ime);
let height = (inset.bottom as f32 / ppp) - fudge;
skb_rect_from_screen_rect(screen_rect, height)
}
#[cfg(not(target_os = "android"))]
{
None
}
}
}
}
}
#[inline]
fn skb_rect_from_screen_rect(screen_rect: Rect, height: f32) -> Option<Rect> {
if height == 0.0 {
return None;
}
let min = Pos2::new(0.0, screen_rect.max.y - height);
Some(Rect::from_min_max(min, screen_rect.max))
}
+6 -16
View File
@@ -86,13 +86,6 @@ impl FilterStates {
}
self.states.insert(relay, state);
}
/// For contacts, since that sub is managed elsewhere
pub fn set_all_states(&mut self, state: FilterState) {
for cur_state in self.states.values_mut() {
*cur_state = state.clone();
}
}
}
/// We may need to fetch some data from relays before our filter is ready.
@@ -183,24 +176,21 @@ pub fn should_since_optimize(limit: u64, num_notes: usize) -> bool {
limit as usize <= num_notes
}
pub fn since_optimize_filter_with(
filter: Filter,
latest_note: Option<&NoteRef>,
since_gap: u64,
) -> Filter {
pub fn since_optimize_filter_with(filter: Filter, notes: &[NoteRef], since_gap: u64) -> Filter {
// Get the latest entry in the events
let Some(latest) = latest_note else {
if notes.is_empty() {
return filter;
};
}
// get the latest note
let latest = notes[0];
let since = latest.created_at - since_gap;
filter.since_mut(since)
}
pub fn since_optimize_filter(filter: Filter, latest: Option<&NoteRef>) -> Filter {
since_optimize_filter_with(filter, latest, 60)
pub fn since_optimize_filter(filter: Filter, notes: &[NoteRef]) -> Filter {
since_optimize_filter_with(filter, notes, 60)
}
pub fn default_limit() -> u64 {
+233 -36
View File
@@ -3,6 +3,7 @@ use fluent::{FluentArgs, FluentBundle, FluentResource};
use fluent_langneg::negotiate_languages;
use std::borrow::Cow;
use std::collections::HashMap;
use sys_locale;
use unic_langid::{langid, LanguageIdentifier};
const EN_US: LanguageIdentifier = langid!("en-US");
@@ -11,13 +12,11 @@ const DE: LanguageIdentifier = langid!("de");
const ES_419: LanguageIdentifier = langid!("es-419");
const ES_ES: LanguageIdentifier = langid!("es-ES");
const FR: LanguageIdentifier = langid!("fr");
const JA: LanguageIdentifier = langid!("ja");
const PT_BR: LanguageIdentifier = langid!("pt-BR");
const PT_PT: LanguageIdentifier = langid!("pt-PT");
const TH: LanguageIdentifier = langid!("th");
const ZH_CN: LanguageIdentifier = langid!("zh-CN");
const ZH_TW: LanguageIdentifier = langid!("zh-TW");
const NUM_FTLS: usize = 12;
const NUM_FTLS: usize = 10;
const EN_US_NATIVE_NAME: &str = "English (US)";
const EN_XA_NATIVE_NAME: &str = "Éñglísh (Pséúdólóçàlé)";
@@ -25,9 +24,7 @@ const DE_NATIVE_NAME: &str = "Deutsch";
const ES_419_NATIVE_NAME: &str = "Español (Latinoamérica)";
const ES_ES_NATIVE_NAME: &str = "Español (España)";
const FR_NATIVE_NAME: &str = "Français";
const JA_NATIVE_NAME: &str = "日本語";
const PT_BR_NATIVE_NAME: &str = "Português (Brasil)";
const PT_PT_NATIVE_NAME: &str = "Português (Portugal)";
const TH_NATIVE_NAME: &str = "ภาษาไทย";
const ZH_CN_NATIVE_NAME: &str = "简体中文";
const ZH_TW_NATIVE_NAME: &str = "繁體中文";
@@ -62,18 +59,10 @@ const FTLS: [StaticBundle; NUM_FTLS] = [
identifier: FR,
ftl: include_str!("../../../../assets/translations/fr/main.ftl"),
},
StaticBundle {
identifier: JA,
ftl: include_str!("../../../../assets/translations/ja/main.ftl"),
},
StaticBundle {
identifier: PT_BR,
ftl: include_str!("../../../../assets/translations/pt-BR/main.ftl"),
},
StaticBundle {
identifier: PT_PT,
ftl: include_str!("../../../../assets/translations/pt-PT/main.ftl"),
},
StaticBundle {
identifier: TH,
ftl: include_str!("../../../../assets/translations/th/main.ftl"),
@@ -113,10 +102,6 @@ pub struct Localization {
impl Default for Localization {
fn default() -> Self {
// Default to English (US)
let default_locale = &EN_US;
let fallback_locale = default_locale.to_owned();
// Build available locales list
let available_locales = vec![
EN_US.clone(),
@@ -125,9 +110,7 @@ impl Default for Localization {
ES_419.clone(),
ES_ES.clone(),
FR.clone(),
JA.clone(),
PT_BR.clone(),
PT_PT.clone(),
TH.clone(),
ZH_CN.clone(),
ZH_TW.clone(),
@@ -140,16 +123,26 @@ impl Default for Localization {
(ES_419, ES_419_NATIVE_NAME.to_owned()),
(ES_ES, ES_ES_NATIVE_NAME.to_owned()),
(FR, FR_NATIVE_NAME.to_owned()),
(JA, JA_NATIVE_NAME.to_owned()),
(PT_BR, PT_BR_NATIVE_NAME.to_owned()),
(PT_PT, PT_PT_NATIVE_NAME.to_owned()),
(TH, TH_NATIVE_NAME.to_owned()),
(ZH_CN, ZH_CN_NATIVE_NAME.to_owned()),
(ZH_TW, ZH_TW_NATIVE_NAME.to_owned()),
]);
// Detect system locale and find best match
let current_locale = Self::negotiate_system_locale_with_preferences(&available_locales);
// Fallback locale is always EN_US
let fallback_locale = EN_US.clone();
tracing::info!(
"Localization initialized - Selected locale: {}, Fallback: {}",
current_locale,
fallback_locale
);
Self {
current_locale: default_locale.to_owned(),
current_locale,
available_locales,
fallback_locale,
locale_native_names,
@@ -175,6 +168,150 @@ impl Localization {
}
}
/// Extract just the language and region from locale string (e.g., "fr-FR-u-mu-celsius" -> "fr-FR")
fn extract_language_region(locale_str: &str) -> String {
// Split by '-' and analyze the parts
let parts: Vec<&str> = locale_str.split('-').collect();
if parts.len() >= 2 {
// Check if the second part looks like a region
let second_part = parts[1];
if (second_part.len() >= 2) {
format!("{}-{}", parts[0], parts[1])
} else {
// Second part is not a region, probably an extension (e.g., "u", "t", "x")
// Just return the language part
parts[0].to_string()
}
} else {
// Only one part, return as is
locale_str.to_string()
}
}
/// Negotiate the best locale from all system preferences against available locales
fn negotiate_system_locale_with_preferences(
available_locales: &[LanguageIdentifier],
) -> LanguageIdentifier {
// Get all system preferred locales in descending order
let mut system_locales: Vec<String> = sys_locale::get_locales().collect();
if system_locales.is_empty() {
tracing::info!("No system locales detected, using fallback: en-US");
return EN_US.clone();
}
tracing::info!("System preferred locales: {:?}", system_locales);
// If we only got one locale, it might be that the system only returns the primary locale
// In this case, we can try to add common fallbacks based on the detected locale
if system_locales.len() == 1 {
let primary = &system_locales[0];
// Try to parse the primary locale, handling extensions
let primary_lang = if let Ok(locale) = primary.parse::<LanguageIdentifier>() {
locale.language.as_str().to_string()
} else {
// If parsing fails, try extracting language-region
// let stripped = Self::extract_language_region(primary);
// if let Ok(locale) = stripped.parse::<LanguageIdentifier>() {
// locale.language.as_str().to_string()
// } else {
tracing::info!("Could not parse primary locale: {}", primary);
"unknown".to_string()
// }
};
tracing::info!(
"Only one system locale detected: {} (language: {})",
primary,
primary_lang
);
// Add common fallbacks for the detected language
match primary_lang.as_str() {
"uk" => {
// For Ukrainian, add common fallbacks
system_locales.push("es-ES".to_string());
system_locales.push("en-US".to_string());
tracing::info!("Added fallbacks for Ukrainian: {:?}", system_locales);
}
"es" => {
// For Spanish, add English fallback
system_locales.push("en-US".to_string());
tracing::info!("Added fallback for Spanish: {:?}", system_locales);
}
_ => {
// For other languages, add English fallback
system_locales.push("en-US".to_string());
tracing::info!("Added fallback for {}: {:?}", primary_lang, system_locales);
}
}
}
// Convert system locale strings to LanguageIdentifiers, handling extensions
let mut parsed_system_locales = Vec::new();
for locale_str in system_locales {
// Try to parse the locale string directly first
if let Ok(locale) = locale_str.parse::<LanguageIdentifier>() {
parsed_system_locales.push(locale);
continue;
}
// If parsing fails, try extracting just language-region
// let stripped_locale = Self::extract_language_region(&locale_str);
// if let Ok(locale) = stripped_locale.parse::<LanguageIdentifier>() {
// parsed_system_locales.push(locale);
// continue;
// }
tracing::info!("Failed to parse locale string: {}", locale_str);
}
if parsed_system_locales.is_empty() {
tracing::info!("No valid system locales parsed, using fallback: en-US");
return EN_US.clone();
}
// First try exact matches with fluent_langneg
let fallback = &EN_US;
let negotiated = negotiate_languages(
&parsed_system_locales,
available_locales,
Some(fallback),
fluent_langneg::NegotiationStrategy::Filtering,
);
if let Some(result) = negotiated.first() {
tracing::info!(
"Exact match found: {} from preferences: {:?}",
result,
parsed_system_locales
);
return (*result).clone();
}
// If no exact match, try language-only fallbacks
tracing::info!("No exact matches found, trying language-only fallbacks");
for system_locale in &parsed_system_locales {
let system_lang = system_locale.language.as_str();
// Look for any available locale with the same language
for available_locale in available_locales {
if available_locale.language.as_str() == system_lang {
tracing::debug!(
"Language match found: {} (system: {})",
available_locale,
system_locale
);
return available_locale.clone();
}
}
}
tracing::info!("No language matches found, using fallback: en-US");
EN_US.clone()
}
/// Gets a localized string by its ID
pub fn get_string(&mut self, id: IntlKey<'_>) -> Result<String, IntlError> {
self.get_cached_string(id, None)
@@ -474,20 +611,6 @@ impl Localization {
Ok(())
}
/// Negotiates the best locale from a list of preferred locales
pub fn negotiate_locale(&self, preferred: &[LanguageIdentifier]) -> LanguageIdentifier {
let available = self.available_locales.clone();
let negotiated = negotiate_languages(
preferred,
&available,
Some(&self.fallback_locale),
fluent_langneg::NegotiationStrategy::Filtering,
);
negotiated
.first()
.map_or(self.fallback_locale.clone(), |v| (*v).clone())
}
}
/// Statistics about cache usage
@@ -500,6 +623,80 @@ pub struct CacheStats {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_language_region() {
// Test that we extract just language and region from various locale formats
// Test locales with extensions
let unicode_locale = "fr-FR-u-mu-celsius";
let extracted = Localization::extract_language_region(unicode_locale);
assert_eq!(extracted, "fr-FR");
let transformed_locale = "en-US-t-0-abc123";
let extracted = Localization::extract_language_region(transformed_locale);
assert_eq!(extracted, "en-US");
let private_locale = "de-DE-x-phonebk";
let extracted = Localization::extract_language_region(private_locale);
assert_eq!(extracted, "de-DE");
// Test simple locale (no extensions)
let simple_locale = "en-US";
let extracted = Localization::extract_language_region(simple_locale);
assert_eq!(extracted, "en-US");
// Test language-only locale
let lang_only = "en";
let extracted = Localization::extract_language_region(lang_only);
assert_eq!(extracted, "en");
// Test language with extensions (no region)
let lang_with_extensions = "fr-u-mu-celsius";
let extracted = Localization::extract_language_region(lang_with_extensions);
assert_eq!(extracted, "fr");
// Test language with other extension types (no region)
let lang_with_t_ext = "en-t-0-abc123";
let extracted = Localization::extract_language_region(lang_with_t_ext);
assert_eq!(extracted, "en");
let lang_with_x_ext = "de-x-phonebk";
let extracted = Localization::extract_language_region(lang_with_x_ext);
assert_eq!(extracted, "de");
// Test locale with numeric region code
let numeric_region = "es-419-u-mu-celsius";
let extracted = Localization::extract_language_region(numeric_region);
assert_eq!(extracted, "es-419");
// Test locale with 3-letter region code
let three_letter_region = "en-USA-t-0-abc123";
let extracted = Localization::extract_language_region(three_letter_region);
assert_eq!(extracted, "en-USA");
// Test locale with 2-letter region code
let two_letter_region = "fr-FR-u-mu-celsius";
let extracted = Localization::extract_language_region(two_letter_region);
assert_eq!(extracted, "fr-FR");
// Test complex locale with multiple parts
let complex_locale = "zh-CN-u-ca-chinese-x-private";
let extracted = Localization::extract_language_region(complex_locale);
assert_eq!(extracted, "zh-CN");
// Verify that extracted locales can be parsed
let test_cases = ["fr-FR", "en-US", "de-DE", "en", "zh-CN"];
for extracted in test_cases {
if let Ok(locale) = extracted.parse::<LanguageIdentifier>() {
tracing::info!("Successfully parsed extracted locale: {}", locale);
} else {
tracing::error!("Failed to parse extracted locale: {}", extracted);
panic!("Should parse locale after extraction");
}
}
}
//
// TODO(jb55): write tests that work, i broke all these during the refacto
+4 -12
View File
@@ -1,6 +1,5 @@
use crate::media::gif::ensure_latest_texture_from_cache;
use crate::media::images::ImageType;
use crate::media::AnimationMode;
use crate::urls::{UrlCache, UrlMimes};
use crate::ImageMetadata;
use crate::ObfuscationType;
@@ -35,7 +34,7 @@ impl TexturesCache {
&mut self,
url: &str,
closure: impl FnOnce() -> Promise<Option<Result<TexturedImage>>>,
) -> LoadableTextureState<'_> {
) -> LoadableTextureState {
let internal = self.handle_and_get_state_internal(url, true, closure);
internal.into()
@@ -45,7 +44,7 @@ impl TexturesCache {
&mut self,
url: &str,
closure: impl FnOnce() -> Promise<Option<Result<TexturedImage>>>,
) -> TextureState<'_> {
) -> TextureState {
let internal = self.handle_and_get_state_internal(url, false, closure);
internal.into()
@@ -96,7 +95,7 @@ impl TexturesCache {
});
}
pub fn get_and_handle(&mut self, url: &str) -> Option<LoadableTextureState<'_>> {
pub fn get_and_handle(&mut self, url: &str) -> Option<LoadableTextureState> {
self.cache.get_mut(url).map(|state| {
handle_occupied(state, true);
state.into()
@@ -465,7 +464,6 @@ impl Images {
ui: &mut egui::Ui,
url: &str,
img_type: ImageType,
animation_mode: AnimationMode,
) -> Option<TextureHandle> {
let cache_type = crate::urls::supported_mime_hosted_at_url(&mut self.urls, url)?;
@@ -487,13 +485,7 @@ impl Images {
MediaCacheType::Gif => &mut self.gifs,
};
ensure_latest_texture_from_cache(
ui,
url,
&mut self.gif_states,
&mut cache.textures_cache,
animation_mode,
)
ensure_latest_texture_from_cache(ui, url, &mut self.gif_states, &mut cache.textures_cache)
}
pub fn get_cache(&self, cache_type: MediaCacheType) -> &MediaCache {
+1 -4
View File
@@ -16,7 +16,6 @@ mod jobs;
pub mod media;
mod muted;
pub mod name;
mod nip51_set;
pub mod note;
mod notecache;
mod options;
@@ -46,7 +45,7 @@ pub use account::relay::RelayAction;
pub use account::FALLBACK_PUBKEY;
pub use app::{App, AppAction, Notedeck};
pub use args::Args;
pub use context::{AppContext, SoftKeyboardContext};
pub use context::AppContext;
pub use error::{show_one_error_message, Error, FilterError, ZapError};
pub use filter::{FilterState, FilterStates, UnifiedSubscription};
pub use fonts::NamedFontFamily;
@@ -66,7 +65,6 @@ pub use media::{
};
pub use muted::{MuteFun, Muted};
pub use name::NostrName;
pub use nip51_set::{create_nip51_set, Nip51Set, Nip51SetCache};
pub use note::{
BroadcastContext, ContextSelection, NoteAction, NoteContext, NoteContextSelection, NoteRef,
RootIdError, RootNoteId, RootNoteIdBuf, ScrollInfo, ZapAction,
@@ -82,7 +80,6 @@ pub use storage::{AccountStorage, DataPath, DataPathType, Directory};
pub use style::NotedeckTextStyle;
pub use theme::ColorTheme;
pub use time::time_ago_since;
pub use time::time_format;
pub use timecache::TimeCached;
pub use unknowns::{get_unknown_note_ids, NoteRefsUnkIdAction, SingleUnkIdAction, UnknownIds};
pub use urls::{supported_mime_hosted_at_url, SupportedMimeType, UrlMimes};
+66 -109
View File
@@ -3,18 +3,14 @@ use std::{
time::{Instant, SystemTime},
};
use crate::media::AnimationMode;
use crate::Animation;
use crate::{GifState, GifStateMap, TextureState, TexturedImage, TexturesCache};
use egui::TextureHandle;
use std::time::Duration;
pub fn ensure_latest_texture_from_cache(
ui: &egui::Ui,
url: &str,
gifs: &mut GifStateMap,
textures: &mut TexturesCache,
animation_mode: AnimationMode,
) -> Option<TextureHandle> {
let tstate = textures.cache.get_mut(url)?;
@@ -22,102 +18,7 @@ pub fn ensure_latest_texture_from_cache(
return None;
};
Some(ensure_latest_texture(ui, url, gifs, img, animation_mode))
}
struct ProcessedGifFrame {
texture: TextureHandle,
maybe_new_state: Option<GifState>,
repaint_at: Option<SystemTime>,
}
/// Process a gif state frame, and optionally present a new
/// state and when to repaint it
fn process_gif_frame(
animation: &Animation,
frame_state: Option<&GifState>,
animation_mode: AnimationMode,
) -> ProcessedGifFrame {
let now = Instant::now();
match frame_state {
Some(prev_state) => {
let should_advance = animation_mode.can_animate()
&& (now - prev_state.last_frame_rendered >= prev_state.last_frame_duration);
if should_advance {
let maybe_new_index = if animation.receiver.is_some()
|| prev_state.last_frame_index < animation.num_frames() - 1
{
prev_state.last_frame_index + 1
} else {
0
};
match animation.get_frame(maybe_new_index) {
Some(frame) => {
let next_frame_time = match animation_mode {
AnimationMode::Continuous { fps } => match fps {
Some(fps) => {
let max_delay_ms = Duration::from_millis((1000.0 / fps) as u64);
SystemTime::now().checked_add(frame.delay.max(max_delay_ms))
}
None => SystemTime::now().checked_add(frame.delay),
},
AnimationMode::NoAnimation | AnimationMode::Reactive => None,
};
ProcessedGifFrame {
texture: frame.texture.clone(),
maybe_new_state: Some(GifState {
last_frame_rendered: now,
last_frame_duration: frame.delay,
next_frame_time,
last_frame_index: maybe_new_index,
}),
repaint_at: next_frame_time,
}
}
None => {
let (texture, maybe_new_state) =
match animation.get_frame(prev_state.last_frame_index) {
Some(frame) => (frame.texture.clone(), None),
None => (animation.first_frame.texture.clone(), None),
};
ProcessedGifFrame {
texture,
maybe_new_state,
repaint_at: prev_state.next_frame_time,
}
}
}
} else {
let (texture, maybe_new_state) =
match animation.get_frame(prev_state.last_frame_index) {
Some(frame) => (frame.texture.clone(), None),
None => (animation.first_frame.texture.clone(), None),
};
ProcessedGifFrame {
texture,
maybe_new_state,
repaint_at: prev_state.next_frame_time,
}
}
}
None => ProcessedGifFrame {
texture: animation.first_frame.texture.clone(),
maybe_new_state: Some(GifState {
last_frame_rendered: now,
last_frame_duration: animation.first_frame.delay,
next_frame_time: None,
last_frame_index: 0,
}),
repaint_at: None,
},
}
Some(ensure_latest_texture(ui, url, gifs, img))
}
pub fn ensure_latest_texture(
@@ -125,7 +26,6 @@ pub fn ensure_latest_texture(
url: &str,
gifs: &mut GifStateMap,
img: &mut TexturedImage,
animation_mode: AnimationMode,
) -> TextureHandle {
match img {
TexturedImage::Static(handle) => handle.clone(),
@@ -145,20 +45,77 @@ pub fn ensure_latest_texture(
}
}
let next_state = process_gif_frame(animation, gifs.get(url), animation_mode);
let now = Instant::now();
let (texture, maybe_new_state, request_next_repaint) = match gifs.get(url) {
Some(prev_state) => {
let should_advance =
now - prev_state.last_frame_rendered >= prev_state.last_frame_duration;
if let Some(new_state) = next_state.maybe_new_state {
if should_advance {
let maybe_new_index = if animation.receiver.is_some()
|| prev_state.last_frame_index < animation.num_frames() - 1
{
prev_state.last_frame_index + 1
} else {
0
};
match animation.get_frame(maybe_new_index) {
Some(frame) => {
let next_frame_time = SystemTime::now().checked_add(frame.delay);
(
&frame.texture,
Some(GifState {
last_frame_rendered: now,
last_frame_duration: frame.delay,
next_frame_time,
last_frame_index: maybe_new_index,
}),
next_frame_time,
)
}
None => {
let (tex, state) =
match animation.get_frame(prev_state.last_frame_index) {
Some(frame) => (&frame.texture, None),
None => (&animation.first_frame.texture, None),
};
(tex, state, prev_state.next_frame_time)
}
}
} else {
let (tex, state) = match animation.get_frame(prev_state.last_frame_index) {
Some(frame) => (&frame.texture, None),
None => (&animation.first_frame.texture, None),
};
(tex, state, prev_state.next_frame_time)
}
}
None => (
&animation.first_frame.texture,
Some(GifState {
last_frame_rendered: now,
last_frame_duration: animation.first_frame.delay,
next_frame_time: None,
last_frame_index: 0,
}),
None,
),
};
if let Some(new_state) = maybe_new_state {
gifs.insert(url.to_owned(), new_state);
}
if let Some(repaint) = next_state.repaint_at {
tracing::trace!("requesting repaint for {url} after {repaint:?}");
if let Ok(dur) = repaint.duration_since(SystemTime::now()) {
ui.ctx().request_repaint_after(dur);
}
if let Some(req) = request_next_repaint {
tracing::trace!("requesting repaint for {url} after {req:?}");
// 24fps for gif is fine
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(41));
}
next_state.texture
texture.clone()
}
}
}
-18
View File
@@ -12,21 +12,3 @@ pub use blur::{
};
pub use images::ImageType;
pub use renderable::RenderableMedia;
#[derive(Copy, Clone, Debug)]
pub enum AnimationMode {
/// Only render when scrolling, network activity, etc
Reactive,
/// Continuous with an optional target fps
Continuous { fps: Option<f32> },
/// Disable animation
NoAnimation,
}
impl AnimationMode {
pub fn can_animate(&self) -> bool {
!matches!(self, Self::NoAnimation)
}
}
-4
View File
@@ -80,8 +80,4 @@ impl Muted {
false
}
pub fn is_pk_muted(&self, pk: &[u8; 32]) -> bool {
self.pubkeys.contains(pk)
}
}
-206
View File
@@ -1,206 +0,0 @@
use enostr::{Pubkey, RelayPool};
use indexmap::IndexMap;
use nostrdb::{Filter, Ndb, Note, Transaction};
use uuid::Uuid;
use crate::{UnifiedSubscription, UnknownIds};
/// Keeps track of most recent NIP-51 sets
#[derive(Debug)]
pub struct Nip51SetCache {
pub sub: UnifiedSubscription,
cached_notes: IndexMap<PackId, Nip51Set>,
}
type PackId = String;
impl Nip51SetCache {
pub fn new(
pool: &mut RelayPool,
ndb: &Ndb,
txn: &Transaction,
unknown_ids: &mut UnknownIds,
nip51_set_filter: Vec<Filter>,
) -> Option<Self> {
let subid = Uuid::new_v4().to_string();
let mut cached_notes = IndexMap::default();
let notes: Option<Vec<Note>> = if let Ok(results) = ndb.query(txn, &nip51_set_filter, 500) {
Some(results.into_iter().map(|r| r.note).collect())
} else {
None
};
if let Some(notes) = notes {
add(notes, &mut cached_notes, ndb, txn, unknown_ids);
}
let sub = match ndb.subscribe(&nip51_set_filter) {
Ok(sub) => sub,
Err(e) => {
tracing::error!("Could not ndb subscribe: {e}");
return None;
}
};
pool.subscribe(subid.clone(), nip51_set_filter);
Some(Self {
sub: UnifiedSubscription {
local: sub,
remote: subid,
},
cached_notes,
})
}
pub fn poll_for_notes(&mut self, ndb: &Ndb, unknown_ids: &mut UnknownIds) {
let new_notes = ndb.poll_for_notes(self.sub.local, 5);
if new_notes.is_empty() {
return;
}
let txn = Transaction::new(ndb).expect("txn");
let notes: Vec<Note> = new_notes
.into_iter()
.filter_map(|new_note_key| ndb.get_note_by_key(&txn, new_note_key).ok())
.collect();
add(notes, &mut self.cached_notes, ndb, &txn, unknown_ids);
}
pub fn iter(&self) -> impl IntoIterator<Item = &Nip51Set> {
self.cached_notes.values()
}
pub fn len(&self) -> usize {
self.cached_notes.len()
}
pub fn is_empty(&self) -> bool {
self.cached_notes.is_empty()
}
pub fn at_index(&self, index: usize) -> Option<&Nip51Set> {
self.cached_notes.get_index(index).map(|(_, s)| s)
}
}
fn add(
notes: Vec<Note>,
cache: &mut IndexMap<PackId, Nip51Set>,
ndb: &Ndb,
txn: &Transaction,
unknown_ids: &mut UnknownIds,
) {
for note in notes {
let Some(new_pack) = create_nip51_set(note) else {
continue;
};
if let Some(cur_cached) = cache.get(&new_pack.identifier) {
if new_pack.created_at <= cur_cached.created_at {
continue;
}
}
for pk in &new_pack.pks {
unknown_ids.add_pubkey_if_missing(ndb, txn, pk);
}
cache.insert(new_pack.identifier.clone(), new_pack);
}
}
pub fn create_nip51_set(note: Note) -> Option<Nip51Set> {
let mut identifier = None;
let mut title = None;
let mut image = None;
let mut description = None;
let mut pks = Vec::new();
for tag in note.tags() {
if tag.count() < 2 {
continue;
}
let Some(first) = tag.get_str(0) else {
continue;
};
match first {
"p" => {
let Some(pk) = tag.get_id(1) else {
continue;
};
pks.push(Pubkey::new(*pk));
}
"d" => {
let Some(id) = tag.get_str(1) else {
continue;
};
identifier = Some(id.to_owned());
}
"image" => {
let Some(cur_img) = tag.get_str(1) else {
continue;
};
image = Some(cur_img.to_owned());
}
"title" => {
let Some(cur_title) = tag.get_str(1) else {
continue;
};
title = Some(cur_title.to_owned());
}
"description" => {
let Some(cur_desc) = tag.get_str(1) else {
continue;
};
description = Some(cur_desc.to_owned());
}
_ => {
continue;
}
};
}
let identifier = identifier?;
Some(Nip51Set {
identifier,
title,
image,
description,
pks,
created_at: note.created_at(),
})
}
/// NIP-51 Set. Read only (do not use for writing)
pub struct Nip51Set {
pub identifier: String, // 'd' tag
pub title: Option<String>,
pub image: Option<String>,
pub description: Option<String>,
pub pks: Vec<Pubkey>,
created_at: u64,
}
impl std::fmt::Debug for Nip51Set {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Nip51Set")
.field("identifier", &self.identifier)
.field("title", &self.title)
.field("image", &self.image)
.field("description", &self.description)
.field("pks", &self.pks.len())
.field("created_at", &self.created_at)
.finish()
}
}
+1 -6
View File
@@ -24,11 +24,7 @@ pub enum NoteAction {
Profile(Pubkey),
/// User has clicked a note link
Note {
note_id: NoteId,
preview: bool,
scroll_offset: f32,
},
Note { note_id: NoteId, preview: bool },
/// User has selected some context option
Context(ContextSelection),
@@ -48,7 +44,6 @@ impl NoteAction {
NoteAction::Note {
note_id: id,
preview: false,
scroll_offset: 0.0,
}
}
}
+3 -3
View File
@@ -20,15 +20,15 @@ bitflags! {
/// Use keystore?
const UseKeystore = 1 << 4;
/// Show client on notes?
const ShowClient = 1 << 5;
/// Simulate is_compiled_as_mobile ?
const Mobile = 1 << 6;
// ===== Feature Flags ======
/// Is notebook enabled?
const FeatureNotebook = 1 << 32;
/// Is clndash enabled?
const FeatureClnDash = 1 << 33;
}
}
+2 -88
View File
@@ -1,14 +1,5 @@
use crate::platform::{file::emit_selected_file, SelectedMedia};
use jni::{
objects::{JByteArray, JClass, JObject, JObjectArray, JString},
JNIEnv,
};
use std::sync::atomic::{AtomicI32, Ordering};
use tracing::{debug, error, info};
pub fn get_jvm() -> jni::JavaVM {
unsafe { jni::JavaVM::from_raw(ndk_context::android_context().vm().cast()) }.unwrap()
}
use tracing::debug;
// Thread-safe static global
static KEYBOARD_HEIGHT: AtomicI32 = AtomicI32::new(0);
@@ -25,7 +16,7 @@ pub extern "C" fn Java_com_damus_notedeck_KeyboardHeightHelper_nativeKeyboardHei
debug!("updating virtual keyboard height {}", height);
// Convert and store atomically
KEYBOARD_HEIGHT.store(height.max(0), Ordering::SeqCst);
KEYBOARD_HEIGHT.store(height, Ordering::SeqCst);
}
/// Gets the current Android virtual keyboard height. Useful for transforming
@@ -33,80 +24,3 @@ pub extern "C" fn Java_com_damus_notedeck_KeyboardHeightHelper_nativeKeyboardHei
pub fn virtual_keyboard_height() -> i32 {
KEYBOARD_HEIGHT.load(Ordering::SeqCst)
}
#[no_mangle]
pub extern "C" fn Java_com_damus_notedeck_MainActivity_nativeOnFilePickedFailed(
mut env: JNIEnv,
_class: JClass,
juri: JString,
je: JString,
) {
let _uri: String = env.get_string(&juri).unwrap().into();
let _error: String = env.get_string(&je).unwrap().into();
}
#[no_mangle]
pub extern "C" fn Java_com_damus_notedeck_MainActivity_nativeOnFilePickedWithContent(
mut env: JNIEnv,
_class: JClass,
// [display_name, size, mime_type]
juri_info: JObjectArray,
jcontent: JByteArray,
) {
debug!("File picked with content");
let display_name: Option<String> = {
let obj = env.get_object_array_element(&juri_info, 0).unwrap();
if obj.is_null() {
None
} else {
Some(env.get_string(&JString::from(obj)).unwrap().into())
}
};
if let Some(display_name) = display_name {
let length = env.get_array_length(&jcontent).unwrap() as usize;
let mut content: Vec<i8> = vec![0; length];
env.get_byte_array_region(&jcontent, 0, &mut content)
.unwrap();
debug!("selected file: {display_name:?} ({length:?} bytes)",);
emit_selected_file(SelectedMedia::from_bytes(
display_name,
content.into_iter().map(|b| b as u8).collect(),
));
} else {
error!("Received null file name");
}
}
pub fn try_open_file_picker() {
match open_file_picker() {
Ok(()) => {
info!("File picker opened successfully");
}
Err(e) => {
error!("Failed to open file picker: {}", e);
}
}
}
pub fn open_file_picker() -> std::result::Result<(), Box<dyn std::error::Error>> {
// Get the Java VM from AndroidApp
let vm = get_jvm();
// Attach current thread to get JNI environment
let mut env = vm.attach_current_thread()?;
let context = unsafe { JObject::from_raw(ndk_context::android_context().context().cast()) };
// Call the openFilePicker method on the MainActivity
env.call_method(
context,
"openFilePicker",
"()V", // Method signature: no parameters, void return
&[], // No arguments
)?;
Ok(())
}
-99
View File
@@ -1,99 +0,0 @@
use std::{path::PathBuf, str::FromStr};
use crossbeam_channel::{unbounded, Receiver, Sender};
use once_cell::sync::Lazy;
use crate::{Error, SupportedMimeType};
#[derive(Debug)]
pub enum MediaFrom {
PathBuf(PathBuf),
Memory(Vec<u8>),
}
#[derive(Debug)]
pub struct SelectedMedia {
pub from: MediaFrom,
pub file_name: String,
pub media_type: SupportedMimeType,
}
impl SelectedMedia {
pub fn from_path(path: PathBuf) -> Result<Self, Error> {
if let Some(ex) = path.extension().and_then(|f| f.to_str()) {
let media_type = SupportedMimeType::from_extension(ex)?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&format!("file.{ex}"))
.to_owned();
Ok(SelectedMedia {
from: MediaFrom::PathBuf(path),
file_name,
media_type,
})
} else {
Err(Error::Generic(format!(
"{path:?} does not have an extension"
)))
}
}
pub fn from_bytes(file_name: String, content: Vec<u8>) -> Result<Self, Error> {
if let Some(ex) = PathBuf::from_str(&file_name)
.unwrap()
.extension()
.and_then(|f| f.to_str())
{
let media_type = SupportedMimeType::from_extension(ex)?;
Ok(SelectedMedia {
from: MediaFrom::Memory(content),
file_name,
media_type,
})
} else {
Err(Error::Generic(format!(
"{file_name:?} does not have an extension"
)))
}
}
}
pub struct SelectedMediaChannel {
sender: Sender<Result<SelectedMedia, Error>>,
receiver: Receiver<Result<SelectedMedia, Error>>,
}
impl Default for SelectedMediaChannel {
fn default() -> Self {
let (sender, receiver) = unbounded();
Self { sender, receiver }
}
}
impl SelectedMediaChannel {
pub fn new_selected_file(&self, media: Result<SelectedMedia, Error>) {
let _ = self.sender.send(media);
}
pub fn try_receive(&self) -> Option<Result<SelectedMedia, Error>> {
self.receiver.try_recv().ok()
}
pub fn receive(&self) -> Option<Result<SelectedMedia, Error>> {
self.receiver.recv().ok()
}
}
pub static SELECTED_MEDIA_CHANNEL: Lazy<SelectedMediaChannel> =
Lazy::new(SelectedMediaChannel::default);
pub fn emit_selected_file(media: Result<SelectedMedia, Error>) {
SELECTED_MEDIA_CHANNEL.new_selected_file(media);
}
pub fn get_next_selected_file() -> Option<Result<SelectedMedia, Error>> {
SELECTED_MEDIA_CHANNEL.try_receive()
}
+4 -31
View File
@@ -1,39 +1,12 @@
use crate::{platform::file::SelectedMedia, Error};
#[cfg(target_os = "android")]
pub mod android;
pub mod file;
pub fn get_next_selected_file() -> Option<Result<SelectedMedia, Error>> {
file::get_next_selected_file()
}
const VIRT_HEIGHT: i32 = 400;
#[cfg(target_os = "android")]
pub fn virtual_keyboard_height(virt: bool) -> i32 {
if virt {
VIRT_HEIGHT
} else {
android::virtual_keyboard_height()
}
pub fn virtual_keyboard_height() -> i32 {
android::virtual_keyboard_height()
}
#[cfg(not(target_os = "android"))]
pub fn virtual_keyboard_height(virt: bool) -> i32 {
if virt {
VIRT_HEIGHT
} else {
0
}
}
pub fn virtual_keyboard_rect(ui: &egui::Ui, virt: bool) -> Option<egui::Rect> {
let height = virtual_keyboard_height(virt);
if height <= 0 {
return None;
}
let screen_rect = ui.ctx().screen_rect();
let min = egui::Pos2::new(0.0, screen_rect.max.y - height as f32);
Some(egui::Rect::from_min_max(min, screen_rect.max))
pub fn virtual_keyboard_height() -> i32 {
0
}
-1
View File
@@ -77,7 +77,6 @@ impl PartialEq for RelaySpec {
impl Eq for RelaySpec {}
#[allow(clippy::non_canonical_partial_ord_impl)]
impl PartialOrd for RelaySpec {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.url.cmp(&other.url))
+2 -1
View File
@@ -13,7 +13,8 @@ pub fn setup_egui_context(
zoom_factor: f32,
) {
let is_mobile = options.contains(NotedeckOptions::Mobile) || crate::ui::is_compiled_as_mobile();
let is_oled = crate::ui::is_oled(is_mobile);
let is_oled = crate::ui::is_oled();
ctx.options_mut(|o| {
tracing::info!("Loaded theme {:?} from disk", theme);
-9
View File
@@ -1,5 +1,4 @@
use crate::{tr, Localization};
use chrono::DateTime;
use std::time::{SystemTime, UNIX_EPOCH};
// Time duration constants in seconds
@@ -84,14 +83,6 @@ fn time_ago_between(i18n: &mut Localization, timestamp: u64, now: u64) -> String
}
}
pub fn time_format(_i18n: &mut Localization, timestamp: u64) -> String {
// TODO: format this using the selected locale
DateTime::from_timestamp(timestamp as i64, 0)
.unwrap()
.format("%l:%M %p %b %d, %Y")
.to_string()
}
pub fn time_ago_since(i18n: &mut Localization, timestamp: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
+2 -2
View File
@@ -16,8 +16,8 @@ pub fn is_narrow(ctx: &egui::Context) -> bool {
screen_size.x < NARROW_SCREEN_WIDTH
}
pub fn is_oled(is_mobile_override: bool) -> bool {
is_mobile_override || is_compiled_as_mobile()
pub fn is_oled() -> bool {
is_compiled_as_mobile()
}
#[inline]
+2 -2
View File
@@ -195,13 +195,13 @@ impl UnknownIds {
}
}
pub fn add_pubkey_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, pubkey: &[u8; 32]) {
pub fn add_pubkey_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, pubkey: &Pubkey) {
// we already have this profile, skip
if ndb.get_profile_by_pubkey(txn, pubkey).is_ok() {
return;
}
let unknown_id = UnknownId::Pubkey(Pubkey::new(*pubkey));
let unknown_id = UnknownId::Pubkey(*pubkey);
if self.ids.contains_key(&unknown_id) {
return;
}
+1 -3
View File
@@ -238,9 +238,7 @@ impl SupportedMimeType {
{
Ok(Self { mime })
} else {
Err(Error::Generic(
format!("{extension} Unsupported mime type",),
))
Err(Error::Generic("Unsupported mime type".to_owned()))
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ impl UserAccount {
}
}
pub fn keypair(&self) -> KeypairUnowned<'_> {
pub fn keypair(&self) -> KeypairUnowned {
KeypairUnowned {
pubkey: &self.key.pubkey,
secret_key: self.key.secret_key.as_ref(),
-2
View File
@@ -9,7 +9,6 @@ license = "GPLv3"
description = "The nostr browser"
[dependencies]
bitflags = { workspace = true }
eframe = { workspace = true }
egui_tabs = { workspace = true }
egui_extras = { workspace = true }
@@ -18,7 +17,6 @@ notedeck_columns = { workspace = true }
notedeck_ui = { workspace = true }
notedeck_dave = { workspace = true }
notedeck_notebook = { workspace = true }
notedeck_clndash = { workspace = true }
notedeck = { workspace = true }
nostrdb = { workspace = true }
puffin = { workspace = true, optional = true }
@@ -8,9 +8,8 @@
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|fontScale|smallestScreenSize"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
>
<intent-filter>
@@ -38,4 +37,4 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
</manifest>
@@ -0,0 +1,48 @@
package com.damus.notedeck;
import android.app.Activity;
import android.content.res.Configuration;
import android.util.Log;
import android.view.View;
public class KeyboardHeightHelper {
private static final String TAG = "KeyboardHeightHelper";
private KeyboardHeightProvider keyboardHeightProvider;
private Activity activity;
// Static JNI method not tied to any specific activity
private static native void nativeKeyboardHeightChanged(int height);
public KeyboardHeightHelper(Activity activity) {
this.activity = activity;
keyboardHeightProvider = new KeyboardHeightProvider(activity);
// Create observer implementation
KeyboardHeightObserver observer = (height, orientation) -> {
Log.d(TAG, "Keyboard height: " + height + "px, orientation: " +
(orientation == Configuration.ORIENTATION_PORTRAIT ? "portrait" : "landscape"));
// Call the generic native method
nativeKeyboardHeightChanged(height);
};
// Set up the provider
keyboardHeightProvider.setKeyboardHeightObserver(observer);
}
public void start() {
// Start the keyboard height provider after the view is ready
final View contentView = activity.findViewById(android.R.id.content);
contentView.post(() -> {
keyboardHeightProvider.start();
});
}
public void stop() {
keyboardHeightProvider.setKeyboardHeightObserver(null);
}
public void close() {
keyboardHeightProvider.close();
}
}
@@ -0,0 +1,35 @@
/*
* This file is part of Siebe Projects samples.
*
* Siebe Projects samples is free software: you can redistribute it and/or modify
* it under the terms of the Lesser GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Siebe Projects samples is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Lesser GNU General Public License for more details.
*
* You should have received a copy of the Lesser GNU General Public License
* along with Siebe Projects samples. If not, see <http://www.gnu.org/licenses/>.
*/
package com.damus.notedeck;
/**
* The observer that will be notified when the height of
* the keyboard has changed
*/
public interface KeyboardHeightObserver {
/**
* Called when the keyboard height has changed, 0 means keyboard is closed,
* >= 1 means keyboard is opened.
*
* @param height The height of the keyboard in pixels
* @param orientation The orientation either: Configuration.ORIENTATION_PORTRAIT or
* Configuration.ORIENTATION_LANDSCAPE
*/
void onKeyboardHeightChanged(int height, int orientation);
}
@@ -0,0 +1,174 @@
/*
* This file is part of Siebe Projects samples.
*
* Siebe Projects samples is free software: you can redistribute it and/or modify
* it under the terms of the Lesser GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Siebe Projects samples is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Lesser GNU General Public License for more details.
*
* You should have received a copy of the Lesser GNU General Public License
* along with Siebe Projects samples. If not, see <http://www.gnu.org/licenses/>.
*/
package com.damus.notedeck;
import android.app.Activity;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.util.Log;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.view.WindowManager.LayoutParams;
import android.widget.PopupWindow;
/**
* The keyboard height provider, this class uses a PopupWindow
* to calculate the window height when the floating keyboard is opened and closed.
*/
public class KeyboardHeightProvider extends PopupWindow {
/** The tag for logging purposes */
private final static String TAG = "sample_KeyboardHeightProvider";
/** The keyboard height observer */
private KeyboardHeightObserver observer;
/** The cached landscape height of the keyboard */
private int keyboardLandscapeHeight;
/** The cached portrait height of the keyboard */
private int keyboardPortraitHeight;
/** The view that is used to calculate the keyboard height */
private View popupView;
/** The parent view */
private View parentView;
/** The root activity that uses this KeyboardHeightProvider */
private Activity activity;
/**
* Construct a new KeyboardHeightProvider
*
* @param activity The parent activity
*/
public KeyboardHeightProvider(Activity activity) {
super(activity);
this.activity = activity;
//LayoutInflater inflator = (LayoutInflater) activity.getSystemService(Activity.LAYOUT_INFLATER_SERVICE);
//this.popupView = inflator.inflate(android.R.layout.popupwindow, null, false);
this.popupView = new View(activity);
setContentView(popupView);
setSoftInputMode(LayoutParams.SOFT_INPUT_ADJUST_RESIZE | LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED);
parentView = activity.findViewById(android.R.id.content);
setWidth(0);
setHeight(LayoutParams.MATCH_PARENT);
popupView.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
if (popupView != null) {
handleOnGlobalLayout();
}
}
});
}
/**
* Start the KeyboardHeightProvider, this must be called after the onResume of the Activity.
* PopupWindows are not allowed to be registered before the onResume has finished
* of the Activity.
*/
public void start() {
if (!isShowing() && parentView.getWindowToken() != null) {
setBackgroundDrawable(new ColorDrawable(0));
showAtLocation(parentView, Gravity.NO_GRAVITY, 0, 0);
}
}
/**
* Close the keyboard height provider,
* this provider will not be used anymore.
*/
public void close() {
this.observer = null;
dismiss();
}
/**
* Set the keyboard height observer to this provider. The
* observer will be notified when the keyboard height has changed.
* For example when the keyboard is opened or closed.
*
* @param observer The observer to be added to this provider.
*/
public void setKeyboardHeightObserver(KeyboardHeightObserver observer) {
this.observer = observer;
}
/**
* Popup window itself is as big as the window of the Activity.
* The keyboard can then be calculated by extracting the popup view bottom
* from the activity window height.
*/
private void handleOnGlobalLayout() {
Point screenSize = new Point();
activity.getWindowManager().getDefaultDisplay().getSize(screenSize);
Rect rect = new Rect();
popupView.getWindowVisibleDisplayFrame(rect);
// REMIND, you may like to change this using the fullscreen size of the phone
// and also using the status bar and navigation bar heights of the phone to calculate
// the keyboard height. But this worked fine on a Nexus.
int orientation = getScreenOrientation();
int keyboardHeight = screenSize.y - rect.bottom;
if (keyboardHeight == 0) {
notifyKeyboardHeightChanged(0, orientation);
}
else if (orientation == Configuration.ORIENTATION_PORTRAIT) {
this.keyboardPortraitHeight = keyboardHeight;
notifyKeyboardHeightChanged(keyboardPortraitHeight, orientation);
}
else {
this.keyboardLandscapeHeight = keyboardHeight;
notifyKeyboardHeightChanged(keyboardLandscapeHeight, orientation);
}
}
private int getScreenOrientation() {
return activity.getResources().getConfiguration().orientation;
}
private void notifyKeyboardHeightChanged(int height, int orientation) {
if (observer != null) {
observer.onKeyboardHeightChanged(height, orientation);
}
}
}
@@ -1,18 +1,13 @@
package com.damus.notedeck;
import android.content.ClipData;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import androidx.core.graphics.Insets;
import androidx.core.view.DisplayCutoutCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
@@ -20,38 +15,52 @@ import androidx.core.view.WindowInsetsControllerCompat;
import com.google.androidgamesdk.GameActivity;
import java.io.ByteArrayOutputStream;
import java.io.FileDescriptor;
import java.io.IOException;
import java.io.InputStream;
public class MainActivity extends GameActivity {
static final int REQUEST_CODE_PICK_FILE = 420;
static {
System.loadLibrary("notedeck_chrome");
}
private native void nativeOnFilePickedFailed(String uri, String e);
private native void nativeOnFilePickedWithContent(Object[] uri_info, byte[] content);
private native void nativeOnKeyboardHeightChanged(int height);
private KeyboardHeightHelper keyboardHelper;
@Override
protected void onCreate(Bundle savedInstanceState) {
// Shrink view so it does not get covered by insets.
public void openFilePicker() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
intent.addCategory(Intent.CATEGORY_OPENABLE);
startActivityForResult(intent, REQUEST_CODE_PICK_FILE);
setupInsets();
//setupFullscreen()
keyboardHelper = new KeyboardHeightHelper(this);
super.onCreate(savedInstanceState);
}
private void setupFullscreen() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
if (controller != null) {
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
controller.hide(WindowInsetsCompat.Type.systemBars());
}
//focus(getContent())
}
// not sure if this does anything
private void focus(View content) {
content.setFocusable(true);
content.setFocusableInTouchMode(true);
content.requestFocus();
}
private View getContent() {
return getWindow().getDecorView().findViewById(android.R.id.content);
}
private void setupInsets() {
// NOTE(jb55): This is needed for keyboard visibility. Without this the
// window still gets the right insets, but theyre consumed before they
// reach the NDK side.
//WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
// NOTE(jb55): This is needed for keyboard visibility. If the bars are
// permanently gone, Android routes the keyboard over the GL surface and
// doesnt change insets.
//WindowInsetsControllerCompat ic = WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
//ic.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
View content = getContent();
ViewCompat.setOnApplyWindowInsetsListener(content, (v, windowInsets) -> {
Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
@@ -63,176 +72,38 @@ public class MainActivity extends GameActivity {
mlp.rightMargin = insets.right;
v.setLayoutParams(mlp);
return windowInsets;
return WindowInsetsCompat.CONSUMED;
});
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowCompat.setDecorFitsSystemWindows(getWindow(), true);
}
@Override
public void onResume() {
super.onResume();
keyboardHelper.start();
}
@Override
public void onPause() {
super.onPause();
keyboardHelper.stop();
}
@Override
public void onDestroy() {
super.onDestroy();
keyboardHelper.close();
}
private void processSelectedFile(Uri uri) {
try {
nativeOnFilePickedWithContent(this.getUriInfo(uri), readUriContent(uri));
} catch (Exception e) {
Log.e("MainActivity", "Error processing file: " + uri.toString(), e);
@Override
public boolean onTouchEvent(MotionEvent event) {
// Offset the location so it fits the view with margins caused by insets.
nativeOnFilePickedFailed(uri.toString(), e.toString());
}
int[] location = new int[2];
findViewById(android.R.id.content).getLocationOnScreen(location);
event.offsetLocation(-location[0], -location[1]);
return super.onTouchEvent(event);
}
private Object[] getUriInfo(Uri uri) throws Exception {
if (!uri.getScheme().equals("content")) {
throw new Exception("uri should start with content://");
}
Cursor cursor = getContentResolver().query(uri, null, null, null, null);
while (cursor.moveToNext()) {
Object[] info = new Object[3];
int col_idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
info[0] = cursor.getString(col_idx);
col_idx = cursor.getColumnIndex(OpenableColumns.SIZE);
info[1] = cursor.getLong(col_idx);
col_idx = cursor.getColumnIndex("mime_type");
info[2] = cursor.getString(col_idx);
return info;
}
return null;
}
private byte[] readUriContent(Uri uri) {
InputStream inputStream = null;
ByteArrayOutputStream buffer = null;
try {
inputStream = getContentResolver().openInputStream(uri);
if (inputStream == null) {
Log.e("MainActivity", "Could not open input stream for URI: " + uri);
return null;
}
buffer = new ByteArrayOutputStream();
byte[] data = new byte[8192]; // 8KB buffer
int bytesRead;
while ((bytesRead = inputStream.read(data)) != -1) {
buffer.write(data, 0, bytesRead);
}
byte[] result = buffer.toByteArray();
Log.d("MainActivity", "Successfully read " + result.length + " bytes");
return result;
} catch (IOException e) {
Log.e("MainActivity", "IOException while reading URI: " + uri, e);
return null;
} catch (SecurityException e) {
Log.e("MainActivity", "SecurityException while reading URI: " + uri, e);
return null;
} finally {
// Close streams
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
Log.e("MainActivity", "Error closing input stream", e);
}
}
if (buffer != null) {
try {
buffer.close();
} catch (IOException e) {
Log.e("MainActivity", "Error closing buffer", e);
}
}
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
// Shrink view so it does not get covered by insets.
setupInsets();
//setupFullscreen()
super.onCreate(savedInstanceState);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_PICK_FILE && resultCode == RESULT_OK) {
if (data == null) return;
if (data.getClipData() != null) {
// Multiple files selected
ClipData clipData = data.getClipData();
for (int i = 0; i < clipData.getItemCount(); i++) {
Uri uri = clipData.getItemAt(i).getUri();
processSelectedFile(uri);
}
} else if (data.getData() != null) {
// Single file selected
Uri uri = data.getData();
processSelectedFile(uri);
}
}
}
private void setupFullscreen() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
if (controller != null) {
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
controller.hide(WindowInsetsCompat.Type.systemBars());
}
//focus(getContent())
}
// not sure if this does anything
private void focus(View content) {
content.setFocusable(true);
content.setFocusableInTouchMode(true);
content.requestFocus();
}
private View getContent() {
return getWindow().getDecorView().findViewById(android.R.id.content);
}
@Override
public void onResume() {
super.onResume();
}
@Override
public void onPause() {
super.onPause();
}
@Override
public void onDestroy() {
super.onDestroy();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// Offset the location so it fits the view with margins caused by insets.
int[] location = new int[2];
findViewById(android.R.id.content).getLocationOnScreen(location);
event.offsetLocation(-location[0], -location[1]);
return super.onTouchEvent(event);
}
}
+7 -8
View File
@@ -8,15 +8,16 @@ use notedeck::Notedeck;
#[no_mangle]
#[tokio::main]
pub async fn android_main(android_app: AndroidApp) {
pub async fn android_main(app: AndroidApp) {
//use tracing_logcat::{LogcatMakeWriter, LogcatTag};
use tracing_subscriber::{prelude::*, EnvFilter};
std::env::set_var("RUST_BACKTRACE", "full");
//std::env::set_var("DAVE_ENDPOINT", "http://ollama.jb55.com/v1");
//std::env::set_var("DAVE_MODEL", "hhao/qwen2.5-coder-tools:latest");
std::env::set_var(
"RUST_LOG",
"egui=debug,egui-winit=debug,winit=debug,notedeck=debug,notedeck_columns=debug,notedeck_chrome=debug,enostr=debug,android_activity=debug",
"egui=debug,egui-winit=debug,notedeck=debug,notedeck_columns=debug,notedeck_chrome=debug,enostr=debug,android_activity=debug",
);
//std::env::set_var(
@@ -41,7 +42,7 @@ pub async fn android_main(android_app: AndroidApp) {
.with(fmt_layer)
.init();
let path = android_app.internal_data_path().expect("data path");
let path = app.internal_data_path().expect("data path");
let mut options = eframe::NativeOptions {
depth_buffer: 24,
..eframe::NativeOptions::default()
@@ -54,18 +55,16 @@ pub async fn android_main(android_app: AndroidApp) {
// builder.with_android_app(app_clone_for_event_loop);
//}));
options.android_app = Some(android_app.clone());
options.android_app = Some(app.clone());
let app_args = get_app_args();
let app_args = get_app_args(app);
let _res = eframe::run_native(
"Damus Notedeck",
options,
Box::new(move |cc| {
let ctx = &cc.egui_ctx;
let mut notedeck = Notedeck::new(ctx, path, &app_args);
notedeck.set_android_context(android_app);
notedeck.setup(ctx);
let chrome = Chrome::new_with_apps(cc, &app_args, &mut notedeck)?;
notedeck.set_app(chrome);
@@ -104,7 +103,7 @@ Using internal storage would be better but it seems hard to get the config file
the device ...
*/
fn get_app_args() -> Vec<String> {
fn get_app_args(_app: AndroidApp) -> Vec<String> {
vec!["argv0-placeholder".to_string()]
/*
use serde_json::value;
-3
View File
@@ -1,5 +1,4 @@
use notedeck::{AppAction, AppContext};
use notedeck_clndash::ClnDash;
use notedeck_columns::Damus;
use notedeck_dave::Dave;
use notedeck_notebook::Notebook;
@@ -9,7 +8,6 @@ pub enum NotedeckApp {
Dave(Box<Dave>),
Columns(Box<Damus>),
Notebook(Box<Notebook>),
ClnDash(Box<ClnDash>),
Other(Box<dyn notedeck::App>),
}
@@ -19,7 +17,6 @@ impl notedeck::App for NotedeckApp {
NotedeckApp::Dave(dave) => dave.update(ctx, ui),
NotedeckApp::Columns(columns) => columns.update(ctx, ui),
NotedeckApp::Notebook(notebook) => notebook.update(ctx, ui),
NotedeckApp::ClnDash(clndash) => clndash.update(ctx, ui),
NotedeckApp::Other(other) => other.update(ctx, ui),
}
}
File diff suppressed because it is too large Load Diff
-2
View File
@@ -5,8 +5,6 @@ mod android;
mod app;
mod chrome;
mod options;
pub use app::NotedeckApp;
pub use chrome::Chrome;
pub use options::ChromeOptions;
+1
View File
@@ -183,6 +183,7 @@ mod tests {
let ctx = egui::Context::default();
let mut notedeck = Notedeck::new(&ctx, &tmpdir, &args);
let unrecognized_args = notedeck.unrecognized_args().clone();
let mut app_ctx = notedeck.app_context();
let app = Damus::new(&mut app_ctx, &args);
-38
View File
@@ -1,38 +0,0 @@
use bitflags::bitflags;
bitflags! {
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ChromeOptions: u64 {
/// Is the chrome currently open?
const NoOptions = 0;
/// Is the chrome currently open?
const IsOpen = 1 << 0;
/// Are we simulating a virtual keyboard? This is mostly for debugging
/// if we are too lazy to open up a real mobile device with soft
/// keyboard
const VirtualKeyboard = 1 << 1;
/// Are we showing the memory debug window?
const MemoryDebug = 1 << 2;
/// Repaint debug
const RepaintDebug = 1 << 3;
/// We need soft keyboard visibility
const KeyboardVisibility = 1 << 4;
}
}
impl Default for ChromeOptions {
fn default() -> Self {
let mut options = ChromeOptions::NoOptions;
options.set(
ChromeOptions::IsOpen,
!notedeck::ui::is_compiled_as_mobile(),
);
options
}
}
-21
View File
@@ -1,21 +0,0 @@
[package]
name = "notedeck_clndash"
edition = "2024"
version.workspace = true
[dependencies]
egui = { workspace = true }
notedeck = { workspace = true }
#notedeck_ui = { workspace = true }
eframe = { workspace = true }
tracing = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
egui_extras = { workspace = true }
lightning-invoice = { workspace = true }
hex = { workspace = true }
nostrdb = { workspace = true }
notedeck_ui = { workspace = true }
lnsocket = "0.5.1"
-77
View File
@@ -1,77 +0,0 @@
# ⚡ clndash
Your Core Lightning dashboard, **without the server nonsense**.
clndash is a weird little experiment: a [notedeck][notedeck] app that talks to your node **directly over the Lightning Network** using [lnsocket][lnsocket] + [Commando][commando] RPCs.
No HTTP. No nginx. No VPS.
Just open clndash, point it at your node, and boom — youre in.
<img src="https://jb55.com/s/476285c50d06c3ce.png" width="50%" />
---
## 🤯 Why?
Because sometimes you just want to *see your channels* and *check invoices* without SSH-ing into a box and typing `lightning-cli`.
And because LN is already a secure, encrypted connection layer — why not just use that?
---
## 🔥 Features (as of today)
* **Plug-and-play LN connection** powered by [lnsocket][lnsocket]
* **Commando RPC** all dashboard data is fetched directly from your CLN node over Lightning
* **Channel overview** total capacity, inbound/outbound liquidity, largest channel, and pretty bars
* **Invoices** shows recent paid invoices (with zap previews if they came from Nostr)
* **No extra daemons** you dont need to run a server to use it
---
## 🪄 Nostr Bonus
Because its a notedeck app, clndash can **render zaps** inline.
Yes, your Core Lightning dashboard can now show you when someone on Nostr just sent you sats and why.
---
## 🏗 Still Baking
This is WIP.
Youll probably hit bugs. UI might be janky. Some features may vanish or suddenly mutate.
If youre reading this and still excited — youre the exact audience.
---
## 🛠 How to connect
1. Get your nodes **public address** (host\:port) and a **Commando rune** with safe permissions.
2. Set them as environment variables:
```bash
export CLNDASH_ID="03f3c108ccd536b8526841f0a5c58212bb9e6584a1eb493080e7c1cc34f82dad71"
export CLNDASH_HOST="node.example.com:9735"
export CLNDASH_RUNE="your_rune_here"
```
3. Run clndash inside notedeck by calling notedeck with the `--clndash` argument.
4. Bask in the glow of real-time LN data over an LN connection.
---
## ⚠️ Disclaimer
* Dont give it a rune that can spend your funds.
* Dont blame me if you break something — this is experimental territory.
* If it connects on the first try, buy yourself a beer.
---
If you like living on the edge of LN/Nostr tooling, youll like this.
If you dont… youll probably want to wait a bit.
[commando]: https://docs.corelightning.org/reference/commando
[lnsocket]: https://github.com/jb55/lnsocket-rs
[notedeck]: https://github.com/damus-io/notedeck
-124
View File
@@ -1,124 +0,0 @@
use crate::event::LoadingState;
use crate::ui;
use egui::Color32;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
pub struct ListPeerChannel {
pub short_channel_id: String,
pub our_reserve_msat: i64,
pub to_us_msat: i64,
pub total_msat: i64,
pub their_reserve_msat: i64,
}
pub struct Channel {
pub to_us: i64,
pub to_them: i64,
pub original: ListPeerChannel,
}
pub struct Channels {
pub max_total_msat: i64,
pub avail_in: i64,
pub avail_out: i64,
pub channels: Vec<Channel>,
}
pub fn channels_ui(ui: &mut egui::Ui, channels: &LoadingState<Channels, lnsocket::Error>) {
match channels {
LoadingState::Loaded(channels) => {
if channels.channels.is_empty() {
ui.label("no channels yet...");
return;
}
for channel in &channels.channels {
channel_ui(ui, channel, channels.max_total_msat);
}
ui.label(format!(
"available out {}",
ui::human_sat(channels.avail_out)
));
ui.label(format!("available in {}", ui::human_sat(channels.avail_in)));
}
LoadingState::Failed(err) => {
ui.label(format!("error fetching channels: {err}"));
}
LoadingState::Loading => {
ui.label("fetching channels...");
}
}
}
pub fn channel_ui(ui: &mut egui::Ui, c: &Channel, max_total_msat: i64) {
// ---------- numbers ----------
let short_channel_id = &c.original.short_channel_id;
let cap_ratio = (c.original.total_msat as f32 / max_total_msat.max(1) as f32).clamp(0.0, 1.0);
// Feel free to switch to log scaling if you have whales:
//let cap_ratio = ((c.original.total_msat as f32 + 1.0).log10() / (max_total_msat as f32 + 1.0).log10()).clamp(0.0, 1.0);
// ---------- colors & style ----------
let out_color = Color32::from_rgb(84, 69, 201); // blue
let in_color = Color32::from_rgb(158, 56, 180); // purple
// Thickness scales with capacity, but keeps a nice minimum
let thickness = 10.0 + cap_ratio * 22.0; // 10 → 32 px
let row_h = thickness + 14.0;
// ---------- layout ----------
let (rect, response) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), row_h),
egui::Sense::hover(),
);
let painter = ui.painter_at(rect);
let bar_rect = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.center().y - thickness * 0.5),
egui::pos2(rect.right(), rect.center().y + thickness * 0.5),
);
let corner_radius = (thickness * 0.5) as u8;
let out_radius = egui::CornerRadius {
ne: 0,
nw: corner_radius,
sw: corner_radius,
se: 0,
};
let in_radius = egui::CornerRadius {
ne: corner_radius,
nw: 0,
sw: 0,
se: corner_radius,
};
/*
painter.rect_filled(bar_rect, rounding, track_color);
painter.rect_stroke(bar_rect, rounding, track_stroke, egui::StrokeKind::Middle);
*/
// Split widths
let usable = (c.to_us + c.to_them).max(1) as f32;
let out_w = (bar_rect.width() * (c.to_us as f32 / usable)).round();
let split_x = bar_rect.left() + out_w;
// Outbound fill (left)
let out_rect = egui::Rect::from_min_max(bar_rect.min, egui::pos2(split_x, bar_rect.max.y));
if out_rect.width() > 0.5 {
painter.rect_filled(out_rect, out_radius, out_color);
}
// Inbound fill (right)
let in_rect = egui::Rect::from_min_max(egui::pos2(split_x, bar_rect.min.y), bar_rect.max);
if in_rect.width() > 0.5 {
painter.rect_filled(in_rect, in_radius, in_color);
}
// Tooltip
response.on_hover_text_at_pointer(format!(
"Channel ID {short_channel_id}\nOutbound (ours): {} sats\nInbound (theirs): {} sats\nCapacity: {} sats",
ui::human_sat(c.to_us),
ui::human_sat(c.to_them),
ui::human_sat(c.original.total_msat),
));
}
-80
View File
@@ -1,80 +0,0 @@
use crate::channels::Channels;
use crate::invoice::Invoice;
use serde::Serialize;
use serde_json::Value;
pub enum ConnectionState {
Dead(String),
Connecting,
Active,
}
pub enum LoadingState<T, E> {
Loading,
Failed(E),
Loaded(T),
}
impl<T, E> Default for LoadingState<T, E> {
fn default() -> Self {
Self::Loading
}
}
impl<T, E> LoadingState<T, E> {
fn _as_ref(&self) -> LoadingState<&T, &E> {
match self {
Self::Loading => LoadingState::<&T, &E>::Loading,
Self::Failed(err) => LoadingState::<&T, &E>::Failed(err),
Self::Loaded(t) => LoadingState::<&T, &E>::Loaded(t),
}
}
pub fn from_result(res: Result<T, E>) -> LoadingState<T, E> {
match res {
Ok(r) => LoadingState::Loaded(r),
Err(err) => LoadingState::Failed(err),
}
}
/*
fn unwrap(self) -> T {
let Self::Loaded(t) = self else {
panic!("unwrap in LoadingState");
};
t
}
*/
}
#[derive(Serialize, Debug, Clone)]
pub struct _WaitRequest {
pub indexname: String,
pub subsystem: String,
pub nextvalue: u64,
}
#[derive(Clone, Debug)]
pub enum Request {
GetInfo,
ListPeerChannels,
PaidInvoices(u32),
}
/// Responses from the socket
pub enum ClnResponse {
GetInfo(Value),
ListPeerChannels(Result<Channels, lnsocket::Error>),
PaidInvoices(Result<Vec<Invoice>, lnsocket::Error>),
}
pub enum Event {
/// We lost the socket somehow
Ended {
reason: String,
},
Connected,
Response(ClnResponse),
}
-77
View File
@@ -1,77 +0,0 @@
use crate::event::LoadingState;
use crate::ui;
use lightning_invoice::Bolt11Invoice;
use notedeck::AppContext;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Deserialize, Serialize)]
pub struct Invoice {
pub lastpay_index: Option<u64>,
pub label: String,
pub bolt11: Bolt11Invoice,
pub payment_hash: String,
pub amount_msat: u64,
pub status: String,
pub description: String,
pub expires_at: u64,
pub created_index: u64,
pub updated_index: u64,
}
pub fn invoices_ui(
ui: &mut egui::Ui,
invoice_notes: &HashMap<String, [u8; 32]>,
ctx: &mut AppContext,
invoices: &LoadingState<Vec<Invoice>, lnsocket::Error>,
) {
match invoices {
LoadingState::Loading => {
ui.label("loading invoices...");
}
LoadingState::Failed(err) => {
ui.label(format!("failed to load invoices: {err}"));
}
LoadingState::Loaded(invoices) => {
use egui_extras::{Column, TableBuilder};
TableBuilder::new(ui)
.column(Column::auto().resizable(true))
.column(Column::remainder())
.vscroll(false)
.header(20.0, |mut header| {
header.col(|ui| {
ui.strong("description");
});
header.col(|ui| {
ui.strong("amount");
});
})
.body(|mut body| {
for invoice in invoices {
body.row(20.0, |mut row| {
row.col(|ui| {
if invoice.description.starts_with("{") {
ui.label("Zap!").on_hover_ui_at_pointer(|ui| {
ui::note_hover_ui(ui, &invoice.label, ctx, invoice_notes);
});
} else {
ui.label(&invoice.description);
}
});
row.col(|ui| match invoice.bolt11.amount_milli_satoshis() {
None => {
ui.label("any");
}
Some(amt) => {
ui.label(ui::human_verbose_sat(amt as i64));
}
});
});
}
});
}
}
}
-290
View File
@@ -1,290 +0,0 @@
use crate::channels::Channel;
use crate::channels::Channels;
use crate::channels::ListPeerChannel;
use crate::event::ClnResponse;
use crate::event::ConnectionState;
use crate::event::Event;
use crate::event::LoadingState;
use crate::event::Request;
use crate::invoice::Invoice;
use crate::summary::Summary;
use crate::watch::fetch_paid_invoices;
use lnsocket::bitcoin::secp256k1::{PublicKey, SecretKey, rand};
use lnsocket::{CommandoClient, LNSocket};
use nostrdb::Ndb;
use notedeck::{AppAction, AppContext};
use serde_json::json;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
mod channels;
mod event;
mod invoice;
mod summary;
mod ui;
mod watch;
#[derive(Default)]
pub struct ClnDash {
initialized: bool,
connection_state: ConnectionState,
summary: LoadingState<Summary, lnsocket::Error>,
get_info: LoadingState<String, lnsocket::Error>,
channels: LoadingState<Channels, lnsocket::Error>,
invoices: LoadingState<Vec<Invoice>, lnsocket::Error>,
channel: Option<CommChannel>,
last_summary: Option<Summary>,
// invoice label to zapreq id
invoice_zap_reqs: HashMap<String, [u8; 32]>,
}
#[derive(serde::Deserialize)]
pub struct ZapReqId {
#[serde(with = "hex::serde")]
id: [u8; 32],
}
impl Default for ConnectionState {
fn default() -> Self {
ConnectionState::Dead("uninitialized".to_string())
}
}
struct CommChannel {
req_tx: UnboundedSender<Request>,
event_rx: UnboundedReceiver<Event>,
}
impl notedeck::App for ClnDash {
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
if !self.initialized {
self.connection_state = ConnectionState::Connecting;
self.setup_connection();
self.initialized = true;
}
self.process_events(ctx.ndb);
self.show(ui, ctx);
None
}
}
impl ClnDash {
fn show(&mut self, ui: &mut egui::Ui, ctx: &mut AppContext) {
egui::Frame::new()
.inner_margin(egui::Margin::same(20))
.show(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
ui::connection_state_ui(ui, &self.connection_state);
crate::summary::summary_ui(ui, self.last_summary.as_ref(), &self.summary);
crate::invoice::invoices_ui(ui, &self.invoice_zap_reqs, ctx, &self.invoices);
crate::channels::channels_ui(ui, &self.channels);
crate::ui::get_info_ui(ui, &self.get_info);
});
});
}
fn setup_connection(&mut self) {
let (req_tx, mut req_rx) = unbounded_channel::<Request>();
let (event_tx, event_rx) = unbounded_channel::<Event>();
self.channel = Some(CommChannel { req_tx, event_rx });
tokio::spawn(async move {
let key = SecretKey::new(&mut rand::thread_rng());
let their_pubkey = PublicKey::from_str(&std::env::var("CLNDASH_ID").unwrap_or(
"03f3c108ccd536b8526841f0a5c58212bb9e6584a1eb493080e7c1cc34f82dad71".to_string(),
))
.unwrap();
let host = std::env::var("CLNDASH_HOST").unwrap_or("ln.damus.io:9735".to_string());
let lnsocket = match LNSocket::connect_and_init(key, their_pubkey, &host).await {
Err(err) => {
let _ = event_tx.send(Event::Ended {
reason: err.to_string(),
});
return;
}
Ok(lnsocket) => {
let _ = event_tx.send(Event::Connected);
lnsocket
}
};
let rune = std::env::var("CLNDASH_RUNE").unwrap_or(
"Vns1Zxvidr4J8pP2ZCg3Wjp2SyGyyf5RHgvFG8L36yM9MzMmbWV0aG9kPWdldGluZm8=".to_string(),
);
let commando = Arc::new(CommandoClient::spawn(lnsocket, &rune));
loop {
match req_rx.recv().await {
None => {
let _ = event_tx.send(Event::Ended {
reason: "channel dead?".to_string(),
});
break;
}
Some(req) => {
tracing::debug!("calling {req:?}");
match req {
Request::GetInfo => {
let event_tx = event_tx.clone();
let commando = commando.clone();
tokio::spawn(async move {
match commando.call("getinfo", json!({})).await {
Ok(v) => {
let _ = event_tx
.send(Event::Response(ClnResponse::GetInfo(v)));
}
Err(err) => {
tracing::error!("get_info error {}", err);
}
}
});
}
Request::PaidInvoices(n) => {
let event_tx = event_tx.clone();
let commando = commando.clone();
tokio::spawn(async move {
let invoices = fetch_paid_invoices(commando, n).await;
let _ = event_tx
.send(Event::Response(ClnResponse::PaidInvoices(invoices)));
});
}
Request::ListPeerChannels => {
let event_tx = event_tx.clone();
let commando = commando.clone();
tokio::spawn(async move {
let peer_channels =
commando.call("listpeerchannels", json!({})).await;
let channels = peer_channels.map(|v| {
let peer_channels: Vec<ListPeerChannel> =
serde_json::from_value(v["channels"].clone()).unwrap();
to_channels(peer_channels)
});
let _ = event_tx.send(Event::Response(
ClnResponse::ListPeerChannels(channels),
));
});
}
}
}
}
}
});
}
fn process_events(&mut self, ndb: &Ndb) {
let Some(channel) = &mut self.channel else {
return;
};
while let Ok(event) = channel.event_rx.try_recv() {
match event {
Event::Ended { reason } => {
self.connection_state = ConnectionState::Dead(reason);
}
Event::Connected => {
self.connection_state = ConnectionState::Active;
let _ = channel.req_tx.send(Request::GetInfo);
let _ = channel.req_tx.send(Request::ListPeerChannels);
let _ = channel.req_tx.send(Request::PaidInvoices(100));
}
Event::Response(resp) => match resp {
ClnResponse::ListPeerChannels(chans) => {
if let LoadingState::Loaded(prev) = &self.channels {
self.last_summary = Some(crate::summary::compute_summary(prev));
}
self.summary = match &chans {
Ok(chans) => {
LoadingState::Loaded(crate::summary::compute_summary(chans))
}
Err(err) => LoadingState::Failed(err.clone()),
};
self.channels = LoadingState::from_result(chans);
}
ClnResponse::GetInfo(value) => {
let res = serde_json::to_string_pretty(&value);
self.get_info =
LoadingState::from_result(res.map_err(|_| lnsocket::Error::Json));
}
ClnResponse::PaidInvoices(invoices) => {
// process zap requests
if let Ok(invoices) = &invoices {
for invoice in invoices {
let zap_req_id: Option<ZapReqId> =
serde_json::from_str(&invoice.description).ok();
if let Some(zap_req_id) = zap_req_id {
self.invoice_zap_reqs
.insert(invoice.label.clone(), zap_req_id.id);
let _ = ndb.process_event(&format!(
"[\"EVENT\",\"a\",{}]",
&invoice.description
));
}
}
}
self.invoices = LoadingState::from_result(invoices);
}
},
}
}
}
}
fn to_channels(peer_channels: Vec<ListPeerChannel>) -> Channels {
let mut avail_out: i64 = 0;
let mut avail_in: i64 = 0;
let mut max_total_msat: i64 = 0;
let mut channels: Vec<Channel> = peer_channels
.into_iter()
.map(|c| {
let to_us = (c.to_us_msat - c.our_reserve_msat).max(0);
let to_them_raw = (c.total_msat - c.to_us_msat).max(0);
let to_them = (to_them_raw - c.their_reserve_msat).max(0);
avail_out += to_us;
avail_in += to_them;
if c.total_msat > max_total_msat {
max_total_msat = c.total_msat; // <-- max, not sum
}
Channel {
to_us,
to_them,
original: c,
}
})
.collect();
channels.sort_by(|a, b| {
let a_capacity = a.to_them + a.to_us;
let b_capacity = b.to_them + b.to_us;
a_capacity.partial_cmp(&b_capacity).unwrap().reverse()
});
Channels {
max_total_msat,
avail_out,
avail_in,
channels,
}
}
-140
View File
@@ -1,140 +0,0 @@
use crate::channels::Channels;
use crate::event::LoadingState;
use crate::ui;
#[derive(Clone, Default)]
pub struct Summary {
pub total_msat: i64,
pub avail_out_msat: i64,
pub avail_in_msat: i64,
pub channel_count: usize,
pub largest_msat: i64,
pub outbound_pct: f32, // fraction of total capacity
}
pub fn compute_summary(ch: &Channels) -> Summary {
let total_msat: i64 = ch.channels.iter().map(|c| c.original.total_msat).sum();
let largest_msat: i64 = ch
.channels
.iter()
.map(|c| c.original.total_msat)
.max()
.unwrap_or(0);
let outbound_pct = if total_msat > 0 {
ch.avail_out as f32 / total_msat as f32
} else {
0.0
};
Summary {
total_msat,
avail_out_msat: ch.avail_out,
avail_in_msat: ch.avail_in,
channel_count: ch.channels.len(),
largest_msat,
outbound_pct,
}
}
pub fn summary_ui(
ui: &mut egui::Ui,
last_summary: Option<&Summary>,
summary: &LoadingState<Summary, lnsocket::Error>,
) {
match summary {
LoadingState::Loading => {
ui.label("loading summary");
}
LoadingState::Failed(err) => {
ui.label(format!("Failed to get summary: {err}"));
}
LoadingState::Loaded(summary) => {
summary_cards_ui(ui, summary, last_summary);
ui.add_space(8.0);
}
}
}
pub fn summary_cards_ui(ui: &mut egui::Ui, s: &Summary, prev: Option<&Summary>) {
let old = prev.cloned().unwrap_or_default();
let items: [(&str, String, Option<String>); 6] = [
(
"Total capacity",
ui::human_sat(s.total_msat),
prev.map(|_| ui::delta_str(s.total_msat, old.total_msat)),
),
(
"Avail out",
ui::human_sat(s.avail_out_msat),
prev.map(|_| ui::delta_str(s.avail_out_msat, old.avail_out_msat)),
),
(
"Avail in",
ui::human_sat(s.avail_in_msat),
prev.map(|_| ui::delta_str(s.avail_in_msat, old.avail_in_msat)),
),
("# Channels", s.channel_count.to_string(), None),
("Largest", ui::human_sat(s.largest_msat), None),
(
"Outbound %",
format!("{:.0}%", s.outbound_pct * 100.0),
None,
),
];
// --- responsive columns ---
let min_card = 160.0;
let cols = ((ui.available_width() / min_card).floor() as usize).max(1);
egui::Grid::new("summary_grid")
.num_columns(cols)
.min_col_width(min_card)
.spacing(egui::vec2(8.0, 8.0))
.show(ui, |ui| {
let items_len = items.len();
for (i, (t, v, d)) in items.into_iter().enumerate() {
card_cell(ui, t, v, d, min_card);
// End the row when we filled a row worth of cells
if (i + 1) % cols == 0 {
ui.end_row();
}
}
// If the last row wasn't full, close it anyway
if items_len % cols != 0 {
ui.end_row();
}
});
}
fn card_cell(ui: &mut egui::Ui, title: &str, value: String, delta: Option<String>, min_card: f32) {
let weak = ui.visuals().weak_text_color();
egui::Frame::group(ui.style())
.fill(ui.visuals().extreme_bg_color)
.corner_radius(egui::CornerRadius::same(10))
.inner_margin(egui::Margin::same(10))
.stroke(ui.visuals().widgets.noninteractive.bg_stroke)
.show(ui, |ui| {
ui.set_min_width(min_card);
ui.vertical(|ui| {
ui.add(
egui::Label::new(egui::RichText::new(title).small().color(weak))
.wrap_mode(egui::TextWrapMode::Wrap),
);
ui.add_space(4.0);
ui.add(
egui::Label::new(egui::RichText::new(value).strong().size(18.0))
.wrap_mode(egui::TextWrapMode::Wrap),
);
if let Some(d) = delta {
ui.add_space(2.0);
ui.add(
egui::Label::new(egui::RichText::new(d).small().color(weak))
.wrap_mode(egui::TextWrapMode::Wrap),
);
}
});
ui.set_min_height(20.0);
});
}
-145
View File
@@ -1,145 +0,0 @@
use crate::event::ConnectionState;
use crate::event::LoadingState;
use egui::Color32;
use egui::Label;
use egui::RichText;
use egui::Widget;
use notedeck::AppContext;
use std::collections::HashMap;
pub fn note_hover_ui(
ui: &mut egui::Ui,
label: &str,
ctx: &mut AppContext,
invoice_notes: &HashMap<String, [u8; 32]>,
) -> Option<notedeck::NoteAction> {
let zap_req_id = invoice_notes.get(label)?;
let Ok(txn) = nostrdb::Transaction::new(ctx.ndb) else {
return None;
};
let Ok(zapreq_note) = ctx.ndb.get_note_by_id(&txn, zap_req_id) else {
return None;
};
for tag in zapreq_note.tags() {
let Some("e") = tag.get_str(0) else {
continue;
};
let Some(target_id) = tag.get_id(1) else {
continue;
};
let Ok(note) = ctx.ndb.get_note_by_id(&txn, target_id) else {
return None;
};
let author = ctx
.ndb
.get_profile_by_pubkey(&txn, zapreq_note.pubkey())
.ok();
// TODO(jb55): make this less horrible
let mut note_context = notedeck::NoteContext {
ndb: ctx.ndb,
accounts: ctx.accounts,
img_cache: ctx.img_cache,
note_cache: ctx.note_cache,
zaps: ctx.zaps,
pool: ctx.pool,
job_pool: ctx.job_pool,
unknown_ids: ctx.unknown_ids,
clipboard: ctx.clipboard,
i18n: ctx.i18n,
global_wallet: ctx.global_wallet,
};
let mut jobs = notedeck::JobsCache::default();
let options = notedeck_ui::NoteOptions::default();
notedeck_ui::ProfilePic::from_profile_or_default(note_context.img_cache, author.as_ref())
.ui(ui);
let nostr_name = notedeck::name::get_display_name(author.as_ref());
ui.label(format!("{} zapped you", nostr_name.name()));
return notedeck_ui::NoteView::new(&mut note_context, &note, options, &mut jobs)
.preview_style()
.hide_media(true)
.show(ui)
.action;
}
None
}
pub fn get_info_ui(ui: &mut egui::Ui, info: &LoadingState<String, lnsocket::Error>) {
ui.horizontal_wrapped(|ui| match info {
LoadingState::Loading => {}
LoadingState::Failed(err) => {
ui.label(format!("failed to fetch node info: {err}"));
}
LoadingState::Loaded(info) => {
ui.add(Label::new(info).wrap_mode(egui::TextWrapMode::Wrap));
}
});
}
pub fn connection_state_ui(ui: &mut egui::Ui, state: &ConnectionState) {
match state {
ConnectionState::Active => {
ui.add(Label::new(RichText::new("Connected").color(Color32::GREEN)));
}
ConnectionState::Connecting => {
ui.add(Label::new(
RichText::new("Connecting").color(Color32::YELLOW),
));
}
ConnectionState::Dead(reason) => {
ui.add(Label::new(
RichText::new(format!("Disconnected: {reason}")).color(Color32::RED),
));
}
}
}
// ---------- helper ----------
pub fn human_sat(msat: i64) -> String {
let sats = msat / 1000;
if sats >= 1_000_000 {
format!("{:.1}M", sats as f64 / 1_000_000.0)
} else if sats >= 1_000 {
format!("{:.1}k", sats as f64 / 1_000.0)
} else {
sats.to_string()
}
}
pub fn human_verbose_sat(msat: i64) -> String {
if msat < 1_000 {
// less than 1 sat
format!("{msat} msat")
} else {
let sats = msat / 1_000;
if sats < 100_000_000 {
// less than 1 BTC
format!("{sats} sat")
} else {
let btc = sats / 100_000_000;
format!("{btc} BTC")
}
}
}
pub fn delta_str(new: i64, old: i64) -> String {
let d = new - old;
match d.cmp(&0) {
std::cmp::Ordering::Greater => format!("{}", human_sat(d)),
std::cmp::Ordering::Less => format!("{}", human_sat(-d)),
std::cmp::Ordering::Equal => "·".into(),
}
}
-198
View File
@@ -1,198 +0,0 @@
use crate::invoice::Invoice;
use lnsocket::CallOpts;
use lnsocket::CommandoClient;
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
#[derive(Deserialize)]
struct UpdatedInvoicesResponse {
updated: u64,
}
#[derive(Deserialize)]
struct PayIndexInvoices {
invoices: Vec<PayIndexScan>,
}
#[derive(Deserialize)]
struct PayIndexScan {
pay_index: Option<u64>,
}
async fn find_lastpay_index(commando: Arc<CommandoClient>) -> Result<Option<u64>, lnsocket::Error> {
const PAGE: u64 = 250;
// 1) get the current updated tail
let created_value = commando
.call(
"wait",
json!({"subsystem":"invoices","indexname":"updated","nextvalue":0}),
)
.await?;
let response: UpdatedInvoicesResponse =
serde_json::from_value(created_value).map_err(|_| lnsocket::Error::Json)?;
// start our window at the tail
let mut start_at = response
.updated
.saturating_add(1) // +1 because we want max(1, updated - PAGE + 1)
.saturating_sub(PAGE)
.max(1);
loop {
// 2) fetch a window (indexed by "updated")
let val = commando
.call_with_opts(
"listinvoices",
json!({
"index": "updated",
"start": start_at,
"limit": PAGE,
}),
// only fetch the one field we care about
CallOpts::default().filter(json!({
"invoices": [{"pay_index": true}]
})),
)
.await?;
let parsed: PayIndexInvoices =
serde_json::from_value(val).map_err(|_| lnsocket::Error::Json)?;
if let Some(pi) = parsed.invoices.iter().filter_map(|inv| inv.pay_index).max() {
return Ok(Some(pi));
}
// 4) no paid invoice in this slice—step back or bail
if start_at == 1 {
return Ok(None);
}
start_at = start_at.saturating_sub(PAGE).max(1);
}
}
pub async fn fetch_paid_invoices(
commando: Arc<CommandoClient>,
limit: u32,
) -> Result<Vec<Invoice>, lnsocket::Error> {
use tokio::task::JoinSet;
// look for an invoice with the last paid index
let Some(lastpay_index) = find_lastpay_index(commando.clone()).await? else {
// no paid invoices
return Ok(vec![]);
};
let mut set: JoinSet<Result<Invoice, lnsocket::Error>> = JoinSet::new();
let start = lastpay_index.saturating_sub(limit as u64);
// 3) Fire off at most `concurrency` `waitanyinvoice` calls at a time,
// collect all successful responses into a Vec.
// fire them ALL at once
for idx in start..lastpay_index {
let c = commando.clone();
set.spawn(async move {
let val = c
.call(
"waitanyinvoice",
serde_json::json!({ "lastpay_index": idx }),
)
.await?;
let parsed: Invoice = serde_json::from_value(val).map_err(|_| lnsocket::Error::Json)?;
Ok(parsed)
});
}
let mut results = Vec::with_capacity(limit as usize);
while let Some(res) = set.join_next().await {
results.push(res.map_err(|_| lnsocket::Error::Io(std::io::ErrorKind::Interrupted))??);
}
results.sort_by(|a, b| a.updated_index.cmp(&b.updated_index).reverse());
Ok(results)
}
// wip watch subsystem
/*
async fn watch_subsystem(
commando: CommandoClient,
subsystem: WaitSubsystem,
index: WaitIndex,
event_tx: UnboundedSender<Event>,
mut cancel_rx: Receiver<()>,
) {
// Step 1: Fetch current index value so we can back up ~20
let mut nextvalue: u64 = match commando
.call(
"wait",
serde_json::json!({
"indexname": index.as_str(),
"subsystem": subsystem.as_str(),
"nextvalue": 0
}),
)
.await
{
Ok(v) => {
// You showed the result has `updated` as the current highest index
let current = v.get("updated").and_then(|x| x.as_u64()).unwrap_or(0);
current.saturating_sub(20) // back up 20, clamp at 0
}
Err(err) => {
tracing::warn!("initial wait(…nextvalue=0) failed: {}", err);
0
}
};
loop {
// You can add a timeout to avoid hanging forever in weird network states.
let fut = commando.call(
"wait",
serde_json::to_value(WaitRequest {
indexname: "invoices".into(),
subsystem: "lightningd".into(),
nextvalue,
})
.unwrap(),
);
tokio::select! {
_ = &mut cancel_rx => {
// graceful shutdown
break;
}
res = fut => {
match res {
Ok(v) => {
// Typical shape: { "nextvalue": n, "invoicestatus": { ... } } (varies by plugin/index)
// Adjust these lookups for your nodes actual wait payload.
if let Some(nv) = v.get("nextvalue").and_then(|x| x.as_u64()) {
nextvalue = nv + 1;
} else {
// Defensive: never get stuck — bump at least by 1
nextvalue += 1;
}
// Inspect/route
let kind = v.get("status").and_then(|s| s.as_str());
let ev = match kind {
Some("paid") => ClnResponse::Invoice(InvoiceEvent::Paid(v.clone())),
Some("created") => ClnResponse::Invoice(InvoiceEvent::Created(v.clone())),
_ => ClnResponse::Invoice(InvoiceEvent::Other(v.clone())),
};
let _ = event_tx.send(Event::Response(ev));
}
Err(err) => {
tracing::warn!("wait(invoices) error: {err}");
// small backoff so we don't tight-loop on persistent errors
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
}
}
}
}
*/
-4
View File
@@ -10,10 +10,6 @@ description = "A tweetdeck-style notedeck app"
[lib]
crate-type = ["lib", "cdylib"]
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
ndk-context = "0.1"
[dependencies]
opener = { workspace = true }
rmpv = { workspace = true }
+53 -110
View File
@@ -1,19 +1,15 @@
use enostr::{FullKeypair, Pubkey};
use nostrdb::{Ndb, Transaction};
use notedeck::{Accounts, AppContext, JobsCache, Localization, SingleUnkIdAction, UnknownIds};
use notedeck_ui::nip51_set::Nip51SetUiCache;
use notedeck::{Accounts, AppContext, Localization, SingleUnkIdAction, UnknownIds};
pub use crate::accounts::route::AccountsResponse;
use crate::app::get_active_columns_mut;
use crate::decks::DecksCache;
use crate::onboarding::Onboarding;
use crate::profile::send_new_contact_list;
use crate::subscriptions::Subscriptions;
use crate::ui::onboarding::{FollowPackOnboardingView, FollowPacksResponse, OnboardingResponse};
use crate::{
login_manager::AcquireKeyState,
route::Route,
timeline::TimelineCache,
ui::{
account_login_view::{AccountLoginResponse, AccountLoginView},
accounts::{AccountsView, AccountsViewResponse},
@@ -41,7 +37,6 @@ pub struct SwitchAccountAction {
/// The account to switch to
pub switch_to: Pubkey,
pub switching_to_new: bool,
}
impl SwitchAccountAction {
@@ -49,14 +44,8 @@ impl SwitchAccountAction {
SwitchAccountAction {
source_column,
switch_to,
switching_to_new: false,
}
}
pub fn switching_to_new(mut self) -> Self {
self.switching_to_new = true;
self
}
}
#[derive(Debug)]
@@ -76,13 +65,13 @@ pub struct AddAccountAction {
pub fn render_accounts_route(
ui: &mut egui::Ui,
app_ctx: &mut AppContext,
jobs: &mut JobsCache,
col: usize,
decks: &mut DecksCache,
timeline_cache: &mut TimelineCache,
login_state: &mut AcquireKeyState,
onboarding: &mut Onboarding,
follow_packs_ui: &mut Nip51SetUiCache,
route: AccountsRoute,
) -> Option<AccountsResponse> {
match route {
) -> AddAccountAction {
let resp = match route {
AccountsRoute::Accounts => AccountsView::new(
app_ctx.ndb,
app_ctx.accounts,
@@ -91,33 +80,47 @@ pub fn render_accounts_route(
)
.ui(ui)
.inner
.map(AccountsRouteResponse::Accounts)
.map(AccountsResponse::Account),
.map(AccountsRouteResponse::Accounts),
AccountsRoute::AddAccount => {
AccountLoginView::new(login_state, app_ctx.clipboard, app_ctx.i18n)
.ui(ui)
.inner
.map(AccountsRouteResponse::AddAccount)
.map(AccountsResponse::Account)
}
AccountsRoute::Onboarding => FollowPackOnboardingView::new(
onboarding,
follow_packs_ui,
app_ctx.ndb,
app_ctx.img_cache,
app_ctx.i18n,
app_ctx.job_pool,
jobs,
)
.ui(ui)
.map(|r| match r {
OnboardingResponse::FollowPacks(follow_packs_response) => {
AccountsResponse::Account(AccountsRouteResponse::AddAccount(
AccountLoginResponse::Onboarding(follow_packs_response),
))
};
if let Some(resp) = resp {
match resp {
AccountsRouteResponse::Accounts(response) => {
let action = process_accounts_view_response(
app_ctx.i18n,
app_ctx.accounts,
decks,
col,
response,
);
AddAccountAction {
accounts_action: action,
unk_id_action: SingleUnkIdAction::no_action(),
}
}
OnboardingResponse::ViewProfile(pubkey) => AccountsResponse::ViewProfile(pubkey),
}),
AccountsRouteResponse::AddAccount(response) => {
let action =
process_login_view_response(app_ctx, timeline_cache, decks, col, response);
*login_state = Default::default();
let router = get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, decks)
.column_mut(col)
.router_mut();
router.go_back();
action
}
}
} else {
AddAccountAction {
accounts_action: None,
unk_id_action: SingleUnkIdAction::no_action(),
}
}
}
@@ -152,53 +155,31 @@ pub fn process_accounts_view_response(
pub fn process_login_view_response(
app_ctx: &mut AppContext,
timeline_cache: &mut TimelineCache,
decks: &mut DecksCache,
subs: &mut Subscriptions,
onboarding: &mut Onboarding,
col: usize,
response: AccountLoginResponse,
) -> AddAccountAction {
let cur_router = get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, decks)
.column_mut(col)
.router_mut();
let r = match response {
let (r, pubkey) = match response {
AccountLoginResponse::CreateNew => {
let kp = FullKeypair::generate();
let pubkey = kp.pubkey;
send_new_contact_list(kp.to_filled(), app_ctx.ndb, app_ctx.pool);
(app_ctx.accounts.add_account(kp.to_keypair()), pubkey)
}
AccountLoginResponse::LoginWith(keypair) => {
cur_router.go_back();
app_ctx.accounts.add_account(keypair)
let pubkey = keypair.pubkey;
(app_ctx.accounts.add_account(keypair), pubkey)
}
AccountLoginResponse::CreatingNew => {
cur_router.route_to(Route::Accounts(AccountsRoute::Onboarding));
onboarding.process(app_ctx.pool, app_ctx.ndb, subs, app_ctx.unknown_ids);
None
}
AccountLoginResponse::Onboarding(onboarding_response) => match onboarding_response {
FollowPacksResponse::NoFollowPacks => {
onboarding.process(app_ctx.pool, app_ctx.ndb, subs, app_ctx.unknown_ids);
None
}
FollowPacksResponse::UserSelectedPacks(nip51_sets_ui_state) => {
let pks_to_follow = nip51_sets_ui_state.get_all_selected();
let kp = FullKeypair::generate();
send_new_contact_list(kp.to_filled(), app_ctx.ndb, app_ctx.pool, pks_to_follow);
cur_router.go_back();
onboarding.end_onboarding(app_ctx.pool, app_ctx.ndb);
app_ctx.accounts.add_account(kp.to_keypair())
}
},
};
decks.add_deck_default(app_ctx, timeline_cache, pubkey);
if let Some(action) = r {
AddAccountAction {
accounts_action: Some(AccountsAction::Switch(SwitchAccountAction {
source_column: col,
switch_to: action.switch_to,
switching_to_new: true,
})),
unk_id_action: action.unk_id_action,
}
@@ -209,41 +190,3 @@ pub fn process_login_view_response(
}
}
}
impl AccountsRouteResponse {
pub fn process(
self,
app_ctx: &mut AppContext,
app: &mut crate::Damus,
col: usize,
) -> AddAccountAction {
match self {
AccountsRouteResponse::Accounts(response) => {
let action = process_accounts_view_response(
app_ctx.i18n,
app_ctx.accounts,
&mut app.decks_cache,
col,
response,
);
AddAccountAction {
accounts_action: action,
unk_id_action: notedeck::SingleUnkIdAction::no_action(),
}
}
AccountsRouteResponse::AddAccount(response) => {
let action = process_login_view_response(
app_ctx,
&mut app.decks_cache,
&mut app.subscriptions,
&mut app.onboarding,
col,
response,
);
app.view_state.login = Default::default();
action
}
}
}
}
@@ -7,16 +7,10 @@ pub enum AccountsRouteResponse {
AddAccount(AccountLoginResponse),
}
pub enum AccountsResponse {
ViewProfile(enostr::Pubkey),
Account(AccountsRouteResponse),
}
#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)]
pub enum AccountsRoute {
Accounts,
AddAccount,
Onboarding,
}
impl AccountsRoute {
@@ -25,7 +19,6 @@ impl AccountsRoute {
match self {
Self::Accounts => &["accounts", "show"],
Self::AddAccount => &["accounts", "new"],
Self::Onboarding => &["accounts", "onboarding"],
}
}
}
+15 -51
View File
@@ -1,12 +1,12 @@
use std::collections::HashSet;
use crate::{
column::Columns,
nav::{RouterAction, RouterType},
route::Route,
timeline::{
thread::{selected_has_at_least_n_replies, NoteSeenFlags, ThreadNode, Threads},
InsertionResponse, ThreadSelection, TimelineCache, TimelineKind,
thread::{
selected_has_at_least_n_replies, InsertionResponse, NoteSeenFlags, ThreadNode, Threads,
},
ThreadSelection, TimelineCache, TimelineKind,
},
view_state::ViewState,
};
@@ -30,9 +30,8 @@ pub enum NotesOpenResult {
Thread(NewThreadNotes),
}
pub struct TimelineOpenResult {
new_notes: Option<NewNotes>,
new_pks: Option<HashSet<Pubkey>>,
pub enum TimelineOpenResult {
NewNotes(NewNotes),
}
struct NoteActionResponse {
@@ -82,11 +81,7 @@ fn execute_note_action(
.open(ndb, note_cache, txn, pool, &kind)
.map(NotesOpenResult::Timeline);
}
NoteAction::Note {
note_id,
preview,
scroll_offset,
} => 'ex: {
NoteAction::Note { note_id, preview } => 'ex: {
let Ok(thread_selection) = ThreadSelection::from_note_id(ndb, note_cache, txn, note_id)
else {
tracing::error!("No thread selection for {}?", hex::encode(note_id.bytes()));
@@ -94,15 +89,7 @@ fn execute_note_action(
};
timeline_res = threads
.open(
ndb,
txn,
pool,
&thread_selection,
preview,
col,
scroll_offset,
)
.open(ndb, txn, pool, &thread_selection, preview, col)
.map(NotesOpenResult::Thread);
let route = Route::Thread(thread_selection);
@@ -271,24 +258,7 @@ fn clear_zap_error(sender: &Pubkey, zaps: &mut Zaps, target: &NoteZapTargetOwned
impl TimelineOpenResult {
pub fn new_notes(notes: Vec<NoteKey>, id: TimelineKind) -> Self {
Self {
new_notes: Some(NewNotes { id, notes }),
new_pks: None,
}
}
pub fn new_pks(pks: HashSet<Pubkey>) -> Self {
Self {
new_notes: None,
new_pks: Some(pks),
}
}
pub fn insert_pks(&mut self, pks: HashSet<Pubkey>) {
match &mut self.new_pks {
Some(cur_pks) => cur_pks.extend(pks),
None => self.new_pks = Some(pks),
}
Self::NewNotes(NewNotes::new(notes, id))
}
pub fn process(
@@ -299,17 +269,11 @@ impl TimelineOpenResult {
storage: &mut TimelineCache,
unknown_ids: &mut UnknownIds,
) {
// update the thread for next render if we have new notes
if let Some(new_notes) = &self.new_notes {
new_notes.process(storage, ndb, txn, unknown_ids, note_cache);
}
let Some(pks) = &self.new_pks else {
return;
};
for pk in pks {
unknown_ids.add_pubkey_if_missing(ndb, txn, pk);
match self {
// update the thread for next render if we have new notes
TimelineOpenResult::NewNotes(new_notes) => {
new_notes.process(storage, ndb, txn, unknown_ids, note_cache);
}
}
}
}
@@ -411,7 +375,7 @@ pub fn process_thread_notes(
created_at,
};
if thread.replies.contains_key(&note_ref.key) {
if thread.replies.contains(&note_ref) {
continue;
}
+54 -111
View File
@@ -4,15 +4,13 @@ use crate::{
decks::{Decks, DecksCache},
draft::Drafts,
nav::{self, ProcessNavResult},
onboarding::Onboarding,
options::AppOptions,
route::Route,
storage,
subscriptions::{SubKind, Subscriptions},
support::Support,
timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind},
toolbar::unseen_notification,
ui::{self, toolbar::toolbar, DesktopSidePanel, SidePanelAction},
ui::{self, DesktopSidePanel, ShowSourceClientOption, SidePanelAction},
view_state::ViewState,
Result,
};
@@ -29,6 +27,7 @@ use notedeck_ui::{
};
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use std::time::Duration;
use tracing::{debug, error, info, trace, warn};
use uuid::Uuid;
@@ -59,20 +58,18 @@ pub struct Damus {
pub note_options: NoteOptions,
pub unrecognized_args: BTreeSet<String>,
/// keep track of follow packs
pub onboarding: Onboarding,
}
fn handle_egui_events(input: &egui::InputState, columns: &mut Columns) {
fn handle_key_events(input: &egui::InputState, columns: &mut Columns) {
for event in &input.raw.events {
match event {
egui::Event::Key { key, pressed, .. } if *pressed => match key {
if let egui::Event::Key {
key, pressed: true, ..
} = event
{
match key {
egui::Key::J => {
//columns.select_down();
{}
columns.select_down();
}
/*
egui::Key::K => {
columns.select_up();
}
@@ -82,18 +79,11 @@ fn handle_egui_events(input: &egui::InputState, columns: &mut Columns) {
egui::Key::L => {
columns.select_left();
}
*/
egui::Key::BrowserBack | egui::Key::Escape => {
columns.get_selected_router().go_back();
}
_ => {}
},
egui::Event::InsetsChanged => {
tracing::debug!("insets have changed!");
}
_ => {}
}
}
}
@@ -105,7 +95,7 @@ fn try_process_event(
) -> Result<()> {
let current_columns =
get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, &mut damus.decks_cache);
ctx.input(|i| handle_egui_events(i, current_columns));
ctx.input(|i| handle_key_events(i, current_columns));
let ctx2 = ctx.clone();
let wakeup = move || {
@@ -148,14 +138,13 @@ fn try_process_event(
}
}
for (kind, timeline) in &mut damus.timeline_cache {
for (_kind, timeline) in &mut damus.timeline_cache {
let is_ready = timeline::is_timeline_ready(
app_ctx.ndb,
app_ctx.pool,
app_ctx.note_cache,
timeline,
app_ctx.accounts,
app_ctx.unknown_ids,
);
if is_ready {
@@ -174,16 +163,9 @@ fn try_process_event(
}
} else {
// TODO: show loading?
if matches!(kind, TimelineKind::List(ListKind::Contact(_))) {
timeline::fetch_contact_list(&mut damus.subscriptions, timeline, app_ctx.accounts);
}
}
}
if let Some(follow_packs) = damus.onboarding.get_follow_packs_mut() {
follow_packs.poll_for_notes(app_ctx.ndb, app_ctx.unknown_ids);
}
if app_ctx.unknown_ids.ready_to_send() {
unknown_id_send(app_ctx.unknown_ids, app_ctx.pool);
}
@@ -223,7 +205,6 @@ fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Con
app_ctx.ndb,
app_ctx.note_cache,
&mut damus.timeline_cache,
app_ctx.unknown_ids,
) {
warn!("update_damus init: {err}");
}
@@ -393,7 +374,7 @@ fn render_damus(
fullscreen_media_viewer_ui(ui, &mut damus.view_state.media_viewer, app_ctx.img_cache);
// We use this for keeping timestamps and things up to date
//ui.ctx().request_repaint_after(Duration::from_secs(5));
ui.ctx().request_repaint_after(Duration::from_secs(5));
app_action
}
@@ -513,10 +494,14 @@ impl Damus {
// cache.add_deck_default(*pk);
//}
};
let settings = &app_context.settings;
let support = Support::new(app_context.path);
let note_options = get_note_options(parsed_args, app_context.settings);
let note_options = get_note_options(parsed_args, settings);
let jobs = JobsCache::default();
let threads = Threads::default();
Self {
@@ -533,7 +518,6 @@ impl Damus {
unrecognized_args,
jobs,
threads,
onboarding: Onboarding::default(),
}
}
@@ -584,7 +568,6 @@ impl Damus {
unrecognized_args: BTreeSet::default(),
jobs: JobsCache::default(),
threads: Threads::default(),
onboarding: Onboarding::default(),
}
}
@@ -595,17 +578,9 @@ impl Damus {
pub fn unrecognized_args(&self) -> &BTreeSet<String> {
&self.unrecognized_args
}
pub fn toolbar_height() -> f32 {
48.0
}
pub fn initially_selected_toolbar_index() -> i32 {
0
}
}
fn get_note_options(args: ColumnsArgs, settings_handler: &mut SettingsHandler) -> NoteOptions {
fn get_note_options(args: ColumnsArgs, settings_handler: &&mut SettingsHandler) -> NoteOptions {
let mut note_options = NoteOptions::default();
note_options.set(
@@ -620,6 +595,17 @@ fn get_note_options(args: ColumnsArgs, settings_handler: &mut SettingsHandler) -
NoteOptions::HideMedia,
args.is_flag_set(ColumnsFlag::NoMedia),
);
note_options.set(
NoteOptions::ShowNoteClientTop,
ShowSourceClientOption::Top == settings_handler.show_source_client().into()
|| args.is_flag_set(ColumnsFlag::ShowNoteClientTop),
);
note_options.set(
NoteOptions::ShowNoteClientBottom,
ShowSourceClientOption::Bottom == settings_handler.show_source_client().into()
|| args.is_flag_set(ColumnsFlag::ShowNoteClientBottom),
);
note_options.set(
NoteOptions::RepliesNewestFirst,
settings_handler.show_replies_newest_first(),
@@ -644,71 +630,36 @@ fn render_damus_mobile(
) -> Option<AppAction> {
//let routes = app.timelines[0].routes.clone();
let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize;
let rect = ui.available_rect_before_wrap();
let mut app_action: Option<AppAction> = None;
// don't show toolbar if soft keyboard is open
let skb_rect = app_ctx.soft_keyboard_rect(
ui.ctx().screen_rect(),
notedeck::SoftKeyboardContext::platform(ui.ctx()),
);
let toolbar_height = if skb_rect.is_none() {
Damus::toolbar_height()
} else {
0.0
};
StripBuilder::new(ui)
.size(Size::remainder()) // top cell
.size(Size::exact(toolbar_height)) // bottom cell
.vertical(|mut strip| {
strip.cell(|ui| {
let rect = ui.available_rect_before_wrap();
if !app.columns(app_ctx.accounts).columns().is_empty() {
let r = nav::render_nav(
active_col,
ui.available_rect_before_wrap(),
app,
app_ctx,
ui,
)
.process_render_nav_response(app, app_ctx, ui);
if let Some(r) = &r {
match r {
ProcessNavResult::SwitchOccurred => {
if !app.options.contains(AppOptions::TmpColumns) {
storage::save_decks_cache(app_ctx.path, &app.decks_cache);
}
}
let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize;
ProcessNavResult::PfpClicked => {
app_action = Some(AppAction::ToggleChrome);
}
}
if !app.columns(app_ctx.accounts).columns().is_empty() {
let r = nav::render_nav(
active_col,
ui.available_rect_before_wrap(),
app,
app_ctx,
ui,
)
.process_render_nav_response(app, app_ctx, ui);
if let Some(r) = &r {
match r {
ProcessNavResult::SwitchOccurred => {
if !app.options.contains(AppOptions::TmpColumns) {
storage::save_decks_cache(app_ctx.path, &app.decks_cache);
}
}
hovering_post_button(ui, app, app_ctx, rect);
});
strip.cell(|ui| 'brk: {
if toolbar_height <= 0.0 {
break 'brk;
ProcessNavResult::PfpClicked => {
app_action = Some(AppAction::ToggleChrome);
}
}
}
}
let unseen_notif = unseen_notification(
app,
app_ctx.ndb,
app_ctx.accounts.get_selected_account().key.pubkey,
);
if skb_rect.is_none() {
let resp = toolbar(ui, unseen_notif);
if let Some(action) = resp {
action.process(app, app_ctx);
}
}
});
});
hovering_post_button(ui, app, app_ctx, rect);
app_action
}
@@ -724,10 +675,8 @@ fn hovering_post_button(
let button_y = ui
.ctx()
.animate_bool_responsive(btn_id, should_show_compose);
rect.min.x = rect.max.x - (if is_narrow(ui.ctx()) { 60.0 } else { 100.0 } * button_y);
rect.min.y = rect.max.y - 100.0;
rect.max.x += 48.0 * (1.0 - button_y);
rect.min.x = rect.max.x - if is_narrow(ui.ctx()) { 60.0 } else { 100.0 };
rect.min.y = rect.max.y - 100.0 * button_y;
let darkmode = ui.ctx().style().visuals.dark_mode;
@@ -904,13 +853,7 @@ fn timelines_view(
let mut save_cols = false;
if let Some(action) = side_panel_action {
save_cols = save_cols
|| action.process(
&mut app.timeline_cache,
&mut app.decks_cache,
ctx,
&mut app.subscriptions,
ui.ctx(),
);
|| action.process(&mut app.timeline_cache, &mut app.decks_cache, ctx, ui.ctx());
}
let mut app_action: Option<AppAction> = None;
+7 -10
View File
@@ -11,6 +11,8 @@ pub enum ColumnsFlag {
Textmode,
Scramble,
NoMedia,
ShowNoteClientTop,
ShowNoteClientBottom,
}
pub struct ColumnsArgs {
@@ -52,6 +54,10 @@ impl ColumnsArgs {
res.clear_flag(ColumnsFlag::SinceOptimize);
} else if arg == "--scramble" {
res.set_flag(ColumnsFlag::Scramble);
} else if arg == "--show-note-client=top" {
res.set_flag(ColumnsFlag::ShowNoteClientTop);
} else if arg == "--show-note-client=bottom" {
res.set_flag(ColumnsFlag::ShowNoteClientBottom);
} else if arg == "--no-media" {
res.set_flag(ColumnsFlag::NoMedia);
} else if arg == "--filter" {
@@ -140,16 +146,7 @@ impl ColumnsArgs {
} else if column_name == "universe" {
debug!("got universe column");
res.columns
.push(ArgColumn::Timeline(TimelineKind::Universe));
} else if let Some(hashtag) = column_name.strip_prefix("hashtag:") {
let hashtags: Vec<String> = hashtag
.split(",")
.map(str::trim)
.filter(|p| !p.is_empty())
.map(ToOwned::to_owned)
.collect();
res.columns
.push(ArgColumn::Timeline(TimelineKind::Hashtag(hashtags)));
.push(ArgColumn::Timeline(TimelineKind::Universe))
} else if let Some(profile_pk_str) = column_name.strip_prefix("profile:") {
if let Ok(pubkey) = Pubkey::parse(profile_pk_str) {
info!("got profile column for user {}", pubkey.hex());
+12 -20
View File
@@ -75,33 +75,25 @@ impl Columns {
/// Select the column based on the timeline kind.
///
/// TODO: add timeline if missing?
pub fn select_by_route(&mut self, desired_route: Route) -> SelectionResult {
pub fn select_by_kind(&mut self, kind: &TimelineKind) -> SelectionResult {
for (i, col) in self.columns.iter().enumerate() {
for route in col.router().routes() {
if *route == desired_route {
if self.selected as usize == i {
return SelectionResult::AlreadySelected(i);
} else {
self.select_column(i as i32);
return SelectionResult::NewSelection(i);
if let Some(timeline) = route.timeline_id() {
if timeline == kind {
tracing::info!("selecting {kind:?} column");
if self.selected as usize == i {
return SelectionResult::AlreadySelected(i);
} else {
self.select_column(i as i32);
return SelectionResult::NewSelection(i);
}
}
}
}
}
if matches!(&desired_route, Route::Timeline(_))
|| matches!(&desired_route, Route::Thread(_))
{
// these require additional handling to add state
tracing::error!("failed to select {desired_route:?} column");
return SelectionResult::Failed;
}
self.add_column(Column::new(vec![desired_route]));
let selected_index = self.columns.len() - 1;
self.select_column(selected_index as i32);
SelectionResult::NewSelection(selected_index)
tracing::error!("failed to select {kind:?} column");
SelectionResult::Failed
}
pub fn add_new_timeline_column(
+1 -1
View File
@@ -190,7 +190,7 @@ impl DecksCache {
&self.fallback_pubkey
}
pub fn get_all_decks_mut(&mut self) -> ValuesMut<'_, Pubkey, Decks> {
pub fn get_all_decks_mut(&mut self) -> ValuesMut<Pubkey, Decks> {
self.account_to_decks.values_mut()
}
-2
View File
@@ -18,7 +18,6 @@ pub mod login_manager;
mod media_upload;
mod multi_subscriber;
mod nav;
mod onboarding;
pub mod options;
mod post;
mod profile;
@@ -28,7 +27,6 @@ mod subscriptions;
mod support;
mod test_data;
pub mod timeline;
mod toolbar;
pub mod ui;
mod unknowns;
mod view_state;
+61 -43
View File
@@ -1,17 +1,18 @@
#![cfg_attr(target_os = "android", allow(dead_code, unused_variables))]
use crate::Error;
use std::path::PathBuf;
use base64::{prelude::BASE64_URL_SAFE, Engine};
use ehttp::Request;
use nostrdb::{Note, NoteBuilder};
use notedeck::{
media::images::fetch_binary_from_disk,
platform::file::{MediaFrom, SelectedMedia},
};
use notedeck::SupportedMimeType;
use poll_promise::Promise;
use sha2::{Digest, Sha256};
use url::Url;
use crate::Error;
use notedeck::media::images::fetch_binary_from_disk;
pub const NOSTR_BUILD_URL: fn() -> Url = || Url::parse("http://nostr.build").unwrap();
const NIP96_WELL_KNOWN: &str = ".well-known/nostr/nip96.json";
@@ -74,7 +75,7 @@ pub fn get_nostr_build_upload_url() -> Promise<Result<String, Error>> {
get_upload_url_from_provider(NOSTR_BUILD_URL())
}
fn create_nip98_note(seckey: &[u8; 32], upload_url: String, payload_hash: String) -> Note<'_> {
fn create_nip98_note(seckey: &[u8; 32], upload_url: String, payload_hash: String) -> Note {
NoteBuilder::new()
.kind(27235)
.start_tag()
@@ -93,15 +94,15 @@ fn create_nip98_note(seckey: &[u8; 32], upload_url: String, payload_hash: String
fn create_nip96_request(
upload_url: &str,
file_name: &str,
media_type: &str,
media_path: MediaPath,
file_contents: Vec<u8>,
nip98_base64: &str,
) -> ehttp::Request {
let boundary = "----boundary";
let mut body = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{file_name}\"\r\nContent-Type: {media_type}\r\n\r\n",
"--{}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n",
boundary, media_path.file_name, media_path.media_type.to_mime()
)
.into_bytes();
body.extend(file_contents);
@@ -133,14 +134,25 @@ fn sha256_hex(contents: &Vec<u8>) -> String {
pub fn nip96_upload(
seckey: [u8; 32],
upload_url: String,
selected_media: SelectedMedia,
media_path: MediaPath,
) -> Promise<Result<Nip94Event, Error>> {
internal_nip96_upload(seckey, upload_url, selected_media)
let bytes_res = fetch_binary_from_disk(media_path.full_path.clone());
let file_bytes = match bytes_res {
Ok(bytes) => bytes,
Err(e) => {
return Promise::from_ready(Err(Error::Generic(format!(
"could not read contents of file to upload: {e}"
))));
}
};
internal_nip96_upload(seckey, upload_url, media_path, file_bytes)
}
pub fn nostrbuild_nip96_upload(
seckey: [u8; 32],
selected_media: SelectedMedia,
media_path: MediaPath,
) -> Promise<Result<Nip94Event, Error>> {
let (sender, promise) = Promise::new();
std::thread::spawn(move || {
@@ -154,7 +166,7 @@ pub fn nostrbuild_nip96_upload(
}
};
let res = nip96_upload(seckey, upload_url, selected_media).block_and_take();
let res = nip96_upload(seckey, upload_url, media_path).block_and_take();
sender.send(res);
});
promise
@@ -163,21 +175,9 @@ pub fn nostrbuild_nip96_upload(
fn internal_nip96_upload(
seckey: [u8; 32],
upload_url: String,
selected_media: SelectedMedia,
media_path: MediaPath,
file_contents: Vec<u8>,
) -> Promise<Result<Nip94Event, Error>> {
let file_name = selected_media.file_name;
let mime_type = selected_media.media_type.to_mime();
let bytes_res = bytes_from_media(selected_media.from);
let file_contents = match bytes_res {
Ok(bytes) => bytes,
Err(e) => {
return Promise::from_ready(Err(Error::Generic(format!(
"could not read contents of file to upload: {e}"
))));
}
};
let file_hash = sha256_hex(&file_contents);
let nip98_note = create_nip98_note(&seckey, upload_url.to_owned(), file_hash);
@@ -186,13 +186,7 @@ fn internal_nip96_upload(
Err(e) => return Promise::from_ready(Err(Error::Generic(e.to_string()))),
};
let request = create_nip96_request(
&upload_url,
&file_name,
mime_type,
file_contents,
&nip98_base64,
);
let request = create_nip96_request(&upload_url, media_path, file_contents, &nip98_base64);
let (sender, promise) = Promise::new();
@@ -238,10 +232,33 @@ fn find_nip94_ev_in_json(json: String) -> Result<Nip94Event, Error> {
}
}
pub fn bytes_from_media(media: MediaFrom) -> Result<Vec<u8>, notedeck::Error> {
match media {
MediaFrom::PathBuf(full_path) => fetch_binary_from_disk(full_path.clone()),
MediaFrom::Memory(bytes) => Ok(bytes),
#[derive(Debug)]
pub struct MediaPath {
full_path: PathBuf,
file_name: String,
media_type: SupportedMimeType,
}
impl MediaPath {
pub fn new(path: PathBuf) -> Result<Self, Error> {
if let Some(ex) = path.extension().and_then(|f| f.to_str()) {
let media_type = SupportedMimeType::from_extension(ex)?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&format!("file.{ex}"))
.to_owned();
Ok(MediaPath {
full_path: path,
file_name,
media_type,
})
} else {
Err(Error::Generic(format!(
"{path:?} does not have an extension"
)))
}
}
}
@@ -332,7 +349,7 @@ mod tests {
use enostr::FullKeypair;
use crate::media_upload::{
get_upload_url_from_provider, nostrbuild_nip96_upload, SelectedMedia, NOSTR_BUILD_URL,
get_upload_url_from_provider, nostrbuild_nip96_upload, MediaPath, NOSTR_BUILD_URL,
};
use super::internal_nip96_upload;
@@ -351,7 +368,7 @@ mod tests {
fn test_internal_nip96() {
// just a random image to test image upload
let file_path = PathBuf::from_str("../../../assets/damus_rounded_80.png").unwrap();
let selected_media = SelectedMedia::from_path(file_path).unwrap();
let media_path = MediaPath::new(file_path).unwrap();
let img_bytes = include_bytes!("../../../assets/damus_rounded_80.png");
let promise = get_upload_url_from_provider(NOSTR_BUILD_URL());
let kp = FullKeypair::generate();
@@ -361,7 +378,8 @@ mod tests {
let promise = internal_nip96_upload(
kp.secret_key.secret_bytes(),
upload_url.to_string(),
selected_media,
media_path,
img_bytes.to_vec(),
);
let res = promise.block_until_ready();
assert!(res.is_ok())
@@ -377,11 +395,11 @@ mod tests {
let file_path =
fs::canonicalize(PathBuf::from_str("../../assets/damus_rounded_80.png").unwrap())
.unwrap();
let selected_media = SelectedMedia::from_path(file_path).unwrap();
let media_path = MediaPath::new(file_path).unwrap();
let kp = FullKeypair::generate();
println!("Using pubkey: {:?}", kp.pubkey);
let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), selected_media);
let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), media_path);
let out = promise.block_and_take();
assert!(out.is_ok());
+23 -65
View File
@@ -1,5 +1,5 @@
use crate::{
accounts::{render_accounts_route, AccountsAction, AccountsResponse},
accounts::{render_accounts_route, AccountsAction},
app::{get_active_columns_mut, get_decks_mut},
column::ColumnsAction,
deck_state::DeckState,
@@ -8,9 +8,7 @@ use crate::{
options::AppOptions,
profile::{ProfileAction, SaveProfileChanges},
route::{Route, Router, SingletonRouter},
subscriptions::Subscriptions,
timeline::{
kind::ListKind,
route::{render_thread_route, render_timeline_route},
TimelineCache, TimelineKind,
},
@@ -21,7 +19,6 @@ use crate::{
configure_deck::ConfigureDeckView,
edit_deck::{EditDeckResponse, EditDeckView},
note::{custom_zap::CustomZapView, NewPostAction, PostAction, PostType, QuoteRepostView},
onboarding::FollowPackOnboardingView,
profile::EditProfileView,
search::{FocusState, SearchView},
settings::SettingsAction,
@@ -40,7 +37,6 @@ use notedeck::{
get_current_default_msats, tr, ui::is_narrow, Accounts, AppContext, NoteAction, NoteContext,
RelayAction,
};
use notedeck_ui::NoteOptions;
use tracing::error;
/// The result of processing a nav response
@@ -83,36 +79,19 @@ impl SwitchingAction {
timeline_cache: &mut TimelineCache,
decks_cache: &mut DecksCache,
ctx: &mut AppContext<'_>,
subs: &mut Subscriptions,
ui_ctx: &egui::Context,
) -> bool {
match &self {
SwitchingAction::Accounts(account_action) => match account_action {
AccountsAction::Switch(switch_action) => {
{
let txn = Transaction::new(ctx.ndb).expect("txn");
ctx.accounts.select_account(
&switch_action.switch_to,
ctx.ndb,
&txn,
ctx.pool,
ui_ctx,
);
let contacts_sub = ctx.accounts.get_subs().contacts.remote.clone();
// this is cringe but we're gonna get a new sub manager soon...
subs.subs.insert(
contacts_sub,
crate::subscriptions::SubKind::FetchingContactList(TimelineKind::List(
ListKind::Contact(*ctx.accounts.selected_account_pubkey()),
)),
);
}
if switch_action.switching_to_new {
decks_cache.add_deck_default(ctx, timeline_cache, switch_action.switch_to);
}
let txn = Transaction::new(ctx.ndb).expect("txn");
ctx.accounts.select_account(
&switch_action.switch_to,
ctx.ndb,
&txn,
ctx.pool,
ui_ctx,
);
// pop nav after switch
get_active_columns_mut(ctx.i18n, ctx.accounts, decks_cache)
.column_mut(switch_action.source_column)
@@ -488,7 +467,6 @@ fn process_render_nav_action(
&mut app.timeline_cache,
&mut app.decks_cache,
ctx,
&mut app.subscriptions,
ui.ctx(),
) {
return Some(ProcessNavResult::SwitchOccurred);
@@ -585,33 +563,21 @@ fn render_nav_body(
&mut note_context,
&mut app.jobs,
),
Route::Accounts(amr) => 's: {
let Some(action) = render_accounts_route(
Route::Accounts(amr) => {
let mut action = render_accounts_route(
ui,
ctx,
&mut app.jobs,
col,
&mut app.decks_cache,
&mut app.timeline_cache,
&mut app.view_state.login,
&mut app.onboarding,
&mut app.view_state.follow_packs,
*amr,
) else {
break 's None;
};
match action {
AccountsResponse::ViewProfile(pubkey) => {
Some(RenderNavAction::NoteAction(NoteAction::Profile(pubkey)))
}
AccountsResponse::Account(accounts_route_response) => {
let mut action = accounts_route_response.process(ctx, app, col);
let txn = Transaction::new(ctx.ndb).expect("txn");
action.process_action(ctx.unknown_ids, ctx.ndb, &txn);
action
.accounts_action
.map(|f| RenderNavAction::SwitchingAction(SwitchingAction::Accounts(f)))
}
}
);
let txn = Transaction::new(ctx.ndb).expect("txn");
action.process_action(ctx.unknown_ids, ctx.ndb, &txn);
action
.accounts_action
.map(|f| RenderNavAction::SwitchingAction(SwitchingAction::Accounts(f)))
}
Route::Relays => RelayView::new(ctx.pool, &mut app.view_state.id_string_map, ctx.i18n)
.ui(ui)
@@ -625,7 +591,6 @@ fn render_nav_body(
)
.ui(ui)
.map(RenderNavAction::SettingsAction),
Route::Reply(id) => {
let txn = if let Ok(txn) = Transaction::new(ctx.ndb) {
txn
@@ -654,16 +619,13 @@ fn render_nav_body(
let action = {
let draft = app.drafts.reply_mut(note.id());
let mut options = app.note_options;
options.set(NoteOptions::Wide, false);
let response = ui::PostReplyView::new(
&mut note_context,
poster,
draft,
&note,
inner_rect,
options,
app.note_options,
&mut app.jobs,
col,
)
@@ -832,7 +794,7 @@ fn render_nav_body(
return action;
};
if EditProfileView::new(ctx.i18n, state, ctx.img_cache, ctx.clipboard).ui(ui) {
if EditProfileView::new(ctx.i18n, state, ctx.img_cache).ui(ui) {
if let Some(state) = app.view_state.pubkey_to_profile_state.get(kp.pubkey) {
action = Some(RenderNavAction::ProfileAction(ProfileAction::SaveChanges(
SaveProfileChanges::new(kp.to_full(), state.clone()),
@@ -960,7 +922,7 @@ pub fn render_nav(
ctx.ndb,
ctx.img_cache,
get_active_columns_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache),
std::slice::from_ref(route),
&[route.clone()],
col,
ctx.i18n,
)
@@ -1094,9 +1056,6 @@ fn get_scroll_id(
Route::Accounts(accounts_route) => match accounts_route {
crate::accounts::AccountsRoute::Accounts => Some(AccountsView::scroll_id()),
crate::accounts::AccountsRoute::AddAccount => None,
crate::accounts::AccountsRoute::Onboarding => {
Some(FollowPackOnboardingView::scroll_id())
}
},
Route::Reply(note_id) => Some(PostReplyView::scroll_id(col, note_id.bytes())),
Route::Quote(note_id) => Some(QuoteRepostView::scroll_id(col, note_id.bytes())),
@@ -1121,7 +1080,6 @@ fn route_uses_frame(route: &Route) -> bool {
Route::Accounts(accounts_route) => match accounts_route {
crate::accounts::AccountsRoute::Accounts => true,
crate::accounts::AccountsRoute::AddAccount => false,
crate::accounts::AccountsRoute::Onboarding => false,
},
Route::Relays => true,
Route::Timeline(_) => false,
-154
View File
@@ -1,154 +0,0 @@
use std::{cell::RefCell, rc::Rc};
use egui_virtual_list::VirtualList;
use enostr::{Pubkey, RelayPool};
use nostrdb::{Filter, Ndb, NoteKey, Transaction};
use notedeck::{create_nip51_set, filter::default_limit, Nip51SetCache, UnknownIds};
use uuid::Uuid;
use crate::subscriptions::Subscriptions;
#[derive(Debug)]
enum OnboardingState {
AwaitingTrustedPksList(Vec<Filter>),
HaveFollowPacks(Nip51SetCache),
}
/// Manages the onboarding process. Responsible for retriving the kind 30000 list of trusted pubkeys
/// and then retrieving all follow packs from the trusted pks updating when new ones arrive
#[derive(Default)]
pub struct Onboarding {
state: Option<Result<OnboardingState, OnboardingError>>,
pub list: Rc<RefCell<VirtualList>>,
}
impl Onboarding {
pub fn get_follow_packs(&self) -> Option<&Nip51SetCache> {
let Some(Ok(OnboardingState::HaveFollowPacks(packs))) = &self.state else {
return None;
};
Some(packs)
}
pub fn get_follow_packs_mut(&mut self) -> Option<&mut Nip51SetCache> {
let Some(Ok(OnboardingState::HaveFollowPacks(packs))) = &mut self.state else {
return None;
};
Some(packs)
}
pub fn process(
&mut self,
pool: &mut RelayPool,
ndb: &Ndb,
subs: &mut Subscriptions,
unknown_ids: &mut UnknownIds,
) {
match &self.state {
Some(res) => {
let Ok(OnboardingState::AwaitingTrustedPksList(filter)) = res else {
return;
};
let txn = Transaction::new(ndb).expect("txns");
let Ok(res) = ndb.query(&txn, filter, 1) else {
return;
};
if res.is_empty() {
return;
}
let key = res.first().expect("checked empty").note_key;
let new_state = get_trusted_authors(ndb, &txn, key).and_then(|trusted_pks| {
let pks: Vec<&[u8; 32]> = trusted_pks.iter().map(|f| f.bytes()).collect();
Nip51SetCache::new(pool, ndb, &txn, unknown_ids, vec![follow_packs_filter(pks)])
.map(OnboardingState::HaveFollowPacks)
.ok_or(OnboardingError::InvalidNip51Set)
});
self.state = Some(new_state);
}
None => {
let filter = vec![trusted_pks_list_filter()];
let subid = Uuid::new_v4().to_string();
pool.subscribe(subid.clone(), filter.clone());
subs.subs
.insert(subid, crate::subscriptions::SubKind::OneShot);
let new_state = Some(Ok(OnboardingState::AwaitingTrustedPksList(filter)));
self.state = new_state;
}
}
}
// Unsubscribe and clear state
pub fn end_onboarding(&mut self, pool: &mut RelayPool, ndb: &mut Ndb) {
let Some(Ok(OnboardingState::HaveFollowPacks(state))) = &mut self.state else {
self.state = None;
return;
};
let unified = &state.sub;
pool.unsubscribe(unified.remote.clone());
let _ = ndb.unsubscribe(unified.local);
self.state = None;
}
}
#[derive(Debug)]
pub enum OnboardingError {
InvalidNip51Set,
InvalidTrustedPksListKind,
NdbCouldNotFindNote,
}
// author providing the list of trusted follow pack authors
const FOLLOW_PACK_AUTHOR: [u8; 32] = [
0x89, 0x5c, 0x2a, 0x90, 0xa8, 0x60, 0xac, 0x18, 0x43, 0x4a, 0xa6, 0x9e, 0x7b, 0x0d, 0xa8, 0x46,
0x57, 0x21, 0x21, 0x6f, 0xa3, 0x6e, 0x42, 0xc0, 0x22, 0xe3, 0x93, 0x57, 0x9c, 0x48, 0x6c, 0xba,
];
fn trusted_pks_list_filter() -> Filter {
Filter::new()
.kinds([30000])
.limit(1)
.authors(&[FOLLOW_PACK_AUTHOR])
.tags(["trusted-follow-pack-authors"], 'd')
.build()
}
pub fn follow_packs_filter(pks: Vec<&[u8; 32]>) -> Filter {
Filter::new()
.kinds([39089])
.limit(default_limit())
.authors(pks)
.build()
}
/// gets the pubkeys from a kind 30000 follow set
fn get_trusted_authors(
ndb: &Ndb,
txn: &Transaction,
key: NoteKey,
) -> Result<Vec<Pubkey>, OnboardingError> {
let Ok(note) = ndb.get_note_by_key(txn, key) else {
return Result::Err(OnboardingError::NdbCouldNotFindNote);
};
if note.kind() != 30000 {
return Result::Err(OnboardingError::InvalidTrustedPksListKind);
}
let Some(nip51set) = create_nip51_set(note) else {
return Result::Err(OnboardingError::InvalidNip51Set);
};
Ok(nip51set.pks)
}
+4 -16
View File
@@ -22,23 +22,11 @@ pub struct NewPost {
pub mentions: Vec<Pubkey>,
}
fn client_variant() -> &'static str {
#[cfg(target_os = "android")]
{
"Damus Android"
}
#[cfg(not(target_os = "android"))]
{
"Damus Notedeck"
}
}
fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> {
builder
.start_tag()
.tag_str("client")
.tag_str(client_variant())
.tag_str("Damus Notedeck")
}
impl NewPost {
@@ -56,7 +44,7 @@ impl NewPost {
}
}
pub fn to_note(&self, seckey: &[u8; 32]) -> Note<'_> {
pub fn to_note(&self, seckey: &[u8; 32]) -> Note {
let mut content = self.content.clone();
append_urls(&mut content, &self.media);
@@ -77,7 +65,7 @@ impl NewPost {
builder.sign(seckey).build().expect("note should be ok")
}
pub fn to_reply(&self, seckey: &[u8; 32], replying_to: &Note) -> Note<'_> {
pub fn to_reply(&self, seckey: &[u8; 32], replying_to: &Note) -> Note {
let mut content = self.content.clone();
append_urls(&mut content, &self.media);
@@ -157,7 +145,7 @@ impl NewPost {
.expect("expected build to work")
}
pub fn to_quote(&self, seckey: &[u8; 32], quoting: &Note) -> Note<'_> {
pub fn to_quote(&self, seckey: &[u8; 32], quoting: &Note) -> Note {
let mut new_content = format!(
"{}\nnostr:{}",
self.content,
+9 -21
View File
@@ -15,7 +15,7 @@ impl SaveProfileChanges {
pub fn new(kp: FullKeypair, state: ProfileState) -> Self {
Self { kp, state }
}
pub fn to_note(&self) -> Note<'_> {
pub fn to_note(&self) -> Note {
let sec = &self.kp.secret_key.to_secret_bytes();
add_client_tag(NoteBuilder::new())
.kind(0)
@@ -218,30 +218,18 @@ fn send_note_builder(builder: NoteBuilder, ndb: &Ndb, pool: &mut RelayPool, kp:
pool.send(event);
}
pub fn send_new_contact_list(
kp: FilledKeypair,
ndb: &Ndb,
pool: &mut RelayPool,
mut pks_to_follow: Vec<Pubkey>,
) {
if !pks_to_follow.contains(kp.pubkey) {
pks_to_follow.push(*kp.pubkey);
}
let builder = construct_new_contact_list(pks_to_follow);
pub fn send_new_contact_list(kp: FilledKeypair, ndb: &Ndb, pool: &mut RelayPool) {
let builder = construct_new_contact_list(kp.pubkey);
send_note_builder(builder, ndb, pool, kp);
}
fn construct_new_contact_list<'a>(pks: Vec<Pubkey>) -> NoteBuilder<'a> {
let mut builder = NoteBuilder::new()
fn construct_new_contact_list<'a>(pk: &'a Pubkey) -> NoteBuilder<'a> {
NoteBuilder::new()
.content("")
.kind(3)
.options(NoteBuildOptions::default());
for pk in pks {
builder = builder.start_tag().tag_str("p").tag_str(&pk.hex());
}
builder
.options(NoteBuildOptions::default())
.start_tag()
.tag_str("p")
.tag_str(&pk.hex())
}
-5
View File
@@ -278,11 +278,6 @@ impl Route {
"Add Account",
"Column title for adding new account"
)),
AccountsRoute::Onboarding => ColumnTitle::formatted(tr!(
i18n,
"Onboarding",
"Column title for finding users to follow"
)),
},
Route::ComposeNote => ColumnTitle::formatted(tr!(
i18n,
+15 -35
View File
@@ -1,7 +1,7 @@
use crate::{
actionbar::TimelineOpenResult,
error::Error,
timeline::{Timeline, TimelineKind, UnknownPksOwned},
timeline::{Timeline, TimelineKind},
};
use notedeck::{filter, FilterState, NoteCache, NoteRef};
@@ -90,19 +90,17 @@ impl TimelineCache {
ndb: &Ndb,
notes: &[NoteRef],
note_cache: &mut NoteCache,
) -> Option<UnknownPksOwned> {
) {
let mut timeline = if let Some(timeline) = id.clone().into_timeline(txn, ndb) {
timeline
} else {
error!("Error creating timeline from {:?}", &id);
return None;
return;
};
// insert initial notes into timeline
let res = timeline.insert_new(txn, ndb, note_cache, notes);
timeline.insert_new(txn, ndb, note_cache, notes);
self.timelines.insert(id, timeline);
res
}
pub fn insert(&mut self, id: TimelineKind, timeline: Timeline) {
@@ -115,22 +113,19 @@ impl TimelineCache {
}
/// Get and/or update the notes associated with this timeline
fn notes<'a>(
pub fn notes<'a>(
&'a mut self,
ndb: &Ndb,
note_cache: &mut NoteCache,
txn: &Transaction,
id: &TimelineKind,
) -> GetNotesResponse<'a> {
) -> Vitality<'a, Timeline> {
// we can't use the naive hashmap entry API here because lookups
// require a copy, wait until we have a raw entry api. We could
// also use hashbrown?
if self.timelines.contains_key(id) {
return GetNotesResponse {
vitality: Vitality::Stale(self.get_expected_mut(id)),
unknown_pks: None,
};
return Vitality::Stale(self.get_expected_mut(id));
}
let notes = if let FilterState::Ready(filters) = id.filters(txn, ndb) {
@@ -154,12 +149,9 @@ impl TimelineCache {
info!("found NotesHolder with {} notes", notes.len());
}
let unknown_pks = self.insert_new(id.to_owned(), txn, ndb, &notes, note_cache);
self.insert_new(id.to_owned(), txn, ndb, &notes, note_cache);
GetNotesResponse {
vitality: Vitality::Fresh(self.get_expected_mut(id)),
unknown_pks,
}
Vitality::Fresh(self.get_expected_mut(id))
}
/// Open a timeline, this is another way of saying insert a timeline
@@ -174,12 +166,11 @@ impl TimelineCache {
pool: &mut RelayPool,
id: &TimelineKind,
) -> Option<TimelineOpenResult> {
let notes_resp = self.notes(ndb, note_cache, txn, id);
let (mut open_result, timeline) = match notes_resp.vitality {
let (open_result, timeline) = match self.notes(ndb, note_cache, txn, id) {
Vitality::Stale(timeline) => {
// The timeline cache is stale, let's update it
let notes = find_new_notes(
timeline.all_or_any_entries().latest(),
timeline.all_or_any_notes(),
timeline.subscription.get_filter()?.local(),
txn,
ndb,
@@ -216,13 +207,6 @@ impl TimelineCache {
timeline.subscription.increment();
if let Some(unknowns) = notes_resp.unknown_pks {
match &mut open_result {
Some(o) => o.insert_pks(unknowns.pks),
None => open_result = Some(TimelineOpenResult::new_pks(unknowns.pks)),
}
}
open_result
}
@@ -247,22 +231,18 @@ impl TimelineCache {
}
}
pub struct GetNotesResponse<'a> {
vitality: Vitality<'a, Timeline>,
unknown_pks: Option<UnknownPksOwned>,
}
/// Look for new thread notes since our last fetch
fn find_new_notes(
latest: Option<&NoteRef>,
notes: &[NoteRef],
filters: &[Filter],
txn: &Transaction,
ndb: &Ndb,
) -> Vec<NoteRef> {
let Some(last_note) = latest else {
if notes.is_empty() {
return vec![];
};
}
let last_note = notes[0];
let filters = filter::make_filters_since(filters, last_note.created_at + 1);
if let Ok(results) = ndb.query(txn, &filters, 1000) {
+4 -4
View File
@@ -532,7 +532,7 @@ impl TimelineKind {
let contact_filter = contacts_filter(pk.bytes());
let results = ndb
.query(txn, std::slice::from_ref(&contact_filter), 1)
.query(txn, &[contact_filter.clone()], 1)
.expect("contact query failed?");
let kind_fn = TimelineKind::last_per_pubkey;
@@ -625,7 +625,7 @@ impl TimelineKind {
pub fn notifications_filter(pk: &Pubkey) -> Filter {
Filter::new()
.pubkeys([pk.bytes()])
.kinds([1, 7])
.kinds([1])
.limit(default_limit())
.build()
}
@@ -681,7 +681,7 @@ fn contact_filter_state(txn: &Transaction, ndb: &Ndb, pk: &Pubkey) -> FilterStat
let contact_filter = contacts_filter(pk);
let results = ndb
.query(txn, std::slice::from_ref(&contact_filter), 1)
.query(txn, &[contact_filter.clone()], 1)
.expect("contact query failed?");
if results.is_empty() {
@@ -706,7 +706,7 @@ fn last_per_pubkey_filter_state(ndb: &Ndb, pk: &Pubkey) -> FilterState {
let txn = Transaction::new(ndb).expect("txn");
let results = ndb
.query(&txn, std::slice::from_ref(&contact_filter), 1)
.query(&txn, &[contact_filter.clone()], 1)
.expect("contact query failed?");
if results.is_empty() {
+107 -148
View File
@@ -2,7 +2,7 @@ use crate::{
error::Error,
multi_subscriber::TimelineSub,
subscriptions::{self, SubKind, Subscriptions},
timeline::{kind::ListKind, note_units::InsertManyResponse, timeline_units::NotePayload},
timeline::kind::ListKind,
Result,
};
@@ -19,7 +19,6 @@ use enostr::{PoolRelay, Pubkey, RelayPool};
use nostrdb::{Filter, Ndb, Note, NoteKey, Transaction};
use std::{
cell::RefCell,
collections::HashSet,
time::{Duration, UNIX_EPOCH},
};
use std::{rc::Rc, time::SystemTime};
@@ -28,17 +27,37 @@ use tracing::{debug, error, info, warn};
pub mod cache;
pub mod kind;
mod note_units;
pub mod route;
pub mod thread;
mod timeline_units;
mod unit;
pub use cache::TimelineCache;
pub use kind::{ColumnTitle, PubkeySource, ThreadSelection, TimelineKind};
pub use note_units::{InsertionResponse, NoteUnits};
pub use timeline_units::{TimelineUnits, UnknownPks};
pub use unit::{CompositeUnit, NoteUnit, ReactionUnit};
//#[derive(Debug, Hash, Clone, Eq, PartialEq)]
//pub type TimelineId = TimelineKind;
/*
impl TimelineId {
pub fn kind(&self) -> &TimelineKind {
&self.kind
}
pub fn new(id: TimelineKind) -> Self {
TimelineId(id)
}
pub fn profile(pubkey: Pubkey) -> Self {
TimelineId::new(TimelineKind::Profile(PubkeySource::pubkey(pubkey)))
}
}
impl fmt::Display for TimelineId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "TimelineId({})", self.0)
}
}
*/
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub enum ViewFilter {
@@ -84,7 +103,7 @@ impl ViewFilter {
/// be captured by a Filter itself.
#[derive(Default, Debug)]
pub struct TimelineTab {
pub units: TimelineUnits,
pub notes: Vec<NoteRef>,
pub selection: i32,
pub filter: ViewFilter,
pub list: Rc<RefCell<VirtualList>>,
@@ -117,9 +136,10 @@ impl TimelineTab {
list.hide_on_resize(None);
list.over_scan(50.0);
let list = Rc::new(RefCell::new(list));
let notes: Vec<NoteRef> = Vec::with_capacity(cap);
TimelineTab {
units: TimelineUnits::with_capacity(cap),
notes,
selection,
filter,
list,
@@ -127,54 +147,45 @@ impl TimelineTab {
}
}
fn insert<'a>(
&mut self,
payloads: Vec<&'a NotePayload>,
ndb: &Ndb,
txn: &Transaction,
reversed: bool,
) -> Option<UnknownPks<'a>> {
if payloads.is_empty() {
return None;
fn insert(&mut self, new_refs: &[NoteRef], reversed: bool) {
if new_refs.is_empty() {
return;
}
let num_prev_items = self.notes.len();
let (notes, merge_kind) = crate::timeline::merge_sorted_vecs(&self.notes, new_refs);
let num_refs = payloads.len();
self.notes = notes;
let new_items = self.notes.len() - num_prev_items;
let resp = self.units.merge_new_notes(payloads, ndb, txn);
// TODO: technically items could have been added inbetween
if new_items > 0 {
let mut list = self.list.borrow_mut();
let InsertManyResponse::Some {
entries_merged,
merge_kind,
} = resp.insertion_response
else {
return resp.tl_response;
};
let mut list = self.list.borrow_mut();
match merge_kind {
// TODO: update egui_virtual_list to support spliced inserts
MergeKind::Spliced => {
debug!("spliced when inserting {num_refs} new notes, resetting virtual list",);
list.reset();
}
MergeKind::FrontInsert => {
// only run this logic if we're reverse-chronological
// reversed in this case means chronological, since the
// default is reverse-chronological. yeah it's confusing.
if !reversed {
debug!("inserting {num_refs} new notes at start");
list.items_inserted_at_start(entries_merged);
match merge_kind {
// TODO: update egui_virtual_list to support spliced inserts
MergeKind::Spliced => {
debug!(
"spliced when inserting {} new notes, resetting virtual list",
new_refs.len()
);
list.reset();
}
MergeKind::FrontInsert => {
// only run this logic if we're reverse-chronological
// reversed in this case means chronological, since the
// default is reverse-chronological. yeah it's confusing.
if !reversed {
debug!("inserting {} new notes at start", new_refs.len());
list.items_inserted_at_start(new_items);
}
}
}
};
resp.tl_response
}
}
pub fn select_down(&mut self) {
debug!("select_down {}", self.selection + 1);
if self.selection + 1 > self.units.len() as i32 {
if self.selection + 1 > self.notes.len() as i32 {
return;
}
@@ -191,14 +202,6 @@ impl TimelineTab {
}
}
impl<'a> UnknownPks<'a> {
pub fn process(&self, unknown_ids: &mut UnknownIds, ndb: &Ndb, txn: &Transaction) {
for pk in &self.unknown_pks {
unknown_ids.add_pubkey_if_missing(ndb, txn, pk);
}
}
}
/// A column in a deck. Holds navigation state, loaded notes, column kind, etc.
#[derive(Debug)]
pub struct Timeline {
@@ -290,20 +293,15 @@ impl Timeline {
/// Get the note refs for NotesAndReplies. If we only have Notes, then
/// just return that instead
pub fn all_or_any_entries(&self) -> &TimelineUnits {
self.entries(ViewFilter::NotesAndReplies)
.unwrap_or_else(|| {
self.entries(ViewFilter::Notes)
.expect("should have at least notes")
})
pub fn all_or_any_notes(&self) -> &[NoteRef] {
self.notes(ViewFilter::NotesAndReplies).unwrap_or_else(|| {
self.notes(ViewFilter::Notes)
.expect("should have at least notes")
})
}
pub fn entries(&self, view: ViewFilter) -> Option<&TimelineUnits> {
self.view(view).map(|v| &v.units)
}
pub fn latest_note(&self, view: ViewFilter) -> Option<&NoteRef> {
self.view(view).and_then(|v| v.units.latest())
pub fn notes(&self, view: ViewFilter) -> Option<&[NoteRef]> {
self.view(view).map(|v| &*v.notes)
}
pub fn view(&self, view: ViewFilter) -> Option<&TimelineTab> {
@@ -322,7 +320,7 @@ impl Timeline {
ndb: &Ndb,
note_cache: &mut NoteCache,
notes: &[NoteRef],
) -> Option<UnknownPksOwned> {
) {
let filters = {
let views = &self.views;
let filters: Vec<fn(&CachedNote, &Note) -> bool> =
@@ -330,7 +328,6 @@ impl Timeline {
filters
};
let mut unknown_pks = HashSet::new();
for note_ref in notes {
for (view, filter) in filters.iter().enumerate() {
if let Ok(note) = ndb.get_note_by_key(txn, note_ref.key) {
@@ -338,32 +335,11 @@ impl Timeline {
note_cache.cached_note_or_insert_mut(note_ref.key, &note),
&note,
) {
if let Some(resp) = self.views[view]
.units
.merge_new_notes(
vec![&NotePayload {
note,
key: note_ref.key,
}],
ndb,
txn,
)
.tl_response
{
let pks: HashSet<Pubkey> = resp
.unknown_pks
.into_iter()
.map(|r| Pubkey::new(*r))
.collect();
unknown_pks.extend(pks);
}
self.views[view].notes.push(*note_ref)
}
}
}
}
Some(UnknownPksOwned { pks: unknown_pks })
}
/// The main function used for inserting notes into timelines. Handles
@@ -378,7 +354,7 @@ impl Timeline {
note_cache: &mut NoteCache,
reversed: bool,
) -> Result<()> {
let mut payloads: Vec<NotePayload> = Vec::with_capacity(new_note_ids.len());
let mut new_refs: Vec<(Note, NoteRef)> = Vec::with_capacity(new_note_ids.len());
for key in new_note_ids {
let note = if let Ok(note) = ndb.get_note_by_key(txn, *key) {
@@ -395,32 +371,35 @@ impl Timeline {
// into the timeline
UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, &note);
payloads.push(NotePayload { note, key: *key });
let created_at = note.created_at();
new_refs.push((
note,
NoteRef {
key: *key,
created_at,
},
));
}
for view in &mut self.views {
match view.filter {
ViewFilter::NotesAndReplies => {
let res: Vec<&NotePayload<'_>> = payloads.iter().collect();
if let Some(res) = view.insert(res, ndb, txn, reversed) {
res.process(unknown_ids, ndb, txn);
}
let refs: Vec<NoteRef> = new_refs.iter().map(|(_note, nr)| *nr).collect();
view.insert(&refs, reversed);
}
ViewFilter::Notes => {
let mut filtered_payloads = Vec::with_capacity(payloads.len());
for payload in &payloads {
let cached_note =
note_cache.cached_note_or_insert(payload.key, &payload.note);
let mut filtered_refs = Vec::with_capacity(new_refs.len());
for (note, nr) in &new_refs {
let cached_note = note_cache.cached_note_or_insert(nr.key, note);
if ViewFilter::filter_notes(cached_note, &payload.note) {
filtered_payloads.push(payload);
if ViewFilter::filter_notes(cached_note, note) {
filtered_refs.push(*nr);
}
}
if let Some(res) = view.insert(filtered_payloads, ndb, txn, reversed) {
res.process(unknown_ids, ndb, txn);
}
view.insert(&filtered_refs, reversed);
}
}
}
@@ -457,18 +436,6 @@ impl Timeline {
}
}
pub struct UnknownPksOwned {
pub pks: HashSet<Pubkey>,
}
impl UnknownPksOwned {
pub fn process(&self, ndb: &Ndb, txn: &Transaction, unknown_ids: &mut UnknownIds) {
self.pks
.iter()
.for_each(|p| unknown_ids.add_pubkey_if_missing(ndb, txn, p));
}
}
pub enum MergeKind {
FrontInsert,
Spliced,
@@ -525,11 +492,10 @@ pub fn setup_new_timeline(
note_cache: &mut NoteCache,
since_optimize: bool,
accounts: &Accounts,
unknown_ids: &mut UnknownIds,
) {
// if we're ready, setup local subs
if is_timeline_ready(ndb, pool, note_cache, timeline, accounts, unknown_ids) {
if let Err(err) = setup_timeline_nostrdb_sub(ndb, txn, note_cache, timeline, unknown_ids) {
if is_timeline_ready(ndb, pool, note_cache, timeline, accounts) {
if let Err(err) = setup_timeline_nostrdb_sub(ndb, txn, note_cache, timeline) {
error!("setup_new_timeline: {err}");
}
}
@@ -598,7 +564,7 @@ pub fn send_initial_timeline_filter(
filter = filter.limit_mut(lim);
}
let entries = timeline.all_or_any_entries();
let notes = timeline.all_or_any_notes();
// Should we since optimize? Not always. For example
// if we only have a few notes locally. One way to
@@ -606,8 +572,8 @@ pub fn send_initial_timeline_filter(
// and seeing what its limit is. If we have less
// notes than the limit, we might want to backfill
// older notes
if can_since_optimize && filter::should_since_optimize(lim, entries.len()) {
filter = filter::since_optimize_filter(filter, entries.latest());
if can_since_optimize && filter::should_since_optimize(lim, notes.len()) {
filter = filter::since_optimize_filter(filter, notes);
} else {
warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", &timeline.kind);
}
@@ -627,14 +593,18 @@ pub fn send_initial_timeline_filter(
}
// we need some data first
FilterState::NeedsRemote => fetch_contact_list(subs, timeline, accounts),
FilterState::NeedsRemote => fetch_contact_list(subs, relay, timeline, accounts),
}
}
pub fn fetch_contact_list(subs: &mut Subscriptions, timeline: &mut Timeline, accounts: &Accounts) {
if timeline.filter.get_any_ready().is_some() {
return;
}
pub fn fetch_contact_list(
subs: &mut Subscriptions,
relay: &mut PoolRelay,
timeline: &mut Timeline,
accounts: &Accounts,
) {
let sub_kind = SubKind::FetchingContactList(timeline.kind.clone());
let sub = &accounts.get_subs().contacts;
let new_filter_state = match accounts.get_selected_account().data.contacts.get_state() {
ContactState::Unreceived => {
@@ -647,14 +617,10 @@ pub fn fetch_contact_list(subs: &mut Subscriptions, timeline: &mut Timeline, acc
} => FilterState::GotRemote(filter::GotRemoteType::Contact),
};
timeline.filter.set_all_states(new_filter_state);
timeline
.filter
.set_relay_state(relay.url().to_string(), new_filter_state);
let sub = &accounts.get_subs().contacts;
if subs.subs.contains_key(&sub.remote) {
return;
}
let sub_kind = SubKind::FetchingContactList(timeline.kind.clone());
subs.subs.insert(sub.remote.clone(), sub_kind);
}
@@ -663,7 +629,6 @@ fn setup_initial_timeline(
txn: &Transaction,
timeline: &mut Timeline,
note_cache: &mut NoteCache,
unknown_ids: &mut UnknownIds,
filters: &HybridFilter,
) -> Result<()> {
// some timelines are one-shot and a refreshed, like last_per_pubkey algo feed
@@ -689,9 +654,7 @@ fn setup_initial_timeline(
.map(NoteRef::from_query_result)
.collect();
if let Some(pks) = timeline.insert_new(txn, ndb, note_cache, &notes) {
pks.process(ndb, txn, unknown_ids);
}
timeline.insert_new(txn, ndb, note_cache, &notes);
Ok(())
}
@@ -700,11 +663,10 @@ pub fn setup_initial_nostrdb_subs(
ndb: &Ndb,
note_cache: &mut NoteCache,
timeline_cache: &mut TimelineCache,
unknown_ids: &mut UnknownIds,
) -> Result<()> {
for (_kind, timeline) in timeline_cache {
let txn = Transaction::new(ndb).expect("txn");
if let Err(err) = setup_timeline_nostrdb_sub(ndb, &txn, note_cache, timeline, unknown_ids) {
if let Err(err) = setup_timeline_nostrdb_sub(ndb, &txn, note_cache, timeline) {
error!("setup_initial_nostrdb_subs: {err}");
}
}
@@ -717,7 +679,6 @@ fn setup_timeline_nostrdb_sub(
txn: &Transaction,
note_cache: &mut NoteCache,
timeline: &mut Timeline,
unknown_ids: &mut UnknownIds,
) -> Result<()> {
let filter_state = timeline
.filter
@@ -725,7 +686,7 @@ fn setup_timeline_nostrdb_sub(
.ok_or(Error::App(notedeck::Error::empty_contact_list()))?
.to_owned();
setup_initial_timeline(ndb, txn, timeline, note_cache, unknown_ids, &filter_state)?;
setup_initial_timeline(ndb, txn, timeline, note_cache, &filter_state)?;
Ok(())
}
@@ -740,7 +701,6 @@ pub fn is_timeline_ready(
note_cache: &mut NoteCache,
timeline: &mut Timeline,
accounts: &Accounts,
unknown_ids: &mut UnknownIds,
) -> bool {
// TODO: we should debounce the filter states a bit to make sure we have
// seen all of the different contact lists from each relay
@@ -814,8 +774,7 @@ pub fn is_timeline_ready(
// queries and setup the local subscription
info!("Found contact list! Setting up local and remote contact list query");
let txn = Transaction::new(ndb).expect("txn");
setup_initial_timeline(ndb, &txn, timeline, note_cache, unknown_ids, &filter)
.expect("setup init");
setup_initial_timeline(ndb, &txn, timeline, note_cache, &filter).expect("setup init");
timeline
.filter
.set_relay_state(relay_id, FilterState::ready_hybrid(filter.clone()));
@@ -1,559 +0,0 @@
use std::collections::{HashMap, HashSet};
use nostrdb::NoteKey;
use notedeck::NoteRef;
use crate::timeline::{
unit::{CompositeUnit, NoteUnit, NoteUnitFragment},
MergeKind,
};
type StorageIndex = usize;
/// Provides efficient access to `NoteUnit`s
/// Useful for threads and timelines
/// when reversed=false, sorts from newest to oldest
#[derive(Debug, Default)]
pub struct NoteUnits {
reversed: bool,
storage: Vec<NoteUnit>,
lookup: HashMap<NoteKey, StorageIndex>, // `NoteKey` to index in `NoteUnits::storage`
order: Vec<StorageIndex>, // the sorted order of the `NoteUnit`s in `NoteUnits::storage`
}
impl NoteUnits {
pub fn values(&self) -> Values<'_> {
Values {
set: self,
front: 0,
back: self.order.len(),
}
}
pub fn contains_key(&self, k: &NoteKey) -> bool {
self.lookup.contains_key(k)
}
pub fn new_with_cap(cap: usize, reversed: bool) -> Self {
Self {
reversed,
storage: Vec::with_capacity(cap),
lookup: HashMap::with_capacity(cap),
order: Vec::with_capacity(cap),
}
}
pub fn len(&self) -> usize {
self.storage.len()
}
pub fn is_empty(&self) -> bool {
self.storage.is_empty()
}
/// Get the kth index from 0..Self::len
pub fn kth(&self, k: usize) -> Option<&NoteUnit> {
if k >= self.order.len() {
return None;
}
let idx = if self.reversed {
self.order[self.order.len() - 1 - k]
} else {
self.order[k]
};
Some(&self.storage[idx])
}
/// Core bulk insert for already-built `NoteUnit`s
/// Merges new `NoteUnit`s into `Self::storage`
/// Updates `Self::order`
fn merge_many_internal(
&mut self,
mut units: Vec<NoteUnit>,
touched_indices: &[usize],
) -> InsertManyResponse {
units.retain(|e| !self.lookup.contains_key(&e.key()));
if units.is_empty() && touched_indices.is_empty() {
return InsertManyResponse::Zero;
}
let mut touched = Vec::new();
if !touched_indices.is_empty() {
touched = touched_indices.to_vec();
touched.sort_unstable(); // sort for later reinsertion
touched.dedup();
self.order.retain(|i| touched.binary_search(i).is_err()); // temporarily remove touched from Self::order
}
units.sort_unstable();
units.dedup_by_key(|u| u.key());
let base = self.storage.len();
let mut new_order = Vec::with_capacity(units.len());
self.storage.reserve(units.len());
for (i, unit) in units.into_iter().enumerate() {
let idx = base + i;
let key = unit.key();
self.storage.push(unit);
self.lookup.insert(key, idx);
new_order.push(idx);
}
let inserted_new = new_order.len();
let front_insertion = inserted_new > 0
&& if self.order.is_empty() || new_order.is_empty() {
true
} else if !self.reversed {
let first_new = *new_order.first().unwrap();
let last_old = *self.order.last().unwrap();
self.storage[first_new] >= self.storage[last_old]
} else {
let last_new = *new_order.last().unwrap();
let first_old = *self.order.first().unwrap();
self.storage[last_new] <= self.storage[first_old]
};
let mut merged = Vec::with_capacity(self.order.len() + new_order.len());
let (mut i, mut j) = (0, 0);
while i < self.order.len() && j < new_order.len() {
let index_left = self.order[i];
let index_right = new_order[j];
let left_item = &self.storage[index_left];
let right_item = &self.storage[index_right];
if left_item <= right_item {
// left_item is newer than right_item
merged.push(index_left);
i += 1;
} else {
merged.push(index_right);
j += 1;
}
}
merged.extend_from_slice(&self.order[i..]);
merged.extend_from_slice(&new_order[j..]);
// reinsert touched
for touched_index in touched {
let pos = merged
.binary_search_by(|&i2| self.storage[i2].cmp(&self.storage[touched_index]))
.unwrap_or_else(|p| p);
merged.insert(pos, touched_index);
}
self.order = merged;
if inserted_new == 0 {
InsertManyResponse::Zero
} else if front_insertion {
InsertManyResponse::Some {
entries_merged: inserted_new,
merge_kind: MergeKind::FrontInsert,
}
} else {
InsertManyResponse::Some {
entries_merged: inserted_new,
merge_kind: MergeKind::Spliced,
}
}
}
/// Merges `NoteUnitFragment`s
/// `NoteUnitFragment::Single` is added normally
/// if `NoteUnitFragment::Composite` exists already, it will fold the fragment into the `CompositeUnit`
/// otherwise, it will generate the `NoteUnit::CompositeUnit` from the `NoteUnitFragment::Composite`
pub fn merge_fragments(&mut self, frags: Vec<NoteUnitFragment>) -> InsertManyResponse {
let mut to_build: HashMap<NoteKey, CompositeUnit> = HashMap::new(); // new composites by key
let mut singles_to_build: Vec<NoteRef> = Vec::new();
let mut singles_seen: HashSet<NoteKey> = HashSet::new();
let mut touched = Vec::new();
for frag in frags {
match frag {
NoteUnitFragment::Single(note_ref) => {
let key = note_ref.key;
if self.lookup.contains_key(&key) {
continue;
}
if singles_seen.insert(key) {
singles_to_build.push(note_ref);
}
}
NoteUnitFragment::Composite(c_frag) => {
let key = c_frag.get_underlying_noteref().key;
if let Some(&storage_idx) = self.lookup.get(&key) {
if let Some(NoteUnit::Composite(c_unit)) = self.storage.get_mut(storage_idx)
{
if c_frag.get_latest_ref() < c_unit.get_latest_ref() {
touched.push(storage_idx);
}
c_frag.fold_into(c_unit);
continue;
}
}
// aggregate for new composite
use std::collections::hash_map::Entry;
match to_build.entry(key) {
Entry::Occupied(mut o) => {
c_frag.fold_into(o.get_mut());
}
Entry::Vacant(v) => {
v.insert(c_frag.into());
}
}
}
}
}
let mut items: Vec<NoteUnit> = Vec::with_capacity(singles_to_build.len() + to_build.len());
items.extend(singles_to_build.into_iter().map(NoteUnit::Single));
items.extend(to_build.into_values().map(NoteUnit::Composite));
self.merge_many_internal(items, &touched)
}
/// Convienience method to merge a single note
pub fn merge_single_unit(&mut self, note_ref: NoteRef) -> InsertionResponse {
match self.merge_many_internal(vec![NoteUnit::Single(note_ref)], &[]) {
InsertManyResponse::Zero => InsertionResponse::AlreadyExists,
InsertManyResponse::Some {
entries_merged: _,
merge_kind,
} => InsertionResponse::Merged(merge_kind),
}
}
pub fn latest_ref(&self) -> Option<&NoteRef> {
if self.reversed {
self.order.last().map(|&i| &self.storage[i])
} else {
self.order.first().map(|&i| &self.storage[i])
}
.map(NoteUnit::get_latest_ref)
}
}
pub enum InsertManyResponse {
Zero,
Some {
entries_merged: usize,
merge_kind: MergeKind,
},
}
pub struct Values<'a> {
set: &'a NoteUnits,
front: usize,
back: usize,
}
impl<'a> Iterator for Values<'a> {
type Item = &'a NoteUnit;
fn next(&mut self) -> Option<Self::Item> {
if self.front >= self.back {
return None;
}
let idx = if !self.set.reversed {
let i = self.front;
self.front += 1;
self.set.order[i]
} else {
self.back -= 1;
self.set.order[self.back]
};
Some(&self.set.storage[idx])
}
}
impl<'a> DoubleEndedIterator for Values<'a> {
fn next_back(&mut self) -> Option<Self::Item> {
if self.front >= self.back {
return None;
}
let idx = if !self.set.reversed {
self.back -= 1;
self.set.order[self.back]
} else {
let i = self.front;
self.front += 1;
self.set.order[i]
};
Some(&self.set.storage[idx])
}
}
pub enum InsertionResponse {
AlreadyExists,
Merged(MergeKind),
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashSet};
use egui::ahash::HashMap;
use enostr::Pubkey;
use nostrdb::NoteKey;
use notedeck::NoteRef;
use pretty_assertions::assert_eq;
use uuid::Uuid;
use crate::timeline::{
unit::{
CompositeFragment, CompositeUnit, NoteUnit, NoteUnitFragment, Reaction,
ReactionFragment, ReactionUnit,
},
NoteUnits,
};
#[derive(Default)]
struct UnitBuilder {
counter: u64,
frags: HashMap<String, NoteUnitFragment>,
units: NoteUnits,
}
impl UnitBuilder {
fn counter(&mut self) -> u64 {
let res = self.counter;
self.counter += 1;
res
}
fn random_sender(&mut self) -> Pubkey {
let mut out = [0u8; 32];
out[..8].copy_from_slice(&self.counter().to_le_bytes());
Pubkey::new(out)
}
fn build_fragment(&mut self, reacted_to: NoteRef) -> NoteUnitFragment {
NoteUnitFragment::Composite(CompositeFragment::Reaction(ReactionFragment {
noteref_reacted_to: reacted_to,
reaction_note_ref: NoteRef {
key: NoteKey::new(self.counter()),
created_at: self.counter(),
},
reaction: Reaction {
reaction: "+".to_owned(),
sender: self.random_sender(),
},
}))
}
fn fragment(&mut self, reacted_to: NoteRef) -> String {
let frag = self.build_fragment(reacted_to);
let id = Uuid::new_v4().to_string();
self.frags.insert(id.clone(), frag.clone());
self.units.merge_fragments(vec![frag]);
id
}
fn fragments_pair(&mut self, reacted_to: NoteRef) -> (String, String) {
let frag1 = self.build_fragment(reacted_to);
let frag2 = self.build_fragment(reacted_to);
self.units
.merge_fragments(vec![frag1.clone(), frag2.clone()]);
let id1 = Uuid::new_v4().to_string();
self.frags.insert(id1.clone(), frag1);
let id2 = Uuid::new_v4().to_string();
self.frags.insert(id2.clone(), frag2);
(id1, id2)
}
fn generate_reaction_note(&mut self) -> NoteRef {
NoteRef {
key: NoteKey::new(self.counter()),
created_at: self.counter(),
}
}
fn insert_note(&mut self) -> String {
let note_ref = NoteRef {
key: NoteKey::new(self.counter()),
created_at: self.counter(),
};
let id = Uuid::new_v4().to_string();
self.frags
.insert(id.clone(), NoteUnitFragment::Single(note_ref.clone()));
self.units.merge_single_unit(note_ref);
id
}
fn expected_reactions(&mut self, ids: Vec<&String>) -> NoteUnit {
let mut reactions = BTreeMap::new();
let mut reaction_id = None;
let mut senders = HashSet::new();
for id in ids {
let NoteUnitFragment::Composite(CompositeFragment::Reaction(reac)) =
self.frags.get(id).unwrap()
else {
panic!("got something other than reaction");
};
if let Some(prev_reac_id) = reaction_id {
if prev_reac_id != reac.noteref_reacted_to {
panic!("internal error");
}
}
reaction_id = Some(reac.noteref_reacted_to);
reactions.insert(reac.reaction_note_ref, reac.reaction.clone());
senders.insert(reac.reaction.sender);
}
NoteUnit::Composite(CompositeUnit::Reaction(ReactionUnit {
note_reacted_to: reaction_id.unwrap(),
reactions,
senders: senders,
}))
}
fn expected_single(&mut self, id: &String) -> NoteUnit {
let Some(NoteUnitFragment::Single(note_ref)) = self.frags.get(id) else {
panic!("fail");
};
NoteUnit::Single(*note_ref)
}
fn asserted_at(&self, index: usize) -> NoteUnit {
self.units.kth(index).unwrap().clone()
}
fn aeq(&mut self, units_kth: usize, expect: Expect) {
assert_eq!(
self.asserted_at(units_kth),
match expect {
Expect::Single(id) => self.expected_single(id),
Expect::Reaction(items) => self.expected_reactions(items),
}
);
}
}
enum Expect<'a> {
Single(&'a String),
Reaction(Vec<&'a String>),
}
#[test]
fn test() {
let mut builder = UnitBuilder::default();
let reaction_note = builder.generate_reaction_note();
let single0 = builder.insert_note();
builder.aeq(0, Expect::Single(&single0));
let reac1 = builder.fragment(reaction_note);
builder.aeq(0, Expect::Reaction(vec![&reac1]));
builder.aeq(1, Expect::Single(&single0));
let single1 = builder.insert_note();
builder.aeq(0, Expect::Single(&single1));
builder.aeq(1, Expect::Reaction(vec![&reac1]));
builder.aeq(2, Expect::Single(&single0));
let reac2 = builder.fragment(reaction_note);
builder.aeq(0, Expect::Reaction(vec![&reac2, &reac1]));
builder.aeq(1, Expect::Single(&single1));
builder.aeq(2, Expect::Single(&single0));
let single2 = builder.insert_note();
builder.aeq(0, Expect::Single(&single2));
builder.aeq(1, Expect::Reaction(vec![&reac2, &reac1]));
builder.aeq(2, Expect::Single(&single1));
builder.aeq(3, Expect::Single(&single0));
let reac3 = builder.fragment(reaction_note);
builder.aeq(0, Expect::Reaction(vec![&reac1, &reac2, &reac3]));
builder.aeq(1, Expect::Single(&single2));
builder.aeq(2, Expect::Single(&single1));
builder.aeq(3, Expect::Single(&single0));
}
#[test]
fn test2() {
let mut builder = UnitBuilder::default();
let reaction_note1 = builder.generate_reaction_note();
let reaction_note2 = builder.generate_reaction_note();
let single0 = builder.insert_note();
builder.aeq(0, Expect::Single(&single0));
let reac1_1 = builder.fragment(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac1_1]));
builder.aeq(1, Expect::Single(&single0));
let reac2_1 = builder.fragment(reaction_note2);
builder.aeq(0, Expect::Reaction(vec![&reac2_1]));
builder.aeq(1, Expect::Reaction(vec![&reac1_1]));
builder.aeq(2, Expect::Single(&single0));
let single1 = builder.insert_note();
builder.aeq(0, Expect::Single(&single1));
builder.aeq(1, Expect::Reaction(vec![&reac2_1]));
builder.aeq(2, Expect::Reaction(vec![&reac1_1]));
builder.aeq(3, Expect::Single(&single0));
let reac1_2 = builder.fragment(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac1_2, &reac1_1]));
builder.aeq(1, Expect::Single(&single1));
builder.aeq(2, Expect::Reaction(vec![&reac2_1]));
builder.aeq(3, Expect::Single(&single0));
let single2 = builder.insert_note();
builder.aeq(0, Expect::Single(&single2));
builder.aeq(1, Expect::Reaction(vec![&reac1_2, &reac1_1]));
builder.aeq(2, Expect::Single(&single1));
builder.aeq(3, Expect::Reaction(vec![&reac2_1]));
builder.aeq(4, Expect::Single(&single0));
let reac1_3 = builder.fragment(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac1_2, &reac1_1, &reac1_3]));
builder.aeq(1, Expect::Single(&single2));
builder.aeq(2, Expect::Single(&single1));
builder.aeq(3, Expect::Reaction(vec![&reac2_1]));
builder.aeq(4, Expect::Single(&single0));
let reac2_2 = builder.fragment(reaction_note2);
builder.aeq(0, Expect::Reaction(vec![&reac2_1, &reac2_2]));
builder.aeq(1, Expect::Reaction(vec![&reac1_2, &reac1_1, &reac1_3]));
builder.aeq(2, Expect::Single(&single2));
builder.aeq(3, Expect::Single(&single1));
builder.aeq(4, Expect::Single(&single0));
}
#[test]
fn test3() {
let mut builder = UnitBuilder::default();
let reaction_note1 = builder.generate_reaction_note();
let single1 = builder.insert_note();
builder.aeq(0, Expect::Single(&single1));
let reac0 = builder.fragment(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac0]));
builder.aeq(1, Expect::Single(&single1));
let (reac1, reac2) = builder.fragments_pair(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac0, &reac1, &reac2]));
builder.aeq(1, Expect::Single(&single1));
let single2 = builder.insert_note();
builder.aeq(0, Expect::Single(&single2));
builder.aeq(1, Expect::Reaction(vec![&reac0, &reac1, &reac2]));
builder.aeq(2, Expect::Single(&single1));
}
}
+105 -47
View File
@@ -1,3 +1,8 @@
use std::{
collections::{BTreeSet, HashSet},
hash::Hash,
};
use egui_nav::ReturnType;
use egui_virtual_list::VirtualList;
use enostr::{NoteId, RelayPool};
@@ -8,17 +13,16 @@ use notedeck::{NoteCache, NoteRef, UnknownIds};
use crate::{
actionbar::{process_thread_notes, NewThreadNotes},
multi_subscriber::ThreadSubs,
timeline::{note_units::NoteUnits, unit::NoteUnit, InsertionResponse},
timeline::MergeKind,
};
use super::ThreadSelection;
pub struct ThreadNode {
pub replies: SingleNoteUnits,
pub replies: HybridSet<NoteRef>,
pub prev: ParentState,
pub have_all_ancestors: bool,
pub list: VirtualList,
pub set_scroll_offset: Option<f32>,
}
#[derive(Clone)]
@@ -28,20 +32,107 @@ pub enum ParentState {
Parent(NoteId),
}
impl ThreadNode {
pub fn new(parent: ParentState) -> Self {
/// Affords:
/// - O(1) contains
/// - O(log n) sorted insertion
pub struct HybridSet<T> {
reversed: bool,
lookup: HashSet<T>, // fast deduplication
ordered: BTreeSet<T>, // sorted iteration
}
impl<T> Default for HybridSet<T> {
fn default() -> Self {
Self {
replies: SingleNoteUnits::new(true),
prev: parent,
have_all_ancestors: false,
list: VirtualList::new(),
set_scroll_offset: None,
reversed: Default::default(),
lookup: Default::default(),
ordered: Default::default(),
}
}
}
pub enum InsertionResponse {
AlreadyExists,
Merged(MergeKind),
}
impl<T: Copy + Ord + Eq + Hash> HybridSet<T> {
pub fn insert(&mut self, val: T) -> InsertionResponse {
if !self.lookup.insert(val) {
return InsertionResponse::AlreadyExists;
}
let front_insertion = match self.ordered.iter().next() {
Some(first) => (val >= *first) == self.reversed,
None => true,
};
self.ordered.insert(val); // O(log n)
InsertionResponse::Merged(if front_insertion {
MergeKind::FrontInsert
} else {
MergeKind::Spliced
})
}
}
impl<T: Eq + Hash> HybridSet<T> {
pub fn contains(&self, val: &T) -> bool {
self.lookup.contains(val) // O(1)
}
}
impl<T> HybridSet<T> {
pub fn iter(&self) -> HybridIter<'_, T> {
HybridIter {
inner: self.ordered.iter(),
reversed: self.reversed,
}
}
pub fn with_offset(mut self, offset: f32) -> Self {
self.set_scroll_offset = Some(offset);
self
pub fn new(reversed: bool) -> Self {
Self {
reversed,
..Default::default()
}
}
}
impl<'a, T> IntoIterator for &'a HybridSet<T> {
type Item = &'a T;
type IntoIter = HybridIter<'a, T>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
pub struct HybridIter<'a, T> {
inner: std::collections::btree_set::Iter<'a, T>,
reversed: bool,
}
impl<'a, T> Iterator for HybridIter<'a, T> {
type Item = &'a T;
fn next(&mut self) -> Option<Self::Item> {
if self.reversed {
self.inner.next_back()
} else {
self.inner.next()
}
}
}
impl ThreadNode {
pub fn new(parent: ParentState) -> Self {
Self {
replies: HybridSet::new(true),
prev: parent,
have_all_ancestors: false,
list: VirtualList::new(),
}
}
}
@@ -56,7 +147,6 @@ pub struct Threads {
impl Threads {
/// Opening a thread.
/// Similar to [[super::cache::TimelineCache::open]]
#[allow(clippy::too_many_arguments)]
pub fn open(
&mut self,
ndb: &mut Ndb,
@@ -65,7 +155,6 @@ impl Threads {
thread: &ThreadSelection,
new_scope: bool,
col: usize,
scroll_offset: f32,
) -> Option<NewThreadNotes> {
tracing::info!("Opening thread: {:?}", thread);
let local_sub_filter = if let Some(selected) = &thread.selected_note {
@@ -95,7 +184,7 @@ impl Threads {
RawEntryMut::Vacant(entry) => {
let id = NoteId::new(*selected_note_id);
let node = ThreadNode::new(ParentState::Unknown).with_offset(scroll_offset);
let node = ThreadNode::new(ParentState::Unknown);
entry.insert(id, node);
&local_sub_filter
@@ -389,34 +478,3 @@ impl NoteSeenFlags {
self.flags.contains_key(&note_id)
}
}
#[derive(Default)]
pub struct SingleNoteUnits {
units: NoteUnits,
}
impl SingleNoteUnits {
pub fn new(reversed: bool) -> Self {
Self {
units: NoteUnits::new_with_cap(0, reversed),
}
}
pub fn insert(&mut self, note_ref: NoteRef) -> InsertionResponse {
self.units.merge_single_unit(note_ref)
}
pub fn values(&self) -> impl Iterator<Item = &NoteRef> {
self.units.values().filter_map(|entry| {
if let NoteUnit::Single(note_ref) = entry {
Some(note_ref)
} else {
None
}
})
}
pub fn contains_key(&self, k: &NoteKey) -> bool {
self.units.contains_key(k)
}
}
@@ -1,178 +0,0 @@
use std::collections::HashSet;
use enostr::Pubkey;
use nostrdb::{Ndb, Note, NoteKey, Transaction};
use notedeck::NoteRef;
use crate::timeline::{
note_units::{InsertManyResponse, NoteUnits},
unit::{CompositeFragment, NoteUnit, NoteUnitFragment, Reaction, ReactionFragment},
};
#[derive(Debug, Default)]
pub struct TimelineUnits {
pub units: NoteUnits,
}
impl TimelineUnits {
pub fn with_capacity(cap: usize) -> Self {
Self {
units: NoteUnits::new_with_cap(cap, false),
}
}
pub fn from_refs_single(refs: Vec<NoteRef>) -> Self {
let mut entries = TimelineUnits::default();
refs.into_iter().for_each(|r| entries.merge_single_note(r));
entries
}
pub fn len(&self) -> usize {
self.units.len()
}
pub fn is_empty(&self) -> bool {
self.units.len() == 0
}
/// returns number of new entries merged
pub fn merge_new_notes<'a>(
&mut self,
payloads: Vec<&'a NotePayload>,
ndb: &Ndb,
txn: &Transaction,
) -> MergeResponse<'a> {
let mut unknown_pks = HashSet::with_capacity(payloads.len());
let new_fragments = payloads
.into_iter()
.filter_map(|p| to_fragment(p, ndb, txn))
.map(|f| {
if let Some(pk) = f.unknown_pk {
unknown_pks.insert(pk);
}
f.fragment
})
.collect();
let tl_response = if unknown_pks.is_empty() {
None
} else {
Some(UnknownPks { unknown_pks })
};
MergeResponse {
insertion_response: self.units.merge_fragments(new_fragments),
tl_response,
}
}
pub fn latest(&self) -> Option<&NoteRef> {
self.units.latest_ref()
}
pub fn merge_single_note(&mut self, note_ref: NoteRef) {
self.units.merge_single_unit(note_ref);
}
/// Used in the view
pub fn get(&self, index: usize) -> Option<&NoteUnit> {
self.units.kth(index)
}
}
pub struct MergeResponse<'a> {
pub insertion_response: InsertManyResponse,
pub tl_response: Option<UnknownPks<'a>>,
}
pub struct UnknownPks<'a> {
pub(crate) unknown_pks: HashSet<&'a [u8; 32]>,
}
pub struct NoteUnitFragmentResponse<'a> {
pub fragment: NoteUnitFragment,
pub unknown_pk: Option<&'a [u8; 32]>,
}
pub struct NotePayload<'a> {
pub note: Note<'a>,
pub key: NoteKey,
}
fn to_fragment<'a>(
payload: &'a NotePayload,
ndb: &Ndb,
txn: &Transaction,
) -> Option<NoteUnitFragmentResponse<'a>> {
match payload.note.kind() {
1 => Some(NoteUnitFragmentResponse {
fragment: NoteUnitFragment::Single(NoteRef {
key: payload.key,
created_at: payload.note.created_at(),
}),
unknown_pk: None,
}),
7 => to_reaction(payload, ndb, txn).map(|r| NoteUnitFragmentResponse {
fragment: NoteUnitFragment::Composite(CompositeFragment::Reaction(r.fragment)),
unknown_pk: Some(r.pk),
}),
_ => None,
}
}
fn to_reaction<'a>(
payload: &'a NotePayload,
ndb: &Ndb,
txn: &Transaction,
) -> Option<ReactionResponse<'a>> {
let reaction = payload.note.content();
let mut note_reacted_to = None;
for tag in payload.note.tags() {
if tag.count() < 2 {
continue;
}
let Some("e") = tag.get_str(0) else {
continue;
};
let Some(react_to_id) = tag.get_id(1) else {
continue;
};
note_reacted_to = Some(react_to_id);
}
let reacted_to_noteid = note_reacted_to?;
let reaction_note_ref = NoteRef {
key: payload.key,
created_at: payload.note.created_at(),
};
let reacted_to_note = ndb.get_note_by_id(txn, reacted_to_noteid).ok()?;
let noteref_reacted_to = NoteRef {
key: reacted_to_note.key()?,
created_at: reacted_to_note.created_at(),
};
Some(ReactionResponse {
fragment: ReactionFragment {
noteref_reacted_to,
reaction_note_ref,
reaction: Reaction {
reaction: reaction.to_string(),
sender: Pubkey::new(*payload.note.pubkey()),
},
},
pk: payload.note.pubkey(),
})
}
pub struct ReactionResponse<'a> {
fragment: ReactionFragment,
pk: &'a [u8; 32],
}
@@ -1,205 +0,0 @@
use std::collections::{BTreeMap, HashSet};
use enostr::Pubkey;
use nostrdb::NoteKey;
use notedeck::NoteRef;
/// A `NoteUnit` represents a cohesive piece of data derived from notes
#[derive(Debug, Clone)]
pub enum NoteUnit {
Single(NoteRef), // A single note
Composite(CompositeUnit),
}
impl NoteUnit {
pub fn key(&self) -> NoteKey {
match self {
NoteUnit::Single(note_ref) => note_ref.key,
NoteUnit::Composite(clustered_entry) => clustered_entry.key(),
}
}
pub fn get_underlying_noteref(&self) -> &NoteRef {
match self {
NoteUnit::Single(note_ref) => note_ref,
NoteUnit::Composite(clustered) => match clustered {
CompositeUnit::Reaction(reaction_entry) => &reaction_entry.note_reacted_to,
},
}
}
pub fn get_latest_ref(&self) -> &NoteRef {
match self {
NoteUnit::Single(note_ref) => note_ref,
NoteUnit::Composite(composite_unit) => composite_unit.get_latest_ref(),
}
}
}
impl Ord for NoteUnit {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.get_latest_ref().cmp(other.get_latest_ref())
}
}
impl PartialEq for NoteUnit {
fn eq(&self, other: &Self) -> bool {
self.get_latest_ref() == other.get_latest_ref()
}
}
impl Eq for NoteUnit {}
impl PartialOrd for NoteUnit {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
/// Combines potentially many notes into one cohesive piece of data
#[derive(Debug, Clone)]
pub enum CompositeUnit {
Reaction(ReactionUnit),
}
impl CompositeUnit {
pub fn get_latest_ref(&self) -> &NoteRef {
match self {
CompositeUnit::Reaction(reaction_unit) => reaction_unit.get_latest_ref(),
}
}
}
impl PartialEq for CompositeUnit {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Reaction(l0), Self::Reaction(r0)) => l0 == r0,
}
}
}
impl CompositeUnit {
pub fn key(&self) -> NoteKey {
match self {
CompositeUnit::Reaction(reaction_entry) => reaction_entry.note_reacted_to.key,
}
}
}
impl From<CompositeFragment> for CompositeUnit {
fn from(value: CompositeFragment) -> Self {
match value {
CompositeFragment::Reaction(reaction_fragment) => {
CompositeUnit::Reaction(reaction_fragment.into())
}
}
}
}
/// Represents all the reactions to a specific note `ReactionUnit::note_reacted_to`
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct ReactionUnit {
pub note_reacted_to: NoteRef, // NOTE: this should not be modified after it's created
pub reactions: BTreeMap<NoteRef, Reaction>,
pub senders: HashSet<Pubkey>, // useful for making sure the same user can't add more than one reaction to a note
}
impl ReactionUnit {
pub fn get_latest_ref(&self) -> &NoteRef {
self.reactions
.first_key_value()
.map(|(r, _)| r)
.unwrap_or(&self.note_reacted_to)
}
}
impl From<ReactionFragment> for ReactionUnit {
fn from(frag: ReactionFragment) -> Self {
let mut senders = HashSet::new();
senders.insert(frag.reaction.sender);
let mut reactions = BTreeMap::new();
reactions.insert(frag.reaction_note_ref, frag.reaction);
Self {
note_reacted_to: frag.noteref_reacted_to,
reactions,
senders,
}
}
}
#[derive(Clone)]
pub enum NoteUnitFragment {
Single(NoteRef),
Composite(CompositeFragment),
}
#[derive(Debug, Clone)]
pub enum CompositeFragment {
Reaction(ReactionFragment),
}
impl CompositeFragment {
pub fn fold_into(self, unit: &mut CompositeUnit) {
match self {
CompositeFragment::Reaction(reaction_fragment) => reaction_fragment.fold_into(unit),
}
}
pub fn key(&self) -> NoteKey {
match self {
CompositeFragment::Reaction(reaction_fragment) => {
reaction_fragment.reaction_note_ref.key
}
}
}
pub fn get_underlying_noteref(&self) -> &NoteRef {
match self {
CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.noteref_reacted_to,
}
}
pub fn get_latest_ref(&self) -> &NoteRef {
match self {
CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.reaction_note_ref,
}
}
}
/// A singluar reaction to a note
#[derive(Debug, Clone)]
pub struct ReactionFragment {
pub noteref_reacted_to: NoteRef,
pub reaction_note_ref: NoteRef,
pub reaction: Reaction,
}
impl ReactionFragment {
/// Add all the contents of Self into `CompositeUnit`
pub fn fold_into(self, unit: &mut CompositeUnit) {
match unit {
CompositeUnit::Reaction(reaction_unit) => {
if self.noteref_reacted_to != reaction_unit.note_reacted_to {
return;
}
if reaction_unit.senders.contains(&self.reaction.sender) {
return;
}
reaction_unit.senders.insert(self.reaction.sender);
reaction_unit
.reactions
.insert(self.reaction_note_ref, self.reaction);
}
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Reaction {
pub reaction: String, // can't use char because some emojis are 'grapheme clusters'
pub sender: Pubkey,
}
-76
View File
@@ -1,76 +0,0 @@
use nostrdb::Transaction;
use notedeck::AppContext;
use crate::{
timeline::{kind::ListKind, TimelineKind},
Damus, Route,
};
// TODO(kernelkind): should account for mutes
pub fn unseen_notification(
columns: &mut Damus,
ndb: &nostrdb::Ndb,
current_pk: notedeck::enostr::Pubkey,
) -> bool {
let Some(tl) = columns
.timeline_cache
.get_mut(&TimelineKind::Notifications(current_pk))
else {
return false;
};
let freshness = &mut tl.current_view_mut().freshness;
freshness.update(|timestamp_last_viewed| {
let filter = crate::timeline::kind::notifications_filter(&current_pk)
.since_mut(timestamp_last_viewed);
let txn = Transaction::new(ndb).expect("txn");
let Some(res) = ndb.query(&txn, &[filter], 1).ok() else {
return false;
};
!res.is_empty()
});
freshness.has_unseen()
}
/// When you click the toolbar button, these actions
/// are returned
#[derive(Debug, Eq, PartialEq)]
pub enum ToolbarAction {
Notifications,
Search,
Home,
}
impl ToolbarAction {
pub fn process(&self, app: &mut Damus, ctx: &mut AppContext) {
let cur_acc_pk = ctx.accounts.get_selected_account().key.pubkey;
let route = match &self {
ToolbarAction::Notifications => {
Route::timeline(TimelineKind::Notifications(cur_acc_pk))
}
ToolbarAction::Search => Route::Search,
ToolbarAction::Home => {
Route::timeline(TimelineKind::List(ListKind::Contact(cur_acc_pk)))
}
};
let Some(cols) = app.decks_cache.active_columns_mut(ctx.i18n, ctx.accounts) else {
return;
};
match cols.select_by_route(route) {
crate::column::SelectionResult::AlreadySelected(_) => {} // great! no need to go to top yet
crate::column::SelectionResult::NewSelection(_) => {
// we already selected this, so scroll to top
app.scroll_to_top();
}
crate::column::SelectionResult::Failed => {
// oh no, something went wrong
// TODO(jb55): handle tab selection failure
}
}
}
}
@@ -1,5 +1,4 @@
use crate::login_manager::AcquireKeyState;
use crate::ui::onboarding::FollowPacksResponse;
use crate::ui::{Preview, PreviewConfig};
use egui::{
Align, Button, Color32, Frame, InnerResponse, Layout, Margin, RichText, TextEdit, Vec2,
@@ -19,8 +18,7 @@ pub struct AccountLoginView<'a> {
}
pub enum AccountLoginResponse {
CreatingNew,
Onboarding(FollowPacksResponse),
CreateNew,
LoginWith(Keypair),
}
@@ -59,7 +57,7 @@ impl<'a> AccountLoginView<'a> {
let text_edit_width = available_width - button_width;
let textedit_resp = ui.add_sized([text_edit_width, 40.0], login_textedit(self.manager, self.i18n));
input_context(ui, &textedit_resp, self.clipboard, self.manager.input_buffer(), PasteBehavior::Clear);
input_context(&textedit_resp, self.clipboard, self.manager.input_buffer(), PasteBehavior::Clear);
if eye_button(ui, self.manager.password_visible()).clicked() {
self.manager.toggle_password_visibility();
@@ -98,7 +96,7 @@ impl<'a> AccountLoginView<'a> {
});
if self.manager.check_for_create_new() {
return Some(AccountLoginResponse::CreatingNew);
return Some(AccountLoginResponse::CreateNew);
}
if let Some(keypair) = self.manager.get_login_keypair() {
@@ -709,7 +709,6 @@ pub fn render_add_column_routes(
ctx.note_cache,
app.options.contains(AppOptions::SinceOptimize),
ctx.accounts,
ctx.unknown_ids,
);
app.columns_mut(ctx.i18n, ctx.accounts)
@@ -750,7 +749,6 @@ pub fn render_add_column_routes(
ctx.note_cache,
app.options.contains(AppOptions::SinceOptimize),
ctx.accounts,
ctx.unknown_ids,
);
app.columns_mut(ctx.i18n, ctx.accounts)
+1 -2
View File
@@ -7,7 +7,6 @@ pub mod edit_deck;
pub mod images;
pub mod mentions_picker;
pub mod note;
pub mod onboarding;
pub mod post;
pub mod preview;
pub mod profile;
@@ -18,7 +17,6 @@ pub mod side_panel;
pub mod support;
pub mod thread;
pub mod timeline;
pub mod toolbar;
pub mod wallet;
pub mod widgets;
@@ -28,6 +26,7 @@ pub use preview::{Preview, PreviewApp, PreviewConfig};
pub use profile::ProfileView;
pub use relay::RelayView;
pub use settings::SettingsView;
pub use settings::ShowSourceClientOption;
pub use side_panel::{DesktopSidePanel, SidePanelAction};
pub use thread::ThreadView;
pub use timeline::TimelineView;
+22 -47
View File
@@ -1,9 +1,11 @@
use crate::draft::{Draft, Drafts, MentionHint};
use crate::media_upload::nostrbuild_nip96_upload;
#[cfg(not(target_os = "android"))]
use crate::media_upload::{nostrbuild_nip96_upload, MediaPath};
use crate::post::{downcast_post_buffer, MentionType, NewPost};
use crate::ui::mentions_picker::MentionPickerView;
use crate::ui::{self, Preview, PreviewConfig};
use crate::Result;
use egui::{
text::{CCursorRange, LayoutJob},
text_edit::TextEditOutput,
@@ -13,23 +15,19 @@ use egui::{
use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool};
use nostrdb::{Ndb, Transaction};
use notedeck::media::gif::ensure_latest_texture;
use notedeck::media::AnimationMode;
#[cfg(target_os = "android")]
use notedeck::platform::android::try_open_file_picker;
use notedeck::platform::get_next_selected_file;
use notedeck::{get_render_state, JobsCache, PixelDimensions, RenderState};
use notedeck::{
name::get_display_name, supported_mime_hosted_at_url, tr, Localization, NoteAction, NoteContext,
};
use notedeck_ui::{
app_images,
context_menu::{input_context, PasteBehavior},
note::render_note_preview,
NoteOptions, ProfilePic,
};
use notedeck::{
name::get_display_name, supported_mime_hosted_at_url, tr, Localization, NoteAction, NoteContext,
};
use tracing::error;
#[cfg(not(target_os = "android"))]
use {notedeck::platform::file::emit_selected_file, notedeck::platform::file::SelectedMedia};
pub struct PostView<'a, 'd> {
note_context: &'a mut NoteContext<'d>,
@@ -39,7 +37,6 @@ pub struct PostView<'a, 'd> {
inner_rect: egui::Rect,
note_options: NoteOptions,
jobs: &'a mut JobsCache,
animation_mode: AnimationMode,
}
#[derive(Clone)]
@@ -113,11 +110,6 @@ impl<'a, 'd> PostView<'a, 'd> {
note_options: NoteOptions,
jobs: &'a mut JobsCache,
) -> Self {
let animation_mode = if note_options.contains(NoteOptions::NoAnimations) {
AnimationMode::NoAnimation
} else {
AnimationMode::Continuous { fps: None }
};
PostView {
note_context,
draft,
@@ -125,7 +117,6 @@ impl<'a, 'd> PostView<'a, 'd> {
post_type,
inner_rect,
note_options,
animation_mode,
jobs,
}
}
@@ -138,11 +129,6 @@ impl<'a, 'd> PostView<'a, 'd> {
PostView::id().with("scroll")
}
pub fn animation_mode(mut self, animation_mode: AnimationMode) -> Self {
self.animation_mode = animation_mode;
self
}
fn editbox(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> egui::Response {
ui.spacing_mut().item_spacing.x = 12.0;
@@ -210,7 +196,6 @@ impl<'a, 'd> PostView<'a, 'd> {
let out = textedit.show(ui);
input_context(
ui,
&out.response,
self.note_context.clipboard,
&mut self.draft.buffer.text_buffer,
@@ -342,22 +327,6 @@ impl<'a, 'd> PostView<'a, 'd> {
}
pub fn ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> PostResponse {
while let Some(selected_file) = get_next_selected_file() {
match selected_file {
Ok(selected_media) => {
let promise = nostrbuild_nip96_upload(
self.poster.secret_key.secret_bytes(),
selected_media,
);
self.draft.uploading_media.push(promise);
}
Err(e) => {
error!("{e}");
self.draft.upload_errors.push(e.to_string());
}
}
}
ScrollArea::vertical()
.id_salt(PostView::scroll_id())
.show(ui, |ui| self.ui_no_scroll(txn, ui))
@@ -523,7 +492,6 @@ impl<'a, 'd> PostView<'a, 'd> {
height,
cur_state,
url,
self.animation_mode,
)
}
to_remove.reverse();
@@ -538,14 +506,22 @@ impl<'a, 'd> PostView<'a, 'd> {
{
if let Some(files) = rfd::FileDialog::new().pick_files() {
for file in files {
emit_selected_file(SelectedMedia::from_path(file));
match MediaPath::new(file) {
Ok(media_path) => {
let promise = nostrbuild_nip96_upload(
self.poster.secret_key.secret_bytes(),
media_path,
);
self.draft.uploading_media.push(promise);
}
Err(e) => {
error!("{e}");
self.draft.upload_errors.push(e.to_string());
}
}
}
}
}
#[cfg(target_os = "android")]
{
try_open_file_picker();
}
}
}
@@ -606,7 +582,6 @@ fn render_post_view_media(
height: u32,
render_state: RenderState,
url: &str,
animation_mode: AnimationMode,
) {
match render_state.texture_state {
notedeck::TextureState::Pending => {
@@ -630,7 +605,7 @@ fn render_post_view_media(
.to_vec();
let texture_handle =
ensure_latest_texture(ui, url, render_state.gifs, renderable_media, animation_mode);
ensure_latest_texture(ui, url, render_state.gifs, renderable_media);
let img_resp = ui.add(
egui::Image::new(&texture_handle)
.max_size(size)
+1 -2
View File
@@ -55,7 +55,6 @@ impl<'a, 'd> PostReplyView<'a, 'd> {
pub fn show(&mut self, ui: &mut egui::Ui) -> PostResponse {
ScrollArea::vertical()
.id_salt(self.scroll_id)
.stick_to_bottom(true)
.show(ui, |ui| self.show_internal(ui))
.inner
}
@@ -122,7 +121,7 @@ impl<'a, 'd> PostReplyView<'a, 'd> {
// large and things start breaking. I think this is an ok
// solution but there could be a better one.
//
//ui.add_space(500.0);
ui.add_space(500.0);
post_response
})
@@ -1,119 +0,0 @@
use std::mem;
use egui::{Layout, ScrollArea};
use nostrdb::Ndb;
use notedeck::{Images, JobPool, JobsCache, Localization};
use notedeck_ui::{
colors,
nip51_set::{Nip51SetUiCache, Nip51SetWidget, Nip51SetWidgetAction, Nip51SetWidgetFlags},
};
use crate::{onboarding::Onboarding, ui::widgets::styled_button};
/// Display Follow Packs for the user to choose from authors trusted by the Damus team
pub struct FollowPackOnboardingView<'a> {
onboarding: &'a mut Onboarding,
ui_state: &'a mut Nip51SetUiCache,
ndb: &'a Ndb,
images: &'a mut Images,
loc: &'a mut Localization,
job_pool: &'a mut JobPool,
jobs: &'a mut JobsCache,
}
pub enum OnboardingResponse {
FollowPacks(FollowPacksResponse),
ViewProfile(enostr::Pubkey),
}
pub enum FollowPacksResponse {
NoFollowPacks,
UserSelectedPacks(Nip51SetUiCache),
}
impl<'a> FollowPackOnboardingView<'a> {
pub fn new(
onboarding: &'a mut Onboarding,
ui_state: &'a mut Nip51SetUiCache,
ndb: &'a Ndb,
images: &'a mut Images,
loc: &'a mut Localization,
job_pool: &'a mut JobPool,
jobs: &'a mut JobsCache,
) -> Self {
Self {
onboarding,
ui_state,
ndb,
images,
loc,
job_pool,
jobs,
}
}
pub fn scroll_id() -> egui::Id {
egui::Id::new("follow_pack_onboarding")
}
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<OnboardingResponse> {
let Some(follow_pack_state) = self.onboarding.get_follow_packs() else {
return Some(OnboardingResponse::FollowPacks(
FollowPacksResponse::NoFollowPacks,
));
};
let max_height = ui.available_height() - 48.0;
let mut action = None;
ScrollArea::vertical()
.id_salt(Self::scroll_id())
.max_height(max_height)
.show(ui, |ui| {
egui::Frame::new().inner_margin(8.0).show(ui, |ui| {
self.onboarding.list.borrow_mut().ui_custom_layout(
ui,
follow_pack_state.len(),
|ui, index| {
let resp = Nip51SetWidget::new(
follow_pack_state,
self.ui_state,
self.ndb,
self.loc,
self.images,
self.job_pool,
self.jobs,
)
.with_flags(Nip51SetWidgetFlags::TRUST_IMAGES)
.render_at_index(ui, index);
if let Some(cur_action) = resp.action {
match cur_action {
Nip51SetWidgetAction::ViewProfile(pubkey) => {
action = Some(OnboardingResponse::ViewProfile(pubkey));
}
}
}
if resp.rendered {
1
} else {
0
}
},
);
})
});
ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
ui.add_space(4.0);
if ui.add(styled_button("Done", colors::PINK)).clicked() {
action = Some(OnboardingResponse::FollowPacks(
FollowPacksResponse::UserSelectedPacks(mem::take(self.ui_state)),
));
}
});
action
}
}
+21 -36
View File
@@ -1,15 +1,12 @@
use core::f32;
use egui::{vec2, Button, CornerRadius, Layout, Margin, RichText, ScrollArea, TextEdit};
use egui_winit::clipboard::Clipboard;
use enostr::ProfileState;
use notedeck::{profile::unwrap_profile_url, tr, Images, Localization, NotedeckTextStyle};
use notedeck_ui::context_menu::{input_context, PasteBehavior};
use notedeck_ui::{profile::banner, ProfilePic};
pub struct EditProfileView<'a> {
state: &'a mut ProfileState,
clipboard: &'a mut Clipboard,
img_cache: &'a mut Images,
i18n: &'a mut Localization,
}
@@ -19,13 +16,11 @@ impl<'a> EditProfileView<'a> {
i18n: &'a mut Localization,
state: &'a mut ProfileState,
img_cache: &'a mut Images,
clipboard: &'a mut Clipboard,
) -> Self {
Self {
i18n,
state,
img_cache,
clipboard,
}
}
@@ -37,7 +32,6 @@ impl<'a> EditProfileView<'a> {
pub fn ui(&mut self, ui: &mut egui::Ui) -> bool {
ScrollArea::vertical()
.id_salt(EditProfileView::scroll_id())
.stick_to_bottom(true)
.show(ui, |ui| {
banner(ui, self.state.banner(), 188.0);
@@ -101,14 +95,14 @@ impl<'a> EditProfileView<'a> {
)
.as_str(),
));
singleline_textedit(ui, self.state.str_mut("display_name"), self.clipboard);
ui.add(singleline_textedit(self.state.str_mut("display_name")));
});
in_frame(ui, |ui| {
ui.add(label(
tr!(self.i18n, "Username", "Profile username field label").as_str(),
));
singleline_textedit(ui, self.state.str_mut("name"), self.clipboard);
ui.add(singleline_textedit(self.state.str_mut("name")));
});
in_frame(ui, |ui| {
@@ -120,28 +114,28 @@ impl<'a> EditProfileView<'a> {
)
.as_str(),
));
multiline_textedit(ui, self.state.str_mut("picture"), self.clipboard);
ui.add(multiline_textedit(self.state.str_mut("picture")));
});
in_frame(ui, |ui| {
ui.add(label(
tr!(self.i18n, "Banner", "Profile banner URL field label").as_str(),
));
multiline_textedit(ui, self.state.str_mut("banner"), self.clipboard);
ui.add(multiline_textedit(self.state.str_mut("banner")));
});
in_frame(ui, |ui| {
ui.add(label(
tr!(self.i18n, "About", "Profile about/bio field label").as_str(),
));
multiline_textedit(ui, self.state.str_mut("about"), self.clipboard);
ui.add(multiline_textedit(self.state.str_mut("about")));
});
in_frame(ui, |ui| {
ui.add(label(
tr!(self.i18n, "Website", "Profile website field label").as_str(),
));
singleline_textedit(ui, self.state.str_mut("website"), self.clipboard);
ui.add(singleline_textedit(self.state.str_mut("website")));
});
in_frame(ui, |ui| {
@@ -153,7 +147,7 @@ impl<'a> EditProfileView<'a> {
)
.as_str(),
));
multiline_textedit(ui, self.state.str_mut("lud16"), self.clipboard);
ui.add(multiline_textedit(self.state.str_mut("lud16")));
});
in_frame(ui, |ui| {
@@ -165,8 +159,7 @@ impl<'a> EditProfileView<'a> {
)
.as_str(),
));
singleline_textedit(ui, self.state.str_mut("nip05"), self.clipboard);
ui.add(singleline_textedit(self.state.str_mut("nip05")));
let Some(nip05) = self.state.nip05() else {
return;
@@ -215,29 +208,21 @@ fn label(text: &str) -> impl egui::Widget + '_ {
}
}
fn singleline_textedit(ui: &mut egui::Ui, data: &mut String, clipboard: &mut Clipboard) {
let r = ui.add(
TextEdit::singleline(data)
.min_size(vec2(0.0, 40.0))
.vertical_align(egui::Align::Center)
.margin(Margin::symmetric(12, 10))
.desired_width(f32::INFINITY),
);
input_context(ui, &r, clipboard, data, PasteBehavior::Clear);
fn singleline_textedit(data: &mut String) -> impl egui::Widget + '_ {
TextEdit::singleline(data)
.min_size(vec2(0.0, 40.0))
.vertical_align(egui::Align::Center)
.margin(Margin::symmetric(12, 10))
.desired_width(f32::INFINITY)
}
fn multiline_textedit(ui: &mut egui::Ui, data: &mut String, clipboard: &mut Clipboard) {
let r = ui.add(
TextEdit::multiline(data)
// .min_size(vec2(0.0, 40.0))
.vertical_align(egui::Align::TOP)
.margin(Margin::symmetric(12, 10))
.desired_width(f32::INFINITY)
.desired_rows(1),
);
input_context(ui, &r, clipboard, data, PasteBehavior::Clear);
fn multiline_textedit(data: &mut String) -> impl egui::Widget + '_ {
TextEdit::multiline(data)
// .min_size(vec2(0.0, 40.0))
.vertical_align(egui::Align::TOP)
.margin(Margin::symmetric(12, 10))
.desired_width(f32::INFINITY)
.desired_rows(1)
}
fn in_frame(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)) {
+10 -7
View File
@@ -73,7 +73,7 @@ impl<'a, 'd> ProfileView<'a, 'd> {
scroll_area = scroll_area.vertical_scroll_offset(offset);
}
let output = scroll_area.show(ui, |ui| 's: {
let output = scroll_area.show(ui, |ui| {
let mut action = None;
let txn = Transaction::new(self.note_context.ndb).expect("txn");
let profile = self
@@ -85,13 +85,15 @@ impl<'a, 'd> ProfileView<'a, 'd> {
if let Some(profile_view_action) = self.profile_body(ui, profile.as_ref()) {
action = Some(profile_view_action);
}
let Some(profile_timeline) = self
let profile_timeline = self
.timeline_cache
.get_mut(&TimelineKind::Profile(*self.pubkey))
else {
break 's action;
};
.notes(
self.note_context.ndb,
self.note_context.note_cache,
&txn,
&TimelineKind::Profile(*self.pubkey),
)
.get_ptr();
profile_timeline.selected_view = tabs_ui(
ui,
@@ -114,6 +116,7 @@ impl<'a, 'd> ProfileView<'a, 'd> {
if let Some(note_action) = TimelineTabView::new(
profile_timeline.current_view(),
reversed,
self.note_options,
&txn,
self.note_context,

Some files were not shown because too many files have changed in this diff Show More