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
136 changed files with 2533 additions and 8890 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=e4231c19dda9e6791d2f7b5cd610b8db5ff9a7f9#e4231c19dda9e6791d2f7b5cd610b8db5ff9a7f9"
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 = "e4231c19dda9e6791d2f7b5cd610b8db5ff9a7f9" }
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

-3
View File
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33337 5.33337V3.46671C5.33337 2.71997 5.33337 2.3466 5.4787 2.06139C5.60653 1.8105 5.8105 1.60653 6.06139 1.4787C6.3466 1.33337 6.71997 1.33337 7.46671 1.33337H12.5334C13.2801 1.33337 13.6535 1.33337 13.9387 1.4787C14.1896 1.60653 14.3936 1.8105 14.5214 2.06139C14.6667 2.3466 14.6667 2.71997 14.6667 3.46671V8.53337C14.6667 9.28011 14.6667 9.65351 14.5214 9.93871C14.3936 10.1896 14.1896 10.3936 13.9387 10.5214C13.6535 10.6667 13.2801 10.6667 12.5334 10.6667H10.6667M3.46671 14.6667H8.53337C9.28011 14.6667 9.65351 14.6667 9.93871 14.5214C10.1896 14.3936 10.3936 14.1896 10.5214 13.9387C10.6667 13.6535 10.6667 13.2801 10.6667 12.5334V7.46671C10.6667 6.71997 10.6667 6.3466 10.5214 6.06139C10.3936 5.8105 10.1896 5.60653 9.93871 5.4787C9.65351 5.33337 9.28011 5.33337 8.53337 5.33337H3.46671C2.71997 5.33337 2.3466 5.33337 2.06139 5.4787C1.8105 5.60653 1.60653 5.8105 1.4787 6.06139C1.33337 6.3466 1.33337 6.71997 1.33337 7.46671V12.5334C1.33337 13.2801 1.33337 13.6535 1.4787 13.9387C1.60653 14.1896 1.8105 14.3936 2.06139 14.5214C2.3466 14.6667 2.71997 14.6667 3.46671 14.6667Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 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 -84
View File
@@ -46,9 +46,6 @@ Add_Hashtag_Column_ebf4 = Add Hashtag Column
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = Add Last Notes Column
# Tooltip text for adding a new deck button
Add_new_deck_f2fc = Add new deck
# Column title for adding notifications column
Add_Notifications_Column_79f8 = Add Notifications Column
@@ -82,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
@@ -139,9 +139,6 @@ Copy_Note_ID_6b45 = Copy Note ID
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = Copy Note JSON
# Tooltip text for copying npub to clipboard
Copy_npub_to_clipboard_c105 = Copy npub to clipboard
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = Copy Pubkey
@@ -214,9 +211,6 @@ Display_name_f9d9 = Display name
# Domain identification message
domain___will_be_used_for_identification_b67e = "{$domain}" will be used for identification
# Button to indicate that the user is done going through the onboarding process.
Done_50dd = Done
# Column title for editing deck
Edit_Deck_4018 = Edit Deck
@@ -247,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
@@ -292,9 +286,6 @@ k_5K_f7e6 = 5K
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Keep track of your notes & replies
# label for keys setting section
Keys_435f = Keys
# Label for language, Appearance settings section
Language_e264 = Language:
@@ -322,18 +313,6 @@ Moves_this_column_to_another_position_0d4b = Moves this column to another positi
# Title for the user's deck
My_Deck_4ac5 = My Deck
# reaction from user to a note you were tagged in
name__reacted_to_a_note_you_were_tagged_in_4b62 = {$name} reacted to a note you were tagged in
# reaction from user to your note
name__reacted_to_your_note_ead9 = {$name} reacted to your note
# repost from user
name__reposted_a_note_you_were_tagged_in_1379 = {$name} reposted a note you were tagged in
# repost from user
name__reposted_your_note_1379 = {$name} reposted your note
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = New to Nostr?
@@ -373,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
@@ -409,9 +382,6 @@ Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard_
# Profile picture URL field label
Profile_picture_81ff = Profile picture
# label describing public key
PUBLIC_ACCOUNT_ID_4394 = PUBLIC ACCOUNT ID
# Column title for quote composition
Quote_475c = Quote
@@ -460,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
@@ -490,24 +457,21 @@ Search_notes_42a6 = Search notes...
# Search in progress message
Searching_for___query_5d18 = Searching for '{$query}'
# label describing secret key
SECRET_ACCOUNT_LOGIN_KEY_8440 = SECRET ACCOUNT LOGIN KEY
# Description for Home column
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
@@ -520,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
@@ -559,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
@@ -583,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
@@ -599,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
@@ -639,35 +600,3 @@ Got__count__results_for___query_85fb =
[one] Got {$count} result for '{$query}'
*[other] Got {$count} results for '{$query}'
}
# amount of reactions a note you were tagged in received
name__and__count__others_reacted_to_a_note_you_were_tagged_in_181a =
{ $count ->
[one] {$name} and {$count} other reacted to a note you were tagged in
*[other] {$name} and {$count} others reacted to a note you were tagged in
}
# describing the amount of reactions your note received
name__and__count__others_reacted_to_your_note_0f6a =
{ $count ->
[one] {$name} and {$count} other reacted to your note
*[other] {$name} and {$count} others reacted to your note
}
# describing the amount of reposts a note you were tagged in received
name__and__count__others_reposted_a_note_you_were_tagged_in_08e1 =
{ $count ->
[one] {$name} and {$count} other reposted a note you were tagged in
*[other] {$name} and {$count} others reposted a note you were tagged in
}
# describing the amount of reposts your note received
name__and__count__others_reposted_your_note_70a0 =
{ $count ->
[one] {$name} and {$count} other reposted your note
*[other] {$name} and {$count} others reposted your note
}
+13 -84
View File
@@ -46,9 +46,6 @@ Add_Hashtag_Column_ebf4 = {"["}Àdd Hàshtàg Çólúmñ{"]"}
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = {"["}Àdd Làst Ñótés Çólúmñ{"]"}
# Tooltip text for adding a new deck button
Add_new_deck_f2fc = {"["}Àdd ñéw déçk{"]"}
# Column title for adding notifications column
Add_Notifications_Column_79f8 = {"["}Àdd Ñótífíçàtíóñs Çólúmñ{"]"}
@@ -82,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{"]"}
@@ -139,9 +139,6 @@ Copy_Note_ID_6b45 = {"["}Çópy Ñóté ÍD{"]"}
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = {"["}Çópy Ñóté JSÓÑ{"]"}
# Tooltip text for copying npub to clipboard
Copy_npub_to_clipboard_c105 = {"["}Çópy ñpúb tó çlípbóàrd{"]"}
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = {"["}Çópy Púbkéy{"]"}
@@ -214,9 +211,6 @@ Display_name_f9d9 = {"["}Dísplày ñàmé{"]"}
# Domain identification message
domain___will_be_used_for_identification_b67e = {"["}"{$domain}" wíll bé úséd fór ídéñtífíçàtíóñ{"]"}
# Button to indicate that the user is done going through the onboarding process.
Done_50dd = {"["}Dóñé{"]"}
# Column title for editing deck
Edit_Deck_4018 = {"["}Édít Déçk{"]"}
@@ -247,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é{"]"}
@@ -292,9 +286,6 @@ k_5K_f7e6 = {"["}5K{"]"}
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = {"["}Kéép tràçk óf yóúr ñótés & réplíés{"]"}
# label for keys setting section
Keys_435f = {"["}Kéys{"]"}
# Label for language, Appearance settings section
Language_e264 = {"["}Làñgúàgé:{"]"}
@@ -322,18 +313,6 @@ Moves_this_column_to_another_position_0d4b = {"["}Móvés thís çólúmñ tó
# Title for the user's deck
My_Deck_4ac5 = {"["}My Déçk{"]"}
# reaction from user to a note you were tagged in
name__reacted_to_a_note_you_were_tagged_in_4b62 = {"["}{$name} réàçtéd tó à ñóté yóú wéré tàggéd íñ{"]"}
# reaction from user to your note
name__reacted_to_your_note_ead9 = {"["}{$name} réàçtéd tó yóúr ñóté{"]"}
# repost from user
name__reposted_a_note_you_were_tagged_in_1379 = {"["}{$name} répóstéd à ñóté yóú wéré tàggéd íñ{"]"}
# repost from user
name__reposted_your_note_1379 = {"["}{$name} répóstéd yóúr ñóté{"]"}
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = {"["}Ñéw tó Ñóstr?{"]"}
@@ -373,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{"]"}
@@ -409,9 +382,6 @@ Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard_
# Profile picture URL field label
Profile_picture_81ff = {"["}Prófílé píçtúré{"]"}
# label describing public key
PUBLIC_ACCOUNT_ID_4394 = {"["}PÚBLÍÇ ÀÇÇÓÚÑT ÍD{"]"}
# Column title for quote composition
Quote_475c = {"["}Qúóté{"]"}
@@ -460,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{"]"}
@@ -490,24 +457,21 @@ Search_notes_42a6 = {"["}Séàrçh ñótés...{"]"}
# Search in progress message
Searching_for___query_5d18 = {"["}Séàrçhíñg fór '{$query}'{"]"}
# label describing secret key
SECRET_ACCOUNT_LOGIN_KEY_8440 = {"["}SÉÇRÉT ÀÇÇÓÚÑT LÓGÍÑ KÉY{"]"}
# Description for Home column
See_notes_from_your_contacts_ac16 = {"["}Séé ñótés fróm yóúr çóñtàçts{"]"}
# 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{"]"}
@@ -520,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{"]"}
@@ -559,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é{"]"}
@@ -583,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é{"]"}
@@ -599,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{"]"}
@@ -639,35 +600,3 @@ Got__count__results_for___query_85fb =
[one] {"["}Gót {$count} résúlt fór '{$query}'{"]"}
*[other] {"["}Gót {$count} résúlts fór '{$query}'{"]"}
}
# amount of reactions a note you were tagged in received
name__and__count__others_reacted_to_a_note_you_were_tagged_in_181a =
{ $count ->
[one] {"["}{$name} àñd {$count} óthér réàçtéd tó à ñóté yóú wéré tàggéd íñ{"]"}
*[other] {"["}{$name} àñd {$count} óthérs réàçtéd tó à ñóté yóú wéré tàggéd íñ{"]"}
}
# describing the amount of reactions your note received
name__and__count__others_reacted_to_your_note_0f6a =
{ $count ->
[one] {"["}{$name} àñd {$count} óthér réàçtéd tó yóúr ñóté{"]"}
*[other] {"["}{$name} àñd {$count} óthérs réàçtéd tó yóúr ñóté{"]"}
}
# describing the amount of reposts a note you were tagged in received
name__and__count__others_reposted_a_note_you_were_tagged_in_08e1 =
{ $count ->
[one] {"["}{$name} àñd {$count} óthér répóstéd à ñóté yóú wéré tàggéd íñ{"]"}
*[other] {"["}{$name} àñd {$count} óthérs répóstéd à ñóté yóú wéré tàggéd íñ{"]"}
}
# describing the amount of reposts your note received
name__and__count__others_reposted_your_note_70a0 =
{ $count ->
[one] {"["}{$name} àñd {$count} óthér répóstéd yóúr ñóté{"]"}
*[other] {"["}{$name} àñd {$count} óthérs répóstéd yóúr ñó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))
}
+1 -12
View File
@@ -33,26 +33,15 @@ pub enum ZapError {
#[error("invalid lud16")]
InvalidLud16(String),
#[error("invalid endpoint response")]
EndpointError(EndpointError),
EndpointError(String),
#[error("bech encoding/decoding error")]
Bech(String),
#[error("serialization/deserialization problem")]
Serialization(String),
#[error("nwc error")]
NWC(String),
#[error("ndb error")]
Ndb(String),
}
impl ZapError {
pub fn endpoint_error(error: String) -> ZapError {
ZapError::EndpointError(EndpointError(error))
}
}
#[derive(Debug, Clone)]
pub struct EndpointError(pub String);
impl From<String> for Error {
fn from(s: String) -> Self {
Error::Generic(s)
+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(),
+48 -81
View File
@@ -1,23 +1,16 @@
use std::collections::HashMap;
use enostr::{NoteId, Pubkey};
use nostrdb::{Ndb, Transaction};
use nwc::nostr::nips::nip47::PayInvoiceResponse;
use poll_promise::Promise;
use tokio::task::JoinError;
use url::Url;
use crate::{
get_wallet_for,
zaps::{
get_users_zap_address,
networking::{fetch_invoice_promise, FetchedInvoiceResponse, LNUrlPayResponse, PayEntry},
},
Accounts, GlobalWallet, ZapError,
use crate::{get_wallet_for, Accounts, GlobalWallet, ZapError};
use super::{
networking::{fetch_invoice_lnurl, fetch_invoice_lud16, FetchedInvoice, FetchingInvoice},
zap::Zap,
};
use super::{networking::FetchingInvoice, zap::Zap};
type ZapId = u32;
#[derive(Default)]
@@ -30,31 +23,11 @@ pub struct Zaps {
zaps: std::collections::HashMap<ZapId, ZapState>,
in_flight: Vec<ZapPromise>,
events: Vec<EventResponse>,
pay_cache: PayCache,
}
/// Cache to hold LNURL payRequest responses from the desired LNURL endpoint
#[derive(Default)]
pub struct PayCache {
// endpoint URL to response
pub pay_responses: HashMap<Url, LNUrlPayResponse>,
}
impl PayCache {
pub fn get_response(&self, url: &Url) -> Option<&LNUrlPayResponse> {
self.pay_responses.get(url)
}
pub fn insert(&mut self, entry: PayEntry) {
self.pay_responses.insert(entry.url, entry.response);
}
}
fn process_event(
id: ZapId,
event: ZapEvent,
cache: &PayCache,
accounts: &mut Accounts,
global_wallet: &mut GlobalWallet,
ndb: &Ndb,
@@ -64,7 +37,7 @@ fn process_event(
ZapEvent::FetchInvoice {
zap_ctx,
sender_relays,
} => process_new_zap_event(cache, zap_ctx, accounts, ndb, txn, sender_relays),
} => process_new_zap_event(zap_ctx, accounts, ndb, txn, sender_relays),
ZapEvent::SendNWC {
zap_ctx,
req_noteid,
@@ -101,7 +74,6 @@ fn process_event(
}
fn process_new_zap_event(
cache: &PayCache,
zap_ctx: ZapCtx,
accounts: &Accounts,
ndb: &Ndb,
@@ -124,8 +96,7 @@ fn process_new_zap_event(
};
let id = zap_ctx.id;
let m_promise = send_note_zap(
cache,
let promise = send_note_zap(
ndb,
txn,
note_target,
@@ -135,41 +106,55 @@ fn process_new_zap_event(
)
.map(|promise| ZapPromise::FetchingInvoice {
ctx: zap_ctx,
promise: Box::new(promise),
promise,
});
let promise = match m_promise {
Ok(promise) => promise,
Err(e) => {
return NextState::Event(EventResponse {
id,
event: Err(ZappingError::InvoiceFetchFailed(e)),
});
}
let Some(promise) = promise else {
return NextState::Event(EventResponse {
id,
event: Err(ZappingError::InvalidZapAddress),
});
};
NextState::Transition(promise)
}
fn send_note_zap(
cache: &PayCache,
ndb: &Ndb,
txn: &Transaction,
note_target: NoteZapTargetOwned,
msats: u64,
nsec: &[u8; 32],
relays: Vec<String>,
) -> Result<FetchingInvoice, ZapError> {
let address = get_users_zap_address(txn, ndb, &note_target.zap_recipient)?;
) -> Option<FetchingInvoice> {
let address = get_users_zap_endpoint(txn, ndb, &note_target.zap_recipient)?;
fetch_invoice_promise(
cache,
address,
msats,
*nsec,
ZapTargetOwned::Note(note_target),
relays,
)
let promise = match address {
ZapAddress::Lud16(s) => {
fetch_invoice_lud16(s, msats, *nsec, ZapTargetOwned::Note(note_target), relays)
}
ZapAddress::Lud06(s) => {
fetch_invoice_lnurl(s, msats, *nsec, ZapTargetOwned::Note(note_target), relays)
}
};
Some(promise)
}
enum ZapAddress {
Lud16(String),
Lud06(String),
}
fn get_users_zap_endpoint(txn: &Transaction, ndb: &Ndb, receiver: &Pubkey) -> Option<ZapAddress> {
let profile = ndb
.get_profile_by_pubkey(txn, receiver.bytes())
.ok()?
.record()
.profile()?;
profile
.lud06()
.map(|l| ZapAddress::Lud06(l.to_string()))
.or(profile.lud16().map(|l| ZapAddress::Lud16(l.to_string())))
}
fn try_get_promise_response(
@@ -184,7 +169,7 @@ fn try_get_promise_response(
match promise {
ZapPromise::FetchingInvoice { ctx, promise } => {
let result = Box::new(promise.block_and_take());
let result = promise.block_and_take();
Some(PromiseResponse::FetchingInvoice { ctx, result })
}
@@ -287,16 +272,6 @@ impl Zaps {
continue;
};
if let PromiseResponse::FetchingInvoice { ctx: _, result } = &resp {
if let Ok(resp) = &**result {
if let Some(entry) = &resp.pay_entry {
let url = &entry.url;
tracing::info!("inserting {url} in pay cache");
self.pay_cache.insert(entry.clone());
}
}
}
self.events.push(resp.take_as_event_response());
}
@@ -311,15 +286,7 @@ impl Zaps {
};
let txn = nostrdb::Transaction::new(ndb).expect("txn");
match process_event(
event_resp.id,
event,
&self.pay_cache,
accounts,
global_wallet,
ndb,
&txn,
) {
match process_event(event_resp.id, event, accounts, global_wallet, ndb, &txn) {
NextState::Event(event_resp) => {
self.zaps
.insert(event_resp.id, ZapState::Pending(event_resp.event));
@@ -516,7 +483,7 @@ impl std::fmt::Display for ZappingError {
enum ZapPromise {
FetchingInvoice {
ctx: ZapCtx,
promise: Box<Promise<Result<FetchedInvoiceResponse, JoinError>>>,
promise: Promise<Result<Result<FetchedInvoice, ZapError>, JoinError>>,
},
SendingNWCInvoice {
ctx: SendingNWCInvoiceContext,
@@ -527,7 +494,7 @@ enum ZapPromise {
enum PromiseResponse {
FetchingInvoice {
ctx: ZapCtx,
result: Box<Result<FetchedInvoiceResponse, JoinError>>,
result: Result<Result<FetchedInvoice, ZapError>, JoinError>,
},
SendingNWCInvoice {
ctx: SendingNWCInvoiceContext,
@@ -540,8 +507,8 @@ impl PromiseResponse {
match self {
PromiseResponse::FetchingInvoice { ctx, result } => {
let id = ctx.id;
let event = match *result {
Ok(r) => match r.invoice {
let event = match result {
Ok(r) => match r {
Ok(invoice) => Ok(ZapEvent::SendNWC {
zap_ctx: ctx,
req_noteid: invoice.request_noteid,
-36
View File
@@ -11,39 +11,3 @@ pub use default_zap::{
get_current_default_msats, DefaultZapError, DefaultZapMsats, PendingDefaultZapState,
UserZapMsats,
};
use enostr::Pubkey;
use nostrdb::{Ndb, Transaction};
use crate::ZapError;
pub enum ZapAddress {
Lud16(String),
Lud06(String),
}
pub fn get_users_zap_address(
txn: &Transaction,
ndb: &Ndb,
receiver: &Pubkey,
) -> Result<ZapAddress, ZapError> {
let Some(profile) = ndb
.get_profile_by_pubkey(txn, receiver.bytes())
.map_err(|e| ZapError::Ndb(e.to_string()))?
.record()
.profile()
else {
return Err(ZapError::Ndb(format!("No profile for {receiver}")));
};
let Some(address) = profile
.lud06()
.map(|l| ZapAddress::Lud06(l.to_string()))
.or(profile.lud16().map(|l| ZapAddress::Lud16(l.to_string())))
else {
return Err(ZapError::Ndb(format!(
"profile for {receiver} doesn't have lud06 or lud16"
)));
};
Ok(address)
}
+100 -210
View File
@@ -1,9 +1,5 @@
use crate::{
error::EndpointError,
zaps::{cache::PayCache, ZapAddress, ZapTargetOwned},
ZapError,
};
use enostr::{NoteId, Pubkey};
use crate::{zaps::ZapTargetOwned, ZapError};
use enostr::NoteId;
use nostrdb::NoteBuilder;
use poll_promise::Promise;
use serde::Deserialize;
@@ -15,20 +11,15 @@ pub struct FetchedInvoice {
pub request_noteid: NoteId, // note id of kind 9734 request
}
pub struct FetchedInvoiceResponse {
pub invoice: Result<FetchedInvoice, ZapError>,
pub pay_entry: Option<PayEntry>,
}
pub type FetchingInvoice = Promise<Result<Result<FetchedInvoice, ZapError>, JoinError>>;
pub type FetchingInvoice = Promise<Result<FetchedInvoiceResponse, JoinError>>;
async fn fetch_pay_req_async(url: &Url) -> Result<LNUrlPayResponseRaw, ZapError> {
async fn fetch_pay_req_async(url: &Url) -> Result<LNUrlPayRequest, ZapError> {
let (sender, promise) = Promise::new();
let on_done = move |response: Result<ehttp::Response, String>| {
let handle = response.map_err(ZapError::endpoint_error).and_then(|resp| {
let handle = response.map_err(ZapError::EndpointError).and_then(|resp| {
if !resp.ok {
return Err(ZapError::endpoint_error(format!(
return Err(ZapError::EndpointError(format!(
"bad http response: {}",
resp.status_text
)));
@@ -45,9 +36,20 @@ async fn fetch_pay_req_async(url: &Url) -> Result<LNUrlPayResponseRaw, ZapError>
tokio::task::block_in_place(|| promise.block_and_take())
}
async fn fetch_pay_req_from_lud16(lud16: &str) -> Result<LNUrlPayRequest, ZapError> {
let url = match generate_endpoint_url(lud16) {
Ok(url) => url,
Err(e) => return Err(e),
};
fetch_pay_req_async(&url).await
}
static HRP_LNURL: bech32::Hrp = bech32::Hrp::parse_unchecked("lnurl");
fn endpoint_url_to_lnurl(endpoint_url: &Url) -> Result<String, ZapError> {
fn lud16_to_lnurl(lud16: &str) -> Result<String, ZapError> {
let endpoint_url = generate_endpoint_url(lud16)?;
let url_str = endpoint_url.to_string();
let data = url_str.as_bytes();
@@ -98,7 +100,7 @@ fn make_kind_9734<'a>(
}
#[derive(Debug, Deserialize)]
pub struct LNUrlPayResponseRaw {
pub struct LNUrlPayRequest {
#[allow(dead_code)]
#[serde(rename = "allowsNostr")]
allow_nostr: bool,
@@ -119,117 +121,57 @@ pub struct LNUrlPayResponseRaw {
max_sendable: u64,
}
impl From<LNUrlPayResponseRaw> for LNUrlPayResponse {
fn from(value: LNUrlPayResponseRaw) -> Self {
let nostr_pubkey = Pubkey::from_hex(&value.nostr_pubkey)
.map_err(|e: enostr::Error| EndpointError(e.to_string()));
let callback_url = Url::parse(&value.callback_url)
.map_err(|e| EndpointError(format!("invalid callback url: {e}")));
Self {
allow_nostr: value.allow_nostr,
nostr_pubkey,
callback_url,
min_sendable: value.min_sendable,
max_sendable: value.max_sendable,
}
}
}
#[derive(Clone, Debug)]
pub struct LNUrlPayResponse {
pub allow_nostr: bool,
pub nostr_pubkey: Result<Pubkey, EndpointError>,
pub callback_url: Result<Url, EndpointError>,
pub min_sendable: u64,
pub max_sendable: u64,
}
#[derive(Clone, Debug)]
pub struct PayEntry {
pub url: Url,
pub response: LNUrlPayResponse,
}
#[derive(Debug, Deserialize)]
struct LNInvoice {
#[serde(rename = "pr")]
invoice: String,
}
fn endpoint_query_for_invoice(
endpoint_base_url: &Url,
fn endpoint_query_for_invoice<'a>(
endpoint_base_url: &'a mut Url,
msats: u64,
lnurl: &str,
note: nostrdb::Note,
) -> Result<Url, ZapError> {
let mut new_url = endpoint_base_url.clone();
) -> Result<&'a Url, ZapError> {
let nostr = note
.json()
.map_err(|e| ZapError::Serialization(format!("failed note to json: {e}")))?;
new_url
Ok(endpoint_base_url
.query_pairs_mut()
.append_pair("amount", &msats.to_string())
.append_pair("lnurl", lnurl)
.append_pair("nostr", &nostr)
.finish();
Ok(new_url)
.finish())
}
pub fn fetch_invoice_promise(
cache: &PayCache,
zap_address: ZapAddress,
pub fn fetch_invoice_lud16(
lud16: String,
msats: u64,
sender_nsec: [u8; 32],
target: ZapTargetOwned,
relays: Vec<String>,
) -> Result<FetchingInvoice, ZapError> {
let (url, lnurl) = match zap_address {
ZapAddress::Lud16(lud16) => {
let url = generate_endpoint_url(&lud16)?;
let lnurl = endpoint_url_to_lnurl(&url)?;
(url, lnurl)
}
ZapAddress::Lud06(lnurl) => (convert_lnurl_to_endpoint_url(&lnurl)?, lnurl),
};
) -> FetchingInvoice {
Promise::spawn_async(tokio::spawn(async move {
fetch_invoice_lud16_async(&lud16, msats, &sender_nsec, target, relays).await
}))
}
match cache.get_response(&url) {
Some(endpoint_resp) => {
tracing::info!("Using existing endpoint response for {url}");
let response = endpoint_resp.clone();
Ok(Promise::spawn_async(tokio::spawn(async move {
fetch_invoice_lnurl_async(
&lnurl,
PayEntry { url, response },
msats,
&sender_nsec,
relays,
target,
)
.await
})))
}
None => Ok(Promise::spawn_async(tokio::spawn(async move {
tracing::info!("querying ln endpoint: {url}");
let pay_req = match fetch_pay_req_async(&url).await {
Ok(p) => PayEntry {
url,
response: p.into(),
},
Err(e) => {
return FetchedInvoiceResponse {
invoice: Err(e),
pay_entry: None,
}
}
};
pub fn fetch_invoice_lnurl(
lnurl: String,
msats: u64,
sender_nsec: [u8; 32],
target: ZapTargetOwned,
relays: Vec<String>,
) -> FetchingInvoice {
Promise::spawn_async(tokio::spawn(async move {
let pay_req = match fetch_pay_req_from_lnurl_async(&lnurl).await {
Ok(req) => req,
Err(e) => return Err(e),
};
fetch_invoice_lnurl_async(&lnurl, pay_req, msats, &sender_nsec, relays, target).await
}))),
}
fetch_invoice_lnurl_async(&lnurl, &pay_req, msats, &sender_nsec, relays, target).await
}))
}
fn convert_lnurl_to_endpoint_url(lnurl: &str) -> Result<Url, ZapError> {
@@ -239,96 +181,68 @@ fn convert_lnurl_to_endpoint_url(lnurl: &str) -> Result<Url, ZapError> {
String::from_utf8(data).map_err(|e| ZapError::Bech(format!("string conversion: {e}")))?;
Url::parse(&url_str)
.map_err(|e| ZapError::endpoint_error(format!("endpoint url from lnurl is invalid: {e}")))
.map_err(|e| ZapError::EndpointError(format!("endpoint url from lnurl is invalid: {e}")))
}
async fn fetch_pay_req_from_lnurl_async(lnurl: &str) -> Result<LNUrlPayRequest, ZapError> {
let url = match convert_lnurl_to_endpoint_url(lnurl) {
Ok(u) => u,
Err(e) => return Err(e),
};
fetch_pay_req_async(&url).await
}
async fn fetch_invoice_lnurl_async(
lnurl: &str,
pay_entry: PayEntry,
pay_req: &LNUrlPayRequest,
msats: u64,
sender_nsec: &[u8; 32],
relays: Vec<String>,
target: ZapTargetOwned,
) -> FetchedInvoiceResponse {
if !pay_entry.response.allow_nostr {
return FetchedInvoiceResponse {
invoice: Err(ZapError::endpoint_error(
"endpoint does not allow nostr".to_owned(),
)),
pay_entry: Some(pay_entry),
};
}
) -> Result<FetchedInvoice, ZapError> {
//let recipient = Pubkey::from_hex(&pay_req.nostr_pubkey)
//.map_err(|e| ZapError::EndpointError(format!("invalid pubkey hex from endpoint: {e}")))?;
if let Err(e) = &pay_entry.response.nostr_pubkey {
return FetchedInvoiceResponse {
invoice: Err(ZapError::EndpointError(e.clone())),
pay_entry: Some(pay_entry),
};
};
let min_sendable = pay_entry.response.min_sendable;
if msats < min_sendable {
return FetchedInvoiceResponse {
invoice: Err(ZapError::endpoint_error(format!(
"zap amount {msats} is less than minimum sendable: {min_sendable} (in msats)"
))),
pay_entry: Some(pay_entry),
};
}
let max_sendable = pay_entry.response.max_sendable;
if msats > max_sendable {
return FetchedInvoiceResponse {
invoice: Err(ZapError::endpoint_error(format!(
"zap amount {msats} is greater than maximum sendable: {max_sendable} (in msats)"
))),
pay_entry: Some(pay_entry),
};
}
let base_url = match &pay_entry.response.callback_url {
Ok(url) => url.clone(),
Err(error) => {
return FetchedInvoiceResponse {
invoice: Err(ZapError::EndpointError(error.clone())),
pay_entry: None,
};
}
};
let mut base_url = Url::parse(&pay_req.callback_url)
.map_err(|e| ZapError::EndpointError(format!("invalid callback url from endpoint: {e}")))?;
let (query, noteid) = {
let comment: &str = "";
let note = make_kind_9734(lnurl, msats, comment, sender_nsec, relays, target);
let noteid = NoteId::new(*note.id());
let query = match endpoint_query_for_invoice(&base_url, msats, lnurl, note) {
Ok(u) => u,
Err(e) => {
return FetchedInvoiceResponse {
invoice: Err(e),
pay_entry: Some(pay_entry),
}
}
};
let query = endpoint_query_for_invoice(&mut base_url, msats, lnurl, note)?;
(query, noteid)
};
let res = fetch_ln_invoice(&query).await;
FetchedInvoiceResponse {
invoice: res.map(|r| FetchedInvoice {
invoice: r.invoice,
request_noteid: noteid,
}),
pay_entry: Some(pay_entry),
}
let res = fetch_invoice(query).await;
res.map(|i| FetchedInvoice {
invoice: i.invoice,
request_noteid: noteid,
})
}
async fn fetch_ln_invoice(req: &Url) -> Result<LNInvoice, ZapError> {
async fn fetch_invoice_lud16_async(
lud16: &str,
msats: u64,
sender_nsec: &[u8; 32],
target: ZapTargetOwned,
relays: Vec<String>,
) -> Result<FetchedInvoice, ZapError> {
let pay_req = fetch_pay_req_from_lud16(lud16).await?;
let lnurl = lud16_to_lnurl(lud16)?;
fetch_invoice_lnurl_async(&lnurl, &pay_req, msats, sender_nsec, relays, target).await
}
async fn fetch_invoice(req: &Url) -> Result<LNInvoice, ZapError> {
let request = ehttp::Request::get(req);
let (sender, promise) = Promise::new();
let on_done = move |response: Result<ehttp::Response, String>| {
let handle = response.map_err(ZapError::endpoint_error).and_then(|resp| {
let handle = response.map_err(ZapError::EndpointError).and_then(|resp| {
if !resp.ok {
return Err(ZapError::endpoint_error(format!(
return Err(ZapError::EndpointError(format!(
"invalid http response: {}",
resp.status_text
)));
@@ -376,32 +290,25 @@ fn generate_endpoint_url(lud16: &str) -> Result<Url, ZapError> {
if use_http { "" } else { "s" }
);
Url::parse(&url_str).map_err(|e| ZapError::endpoint_error(e.to_string()))
Url::parse(&url_str).map_err(|e| ZapError::EndpointError(e.to_string()))
}
#[cfg(test)]
mod tests {
use enostr::{FullKeypair, NoteId};
use crate::zaps::{
cache::PayCache,
networking::{
convert_lnurl_to_endpoint_url, endpoint_url_to_lnurl, fetch_pay_req_async,
generate_endpoint_url,
},
};
use crate::zaps::networking::convert_lnurl_to_endpoint_url;
use super::fetch_invoice_promise;
use super::{
fetch_invoice_lnurl, fetch_invoice_lud16, fetch_pay_req_from_lud16, lud16_to_lnurl,
};
#[ignore] // don't run this test automatically since it sends real http
#[tokio::test(flavor = "multi_thread")]
async fn test_get_pay_req() {
let lud16 = "jb55@sendsats.lol";
let url = generate_endpoint_url(lud16);
assert!(url.is_ok());
let maybe_res = fetch_pay_req_async(&url.unwrap()).await;
let maybe_res = fetch_pay_req_from_lud16(lud16).await;
assert!(maybe_res.is_ok());
@@ -421,10 +328,7 @@ mod tests {
fn test_lnurl() {
let lud16 = "jb55@sendsats.lol";
let url = generate_endpoint_url(lud16);
assert!(url.is_ok());
let maybe_lnurl = endpoint_url_to_lnurl(&url.unwrap());
let maybe_lnurl = lud16_to_lnurl(lud16);
assert!(maybe_lnurl.is_ok());
let lnurl = maybe_lnurl.unwrap();
@@ -440,11 +344,9 @@ mod tests {
let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
let kp = FullKeypair::generate();
let mut cache = PayCache::default();
let maybe_invoice = rt.block_on(async {
fetch_invoice_promise(
&mut cache,
crate::zaps::ZapAddress::Lud16("jb55@sendsats.lol".to_owned()),
fetch_invoice_lud16(
"jb55@sendsats.lol".to_owned(),
1000,
FullKeypair::generate().secret_key.to_secret_bytes(),
crate::zaps::ZapTargetOwned::Note(crate::NoteZapTargetOwned {
@@ -453,18 +355,14 @@ mod tests {
}),
vec!["wss://relay.damus.io".to_owned()],
)
.map(|p| p.block_and_take())
.block_and_take()
});
assert!(maybe_invoice.is_ok());
let inner = maybe_invoice.unwrap();
assert!(inner.is_ok());
let inner = inner.unwrap().invoice;
assert!(inner.is_ok());
let inner = inner.unwrap();
assert!(inner.invoice.starts_with("lnbc"));
let invoice = inner.unwrap();
assert!(invoice.invoice.starts_with("lnbc"));
}
#[test]
@@ -487,11 +385,9 @@ mod tests {
let kp = FullKeypair::generate();
let mut cache = PayCache::default();
let maybe_invoice = rt.block_on(async {
fetch_invoice_promise(
&mut cache,
crate::zaps::ZapAddress::Lud06(lnurl.to_owned()),
fetch_invoice_lnurl(
lnurl.to_owned(),
1000,
kp.secret_key.to_secret_bytes(),
crate::zaps::ZapTargetOwned::Note(crate::NoteZapTargetOwned {
@@ -500,17 +396,11 @@ mod tests {
}),
[relay.to_owned()].to_vec(),
)
.map(|p| p.block_and_take())
.block_and_take()
});
assert!(maybe_invoice.is_ok());
let inner = maybe_invoice.unwrap();
assert!(inner.is_ok());
let inner = inner.unwrap().invoice;
assert!(inner.is_ok());
let inner = inner.unwrap();
assert!(inner.invoice.starts_with("lnbc"));
assert!(maybe_invoice.unwrap().unwrap().invoice.starts_with("lnbc"));
}
}
-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, 6])
.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::{CompositeType, InsertionResponse, NoteUnits};
pub use timeline_units::{TimelineUnits, UnknownPks};
pub use unit::{CompositeUnit, NoteUnit, ReactionUnit, RepostUnit};
//#[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,668 +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<UnitKey, StorageIndex>, // the key 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: &UnitKey) -> 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<CompositeKey, 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(&UnitKey::Single(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;
let composite_type = c_frag.get_type();
if let Some(&storage_idx) = self.lookup.get(&UnitKey::Composite(c_frag.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(CompositeKey {
key,
composite_type,
}) {
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)
}
}
#[derive(Hash, PartialEq, Eq, Debug)]
pub struct CompositeKey {
pub key: NoteKey,
pub composite_type: CompositeType,
}
#[derive(Hash, PartialEq, Eq, Debug)]
pub enum CompositeType {
Reaction,
Repost,
}
#[derive(Hash, PartialEq, Eq, Debug)]
pub enum UnitKey {
Single(NoteKey),
Composite(CompositeKey),
}
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, RepostFragment,
},
NoteUnits, RepostUnit,
};
#[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_reac_frag(&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 build_repost_frag(&mut self, reposting: NoteRef) -> NoteUnitFragment {
NoteUnitFragment::Composite(CompositeFragment::Repost(RepostFragment {
reposted_noteref: reposting,
repost_noteref: self.new_noteref(),
reposter: self.random_sender(),
}))
}
fn insert_repost(&mut self, reposting: NoteRef) -> String {
let repost = self.build_repost_frag(reposting);
let id = Uuid::new_v4().to_string();
self.frags.insert(id.clone(), repost.clone());
self.units.merge_fragments(vec![repost]);
id
}
fn insert_reac_frag(&mut self, reacted_to: NoteRef) -> String {
let frag = self.build_reac_frag(reacted_to);
let id = Uuid::new_v4().to_string();
self.frags.insert(id.clone(), frag.clone());
self.units.merge_fragments(vec![frag]);
id
}
fn insert_reac_frag_pair(&mut self, reacted_to: NoteRef) -> (String, String) {
let frag1 = self.build_reac_frag(reacted_to);
let frag2 = self.build_reac_frag(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 new_noteref(&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_reposts(&mut self, ids: Vec<&String>) -> NoteUnit {
let mut reposts = BTreeMap::new();
let mut reposted_id = None;
let mut senders = HashSet::new();
for id in ids {
let NoteUnitFragment::Composite(CompositeFragment::Repost(repost)) =
self.frags.get(id).unwrap()
else {
panic!("got something other than repost");
};
if let Some(prev_reposted_id) = reposted_id {
if prev_reposted_id != repost.reposted_noteref {
panic!("internal error");
}
}
reposted_id = Some(repost.reposted_noteref);
reposts.insert(repost.repost_noteref, repost.reposter);
senders.insert(repost.reposter);
}
NoteUnit::Composite(CompositeUnit::Repost(RepostUnit {
note_reposted: reposted_id.unwrap(),
reposts,
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),
Expect::Repost(items) => self.expected_reposts(items),
}
);
}
}
enum Expect<'a> {
Single(&'a String),
Reaction(Vec<&'a String>),
Repost(Vec<&'a String>),
}
#[test]
fn test_reactions1() {
let mut builder = UnitBuilder::default();
let reaction_note = builder.new_noteref();
let single0 = builder.insert_note();
builder.aeq(0, Expect::Single(&single0));
let reac1 = builder.insert_reac_frag(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.insert_reac_frag(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.insert_reac_frag(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 test_reactions2() {
let mut builder = UnitBuilder::default();
let reaction_note1 = builder.new_noteref();
let reaction_note2 = builder.new_noteref();
let single0 = builder.insert_note();
builder.aeq(0, Expect::Single(&single0));
let reac1_1 = builder.insert_reac_frag(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac1_1]));
builder.aeq(1, Expect::Single(&single0));
let reac2_1 = builder.insert_reac_frag(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.insert_reac_frag(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.insert_reac_frag(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.insert_reac_frag(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 test_reactions3() {
let mut builder = UnitBuilder::default();
let reaction_note1 = builder.new_noteref();
let single1 = builder.insert_note();
builder.aeq(0, Expect::Single(&single1));
let reac0 = builder.insert_reac_frag(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac0]));
builder.aeq(1, Expect::Single(&single1));
let (reac1, reac2) = builder.insert_reac_frag_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));
}
#[test]
fn test_repost() {
let mut builder = UnitBuilder::default();
let repost_note = builder.new_noteref();
let single1 = builder.insert_note();
builder.aeq(0, Expect::Single(&single1));
let repost1 = builder.insert_repost(repost_note);
builder.aeq(0, Expect::Repost(vec![&repost1]));
builder.aeq(1, Expect::Single(&single1));
let single2 = builder.insert_note();
builder.aeq(0, Expect::Single(&single2));
builder.aeq(1, Expect::Repost(vec![&repost1]));
builder.aeq(2, Expect::Single(&single1));
let reac1 = builder.insert_reac_frag(repost_note);
builder.aeq(0, Expect::Reaction(vec![&reac1]));
builder.aeq(1, Expect::Single(&single2));
builder.aeq(2, Expect::Repost(vec![&repost1]));
builder.aeq(3, Expect::Single(&single1));
let repost2 = builder.insert_repost(repost_note);
builder.aeq(0, Expect::Repost(vec![&repost1, &repost2]));
builder.aeq(1, Expect::Reaction(vec![&reac1]));
builder.aeq(2, Expect::Single(&single2));
builder.aeq(3, Expect::Single(&single1));
let reac2 = builder.insert_reac_frag(repost_note);
builder.aeq(0, Expect::Reaction(vec![&reac1, &reac2]));
builder.aeq(1, Expect::Repost(vec![&repost1, &repost2]));
builder.aeq(2, Expect::Single(&single2));
builder.aeq(3, Expect::Single(&single1));
}
}
+105 -51
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,21 +13,16 @@ use notedeck::{NoteCache, NoteRef, UnknownIds};
use crate::{
actionbar::{process_thread_notes, NewThreadNotes},
multi_subscriber::ThreadSubs,
timeline::{
note_units::{NoteUnits, UnitKey},
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)]
@@ -32,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(),
}
}
}
@@ -60,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,
@@ -69,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 {
@@ -99,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
@@ -393,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(&UnitKey::Single(*k))
}
}
@@ -1,242 +0,0 @@
use std::collections::HashSet;
use enostr::Pubkey;
use nostrdb::{Ndb, Note, NoteKey, Transaction};
use notedeck::NoteRef;
use notedeck_ui::note::get_reposted_note;
use crate::timeline::{
note_units::{InsertManyResponse, NoteUnits},
unit::{
CompositeFragment, NoteUnit, NoteUnitFragment, Reaction, ReactionFragment, RepostFragment,
},
};
#[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,
}
impl<'a> NotePayload<'a> {
pub fn noteref(&self) -> NoteRef {
NoteRef {
key: self.key,
created_at: self.note.created_at(),
}
}
}
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),
}),
6 => to_repost(payload, ndb, txn).map(RepostResponse::into),
_ => 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 = payload.noteref();
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], // reaction sender
}
pub struct RepostResponse<'a> {
fragment: RepostFragment,
reposter_pk: &'a [u8; 32],
}
impl<'a> From<RepostResponse<'a>> for NoteUnitFragmentResponse<'a> {
fn from(value: RepostResponse<'a>) -> Self {
Self {
fragment: NoteUnitFragment::Composite(CompositeFragment::Repost(value.fragment)),
unknown_pk: Some(value.reposter_pk),
}
}
}
fn to_repost<'a>(
payload: &'a NotePayload,
ndb: &Ndb,
txn: &Transaction,
) -> Option<RepostResponse<'a>> {
let reposted_note = match get_reposted_note(ndb, txn, &payload.note) {
Some(r) => r,
None => {
tracing::error!(
"Could not get reposted note for note id {}",
enostr::NoteId::new(*payload.note.id()).hex()
);
return None;
}
};
let reposted_key = match reposted_note.key() {
Some(r) => r,
None => {
tracing::error!(
"Could not get key of reposted note {}",
enostr::NoteId::new(*reposted_note.id()).hex()
);
return None;
}
};
Some(RepostResponse {
fragment: RepostFragment {
reposted_noteref: NoteRef {
key: reposted_key,
created_at: reposted_note.created_at(),
},
repost_noteref: payload.noteref(),
reposter: Pubkey::new(*payload.note.pubkey()),
},
reposter_pk: payload.note.pubkey(),
})
}
@@ -1,302 +0,0 @@
use std::collections::{BTreeMap, HashSet};
use enostr::Pubkey;
use notedeck::NoteRef;
use crate::timeline::note_units::{CompositeKey, CompositeType, UnitKey};
/// 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) -> UnitKey {
match self {
NoteUnit::Single(note_ref) => UnitKey::Single(note_ref.key),
NoteUnit::Composite(clustered_entry) => UnitKey::Composite(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,
CompositeUnit::Repost(repost_unit) => &repost_unit.note_reposted,
},
}
}
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),
Repost(RepostUnit),
}
impl CompositeUnit {
pub fn get_latest_ref(&self) -> &NoteRef {
match self {
CompositeUnit::Reaction(reaction_unit) => reaction_unit.get_latest_ref(),
CompositeUnit::Repost(repost_unit) => repost_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,
(Self::Repost(l0), Self::Repost(r0)) => l0 == r0,
_ => false,
}
}
}
impl CompositeUnit {
pub fn key(&self) -> CompositeKey {
match self {
CompositeUnit::Reaction(reaction_entry) => CompositeKey {
key: reaction_entry.note_reacted_to.key,
composite_type: CompositeType::Reaction,
},
CompositeUnit::Repost(repost_unit) => CompositeKey {
key: repost_unit.note_reposted.key,
composite_type: CompositeType::Repost,
},
}
}
}
impl From<CompositeFragment> for CompositeUnit {
fn from(value: CompositeFragment) -> Self {
match value {
CompositeFragment::Reaction(reaction_fragment) => {
CompositeUnit::Reaction(reaction_fragment.into())
}
CompositeFragment::Repost(repost_fragment) => {
CompositeUnit::Repost(repost_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(Debug, PartialEq, Eq, Clone)]
pub struct RepostUnit {
pub note_reposted: NoteRef,
pub reposts: BTreeMap<NoteRef, Pubkey>, // repost note to sender
pub senders: HashSet<Pubkey>,
}
impl RepostUnit {
pub fn get_latest_ref(&self) -> &NoteRef {
self.reposts
.first_key_value()
.map(|(r, _)| r)
.unwrap_or(&self.note_reposted)
}
}
impl From<RepostFragment> for RepostUnit {
fn from(value: RepostFragment) -> Self {
let mut reposts = BTreeMap::new();
reposts.insert(value.repost_noteref, value.reposter);
let mut senders = HashSet::new();
senders.insert(value.reposter);
Self {
note_reposted: value.reposted_noteref,
reposts,
senders,
}
}
}
#[derive(Clone)]
pub enum NoteUnitFragment {
Single(NoteRef),
Composite(CompositeFragment),
}
#[derive(Debug, Clone)]
pub enum CompositeFragment {
Reaction(ReactionFragment),
Repost(RepostFragment),
}
impl CompositeFragment {
pub fn fold_into(self, unit: &mut CompositeUnit) {
match self {
CompositeFragment::Reaction(reaction_fragment) => {
let CompositeUnit::Reaction(reaction_unit) = unit else {
tracing::error!("Attempting to fold a reaction fragment into a unit which isn't ReactionUnit. Doing nothing, this should never occur");
return;
};
reaction_fragment.fold_into(reaction_unit);
}
CompositeFragment::Repost(repost_fragment) => {
let CompositeUnit::Repost(repost_unit) = unit else {
tracing::error!("Attempting to fold a repost fragment into a unit which isn't RepostUnit. Doing nothing, this should never occur");
return;
};
repost_fragment.fold_into(repost_unit);
}
}
}
pub fn key(&self) -> CompositeKey {
match self {
CompositeFragment::Reaction(reaction) => CompositeKey {
key: reaction.noteref_reacted_to.key,
composite_type: CompositeType::Reaction,
},
CompositeFragment::Repost(repost) => CompositeKey {
key: repost.reposted_noteref.key,
composite_type: CompositeType::Repost,
},
}
}
pub fn get_underlying_noteref(&self) -> &NoteRef {
match self {
CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.noteref_reacted_to,
CompositeFragment::Repost(repost_fragment) => &repost_fragment.reposted_noteref,
}
}
pub fn get_latest_ref(&self) -> &NoteRef {
match self {
CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.reaction_note_ref,
CompositeFragment::Repost(repost_fragment) => &repost_fragment.repost_noteref,
}
}
pub fn get_type(&self) -> CompositeType {
match self {
CompositeFragment::Reaction(_) => CompositeType::Reaction,
CompositeFragment::Repost(_) => CompositeType::Repost,
}
}
}
/// 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 ReactionUnit) {
if self.noteref_reacted_to != unit.note_reacted_to {
tracing::error!("Attempting to fold a reaction fragment into a ReactionUnit which as a different note reacted to: {:?} != {:?}. This should never occur", self.noteref_reacted_to, unit.note_reacted_to);
return;
}
if unit.senders.contains(&self.reaction.sender) {
return;
}
unit.senders.insert(self.reaction.sender);
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,
}
/// Represents a singular repost
#[derive(Debug, Clone)]
pub struct RepostFragment {
pub reposted_noteref: NoteRef,
pub repost_noteref: NoteRef,
pub reposter: Pubkey,
}
impl RepostFragment {
pub fn fold_into(self, unit: &mut RepostUnit) {
if self.reposted_noteref != unit.note_reposted {
tracing::error!("Attempting to fold a repost fragment into a RepostUnit which has a different note reposted: {:?} != {:?}. This should never occur", self.reposted_noteref, unit.note_reposted);
return;
}
if unit.senders.contains(&self.reposter) {
return;
}
unit.senders.insert(self.reposter);
unit.reposts.insert(self.repost_noteref, self.reposter);
}
}
-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() {
@@ -157,7 +155,7 @@ fn login_textedit<'a>(
text_edit
}
pub fn eye_button(ui: &mut egui::Ui, is_visible: bool) -> egui::Response {
fn eye_button(ui: &mut egui::Ui, is_visible: bool) -> egui::Response {
let is_dark_mode = ui.visuals().dark_mode;
let icon = if is_visible && is_dark_mode {
app_images::eye_dark_image()
@@ -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;

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