Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
b84ad4f1cd
|
|||
|
736ce50f64
|
|||
| e9ca793509 | |||
| ea65af8d5b | |||
| 11aa2142cf | |||
| 6ee2b28e70 | |||
| 31ee64827a | |||
| f243adc855 | |||
| 5224a5d8ae | |||
| 2c96dd99a8 | |||
| e7843bad2f | |||
| c2f012ff75 | |||
| 76fd7a9753 | |||
| 8b5464641d | |||
| c06d18f76b | |||
| 84e60e0642 | |||
| 23f35c60bb | |||
| 30c2ebdcc2 | |||
| 1658600604 | |||
| 529377a706 | |||
| 30af03cfcc | |||
| bb878d3772 | |||
| 5c9eb492b6 | |||
| 0b584a773f | |||
| 78504a6673 | |||
| ae204cbd5c | |||
| 7d4e9799e5 | |||
| 55d7cd3379 | |||
| 697040d862 | |||
| 49866418a6 | |||
| 9b784dfdf7 | |||
| c1d6c0f535 | |||
| 1a93663b1a | |||
| 4992e25b3a | |||
| 7b1ace328f | |||
| 2973a0c6c5 | |||
| 4f63629715 | |||
| 686dea9831 | |||
| 01171ff9d7 | |||
| b421e7e45f | |||
| 86641c6121 |
Generated
+31
-26
@@ -126,7 +126,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "android-activity"
|
||||
version = "0.6.0"
|
||||
source = "git+https://github.com/damus-io/android-activity?rev=092a83b747937a2890ac219617a4252c001842ea#092a83b747937a2890ac219617a4252c001842ea"
|
||||
source = "git+https://github.com/damus-io/android-activity?rev=4ee16f1585e4a75031dc10785163d4b920f95805#4ee16f1585e4a75031dc10785163d4b920f95805"
|
||||
dependencies = [
|
||||
"android-properties",
|
||||
"bitflags 2.9.1",
|
||||
@@ -193,7 +193,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.1",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -1403,7 +1403,7 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/damus-io/winit?rev=9e4ea9de75222d2523a20f18d3a0a108c573737d#9e4ea9de75222d2523a20f18d3a0a108c573737d"
|
||||
source = "git+https://github.com/damus-io/winit?rev=a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d#a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
@@ -1420,17 +1420,17 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.31.1"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f#c1f9e565aa17a7a8b40736602b6ea8a52876f46f"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f)",
|
||||
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72)",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eframe"
|
||||
version = "0.31.1"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f#c1f9e565aa17a7a8b40736602b6ea8a52876f46f"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1466,13 +1466,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui"
|
||||
version = "0.31.1"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f#c1f9e565aa17a7a8b40736602b6ea8a52876f46f"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"ahash",
|
||||
"backtrace",
|
||||
"bitflags 2.9.1",
|
||||
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f)",
|
||||
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72)",
|
||||
"epaint",
|
||||
"log",
|
||||
"nohash-hasher",
|
||||
@@ -1484,7 +1484,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.31.1"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f#c1f9e565aa17a7a8b40736602b6ea8a52876f46f"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1503,7 +1503,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui-winit"
|
||||
version = "0.31.1"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f#c1f9e565aa17a7a8b40736602b6ea8a52876f46f"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"arboard",
|
||||
@@ -1521,7 +1521,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.31.1"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f#c1f9e565aa17a7a8b40736602b6ea8a52876f46f"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
@@ -1538,7 +1538,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.31.1"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f#c1f9e565aa17a7a8b40736602b6ea8a52876f46f"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1617,7 +1617,7 @@ checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b"
|
||||
[[package]]
|
||||
name = "emath"
|
||||
version = "0.31.1"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f#c1f9e565aa17a7a8b40736602b6ea8a52876f46f"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
@@ -1715,13 +1715,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.31.1"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f#c1f9e565aa17a7a8b40736602b6ea8a52876f46f"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
"ecolor",
|
||||
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f)",
|
||||
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72)",
|
||||
"epaint_default_fonts",
|
||||
"log",
|
||||
"nohash-hasher",
|
||||
@@ -1733,7 +1733,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "epaint_default_fonts"
|
||||
version = "0.31.1"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c1f9e565aa17a7a8b40736602b6ea8a52876f46f#c1f9e565aa17a7a8b40736602b6ea8a52876f46f"
|
||||
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
@@ -3505,15 +3505,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notedeck"
|
||||
version = "0.6.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"android-activity 0.6.0 (git+https://github.com/damus-io/android-activity?rev=092a83b747937a2890ac219617a4252c001842ea)",
|
||||
"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",
|
||||
@@ -3527,10 +3528,12 @@ 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",
|
||||
@@ -3558,7 +3561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notedeck_chrome"
|
||||
version = "0.6.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"eframe",
|
||||
@@ -3590,7 +3593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notedeck_clndash"
|
||||
version = "0.6.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3609,7 +3612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notedeck_columns"
|
||||
version = "0.6.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bech32",
|
||||
@@ -3629,6 +3632,8 @@ 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",
|
||||
@@ -3663,7 +3668,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notedeck_dave"
|
||||
version = "0.6.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"async-openai",
|
||||
"bytemuck",
|
||||
@@ -3688,7 +3693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notedeck_notebook"
|
||||
version = "0.6.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"jsoncanvas",
|
||||
@@ -3697,7 +3702,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notedeck_ui"
|
||||
version = "0.6.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"eframe",
|
||||
@@ -7447,10 +7452,10 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.8"
|
||||
source = "git+https://github.com/damus-io/winit?rev=9e4ea9de75222d2523a20f18d3a0a108c573737d#9e4ea9de75222d2523a20f18d3a0a108c573737d"
|
||||
source = "git+https://github.com/damus-io/winit?rev=a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d#a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"android-activity 0.6.0 (git+https://github.com/damus-io/android-activity?rev=092a83b747937a2890ac219617a4252c001842ea)",
|
||||
"android-activity 0.6.0 (git+https://github.com/damus-io/android-activity?rev=4ee16f1585e4a75031dc10785163d4b920f95805)",
|
||||
"atomic-waker",
|
||||
"bitflags 2.9.1",
|
||||
"block2 0.5.1",
|
||||
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
package.version = "0.6.0"
|
||||
package.version = "0.7.1"
|
||||
members = [
|
||||
"crates/notedeck",
|
||||
"crates/notedeck_chrome",
|
||||
@@ -88,7 +88,7 @@ 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 = "092a83b747937a2890ac219617a4252c001842ea", features = [ "game-activity" ] }
|
||||
android-activity = { git = "https://github.com/damus-io/android-activity", rev = "4ee16f1585e4a75031dc10785163d4b920f95805", features = [ "game-activity" ] }
|
||||
|
||||
[profile.small]
|
||||
inherits = 'release'
|
||||
@@ -106,12 +106,12 @@ 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 = "c1f9e565aa17a7a8b40736602b6ea8a52876f46f" }
|
||||
eframe = { git = "https://github.com/damus-io/egui", rev = "c1f9e565aa17a7a8b40736602b6ea8a52876f46f" }
|
||||
egui-winit = { git = "https://github.com/damus-io/egui", rev = "c1f9e565aa17a7a8b40736602b6ea8a52876f46f" }
|
||||
egui-wgpu = { git = "https://github.com/damus-io/egui", rev = "c1f9e565aa17a7a8b40736602b6ea8a52876f46f" }
|
||||
egui_extras = { git = "https://github.com/damus-io/egui", rev = "c1f9e565aa17a7a8b40736602b6ea8a52876f46f" }
|
||||
epaint = { git = "https://github.com/damus-io/egui", rev = "c1f9e565aa17a7a8b40736602b6ea8a52876f46f" }
|
||||
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" }
|
||||
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" }
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -239,6 +239,8 @@ Notifications_ef56 = Benachrichtigungen
|
||||
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
|
||||
# Button label to open email client
|
||||
Open_Email_25e9 = E-Mail öffnen
|
||||
# Instruction to open email client
|
||||
@@ -315,6 +317,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
|
||||
|
||||
@@ -352,6 +352,9 @@ 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
|
||||
|
||||
@@ -466,6 +469,9 @@ 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
|
||||
|
||||
|
||||
@@ -352,6 +352,9 @@ 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{"]"}
|
||||
|
||||
@@ -466,6 +469,9 @@ See_notes_from_your_contacts_ac16 = {"["}Séé ñótés fróm yóúr çóñtàç
|
||||
# Description for universe column
|
||||
See_the_whole_nostr_universe_7694 = {"["}Séé thé whólé ñóstr úñívérsé{"]"}
|
||||
|
||||
# Button to select all profiles in follow pack
|
||||
Select_All_a319 = {"["}Séléçt Àll{"]"}
|
||||
|
||||
# Button label to send a zap
|
||||
Send_1ea4 = {"["}Séñd{"]"}
|
||||
|
||||
|
||||
@@ -237,6 +237,8 @@ Notifications_ef56 = Notifications
|
||||
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
|
||||
@@ -313,6 +315,8 @@ 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
|
||||
|
||||
@@ -237,6 +237,8 @@ Notifications_ef56 = Notificações
|
||||
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
|
||||
@@ -313,6 +315,8 @@ 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
|
||||
|
||||
@@ -237,6 +237,8 @@ Notifications_ef56 = Notificações
|
||||
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
|
||||
@@ -313,6 +315,8 @@ Searching_for___query_5d18 = Procurando por '{ $query }'
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -90,11 +90,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 = คัดลอก Pubkey
|
||||
Copy_Pubkey_9cc4 = คัดลอก npub
|
||||
# Copy the text content of the note to clipboard
|
||||
Copy_Text_f81c = คัดลอกข้อความ
|
||||
# Relative time in days
|
||||
@@ -164,7 +164,7 @@ 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 = Font size:
|
||||
Font_size_dd73 = ขนาดตัวอักษร:
|
||||
# Title for hashtags column
|
||||
Hashtags_f8e0 = แฮชแท็ก
|
||||
# Title for Home column
|
||||
@@ -238,7 +238,9 @@ 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 = On
|
||||
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
|
||||
@@ -254,7 +256,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
|
||||
@@ -292,7 +294,7 @@ Repost_this_note_8e56 = รีโพสต์โน้ตนี้
|
||||
# Label for reposted notes
|
||||
Reposted_61c8 = รีโพสต์แล้ว
|
||||
# Label for reset note body font size, Appearance settings section
|
||||
Reset_4e60 = Reset
|
||||
Reset_4e60 = รีเซ็ต
|
||||
# Label for reset zoom level, Appearance settings section
|
||||
Reset_62d4 = รีเซ็ต
|
||||
# Heading for support section
|
||||
@@ -315,6 +317,8 @@ 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
|
||||
@@ -328,7 +332,7 @@ 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 = Sort replies newest first:
|
||||
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
|
||||
@@ -354,7 +358,7 @@ 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 = Support email:
|
||||
Support_email_44d9 = อีเมลฝ่ายสนับสนุน:
|
||||
# Hover text for dark mode toggle button
|
||||
Switch_to_dark_mode_4dec = เปลี่ยนเป็นโหมดมืด
|
||||
# Hover text for light mode toggle button
|
||||
@@ -376,7 +380,7 @@ 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
|
||||
@@ -388,7 +392,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
|
||||
|
||||
@@ -50,6 +50,8 @@ md5 = { workspace = true }
|
||||
bitflags = { workspace = true }
|
||||
regex = "1"
|
||||
chrono = { workspace = true }
|
||||
indexmap = {workspace = true}
|
||||
crossbeam-channel = "0.5"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
@@ -58,6 +60,7 @@ 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"]
|
||||
|
||||
@@ -267,6 +267,11 @@ 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
|
||||
|
||||
@@ -183,21 +183,24 @@ pub fn should_since_optimize(limit: u64, num_notes: usize) -> bool {
|
||||
limit as usize <= num_notes
|
||||
}
|
||||
|
||||
pub fn since_optimize_filter_with(filter: Filter, notes: &[NoteRef], since_gap: u64) -> Filter {
|
||||
pub fn since_optimize_filter_with(
|
||||
filter: Filter,
|
||||
latest_note: Option<&NoteRef>,
|
||||
since_gap: u64,
|
||||
) -> Filter {
|
||||
// Get the latest entry in the events
|
||||
if notes.is_empty() {
|
||||
let Some(latest) = latest_note else {
|
||||
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, notes: &[NoteRef]) -> Filter {
|
||||
since_optimize_filter_with(filter, notes, 60)
|
||||
pub fn since_optimize_filter(filter: Filter, latest: Option<&NoteRef>) -> Filter {
|
||||
since_optimize_filter_with(filter, latest, 60)
|
||||
}
|
||||
|
||||
pub fn default_limit() -> u64 {
|
||||
|
||||
@@ -80,4 +80,8 @@ impl Muted {
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn is_pk_muted(&self, pk: &[u8; 32]) -> bool {
|
||||
self.pubkeys.contains(pk)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use enostr::{Pubkey, RelayPool};
|
||||
use indexmap::IndexMap;
|
||||
use nostrdb::{Filter, Ndb, Note, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -10,7 +9,7 @@ use crate::{UnifiedSubscription, UnknownIds};
|
||||
#[derive(Debug)]
|
||||
pub struct Nip51SetCache {
|
||||
pub sub: UnifiedSubscription,
|
||||
cached_notes: HashMap<PackId, Nip51Set>,
|
||||
cached_notes: IndexMap<PackId, Nip51Set>,
|
||||
}
|
||||
|
||||
type PackId = String;
|
||||
@@ -24,7 +23,7 @@ impl Nip51SetCache {
|
||||
nip51_set_filter: Vec<Filter>,
|
||||
) -> Option<Self> {
|
||||
let subid = Uuid::new_v4().to_string();
|
||||
let mut cached_notes = HashMap::default();
|
||||
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())
|
||||
@@ -73,11 +72,23 @@ impl Nip51SetCache {
|
||||
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 HashMap<PackId, Nip51Set>,
|
||||
cache: &mut IndexMap<PackId, Nip51Set>,
|
||||
ndb: &Ndb,
|
||||
txn: &Transaction,
|
||||
unknown_ids: &mut UnknownIds,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
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;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
pub fn get_jvm() -> jni::JavaVM {
|
||||
unsafe { jni::JavaVM::from_raw(ndk_context::android_context().vm().cast()) }.unwrap()
|
||||
}
|
||||
|
||||
// Thread-safe static global
|
||||
static KEYBOARD_HEIGHT: AtomicI32 = AtomicI32::new(0);
|
||||
@@ -24,3 +33,80 @@ 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(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,5 +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;
|
||||
|
||||
|
||||
@@ -195,13 +195,13 @@ impl UnknownIds {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_pubkey_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, pubkey: &Pubkey) {
|
||||
pub fn add_pubkey_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, pubkey: &[u8; 32]) {
|
||||
// we already have this profile, skip
|
||||
if ndb.get_profile_by_pubkey(txn, pubkey).is_ok() {
|
||||
return;
|
||||
}
|
||||
|
||||
let unknown_id = UnknownId::Pubkey(*pubkey);
|
||||
let unknown_id = UnknownId::Pubkey(Pubkey::new(*pubkey));
|
||||
if self.ids.contains_key(&unknown_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,7 +238,9 @@ impl SupportedMimeType {
|
||||
{
|
||||
Ok(Self { mime })
|
||||
} else {
|
||||
Err(Error::Generic("Unsupported mime type".to_owned()))
|
||||
Err(Error::Generic(
|
||||
format!("{extension} Unsupported mime type",),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-72
@@ -1,13 +1,18 @@
|
||||
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;
|
||||
@@ -15,52 +20,23 @@ 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 {
|
||||
System.loadLibrary("notedeck_chrome");
|
||||
}
|
||||
static final int REQUEST_CODE_PICK_FILE = 420;
|
||||
|
||||
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.
|
||||
super.onCreate(savedInstanceState);
|
||||
private native void nativeOnFilePickedFailed(String uri, String e);
|
||||
private native void nativeOnFilePickedWithContent(Object[] uri_info, byte[] content);
|
||||
|
||||
setupInsets();
|
||||
|
||||
//setupFullscreen()
|
||||
|
||||
//keyboardHelper = new KeyboardHeightHelper(this);
|
||||
|
||||
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
private void setupInsets() {
|
||||
@@ -92,35 +68,171 @@ public class MainActivity extends GameActivity {
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
}
|
||||
|
||||
/*
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
keyboardHelper.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
keyboardHelper.stop();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
keyboardHelper.close();
|
||||
}
|
||||
*/
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
// Offset the location so it fits the view with margins caused by insets.
|
||||
private void processSelectedFile(Uri uri) {
|
||||
try {
|
||||
nativeOnFilePickedWithContent(this.getUriInfo(uri), readUriContent(uri));
|
||||
} catch (Exception e) {
|
||||
Log.e("MainActivity", "Error processing file: " + uri.toString(), e);
|
||||
|
||||
int[] location = new int[2];
|
||||
findViewById(android.R.id.content).getLocationOnScreen(location);
|
||||
event.offsetLocation(-location[0], -location[1]);
|
||||
|
||||
return super.onTouchEvent(event);
|
||||
nativeOnFilePickedFailed(uri.toString(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,11 @@ use notedeck::Notedeck;
|
||||
|
||||
#[no_mangle]
|
||||
#[tokio::main]
|
||||
pub async fn android_main(app: AndroidApp) {
|
||||
pub async fn android_main(android_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",
|
||||
@@ -42,7 +41,7 @@ pub async fn android_main(app: AndroidApp) {
|
||||
.with(fmt_layer)
|
||||
.init();
|
||||
|
||||
let path = app.internal_data_path().expect("data path");
|
||||
let path = android_app.internal_data_path().expect("data path");
|
||||
let mut options = eframe::NativeOptions {
|
||||
depth_buffer: 24,
|
||||
..eframe::NativeOptions::default()
|
||||
@@ -55,17 +54,18 @@ pub async fn android_main(app: AndroidApp) {
|
||||
// builder.with_android_app(app_clone_for_event_loop);
|
||||
//}));
|
||||
|
||||
options.android_app = Some(app.clone());
|
||||
options.android_app = Some(android_app.clone());
|
||||
|
||||
let app_args = get_app_args(app.clone());
|
||||
let app_args = get_app_args();
|
||||
|
||||
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(app.clone());
|
||||
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 +104,7 @@ Using internal storage would be better but it seems hard to get the config file
|
||||
the device ...
|
||||
*/
|
||||
|
||||
fn get_app_args(_app: AndroidApp) -> Vec<String> {
|
||||
fn get_app_args() -> Vec<String> {
|
||||
vec!["argv0-placeholder".to_string()]
|
||||
/*
|
||||
use serde_json::value;
|
||||
|
||||
@@ -307,7 +307,6 @@ impl Chrome {
|
||||
strip.cell(|ui| {
|
||||
// keyboard-visibility virtual keyboard
|
||||
if virtual_keyboard && keyboard_height > 0.0 {
|
||||
tracing::debug!("got here");
|
||||
virtual_keyboard_ui(ui, ui.available_rect_before_wrap())
|
||||
}
|
||||
});
|
||||
@@ -777,14 +776,14 @@ fn bottomup_sidebar(
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.clicked()
|
||||
{
|
||||
chrome.show_memory_debug = !chrome.show_memory_debug;
|
||||
chrome.options.toggle(ChromeOptions::MemoryDebug);
|
||||
}
|
||||
}
|
||||
if let Some(resident) = mem_use.resident {
|
||||
ui.weak(format!("{}", format_bytes(resident as f64)));
|
||||
}
|
||||
|
||||
if chrome.show_memory_debug {
|
||||
if chrome.options.contains(ChromeOptions::MemoryDebug) {
|
||||
egui::Window::new("Memory Debug").show(ui.ctx(), memory_debug_ui);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,6 @@ 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);
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ 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 }
|
||||
|
||||
@@ -78,7 +78,7 @@ pub fn render_accounts_route(
|
||||
app_ctx: &mut AppContext,
|
||||
jobs: &mut JobsCache,
|
||||
login_state: &mut AcquireKeyState,
|
||||
onboarding: &Onboarding,
|
||||
onboarding: &mut Onboarding,
|
||||
follow_packs_ui: &mut Nip51SetUiCache,
|
||||
route: AccountsRoute,
|
||||
) -> Option<AccountsResponse> {
|
||||
|
||||
@@ -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, InsertionResponse, NoteSeenFlags, ThreadNode, Threads,
|
||||
},
|
||||
ThreadSelection, TimelineCache, TimelineKind,
|
||||
thread::{selected_has_at_least_n_replies, NoteSeenFlags, ThreadNode, Threads},
|
||||
InsertionResponse, ThreadSelection, TimelineCache, TimelineKind,
|
||||
},
|
||||
view_state::ViewState,
|
||||
};
|
||||
@@ -30,8 +30,9 @@ pub enum NotesOpenResult {
|
||||
Thread(NewThreadNotes),
|
||||
}
|
||||
|
||||
pub enum TimelineOpenResult {
|
||||
NewNotes(NewNotes),
|
||||
pub struct TimelineOpenResult {
|
||||
new_notes: Option<NewNotes>,
|
||||
new_pks: Option<HashSet<Pubkey>>,
|
||||
}
|
||||
|
||||
struct NoteActionResponse {
|
||||
@@ -270,7 +271,24 @@ fn clear_zap_error(sender: &Pubkey, zaps: &mut Zaps, target: &NoteZapTargetOwned
|
||||
|
||||
impl TimelineOpenResult {
|
||||
pub fn new_notes(notes: Vec<NoteKey>, id: TimelineKind) -> Self {
|
||||
Self::NewNotes(NewNotes::new(notes, id))
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(
|
||||
@@ -281,11 +299,17 @@ impl TimelineOpenResult {
|
||||
storage: &mut TimelineCache,
|
||||
unknown_ids: &mut UnknownIds,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,7 +411,7 @@ pub fn process_thread_notes(
|
||||
created_at,
|
||||
};
|
||||
|
||||
if thread.replies.contains(¬e_ref) {
|
||||
if thread.replies.contains_key(¬e_ref.key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ fn try_process_event(
|
||||
app_ctx.note_cache,
|
||||
timeline,
|
||||
app_ctx.accounts,
|
||||
app_ctx.unknown_ids,
|
||||
);
|
||||
|
||||
if is_ready {
|
||||
@@ -222,6 +223,7 @@ 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}");
|
||||
}
|
||||
|
||||
@@ -140,7 +140,16 @@ impl ColumnsArgs {
|
||||
} else if column_name == "universe" {
|
||||
debug!("got universe column");
|
||||
res.columns
|
||||
.push(ArgColumn::Timeline(TimelineKind::Universe))
|
||||
.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)));
|
||||
} 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());
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
#![cfg_attr(target_os = "android", allow(dead_code, unused_variables))]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::Error;
|
||||
use base64::{prelude::BASE64_URL_SAFE, Engine};
|
||||
use ehttp::Request;
|
||||
use nostrdb::{Note, NoteBuilder};
|
||||
use notedeck::SupportedMimeType;
|
||||
use notedeck::{
|
||||
media::images::fetch_binary_from_disk,
|
||||
platform::file::{MediaFrom, SelectedMedia},
|
||||
};
|
||||
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";
|
||||
|
||||
@@ -94,15 +93,15 @@ fn create_nip98_note(seckey: &[u8; 32], upload_url: String, payload_hash: String
|
||||
|
||||
fn create_nip96_request(
|
||||
upload_url: &str,
|
||||
media_path: MediaPath,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
file_contents: Vec<u8>,
|
||||
nip98_base64: &str,
|
||||
) -> ehttp::Request {
|
||||
let boundary = "----boundary";
|
||||
|
||||
let mut body = format!(
|
||||
"--{}\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()
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{file_name}\"\r\nContent-Type: {media_type}\r\n\r\n",
|
||||
)
|
||||
.into_bytes();
|
||||
body.extend(file_contents);
|
||||
@@ -134,25 +133,14 @@ fn sha256_hex(contents: &Vec<u8>) -> String {
|
||||
pub fn nip96_upload(
|
||||
seckey: [u8; 32],
|
||||
upload_url: String,
|
||||
media_path: MediaPath,
|
||||
selected_media: SelectedMedia,
|
||||
) -> Promise<Result<Nip94Event, Error>> {
|
||||
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)
|
||||
internal_nip96_upload(seckey, upload_url, selected_media)
|
||||
}
|
||||
|
||||
pub fn nostrbuild_nip96_upload(
|
||||
seckey: [u8; 32],
|
||||
media_path: MediaPath,
|
||||
selected_media: SelectedMedia,
|
||||
) -> Promise<Result<Nip94Event, Error>> {
|
||||
let (sender, promise) = Promise::new();
|
||||
std::thread::spawn(move || {
|
||||
@@ -166,7 +154,7 @@ pub fn nostrbuild_nip96_upload(
|
||||
}
|
||||
};
|
||||
|
||||
let res = nip96_upload(seckey, upload_url, media_path).block_and_take();
|
||||
let res = nip96_upload(seckey, upload_url, selected_media).block_and_take();
|
||||
sender.send(res);
|
||||
});
|
||||
promise
|
||||
@@ -175,9 +163,21 @@ pub fn nostrbuild_nip96_upload(
|
||||
fn internal_nip96_upload(
|
||||
seckey: [u8; 32],
|
||||
upload_url: String,
|
||||
media_path: MediaPath,
|
||||
file_contents: Vec<u8>,
|
||||
selected_media: SelectedMedia,
|
||||
) -> 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,7 +186,13 @@ fn internal_nip96_upload(
|
||||
Err(e) => return Promise::from_ready(Err(Error::Generic(e.to_string()))),
|
||||
};
|
||||
|
||||
let request = create_nip96_request(&upload_url, media_path, file_contents, &nip98_base64);
|
||||
let request = create_nip96_request(
|
||||
&upload_url,
|
||||
&file_name,
|
||||
mime_type,
|
||||
file_contents,
|
||||
&nip98_base64,
|
||||
);
|
||||
|
||||
let (sender, promise) = Promise::new();
|
||||
|
||||
@@ -232,33 +238,10 @@ fn find_nip94_ev_in_json(json: String) -> Result<Nip94Event, Error> {
|
||||
}
|
||||
}
|
||||
|
||||
#[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"
|
||||
)))
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +332,7 @@ mod tests {
|
||||
use enostr::FullKeypair;
|
||||
|
||||
use crate::media_upload::{
|
||||
get_upload_url_from_provider, nostrbuild_nip96_upload, MediaPath, NOSTR_BUILD_URL,
|
||||
get_upload_url_from_provider, nostrbuild_nip96_upload, SelectedMedia, NOSTR_BUILD_URL,
|
||||
};
|
||||
|
||||
use super::internal_nip96_upload;
|
||||
@@ -368,7 +351,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 media_path = MediaPath::new(file_path).unwrap();
|
||||
let selected_media = SelectedMedia::from_path(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();
|
||||
@@ -378,8 +361,7 @@ mod tests {
|
||||
let promise = internal_nip96_upload(
|
||||
kp.secret_key.secret_bytes(),
|
||||
upload_url.to_string(),
|
||||
media_path,
|
||||
img_bytes.to_vec(),
|
||||
selected_media,
|
||||
);
|
||||
let res = promise.block_until_ready();
|
||||
assert!(res.is_ok())
|
||||
@@ -395,11 +377,11 @@ mod tests {
|
||||
let file_path =
|
||||
fs::canonicalize(PathBuf::from_str("../../assets/damus_rounded_80.png").unwrap())
|
||||
.unwrap();
|
||||
let media_path = MediaPath::new(file_path).unwrap();
|
||||
let selected_media = SelectedMedia::from_path(file_path).unwrap();
|
||||
let kp = FullKeypair::generate();
|
||||
println!("Using pubkey: {:?}", kp.pubkey);
|
||||
|
||||
let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), media_path);
|
||||
let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), selected_media);
|
||||
|
||||
let out = promise.block_and_take();
|
||||
assert!(out.is_ok());
|
||||
|
||||
@@ -591,7 +591,7 @@ fn render_nav_body(
|
||||
ctx,
|
||||
&mut app.jobs,
|
||||
&mut app.view_state.login,
|
||||
&app.onboarding,
|
||||
&mut app.onboarding,
|
||||
&mut app.view_state.follow_packs,
|
||||
*amr,
|
||||
) else {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
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};
|
||||
@@ -16,6 +19,7 @@ enum OnboardingState {
|
||||
#[derive(Default)]
|
||||
pub struct Onboarding {
|
||||
state: Option<Result<OnboardingState, OnboardingError>>,
|
||||
pub list: Rc<RefCell<VirtualList>>,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
@@ -107,8 +111,8 @@ pub enum OnboardingError {
|
||||
|
||||
// author providing the list of trusted follow pack authors
|
||||
const FOLLOW_PACK_AUTHOR: [u8; 32] = [
|
||||
0x34, 0x27, 0x76, 0x21, 0x61, 0x20, 0x15, 0x65, 0x49, 0x7d, 0xd9, 0x9c, 0x7a, 0x81, 0xd6, 0x11,
|
||||
0x8f, 0x46, 0xf6, 0x19, 0xc9, 0xec, 0x56, 0x32, 0x87, 0x05, 0xcc, 0x85, 0x07, 0x17, 0xa5, 0x4a,
|
||||
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 {
|
||||
@@ -116,7 +120,7 @@ fn trusted_pks_list_filter() -> Filter {
|
||||
.kinds([30000])
|
||||
.limit(1)
|
||||
.authors(&[FOLLOW_PACK_AUTHOR])
|
||||
.tags(["trusted-follow-pack-authors"], 'd') // TODO(kernelkind): replace with actual d tag
|
||||
.tags(["trusted-follow-pack-authors"], 'd')
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
actionbar::TimelineOpenResult,
|
||||
error::Error,
|
||||
timeline::{Timeline, TimelineKind},
|
||||
timeline::{Timeline, TimelineKind, UnknownPksOwned},
|
||||
};
|
||||
|
||||
use notedeck::{filter, FilterState, NoteCache, NoteRef};
|
||||
@@ -90,17 +90,19 @@ 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;
|
||||
return None;
|
||||
};
|
||||
|
||||
// insert initial notes into timeline
|
||||
timeline.insert_new(txn, ndb, note_cache, notes);
|
||||
let res = timeline.insert_new(txn, ndb, note_cache, notes);
|
||||
self.timelines.insert(id, timeline);
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, id: TimelineKind, timeline: Timeline) {
|
||||
@@ -113,19 +115,22 @@ impl TimelineCache {
|
||||
}
|
||||
|
||||
/// Get and/or update the notes associated with this timeline
|
||||
pub fn notes<'a>(
|
||||
fn notes<'a>(
|
||||
&'a mut self,
|
||||
ndb: &Ndb,
|
||||
note_cache: &mut NoteCache,
|
||||
txn: &Transaction,
|
||||
id: &TimelineKind,
|
||||
) -> Vitality<'a, Timeline> {
|
||||
) -> GetNotesResponse<'a> {
|
||||
// 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 Vitality::Stale(self.get_expected_mut(id));
|
||||
return GetNotesResponse {
|
||||
vitality: Vitality::Stale(self.get_expected_mut(id)),
|
||||
unknown_pks: None,
|
||||
};
|
||||
}
|
||||
|
||||
let notes = if let FilterState::Ready(filters) = id.filters(txn, ndb) {
|
||||
@@ -149,9 +154,12 @@ impl TimelineCache {
|
||||
info!("found NotesHolder with {} notes", notes.len());
|
||||
}
|
||||
|
||||
self.insert_new(id.to_owned(), txn, ndb, ¬es, note_cache);
|
||||
let unknown_pks = self.insert_new(id.to_owned(), txn, ndb, ¬es, note_cache);
|
||||
|
||||
Vitality::Fresh(self.get_expected_mut(id))
|
||||
GetNotesResponse {
|
||||
vitality: Vitality::Fresh(self.get_expected_mut(id)),
|
||||
unknown_pks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a timeline, this is another way of saying insert a timeline
|
||||
@@ -166,11 +174,12 @@ impl TimelineCache {
|
||||
pool: &mut RelayPool,
|
||||
id: &TimelineKind,
|
||||
) -> Option<TimelineOpenResult> {
|
||||
let (open_result, timeline) = match self.notes(ndb, note_cache, txn, id) {
|
||||
let notes_resp = self.notes(ndb, note_cache, txn, id);
|
||||
let (mut open_result, timeline) = match notes_resp.vitality {
|
||||
Vitality::Stale(timeline) => {
|
||||
// The timeline cache is stale, let's update it
|
||||
let notes = find_new_notes(
|
||||
timeline.all_or_any_notes(),
|
||||
timeline.all_or_any_entries().latest(),
|
||||
timeline.subscription.get_filter()?.local(),
|
||||
txn,
|
||||
ndb,
|
||||
@@ -207,6 +216,13 @@ 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
|
||||
}
|
||||
|
||||
@@ -231,18 +247,22 @@ 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(
|
||||
notes: &[NoteRef],
|
||||
latest: Option<&NoteRef>,
|
||||
filters: &[Filter],
|
||||
txn: &Transaction,
|
||||
ndb: &Ndb,
|
||||
) -> Vec<NoteRef> {
|
||||
if notes.is_empty() {
|
||||
let Some(last_note) = latest else {
|
||||
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) {
|
||||
|
||||
@@ -625,7 +625,7 @@ impl TimelineKind {
|
||||
pub fn notifications_filter(pk: &Pubkey) -> Filter {
|
||||
Filter::new()
|
||||
.pubkeys([pk.bytes()])
|
||||
.kinds([1])
|
||||
.kinds([1, 7])
|
||||
.limit(default_limit())
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
error::Error,
|
||||
multi_subscriber::TimelineSub,
|
||||
subscriptions::{self, SubKind, Subscriptions},
|
||||
timeline::kind::ListKind,
|
||||
timeline::{kind::ListKind, note_units::InsertManyResponse, timeline_units::NotePayload},
|
||||
Result,
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ 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};
|
||||
@@ -27,37 +28,17 @@ 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};
|
||||
|
||||
//#[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)
|
||||
}
|
||||
}
|
||||
*/
|
||||
pub use note_units::{InsertionResponse, NoteUnits};
|
||||
pub use timeline_units::{TimelineUnits, UnknownPks};
|
||||
pub use unit::{CompositeUnit, NoteUnit, ReactionUnit};
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
|
||||
pub enum ViewFilter {
|
||||
@@ -103,7 +84,7 @@ impl ViewFilter {
|
||||
/// be captured by a Filter itself.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct TimelineTab {
|
||||
pub notes: Vec<NoteRef>,
|
||||
pub units: TimelineUnits,
|
||||
pub selection: i32,
|
||||
pub filter: ViewFilter,
|
||||
pub list: Rc<RefCell<VirtualList>>,
|
||||
@@ -136,10 +117,9 @@ 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 {
|
||||
notes,
|
||||
units: TimelineUnits::with_capacity(cap),
|
||||
selection,
|
||||
filter,
|
||||
list,
|
||||
@@ -147,45 +127,54 @@ impl TimelineTab {
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&mut self, new_refs: &[NoteRef], reversed: bool) {
|
||||
if new_refs.is_empty() {
|
||||
return;
|
||||
fn insert<'a>(
|
||||
&mut self,
|
||||
payloads: Vec<&'a NotePayload>,
|
||||
ndb: &Ndb,
|
||||
txn: &Transaction,
|
||||
reversed: bool,
|
||||
) -> Option<UnknownPks<'a>> {
|
||||
if payloads.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let num_prev_items = self.notes.len();
|
||||
let (notes, merge_kind) = crate::timeline::merge_sorted_vecs(&self.notes, new_refs);
|
||||
|
||||
self.notes = notes;
|
||||
let new_items = self.notes.len() - num_prev_items;
|
||||
let num_refs = payloads.len();
|
||||
|
||||
// TODO: technically items could have been added inbetween
|
||||
if new_items > 0 {
|
||||
let mut list = self.list.borrow_mut();
|
||||
let resp = self.units.merge_new_notes(payloads, ndb, txn);
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
resp.tl_response
|
||||
}
|
||||
|
||||
pub fn select_down(&mut self) {
|
||||
debug!("select_down {}", self.selection + 1);
|
||||
if self.selection + 1 > self.notes.len() as i32 {
|
||||
if self.selection + 1 > self.units.len() as i32 {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -202,6 +191,14 @@ 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 {
|
||||
@@ -293,15 +290,20 @@ impl Timeline {
|
||||
|
||||
/// Get the note refs for NotesAndReplies. If we only have Notes, then
|
||||
/// just return that instead
|
||||
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 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 notes(&self, view: ViewFilter) -> Option<&[NoteRef]> {
|
||||
self.view(view).map(|v| &*v.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 view(&self, view: ViewFilter) -> Option<&TimelineTab> {
|
||||
@@ -320,7 +322,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> =
|
||||
@@ -328,6 +330,7 @@ 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) {
|
||||
@@ -335,11 +338,32 @@ impl Timeline {
|
||||
note_cache.cached_note_or_insert_mut(note_ref.key, ¬e),
|
||||
¬e,
|
||||
) {
|
||||
self.views[view].notes.push(*note_ref)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(UnknownPksOwned { pks: unknown_pks })
|
||||
}
|
||||
|
||||
/// The main function used for inserting notes into timelines. Handles
|
||||
@@ -354,7 +378,7 @@ impl Timeline {
|
||||
note_cache: &mut NoteCache,
|
||||
reversed: bool,
|
||||
) -> Result<()> {
|
||||
let mut new_refs: Vec<(Note, NoteRef)> = Vec::with_capacity(new_note_ids.len());
|
||||
let mut payloads: Vec<NotePayload> = 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) {
|
||||
@@ -371,35 +395,32 @@ impl Timeline {
|
||||
// into the timeline
|
||||
UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, ¬e);
|
||||
|
||||
let created_at = note.created_at();
|
||||
new_refs.push((
|
||||
note,
|
||||
NoteRef {
|
||||
key: *key,
|
||||
created_at,
|
||||
},
|
||||
));
|
||||
payloads.push(NotePayload { note, key: *key });
|
||||
}
|
||||
|
||||
for view in &mut self.views {
|
||||
match view.filter {
|
||||
ViewFilter::NotesAndReplies => {
|
||||
let refs: Vec<NoteRef> = new_refs.iter().map(|(_note, nr)| *nr).collect();
|
||||
|
||||
view.insert(&refs, reversed);
|
||||
let res: Vec<&NotePayload<'_>> = payloads.iter().collect();
|
||||
if let Some(res) = view.insert(res, ndb, txn, reversed) {
|
||||
res.process(unknown_ids, ndb, txn);
|
||||
}
|
||||
}
|
||||
|
||||
ViewFilter::Notes => {
|
||||
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);
|
||||
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);
|
||||
|
||||
if ViewFilter::filter_notes(cached_note, note) {
|
||||
filtered_refs.push(*nr);
|
||||
if ViewFilter::filter_notes(cached_note, &payload.note) {
|
||||
filtered_payloads.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
view.insert(&filtered_refs, reversed);
|
||||
if let Some(res) = view.insert(filtered_payloads, ndb, txn, reversed) {
|
||||
res.process(unknown_ids, ndb, txn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,6 +457,18 @@ 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,
|
||||
@@ -492,10 +525,11 @@ 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) {
|
||||
if let Err(err) = setup_timeline_nostrdb_sub(ndb, txn, note_cache, timeline) {
|
||||
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) {
|
||||
error!("setup_new_timeline: {err}");
|
||||
}
|
||||
}
|
||||
@@ -564,7 +598,7 @@ pub fn send_initial_timeline_filter(
|
||||
filter = filter.limit_mut(lim);
|
||||
}
|
||||
|
||||
let notes = timeline.all_or_any_notes();
|
||||
let entries = timeline.all_or_any_entries();
|
||||
|
||||
// Should we since optimize? Not always. For example
|
||||
// if we only have a few notes locally. One way to
|
||||
@@ -572,8 +606,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, notes.len()) {
|
||||
filter = filter::since_optimize_filter(filter, notes);
|
||||
if can_since_optimize && filter::should_since_optimize(lim, entries.len()) {
|
||||
filter = filter::since_optimize_filter(filter, entries.latest());
|
||||
} else {
|
||||
warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", &timeline.kind);
|
||||
}
|
||||
@@ -629,6 +663,7 @@ 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
|
||||
@@ -654,7 +689,9 @@ fn setup_initial_timeline(
|
||||
.map(NoteRef::from_query_result)
|
||||
.collect();
|
||||
|
||||
timeline.insert_new(txn, ndb, note_cache, ¬es);
|
||||
if let Some(pks) = timeline.insert_new(txn, ndb, note_cache, ¬es) {
|
||||
pks.process(ndb, txn, unknown_ids);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -663,10 +700,11 @@ 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) {
|
||||
if let Err(err) = setup_timeline_nostrdb_sub(ndb, &txn, note_cache, timeline, unknown_ids) {
|
||||
error!("setup_initial_nostrdb_subs: {err}");
|
||||
}
|
||||
}
|
||||
@@ -679,6 +717,7 @@ fn setup_timeline_nostrdb_sub(
|
||||
txn: &Transaction,
|
||||
note_cache: &mut NoteCache,
|
||||
timeline: &mut Timeline,
|
||||
unknown_ids: &mut UnknownIds,
|
||||
) -> Result<()> {
|
||||
let filter_state = timeline
|
||||
.filter
|
||||
@@ -686,7 +725,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, &filter_state)?;
|
||||
setup_initial_timeline(ndb, txn, timeline, note_cache, unknown_ids, &filter_state)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -701,6 +740,7 @@ 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
|
||||
@@ -774,7 +814,8 @@ 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, &filter).expect("setup init");
|
||||
setup_initial_timeline(ndb, &txn, timeline, note_cache, unknown_ids, &filter)
|
||||
.expect("setup init");
|
||||
timeline
|
||||
.filter
|
||||
.set_relay_state(relay_id, FilterState::ready_hybrid(filter.clone()));
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use nostrdb::NoteKey;
|
||||
use notedeck::NoteRef;
|
||||
|
||||
use crate::timeline::{
|
||||
unit::{CompositeUnit, NoteUnit, NoteUnitFragment},
|
||||
MergeKind,
|
||||
};
|
||||
|
||||
type StorageIndex = usize;
|
||||
|
||||
/// Provides efficient access to `NoteUnit`s
|
||||
/// Useful for threads and timelines
|
||||
/// when reversed=false, sorts from newest to oldest
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NoteUnits {
|
||||
reversed: bool,
|
||||
storage: Vec<NoteUnit>,
|
||||
lookup: HashMap<NoteKey, StorageIndex>, // `NoteKey` to index in `NoteUnits::storage`
|
||||
order: Vec<StorageIndex>, // the sorted order of the `NoteUnit`s in `NoteUnits::storage`
|
||||
}
|
||||
|
||||
impl NoteUnits {
|
||||
pub fn values(&self) -> Values<'_> {
|
||||
Values {
|
||||
set: self,
|
||||
front: 0,
|
||||
back: self.order.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains_key(&self, k: &NoteKey) -> bool {
|
||||
self.lookup.contains_key(k)
|
||||
}
|
||||
|
||||
pub fn new_with_cap(cap: usize, reversed: bool) -> Self {
|
||||
Self {
|
||||
reversed,
|
||||
storage: Vec::with_capacity(cap),
|
||||
lookup: HashMap::with_capacity(cap),
|
||||
order: Vec::with_capacity(cap),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.storage.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.storage.is_empty()
|
||||
}
|
||||
|
||||
/// Get the kth index from 0..Self::len
|
||||
pub fn kth(&self, k: usize) -> Option<&NoteUnit> {
|
||||
if k >= self.order.len() {
|
||||
return None;
|
||||
}
|
||||
let idx = if self.reversed {
|
||||
self.order[self.order.len() - 1 - k]
|
||||
} else {
|
||||
self.order[k]
|
||||
};
|
||||
Some(&self.storage[idx])
|
||||
}
|
||||
|
||||
/// Core bulk insert for already-built `NoteUnit`s
|
||||
/// Merges new `NoteUnit`s into `Self::storage`
|
||||
/// Updates `Self::order`
|
||||
fn merge_many_internal(
|
||||
&mut self,
|
||||
mut units: Vec<NoteUnit>,
|
||||
touched_indices: &[usize],
|
||||
) -> InsertManyResponse {
|
||||
units.retain(|e| !self.lookup.contains_key(&e.key()));
|
||||
if units.is_empty() && touched_indices.is_empty() {
|
||||
return InsertManyResponse::Zero;
|
||||
}
|
||||
|
||||
let mut touched = Vec::new();
|
||||
if !touched_indices.is_empty() {
|
||||
touched = touched_indices.to_vec();
|
||||
touched.sort_unstable(); // sort for later reinsertion
|
||||
touched.dedup();
|
||||
self.order.retain(|i| touched.binary_search(i).is_err()); // temporarily remove touched from Self::order
|
||||
}
|
||||
|
||||
units.sort_unstable();
|
||||
units.dedup_by_key(|u| u.key());
|
||||
|
||||
let base = self.storage.len();
|
||||
let mut new_order = Vec::with_capacity(units.len());
|
||||
self.storage.reserve(units.len());
|
||||
for (i, unit) in units.into_iter().enumerate() {
|
||||
let idx = base + i;
|
||||
let key = unit.key();
|
||||
self.storage.push(unit);
|
||||
self.lookup.insert(key, idx);
|
||||
new_order.push(idx);
|
||||
}
|
||||
|
||||
let inserted_new = new_order.len();
|
||||
|
||||
let front_insertion = inserted_new > 0
|
||||
&& if self.order.is_empty() || new_order.is_empty() {
|
||||
true
|
||||
} else if !self.reversed {
|
||||
let first_new = *new_order.first().unwrap();
|
||||
let last_old = *self.order.last().unwrap();
|
||||
self.storage[first_new] >= self.storage[last_old]
|
||||
} else {
|
||||
let last_new = *new_order.last().unwrap();
|
||||
let first_old = *self.order.first().unwrap();
|
||||
self.storage[last_new] <= self.storage[first_old]
|
||||
};
|
||||
|
||||
let mut merged = Vec::with_capacity(self.order.len() + new_order.len());
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < self.order.len() && j < new_order.len() {
|
||||
let index_left = self.order[i];
|
||||
let index_right = new_order[j];
|
||||
let left_item = &self.storage[index_left];
|
||||
let right_item = &self.storage[index_right];
|
||||
if left_item <= right_item {
|
||||
// left_item is newer than right_item
|
||||
merged.push(index_left);
|
||||
i += 1;
|
||||
} else {
|
||||
merged.push(index_right);
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
merged.extend_from_slice(&self.order[i..]);
|
||||
merged.extend_from_slice(&new_order[j..]);
|
||||
|
||||
// reinsert touched
|
||||
for touched_index in touched {
|
||||
let pos = merged
|
||||
.binary_search_by(|&i2| self.storage[i2].cmp(&self.storage[touched_index]))
|
||||
.unwrap_or_else(|p| p);
|
||||
merged.insert(pos, touched_index);
|
||||
}
|
||||
|
||||
self.order = merged;
|
||||
|
||||
if inserted_new == 0 {
|
||||
InsertManyResponse::Zero
|
||||
} else if front_insertion {
|
||||
InsertManyResponse::Some {
|
||||
entries_merged: inserted_new,
|
||||
merge_kind: MergeKind::FrontInsert,
|
||||
}
|
||||
} else {
|
||||
InsertManyResponse::Some {
|
||||
entries_merged: inserted_new,
|
||||
merge_kind: MergeKind::Spliced,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges `NoteUnitFragment`s
|
||||
/// `NoteUnitFragment::Single` is added normally
|
||||
/// if `NoteUnitFragment::Composite` exists already, it will fold the fragment into the `CompositeUnit`
|
||||
/// otherwise, it will generate the `NoteUnit::CompositeUnit` from the `NoteUnitFragment::Composite`
|
||||
pub fn merge_fragments(&mut self, frags: Vec<NoteUnitFragment>) -> InsertManyResponse {
|
||||
let mut to_build: HashMap<NoteKey, CompositeUnit> = HashMap::new(); // new composites by key
|
||||
let mut singles_to_build: Vec<NoteRef> = Vec::new();
|
||||
let mut singles_seen: HashSet<NoteKey> = HashSet::new();
|
||||
|
||||
let mut touched = Vec::new();
|
||||
for frag in frags {
|
||||
match frag {
|
||||
NoteUnitFragment::Single(note_ref) => {
|
||||
let key = note_ref.key;
|
||||
if self.lookup.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
if singles_seen.insert(key) {
|
||||
singles_to_build.push(note_ref);
|
||||
}
|
||||
}
|
||||
NoteUnitFragment::Composite(c_frag) => {
|
||||
let key = c_frag.get_underlying_noteref().key;
|
||||
|
||||
if let Some(&storage_idx) = self.lookup.get(&key) {
|
||||
if let Some(NoteUnit::Composite(c_unit)) = self.storage.get_mut(storage_idx)
|
||||
{
|
||||
if c_frag.get_latest_ref() < c_unit.get_latest_ref() {
|
||||
touched.push(storage_idx);
|
||||
}
|
||||
c_frag.fold_into(c_unit);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// aggregate for new composite
|
||||
use std::collections::hash_map::Entry;
|
||||
match to_build.entry(key) {
|
||||
Entry::Occupied(mut o) => {
|
||||
c_frag.fold_into(o.get_mut());
|
||||
}
|
||||
Entry::Vacant(v) => {
|
||||
v.insert(c_frag.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut items: Vec<NoteUnit> = Vec::with_capacity(singles_to_build.len() + to_build.len());
|
||||
items.extend(singles_to_build.into_iter().map(NoteUnit::Single));
|
||||
items.extend(to_build.into_values().map(NoteUnit::Composite));
|
||||
|
||||
self.merge_many_internal(items, &touched)
|
||||
}
|
||||
|
||||
/// Convienience method to merge a single note
|
||||
pub fn merge_single_unit(&mut self, note_ref: NoteRef) -> InsertionResponse {
|
||||
match self.merge_many_internal(vec![NoteUnit::Single(note_ref)], &[]) {
|
||||
InsertManyResponse::Zero => InsertionResponse::AlreadyExists,
|
||||
InsertManyResponse::Some {
|
||||
entries_merged: _,
|
||||
merge_kind,
|
||||
} => InsertionResponse::Merged(merge_kind),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn latest_ref(&self) -> Option<&NoteRef> {
|
||||
if self.reversed {
|
||||
self.order.last().map(|&i| &self.storage[i])
|
||||
} else {
|
||||
self.order.first().map(|&i| &self.storage[i])
|
||||
}
|
||||
.map(NoteUnit::get_latest_ref)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum InsertManyResponse {
|
||||
Zero,
|
||||
Some {
|
||||
entries_merged: usize,
|
||||
merge_kind: MergeKind,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Values<'a> {
|
||||
set: &'a NoteUnits,
|
||||
front: usize,
|
||||
back: usize,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Values<'a> {
|
||||
type Item = &'a NoteUnit;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.front >= self.back {
|
||||
return None;
|
||||
}
|
||||
let idx = if !self.set.reversed {
|
||||
let i = self.front;
|
||||
self.front += 1;
|
||||
self.set.order[i]
|
||||
} else {
|
||||
self.back -= 1;
|
||||
self.set.order[self.back]
|
||||
};
|
||||
Some(&self.set.storage[idx])
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> DoubleEndedIterator for Values<'a> {
|
||||
fn next_back(&mut self) -> Option<Self::Item> {
|
||||
if self.front >= self.back {
|
||||
return None;
|
||||
}
|
||||
let idx = if !self.set.reversed {
|
||||
self.back -= 1;
|
||||
self.set.order[self.back]
|
||||
} else {
|
||||
let i = self.front;
|
||||
self.front += 1;
|
||||
self.set.order[i]
|
||||
};
|
||||
Some(&self.set.storage[idx])
|
||||
}
|
||||
}
|
||||
|
||||
pub enum InsertionResponse {
|
||||
AlreadyExists,
|
||||
Merged(MergeKind),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use egui::ahash::HashMap;
|
||||
use enostr::Pubkey;
|
||||
use nostrdb::NoteKey;
|
||||
use notedeck::NoteRef;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::timeline::{
|
||||
unit::{
|
||||
CompositeFragment, CompositeUnit, NoteUnit, NoteUnitFragment, Reaction,
|
||||
ReactionFragment, ReactionUnit,
|
||||
},
|
||||
NoteUnits,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct UnitBuilder {
|
||||
counter: u64,
|
||||
frags: HashMap<String, NoteUnitFragment>,
|
||||
units: NoteUnits,
|
||||
}
|
||||
|
||||
impl UnitBuilder {
|
||||
fn counter(&mut self) -> u64 {
|
||||
let res = self.counter;
|
||||
self.counter += 1;
|
||||
res
|
||||
}
|
||||
|
||||
fn random_sender(&mut self) -> Pubkey {
|
||||
let mut out = [0u8; 32];
|
||||
out[..8].copy_from_slice(&self.counter().to_le_bytes());
|
||||
|
||||
Pubkey::new(out)
|
||||
}
|
||||
|
||||
fn build_fragment(&mut self, reacted_to: NoteRef) -> NoteUnitFragment {
|
||||
NoteUnitFragment::Composite(CompositeFragment::Reaction(ReactionFragment {
|
||||
noteref_reacted_to: reacted_to,
|
||||
reaction_note_ref: NoteRef {
|
||||
key: NoteKey::new(self.counter()),
|
||||
created_at: self.counter(),
|
||||
},
|
||||
reaction: Reaction {
|
||||
reaction: "+".to_owned(),
|
||||
sender: self.random_sender(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn fragment(&mut self, reacted_to: NoteRef) -> String {
|
||||
let frag = self.build_fragment(reacted_to);
|
||||
let id = Uuid::new_v4().to_string();
|
||||
self.frags.insert(id.clone(), frag.clone());
|
||||
|
||||
self.units.merge_fragments(vec![frag]);
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
fn fragments_pair(&mut self, reacted_to: NoteRef) -> (String, String) {
|
||||
let frag1 = self.build_fragment(reacted_to);
|
||||
let frag2 = self.build_fragment(reacted_to);
|
||||
|
||||
self.units
|
||||
.merge_fragments(vec![frag1.clone(), frag2.clone()]);
|
||||
|
||||
let id1 = Uuid::new_v4().to_string();
|
||||
self.frags.insert(id1.clone(), frag1);
|
||||
let id2 = Uuid::new_v4().to_string();
|
||||
self.frags.insert(id2.clone(), frag2);
|
||||
|
||||
(id1, id2)
|
||||
}
|
||||
|
||||
fn generate_reaction_note(&mut self) -> NoteRef {
|
||||
NoteRef {
|
||||
key: NoteKey::new(self.counter()),
|
||||
created_at: self.counter(),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_note(&mut self) -> String {
|
||||
let note_ref = NoteRef {
|
||||
key: NoteKey::new(self.counter()),
|
||||
created_at: self.counter(),
|
||||
};
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
self.frags
|
||||
.insert(id.clone(), NoteUnitFragment::Single(note_ref.clone()));
|
||||
|
||||
self.units.merge_single_unit(note_ref);
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
fn expected_reactions(&mut self, ids: Vec<&String>) -> NoteUnit {
|
||||
let mut reactions = BTreeMap::new();
|
||||
let mut reaction_id = None;
|
||||
let mut senders = HashSet::new();
|
||||
for id in ids {
|
||||
let NoteUnitFragment::Composite(CompositeFragment::Reaction(reac)) =
|
||||
self.frags.get(id).unwrap()
|
||||
else {
|
||||
panic!("got something other than reaction");
|
||||
};
|
||||
|
||||
if let Some(prev_reac_id) = reaction_id {
|
||||
if prev_reac_id != reac.noteref_reacted_to {
|
||||
panic!("internal error");
|
||||
}
|
||||
}
|
||||
|
||||
reaction_id = Some(reac.noteref_reacted_to);
|
||||
|
||||
reactions.insert(reac.reaction_note_ref, reac.reaction.clone());
|
||||
senders.insert(reac.reaction.sender);
|
||||
}
|
||||
|
||||
NoteUnit::Composite(CompositeUnit::Reaction(ReactionUnit {
|
||||
note_reacted_to: reaction_id.unwrap(),
|
||||
reactions,
|
||||
senders: senders,
|
||||
}))
|
||||
}
|
||||
|
||||
fn expected_single(&mut self, id: &String) -> NoteUnit {
|
||||
let Some(NoteUnitFragment::Single(note_ref)) = self.frags.get(id) else {
|
||||
panic!("fail");
|
||||
};
|
||||
|
||||
NoteUnit::Single(*note_ref)
|
||||
}
|
||||
|
||||
fn asserted_at(&self, index: usize) -> NoteUnit {
|
||||
self.units.kth(index).unwrap().clone()
|
||||
}
|
||||
|
||||
fn aeq(&mut self, units_kth: usize, expect: Expect) {
|
||||
assert_eq!(
|
||||
self.asserted_at(units_kth),
|
||||
match expect {
|
||||
Expect::Single(id) => self.expected_single(id),
|
||||
Expect::Reaction(items) => self.expected_reactions(items),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum Expect<'a> {
|
||||
Single(&'a String),
|
||||
Reaction(Vec<&'a String>),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test() {
|
||||
let mut builder = UnitBuilder::default();
|
||||
let reaction_note = builder.generate_reaction_note();
|
||||
|
||||
let single0 = builder.insert_note();
|
||||
builder.aeq(0, Expect::Single(&single0));
|
||||
|
||||
let reac1 = builder.fragment(reaction_note);
|
||||
builder.aeq(0, Expect::Reaction(vec![&reac1]));
|
||||
builder.aeq(1, Expect::Single(&single0));
|
||||
|
||||
let single1 = builder.insert_note();
|
||||
builder.aeq(0, Expect::Single(&single1));
|
||||
builder.aeq(1, Expect::Reaction(vec![&reac1]));
|
||||
builder.aeq(2, Expect::Single(&single0));
|
||||
|
||||
let reac2 = builder.fragment(reaction_note);
|
||||
builder.aeq(0, Expect::Reaction(vec![&reac2, &reac1]));
|
||||
builder.aeq(1, Expect::Single(&single1));
|
||||
builder.aeq(2, Expect::Single(&single0));
|
||||
|
||||
let single2 = builder.insert_note();
|
||||
builder.aeq(0, Expect::Single(&single2));
|
||||
builder.aeq(1, Expect::Reaction(vec![&reac2, &reac1]));
|
||||
builder.aeq(2, Expect::Single(&single1));
|
||||
builder.aeq(3, Expect::Single(&single0));
|
||||
|
||||
let reac3 = builder.fragment(reaction_note);
|
||||
builder.aeq(0, Expect::Reaction(vec![&reac1, &reac2, &reac3]));
|
||||
builder.aeq(1, Expect::Single(&single2));
|
||||
builder.aeq(2, Expect::Single(&single1));
|
||||
builder.aeq(3, Expect::Single(&single0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test2() {
|
||||
let mut builder = UnitBuilder::default();
|
||||
let reaction_note1 = builder.generate_reaction_note();
|
||||
let reaction_note2 = builder.generate_reaction_note();
|
||||
|
||||
let single0 = builder.insert_note();
|
||||
builder.aeq(0, Expect::Single(&single0));
|
||||
|
||||
let reac1_1 = builder.fragment(reaction_note1);
|
||||
builder.aeq(0, Expect::Reaction(vec![&reac1_1]));
|
||||
builder.aeq(1, Expect::Single(&single0));
|
||||
|
||||
let reac2_1 = builder.fragment(reaction_note2);
|
||||
builder.aeq(0, Expect::Reaction(vec![&reac2_1]));
|
||||
builder.aeq(1, Expect::Reaction(vec![&reac1_1]));
|
||||
builder.aeq(2, Expect::Single(&single0));
|
||||
|
||||
let single1 = builder.insert_note();
|
||||
builder.aeq(0, Expect::Single(&single1));
|
||||
builder.aeq(1, Expect::Reaction(vec![&reac2_1]));
|
||||
builder.aeq(2, Expect::Reaction(vec![&reac1_1]));
|
||||
builder.aeq(3, Expect::Single(&single0));
|
||||
|
||||
let reac1_2 = builder.fragment(reaction_note1);
|
||||
builder.aeq(0, Expect::Reaction(vec![&reac1_2, &reac1_1]));
|
||||
builder.aeq(1, Expect::Single(&single1));
|
||||
builder.aeq(2, Expect::Reaction(vec![&reac2_1]));
|
||||
builder.aeq(3, Expect::Single(&single0));
|
||||
|
||||
let single2 = builder.insert_note();
|
||||
builder.aeq(0, Expect::Single(&single2));
|
||||
builder.aeq(1, Expect::Reaction(vec![&reac1_2, &reac1_1]));
|
||||
builder.aeq(2, Expect::Single(&single1));
|
||||
builder.aeq(3, Expect::Reaction(vec![&reac2_1]));
|
||||
builder.aeq(4, Expect::Single(&single0));
|
||||
|
||||
let reac1_3 = builder.fragment(reaction_note1);
|
||||
builder.aeq(0, Expect::Reaction(vec![&reac1_2, &reac1_1, &reac1_3]));
|
||||
builder.aeq(1, Expect::Single(&single2));
|
||||
builder.aeq(2, Expect::Single(&single1));
|
||||
builder.aeq(3, Expect::Reaction(vec![&reac2_1]));
|
||||
builder.aeq(4, Expect::Single(&single0));
|
||||
|
||||
let reac2_2 = builder.fragment(reaction_note2);
|
||||
builder.aeq(0, Expect::Reaction(vec![&reac2_1, &reac2_2]));
|
||||
builder.aeq(1, Expect::Reaction(vec![&reac1_2, &reac1_1, &reac1_3]));
|
||||
builder.aeq(2, Expect::Single(&single2));
|
||||
builder.aeq(3, Expect::Single(&single1));
|
||||
builder.aeq(4, Expect::Single(&single0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test3() {
|
||||
let mut builder = UnitBuilder::default();
|
||||
let reaction_note1 = builder.generate_reaction_note();
|
||||
|
||||
let single1 = builder.insert_note();
|
||||
builder.aeq(0, Expect::Single(&single1));
|
||||
|
||||
let reac0 = builder.fragment(reaction_note1);
|
||||
builder.aeq(0, Expect::Reaction(vec![&reac0]));
|
||||
builder.aeq(1, Expect::Single(&single1));
|
||||
|
||||
let (reac1, reac2) = builder.fragments_pair(reaction_note1);
|
||||
builder.aeq(0, Expect::Reaction(vec![&reac0, &reac1, &reac2]));
|
||||
builder.aeq(1, Expect::Single(&single1));
|
||||
|
||||
let single2 = builder.insert_note();
|
||||
builder.aeq(0, Expect::Single(&single2));
|
||||
builder.aeq(1, Expect::Reaction(vec![&reac0, &reac1, &reac2]));
|
||||
builder.aeq(2, Expect::Single(&single1));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
use std::{
|
||||
collections::{BTreeSet, HashSet},
|
||||
hash::Hash,
|
||||
};
|
||||
|
||||
use egui_nav::ReturnType;
|
||||
use egui_virtual_list::VirtualList;
|
||||
use enostr::{NoteId, RelayPool};
|
||||
@@ -13,13 +8,13 @@ use notedeck::{NoteCache, NoteRef, UnknownIds};
|
||||
use crate::{
|
||||
actionbar::{process_thread_notes, NewThreadNotes},
|
||||
multi_subscriber::ThreadSubs,
|
||||
timeline::MergeKind,
|
||||
timeline::{note_units::NoteUnits, unit::NoteUnit, InsertionResponse},
|
||||
};
|
||||
|
||||
use super::ThreadSelection;
|
||||
|
||||
pub struct ThreadNode {
|
||||
pub replies: HybridSet<NoteRef>,
|
||||
pub replies: SingleNoteUnits,
|
||||
pub prev: ParentState,
|
||||
pub have_all_ancestors: bool,
|
||||
pub list: VirtualList,
|
||||
@@ -33,103 +28,10 @@ pub enum ParentState {
|
||||
Parent(NoteId),
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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 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),
|
||||
replies: SingleNoteUnits::new(true),
|
||||
prev: parent,
|
||||
have_all_ancestors: false,
|
||||
list: VirtualList::new(),
|
||||
@@ -487,3 +389,34 @@ impl NoteSeenFlags {
|
||||
self.flags.contains_key(¬e_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SingleNoteUnits {
|
||||
units: NoteUnits,
|
||||
}
|
||||
|
||||
impl SingleNoteUnits {
|
||||
pub fn new(reversed: bool) -> Self {
|
||||
Self {
|
||||
units: NoteUnits::new_with_cap(0, reversed),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, note_ref: NoteRef) -> InsertionResponse {
|
||||
self.units.merge_single_unit(note_ref)
|
||||
}
|
||||
|
||||
pub fn values(&self) -> impl Iterator<Item = &NoteRef> {
|
||||
self.units.values().filter_map(|entry| {
|
||||
if let NoteUnit::Single(note_ref) = entry {
|
||||
Some(note_ref)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn contains_key(&self, k: &NoteKey) -> bool {
|
||||
self.units.contains_key(k)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use enostr::Pubkey;
|
||||
use nostrdb::{Ndb, Note, NoteKey, Transaction};
|
||||
use notedeck::NoteRef;
|
||||
|
||||
use crate::timeline::{
|
||||
note_units::{InsertManyResponse, NoteUnits},
|
||||
unit::{CompositeFragment, NoteUnit, NoteUnitFragment, Reaction, ReactionFragment},
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TimelineUnits {
|
||||
pub units: NoteUnits,
|
||||
}
|
||||
|
||||
impl TimelineUnits {
|
||||
pub fn with_capacity(cap: usize) -> Self {
|
||||
Self {
|
||||
units: NoteUnits::new_with_cap(cap, false),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_refs_single(refs: Vec<NoteRef>) -> Self {
|
||||
let mut entries = TimelineUnits::default();
|
||||
refs.into_iter().for_each(|r| entries.merge_single_note(r));
|
||||
entries
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.units.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.units.len() == 0
|
||||
}
|
||||
|
||||
/// returns number of new entries merged
|
||||
pub fn merge_new_notes<'a>(
|
||||
&mut self,
|
||||
payloads: Vec<&'a NotePayload>,
|
||||
ndb: &Ndb,
|
||||
txn: &Transaction,
|
||||
) -> MergeResponse<'a> {
|
||||
let mut unknown_pks = HashSet::with_capacity(payloads.len());
|
||||
let new_fragments = payloads
|
||||
.into_iter()
|
||||
.filter_map(|p| to_fragment(p, ndb, txn))
|
||||
.map(|f| {
|
||||
if let Some(pk) = f.unknown_pk {
|
||||
unknown_pks.insert(pk);
|
||||
}
|
||||
f.fragment
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tl_response = if unknown_pks.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(UnknownPks { unknown_pks })
|
||||
};
|
||||
|
||||
MergeResponse {
|
||||
insertion_response: self.units.merge_fragments(new_fragments),
|
||||
tl_response,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn latest(&self) -> Option<&NoteRef> {
|
||||
self.units.latest_ref()
|
||||
}
|
||||
|
||||
pub fn merge_single_note(&mut self, note_ref: NoteRef) {
|
||||
self.units.merge_single_unit(note_ref);
|
||||
}
|
||||
|
||||
/// Used in the view
|
||||
pub fn get(&self, index: usize) -> Option<&NoteUnit> {
|
||||
self.units.kth(index)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MergeResponse<'a> {
|
||||
pub insertion_response: InsertManyResponse,
|
||||
pub tl_response: Option<UnknownPks<'a>>,
|
||||
}
|
||||
|
||||
pub struct UnknownPks<'a> {
|
||||
pub(crate) unknown_pks: HashSet<&'a [u8; 32]>,
|
||||
}
|
||||
|
||||
pub struct NoteUnitFragmentResponse<'a> {
|
||||
pub fragment: NoteUnitFragment,
|
||||
pub unknown_pk: Option<&'a [u8; 32]>,
|
||||
}
|
||||
|
||||
pub struct NotePayload<'a> {
|
||||
pub note: Note<'a>,
|
||||
pub key: NoteKey,
|
||||
}
|
||||
|
||||
fn to_fragment<'a>(
|
||||
payload: &'a NotePayload,
|
||||
ndb: &Ndb,
|
||||
txn: &Transaction,
|
||||
) -> Option<NoteUnitFragmentResponse<'a>> {
|
||||
match payload.note.kind() {
|
||||
1 => Some(NoteUnitFragmentResponse {
|
||||
fragment: NoteUnitFragment::Single(NoteRef {
|
||||
key: payload.key,
|
||||
created_at: payload.note.created_at(),
|
||||
}),
|
||||
unknown_pk: None,
|
||||
}),
|
||||
7 => to_reaction(payload, ndb, txn).map(|r| NoteUnitFragmentResponse {
|
||||
fragment: NoteUnitFragment::Composite(CompositeFragment::Reaction(r.fragment)),
|
||||
unknown_pk: Some(r.pk),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_reaction<'a>(
|
||||
payload: &'a NotePayload,
|
||||
ndb: &Ndb,
|
||||
txn: &Transaction,
|
||||
) -> Option<ReactionResponse<'a>> {
|
||||
let reaction = payload.note.content();
|
||||
|
||||
let mut note_reacted_to = None;
|
||||
|
||||
for tag in payload.note.tags() {
|
||||
if tag.count() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some("e") = tag.get_str(0) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(react_to_id) = tag.get_id(1) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
note_reacted_to = Some(react_to_id);
|
||||
}
|
||||
|
||||
let reacted_to_noteid = note_reacted_to?;
|
||||
|
||||
let reaction_note_ref = NoteRef {
|
||||
key: payload.key,
|
||||
created_at: payload.note.created_at(),
|
||||
};
|
||||
|
||||
let reacted_to_note = ndb.get_note_by_id(txn, reacted_to_noteid).ok()?;
|
||||
|
||||
let noteref_reacted_to = NoteRef {
|
||||
key: reacted_to_note.key()?,
|
||||
created_at: reacted_to_note.created_at(),
|
||||
};
|
||||
|
||||
Some(ReactionResponse {
|
||||
fragment: ReactionFragment {
|
||||
noteref_reacted_to,
|
||||
reaction_note_ref,
|
||||
reaction: Reaction {
|
||||
reaction: reaction.to_string(),
|
||||
sender: Pubkey::new(*payload.note.pubkey()),
|
||||
},
|
||||
},
|
||||
pk: payload.note.pubkey(),
|
||||
})
|
||||
}
|
||||
|
||||
pub struct ReactionResponse<'a> {
|
||||
fragment: ReactionFragment,
|
||||
pk: &'a [u8; 32],
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use enostr::Pubkey;
|
||||
use nostrdb::NoteKey;
|
||||
use notedeck::NoteRef;
|
||||
|
||||
/// A `NoteUnit` represents a cohesive piece of data derived from notes
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NoteUnit {
|
||||
Single(NoteRef), // A single note
|
||||
Composite(CompositeUnit),
|
||||
}
|
||||
|
||||
impl NoteUnit {
|
||||
pub fn key(&self) -> NoteKey {
|
||||
match self {
|
||||
NoteUnit::Single(note_ref) => note_ref.key,
|
||||
NoteUnit::Composite(clustered_entry) => clustered_entry.key(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_underlying_noteref(&self) -> &NoteRef {
|
||||
match self {
|
||||
NoteUnit::Single(note_ref) => note_ref,
|
||||
NoteUnit::Composite(clustered) => match clustered {
|
||||
CompositeUnit::Reaction(reaction_entry) => &reaction_entry.note_reacted_to,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_latest_ref(&self) -> &NoteRef {
|
||||
match self {
|
||||
NoteUnit::Single(note_ref) => note_ref,
|
||||
NoteUnit::Composite(composite_unit) => composite_unit.get_latest_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for NoteUnit {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.get_latest_ref().cmp(other.get_latest_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for NoteUnit {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.get_latest_ref() == other.get_latest_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for NoteUnit {}
|
||||
|
||||
impl PartialOrd for NoteUnit {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
/// Combines potentially many notes into one cohesive piece of data
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CompositeUnit {
|
||||
Reaction(ReactionUnit),
|
||||
}
|
||||
|
||||
impl CompositeUnit {
|
||||
pub fn get_latest_ref(&self) -> &NoteRef {
|
||||
match self {
|
||||
CompositeUnit::Reaction(reaction_unit) => reaction_unit.get_latest_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for CompositeUnit {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::Reaction(l0), Self::Reaction(r0)) => l0 == r0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CompositeUnit {
|
||||
pub fn key(&self) -> NoteKey {
|
||||
match self {
|
||||
CompositeUnit::Reaction(reaction_entry) => reaction_entry.note_reacted_to.key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CompositeFragment> for CompositeUnit {
|
||||
fn from(value: CompositeFragment) -> Self {
|
||||
match value {
|
||||
CompositeFragment::Reaction(reaction_fragment) => {
|
||||
CompositeUnit::Reaction(reaction_fragment.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents all the reactions to a specific note `ReactionUnit::note_reacted_to`
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct ReactionUnit {
|
||||
pub note_reacted_to: NoteRef, // NOTE: this should not be modified after it's created
|
||||
pub reactions: BTreeMap<NoteRef, Reaction>,
|
||||
pub senders: HashSet<Pubkey>, // useful for making sure the same user can't add more than one reaction to a note
|
||||
}
|
||||
|
||||
impl ReactionUnit {
|
||||
pub fn get_latest_ref(&self) -> &NoteRef {
|
||||
self.reactions
|
||||
.first_key_value()
|
||||
.map(|(r, _)| r)
|
||||
.unwrap_or(&self.note_reacted_to)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ReactionFragment> for ReactionUnit {
|
||||
fn from(frag: ReactionFragment) -> Self {
|
||||
let mut senders = HashSet::new();
|
||||
senders.insert(frag.reaction.sender);
|
||||
|
||||
let mut reactions = BTreeMap::new();
|
||||
reactions.insert(frag.reaction_note_ref, frag.reaction);
|
||||
|
||||
Self {
|
||||
note_reacted_to: frag.noteref_reacted_to,
|
||||
reactions,
|
||||
senders,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum NoteUnitFragment {
|
||||
Single(NoteRef),
|
||||
Composite(CompositeFragment),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CompositeFragment {
|
||||
Reaction(ReactionFragment),
|
||||
}
|
||||
|
||||
impl CompositeFragment {
|
||||
pub fn fold_into(self, unit: &mut CompositeUnit) {
|
||||
match self {
|
||||
CompositeFragment::Reaction(reaction_fragment) => reaction_fragment.fold_into(unit),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> NoteKey {
|
||||
match self {
|
||||
CompositeFragment::Reaction(reaction_fragment) => {
|
||||
reaction_fragment.reaction_note_ref.key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_underlying_noteref(&self) -> &NoteRef {
|
||||
match self {
|
||||
CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.noteref_reacted_to,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_latest_ref(&self) -> &NoteRef {
|
||||
match self {
|
||||
CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.reaction_note_ref,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A singluar reaction to a note
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReactionFragment {
|
||||
pub noteref_reacted_to: NoteRef,
|
||||
pub reaction_note_ref: NoteRef,
|
||||
pub reaction: Reaction,
|
||||
}
|
||||
|
||||
impl ReactionFragment {
|
||||
/// Add all the contents of Self into `CompositeUnit`
|
||||
pub fn fold_into(self, unit: &mut CompositeUnit) {
|
||||
match unit {
|
||||
CompositeUnit::Reaction(reaction_unit) => {
|
||||
if self.noteref_reacted_to != reaction_unit.note_reacted_to {
|
||||
return;
|
||||
}
|
||||
|
||||
if reaction_unit.senders.contains(&self.reaction.sender) {
|
||||
return;
|
||||
}
|
||||
|
||||
reaction_unit.senders.insert(self.reaction.sender);
|
||||
reaction_unit
|
||||
.reactions
|
||||
.insert(self.reaction_note_ref, self.reaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct Reaction {
|
||||
pub reaction: String, // can't use char because some emojis are 'grapheme clusters'
|
||||
pub sender: Pubkey,
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use crate::{
|
||||
Damus, Route,
|
||||
};
|
||||
|
||||
// TODO(kernelkind): should account for mutes
|
||||
pub fn unseen_notification(
|
||||
columns: &mut Damus,
|
||||
ndb: &nostrdb::Ndb,
|
||||
|
||||
@@ -709,6 +709,7 @@ 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)
|
||||
@@ -749,6 +750,7 @@ 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,11 +1,9 @@
|
||||
use crate::draft::{Draft, Drafts, MentionHint};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use crate::media_upload::{nostrbuild_nip96_upload, MediaPath};
|
||||
use crate::media_upload::nostrbuild_nip96_upload;
|
||||
use crate::post::{downcast_post_buffer, MentionType, NewPost};
|
||||
use crate::ui::mentions_picker::MentionPickerView;
|
||||
use crate::ui::{self, Preview, PreviewConfig};
|
||||
use crate::Result;
|
||||
|
||||
use egui::{
|
||||
text::{CCursorRange, LayoutJob},
|
||||
text_edit::TextEditOutput,
|
||||
@@ -16,19 +14,22 @@ use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool};
|
||||
use nostrdb::{Ndb, Transaction};
|
||||
use notedeck::media::gif::ensure_latest_texture;
|
||||
use notedeck::media::AnimationMode;
|
||||
#[cfg(target_os = "android")]
|
||||
use notedeck::platform::android::try_open_file_picker;
|
||||
use notedeck::platform::get_next_selected_file;
|
||||
use notedeck::{get_render_state, JobsCache, PixelDimensions, RenderState};
|
||||
|
||||
use notedeck::{
|
||||
name::get_display_name, supported_mime_hosted_at_url, tr, Localization, NoteAction, NoteContext,
|
||||
};
|
||||
use notedeck_ui::{
|
||||
app_images,
|
||||
context_menu::{input_context, PasteBehavior},
|
||||
note::render_note_preview,
|
||||
NoteOptions, ProfilePic,
|
||||
};
|
||||
|
||||
use notedeck::{
|
||||
name::get_display_name, supported_mime_hosted_at_url, tr, Localization, NoteAction, NoteContext,
|
||||
};
|
||||
use tracing::error;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use {notedeck::platform::file::emit_selected_file, notedeck::platform::file::SelectedMedia};
|
||||
|
||||
pub struct PostView<'a, 'd> {
|
||||
note_context: &'a mut NoteContext<'d>,
|
||||
@@ -341,6 +342,22 @@ impl<'a, 'd> PostView<'a, 'd> {
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> PostResponse {
|
||||
while let Some(selected_file) = get_next_selected_file() {
|
||||
match selected_file {
|
||||
Ok(selected_media) => {
|
||||
let promise = nostrbuild_nip96_upload(
|
||||
self.poster.secret_key.secret_bytes(),
|
||||
selected_media,
|
||||
);
|
||||
self.draft.uploading_media.push(promise);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
self.draft.upload_errors.push(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScrollArea::vertical()
|
||||
.id_salt(PostView::scroll_id())
|
||||
.show(ui, |ui| self.ui_no_scroll(txn, ui))
|
||||
@@ -521,22 +538,14 @@ impl<'a, 'd> PostView<'a, 'd> {
|
||||
{
|
||||
if let Some(files) = rfd::FileDialog::new().pick_files() {
|
||||
for file in files {
|
||||
match MediaPath::new(file) {
|
||||
Ok(media_path) => {
|
||||
let promise = nostrbuild_nip96_upload(
|
||||
self.poster.secret_key.secret_bytes(),
|
||||
media_path,
|
||||
);
|
||||
self.draft.uploading_media.push(promise);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
self.draft.upload_errors.push(e.to_string());
|
||||
}
|
||||
}
|
||||
emit_selected_file(SelectedMedia::from_path(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
try_open_file_picker();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ use nostrdb::Ndb;
|
||||
use notedeck::{Images, JobPool, JobsCache, Localization};
|
||||
use notedeck_ui::{
|
||||
colors,
|
||||
nip51_set::{Nip51SetUiCache, Nip51SetWidget, Nip51SetWidgetFlags, Nip51SetWidgetResponse},
|
||||
nip51_set::{Nip51SetUiCache, Nip51SetWidget, Nip51SetWidgetAction, Nip51SetWidgetFlags},
|
||||
};
|
||||
|
||||
use crate::{onboarding::Onboarding, ui::widgets::styled_button};
|
||||
|
||||
/// Display Follow Packs for the user to choose from authors trusted by the Damus team
|
||||
pub struct FollowPackOnboardingView<'a> {
|
||||
onboarding: &'a Onboarding,
|
||||
onboarding: &'a mut Onboarding,
|
||||
ui_state: &'a mut Nip51SetUiCache,
|
||||
ndb: &'a Ndb,
|
||||
images: &'a mut Images,
|
||||
@@ -33,7 +33,7 @@ pub enum FollowPacksResponse {
|
||||
|
||||
impl<'a> FollowPackOnboardingView<'a> {
|
||||
pub fn new(
|
||||
onboarding: &'a Onboarding,
|
||||
onboarding: &'a mut Onboarding,
|
||||
ui_state: &'a mut Nip51SetUiCache,
|
||||
ndb: &'a Ndb,
|
||||
images: &'a mut Images,
|
||||
@@ -71,24 +71,37 @@ impl<'a> FollowPackOnboardingView<'a> {
|
||||
.max_height(max_height)
|
||||
.show(ui, |ui| {
|
||||
egui::Frame::new().inner_margin(8.0).show(ui, |ui| {
|
||||
if let Some(resp) = Nip51SetWidget::new(
|
||||
follow_pack_state,
|
||||
self.ui_state,
|
||||
self.ndb,
|
||||
self.loc,
|
||||
self.images,
|
||||
self.job_pool,
|
||||
self.jobs,
|
||||
)
|
||||
.with_flags(Nip51SetWidgetFlags::TRUST_IMAGES)
|
||||
.ui(ui)
|
||||
{
|
||||
match resp {
|
||||
Nip51SetWidgetResponse::ViewProfile(pubkey) => {
|
||||
action = Some(OnboardingResponse::ViewProfile(pubkey));
|
||||
self.onboarding.list.borrow_mut().ui_custom_layout(
|
||||
ui,
|
||||
follow_pack_state.len(),
|
||||
|ui, index| {
|
||||
let resp = Nip51SetWidget::new(
|
||||
follow_pack_state,
|
||||
self.ui_state,
|
||||
self.ndb,
|
||||
self.loc,
|
||||
self.images,
|
||||
self.job_pool,
|
||||
self.jobs,
|
||||
)
|
||||
.with_flags(Nip51SetWidgetFlags::TRUST_IMAGES)
|
||||
.render_at_index(ui, index);
|
||||
|
||||
if let Some(cur_action) = resp.action {
|
||||
match cur_action {
|
||||
Nip51SetWidgetAction::ViewProfile(pubkey) => {
|
||||
action = Some(OnboardingResponse::ViewProfile(pubkey));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.rendered {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ impl<'a, 'd> ProfileView<'a, 'd> {
|
||||
scroll_area = scroll_area.vertical_scroll_offset(offset);
|
||||
}
|
||||
|
||||
let output = scroll_area.show(ui, |ui| {
|
||||
let output = scroll_area.show(ui, |ui| 's: {
|
||||
let mut action = None;
|
||||
let txn = Transaction::new(self.note_context.ndb).expect("txn");
|
||||
let profile = self
|
||||
@@ -85,15 +85,13 @@ impl<'a, 'd> ProfileView<'a, 'd> {
|
||||
if let Some(profile_view_action) = self.profile_body(ui, profile.as_ref()) {
|
||||
action = Some(profile_view_action);
|
||||
}
|
||||
let profile_timeline = self
|
||||
|
||||
let Some(profile_timeline) = self
|
||||
.timeline_cache
|
||||
.notes(
|
||||
self.note_context.ndb,
|
||||
self.note_context.note_cache,
|
||||
&txn,
|
||||
&TimelineKind::Profile(*self.pubkey),
|
||||
)
|
||||
.get_ptr();
|
||||
.get_mut(&TimelineKind::Profile(*self.pubkey))
|
||||
else {
|
||||
break 's action;
|
||||
};
|
||||
|
||||
profile_timeline.selected_view = tabs_ui(
|
||||
ui,
|
||||
@@ -116,7 +114,6 @@ impl<'a, 'd> ProfileView<'a, 'd> {
|
||||
|
||||
if let Some(note_action) = TimelineTabView::new(
|
||||
profile_timeline.current_view(),
|
||||
reversed,
|
||||
self.note_options,
|
||||
&txn,
|
||||
self.note_context,
|
||||
|
||||
@@ -2,7 +2,10 @@ use egui::{vec2, Align, Color32, CornerRadius, RichText, Stroke, TextEdit};
|
||||
use enostr::{NoteId, Pubkey};
|
||||
use state::TypingType;
|
||||
|
||||
use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView};
|
||||
use crate::{
|
||||
timeline::{TimelineTab, TimelineUnits},
|
||||
ui::timeline::TimelineTabView,
|
||||
};
|
||||
use egui_winit::clipboard::Clipboard;
|
||||
use nostrdb::{Filter, Ndb, Transaction};
|
||||
use notedeck::{tr, tr_plural, JobsCache, Localization, NoteAction, NoteContext, NoteRef};
|
||||
@@ -125,7 +128,7 @@ impl<'a, 'd> SearchView<'a, 'd> {
|
||||
"Got {count} result for '{query}'", // one
|
||||
"Got {count} results for '{query}'", // other
|
||||
"Search results count", // comment
|
||||
self.query.notes.notes.len(), // count
|
||||
self.query.notes.units.len(), // count
|
||||
query = &self.query.string
|
||||
));
|
||||
note_action = self.show_search_results(ui);
|
||||
@@ -153,10 +156,8 @@ impl<'a, 'd> SearchView<'a, 'd> {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(SearchView::scroll_id())
|
||||
.show(ui, |ui| {
|
||||
let reversed = false;
|
||||
TimelineTabView::new(
|
||||
&self.query.notes,
|
||||
reversed,
|
||||
self.note_options,
|
||||
self.txn,
|
||||
self.note_context,
|
||||
@@ -190,7 +191,7 @@ fn execute_search(
|
||||
return;
|
||||
};
|
||||
|
||||
tab.notes = note_refs;
|
||||
tab.units = TimelineUnits::from_refs_single(note_refs);
|
||||
tab.list.borrow_mut().reset();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ impl<'a, 'd> ThreadView<'a, 'd> {
|
||||
parent_state = ParentState::Unknown;
|
||||
}
|
||||
|
||||
for note_ref in &cur_node.replies {
|
||||
for note_ref in cur_node.replies.values() {
|
||||
if let Ok(note) = self.note_context.ndb.get_note_by_key(txn, note_ref.key) {
|
||||
note_builder.add_reply(note);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
use egui::containers::scroll_area::ScrollBarVisibility;
|
||||
use egui::{vec2, Direction, Layout, Pos2, Stroke};
|
||||
use egui::{vec2, Direction, Layout, Margin, Pos2, ScrollArea, Sense, Stroke};
|
||||
use egui_tabs::TabColor;
|
||||
use nostrdb::Transaction;
|
||||
use enostr::Pubkey;
|
||||
use nostrdb::{ProfileRecord, Transaction};
|
||||
use notedeck::name::get_display_name;
|
||||
use notedeck::ui::is_narrow;
|
||||
use notedeck::JobsCache;
|
||||
use notedeck::{JobsCache, Muted, NoteRef};
|
||||
use notedeck_ui::app_images::like_image;
|
||||
use notedeck_ui::ProfilePic;
|
||||
use std::f32::consts::PI;
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::timeline::{TimelineCache, TimelineKind, TimelineTab, ViewFilter};
|
||||
use crate::timeline::{
|
||||
CompositeUnit, NoteUnit, ReactionUnit, TimelineCache, TimelineKind, TimelineTab, ViewFilter,
|
||||
};
|
||||
use notedeck::{
|
||||
note::root_note_id_from_selected_id, tr, Localization, NoteAction, NoteContext, ScrollInfo,
|
||||
};
|
||||
@@ -20,7 +26,6 @@ pub struct TimelineView<'a, 'd> {
|
||||
timeline_id: &'a TimelineKind,
|
||||
timeline_cache: &'a mut TimelineCache,
|
||||
note_options: NoteOptions,
|
||||
reverse: bool,
|
||||
note_context: &'a mut NoteContext<'d>,
|
||||
jobs: &'a mut JobsCache,
|
||||
col: usize,
|
||||
@@ -37,13 +42,11 @@ impl<'a, 'd> TimelineView<'a, 'd> {
|
||||
jobs: &'a mut JobsCache,
|
||||
col: usize,
|
||||
) -> Self {
|
||||
let reverse = false;
|
||||
let scroll_to_top = false;
|
||||
TimelineView {
|
||||
timeline_id,
|
||||
timeline_cache,
|
||||
note_options,
|
||||
reverse,
|
||||
note_context,
|
||||
jobs,
|
||||
col,
|
||||
@@ -56,7 +59,6 @@ impl<'a, 'd> TimelineView<'a, 'd> {
|
||||
ui,
|
||||
self.timeline_id,
|
||||
self.timeline_cache,
|
||||
self.reverse,
|
||||
self.note_options,
|
||||
self.note_context,
|
||||
self.jobs,
|
||||
@@ -70,11 +72,6 @@ impl<'a, 'd> TimelineView<'a, 'd> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reversed(mut self) -> Self {
|
||||
self.reverse = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn scroll_id(
|
||||
timeline_cache: &TimelineCache,
|
||||
timeline_id: &TimelineKind,
|
||||
@@ -90,7 +87,6 @@ fn timeline_ui(
|
||||
ui: &mut egui::Ui,
|
||||
timeline_id: &TimelineKind,
|
||||
timeline_cache: &mut TimelineCache,
|
||||
reversed: bool,
|
||||
note_options: NoteOptions,
|
||||
note_context: &mut NoteContext,
|
||||
jobs: &mut JobsCache,
|
||||
@@ -186,7 +182,6 @@ fn timeline_ui(
|
||||
|
||||
TimelineTabView::new(
|
||||
timeline.current_view(),
|
||||
reversed,
|
||||
note_options,
|
||||
&txn,
|
||||
note_context,
|
||||
@@ -380,7 +375,6 @@ fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef {
|
||||
|
||||
pub struct TimelineTabView<'a, 'd> {
|
||||
tab: &'a TimelineTab,
|
||||
reversed: bool,
|
||||
note_options: NoteOptions,
|
||||
txn: &'a Transaction,
|
||||
note_context: &'a mut NoteContext<'d>,
|
||||
@@ -391,7 +385,6 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
tab: &'a TimelineTab,
|
||||
reversed: bool,
|
||||
note_options: NoteOptions,
|
||||
txn: &'a Transaction,
|
||||
note_context: &'a mut NoteContext<'d>,
|
||||
@@ -399,7 +392,6 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
|
||||
) -> Self {
|
||||
Self {
|
||||
tab,
|
||||
reversed,
|
||||
note_options,
|
||||
txn,
|
||||
note_context,
|
||||
@@ -409,57 +401,30 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
|
||||
|
||||
pub fn show(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> {
|
||||
let mut action: Option<NoteAction> = None;
|
||||
let len = self.tab.notes.len();
|
||||
let len = self.tab.units.len();
|
||||
|
||||
let is_muted = self.note_context.accounts.mutefun();
|
||||
let mute = self.note_context.accounts.mute();
|
||||
|
||||
self.tab
|
||||
.list
|
||||
.borrow_mut()
|
||||
.ui_custom_layout(ui, len, |ui, start_index| {
|
||||
.ui_custom_layout(ui, len, |ui, index| {
|
||||
// tracing::info!("rendering index: {index}");
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
|
||||
let ind = if self.reversed {
|
||||
len - start_index - 1
|
||||
} else {
|
||||
start_index
|
||||
let Some(entry) = self.tab.units.get(index) else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let note_key = self.tab.notes[ind].key;
|
||||
match self.render_entry(ui, entry, &mute) {
|
||||
RenderEntryResponse::Unsuccessful => return 0,
|
||||
|
||||
let note =
|
||||
if let Ok(note) = self.note_context.ndb.get_note_by_key(self.txn, note_key) {
|
||||
note
|
||||
} else {
|
||||
warn!("failed to query note {:?}", note_key);
|
||||
return 0;
|
||||
};
|
||||
|
||||
// should we mute the thread? we might not have it!
|
||||
let muted = if let Ok(root_id) = root_note_id_from_selected_id(
|
||||
self.note_context.ndb,
|
||||
self.note_context.note_cache,
|
||||
self.txn,
|
||||
note.id(),
|
||||
) {
|
||||
is_muted(¬e, root_id.bytes())
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !muted {
|
||||
notedeck_ui::padding(8.0, ui, |ui| {
|
||||
let resp =
|
||||
NoteView::new(self.note_context, ¬e, self.note_options, self.jobs)
|
||||
.show(ui);
|
||||
|
||||
if let Some(note_action) = resp.action {
|
||||
action = Some(note_action)
|
||||
RenderEntryResponse::Success(note_action) => {
|
||||
if let Some(cur_action) = note_action {
|
||||
action = Some(cur_action);
|
||||
}
|
||||
});
|
||||
|
||||
notedeck_ui::hline(ui);
|
||||
}
|
||||
}
|
||||
|
||||
1
|
||||
@@ -467,4 +432,204 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
|
||||
|
||||
action
|
||||
}
|
||||
|
||||
fn render_entry(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
entry: &NoteUnit,
|
||||
mute: &std::sync::Arc<Muted>,
|
||||
) -> RenderEntryResponse {
|
||||
match entry {
|
||||
NoteUnit::Single(note_ref) => render_note(
|
||||
ui,
|
||||
self.note_context,
|
||||
self.note_options,
|
||||
self.jobs,
|
||||
mute,
|
||||
self.txn,
|
||||
note_ref,
|
||||
),
|
||||
NoteUnit::Composite(composite) => match composite {
|
||||
CompositeUnit::Reaction(reaction_unit) => render_reaction_cluster(
|
||||
ui,
|
||||
self.note_context,
|
||||
self.note_options,
|
||||
self.jobs,
|
||||
mute,
|
||||
self.txn,
|
||||
reaction_unit,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_note(
|
||||
ui: &mut egui::Ui,
|
||||
note_context: &mut NoteContext,
|
||||
note_options: NoteOptions,
|
||||
jobs: &mut JobsCache,
|
||||
mute: &std::sync::Arc<Muted>,
|
||||
txn: &Transaction,
|
||||
note_ref: &NoteRef,
|
||||
) -> RenderEntryResponse {
|
||||
let note = if let Ok(note) = note_context.ndb.get_note_by_key(txn, note_ref.key) {
|
||||
note
|
||||
} else {
|
||||
warn!("failed to query note {:?}", note_ref.key);
|
||||
return RenderEntryResponse::Unsuccessful;
|
||||
};
|
||||
|
||||
let muted = if let Ok(root_id) =
|
||||
root_note_id_from_selected_id(note_context.ndb, note_context.note_cache, txn, note.id())
|
||||
{
|
||||
mute.is_muted(¬e, root_id.bytes())
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if muted {
|
||||
return RenderEntryResponse::Success(None);
|
||||
}
|
||||
|
||||
let mut action = None;
|
||||
notedeck_ui::padding(8.0, ui, |ui| {
|
||||
let resp = NoteView::new(note_context, ¬e, note_options, jobs).show(ui);
|
||||
|
||||
if let Some(note_action) = resp.action {
|
||||
action = Some(note_action);
|
||||
}
|
||||
});
|
||||
|
||||
notedeck_ui::hline(ui);
|
||||
|
||||
RenderEntryResponse::Success(action)
|
||||
}
|
||||
|
||||
fn render_reaction_cluster(
|
||||
ui: &mut egui::Ui,
|
||||
note_context: &mut NoteContext,
|
||||
note_options: NoteOptions,
|
||||
jobs: &mut JobsCache,
|
||||
mute: &std::sync::Arc<Muted>,
|
||||
txn: &Transaction,
|
||||
reaction: &ReactionUnit,
|
||||
) -> RenderEntryResponse {
|
||||
let reacted_to_key = reaction.note_reacted_to.key;
|
||||
let reacted_to_note = if let Ok(note) = note_context.ndb.get_note_by_key(txn, reacted_to_key) {
|
||||
note
|
||||
} else {
|
||||
warn!("failed to query note {:?}", reacted_to_key);
|
||||
return RenderEntryResponse::Unsuccessful;
|
||||
};
|
||||
|
||||
let profiles_to_show: Vec<ProfileEntry> = reaction
|
||||
.reactions
|
||||
.values()
|
||||
.filter(|r| !mute.is_pk_muted(r.sender.bytes()))
|
||||
.map(|r| &r.sender)
|
||||
.map(|p| ProfileEntry {
|
||||
record: note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok(),
|
||||
pk: p,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let first_name = get_display_name(profiles_to_show.iter().find_map(|opt| opt.record.as_ref()))
|
||||
.name()
|
||||
.to_string();
|
||||
let num_profiles_other = profiles_to_show.len() - 1;
|
||||
|
||||
let mut action = None;
|
||||
egui::Frame::new()
|
||||
.inner_margin(Margin::symmetric(8, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.allocate_ui_with_layout(
|
||||
vec2(ui.available_width(), 32.0),
|
||||
Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.add_sized(vec2(28.0, 28.0), like_image());
|
||||
});
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ScrollArea::horizontal()
|
||||
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
|
||||
.show(ui, |ui| {
|
||||
for entry in profiles_to_show {
|
||||
let resp = ui.add(
|
||||
&mut ProfilePic::from_profile_or_default(
|
||||
note_context.img_cache,
|
||||
entry.record.as_ref(),
|
||||
)
|
||||
.size(24.0)
|
||||
.sense(Sense::click()),
|
||||
);
|
||||
|
||||
if resp.clicked() {
|
||||
action = Some(NoteAction::Profile(*entry.pk))
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let note_type_desc = if note_context
|
||||
.accounts
|
||||
.get_selected_account()
|
||||
.key
|
||||
.pubkey
|
||||
.bytes()
|
||||
!= reacted_to_note.pubkey()
|
||||
{
|
||||
"note you were tagged in"
|
||||
} else {
|
||||
"your note"
|
||||
};
|
||||
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(52.0);
|
||||
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if num_profiles_other > 0 {
|
||||
ui.label(format!(
|
||||
"{first_name} and {num_profiles_other} others reacted to {note_type_desc}",
|
||||
));
|
||||
} else {
|
||||
ui.label(format!("{first_name} reacted to {note_type_desc}"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(48.0);
|
||||
let options = note_options
|
||||
.difference(NoteOptions::ActionBar | NoteOptions::OptionsButton)
|
||||
.union(NoteOptions::NotificationPreview);
|
||||
let resp = NoteView::new(note_context, &reacted_to_note, options, jobs).show(ui);
|
||||
|
||||
if let Some(note_action) = resp.action {
|
||||
action = Some(note_action);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
notedeck_ui::hline(ui);
|
||||
RenderEntryResponse::Success(action)
|
||||
}
|
||||
|
||||
enum RenderEntryResponse {
|
||||
Unsuccessful,
|
||||
Success(Option<NoteAction>),
|
||||
}
|
||||
|
||||
struct ProfileEntry<'a> {
|
||||
record: Option<ProfileRecord<'a>>,
|
||||
pk: &'a Pubkey,
|
||||
}
|
||||
|
||||
@@ -240,3 +240,7 @@ pub fn zap_dark_image() -> Image<'static> {
|
||||
pub fn zap_light_image() -> Image<'static> {
|
||||
zap_dark_image().tint(Color32::BLACK)
|
||||
}
|
||||
|
||||
pub fn like_image() -> Image<'static> {
|
||||
Image::new(include_image!("../../../assets/icons/like_icon_4x.png"))
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ impl Default for Nip51SetWidgetFlags {
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Nip51SetWidgetResponse {
|
||||
pub enum Nip51SetWidgetAction {
|
||||
ViewProfile(Pubkey),
|
||||
}
|
||||
|
||||
@@ -73,32 +73,62 @@ impl<'a> Nip51SetWidget<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<Nip51SetWidgetResponse> {
|
||||
fn render_set(&mut self, ui: &mut egui::Ui, set: &Nip51Set) -> Nip51SetWidgetResponse {
|
||||
if should_skip(set, &self.flags) {
|
||||
return Nip51SetWidgetResponse {
|
||||
action: None,
|
||||
rendered: false,
|
||||
};
|
||||
}
|
||||
|
||||
let action = egui::Frame::new()
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
//.fill(ui.visuals().extreme_bg_color)
|
||||
.inner_margin(Margin::same(8))
|
||||
.show(ui, |ui| {
|
||||
render_pack(
|
||||
ui,
|
||||
set,
|
||||
self.ui_state,
|
||||
self.ndb,
|
||||
self.images,
|
||||
self.job_pool,
|
||||
self.jobs,
|
||||
self.loc,
|
||||
self.flags.contains(Nip51SetWidgetFlags::TRUST_IMAGES),
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
|
||||
Nip51SetWidgetResponse {
|
||||
action,
|
||||
rendered: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_at_index(&mut self, ui: &mut egui::Ui, index: usize) -> Nip51SetWidgetResponse {
|
||||
let Some(set) = self.state.at_index(index) else {
|
||||
return Nip51SetWidgetResponse {
|
||||
action: None,
|
||||
rendered: false,
|
||||
};
|
||||
};
|
||||
|
||||
self.render_set(ui, set)
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<Nip51SetWidgetAction> {
|
||||
let mut resp = None;
|
||||
for pack in self.state.iter() {
|
||||
if should_skip(pack, &self.flags) {
|
||||
continue;
|
||||
let res = self.render_set(ui, pack);
|
||||
|
||||
if let Some(action) = res.action {
|
||||
resp = Some(action);
|
||||
}
|
||||
|
||||
egui::Frame::new()
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.fill(ui.visuals().extreme_bg_color)
|
||||
.inner_margin(Margin::same(8))
|
||||
.show(ui, |ui| {
|
||||
if let Some(cur_resp) = render_pack(
|
||||
ui,
|
||||
pack,
|
||||
self.ui_state,
|
||||
self.ndb,
|
||||
self.images,
|
||||
self.job_pool,
|
||||
self.jobs,
|
||||
self.loc,
|
||||
self.flags.contains(Nip51SetWidgetFlags::TRUST_IMAGES),
|
||||
) {
|
||||
resp = Some(cur_resp);
|
||||
}
|
||||
});
|
||||
if !res.rendered {
|
||||
continue;
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
@@ -107,6 +137,11 @@ impl<'a> Nip51SetWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Nip51SetWidgetResponse {
|
||||
pub action: Option<Nip51SetWidgetAction>,
|
||||
pub rendered: bool,
|
||||
}
|
||||
|
||||
fn should_skip(set: &Nip51Set, required: &Nip51SetWidgetFlags) -> bool {
|
||||
(required.contains(Nip51SetWidgetFlags::REQUIRES_TITLE) && set.title.is_none())
|
||||
|| (required.contains(Nip51SetWidgetFlags::REQUIRES_IMAGE) && set.image.is_none())
|
||||
@@ -126,7 +161,7 @@ fn render_pack(
|
||||
jobs: &mut JobsCache,
|
||||
loc: &mut Localization,
|
||||
image_trusted: bool,
|
||||
) -> Option<Nip51SetWidgetResponse> {
|
||||
) -> Option<Nip51SetWidgetAction> {
|
||||
let max_img_size = vec2(ui.available_width(), 200.0);
|
||||
|
||||
ui.allocate_new_ui(UiBuilder::new(), |ui| 's: {
|
||||
@@ -170,9 +205,14 @@ fn render_pack(
|
||||
)));
|
||||
}
|
||||
if let Some(desc) = &pack.description {
|
||||
ui.add(egui::Label::new(egui::RichText::new(desc).size(
|
||||
get_font_size(ui.ctx(), ¬edeck::NotedeckTextStyle::Heading3),
|
||||
)));
|
||||
ui.add(egui::Label::new(
|
||||
egui::RichText::new(desc)
|
||||
.size(get_font_size(
|
||||
ui.ctx(),
|
||||
¬edeck::NotedeckTextStyle::Heading3,
|
||||
))
|
||||
.color(ui.visuals().weak_text_color()),
|
||||
));
|
||||
}
|
||||
let checked = ui.checkbox(
|
||||
ui_state.get_select_all_state(&pack.identifier),
|
||||
@@ -199,8 +239,9 @@ fn render_pack(
|
||||
};
|
||||
|
||||
let mut resp = None;
|
||||
let txn = Transaction::new(ndb).expect("txn");
|
||||
|
||||
for pk in &pack.pks {
|
||||
let txn = Transaction::new(ndb).expect("txn");
|
||||
let m_profile = ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok();
|
||||
|
||||
let cur_state = ui_state.get_pk_selected_state(&pack.identifier, pk);
|
||||
@@ -210,13 +251,15 @@ fn render_pack(
|
||||
|
||||
ui.separator();
|
||||
if render_profile_item(ui, images, m_profile.as_ref(), cur_state) {
|
||||
resp = Some(Nip51SetWidgetResponse::ViewProfile(*pk));
|
||||
resp = Some(Nip51SetWidgetAction::ViewProfile(*pk));
|
||||
}
|
||||
}
|
||||
|
||||
resp
|
||||
}
|
||||
|
||||
const PFP_SIZE: f32 = 32.0;
|
||||
|
||||
fn render_profile_item(
|
||||
ui: &mut egui::Ui,
|
||||
images: &mut Images,
|
||||
@@ -224,7 +267,7 @@ fn render_profile_item(
|
||||
checked: &mut bool,
|
||||
) -> bool {
|
||||
let (card_rect, card_resp) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), 48.0), egui::Sense::click());
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), PFP_SIZE), egui::Sense::click());
|
||||
|
||||
let mut clicked_response = card_resp;
|
||||
|
||||
@@ -246,13 +289,14 @@ fn render_profile_item(
|
||||
|
||||
clicked_response = clicked_response.union(resp.response);
|
||||
|
||||
let (pfp_rect, body_rect) = remaining_rect.split_left_right_at_x(remaining_rect.left() + 48.0);
|
||||
let (pfp_rect, body_rect) =
|
||||
remaining_rect.split_left_right_at_x(remaining_rect.left() + PFP_SIZE);
|
||||
|
||||
let _ = ui.allocate_new_ui(UiBuilder::new().max_rect(pfp_rect), |ui| {
|
||||
let pfp_resp = ui.add(
|
||||
&mut ProfilePic::new(images, get_profile_url(profile))
|
||||
.sense(Sense::click())
|
||||
.size(48.0),
|
||||
.size(PFP_SIZE),
|
||||
);
|
||||
|
||||
clicked_response = clicked_response.union(pfp_resp);
|
||||
@@ -273,7 +317,7 @@ fn render_profile_item(
|
||||
if let Some(disp) = name.display_name {
|
||||
let galley = painter.layout_no_wrap(
|
||||
disp.to_owned(),
|
||||
NotedeckTextStyle::Heading3.get_font_id(ui.ctx()),
|
||||
NotedeckTextStyle::Body.get_font_id(ui.ctx()),
|
||||
ui.visuals().text_color(),
|
||||
);
|
||||
|
||||
|
||||
@@ -334,14 +334,14 @@ fn render_undecorated_note_contents<'a>(
|
||||
.selectable(selectable),
|
||||
);
|
||||
} else {
|
||||
ui.add(
|
||||
Label::new(
|
||||
RichText::new(block_str)
|
||||
.text_style(NotedeckTextStyle::NoteBody.text_style()),
|
||||
)
|
||||
.wrap()
|
||||
.selectable(selectable),
|
||||
);
|
||||
let mut richtext = RichText::new(block_str)
|
||||
.text_style(NotedeckTextStyle::NoteBody.text_style());
|
||||
|
||||
if options.contains(NoteOptions::NotificationPreview) {
|
||||
richtext = richtext.color(egui::Color32::from_rgb(0x87, 0x87, 0x8D));
|
||||
}
|
||||
|
||||
ui.add(Label::new(richtext).wrap().selectable(selectable));
|
||||
}
|
||||
// don't render any more blocks
|
||||
if truncate {
|
||||
|
||||
@@ -426,16 +426,19 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
||||
) -> egui::InnerResponse<NoteUiResponse> {
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
|
||||
let mut note_action: Option<NoteAction> = None;
|
||||
let pfp_rect = ui
|
||||
.horizontal(|ui| {
|
||||
let mut pfp_rect = None;
|
||||
|
||||
if !self.flags.contains(NoteOptions::NotificationPreview) {
|
||||
ui.horizontal(|ui| {
|
||||
let pfp_resp = self.pfp(note_key, profile, ui);
|
||||
let pfp_rect = pfp_resp.bounding_rect;
|
||||
pfp_rect = Some(pfp_resp.bounding_rect);
|
||||
note_action = pfp_resp
|
||||
.into_action(self.note.pubkey())
|
||||
.or(note_action.take());
|
||||
|
||||
let size = ui.available_size();
|
||||
ui.vertical(|ui| 's: {
|
||||
|
||||
ui.vertical(|ui| {
|
||||
ui.add_sized(
|
||||
[size.x, self.options().pfp_size() as f32],
|
||||
|ui: &mut egui::Ui| {
|
||||
@@ -460,7 +463,7 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
||||
.borrow(self.note.tags());
|
||||
|
||||
if note_reply.reply().is_none() {
|
||||
break 's;
|
||||
return;
|
||||
}
|
||||
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
@@ -477,10 +480,8 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
||||
.or(note_action.take());
|
||||
});
|
||||
});
|
||||
|
||||
pfp_rect
|
||||
})
|
||||
.inner;
|
||||
});
|
||||
}
|
||||
|
||||
let mut contents =
|
||||
NoteContents::new(self.note_context, txn, self.note, self.flags, self.jobs);
|
||||
@@ -530,37 +531,51 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
||||
) -> egui::InnerResponse<NoteUiResponse> {
|
||||
// main design
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
|
||||
let pfp_resp = self.pfp(note_key, profile, ui);
|
||||
let pfp_rect = pfp_resp.bounding_rect;
|
||||
let mut note_action: Option<NoteAction> = pfp_resp.into_action(self.note.pubkey());
|
||||
let (mut note_action, pfp_rect) =
|
||||
if self.flags.contains(NoteOptions::NotificationPreview) {
|
||||
// do not render pfp
|
||||
(None, None)
|
||||
} else {
|
||||
let pfp_resp = self.pfp(note_key, profile, ui);
|
||||
let pfp_rect = pfp_resp.bounding_rect;
|
||||
(pfp_resp.into_action(self.note.pubkey()), Some(pfp_rect))
|
||||
};
|
||||
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
|
||||
NoteView::note_header(ui, self.note_context.i18n, self.note, profile, self.flags);
|
||||
|
||||
ui.horizontal_wrapped(|ui| 's: {
|
||||
ui.spacing_mut().item_spacing.x = 1.0;
|
||||
|
||||
let note_reply = self
|
||||
.note_context
|
||||
.note_cache
|
||||
.cached_note_or_insert_mut(note_key, self.note)
|
||||
.reply
|
||||
.borrow(self.note.tags());
|
||||
|
||||
if note_reply.reply().is_none() {
|
||||
break 's;
|
||||
}
|
||||
|
||||
note_action = reply_desc(
|
||||
if !self.flags.contains(NoteOptions::NotificationPreview) {
|
||||
NoteView::note_header(
|
||||
ui,
|
||||
txn,
|
||||
¬e_reply,
|
||||
self.note_context,
|
||||
self.note_context.i18n,
|
||||
self.note,
|
||||
profile,
|
||||
self.flags,
|
||||
self.jobs,
|
||||
)
|
||||
.or(note_action.take());
|
||||
});
|
||||
);
|
||||
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 1.0;
|
||||
|
||||
let note_reply = self
|
||||
.note_context
|
||||
.note_cache
|
||||
.cached_note_or_insert_mut(note_key, self.note)
|
||||
.reply
|
||||
.borrow(self.note.tags());
|
||||
|
||||
if note_reply.reply().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
note_action = reply_desc(
|
||||
ui,
|
||||
txn,
|
||||
¬e_reply,
|
||||
self.note_context,
|
||||
self.flags,
|
||||
self.jobs,
|
||||
)
|
||||
.or(note_action.take());
|
||||
});
|
||||
}
|
||||
|
||||
let mut contents =
|
||||
NoteContents::new(self.note_context, txn, self.note, self.flags, self.jobs);
|
||||
@@ -639,9 +654,12 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
||||
.then_some(NoteAction::note(NoteId::new(*self.note.id())))
|
||||
.or(note_action);
|
||||
|
||||
NoteResponse::new(response.response)
|
||||
.with_action(note_action)
|
||||
.with_pfp(note_ui_resp.pfp_rect)
|
||||
let mut resp = NoteResponse::new(response.response).with_action(note_action);
|
||||
if let Some(pfp_rect) = note_ui_resp.pfp_rect {
|
||||
resp = resp.with_pfp(pfp_rect);
|
||||
}
|
||||
|
||||
resp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,7 +705,7 @@ fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option
|
||||
|
||||
struct NoteUiResponse {
|
||||
action: Option<NoteAction>,
|
||||
pfp_rect: egui::Rect,
|
||||
pfp_rect: Option<egui::Rect>,
|
||||
}
|
||||
|
||||
struct PfpResponse {
|
||||
|
||||
@@ -38,6 +38,9 @@ bitflags! {
|
||||
|
||||
/// no animation override (accessibility)
|
||||
const NoAnimations = 1 << 17;
|
||||
|
||||
/// Styled for a notification preview
|
||||
const NotificationPreview = 1 << 18;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ fi
|
||||
|
||||
# Build the .app bundle
|
||||
echo "Building .app bundle..."
|
||||
cargo bundle --release --target $TARGET
|
||||
cargo bundle -k notedeck_chrome --release --target $TARGET
|
||||
|
||||
# Sign the app
|
||||
echo "Codesigning the app..."
|
||||
|
||||
Reference in New Issue
Block a user