73 Commits

Author SHA1 Message Date
tyiu e3e7d54142 Import translations
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-09-22 00:46:40 -04:00
William Casarin a6d91c43e4 Merge a bunch of fixes from kernel
PRs

* 1141
* 1137
* 1136

kernelkind (10):
      Revert "feat: transitively trust images from parent note"
      feat: enable transitive trust for repost
      fix `NoteUnits` front insertion logic
      fix: don't reset scroll position when switching toolbar
      fix: no longer make the scroll position jump oddly
      fix: repost desc text size on newline
      make `tabs_ui` return `InnerResponse`
      refactor: impl transitive trust via `NoteOptions::TrustMedia`
      refactor: move `profile_body` to fn
      refactor: remove unnecessary code
2025-09-16 11:28:48 -07:00
kernelkind 19fe3703d9 fix: add tag for hashtag in reply
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-13 17:15:58 -04:00
kernelkind ca67977b82 fix: don't reset scroll position when switching toolbar
Closes: https://github.com/damus-io/notedeck/issues/1140

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-13 15:37:05 -04:00
kernelkind 559e9577fc fix: no longer make the scroll position jump oddly
only allow front insert in profile when body is fully obstructed

Closes: https://github.com/damus-io/notedeck/issues/1072

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-13 15:36:18 -04:00
kernelkind 563fbb9c4b fix NoteUnits front insertion logic
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-13 15:18:29 -04:00
kernelkind 391900d393 make tabs_ui return InnerResponse
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-13 14:51:09 -04:00
kernelkind 11700d6217 refactor: move profile_body to fn
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-13 14:51:03 -04:00
kernelkind 50293a6f34 refactor: remove unnecessary code
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-13 14:49:46 -04:00
kernelkind d6182ed7c3 Revert "feat: transitively trust images from parent note"
This reverts commit ea14713b58.
2025-09-11 19:39:12 -04:00
kernelkind a0e9c8b434 feat: enable transitive trust for repost
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-11 19:38:32 -04:00
kernelkind 4ac2e59983 refactor: impl transitive trust via NoteOptions::TrustMedia
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-11 19:37:56 -04:00
kernelkind 3f1a194983 fix: repost desc text size on newline
make the repost desc size small when it is on a newline instead of
inline with the pfps, which was introduced here: eb446376

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-11 17:14:25 -04:00
William Casarin a8eaea6509 test: fix relay message tests
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-10 16:56:33 -07:00
William Casarin 9278c90802 time: more time-ago granularity in months/years
before: 1y
after:  1y 8mo

etc

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-10 16:40:13 -07:00
William Casarin 02a90eccd1 enostr: show unrecognized message in log
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-10 15:33:28 -07:00
William Casarin c0fcf53ff6 Merge a bunch of fixes by kernel
kernelkind (3):
      fix: can upload photo from reply or quote
      fix: image shimmer bug
      feat: transitively trust images from parent note
2025-09-10 12:06:31 -07:00
William Casarin f889b54ed9 refactor: replace notification bool prop drill with note option
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-10 12:04:59 -07:00
William Casarin 7b4c96df91 images: disable useless animation frame log
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-10 12:03:41 -07:00
William Casarin eb44637601 ui/timeline: make notification text smaller
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-10 12:03:23 -07:00
kernelkind ea14713b58 feat: transitively trust images from parent note
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 19:56:12 -04:00
kernelkind a5e7880e25 fix: image shimmer bug
if the same image on two seperate columns unblur at the same time,
it caused them both to continually cycle between blurred and
unblurred

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 19:20:42 -04:00
kernelkind 409ca68567 fix: can upload photo from reply or quote
moved the file retrieval check from `PostView::ui` ->
`PostView::ui_no_scroll`, which is used by the `PostReplyView` &
`QuoteRepostView`

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 18:39:02 -04:00
kernelkind 6cf193b7e3 ui: minor tweaks
closes: https://github.com/damus-io/notedeck/issues/1120

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 18:13:52 -04:00
kernelkind 5bb17cd810 log: info -> debug for ndb can't find repost
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 17:56:26 -04:00
kernelkind ba359c95c2 allow reposts in "Notes" timeline tab
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 17:56:23 -04:00
kernelkind e0ed122951 use NdbQueryPackage to call ndb::query multiple times
necessary to ensure we can retrieve reposts from ndb

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 17:56:19 -04:00
kernelkind e1ad2e231f filter: add repost kind to FilteredTags::into_filter
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 17:56:14 -04:00
kernelkind 91028929b2 ui: add support for non-notification composite rendering
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 17:56:09 -04:00
kernelkind 2eef34fa1c note: remove repost from note ui
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 17:53:28 -04:00
kernelkind b8eecf0c9a introduce NdbQueryPackages
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 17:53:22 -04:00
kernelkind 1b9e77a1ff filter: remove unused code
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-09 17:53:12 -04:00
William Casarin 28634301b8 Merge localization fixes by Terry
Terry Yiu (1):
      Add missing localized strings and export strings for translation
2025-09-08 15:04:26 -07:00
William Casarin ce0d3e8e88 Merge fix blank thread from notifications by kernel
kernelkind (1):
      fix blank thread from notifications
2025-09-08 15:03:50 -07:00
William Casarin 0b4545d598 filter: reservoir sample the algo feed
so its not the same static 15 pubkeys

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-08 15:03:02 -07:00
kernelkind 6db03364fd fix blank thread from notifications
forgot to check whether underlying note is muted in reaction & reposts

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-05 16:21:33 -04:00
tyiu 97b6755504 Add missing localized strings and export strings for translation 2025-09-04 22:22:27 -04:00
William Casarin c765b031e9 ui/note: fix actionbar note responses
they are not responsive on android?

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-04 15:34:13 -07:00
William Casarin 024cf3ef91 Merge back nav threshold fix by kernel #1118 2025-09-04 14:25:43 -07:00
kernelkind 3a0da9a3e0 nav: reduce back nav threshold from 1/2 to 1/4
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 14:24:07 -07:00
kernelkind 10b62a073b test: NoteUnits repost test
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:53 -04:00
kernelkind ac212b96a6 process repost notes
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:49 -04:00
kernelkind 637b05c1e2 make get_reposted_note pub
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:46 -04:00
kernelkind f436b49fec add Repost to composite unit & fragment
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:43 -04:00
kernelkind 04ce29d1dd ui: render repost cluster impl
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:40 -04:00
kernelkind ae1d5ab1c5 add CompositeType::Repost
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:37 -04:00
kernelkind 7caf77aa1c image: repost_image wrapper
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:34 -04:00
kernelkind 80ae489967 ui: repost description impl
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:31 -04:00
kernelkind 259c0b677a add RepostUnit & RepostFragment
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:27 -04:00
kernelkind 3b7f1f1b39 test: better naming for NoteUnits tests
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:24 -04:00
kernelkind f2258ab16b add NotePayload::noteref helper
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:21 -04:00
kernelkind 571435cf85 ui: modularize composite entry rendering & fix tr
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:53:11 -04:00
kernelkind 8f8ff42156 NoteUnits: use UnitKey instead of just NoteKey
in preparation for multiple composite types

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:45:39 -04:00
kernelkind 3a95ba05a8 add ReactionFragment error msg
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-04 15:45:31 -04:00
William Casarin 73e44d1497 Merge Translations by terry et al #1082
Terry Yiu (2):
      Export strings for translation
      Import translations
2025-09-02 15:10:26 -07:00
William Casarin 43b98fc6ed Merge add keys section to settings by kernel #1096
kernelkind (4):
      add `AnimationHelper::scaled_rect`
      add copy to clipboard img
      make eye button public
      add keys section to settings
2025-09-02 15:09:38 -07:00
William Casarin 95ee275153 Merge custom-zap: dont force keyboard by kernel #1097
kernelkind (1):
      custom-zap: dont force keyboard
2025-09-02 15:08:53 -07:00
kernelkind 8bc54cc519 zap: add requirements for zapping user
these requirements are specified by nip 57 but weren't implemented

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-01 17:07:53 -04:00
kernelkind 5282373434 use PayCache when zapping
to avoid needlessly querying ln endpoint

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-01 17:07:43 -04:00
kernelkind 14c59a6c94 introduce PayCache
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-01 17:03:57 -04:00
kernelkind 09238baee0 add LNUrlPayResponse
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-01 17:03:52 -04:00
kernelkind 594072cfb8 make get_users_zap_address Result
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-01 17:03:41 -04:00
kernelkind 2882b1c2d9 move ZapAddress to zaps/mod.rs
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-01 17:01:08 -04:00
kernelkind f4b8d235eb rename get_users_zap_endpoint -> get_users_zap_address
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-01 17:01:05 -04:00
kernelkind cf48b29fd8 make endpoint error into struct
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-01 17:01:01 -04:00
kernelkind 2a7c5eb983 rename LNUrlPayRequest -> LNUrlPayResponseRaw
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-09-01 13:33:33 -04:00
kernelkind 72d696beb2 actionbar: reintroduce error messages
there was a regression that caused error messages to not be displayed
any more when zapping.

Now when you click the zap button and the zap fails for some reason, the
zap button will be replaced with an X and hovering over the X displays
the error message

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-31 19:34:51 -04:00
kernelkind dea695fa8e actionbar: don't early return
it's not good practice to early return while rendering,
super easy to introduce flickering

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-31 19:29:11 -04:00
kernelkind fc1caf5eb4 custom-zap: dont force keyboard
it's not necessary

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-28 16:46:26 -04:00
kernelkind 5539e4ef82 add keys section to settings
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-28 16:27:51 -04:00
kernelkind 408afbda50 make eye button public
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-28 16:27:32 -04:00
kernelkind af4b896739 add copy to clipboard img
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-28 16:27:08 -04:00
kernelkind d448caa369 add AnimationHelper::scaled_rect
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-28 16:26:36 -04:00
45 changed files with 2295 additions and 846 deletions
Generated
+9 -8
View File
@@ -193,7 +193,7 @@ dependencies = [
"objc2-foundation 0.3.1",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
"x11rb",
]
@@ -246,7 +246,7 @@ dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.1",
"rand 0.9.2",
"raw-window-handle",
"serde",
"serde_repr",
@@ -1555,7 +1555,7 @@ dependencies = [
[[package]]
name = "egui_nav"
version = "0.2.0"
source = "git+https://github.com/damus-io/egui-nav?rev=de6e2d51892478fdd516df166f866e64dedbae07#de6e2d51892478fdd516df166f866e64dedbae07"
source = "git+https://github.com/damus-io/egui-nav?rev=e4231c19dda9e6791d2f7b5cd610b8db5ff9a7f9#e4231c19dda9e6791d2f7b5cd610b8db5ff9a7f9"
dependencies = [
"egui",
"egui_extras",
@@ -3542,6 +3542,7 @@ dependencies = [
"profiling",
"puffin",
"puffin_egui",
"rand 0.9.2",
"regex",
"secp256k1 0.30.0",
"serde",
@@ -3683,7 +3684,7 @@ dependencies = [
"nostrdb",
"notedeck",
"notedeck_ui",
"rand 0.9.1",
"rand 0.9.2",
"serde",
"serde_json",
"sha2",
@@ -4603,7 +4604,7 @@ dependencies = [
"bytes",
"getrandom 0.3.3",
"lru-slab",
"rand 0.9.1",
"rand 0.9.2",
"ring",
"rustc-hash 2.1.1",
"rustls",
@@ -4657,9 +4658,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.1"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
@@ -6278,7 +6279,7 @@ dependencies = [
"http",
"httparse",
"log",
"rand 0.9.1",
"rand 0.9.2",
"rustls",
"rustls-pki-types",
"sha1",
+2 -1
View File
@@ -19,6 +19,7 @@ chrono = "0.4.40"
base32 = "0.4.0"
base64 = "0.22.1"
rmpv = "1.3.0"
rand = "0.9.2"
bech32 = { version = "0.11", default-features = false }
bitflags = "2.5.0"
dirs = "5.0.1"
@@ -27,7 +28,7 @@ egui = { version = "0.31.1", features = ["serde"] }
egui-wgpu = "0.31.1"
egui_extras = { version = "0.31.1", features = ["all_loaders"] }
egui-winit = { version = "0.31.1", features = ["android-game-activity", "clipboard"] }
egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "de6e2d51892478fdd516df166f866e64dedbae07" }
egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "e4231c19dda9e6791d2f7b5cd610b8db5ff9a7f9" }
egui_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "6eb91740577b374a8a6658c09c9a4181299734d0" }
#egui_virtual_list = "0.6.0"
egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b6794f5e707a2f4109633770e02b02fb722e1" }
+3
View File
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33337 5.33337V3.46671C5.33337 2.71997 5.33337 2.3466 5.4787 2.06139C5.60653 1.8105 5.8105 1.60653 6.06139 1.4787C6.3466 1.33337 6.71997 1.33337 7.46671 1.33337H12.5334C13.2801 1.33337 13.6535 1.33337 13.9387 1.4787C14.1896 1.60653 14.3936 1.8105 14.5214 2.06139C14.6667 2.3466 14.6667 2.71997 14.6667 3.46671V8.53337C14.6667 9.28011 14.6667 9.65351 14.5214 9.93871C14.3936 10.1896 14.1896 10.3936 13.9387 10.5214C13.6535 10.6667 13.2801 10.6667 12.5334 10.6667H10.6667M3.46671 14.6667H8.53337C9.28011 14.6667 9.65351 14.6667 9.93871 14.5214C10.1896 14.3936 10.3936 14.1896 10.5214 13.9387C10.6667 13.6535 10.6667 13.2801 10.6667 12.5334V7.46671C10.6667 6.71997 10.6667 6.3466 10.5214 6.06139C10.3936 5.8105 10.1896 5.60653 9.93871 5.4787C9.65351 5.33337 9.28011 5.33337 8.53337 5.33337H3.46671C2.71997 5.33337 2.3466 5.33337 2.06139 5.4787C1.8105 5.60653 1.60653 5.8105 1.4787 6.06139C1.33337 6.3466 1.33337 6.71997 1.33337 7.46671V12.5334C1.33337 13.2801 1.33337 13.6535 1.4787 13.9387C1.60653 14.1896 1.8105 14.3936 2.06139 14.5214C2.3466 14.6667 2.71997 14.6667 3.46671 14.6667Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+62
View File
@@ -46,6 +46,9 @@ Add_Hashtag_Column_ebf4 = Add Hashtag Column
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = Add Last Notes Column
# Tooltip text for adding a new deck button
Add_new_deck_f2fc = Add new deck
# Column title for adding notifications column
Add_Notifications_Column_79f8 = Add Notifications Column
@@ -136,6 +139,9 @@ Copy_Note_ID_6b45 = Copy Note ID
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = Copy Note JSON
# Tooltip text for copying npub to clipboard
Copy_npub_to_clipboard_c105 = Copy npub to clipboard
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = Copy Pubkey
@@ -208,6 +214,9 @@ Display_name_f9d9 = Display name
# Domain identification message
domain___will_be_used_for_identification_b67e = "{$domain}" will be used for identification
# Button to indicate that the user is done going through the onboarding process.
Done_50dd = Done
# Column title for editing deck
Edit_Deck_4018 = Edit Deck
@@ -283,6 +292,9 @@ k_5K_f7e6 = 5K
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Keep track of your notes & replies
# label for keys setting section
Keys_435f = Keys
# Label for language, Appearance settings section
Language_e264 = Language:
@@ -310,6 +322,18 @@ Moves_this_column_to_another_position_0d4b = Moves this column to another positi
# Title for the user's deck
My_Deck_4ac5 = My Deck
# reaction from user to a note you were tagged in
name__reacted_to_a_note_you_were_tagged_in_4b62 = {$name} reacted to a note you were tagged in
# reaction from user to your note
name__reacted_to_your_note_ead9 = {$name} reacted to your note
# repost from user
name__reposted_a_note_you_were_tagged_in_1379 = {$name} reposted a note you were tagged in
# repost from user
name__reposted_your_note_1379 = {$name} reposted your note
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = New to Nostr?
@@ -385,6 +409,9 @@ Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard_
# Profile picture URL field label
Profile_picture_81ff = Profile picture
# label describing public key
PUBLIC_ACCOUNT_ID_4394 = PUBLIC ACCOUNT ID
# Column title for quote composition
Quote_475c = Quote
@@ -463,6 +490,9 @@ Search_notes_42a6 = Search notes...
# Search in progress message
Searching_for___query_5d18 = Searching for '{$query}'
# label describing secret key
SECRET_ACCOUNT_LOGIN_KEY_8440 = SECRET ACCOUNT LOGIN KEY
# Description for Home column
See_notes_from_your_contacts_ac16 = See notes from your contacts
@@ -609,3 +639,35 @@ Got__count__results_for___query_85fb =
[one] Got {$count} result for '{$query}'
*[other] Got {$count} results for '{$query}'
}
# amount of reactions a note you were tagged in received
name__and__count__others_reacted_to_a_note_you_were_tagged_in_181a =
{ $count ->
[one] {$name} and {$count} other reacted to a note you were tagged in
*[other] {$name} and {$count} others reacted to a note you were tagged in
}
# describing the amount of reactions your note received
name__and__count__others_reacted_to_your_note_0f6a =
{ $count ->
[one] {$name} and {$count} other reacted to your note
*[other] {$name} and {$count} others reacted to your note
}
# describing the amount of reposts a note you were tagged in received
name__and__count__others_reposted_a_note_you_were_tagged_in_08e1 =
{ $count ->
[one] {$name} and {$count} other reposted a note you were tagged in
*[other] {$name} and {$count} others reposted a note you were tagged in
}
# describing the amount of reposts your note received
name__and__count__others_reposted_your_note_70a0 =
{ $count ->
[one] {$name} and {$count} other reposted your note
*[other] {$name} and {$count} others reposted your note
}
+62
View File
@@ -46,6 +46,9 @@ Add_Hashtag_Column_ebf4 = {"["}Àdd Hàshtàg Çólúmñ{"]"}
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = {"["}Àdd Làst Ñótés Çólúmñ{"]"}
# Tooltip text for adding a new deck button
Add_new_deck_f2fc = {"["}Àdd ñéw déçk{"]"}
# Column title for adding notifications column
Add_Notifications_Column_79f8 = {"["}Àdd Ñótífíçàtíóñs Çólúmñ{"]"}
@@ -136,6 +139,9 @@ Copy_Note_ID_6b45 = {"["}Çópy Ñóté ÍD{"]"}
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = {"["}Çópy Ñóté JSÓÑ{"]"}
# Tooltip text for copying npub to clipboard
Copy_npub_to_clipboard_c105 = {"["}Çópy ñpúb tó çlípbóàrd{"]"}
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = {"["}Çópy Púbkéy{"]"}
@@ -208,6 +214,9 @@ Display_name_f9d9 = {"["}Dísplày ñàmé{"]"}
# Domain identification message
domain___will_be_used_for_identification_b67e = {"["}"{$domain}" wíll bé úséd fór ídéñtífíçàtíóñ{"]"}
# Button to indicate that the user is done going through the onboarding process.
Done_50dd = {"["}Dóñé{"]"}
# Column title for editing deck
Edit_Deck_4018 = {"["}Édít Déçk{"]"}
@@ -283,6 +292,9 @@ k_5K_f7e6 = {"["}5K{"]"}
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = {"["}Kéép tràçk óf yóúr ñótés & réplíés{"]"}
# label for keys setting section
Keys_435f = {"["}Kéys{"]"}
# Label for language, Appearance settings section
Language_e264 = {"["}Làñgúàgé:{"]"}
@@ -310,6 +322,18 @@ Moves_this_column_to_another_position_0d4b = {"["}Móvés thís çólúmñ tó
# Title for the user's deck
My_Deck_4ac5 = {"["}My Déçk{"]"}
# reaction from user to a note you were tagged in
name__reacted_to_a_note_you_were_tagged_in_4b62 = {"["}{$name} réàçtéd tó à ñóté yóú wéré tàggéd íñ{"]"}
# reaction from user to your note
name__reacted_to_your_note_ead9 = {"["}{$name} réàçtéd tó yóúr ñóté{"]"}
# repost from user
name__reposted_a_note_you_were_tagged_in_1379 = {"["}{$name} répóstéd à ñóté yóú wéré tàggéd íñ{"]"}
# repost from user
name__reposted_your_note_1379 = {"["}{$name} répóstéd yóúr ñóté{"]"}
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = {"["}Ñéw tó Ñóstr?{"]"}
@@ -385,6 +409,9 @@ Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard_
# Profile picture URL field label
Profile_picture_81ff = {"["}Prófílé píçtúré{"]"}
# label describing public key
PUBLIC_ACCOUNT_ID_4394 = {"["}PÚBLÍÇ ÀÇÇÓÚÑT ÍD{"]"}
# Column title for quote composition
Quote_475c = {"["}Qúóté{"]"}
@@ -463,6 +490,9 @@ Search_notes_42a6 = {"["}Séàrçh ñótés...{"]"}
# Search in progress message
Searching_for___query_5d18 = {"["}Séàrçhíñg fór '{$query}'{"]"}
# label describing secret key
SECRET_ACCOUNT_LOGIN_KEY_8440 = {"["}SÉÇRÉT ÀÇÇÓÚÑT LÓGÍÑ KÉY{"]"}
# Description for Home column
See_notes_from_your_contacts_ac16 = {"["}Séé ñótés fróm yóúr çóñtàçts{"]"}
@@ -609,3 +639,35 @@ Got__count__results_for___query_85fb =
[one] {"["}Gót {$count} résúlt fór '{$query}'{"]"}
*[other] {"["}Gót {$count} résúlts fór '{$query}'{"]"}
}
# amount of reactions a note you were tagged in received
name__and__count__others_reacted_to_a_note_you_were_tagged_in_181a =
{ $count ->
[one] {"["}{$name} àñd {$count} óthér réàçtéd tó à ñóté yóú wéré tàggéd íñ{"]"}
*[other] {"["}{$name} àñd {$count} óthérs réàçtéd tó à ñóté yóú wéré tàggéd íñ{"]"}
}
# describing the amount of reactions your note received
name__and__count__others_reacted_to_your_note_0f6a =
{ $count ->
[one] {"["}{$name} àñd {$count} óthér réàçtéd tó yóúr ñóté{"]"}
*[other] {"["}{$name} àñd {$count} óthérs réàçtéd tó yóúr ñóté{"]"}
}
# describing the amount of reposts a note you were tagged in received
name__and__count__others_reposted_a_note_you_were_tagged_in_08e1 =
{ $count ->
[one] {"["}{$name} àñd {$count} óthér répóstéd à ñóté yóú wéré tàggéd íñ{"]"}
*[other] {"["}{$name} àñd {$count} óthérs répóstéd à ñóté yóú wéré tàggéd íñ{"]"}
}
# describing the amount of reposts your note received
name__and__count__others_reposted_your_note_70a0 =
{ $count ->
[one] {"["}{$name} àñd {$count} óthér répóstéd yóúr ñóté{"]"}
*[other] {"["}{$name} àñd {$count} óthérs répóstéd yóúr ñóté{"]"}
}
+55 -7
View File
@@ -33,6 +33,8 @@ Add_External_Notifications_Column_41ae = Agregar columna de notificaciones exter
Add_Hashtag_Column_ebf4 = Agregar columna de hashtags
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = Agregar columna de últimas notas
# Tooltip text for adding a new deck button
Add_new_deck_f2fc = Agregar nuevo deck
# Column title for adding notifications column
Add_Notifications_Column_79f8 = Agregar columna de notificaciones
# Button label to add a relay
@@ -93,6 +95,8 @@ Copy_Link_dc7c = Copiar enlace
Copy_Note_ID_6b45 = Copiar ID de nota
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = Copiar JSON de nota
# Tooltip text for copying npub to clipboard
Copy_npub_to_clipboard_c105 = Copiar npub al portapapeles
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = Copiar pubkey
# Copy the text content of the note to clipboard
@@ -141,6 +145,8 @@ Delete_Wallet_d1d4 = Eliminar billetera
Display_name_f9d9 = Nombre para mostrar
# Domain identification message
domain___will_be_used_for_identification_b67e = "{ $domain }" se utilizará para la identificación
# Button to indicate that the user is done going through the onboarding process.
Done_50dd = Listo
# Column title for editing deck
Edit_Deck_4018 = Editar deck
# Button label to edit a deck
@@ -162,7 +168,7 @@ Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__ns
# Label for find user button
Find_User_bd12 = Buscar usuario
# Label for font size, Appearance settings section
Font_size_dd73 = Font size:
Font_size_dd73 = Tamaño de la fuente:
# Title for hashtags column
Hashtags_f8e0 = Hashtags
# Title for Home column
@@ -191,6 +197,8 @@ k_50K_c2dc = 50.000
k_5K_f7e6 = 5.000
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Haz seguimiento de tus notas y respuestas
# label for keys setting section
Keys_435f = Claves
# Label for language, Appearance settings section
Language_e264 = Idioma:
# Title for last note per user column
@@ -209,6 +217,14 @@ Media_from_someone_you_don_t_follow_5611 = Contenido multimedia de alguien que n
Moves_this_column_to_another_position_0d4b = Mueve esta columna a otra posición
# Title for the user's deck
My_Deck_4ac5 = Mi deck
# reaction from user to a note you were tagged in
name__reacted_to_a_note_you_were_tagged_in_4b62 = { $name } reaccionó a una nota en la que te etiquetaron
# reaction from user to your note
name__reacted_to_your_note_ead9 = { $name } reaccionó a tu nota
# repost from user
name__reposted_a_note_you_were_tagged_in_1379 = { $name } volvió a publicar una nota en la que te etiquetaron
# repost from user
name__reposted_your_note_1379 = { $name } volvió a publicar tu nota
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = ¿Primera vez en Nostr?
# NIP-05 identity field label
@@ -236,7 +252,9 @@ Notifications_ef56 = Notificaciones
# Relative time for very recent events (less than 3 seconds)
now_2181 = ahora
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = On
On_f412 = Activado
# Column title for finding users to follow
Onboarding_4a25 = Incorporación
# Button label to open email client
Open_Email_25e9 = Abrir correo electrónico
# Instruction to open email client
@@ -257,6 +275,8 @@ Post_now_8a49 = Publicar ahora
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = Presiona el siguiente botón para copiar los registros más recientes al portapapeles del sistema. A continuación, pégalos en tu correo electrónico.
# Profile picture URL field label
Profile_picture_81ff = Imagen de perfil
# label describing public key
PUBLIC_ACCOUNT_ID_4394 = ID DE CUENTA PÚBLICA
# Column title for quote composition
Quote_475c = Citar
# Error message when quote note cannot be found
@@ -290,7 +310,7 @@ Repost_this_note_8e56 = Volver a publicar esta nota
# Label for reposted notes
Reposted_61c8 = Publicadas de nuevo
# Label for reset note body font size, Appearance settings section
Reset_4e60 = Reset
Reset_4e60 = Restablecer
# Label for reset zoom level, Appearance settings section
Reset_62d4 = Restablecer
# Heading for support section
@@ -309,10 +329,14 @@ Search_c573 = Búsqueda
Search_notes_42a6 = Buscar notas...
# Search in progress message
Searching_for___query_5d18 = Buscando '{ $query }'
# label describing secret key
SECRET_ACCOUNT_LOGIN_KEY_8440 = CLAVE DE INICIO DE SESIÓN DE CUENTA SECRETA
# Description for Home column
See_notes_from_your_contacts_ac16 = Ver notas de tus contactos
# Description for universe column
See_the_whole_nostr_universe_7694 = Ver todo el universo de nostr
# Button to select all profiles in follow pack
Select_All_a319 = Seleccionar todo
# Button label to send a zap
Send_1ea4 = Enviar
# Column title for app settings
@@ -326,7 +350,7 @@ Someone_else_s_Notes_7e5f = Notas de otra persona
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Notificaciones de otra persona
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = Sort replies newest first:
Sort_replies_newest_first_b6c3 = Ordenar las respuestas más recientes primero:
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Busca la última nota de cada usuario en tu lista de contactos
# Description for hashtags column
@@ -352,7 +376,7 @@ Subscribe_to_someone_else_s_notes_d1e9 = Suscribirse a las notas de otra persona
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = Suscribirse a las notas de alguien
# Support email address
Support_email_44d9 = Support email:
Support_email_44d9 = Correo electrónico de ayuda:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Cambiar a modo oscuro
# Hover text for light mode toggle button
@@ -405,6 +429,30 @@ Zoom_Level_29a8 = Nivel de zoom:
# Search results count
Got__count__results_for___query_85fb =
{ $count ->
[uno] Obtuvo { $count } resultado para '{ $query }'
*[otro] Obtuvo { $count } resultados para '{ $query }'
[uno] Se obtuvo { $count } resultado para '{ $query }'
*[otro] Se obtuvieron { $count } resultados para '{ $query }'
}
# amount of reactions a note you were tagged in received
name__and__count__others_reacted_to_a_note_you_were_tagged_in_181a =
{ $count ->
[one] { $name } y { $count } persona más reaccionaron a una nota en la que te etiquetaron
*[other] { $name } y { $count } personas más reaccionaron a una nota en la que te etiquetaron
}
# describing the amount of reactions your note received
name__and__count__others_reacted_to_your_note_0f6a =
{ $count ->
[one] { $name } y { $count } persona más reaccionaron a tu nota
*[other] { $name } y { $count } personas más reaccionaron a tu nota
}
# describing the amount of reposts a note you were tagged in received
name__and__count__others_reposted_a_note_you_were_tagged_in_08e1 =
{ $count ->
[one] { $name } y { $count } persona más volvieron a publicar una nota en la que te etiquetaron
*[other] { $name } y { $count } personas más volvieron a publicar una nota en la que te etiquetaron
}
# describing the amount of reposts your note received
name__and__count__others_reposted_your_note_70a0 =
{ $count ->
[one] { $name } y { $count } persona más volvieron a publicar tu nota
*[other] { $name } y { $count } personas más volvieron a publicar tu nota
}
+55 -7
View File
@@ -33,6 +33,8 @@ Add_External_Notifications_Column_41ae = Añadir columna de notificaciones exter
Add_Hashtag_Column_ebf4 = Añadir columna de hashtags
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = Añadir columna de últimas notas
# Tooltip text for adding a new deck button
Add_new_deck_f2fc = Añadir nuevo deck
# Column title for adding notifications column
Add_Notifications_Column_79f8 = Añadir columna de notificaciones
# Button label to add a relay
@@ -93,6 +95,8 @@ Copy_Link_dc7c = Copiar enlace
Copy_Note_ID_6b45 = Copiar ID de nota
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = Copiar JSON de nota
# Tooltip text for copying npub to clipboard
Copy_npub_to_clipboard_c105 = Copiar npub al portapapeles
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = Copiar pubkey
# Copy the text content of the note to clipboard
@@ -141,6 +145,8 @@ Delete_Wallet_d1d4 = Eliminar monedero
Display_name_f9d9 = Nombre para mostrar
# Domain identification message
domain___will_be_used_for_identification_b67e = "{ $domain }" se utilizará para la identificación
# Button to indicate that the user is done going through the onboarding process.
Done_50dd = Listo
# Column title for editing deck
Edit_Deck_4018 = Editar deck
# Button label to edit a deck
@@ -162,7 +168,7 @@ Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__ns
# Label for find user button
Find_User_bd12 = Buscar usuario
# Label for font size, Appearance settings section
Font_size_dd73 = Font size:
Font_size_dd73 = Tamaño de la fuente:
# Title for hashtags column
Hashtags_f8e0 = Hashtags
# Title for Home column
@@ -191,6 +197,8 @@ k_50K_c2dc = 50.000
k_5K_f7e6 = 5.000
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Haz seguimiento de tus notas y respuestas
# label for keys setting section
Keys_435f = Claves
# Label for language, Appearance settings section
Language_e264 = Idioma:
# Title for last note per user column
@@ -209,6 +217,14 @@ Media_from_someone_you_don_t_follow_5611 = Contenido multimedia de alguien que n
Moves_this_column_to_another_position_0d4b = Mueve esta columna a otra posición
# Title for the user's deck
My_Deck_4ac5 = Mi deck
# reaction from user to a note you were tagged in
name__reacted_to_a_note_you_were_tagged_in_4b62 = { $name } ha reaccionado a una nota en la que te han etiquetado
# reaction from user to your note
name__reacted_to_your_note_ead9 = { $name } ha reaccionado a tu nota
# repost from user
name__reposted_a_note_you_were_tagged_in_1379 = { $name } ha vuelto a publicar una nota en la que te han etiquetado
# repost from user
name__reposted_your_note_1379 = { $name } ha vuelto a publicar tu nota
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = ¿Primera vez en Nostr?
# NIP-05 identity field label
@@ -236,7 +252,9 @@ Notifications_ef56 = Notificaciones
# Relative time for very recent events (less than 3 seconds)
now_2181 = ahora
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = On
On_f412 = Activado
# Column title for finding users to follow
Onboarding_4a25 = Incorporación
# Button label to open email client
Open_Email_25e9 = Abrir correo electrónico
# Instruction to open email client
@@ -257,6 +275,8 @@ Post_now_8a49 = Publicar ahora
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = Presiona el siguiente botón para copiar los registros más recientes al portapapeles del sistema. A continuación, pégalos en tu correo electrónico.
# Profile picture URL field label
Profile_picture_81ff = Imagen de perfil
# label describing public key
PUBLIC_ACCOUNT_ID_4394 = ID DE CUENTA PÚBLICA
# Column title for quote composition
Quote_475c = Citar
# Error message when quote note cannot be found
@@ -290,7 +310,7 @@ Repost_this_note_8e56 = Volver a publicar esta nota
# Label for reposted notes
Reposted_61c8 = Publicadas de nuevo
# Label for reset note body font size, Appearance settings section
Reset_4e60 = Reset
Reset_4e60 = Restablecer
# Label for reset zoom level, Appearance settings section
Reset_62d4 = Restablecer
# Heading for support section
@@ -309,10 +329,14 @@ Search_c573 = Búsqueda
Search_notes_42a6 = Buscar notas...
# Search in progress message
Searching_for___query_5d18 = Buscando '{ $query }'
# label describing secret key
SECRET_ACCOUNT_LOGIN_KEY_8440 = CLAVE DE INICIO DE SESIÓN DE CUENTA SECRETA
# Description for Home column
See_notes_from_your_contacts_ac16 = Ver notas de tus contactos
# Description for universe column
See_the_whole_nostr_universe_7694 = Ver todo el universo de nostr
# Button to select all profiles in follow pack
Select_All_a319 = Seleccionar todo
# Button label to send a zap
Send_1ea4 = Enviar
# Column title for app settings
@@ -326,7 +350,7 @@ Someone_else_s_Notes_7e5f = Notas de otra persona
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Notificaciones de otra persona
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = Sort replies newest first:
Sort_replies_newest_first_b6c3 = Ordenar las respuestas más recientes primero:
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Busca la última nota de cada usuario en tu lista de contactos
# Description for hashtags column
@@ -352,7 +376,7 @@ Subscribe_to_someone_else_s_notes_d1e9 = Suscribirse a las notas de otra persona
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = Suscribirse a las notas de alguien
# Support email address
Support_email_44d9 = Support email:
Support_email_44d9 = Correo electrónico de ayuda:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Cambiar a modo oscuro
# Hover text for light mode toggle button
@@ -405,6 +429,30 @@ Zoom_Level_29a8 = Nivel de zoom:
# Search results count
Got__count__results_for___query_85fb =
{ $count ->
[uno] Obtuvo { $count } resultado para '{ $query }'
*[otro] Obtuvo { $count } resultados para '{ $query }'
[uno] Se ha obtenido { $count } resultado para '{ $query }'
*[otro] Se han obtenido { $count } resultados para '{ $query }'
}
# amount of reactions a note you were tagged in received
name__and__count__others_reacted_to_a_note_you_were_tagged_in_181a =
{ $count ->
[one] { $name } y { $count } persona más han reaccionado a una nota en la que te han etiquetado
*[other] { $name } y { $count } personas más han reaccionado a una nota en la que te han etiquetado
}
# describing the amount of reactions your note received
name__and__count__others_reacted_to_your_note_0f6a =
{ $count ->
[one] { $name } y { $count } persona más han reaccionado a tu nota
*[other] { $name } y { $count } personas más han reaccionado a tu nota
}
# describing the amount of reposts a note you were tagged in received
name__and__count__others_reposted_a_note_you_were_tagged_in_08e1 =
{ $count ->
[one] { $name } y { $count } persona más han vuelto a publicar una nota en la que te han etiquetado
*[other] { $name } y { $count } personas más han vuelto a publicar una nota en la que te han etiquetado
}
# describing the amount of reposts your note received
name__and__count__others_reposted_your_note_70a0 =
{ $count ->
[one] { $name } y { $count } persona han vuelto a publicar tu nota
*[other] { $name } y { $count } personas más han vuelto a publicar tu nota
}
+44
View File
@@ -33,6 +33,8 @@ Add_External_Notifications_Column_41ae = Ajouter une colonne pour les notificati
Add_Hashtag_Column_ebf4 = Ajouter une colonne Hashtag
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = Ajouter une colonne pour les dernières notes
# Tooltip text for adding a new deck button
Add_new_deck_f2fc = Ajouter un nouveau deck
# Column title for adding notifications column
Add_Notifications_Column_79f8 = Ajouter une colonne pour les notifications
# Button label to add a relay
@@ -93,6 +95,8 @@ Copy_Link_dc7c = Copier le lien
Copy_Note_ID_6b45 = Copier l'ID de la note
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = Copier le JSON de la note
# Tooltip text for copying npub to clipboard
Copy_npub_to_clipboard_c105 = Copier la npub dans le presse-papiers
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = Copier la Pubkey
# Copy the text content of the note to clipboard
@@ -141,6 +145,8 @@ Delete_Wallet_d1d4 = Supprimer le portefeuille
Display_name_f9d9 = Nom d'utilisateur
# Domain identification message
domain___will_be_used_for_identification_b67e = "{ $domain }" sera utilisé pour l'identification
# Button to indicate that the user is done going through the onboarding process.
Done_50dd = Fait
# Column title for editing deck
Edit_Deck_4018 = Modifier le deck
# Button label to edit a deck
@@ -191,6 +197,8 @@ k_50K_c2dc = 50K
k_5K_f7e6 = 5K
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Gardez une trace de vos notes & réponses
# label for keys setting section
Keys_435f = Clés
# Label for language, Appearance settings section
Language_e264 = Langue :
# Title for last note per user column
@@ -209,6 +217,14 @@ Media_from_someone_you_don_t_follow_5611 = Média d'une personne que vous ne sui
Moves_this_column_to_another_position_0d4b = Déplace cette colonne vers une autre position
# Title for the user's deck
My_Deck_4ac5 = Mon deck
# reaction from user to a note you were tagged in
name__reacted_to_a_note_you_were_tagged_in_4b62 = { $name } a réagi à une note dans laquelle vous avez été tagué
# reaction from user to your note
name__reacted_to_your_note_ead9 = { $name } a réagi à votre note
# repost from user
name__reposted_a_note_you_were_tagged_in_1379 = { $name } a reposté une note dans laquelle vous avez été tagué
# repost from user
name__reposted_your_note_1379 = { $name } a reposté votre note
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = Nouveau sur Nostr ?
# NIP-05 identity field label
@@ -259,6 +275,8 @@ Post_now_8a49 = Publier maintenant
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = Cliquez sur le bouton ci-dessous pour copier vos données les plus récentes dans le presse-papiers de votre système. Collez-les ensuite dans votre courrier électronique.
# Profile picture URL field label
Profile_picture_81ff = Photo de profil
# label describing public key
PUBLIC_ACCOUNT_ID_4394 = IDENTITE PUBLIQUE DU COMPTE
# Column title for quote composition
Quote_475c = Citation
# Error message when quote note cannot be found
@@ -311,6 +329,8 @@ Search_c573 = Rechercher
Search_notes_42a6 = Rechercher des notes...
# Search in progress message
Searching_for___query_5d18 = Recherche par '{ $query }'
# label describing secret key
SECRET_ACCOUNT_LOGIN_KEY_8440 = CLÉ SECRETE DE CONNEXION DU COMPTE
# Description for Home column
See_notes_from_your_contacts_ac16 = Afficher les notes de vos contacts
# Description for universe column
@@ -412,3 +432,27 @@ Got__count__results_for___query_85fb =
[one] A obtenu { $count } pour '{ $query }'
*[other] A obtenu { $count } pour '{ $query }'
}
# amount of reactions a note you were tagged in received
name__and__count__others_reacted_to_a_note_you_were_tagged_in_181a =
{ $count ->
[un] { $name } et { $count } a réagi à une note où vous êtes tagué
*[autre] { $name } et { $count } ont réagi à une note où vous êtes tagué
}
# describing the amount of reactions your note received
name__and__count__others_reacted_to_your_note_0f6a =
{ $count ->
[un] { $name } et { $count } autres ont réagi à votre note
*[autre] { $name } et { $count } autres ont réagi à votre note
}
# describing the amount of reposts a note you were tagged in received
name__and__count__others_reposted_a_note_you_were_tagged_in_08e1 =
{ $count ->
[un] { $name } et { $count } a reposté une note où vous êtes tagué
*[autre] { $name } et { $count } ont reposté une note où vous êtes tagué
}
# describing the amount of reposts your note received
name__and__count__others_reposted_your_note_70a0 =
{ $count ->
[un] { $name } et { $count } a reposté votre note
*[autre] { $name } et { $count } ont reposté votre note
}
+44
View File
@@ -33,6 +33,8 @@ Add_External_Notifications_Column_41ae = Adicionar coluna de notificações exte
Add_Hashtag_Column_ebf4 = Adicionar coluna de marcadores
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = Adicionar coluna de últimas notas
# Tooltip text for adding a new deck button
Add_new_deck_f2fc = Adicionar nova aba
# Column title for adding notifications column
Add_Notifications_Column_79f8 = Adicionar coluna de notificações
# Button label to add a relay
@@ -93,6 +95,8 @@ Copy_Link_dc7c = Copiar link
Copy_Note_ID_6b45 = Copiar ID da nota
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = Copiar JSON da nota
# Tooltip text for copying npub to clipboard
Copy_npub_to_clipboard_c105 = Copiar npub para área de transferência
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = Copiar chave pública
# Copy the text content of the note to clipboard
@@ -141,6 +145,8 @@ Delete_Wallet_d1d4 = Eliminar carteira
Display_name_f9d9 = Nome a mostrar
# Domain identification message
domain___will_be_used_for_identification_b67e = "{ $domain }" será usado para identificação
# Button to indicate that the user is done going through the onboarding process.
Done_50dd = Concluído
# Column title for editing deck
Edit_Deck_4018 = Editar aba
# Button label to edit a deck
@@ -191,6 +197,8 @@ k_50K_c2dc = 50K
k_5K_f7e6 = 5K
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Acompanha as tuas notas e respostas
# label for keys setting section
Keys_435f = Chaves
# Label for language, Appearance settings section
Language_e264 = Idioma:
# Title for last note per user column
@@ -209,6 +217,14 @@ Media_from_someone_you_don_t_follow_5611 = Conteúdo de alguém que não segues
Moves_this_column_to_another_position_0d4b = Mover esta coluna para outra posição
# Title for the user's deck
My_Deck_4ac5 = Minha aba
# reaction from user to a note you were tagged in
name__reacted_to_a_note_you_were_tagged_in_4b62 = { $name } reagiu a uma nota em que te marcaram
# reaction from user to your note
name__reacted_to_your_note_ead9 = { $name } reagiu à tua nota
# repost from user
name__reposted_a_note_you_were_tagged_in_1379 = { $name } republicou uma nota em que te marcaram
# repost from user
name__reposted_your_note_1379 = { $name } republicou a tua nota
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = Nov@ no Nostr?
# NIP-05 identity field label
@@ -259,6 +275,8 @@ Post_now_8a49 = Publicar agora
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = Prime o botão abaixo para copiar os teus registos mais recentes para a área de transferência do teu sistema. Depois cola-os no teu e-mail.
# Profile picture URL field label
Profile_picture_81ff = Foto de perfil
# label describing public key
PUBLIC_ACCOUNT_ID_4394 = ID da CONTA PÚBLICA
# Column title for quote composition
Quote_475c = Citação
# Error message when quote note cannot be found
@@ -311,6 +329,8 @@ Search_c573 = Procurar
Search_notes_42a6 = Procurar notas...
# Search in progress message
Searching_for___query_5d18 = Procurando por '{ $query }'
# label describing secret key
SECRET_ACCOUNT_LOGIN_KEY_8440 = CHAVE SECRETA DE LOGIN DA CONTA
# Description for Home column
See_notes_from_your_contacts_ac16 = Ver notas dos meus contactos
# Description for universe column
@@ -412,3 +432,27 @@ Got__count__results_for___query_85fb =
[one] { $count } resultado obtido para '{ $query }'
*[other] { $count } resultados obtidos para '{ $query }'
}
# amount of reactions a note you were tagged in received
name__and__count__others_reacted_to_a_note_you_were_tagged_in_181a =
{ $count ->
[one] { $name } e { $count } outro reagiram a uma nota em que te marcaram
*[other] { $name } e { $count } outros reagiram a uma nota que te marcaram
}
# describing the amount of reactions your note received
name__and__count__others_reacted_to_your_note_0f6a =
{ $count ->
[one] { $name } e { $count } outro reagiram à tua nota
*[other] { $name } e { $count } outros reagiram à tua nota
}
# describing the amount of reposts a note you were tagged in received
name__and__count__others_reposted_a_note_you_were_tagged_in_08e1 =
{ $count ->
[one] { $name } e { $count } outro republicaram uma nota em que te marcaram
*[other] { $name } e { $count } outros republicaram uma nota que te marcaram
}
# describing the amount of reposts your note received
name__and__count__others_reposted_your_note_70a0 =
{ $count ->
[one] { $name } e { $count } outro republicaram a tua nota
*[other] { $name } e { $count } outros republicaram a tua nota
}
+44
View File
@@ -33,6 +33,8 @@ Add_External_Notifications_Column_41ae = เพิ่มคอลัมน์ก
Add_Hashtag_Column_ebf4 = เพิ่มคอลัมน์แฮชแท็ก
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = เพิ่มคอลัมน์โน้ตล่าสุด
# Tooltip text for adding a new deck button
Add_new_deck_f2fc = เพิ่ม deck ใหม่
# Column title for adding notifications column
Add_Notifications_Column_79f8 = เพิ่มคอลัมน์การแจ้งเตือน
# Button label to add a relay
@@ -93,6 +95,8 @@ Copy_Link_dc7c = คัดลอกลิงก์
Copy_Note_ID_6b45 = คัดลอก โน้ต ID
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = คัดลอก JSON ของโน้ต
# Tooltip text for copying npub to clipboard
Copy_npub_to_clipboard_c105 = คัดลอก npub ไปยังคลิปบอร์ด
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = คัดลอก npub
# Copy the text content of the note to clipboard
@@ -141,6 +145,8 @@ Delete_Wallet_d1d4 = ลบวอลเล็ต
Display_name_f9d9 = ชื่อที่แสดง
# Domain identification message
domain___will_be_used_for_identification_b67e = { $domain } จะใช้สำหรับการระบุตัวตน
# Button to indicate that the user is done going through the onboarding process.
Done_50dd = เสร็จ
# Column title for editing deck
Edit_Deck_4018 = แก้ไข Deck
# Button label to edit a deck
@@ -193,6 +199,8 @@ k_50K_c2dc = 50K
k_5K_f7e6 = 5K
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = ติดตามโน้ตและการตอบกลับของคุณ
# label for keys setting section
Keys_435f = คีย์
# Label for language, Appearance settings section
Language_e264 = ภาษา:
# Title for last note per user column
@@ -211,6 +219,14 @@ Media_from_someone_you_don_t_follow_5611 = สื่อจากคนที่
Moves_this_column_to_another_position_0d4b = ย้ายคอลัมน์นี้ไปยังตำแหน่งอื่น
# Title for the user's deck
My_Deck_4ac5 = Deck ของฉัน
# reaction from user to a note you were tagged in
name__reacted_to_a_note_you_were_tagged_in_4b62 = { $name } react ต่อโน้ตที่คุณถูกแท็ก
# reaction from user to your note
name__reacted_to_your_note_ead9 = { $name } react ต่อโน้ตของคุณ
# repost from user
name__reposted_a_note_you_were_tagged_in_1379 = { $name } รีโพสต์โน้ตที่คุณถูกแท็ก
# repost from user
name__reposted_your_note_1379 = { $name } รีโพสต์โน้ตของคุณ
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = มือใหม่สำหรับ Nostr?
# NIP-05 identity field label
@@ -261,6 +277,8 @@ Post_now_8a49 = โพสต์
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = กดปุ่มด้านล่างเพื่อคัดลอกข้อมูลบันทึกล่าสุด ไปยังคลิปบอร์ด จากนั้นนำไปวางในอีเมลของคุณ
# Profile picture URL field label
Profile_picture_81ff = รูปโปรไฟล์
# label describing public key
PUBLIC_ACCOUNT_ID_4394 = ไอดีบัญชีสาธารณะ
# Column title for quote composition
Quote_475c = อ้างอิง
# Error message when quote note cannot be found
@@ -313,6 +331,8 @@ Search_c573 = ค้นหา
Search_notes_42a6 = ค้นหาโน้ต...
# Search in progress message
Searching_for___query_5d18 = กำลังค้นหา '{ $query }'
# label describing secret key
SECRET_ACCOUNT_LOGIN_KEY_8440 = คีย์ลับสำหรับล็อกอินบัญชี
# Description for Home column
See_notes_from_your_contacts_ac16 = ดูโน้ตจากผู้ติดต่อของคุณ
# Description for universe column
@@ -414,3 +434,27 @@ Got__count__results_for___query_85fb =
[one] ผลการค้นหา '{ $query }': พบ { $count } รายการ
*[other] ผลการค้นหา '{ $query }': พบ { $count } รายการ
}
# amount of reactions a note you were tagged in received
name__and__count__others_reacted_to_a_note_you_were_tagged_in_181a =
{ $count ->
[one] { $name } และอีก { $count } คน reacted ต่อโน้ตที่คุณถูกแท็ก
*[other] { $name } และอีก { $count } คน reacted ต่อโน้ตที่คุณถูกแท็ก
}
# describing the amount of reactions your note received
name__and__count__others_reacted_to_your_note_0f6a =
{ $count ->
[one] { $name } และอีก { $count } คน reacted ต่อโน้ตที่ของคุณ
*[other] { $name } และอีก { $count } คน reacted ต่อโน้ตของคุณ
}
# describing the amount of reposts a note you were tagged in received
name__and__count__others_reposted_a_note_you_were_tagged_in_08e1 =
{ $count ->
[one] { $name } และอีก { $count } คน รีโพสต์โน้ตที่คุณถูกแท็ก
*[other] { $name } และอีก { $count } คน รีโพสต์โน้ตที่คุณถูกแท็ก
}
# describing the amount of reposts your note received
name__and__count__others_reposted_your_note_70a0 =
{ $count ->
[one] { $name } และอีก { $count } คน รีโพสต์โน้ตของคุณ
*[other] { $name } และอีก { $count } คน รีโพสต์โน้ตของคุณ
}
+6 -4
View File
@@ -152,7 +152,9 @@ impl<'a> RelayMessage<'a> {
return Ok(Self::ok(event_id, status, message));
}
Err(Error::DecodeFailed("unrecognized message type".into()))
Err(Error::DecodeFailed(format!(
"unrecognized message type: '{msg}'"
)))
}
}
@@ -220,15 +222,15 @@ mod tests {
),
(
r#"["NOTICE": 404]"#,
Err(Error::DecodeFailed("unrecognized message type".into())),
Err(Error::DecodeFailed("unrecognized message type: '[\"NOTICE\": 404]'".into())),
),
(
r#"["OK","event_id"]"#,
Err(Error::DecodeFailed("unrecognized message type".into())),
Err(Error::DecodeFailed("unrecognized message type: '[\"OK\",\"event_id\"]'".into())),
),
(
r#"["OK","b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30"]"#,
Err(Error::DecodeFailed("unrecognized message type".into())),
Err(Error::DecodeFailed("unrecognized message type: '[\"OK\",\"b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30\"]'".into())),
),
(
r#"["OK","b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30",hello,""]"#,
+1
View File
@@ -51,6 +51,7 @@ bitflags = { workspace = true }
regex = "1"
chrono = { workspace = true }
indexmap = {workspace = true}
rand = {workspace = true}
crossbeam-channel = "0.5"
[dev-dependencies]
+10 -4
View File
@@ -1,5 +1,5 @@
use crate::{
filter::{self, HybridFilter},
filter::{self, HybridFilter, ValidKind},
Error,
};
use nostrdb::{Filter, Note};
@@ -15,10 +15,16 @@ pub fn hybrid_contacts_filter(
add_pk: Option<&[u8; 32]>,
with_hashtags: bool,
) -> Result<HybridFilter, Error> {
let local = filter::filter_from_tags(note, add_pk, with_hashtags)?
.into_filter([1], filter::default_limit());
let local = vec![
filter::filter_from_tags(note, add_pk, with_hashtags)?
.into_query_package(ValidKind::One, filter::default_limit()),
filter::filter_from_tags(note, add_pk, with_hashtags)?
.into_query_package(ValidKind::Six, filter::default_limit()),
filter::filter_from_tags(note, add_pk, with_hashtags)?
.into_query_package(ValidKind::Zero, filter::default_limit()),
];
let remote = filter::filter_from_tags(note, add_pk, with_hashtags)?
.into_filter([1, 0], filter::default_remote_limit());
.into_filter(vec![1, 0], filter::default_remote_limit());
Ok(HybridFilter::split(local, remote))
}
+12 -1
View File
@@ -33,15 +33,26 @@ pub enum ZapError {
#[error("invalid lud16")]
InvalidLud16(String),
#[error("invalid endpoint response")]
EndpointError(String),
EndpointError(EndpointError),
#[error("bech encoding/decoding error")]
Bech(String),
#[error("serialization/deserialization problem")]
Serialization(String),
#[error("nwc error")]
NWC(String),
#[error("ndb error")]
Ndb(String),
}
impl ZapError {
pub fn endpoint_error(error: String) -> ZapError {
ZapError::EndpointError(EndpointError(error))
}
}
#[derive(Debug, Clone)]
pub struct EndpointError(pub String);
impl From<String> for Error {
fn from(s: String) -> Self {
Error::Generic(s)
+109 -46
View File
@@ -142,12 +142,6 @@ impl FilterState {
Self::Ready(HybridFilter::unsplit(filter))
}
/// The filter is ready, but we have a different local filter from
/// our remote one
pub fn ready_split(local: Vec<Filter>, remote: Vec<Filter>) -> Self {
Self::Ready(HybridFilter::split(local, remote))
}
/// Our hybrid filter is ready (either split or unsplit)
pub fn ready_hybrid(filter: HybridFilter) -> Self {
Self::Ready(filter)
@@ -219,7 +213,7 @@ pub struct FilteredTags {
/// The local and remote filter are related but slightly different
#[derive(Debug, Clone)]
pub struct SplitFilter {
pub local: Vec<Filter>,
pub local: Vec<NdbQueryPackage>,
pub remote: Vec<Filter>,
}
@@ -236,16 +230,23 @@ impl HybridFilter {
HybridFilter::Unsplit(filter)
}
pub fn split(local: Vec<Filter>, remote: Vec<Filter>) -> Self {
pub fn split(local: Vec<NdbQueryPackage>, remote: Vec<Filter>) -> Self {
HybridFilter::Split(SplitFilter { local, remote })
}
pub fn local(&self) -> &[Filter] {
pub fn local(&self) -> NdbQueryPackages<'_> {
match self {
Self::Split(split) => &split.local,
Self::Split(split) => NdbQueryPackages {
packages: split.local.iter().map(NdbQueryPackage::borrow).collect(),
},
// local as the same as remote in unsplit
Self::Unsplit(local) => local,
Self::Unsplit(local) => NdbQueryPackages {
packages: vec![NdbQueryPackageUnowned {
filters: local,
kind: None,
}],
},
}
}
@@ -260,77 +261,139 @@ impl HybridFilter {
}
impl FilteredTags {
pub fn into_follow_filter(self) -> Vec<Filter> {
self.into_filter([1], default_limit())
}
// TODO: make this more general
pub fn into_filter<I>(self, kinds: I, limit: u64) -> Vec<Filter>
where
I: IntoIterator<Item = u64> + Copy,
{
pub fn into_query_package(self, kind: ValidKind, limit: u64) -> NdbQueryPackage {
let mut filters: Vec<Filter> = Vec::with_capacity(2);
if let Some(authors) = self.authors {
filters.push(authors.kinds(kinds).limit(limit).build())
filters.push(authors.kinds(vec![kind.kind()]).limit(limit).build())
}
if let Some(hashtags) = self.hashtags {
filters.push(hashtags.kinds(kinds).limit(limit).build())
if matches!(&kind, ValidKind::One | ValidKind::Zero) {
filters.push(hashtags.kinds(vec![kind.kind()]).limit(limit).build())
}
}
NdbQueryPackage { filters, kind }
}
// TODO: make this more general
pub fn into_filter(self, shared_kinds: Vec<u64>, limit: u64) -> Vec<Filter> {
let mut filters: Vec<Filter> = Vec::with_capacity(2);
if let Some(authors) = self.authors {
let mut author_kinds = shared_kinds.clone();
author_kinds.insert(0, 6);
filters.push(authors.kinds(author_kinds).limit(limit).build())
}
if let Some(hashtags) = self.hashtags {
filters.push(hashtags.kinds(shared_kinds).limit(limit).build())
}
filters
}
}
/// `Ndb::query` retrieves the most recent notes of one kind until it can't find anymore THEN proceeds to the next kind.
/// This is not optimal for many scenarios, so this data structure represents data that is packaged optimally for one `Ndb::query`,
#[derive(Debug, Clone)]
pub struct NdbQueryPackage {
pub kind: ValidKind,
pub filters: Vec<Filter>,
}
impl NdbQueryPackage {
pub fn borrow(&self) -> NdbQueryPackageUnowned<'_> {
NdbQueryPackageUnowned {
filters: &self.filters,
kind: Some(self.kind.clone()),
}
}
}
#[derive(Debug, Clone)]
pub struct NdbQueryPackageUnowned<'a> {
pub kind: Option<ValidKind>,
pub filters: &'a Vec<Filter>,
}
pub struct NdbQueryPackages<'a> {
pub packages: Vec<NdbQueryPackageUnowned<'a>>,
}
impl<'a> NdbQueryPackages<'a> {
pub fn combined(&self) -> Vec<Filter> {
let mut combined = Vec::new();
for package in &self.packages {
combined.extend_from_slice(package.filters);
}
combined
}
}
#[derive(Debug, Clone)]
pub enum ValidKind {
Zero,
One,
Six,
}
impl ValidKind {
fn kind(&self) -> u64 {
match self {
ValidKind::Zero => 0,
ValidKind::One => 1,
ValidKind::Six => 6,
}
}
}
/// Create a "last N notes per pubkey" query.
pub fn last_n_per_pubkey_from_tags(
note: &Note,
kind: u64,
notes_per_pubkey: u64,
) -> Result<Vec<Filter>, Error> {
use rand::Rng;
let mut filters: Vec<Filter> = vec![];
let mut rng = rand::rng();
for tag in note.tags() {
// TODO: fix arbitrary MAX_FILTER limit in nostrdb
if filters.len() == 15 {
break;
}
const LIMIT: usize = 15;
for (i, tag) in note.tags().iter().enumerate() {
if tag.count() < 2 {
continue;
}
let t = if let Some(t) = tag.get_unchecked(0).variant().str() {
t
} else {
let Some("p") = tag.get_str(0) else {
continue;
};
if t == "p" {
let author = if let Some(author) = tag.get_unchecked(1).variant().id() {
author
} else {
let Some(author) = tag.get_id(1) else {
continue;
};
let mk_filter = || {
let mut filter = Filter::new();
filter.start_authors_field()?;
filter.add_id_element(author)?;
let _ = filter.start_authors_field();
let _ = filter.add_id_element(author);
filter.end_field();
filters.push(filter.kinds([kind]).limit(notes_per_pubkey).build());
} else if t == "t" {
let hashtag = if let Some(hashtag) = tag.get_unchecked(1).variant().str() {
hashtag
} else {
continue;
filter.kinds([kind]).limit(notes_per_pubkey).build()
};
let mut filter = Filter::new();
filter.start_tags_field('t')?;
filter.add_str_element(hashtag)?;
filter.end_field();
filters.push(filter.kinds([kind]).limit(notes_per_pubkey).build());
// since we're limited due to a nostrdb bug, we reservoir sample to keep things interesting
if filters.len() < LIMIT {
filters.push(mk_filter());
} else {
let j = rng.random_range(0..=i);
if j < LIMIT {
filters[j] = mk_filter();
}
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ impl TexturesCache {
entry.replace_entry_with(|_, v| {
let TextureStateInternal::Loading(textured) = v else {
return None;
return Some(v);
};
Some(TextureStateInternal::Loaded(textured))
+1 -1
View File
@@ -305,7 +305,7 @@ fn generate_gif(
);
if tex_input.send(texture_frame).is_err() {
tracing::debug!("AnimationTextureFrame mpsc stopped abruptly");
//tracing::debug!("AnimationTextureFrame mpsc stopped abruptly");
break;
}
}
+57 -55
View File
@@ -10,73 +10,75 @@ const ONE_WEEK_IN_SECONDS: u64 = 604_800;
const ONE_MONTH_IN_SECONDS: u64 = 2_592_000; // 30 days
const ONE_YEAR_IN_SECONDS: u64 = 31_536_000; // 365 days
// Range boundary constants for match patterns
const MAX_SECONDS: u64 = ONE_MINUTE_IN_SECONDS - 1;
const MAX_SECONDS_FOR_MINUTES: u64 = ONE_HOUR_IN_SECONDS - 1;
const MAX_SECONDS_FOR_HOURS: u64 = ONE_DAY_IN_SECONDS - 1;
const MAX_SECONDS_FOR_DAYS: u64 = ONE_WEEK_IN_SECONDS - 1;
const MAX_SECONDS_FOR_WEEKS: u64 = ONE_MONTH_IN_SECONDS - 1;
const MAX_SECONDS_FOR_MONTHS: u64 = ONE_YEAR_IN_SECONDS - 1;
/// Calculate relative time between two timestamps
/// Calculate relative time between two timestamps, with two units only
/// when the scale is large enough (e.g., "1y 6m", "5d 4h"),
/// but not for hours/minutes/seconds.
fn time_ago_between(i18n: &mut Localization, timestamp: u64, now: u64) -> String {
// Determine if the timestamp is in the future or the past
let duration = if now >= timestamp {
now.saturating_sub(timestamp)
} else {
timestamp.saturating_sub(now)
};
let time_str = match duration {
0..=2 => tr!(
// Special-case: "now" for < 3 seconds
if duration <= 2 {
let s = tr!(
i18n,
"now",
"Relative time for very recent events (less than 3 seconds)"
),
3..=MAX_SECONDS => tr!(
i18n,
"{count}s",
"Relative time in seconds",
count = duration
),
ONE_MINUTE_IN_SECONDS..=MAX_SECONDS_FOR_MINUTES => tr!(
i18n,
"{count}m",
"Relative time in minutes",
count = duration / ONE_MINUTE_IN_SECONDS
),
ONE_HOUR_IN_SECONDS..=MAX_SECONDS_FOR_HOURS => tr!(
i18n,
"{count}h",
"Relative time in hours",
count = duration / ONE_HOUR_IN_SECONDS
),
ONE_DAY_IN_SECONDS..=MAX_SECONDS_FOR_DAYS => tr!(
i18n,
"{count}d",
"Relative time in days",
count = duration / ONE_DAY_IN_SECONDS
),
ONE_WEEK_IN_SECONDS..=MAX_SECONDS_FOR_WEEKS => tr!(
i18n,
"{count}w",
"Relative time in weeks",
count = duration / ONE_WEEK_IN_SECONDS
),
ONE_MONTH_IN_SECONDS..=MAX_SECONDS_FOR_MONTHS => tr!(
i18n,
"{count}mo",
"Relative time in months",
count = duration / ONE_MONTH_IN_SECONDS
),
_ => tr!(
i18n,
"{count}y",
"Relative time in years",
count = duration / ONE_YEAR_IN_SECONDS
),
);
return if timestamp > now { format!("+{s}") } else { s };
}
// Break into buckets
let years = duration / ONE_YEAR_IN_SECONDS;
let rem_y = duration % ONE_YEAR_IN_SECONDS;
let months = rem_y / ONE_MONTH_IN_SECONDS;
let rem_m = rem_y % ONE_MONTH_IN_SECONDS;
let weeks = rem_m / ONE_WEEK_IN_SECONDS;
let rem_w = rem_m % ONE_WEEK_IN_SECONDS;
let days = rem_w / ONE_DAY_IN_SECONDS;
let rem_d = rem_w % ONE_DAY_IN_SECONDS;
let hours = rem_d / ONE_HOUR_IN_SECONDS;
let rem_h = rem_d % ONE_HOUR_IN_SECONDS;
let mins = rem_h / ONE_MINUTE_IN_SECONDS;
let secs = rem_h % ONE_MINUTE_IN_SECONDS;
let mut parts: Vec<String> = Vec::with_capacity(2);
let mut push_part = |count: u64, key: &str, desc: &str| {
if count > 0 && parts.len() < 2 {
parts.push(tr!(i18n, key, desc, count = count));
}
};
if years > 0 {
push_part(years, "{count}y", "Relative time in years");
push_part(months, "{count}mo", "Relative time in months");
} else if months > 0 {
push_part(months, "{count}mo", "Relative time in months");
push_part(weeks, "{count}w", "Relative time in weeks");
} else if weeks > 0 {
push_part(weeks, "{count}w", "Relative time in weeks");
push_part(days, "{count}d", "Relative time in days");
} else if days > 0 {
push_part(days, "{count}d", "Relative time in days");
push_part(hours, "{count}h", "Relative time in hours");
} else if hours > 0 {
push_part(hours, "{count}h", "Relative time in hours");
} else if mins > 0 {
push_part(mins, "{count}m", "Relative time in minutes");
} else {
push_part(secs.max(1), "{count}s", "Relative time in seconds");
}
let time_str = parts.join(" ");
if timestamp > now {
format!("+{time_str}")
} else {
+78 -45
View File
@@ -1,16 +1,23 @@
use std::collections::HashMap;
use enostr::{NoteId, Pubkey};
use nostrdb::{Ndb, Transaction};
use nwc::nostr::nips::nip47::PayInvoiceResponse;
use poll_promise::Promise;
use tokio::task::JoinError;
use url::Url;
use crate::{get_wallet_for, Accounts, GlobalWallet, ZapError};
use super::{
networking::{fetch_invoice_lnurl, fetch_invoice_lud16, FetchedInvoice, FetchingInvoice},
zap::Zap,
use crate::{
get_wallet_for,
zaps::{
get_users_zap_address,
networking::{fetch_invoice_promise, FetchedInvoiceResponse, LNUrlPayResponse, PayEntry},
},
Accounts, GlobalWallet, ZapError,
};
use super::{networking::FetchingInvoice, zap::Zap};
type ZapId = u32;
#[derive(Default)]
@@ -23,11 +30,31 @@ pub struct Zaps {
zaps: std::collections::HashMap<ZapId, ZapState>,
in_flight: Vec<ZapPromise>,
events: Vec<EventResponse>,
pay_cache: PayCache,
}
/// Cache to hold LNURL payRequest responses from the desired LNURL endpoint
#[derive(Default)]
pub struct PayCache {
// endpoint URL to response
pub pay_responses: HashMap<Url, LNUrlPayResponse>,
}
impl PayCache {
pub fn get_response(&self, url: &Url) -> Option<&LNUrlPayResponse> {
self.pay_responses.get(url)
}
pub fn insert(&mut self, entry: PayEntry) {
self.pay_responses.insert(entry.url, entry.response);
}
}
fn process_event(
id: ZapId,
event: ZapEvent,
cache: &PayCache,
accounts: &mut Accounts,
global_wallet: &mut GlobalWallet,
ndb: &Ndb,
@@ -37,7 +64,7 @@ fn process_event(
ZapEvent::FetchInvoice {
zap_ctx,
sender_relays,
} => process_new_zap_event(zap_ctx, accounts, ndb, txn, sender_relays),
} => process_new_zap_event(cache, zap_ctx, accounts, ndb, txn, sender_relays),
ZapEvent::SendNWC {
zap_ctx,
req_noteid,
@@ -74,6 +101,7 @@ fn process_event(
}
fn process_new_zap_event(
cache: &PayCache,
zap_ctx: ZapCtx,
accounts: &Accounts,
ndb: &Ndb,
@@ -96,7 +124,8 @@ fn process_new_zap_event(
};
let id = zap_ctx.id;
let promise = send_note_zap(
let m_promise = send_note_zap(
cache,
ndb,
txn,
note_target,
@@ -106,55 +135,41 @@ fn process_new_zap_event(
)
.map(|promise| ZapPromise::FetchingInvoice {
ctx: zap_ctx,
promise,
promise: Box::new(promise),
});
let Some(promise) = promise else {
let promise = match m_promise {
Ok(promise) => promise,
Err(e) => {
return NextState::Event(EventResponse {
id,
event: Err(ZappingError::InvalidZapAddress),
event: Err(ZappingError::InvoiceFetchFailed(e)),
});
}
};
NextState::Transition(promise)
}
fn send_note_zap(
cache: &PayCache,
ndb: &Ndb,
txn: &Transaction,
note_target: NoteZapTargetOwned,
msats: u64,
nsec: &[u8; 32],
relays: Vec<String>,
) -> Option<FetchingInvoice> {
let address = get_users_zap_endpoint(txn, ndb, &note_target.zap_recipient)?;
) -> Result<FetchingInvoice, ZapError> {
let address = get_users_zap_address(txn, ndb, &note_target.zap_recipient)?;
let promise = match address {
ZapAddress::Lud16(s) => {
fetch_invoice_lud16(s, msats, *nsec, ZapTargetOwned::Note(note_target), relays)
}
ZapAddress::Lud06(s) => {
fetch_invoice_lnurl(s, msats, *nsec, ZapTargetOwned::Note(note_target), relays)
}
};
Some(promise)
}
enum ZapAddress {
Lud16(String),
Lud06(String),
}
fn get_users_zap_endpoint(txn: &Transaction, ndb: &Ndb, receiver: &Pubkey) -> Option<ZapAddress> {
let profile = ndb
.get_profile_by_pubkey(txn, receiver.bytes())
.ok()?
.record()
.profile()?;
profile
.lud06()
.map(|l| ZapAddress::Lud06(l.to_string()))
.or(profile.lud16().map(|l| ZapAddress::Lud16(l.to_string())))
fetch_invoice_promise(
cache,
address,
msats,
*nsec,
ZapTargetOwned::Note(note_target),
relays,
)
}
fn try_get_promise_response(
@@ -169,7 +184,7 @@ fn try_get_promise_response(
match promise {
ZapPromise::FetchingInvoice { ctx, promise } => {
let result = promise.block_and_take();
let result = Box::new(promise.block_and_take());
Some(PromiseResponse::FetchingInvoice { ctx, result })
}
@@ -272,6 +287,16 @@ impl Zaps {
continue;
};
if let PromiseResponse::FetchingInvoice { ctx: _, result } = &resp {
if let Ok(resp) = &**result {
if let Some(entry) = &resp.pay_entry {
let url = &entry.url;
tracing::info!("inserting {url} in pay cache");
self.pay_cache.insert(entry.clone());
}
}
}
self.events.push(resp.take_as_event_response());
}
@@ -286,7 +311,15 @@ impl Zaps {
};
let txn = nostrdb::Transaction::new(ndb).expect("txn");
match process_event(event_resp.id, event, accounts, global_wallet, ndb, &txn) {
match process_event(
event_resp.id,
event,
&self.pay_cache,
accounts,
global_wallet,
ndb,
&txn,
) {
NextState::Event(event_resp) => {
self.zaps
.insert(event_resp.id, ZapState::Pending(event_resp.event));
@@ -483,7 +516,7 @@ impl std::fmt::Display for ZappingError {
enum ZapPromise {
FetchingInvoice {
ctx: ZapCtx,
promise: Promise<Result<Result<FetchedInvoice, ZapError>, JoinError>>,
promise: Box<Promise<Result<FetchedInvoiceResponse, JoinError>>>,
},
SendingNWCInvoice {
ctx: SendingNWCInvoiceContext,
@@ -494,7 +527,7 @@ enum ZapPromise {
enum PromiseResponse {
FetchingInvoice {
ctx: ZapCtx,
result: Result<Result<FetchedInvoice, ZapError>, JoinError>,
result: Box<Result<FetchedInvoiceResponse, JoinError>>,
},
SendingNWCInvoice {
ctx: SendingNWCInvoiceContext,
@@ -507,8 +540,8 @@ impl PromiseResponse {
match self {
PromiseResponse::FetchingInvoice { ctx, result } => {
let id = ctx.id;
let event = match result {
Ok(r) => match r {
let event = match *result {
Ok(r) => match r.invoice {
Ok(invoice) => Ok(ZapEvent::SendNWC {
zap_ctx: ctx,
req_noteid: invoice.request_noteid,
+36
View File
@@ -11,3 +11,39 @@ pub use default_zap::{
get_current_default_msats, DefaultZapError, DefaultZapMsats, PendingDefaultZapState,
UserZapMsats,
};
use enostr::Pubkey;
use nostrdb::{Ndb, Transaction};
use crate::ZapError;
pub enum ZapAddress {
Lud16(String),
Lud06(String),
}
pub fn get_users_zap_address(
txn: &Transaction,
ndb: &Ndb,
receiver: &Pubkey,
) -> Result<ZapAddress, ZapError> {
let Some(profile) = ndb
.get_profile_by_pubkey(txn, receiver.bytes())
.map_err(|e| ZapError::Ndb(e.to_string()))?
.record()
.profile()
else {
return Err(ZapError::Ndb(format!("No profile for {receiver}")));
};
let Some(address) = profile
.lud06()
.map(|l| ZapAddress::Lud06(l.to_string()))
.or(profile.lud16().map(|l| ZapAddress::Lud16(l.to_string())))
else {
return Err(ZapError::Ndb(format!(
"profile for {receiver} doesn't have lud06 or lud16"
)));
};
Ok(address)
}
+209 -99
View File
@@ -1,5 +1,9 @@
use crate::{zaps::ZapTargetOwned, ZapError};
use enostr::NoteId;
use crate::{
error::EndpointError,
zaps::{cache::PayCache, ZapAddress, ZapTargetOwned},
ZapError,
};
use enostr::{NoteId, Pubkey};
use nostrdb::NoteBuilder;
use poll_promise::Promise;
use serde::Deserialize;
@@ -11,15 +15,20 @@ pub struct FetchedInvoice {
pub request_noteid: NoteId, // note id of kind 9734 request
}
pub type FetchingInvoice = Promise<Result<Result<FetchedInvoice, ZapError>, JoinError>>;
pub struct FetchedInvoiceResponse {
pub invoice: Result<FetchedInvoice, ZapError>,
pub pay_entry: Option<PayEntry>,
}
async fn fetch_pay_req_async(url: &Url) -> Result<LNUrlPayRequest, ZapError> {
pub type FetchingInvoice = Promise<Result<FetchedInvoiceResponse, JoinError>>;
async fn fetch_pay_req_async(url: &Url) -> Result<LNUrlPayResponseRaw, ZapError> {
let (sender, promise) = Promise::new();
let on_done = move |response: Result<ehttp::Response, String>| {
let handle = response.map_err(ZapError::EndpointError).and_then(|resp| {
let handle = response.map_err(ZapError::endpoint_error).and_then(|resp| {
if !resp.ok {
return Err(ZapError::EndpointError(format!(
return Err(ZapError::endpoint_error(format!(
"bad http response: {}",
resp.status_text
)));
@@ -36,20 +45,9 @@ async fn fetch_pay_req_async(url: &Url) -> Result<LNUrlPayRequest, ZapError> {
tokio::task::block_in_place(|| promise.block_and_take())
}
async fn fetch_pay_req_from_lud16(lud16: &str) -> Result<LNUrlPayRequest, ZapError> {
let url = match generate_endpoint_url(lud16) {
Ok(url) => url,
Err(e) => return Err(e),
};
fetch_pay_req_async(&url).await
}
static HRP_LNURL: bech32::Hrp = bech32::Hrp::parse_unchecked("lnurl");
fn lud16_to_lnurl(lud16: &str) -> Result<String, ZapError> {
let endpoint_url = generate_endpoint_url(lud16)?;
fn endpoint_url_to_lnurl(endpoint_url: &Url) -> Result<String, ZapError> {
let url_str = endpoint_url.to_string();
let data = url_str.as_bytes();
@@ -100,7 +98,7 @@ fn make_kind_9734<'a>(
}
#[derive(Debug, Deserialize)]
pub struct LNUrlPayRequest {
pub struct LNUrlPayResponseRaw {
#[allow(dead_code)]
#[serde(rename = "allowsNostr")]
allow_nostr: bool,
@@ -121,57 +119,117 @@ pub struct LNUrlPayRequest {
max_sendable: u64,
}
impl From<LNUrlPayResponseRaw> for LNUrlPayResponse {
fn from(value: LNUrlPayResponseRaw) -> Self {
let nostr_pubkey = Pubkey::from_hex(&value.nostr_pubkey)
.map_err(|e: enostr::Error| EndpointError(e.to_string()));
let callback_url = Url::parse(&value.callback_url)
.map_err(|e| EndpointError(format!("invalid callback url: {e}")));
Self {
allow_nostr: value.allow_nostr,
nostr_pubkey,
callback_url,
min_sendable: value.min_sendable,
max_sendable: value.max_sendable,
}
}
}
#[derive(Clone, Debug)]
pub struct LNUrlPayResponse {
pub allow_nostr: bool,
pub nostr_pubkey: Result<Pubkey, EndpointError>,
pub callback_url: Result<Url, EndpointError>,
pub min_sendable: u64,
pub max_sendable: u64,
}
#[derive(Clone, Debug)]
pub struct PayEntry {
pub url: Url,
pub response: LNUrlPayResponse,
}
#[derive(Debug, Deserialize)]
struct LNInvoice {
#[serde(rename = "pr")]
invoice: String,
}
fn endpoint_query_for_invoice<'a>(
endpoint_base_url: &'a mut Url,
fn endpoint_query_for_invoice(
endpoint_base_url: &Url,
msats: u64,
lnurl: &str,
note: nostrdb::Note,
) -> Result<&'a Url, ZapError> {
) -> Result<Url, ZapError> {
let mut new_url = endpoint_base_url.clone();
let nostr = note
.json()
.map_err(|e| ZapError::Serialization(format!("failed note to json: {e}")))?;
Ok(endpoint_base_url
new_url
.query_pairs_mut()
.append_pair("amount", &msats.to_string())
.append_pair("lnurl", lnurl)
.append_pair("nostr", &nostr)
.finish())
.finish();
Ok(new_url)
}
pub fn fetch_invoice_lud16(
lud16: String,
pub fn fetch_invoice_promise(
cache: &PayCache,
zap_address: ZapAddress,
msats: u64,
sender_nsec: [u8; 32],
target: ZapTargetOwned,
relays: Vec<String>,
) -> FetchingInvoice {
Promise::spawn_async(tokio::spawn(async move {
fetch_invoice_lud16_async(&lud16, msats, &sender_nsec, target, relays).await
}))
}
pub fn fetch_invoice_lnurl(
lnurl: String,
msats: u64,
sender_nsec: [u8; 32],
target: ZapTargetOwned,
relays: Vec<String>,
) -> FetchingInvoice {
Promise::spawn_async(tokio::spawn(async move {
let pay_req = match fetch_pay_req_from_lnurl_async(&lnurl).await {
Ok(req) => req,
Err(e) => return Err(e),
) -> Result<FetchingInvoice, ZapError> {
let (url, lnurl) = match zap_address {
ZapAddress::Lud16(lud16) => {
let url = generate_endpoint_url(&lud16)?;
let lnurl = endpoint_url_to_lnurl(&url)?;
(url, lnurl)
}
ZapAddress::Lud06(lnurl) => (convert_lnurl_to_endpoint_url(&lnurl)?, lnurl),
};
fetch_invoice_lnurl_async(&lnurl, &pay_req, msats, &sender_nsec, relays, target).await
}))
match cache.get_response(&url) {
Some(endpoint_resp) => {
tracing::info!("Using existing endpoint response for {url}");
let response = endpoint_resp.clone();
Ok(Promise::spawn_async(tokio::spawn(async move {
fetch_invoice_lnurl_async(
&lnurl,
PayEntry { url, response },
msats,
&sender_nsec,
relays,
target,
)
.await
})))
}
None => Ok(Promise::spawn_async(tokio::spawn(async move {
tracing::info!("querying ln endpoint: {url}");
let pay_req = match fetch_pay_req_async(&url).await {
Ok(p) => PayEntry {
url,
response: p.into(),
},
Err(e) => {
return FetchedInvoiceResponse {
invoice: Err(e),
pay_entry: None,
}
}
};
fetch_invoice_lnurl_async(&lnurl, pay_req, msats, &sender_nsec, relays, target).await
}))),
}
}
fn convert_lnurl_to_endpoint_url(lnurl: &str) -> Result<Url, ZapError> {
@@ -181,68 +239,96 @@ fn convert_lnurl_to_endpoint_url(lnurl: &str) -> Result<Url, ZapError> {
String::from_utf8(data).map_err(|e| ZapError::Bech(format!("string conversion: {e}")))?;
Url::parse(&url_str)
.map_err(|e| ZapError::EndpointError(format!("endpoint url from lnurl is invalid: {e}")))
}
async fn fetch_pay_req_from_lnurl_async(lnurl: &str) -> Result<LNUrlPayRequest, ZapError> {
let url = match convert_lnurl_to_endpoint_url(lnurl) {
Ok(u) => u,
Err(e) => return Err(e),
};
fetch_pay_req_async(&url).await
.map_err(|e| ZapError::endpoint_error(format!("endpoint url from lnurl is invalid: {e}")))
}
async fn fetch_invoice_lnurl_async(
lnurl: &str,
pay_req: &LNUrlPayRequest,
pay_entry: PayEntry,
msats: u64,
sender_nsec: &[u8; 32],
relays: Vec<String>,
target: ZapTargetOwned,
) -> Result<FetchedInvoice, ZapError> {
//let recipient = Pubkey::from_hex(&pay_req.nostr_pubkey)
//.map_err(|e| ZapError::EndpointError(format!("invalid pubkey hex from endpoint: {e}")))?;
) -> FetchedInvoiceResponse {
if !pay_entry.response.allow_nostr {
return FetchedInvoiceResponse {
invoice: Err(ZapError::endpoint_error(
"endpoint does not allow nostr".to_owned(),
)),
pay_entry: Some(pay_entry),
};
}
let mut base_url = Url::parse(&pay_req.callback_url)
.map_err(|e| ZapError::EndpointError(format!("invalid callback url from endpoint: {e}")))?;
if let Err(e) = &pay_entry.response.nostr_pubkey {
return FetchedInvoiceResponse {
invoice: Err(ZapError::EndpointError(e.clone())),
pay_entry: Some(pay_entry),
};
};
let min_sendable = pay_entry.response.min_sendable;
if msats < min_sendable {
return FetchedInvoiceResponse {
invoice: Err(ZapError::endpoint_error(format!(
"zap amount {msats} is less than minimum sendable: {min_sendable} (in msats)"
))),
pay_entry: Some(pay_entry),
};
}
let max_sendable = pay_entry.response.max_sendable;
if msats > max_sendable {
return FetchedInvoiceResponse {
invoice: Err(ZapError::endpoint_error(format!(
"zap amount {msats} is greater than maximum sendable: {max_sendable} (in msats)"
))),
pay_entry: Some(pay_entry),
};
}
let base_url = match &pay_entry.response.callback_url {
Ok(url) => url.clone(),
Err(error) => {
return FetchedInvoiceResponse {
invoice: Err(ZapError::EndpointError(error.clone())),
pay_entry: None,
};
}
};
let (query, noteid) = {
let comment: &str = "";
let note = make_kind_9734(lnurl, msats, comment, sender_nsec, relays, target);
let noteid = NoteId::new(*note.id());
let query = endpoint_query_for_invoice(&mut base_url, msats, lnurl, note)?;
let query = match endpoint_query_for_invoice(&base_url, msats, lnurl, note) {
Ok(u) => u,
Err(e) => {
return FetchedInvoiceResponse {
invoice: Err(e),
pay_entry: Some(pay_entry),
}
}
};
(query, noteid)
};
let res = fetch_invoice(query).await;
res.map(|i| FetchedInvoice {
invoice: i.invoice,
let res = fetch_ln_invoice(&query).await;
FetchedInvoiceResponse {
invoice: res.map(|r| FetchedInvoice {
invoice: r.invoice,
request_noteid: noteid,
})
}),
pay_entry: Some(pay_entry),
}
}
async fn fetch_invoice_lud16_async(
lud16: &str,
msats: u64,
sender_nsec: &[u8; 32],
target: ZapTargetOwned,
relays: Vec<String>,
) -> Result<FetchedInvoice, ZapError> {
let pay_req = fetch_pay_req_from_lud16(lud16).await?;
let lnurl = lud16_to_lnurl(lud16)?;
fetch_invoice_lnurl_async(&lnurl, &pay_req, msats, sender_nsec, relays, target).await
}
async fn fetch_invoice(req: &Url) -> Result<LNInvoice, ZapError> {
async fn fetch_ln_invoice(req: &Url) -> Result<LNInvoice, ZapError> {
let request = ehttp::Request::get(req);
let (sender, promise) = Promise::new();
let on_done = move |response: Result<ehttp::Response, String>| {
let handle = response.map_err(ZapError::EndpointError).and_then(|resp| {
let handle = response.map_err(ZapError::endpoint_error).and_then(|resp| {
if !resp.ok {
return Err(ZapError::EndpointError(format!(
return Err(ZapError::endpoint_error(format!(
"invalid http response: {}",
resp.status_text
)));
@@ -290,25 +376,32 @@ fn generate_endpoint_url(lud16: &str) -> Result<Url, ZapError> {
if use_http { "" } else { "s" }
);
Url::parse(&url_str).map_err(|e| ZapError::EndpointError(e.to_string()))
Url::parse(&url_str).map_err(|e| ZapError::endpoint_error(e.to_string()))
}
#[cfg(test)]
mod tests {
use enostr::{FullKeypair, NoteId};
use crate::zaps::networking::convert_lnurl_to_endpoint_url;
use super::{
fetch_invoice_lnurl, fetch_invoice_lud16, fetch_pay_req_from_lud16, lud16_to_lnurl,
use crate::zaps::{
cache::PayCache,
networking::{
convert_lnurl_to_endpoint_url, endpoint_url_to_lnurl, fetch_pay_req_async,
generate_endpoint_url,
},
};
use super::fetch_invoice_promise;
#[ignore] // don't run this test automatically since it sends real http
#[tokio::test(flavor = "multi_thread")]
async fn test_get_pay_req() {
let lud16 = "jb55@sendsats.lol";
let maybe_res = fetch_pay_req_from_lud16(lud16).await;
let url = generate_endpoint_url(lud16);
assert!(url.is_ok());
let maybe_res = fetch_pay_req_async(&url.unwrap()).await;
assert!(maybe_res.is_ok());
@@ -328,7 +421,10 @@ mod tests {
fn test_lnurl() {
let lud16 = "jb55@sendsats.lol";
let maybe_lnurl = lud16_to_lnurl(lud16);
let url = generate_endpoint_url(lud16);
assert!(url.is_ok());
let maybe_lnurl = endpoint_url_to_lnurl(&url.unwrap());
assert!(maybe_lnurl.is_ok());
let lnurl = maybe_lnurl.unwrap();
@@ -344,9 +440,11 @@ mod tests {
let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
let kp = FullKeypair::generate();
let mut cache = PayCache::default();
let maybe_invoice = rt.block_on(async {
fetch_invoice_lud16(
"jb55@sendsats.lol".to_owned(),
fetch_invoice_promise(
&mut cache,
crate::zaps::ZapAddress::Lud16("jb55@sendsats.lol".to_owned()),
1000,
FullKeypair::generate().secret_key.to_secret_bytes(),
crate::zaps::ZapTargetOwned::Note(crate::NoteZapTargetOwned {
@@ -355,14 +453,18 @@ mod tests {
}),
vec!["wss://relay.damus.io".to_owned()],
)
.block_and_take()
.map(|p| p.block_and_take())
});
assert!(maybe_invoice.is_ok());
let inner = maybe_invoice.unwrap();
assert!(inner.is_ok());
let invoice = inner.unwrap();
assert!(invoice.invoice.starts_with("lnbc"));
let inner = inner.unwrap().invoice;
assert!(inner.is_ok());
let inner = inner.unwrap();
assert!(inner.invoice.starts_with("lnbc"));
}
#[test]
@@ -385,9 +487,11 @@ mod tests {
let kp = FullKeypair::generate();
let mut cache = PayCache::default();
let maybe_invoice = rt.block_on(async {
fetch_invoice_lnurl(
lnurl.to_owned(),
fetch_invoice_promise(
&mut cache,
crate::zaps::ZapAddress::Lud06(lnurl.to_owned()),
1000,
kp.secret_key.to_secret_bytes(),
crate::zaps::ZapTargetOwned::Note(crate::NoteZapTargetOwned {
@@ -396,11 +500,17 @@ mod tests {
}),
[relay.to_owned()].to_vec(),
)
.block_and_take()
.map(|p| p.block_and_take())
});
assert!(maybe_invoice.is_ok());
let inner = maybe_invoice.unwrap();
assert!(inner.is_ok());
let inner = inner.unwrap().invoice;
assert!(inner.is_ok());
assert!(maybe_invoice.unwrap().unwrap().invoice.starts_with("lnbc"));
let inner = inner.unwrap();
assert!(inner.invoice.starts_with("lnbc"));
}
}
@@ -311,7 +311,7 @@ impl TimelineSub {
let before = self.state.clone();
match &mut self.state {
SubState::NoSub { dependers } => {
let Some(sub) = ndb_sub(ndb, filter.local(), "") else {
let Some(sub) = ndb_sub(ndb, &filter.local().combined(), "") else {
return;
};
@@ -326,7 +326,7 @@ impl TimelineSub {
dependers: _,
} => {}
SubState::RemoteOnly { remote, dependers } => {
let Some(local) = ndb_sub(ndb, filter.local(), "") else {
let Some(local) = ndb_sub(ndb, &filter.local().combined(), "") else {
return;
};
self.state = SubState::Unified {
+14 -32
View File
@@ -56,11 +56,12 @@ impl NewPost {
}
}
pub fn to_note(&self, seckey: &[u8; 32]) -> Note<'_> {
let mut content = self.content.clone();
/// creates a NoteBuilder with all the shared data between note, reply & quote reply
fn builder_with_shared_tags<'a>(&self, mut content: String) -> NoteBuilder<'a> {
append_urls(&mut content, &self.media);
let mut builder = add_client_tag(NoteBuilder::new()).kind(1).content(&content);
let mut builder = NoteBuilder::new().kind(1).content(&content);
builder = add_client_tag(builder);
for hashtag in Self::extract_hashtags(&self.content) {
builder = builder.start_tag().tag_str("t").tag_str(&hashtag);
@@ -74,18 +75,21 @@ impl NewPost {
builder = add_mention_tags(builder, &self.mentions);
}
builder
}
pub fn to_note(&self, seckey: &[u8; 32]) -> Note<'_> {
let builder = self.builder_with_shared_tags(self.content.clone());
builder.sign(seckey).build().expect("note should be ok")
}
pub fn to_reply(&self, seckey: &[u8; 32], replying_to: &Note) -> Note<'_> {
let mut content = self.content.clone();
append_urls(&mut content, &self.media);
let builder = add_client_tag(NoteBuilder::new()).kind(1).content(&content);
let mut builder = self.builder_with_shared_tags(self.content.clone());
let nip10 = NoteReply::new(replying_to.tags());
let mut builder = if let Some(root) = nip10.root() {
builder = if let Some(root) = nip10.root() {
builder
.start_tag()
.tag_str("e")
@@ -143,14 +147,6 @@ impl NewPost {
builder = builder.start_tag().tag_str("p").tag_str(&hex::encode(id));
}
if !self.media.is_empty() {
builder = add_imeta_tags(builder, &self.media);
}
if !self.mentions.is_empty() {
builder = add_mention_tags(builder, &self.mentions);
}
builder
.sign(seckey)
.build()
@@ -158,27 +154,13 @@ impl NewPost {
}
pub fn to_quote(&self, seckey: &[u8; 32], quoting: &Note) -> Note<'_> {
let mut new_content = format!(
let new_content = format!(
"{}\nnostr:{}",
self.content,
enostr::NoteId::new(*quoting.id()).to_bech().unwrap()
);
append_urls(&mut new_content, &self.media);
let mut builder = NoteBuilder::new().kind(1).content(&new_content);
for hashtag in Self::extract_hashtags(&self.content) {
builder = builder.start_tag().tag_str("t").tag_str(&hashtag);
}
if !self.media.is_empty() {
builder = add_imeta_tags(builder, &self.media);
}
if !self.mentions.is_empty() {
builder = add_mention_tags(builder, &self.mentions);
}
let builder = self.builder_with_shared_tags(new_content);
builder
.start_tag()
+21 -6
View File
@@ -134,15 +134,22 @@ impl TimelineCache {
}
let notes = if let FilterState::Ready(filters) = id.filters(txn, ndb) {
if let Ok(results) = ndb.query(txn, filters.local(), 1000) {
results
let mut notes = Vec::new();
for package in filters.local().packages {
if let Ok(results) = ndb.query(txn, package.filters, 1000) {
let cur_notes: Vec<NoteRef> = results
.into_iter()
.map(NoteRef::from_query_result)
.collect()
.collect();
notes.extend(cur_notes);
} else {
debug!("got no results from TimelineCache lookup for {:?}", id);
vec![]
}
}
notes
} else {
// filter is not ready yet
vec![]
@@ -178,12 +185,20 @@ impl TimelineCache {
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(
let notes = {
let mut notes = Vec::new();
for package in timeline.subscription.get_filter()?.local().packages {
let cur_notes = find_new_notes(
timeline.all_or_any_entries().latest(),
timeline.subscription.get_filter()?.local(),
package.filters,
txn,
ndb,
);
notes.extend(cur_notes);
}
notes
};
let open_result = if notes.is_empty() {
None
} else {
+19 -4
View File
@@ -3,6 +3,7 @@ use crate::search::SearchQuery;
use crate::timeline::{Timeline, TimelineTab};
use enostr::{Filter, NoteId, Pubkey};
use nostrdb::{Ndb, Transaction};
use notedeck::filter::{NdbQueryPackage, ValidKind};
use notedeck::{
contacts::{contacts_filter, hybrid_contacts_filter},
filter::{self, default_limit, default_remote_limit, HybridFilter},
@@ -625,7 +626,7 @@ impl TimelineKind {
pub fn notifications_filter(pk: &Pubkey) -> Filter {
Filter::new()
.pubkeys([pk.bytes()])
.kinds([1, 7])
.kinds([1, 7, 6])
.limit(default_limit())
.build()
}
@@ -728,15 +729,29 @@ fn last_per_pubkey_filter_state(ndb: &Ndb, pk: &Pubkey) -> FilterState {
}
fn profile_filter(pk: &[u8; 32]) -> HybridFilter {
HybridFilter::split(
vec![Filter::new()
let local = vec![
NdbQueryPackage {
filters: vec![Filter::new()
.authors([pk])
.kinds([1])
.limit(default_limit())
.build()],
kind: ValidKind::One,
},
NdbQueryPackage {
filters: vec![Filter::new()
.authors([pk])
.kinds([6])
.limit(default_limit())
.build()],
kind: ValidKind::Six,
},
];
HybridFilter::split(
local,
vec![Filter::new()
.authors([pk])
.kinds([1, 0])
.kinds([1, 6, 0])
.limit(default_remote_limit())
.build()],
)
+41 -9
View File
@@ -36,9 +36,9 @@ mod unit;
pub use cache::TimelineCache;
pub use kind::{ColumnTitle, PubkeySource, ThreadSelection, TimelineKind};
pub use note_units::{InsertionResponse, NoteUnits};
pub use note_units::{CompositeType, InsertionResponse, NoteUnits};
pub use timeline_units::{TimelineUnits, UnknownPks};
pub use unit::{CompositeUnit, NoteUnit, ReactionUnit};
pub use unit::{CompositeUnit, NoteUnit, ReactionUnit, RepostUnit};
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub enum ViewFilter {
@@ -63,7 +63,7 @@ impl ViewFilter {
}
pub fn filter_notes(cache: &CachedNote, note: &Note) -> bool {
!cache.reply.borrow(note.tags()).is_reply()
note.kind() == 6 || !cache.reply.borrow(note.tags()).is_reply()
}
fn identity(_cache: &CachedNote, _note: &Note) -> bool {
@@ -133,6 +133,7 @@ impl TimelineTab {
ndb: &Ndb,
txn: &Transaction,
reversed: bool,
use_front_insert: bool,
) -> Option<UnknownPks<'a>> {
if payloads.is_empty() {
return None;
@@ -158,7 +159,11 @@ impl TimelineTab {
debug!("spliced when inserting {num_refs} new notes, resetting virtual list",);
list.reset();
}
MergeKind::FrontInsert => {
MergeKind::FrontInsert => 's: {
if !use_front_insert {
break 's;
}
// 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.
@@ -210,6 +215,7 @@ pub struct Timeline {
pub selected_view: usize,
pub subscription: TimelineSub,
pub enable_front_insert: bool,
}
impl Timeline {
@@ -271,12 +277,16 @@ impl Timeline {
let subscription = TimelineSub::default();
let selected_view = 0;
// by default, disabled for profiles since they contain widgets above the list items
let enable_front_insert = !matches!(kind, TimelineKind::Profile(_));
Timeline {
kind,
filter,
views,
subscription,
selected_view,
enable_front_insert,
}
}
@@ -402,7 +412,9 @@ impl Timeline {
match view.filter {
ViewFilter::NotesAndReplies => {
let res: Vec<&NotePayload<'_>> = payloads.iter().collect();
if let Some(res) = view.insert(res, ndb, txn, reversed) {
if let Some(res) =
view.insert(res, ndb, txn, reversed, self.enable_front_insert)
{
res.process(unknown_ids, ndb, txn);
}
}
@@ -418,7 +430,13 @@ impl Timeline {
}
}
if let Some(res) = view.insert(filtered_payloads, ndb, txn, reversed) {
if let Some(res) = view.insert(
filtered_payloads,
ndb,
txn,
reversed,
self.enable_front_insert,
) {
res.process(unknown_ids, ndb, txn);
}
}
@@ -676,18 +694,32 @@ fn setup_initial_timeline(
timeline.subscription, timeline.filter
);
let notes = {
let mut notes = Vec::new();
for package in filters.local().packages {
let mut lim = 0i32;
for filter in filters.local() {
for filter in package.filters {
lim += filter.limit().unwrap_or(1) as i32;
}
debug!("setup_initial_timeline: limit for local filter is {}", lim);
let notes: Vec<NoteRef> = ndb
.query(txn, filters.local(), lim)?
let cur_notes: Vec<NoteRef> = ndb
.query(txn, package.filters, lim)?
.into_iter()
.map(NoteRef::from_query_result)
.collect();
tracing::debug!(
"Found {} notes for kind: {:?}",
cur_notes.len(),
package.kind
);
notes.extend(&cur_notes);
}
notes
};
if let Some(pks) = timeline.insert_new(txn, ndb, note_cache, &notes) {
pks.process(ndb, txn, unknown_ids);
@@ -17,7 +17,7 @@ type StorageIndex = usize;
pub struct NoteUnits {
reversed: bool,
storage: Vec<NoteUnit>,
lookup: HashMap<NoteKey, StorageIndex>, // `NoteKey` to index in `NoteUnits::storage`
lookup: HashMap<UnitKey, StorageIndex>, // the key to index in `NoteUnits::storage`
order: Vec<StorageIndex>, // the sorted order of the `NoteUnit`s in `NoteUnits::storage`
}
@@ -30,7 +30,7 @@ impl NoteUnits {
}
}
pub fn contains_key(&self, k: &NoteKey) -> bool {
pub fn contains_key(&self, k: &UnitKey) -> bool {
self.lookup.contains_key(k)
}
@@ -101,16 +101,25 @@ impl NoteUnits {
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();
let front_insertion = if self.order.is_empty() || new_order.is_empty() {
!new_order.is_empty()
} else if self.reversed {
// reversed is true, sorting should occur less recent to most recent (oldest to newest, opposite of `self.order`)
let first_new = *new_order.first().unwrap(); // most recent unit of the new order
let last_old = *self.order.last().unwrap(); // least recent unit of the current order
// if the most recent unit of the new order is less recent than the least recent unit of the current order,
// all current order units are less recent than the new order units.
// In other words, they are all being inserted in the front
self.storage[first_new] >= self.storage[last_old]
} else {
let last_new = *new_order.last().unwrap();
let first_old = *self.order.first().unwrap();
// reversed is false, sorting should occur most recent to least recent (newest to oldest, as it is in `self.order`)
let last_new = *new_order.last().unwrap(); // least recent unit of the new order
let first_old = *self.order.first().unwrap(); // most recent unit of the current order
// if the least recent unit of the new order is more recent than the most recent unit of the current order,
// all new units are more recent than the current units.
// In other words, they are all being inserted in the front
self.storage[last_new] <= self.storage[first_old]
};
@@ -119,10 +128,10 @@ impl NoteUnits {
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
let left_unit = &self.storage[index_left];
let right_unit = &self.storage[index_right];
if left_unit <= right_unit {
// the left unit is more recent than (or the same recency as) the right unit
merged.push(index_left);
i += 1;
} else {
@@ -163,7 +172,7 @@ impl NoteUnits {
/// 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 to_build: HashMap<CompositeKey, CompositeUnit> = HashMap::new(); // new composites by key
let mut singles_to_build: Vec<NoteRef> = Vec::new();
let mut singles_seen: HashSet<NoteKey> = HashSet::new();
@@ -172,7 +181,7 @@ impl NoteUnits {
match frag {
NoteUnitFragment::Single(note_ref) => {
let key = note_ref.key;
if self.lookup.contains_key(&key) {
if self.lookup.contains_key(&UnitKey::Single(key)) {
continue;
}
if singles_seen.insert(key) {
@@ -181,8 +190,9 @@ impl NoteUnits {
}
NoteUnitFragment::Composite(c_frag) => {
let key = c_frag.get_underlying_noteref().key;
let composite_type = c_frag.get_type();
if let Some(&storage_idx) = self.lookup.get(&key) {
if let Some(&storage_idx) = self.lookup.get(&UnitKey::Composite(c_frag.key())) {
if let Some(NoteUnit::Composite(c_unit)) = self.storage.get_mut(storage_idx)
{
if c_frag.get_latest_ref() < c_unit.get_latest_ref() {
@@ -194,7 +204,10 @@ impl NoteUnits {
}
// aggregate for new composite
use std::collections::hash_map::Entry;
match to_build.entry(key) {
match to_build.entry(CompositeKey {
key,
composite_type,
}) {
Entry::Occupied(mut o) => {
c_frag.fold_into(o.get_mut());
}
@@ -234,6 +247,24 @@ impl NoteUnits {
}
}
#[derive(Hash, PartialEq, Eq, Debug)]
pub struct CompositeKey {
pub key: NoteKey,
pub composite_type: CompositeType,
}
#[derive(Hash, PartialEq, Eq, Debug)]
pub enum CompositeType {
Reaction,
Repost,
}
#[derive(Hash, PartialEq, Eq, Debug)]
pub enum UnitKey {
Single(NoteKey),
Composite(CompositeKey),
}
pub enum InsertManyResponse {
Zero,
Some {
@@ -303,9 +334,9 @@ mod tests {
use crate::timeline::{
unit::{
CompositeFragment, CompositeUnit, NoteUnit, NoteUnitFragment, Reaction,
ReactionFragment, ReactionUnit,
ReactionFragment, ReactionUnit, RepostFragment,
},
NoteUnits,
NoteUnits, RepostUnit,
};
#[derive(Default)]
@@ -329,7 +360,7 @@ mod tests {
Pubkey::new(out)
}
fn build_fragment(&mut self, reacted_to: NoteRef) -> NoteUnitFragment {
fn build_reac_frag(&mut self, reacted_to: NoteRef) -> NoteUnitFragment {
NoteUnitFragment::Composite(CompositeFragment::Reaction(ReactionFragment {
noteref_reacted_to: reacted_to,
reaction_note_ref: NoteRef {
@@ -343,8 +374,27 @@ mod tests {
}))
}
fn fragment(&mut self, reacted_to: NoteRef) -> String {
let frag = self.build_fragment(reacted_to);
fn build_repost_frag(&mut self, reposting: NoteRef) -> NoteUnitFragment {
NoteUnitFragment::Composite(CompositeFragment::Repost(RepostFragment {
reposted_noteref: reposting,
repost_noteref: self.new_noteref(),
reposter: self.random_sender(),
}))
}
fn insert_repost(&mut self, reposting: NoteRef) -> String {
let repost = self.build_repost_frag(reposting);
let id = Uuid::new_v4().to_string();
self.frags.insert(id.clone(), repost.clone());
self.units.merge_fragments(vec![repost]);
id
}
fn insert_reac_frag(&mut self, reacted_to: NoteRef) -> String {
let frag = self.build_reac_frag(reacted_to);
let id = Uuid::new_v4().to_string();
self.frags.insert(id.clone(), frag.clone());
@@ -353,9 +403,9 @@ mod tests {
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);
fn insert_reac_frag_pair(&mut self, reacted_to: NoteRef) -> (String, String) {
let frag1 = self.build_reac_frag(reacted_to);
let frag2 = self.build_reac_frag(reacted_to);
self.units
.merge_fragments(vec![frag1.clone(), frag2.clone()]);
@@ -368,7 +418,7 @@ mod tests {
(id1, id2)
}
fn generate_reaction_note(&mut self) -> NoteRef {
fn new_noteref(&mut self) -> NoteRef {
NoteRef {
key: NoteKey::new(self.counter()),
created_at: self.counter(),
@@ -420,6 +470,36 @@ mod tests {
}))
}
fn expected_reposts(&mut self, ids: Vec<&String>) -> NoteUnit {
let mut reposts = BTreeMap::new();
let mut reposted_id = None;
let mut senders = HashSet::new();
for id in ids {
let NoteUnitFragment::Composite(CompositeFragment::Repost(repost)) =
self.frags.get(id).unwrap()
else {
panic!("got something other than repost");
};
if let Some(prev_reposted_id) = reposted_id {
if prev_reposted_id != repost.reposted_noteref {
panic!("internal error");
}
}
reposted_id = Some(repost.reposted_noteref);
reposts.insert(repost.repost_noteref, repost.reposter);
senders.insert(repost.reposter);
}
NoteUnit::Composite(CompositeUnit::Repost(RepostUnit {
note_reposted: reposted_id.unwrap(),
reposts,
senders,
}))
}
fn expected_single(&mut self, id: &String) -> NoteUnit {
let Some(NoteUnitFragment::Single(note_ref)) = self.frags.get(id) else {
panic!("fail");
@@ -438,6 +518,7 @@ mod tests {
match expect {
Expect::Single(id) => self.expected_single(id),
Expect::Reaction(items) => self.expected_reactions(items),
Expect::Repost(items) => self.expected_reposts(items),
}
);
}
@@ -446,17 +527,18 @@ mod tests {
enum Expect<'a> {
Single(&'a String),
Reaction(Vec<&'a String>),
Repost(Vec<&'a String>),
}
#[test]
fn test() {
fn test_reactions1() {
let mut builder = UnitBuilder::default();
let reaction_note = builder.generate_reaction_note();
let reaction_note = builder.new_noteref();
let single0 = builder.insert_note();
builder.aeq(0, Expect::Single(&single0));
let reac1 = builder.fragment(reaction_note);
let reac1 = builder.insert_reac_frag(reaction_note);
builder.aeq(0, Expect::Reaction(vec![&reac1]));
builder.aeq(1, Expect::Single(&single0));
@@ -465,7 +547,7 @@ mod tests {
builder.aeq(1, Expect::Reaction(vec![&reac1]));
builder.aeq(2, Expect::Single(&single0));
let reac2 = builder.fragment(reaction_note);
let reac2 = builder.insert_reac_frag(reaction_note);
builder.aeq(0, Expect::Reaction(vec![&reac2, &reac1]));
builder.aeq(1, Expect::Single(&single1));
builder.aeq(2, Expect::Single(&single0));
@@ -476,7 +558,7 @@ mod tests {
builder.aeq(2, Expect::Single(&single1));
builder.aeq(3, Expect::Single(&single0));
let reac3 = builder.fragment(reaction_note);
let reac3 = builder.insert_reac_frag(reaction_note);
builder.aeq(0, Expect::Reaction(vec![&reac1, &reac2, &reac3]));
builder.aeq(1, Expect::Single(&single2));
builder.aeq(2, Expect::Single(&single1));
@@ -484,19 +566,19 @@ mod tests {
}
#[test]
fn test2() {
fn test_reactions2() {
let mut builder = UnitBuilder::default();
let reaction_note1 = builder.generate_reaction_note();
let reaction_note2 = builder.generate_reaction_note();
let reaction_note1 = builder.new_noteref();
let reaction_note2 = builder.new_noteref();
let single0 = builder.insert_note();
builder.aeq(0, Expect::Single(&single0));
let reac1_1 = builder.fragment(reaction_note1);
let reac1_1 = builder.insert_reac_frag(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac1_1]));
builder.aeq(1, Expect::Single(&single0));
let reac2_1 = builder.fragment(reaction_note2);
let reac2_1 = builder.insert_reac_frag(reaction_note2);
builder.aeq(0, Expect::Reaction(vec![&reac2_1]));
builder.aeq(1, Expect::Reaction(vec![&reac1_1]));
builder.aeq(2, Expect::Single(&single0));
@@ -507,7 +589,7 @@ mod tests {
builder.aeq(2, Expect::Reaction(vec![&reac1_1]));
builder.aeq(3, Expect::Single(&single0));
let reac1_2 = builder.fragment(reaction_note1);
let reac1_2 = builder.insert_reac_frag(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac1_2, &reac1_1]));
builder.aeq(1, Expect::Single(&single1));
builder.aeq(2, Expect::Reaction(vec![&reac2_1]));
@@ -520,14 +602,14 @@ mod tests {
builder.aeq(3, Expect::Reaction(vec![&reac2_1]));
builder.aeq(4, Expect::Single(&single0));
let reac1_3 = builder.fragment(reaction_note1);
let reac1_3 = builder.insert_reac_frag(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac1_2, &reac1_1, &reac1_3]));
builder.aeq(1, Expect::Single(&single2));
builder.aeq(2, Expect::Single(&single1));
builder.aeq(3, Expect::Reaction(vec![&reac2_1]));
builder.aeq(4, Expect::Single(&single0));
let reac2_2 = builder.fragment(reaction_note2);
let reac2_2 = builder.insert_reac_frag(reaction_note2);
builder.aeq(0, Expect::Reaction(vec![&reac2_1, &reac2_2]));
builder.aeq(1, Expect::Reaction(vec![&reac1_2, &reac1_1, &reac1_3]));
builder.aeq(2, Expect::Single(&single2));
@@ -536,18 +618,18 @@ mod tests {
}
#[test]
fn test3() {
fn test_reactions3() {
let mut builder = UnitBuilder::default();
let reaction_note1 = builder.generate_reaction_note();
let reaction_note1 = builder.new_noteref();
let single1 = builder.insert_note();
builder.aeq(0, Expect::Single(&single1));
let reac0 = builder.fragment(reaction_note1);
let reac0 = builder.insert_reac_frag(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac0]));
builder.aeq(1, Expect::Single(&single1));
let (reac1, reac2) = builder.fragments_pair(reaction_note1);
let (reac1, reac2) = builder.insert_reac_frag_pair(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac0, &reac1, &reac2]));
builder.aeq(1, Expect::Single(&single1));
@@ -556,4 +638,40 @@ mod tests {
builder.aeq(1, Expect::Reaction(vec![&reac0, &reac1, &reac2]));
builder.aeq(2, Expect::Single(&single1));
}
#[test]
fn test_repost() {
let mut builder = UnitBuilder::default();
let repost_note = builder.new_noteref();
let single1 = builder.insert_note();
builder.aeq(0, Expect::Single(&single1));
let repost1 = builder.insert_repost(repost_note);
builder.aeq(0, Expect::Repost(vec![&repost1]));
builder.aeq(1, Expect::Single(&single1));
let single2 = builder.insert_note();
builder.aeq(0, Expect::Single(&single2));
builder.aeq(1, Expect::Repost(vec![&repost1]));
builder.aeq(2, Expect::Single(&single1));
let reac1 = builder.insert_reac_frag(repost_note);
builder.aeq(0, Expect::Reaction(vec![&reac1]));
builder.aeq(1, Expect::Single(&single2));
builder.aeq(2, Expect::Repost(vec![&repost1]));
builder.aeq(3, Expect::Single(&single1));
let repost2 = builder.insert_repost(repost_note);
builder.aeq(0, Expect::Repost(vec![&repost1, &repost2]));
builder.aeq(1, Expect::Reaction(vec![&reac1]));
builder.aeq(2, Expect::Single(&single2));
builder.aeq(3, Expect::Single(&single1));
let reac2 = builder.insert_reac_frag(repost_note);
builder.aeq(0, Expect::Reaction(vec![&reac1, &reac2]));
builder.aeq(1, Expect::Repost(vec![&repost1, &repost2]));
builder.aeq(2, Expect::Single(&single2));
builder.aeq(3, Expect::Single(&single1));
}
}
@@ -31,7 +31,6 @@ pub fn render_timeline_route(
| TimelineKind::Generic(_) => {
let note_action =
ui::TimelineView::new(kind, timeline_cache, note_context, note_options, jobs, col)
.scroll_to_top(scroll_to_top)
.ui(ui);
note_action.map(RenderNavAction::NoteAction)
@@ -8,7 +8,11 @@ use notedeck::{NoteCache, NoteRef, UnknownIds};
use crate::{
actionbar::{process_thread_notes, NewThreadNotes},
multi_subscriber::ThreadSubs,
timeline::{note_units::NoteUnits, unit::NoteUnit, InsertionResponse},
timeline::{
note_units::{NoteUnits, UnitKey},
unit::NoteUnit,
InsertionResponse,
},
};
use super::ThreadSelection;
@@ -417,6 +421,6 @@ impl SingleNoteUnits {
}
pub fn contains_key(&self, k: &NoteKey) -> bool {
self.units.contains_key(k)
self.units.contains_key(&UnitKey::Single(*k))
}
}
@@ -3,10 +3,13 @@ use std::collections::HashSet;
use enostr::Pubkey;
use nostrdb::{Ndb, Note, NoteKey, Transaction};
use notedeck::NoteRef;
use notedeck_ui::note::get_reposted_note;
use crate::timeline::{
note_units::{InsertManyResponse, NoteUnits},
unit::{CompositeFragment, NoteUnit, NoteUnitFragment, Reaction, ReactionFragment},
unit::{
CompositeFragment, NoteUnit, NoteUnitFragment, Reaction, ReactionFragment, RepostFragment,
},
};
#[derive(Debug, Default)]
@@ -99,6 +102,15 @@ pub struct NotePayload<'a> {
pub key: NoteKey,
}
impl<'a> NotePayload<'a> {
pub fn noteref(&self) -> NoteRef {
NoteRef {
key: self.key,
created_at: self.note.created_at(),
}
}
}
fn to_fragment<'a>(
payload: &'a NotePayload,
ndb: &Ndb,
@@ -116,6 +128,7 @@ fn to_fragment<'a>(
fragment: NoteUnitFragment::Composite(CompositeFragment::Reaction(r.fragment)),
unknown_pk: Some(r.pk),
}),
6 => to_repost(payload, ndb, txn).map(RepostResponse::into),
_ => None,
}
}
@@ -147,10 +160,7 @@ fn to_reaction<'a>(
let reacted_to_noteid = note_reacted_to?;
let reaction_note_ref = NoteRef {
key: payload.key,
created_at: payload.note.created_at(),
};
let reaction_note_ref = payload.noteref();
let reacted_to_note = ndb.get_note_by_id(txn, reacted_to_noteid).ok()?;
@@ -174,5 +184,59 @@ fn to_reaction<'a>(
pub struct ReactionResponse<'a> {
fragment: ReactionFragment,
pk: &'a [u8; 32],
pk: &'a [u8; 32], // reaction sender
}
pub struct RepostResponse<'a> {
fragment: RepostFragment,
reposter_pk: &'a [u8; 32],
}
impl<'a> From<RepostResponse<'a>> for NoteUnitFragmentResponse<'a> {
fn from(value: RepostResponse<'a>) -> Self {
Self {
fragment: NoteUnitFragment::Composite(CompositeFragment::Repost(value.fragment)),
unknown_pk: Some(value.reposter_pk),
}
}
}
fn to_repost<'a>(
payload: &'a NotePayload,
ndb: &Ndb,
txn: &Transaction,
) -> Option<RepostResponse<'a>> {
let reposted_note = match get_reposted_note(ndb, txn, &payload.note) {
Some(r) => r,
None => {
tracing::debug!(
"Could not get reposted note for note id {}",
enostr::NoteId::new(*payload.note.id()).hex()
);
return None;
}
};
let reposted_key = match reposted_note.key() {
Some(r) => r,
None => {
tracing::error!(
"Could not get key of reposted note {}",
enostr::NoteId::new(*reposted_note.id()).hex()
);
return None;
}
};
Some(RepostResponse {
fragment: RepostFragment {
reposted_noteref: NoteRef {
key: reposted_key,
created_at: reposted_note.created_at(),
},
repost_noteref: payload.noteref(),
reposter: Pubkey::new(*payload.note.pubkey()),
},
reposter_pk: payload.note.pubkey(),
})
}
+119 -22
View File
@@ -1,9 +1,10 @@
use std::collections::{BTreeMap, HashSet};
use enostr::Pubkey;
use nostrdb::NoteKey;
use notedeck::NoteRef;
use crate::timeline::note_units::{CompositeKey, CompositeType, UnitKey};
/// A `NoteUnit` represents a cohesive piece of data derived from notes
#[derive(Debug, Clone)]
pub enum NoteUnit {
@@ -12,10 +13,10 @@ pub enum NoteUnit {
}
impl NoteUnit {
pub fn key(&self) -> NoteKey {
pub fn key(&self) -> UnitKey {
match self {
NoteUnit::Single(note_ref) => note_ref.key,
NoteUnit::Composite(clustered_entry) => clustered_entry.key(),
NoteUnit::Single(note_ref) => UnitKey::Single(note_ref.key),
NoteUnit::Composite(clustered_entry) => UnitKey::Composite(clustered_entry.key()),
}
}
@@ -24,6 +25,7 @@ impl NoteUnit {
NoteUnit::Single(note_ref) => note_ref,
NoteUnit::Composite(clustered) => match clustered {
CompositeUnit::Reaction(reaction_entry) => &reaction_entry.note_reacted_to,
CompositeUnit::Repost(repost_unit) => &repost_unit.note_reposted,
},
}
}
@@ -60,12 +62,14 @@ impl PartialOrd for NoteUnit {
#[derive(Debug, Clone)]
pub enum CompositeUnit {
Reaction(ReactionUnit),
Repost(RepostUnit),
}
impl CompositeUnit {
pub fn get_latest_ref(&self) -> &NoteRef {
match self {
CompositeUnit::Reaction(reaction_unit) => reaction_unit.get_latest_ref(),
CompositeUnit::Repost(repost_unit) => repost_unit.get_latest_ref(),
}
}
}
@@ -74,14 +78,23 @@ impl PartialEq for CompositeUnit {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Reaction(l0), Self::Reaction(r0)) => l0 == r0,
(Self::Repost(l0), Self::Repost(r0)) => l0 == r0,
_ => false,
}
}
}
impl CompositeUnit {
pub fn key(&self) -> NoteKey {
pub fn key(&self) -> CompositeKey {
match self {
CompositeUnit::Reaction(reaction_entry) => reaction_entry.note_reacted_to.key,
CompositeUnit::Reaction(reaction_entry) => CompositeKey {
key: reaction_entry.note_reacted_to.key,
composite_type: CompositeType::Reaction,
},
CompositeUnit::Repost(repost_unit) => CompositeKey {
key: repost_unit.note_reposted.key,
composite_type: CompositeType::Repost,
},
}
}
}
@@ -92,6 +105,9 @@ impl From<CompositeFragment> for CompositeUnit {
CompositeFragment::Reaction(reaction_fragment) => {
CompositeUnit::Reaction(reaction_fragment.into())
}
CompositeFragment::Repost(repost_fragment) => {
CompositeUnit::Repost(repost_fragment.into())
}
}
}
}
@@ -129,6 +145,38 @@ impl From<ReactionFragment> for ReactionUnit {
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct RepostUnit {
pub note_reposted: NoteRef,
pub reposts: BTreeMap<NoteRef, Pubkey>, // repost note to sender
pub senders: HashSet<Pubkey>,
}
impl RepostUnit {
pub fn get_latest_ref(&self) -> &NoteRef {
self.reposts
.first_key_value()
.map(|(r, _)| r)
.unwrap_or(&self.note_reposted)
}
}
impl From<RepostFragment> for RepostUnit {
fn from(value: RepostFragment) -> Self {
let mut reposts = BTreeMap::new();
reposts.insert(value.repost_noteref, value.reposter);
let mut senders = HashSet::new();
senders.insert(value.reposter);
Self {
note_reposted: value.reposted_noteref,
reposts,
senders,
}
}
}
#[derive(Clone)]
pub enum NoteUnitFragment {
Single(NoteRef),
@@ -138,32 +186,62 @@ pub enum NoteUnitFragment {
#[derive(Debug, Clone)]
pub enum CompositeFragment {
Reaction(ReactionFragment),
Repost(RepostFragment),
}
impl CompositeFragment {
pub fn fold_into(self, unit: &mut CompositeUnit) {
match self {
CompositeFragment::Reaction(reaction_fragment) => reaction_fragment.fold_into(unit),
CompositeFragment::Reaction(reaction_fragment) => {
let CompositeUnit::Reaction(reaction_unit) = unit else {
tracing::error!("Attempting to fold a reaction fragment into a unit which isn't ReactionUnit. Doing nothing, this should never occur");
return;
};
reaction_fragment.fold_into(reaction_unit);
}
CompositeFragment::Repost(repost_fragment) => {
let CompositeUnit::Repost(repost_unit) = unit else {
tracing::error!("Attempting to fold a repost fragment into a unit which isn't RepostUnit. Doing nothing, this should never occur");
return;
};
repost_fragment.fold_into(repost_unit);
}
}
}
pub fn key(&self) -> NoteKey {
pub fn key(&self) -> CompositeKey {
match self {
CompositeFragment::Reaction(reaction_fragment) => {
reaction_fragment.reaction_note_ref.key
}
CompositeFragment::Reaction(reaction) => CompositeKey {
key: reaction.noteref_reacted_to.key,
composite_type: CompositeType::Reaction,
},
CompositeFragment::Repost(repost) => CompositeKey {
key: repost.reposted_noteref.key,
composite_type: CompositeType::Repost,
},
}
}
pub fn get_underlying_noteref(&self) -> &NoteRef {
match self {
CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.noteref_reacted_to,
CompositeFragment::Repost(repost_fragment) => &repost_fragment.reposted_noteref,
}
}
pub fn get_latest_ref(&self) -> &NoteRef {
match self {
CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.reaction_note_ref,
CompositeFragment::Repost(repost_fragment) => &repost_fragment.repost_noteref,
}
}
pub fn get_type(&self) -> CompositeType {
match self {
CompositeFragment::Reaction(_) => CompositeType::Reaction,
CompositeFragment::Repost(_) => CompositeType::Repost,
}
}
}
@@ -178,23 +256,18 @@ pub struct ReactionFragment {
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 {
pub fn fold_into(self, unit: &mut ReactionUnit) {
if self.noteref_reacted_to != unit.note_reacted_to {
tracing::error!("Attempting to fold a reaction fragment into a ReactionUnit which as a different note reacted to: {:?} != {:?}. This should never occur", self.noteref_reacted_to, unit.note_reacted_to);
return;
}
if reaction_unit.senders.contains(&self.reaction.sender) {
if unit.senders.contains(&self.reaction.sender) {
return;
}
reaction_unit.senders.insert(self.reaction.sender);
reaction_unit
.reactions
.insert(self.reaction_note_ref, self.reaction);
}
}
unit.senders.insert(self.reaction.sender);
unit.reactions.insert(self.reaction_note_ref, self.reaction);
}
}
@@ -203,3 +276,27 @@ pub struct Reaction {
pub reaction: String, // can't use char because some emojis are 'grapheme clusters'
pub sender: Pubkey,
}
/// Represents a singular repost
#[derive(Debug, Clone)]
pub struct RepostFragment {
pub reposted_noteref: NoteRef,
pub repost_noteref: NoteRef,
pub reposter: Pubkey,
}
impl RepostFragment {
pub fn fold_into(self, unit: &mut RepostUnit) {
if self.reposted_noteref != unit.note_reposted {
tracing::error!("Attempting to fold a repost fragment into a RepostUnit which has a different note reposted: {:?} != {:?}. This should never occur", self.reposted_noteref, unit.note_reposted);
return;
}
if unit.senders.contains(&self.reposter) {
return;
}
unit.senders.insert(self.reposter);
unit.reposts.insert(self.repost_noteref, self.reposter);
}
}
@@ -157,7 +157,7 @@ fn login_textedit<'a>(
text_edit
}
fn eye_button(ui: &mut egui::Ui, is_visible: bool) -> egui::Response {
pub fn eye_button(ui: &mut egui::Ui, is_visible: bool) -> egui::Response {
let is_dark_mode = ui.visuals().dark_mode;
let icon = if is_visible && is_dark_mode {
app_images::eye_dark_image()
@@ -292,10 +292,6 @@ fn show_amount(
ui.add_space(8.0);
});
});
// let user_changed = cur_input != Some(user_input.clone());
ui.memory_mut(|m| m.request_focus(user_input_id));
// ui.data_mut(|d| d.insert_temp(id, user_input));
}
const SELECTION_BUTTONS: [ZapSelectionButton; 8] = [
+7 -7
View File
@@ -342,6 +342,13 @@ impl<'a, 'd> PostView<'a, 'd> {
}
pub fn ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> PostResponse {
ScrollArea::vertical()
.id_salt(PostView::scroll_id())
.show(ui, |ui| self.ui_no_scroll(txn, ui))
.inner
}
pub fn ui_no_scroll(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> PostResponse {
while let Some(selected_file) = get_next_selected_file() {
match selected_file {
Ok(selected_media) => {
@@ -358,13 +365,6 @@ impl<'a, 'd> PostView<'a, 'd> {
}
}
ScrollArea::vertical()
.id_salt(PostView::scroll_id())
.show(ui, |ui| self.ui_no_scroll(txn, ui))
.inner
}
pub fn ui_no_scroll(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> PostResponse {
let focused = self.focused(ui);
let stroke = if focused {
ui.visuals().selection.stroke
+2 -2
View File
@@ -2,7 +2,7 @@ use std::mem;
use egui::{Layout, ScrollArea};
use nostrdb::Ndb;
use notedeck::{Images, JobPool, JobsCache, Localization};
use notedeck::{tr, Images, JobPool, JobsCache, Localization};
use notedeck_ui::{
colors,
nip51_set::{Nip51SetUiCache, Nip51SetWidget, Nip51SetWidgetAction, Nip51SetWidgetFlags},
@@ -107,7 +107,7 @@ impl<'a> FollowPackOnboardingView<'a> {
ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
ui.add_space(4.0);
if ui.add(styled_button("Done", colors::PINK)).clicked() {
if ui.add(styled_button(tr!(self.loc, "Done", "Button to indicate that the user is done going through the onboarding process.").as_str(), colors::PINK)).clicked() {
action = Some(OnboardingResponse::FollowPacks(
FollowPacksResponse::UserSelectedPacks(mem::take(self.ui_state)),
));
+46 -35
View File
@@ -39,6 +39,11 @@ pub enum ProfileViewAction {
Follow(Pubkey),
}
struct ProfileScrollResponse {
body_end_pos: f32,
action: Option<ProfileViewAction>,
}
impl<'a, 'd> ProfileView<'a, 'd> {
#[allow(clippy::too_many_arguments)]
pub fn new(
@@ -65,15 +70,13 @@ impl<'a, 'd> ProfileView<'a, 'd> {
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<ProfileViewAction> {
let scroll_id = ProfileView::scroll_id(self.col_id, self.pubkey);
let offset_id = scroll_id.with("scroll_offset");
let scroll_area = ScrollArea::vertical().id_salt(scroll_id).animated(false);
let mut scroll_area = ScrollArea::vertical().id_salt(scroll_id);
let profile_timeline = self
.timeline_cache
.get_mut(&TimelineKind::Profile(*self.pubkey))?;
if let Some(offset) = ui.data(|i| i.get_temp::<f32>(offset_id)) {
scroll_area = scroll_area.vertical_scroll_offset(offset);
}
let output = scroll_area.show(ui, |ui| 's: {
let output = scroll_area.show(ui, |ui| {
let mut action = None;
let txn = Transaction::new(self.note_context.ndb).expect("txn");
let profile = self
@@ -82,23 +85,19 @@ impl<'a, 'd> ProfileView<'a, 'd> {
.get_profile_by_pubkey(&txn, self.pubkey.bytes())
.ok();
if let Some(profile_view_action) = self.profile_body(ui, profile.as_ref()) {
if let Some(profile_view_action) =
profile_body(ui, self.pubkey, self.note_context, profile.as_ref())
{
action = Some(profile_view_action);
}
let Some(profile_timeline) = self
.timeline_cache
.get_mut(&TimelineKind::Profile(*self.pubkey))
else {
break 's action;
};
profile_timeline.selected_view = tabs_ui(
let tabs_resp = tabs_ui(
ui,
self.note_context.i18n,
profile_timeline.selected_view,
&profile_timeline.views,
);
profile_timeline.selected_view = tabs_resp.inner;
let reversed = false;
// poll for new notes and insert them into our existing notes
@@ -124,19 +123,25 @@ impl<'a, 'd> ProfileView<'a, 'd> {
action = Some(ProfileViewAction::Note(note_action));
}
action
ProfileScrollResponse {
body_end_pos: tabs_resp.response.rect.bottom(),
action,
}
});
ui.data_mut(|d| d.insert_temp(offset_id, output.state.offset.y));
// only allow front insert when the profile body is fully obstructed
profile_timeline.enable_front_insert = output.inner.body_end_pos < ui.clip_rect().top();
output.inner
output.inner.action
}
}
fn profile_body(
&mut self,
fn profile_body(
ui: &mut egui::Ui,
pubkey: &Pubkey,
note_context: &mut NoteContext,
profile: Option<&ProfileRecord<'_>>,
) -> Option<ProfileViewAction> {
) -> Option<ProfileViewAction> {
let mut action = None;
ui.vertical(|ui| {
banner(
@@ -158,13 +163,16 @@ impl<'a, 'd> ProfileView<'a, 'd> {
ui.horizontal(|ui| {
ui.put(
pfp_rect,
&mut ProfilePic::new(self.note_context.img_cache, get_profile_url(profile))
&mut ProfilePic::new(note_context.img_cache, get_profile_url(profile))
.size(size)
.border(ProfilePic::border_stroke(ui)),
);
if ui.add(copy_key_widget(&pfp_rect)).clicked() {
let to_copy = if let Some(bech) = self.pubkey.npub() {
if ui
.add(copy_key_widget(&pfp_rect, note_context.i18n))
.clicked()
{
let to_copy = if let Some(bech) = pubkey.npub() {
bech
} else {
error!("Could not convert Pubkey to bech");
@@ -176,12 +184,12 @@ impl<'a, 'd> ProfileView<'a, 'd> {
ui.with_layout(Layout::right_to_left(egui::Align::RIGHT), |ui| {
ui.add_space(24.0);
let target_key = self.pubkey;
let selected = self.note_context.accounts.get_selected_account();
let target_key = pubkey;
let selected = note_context.accounts.get_selected_account();
let profile_type = if selected.key.secret_key.is_none() {
ProfileType::ReadOnly
} else if &selected.key.pubkey == self.pubkey {
} else if &selected.key.pubkey == pubkey {
ProfileType::MyProfile
} else {
ProfileType::Followable(selected.is_following(target_key.bytes()))
@@ -189,10 +197,7 @@ impl<'a, 'd> ProfileView<'a, 'd> {
match profile_type {
ProfileType::MyProfile => {
if ui
.add(edit_profile_button(self.note_context.i18n))
.clicked()
{
if ui.add(edit_profile_button(note_context.i18n)).clicked() {
action = Some(ProfileViewAction::EditProfile);
}
}
@@ -259,7 +264,6 @@ impl<'a, 'd> ProfileView<'a, 'd> {
});
action
}
}
enum ProfileType {
@@ -297,7 +301,10 @@ fn handle_lud16(ui: &mut egui::Ui, lud16: &str) {
.on_hover_text(lud16);
}
fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ {
fn copy_key_widget<'a>(
pfp_rect: &'a egui::Rect,
i18n: &'a mut Localization,
) -> impl egui::Widget + 'a {
|ui: &mut egui::Ui| -> egui::Response {
let painter = ui.painter();
#[allow(deprecated)]
@@ -311,7 +318,11 @@ fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ {
ui.id().with("custom_painter"),
Sense::click(),
)
.on_hover_text("Copy npub to clipboard");
.on_hover_text(tr!(
i18n,
"Copy npub to clipboard",
"Tooltip text for copying npub to clipboard"
));
let copy_key_rounding = CornerRadius::same(100);
let fill_color = if resp.hovered() {
+162 -3
View File
@@ -1,6 +1,8 @@
use egui::{
vec2, Button, Color32, ComboBox, FontId, Frame, Margin, RichText, ScrollArea, ThemePreference,
vec2, Button, Color32, ComboBox, CornerRadius, FontId, Frame, Layout, Margin, RichText,
ScrollArea, TextEdit, ThemePreference,
};
use egui_extras::{Size, StripBuilder};
use enostr::NoteId;
use nostrdb::Transaction;
use notedeck::{
@@ -9,9 +11,12 @@ use notedeck::{
Images, JobsCache, LanguageIdentifier, Localization, NoteContext, NotedeckTextStyle, Settings,
SettingsHandler, DEFAULT_NOTE_BODY_FONT_SIZE,
};
use notedeck_ui::{NoteOptions, NoteView};
use notedeck_ui::{
app_images::{copy_to_clipboard_dark_image, copy_to_clipboard_image},
AnimationHelper, NoteOptions, NoteView,
};
use crate::{nav::RouterAction, Damus, Route};
use crate::{nav::RouterAction, ui::account_login_view::eye_button, Damus, Route};
const PREVIEW_NOTE_ID: &str = "note1edjc8ggj07hwv77g2405uh6j2jkk5aud22gktxrvc2wnre4vdwgqzlv2gw";
@@ -470,6 +475,149 @@ impl<'a> SettingsView<'a> {
action
}
fn keys_section(&mut self, ui: &mut egui::Ui) {
let title = tr!(
self.note_context.i18n,
"Keys",
"label for keys setting section"
);
settings_group(ui, title, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label(
richtext_small(tr!(
self.note_context.i18n,
"PUBLIC ACCOUNT ID",
"label describing public key"
))
.color(ui.visuals().gray_out(ui.visuals().text_color())),
);
});
let copy_img = if ui.visuals().dark_mode {
copy_to_clipboard_image()
} else {
copy_to_clipboard_dark_image()
};
let copy_max_size = vec2(16.0, 16.0);
if let Some(npub) = self.note_context.accounts.selected_account_pubkey().npub() {
item_frame(ui).show(ui, |ui| {
StripBuilder::new(ui)
.size(Size::exact(24.0))
.cell_layout(Layout::left_to_right(egui::Align::Center))
.vertical(|mut strip| {
strip.strip(|builder| {
builder
.size(Size::remainder())
.size(Size::exact(16.0))
.cell_layout(Layout::left_to_right(egui::Align::Center))
.horizontal(|mut strip| {
strip.cell(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(richtext_small(&npub));
});
});
strip.cell(|ui| {
let helper = AnimationHelper::new(
ui,
"copy-to-clipboard-npub",
copy_max_size,
);
copy_img.paint_at(ui, helper.scaled_rect());
if helper.take_animation_response().clicked() {
ui.ctx().copy_text(npub);
}
});
});
});
});
});
}
let Some(filled) = self.note_context.accounts.selected_filled() else {
return;
};
let Some(mut nsec) = bech32::encode::<bech32::Bech32>(
bech32::Hrp::parse_unchecked("nsec"),
&filled.secret_key.secret_bytes(),
)
.ok() else {
return;
};
ui.horizontal_wrapped(|ui| {
ui.label(
richtext_small(tr!(
self.note_context.i18n,
"SECRET ACCOUNT LOGIN KEY",
"label describing secret key"
))
.color(ui.visuals().gray_out(ui.visuals().text_color())),
);
});
let is_password_id = ui.id().with("is-password");
let is_password = ui
.ctx()
.data_mut(|d| d.get_temp(is_password_id))
.unwrap_or(true);
item_frame(ui).show(ui, |ui| {
StripBuilder::new(ui)
.size(Size::exact(24.0))
.cell_layout(Layout::left_to_right(egui::Align::Center))
.vertical(|mut strip| {
strip.strip(|builder| {
builder
.size(Size::remainder())
.size(Size::exact(48.0))
.cell_layout(Layout::left_to_right(egui::Align::Center))
.horizontal(|mut strip| {
strip.cell(|ui| {
if is_password {
ui.add(
TextEdit::singleline(&mut nsec)
.password(is_password)
.interactive(false)
.frame(false),
);
} else {
ui.horizontal_wrapped(|ui| {
ui.label(richtext_small(&nsec));
});
}
});
strip.cell(|ui| {
let helper = AnimationHelper::new(
ui,
"copy-to-clipboard-nsec",
copy_max_size,
);
copy_img.paint_at(ui, helper.scaled_rect());
if helper.take_animation_response().clicked() {
ui.ctx().copy_text(nsec);
}
if eye_button(ui, is_password).clicked() {
ui.ctx().data_mut(|d| {
d.insert_temp(is_password_id, !is_password)
});
}
});
});
});
});
});
});
}
fn manage_relays_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
let mut action = None;
@@ -509,6 +657,10 @@ impl<'a> SettingsView<'a> {
ui.add_space(5.0);
self.keys_section(ui);
ui.add_space(5.0);
if let Some(new_action) = self.other_options_section(ui) {
action = Some(new_action);
}
@@ -542,3 +694,10 @@ pub fn format_size(size_bytes: u64) -> String {
format!("{:.2} GB", size / GB)
}
}
fn item_frame(ui: &egui::Ui) -> egui::Frame {
Frame::new()
.inner_margin(Margin::same(8))
.corner_radius(CornerRadius::same(8))
.fill(ui.visuals().panel_fill)
}
+7 -3
View File
@@ -120,7 +120,7 @@ impl<'a> DesktopSidePanel<'a> {
.color(ui.visuals().noninteractive().fg_stroke.color),
));
ui.add_space(8.0);
let add_deck_resp = ui.add(add_deck_button());
let add_deck_resp = ui.add(add_deck_button(self.i18n));
let decks_inner = ScrollArea::vertical()
.max_height(ui.available_height() - (3.0 * (ICON_WIDTH + 12.0)))
@@ -383,7 +383,7 @@ pub fn search_button() -> impl Widget {
// TODO: convert to responsive button when expanded side panel impl is finished
fn add_deck_button() -> impl Widget {
fn add_deck_button<'a>(i18n: &'a mut Localization) -> impl Widget + 'a {
|ui: &mut egui::Ui| -> egui::Response {
let img_size = 40.0;
@@ -403,7 +403,11 @@ fn add_deck_button() -> impl Widget {
helper
.take_animation_response()
.on_hover_cursor(CursorIcon::PointingHand)
.on_hover_text("Add new deck")
.on_hover_text(tr!(
i18n,
"Add new deck",
"Tooltip text for adding a new deck button"
))
}
}
+458 -112
View File
@@ -1,18 +1,20 @@
use egui::containers::scroll_area::ScrollBarVisibility;
use egui::{vec2, Direction, Layout, Margin, Pos2, ScrollArea, Sense, Stroke};
use egui::{vec2, Color32, Direction, Layout, Margin, Pos2, RichText, ScrollArea, Sense, Stroke};
use egui_tabs::TabColor;
use enostr::Pubkey;
use nostrdb::{ProfileRecord, Transaction};
use nostrdb::{Note, ProfileRecord, Transaction};
use notedeck::fonts::get_font_size;
use notedeck::name::get_display_name;
use notedeck::ui::is_narrow;
use notedeck::{JobsCache, Muted, NoteRef};
use notedeck_ui::app_images::like_image;
use notedeck_ui::ProfilePic;
use notedeck::{tr_plural, JobsCache, Muted, NotedeckTextStyle};
use notedeck_ui::app_images::{like_image, repost_image};
use notedeck_ui::{ProfilePic, ProfilePreview};
use std::f32::consts::PI;
use tracing::{error, warn};
use crate::timeline::{
CompositeUnit, NoteUnit, ReactionUnit, TimelineCache, TimelineKind, TimelineTab, ViewFilter,
CompositeType, CompositeUnit, NoteUnit, ReactionUnit, RepostUnit, TimelineCache, TimelineKind,
TimelineTab, ViewFilter,
};
use notedeck::{
note::root_note_id_from_selected_id, tr, Localization, NoteAction, NoteContext, ScrollInfo,
@@ -87,7 +89,7 @@ fn timeline_ui(
ui: &mut egui::Ui,
timeline_id: &TimelineKind,
timeline_cache: &mut TimelineCache,
note_options: NoteOptions,
mut note_options: NoteOptions,
note_context: &mut NoteContext,
jobs: &mut JobsCache,
col: usize,
@@ -117,7 +119,8 @@ fn timeline_ui(
note_context.i18n,
timeline.selected_view,
&timeline.views,
);
)
.inner;
// need this for some reason??
ui.add_space(3.0);
@@ -150,12 +153,6 @@ fn timeline_ui(
.auto_shrink([false, false])
.scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible);
let offset_id = scroll_id.with("timeline_scroll_offset");
if let Some(offset) = ui.data(|i| i.get_temp::<f32>(offset_id)) {
scroll_area = scroll_area.vertical_scroll_offset(offset);
}
if goto_top_resp.is_some_and(|r| r.clicked()) {
scroll_area = scroll_area.vertical_scroll_offset(0.0);
}
@@ -180,6 +177,10 @@ fn timeline_ui(
let txn = Transaction::new(note_context.ndb).expect("failed to create txn");
if matches!(timeline_id, TimelineKind::Notifications(_)) {
note_options.set(NoteOptions::Notification, true)
}
TimelineTabView::new(
timeline.current_view(),
note_options,
@@ -190,8 +191,6 @@ fn timeline_ui(
.show(ui)
});
ui.data_mut(|d| d.insert_temp(offset_id, scroll_output.state.offset.y));
let at_top_after_scroll = scroll_output.state.offset.y == 0.0;
let cur_show_top_button = ui.ctx().data(|d| d.get_temp::<bool>(show_top_button_id));
@@ -279,7 +278,7 @@ pub fn tabs_ui(
i18n: &mut Localization,
selected: usize,
views: &[TimelineTab],
) -> usize {
) -> egui::InnerResponse<usize> {
ui.spacing_mut().item_spacing.y = 0.0;
let tab_res = egui_tabs::Tabs::new(views.len() as i32)
@@ -327,7 +326,9 @@ pub fn tabs_ui(
let sel = tab_res.selected().unwrap_or_default();
let (underline, underline_y) = tab_res.inner()[sel as usize].inner;
let res_inner = &tab_res.inner()[sel as usize];
let (underline, underline_y) = res_inner.inner;
let underline_width = underline.span();
let tab_anim_id = ui.id().with("tab_anim");
@@ -354,7 +355,7 @@ pub fn tabs_ui(
ui.painter().hline(underline, underline_y, stroke);
sel as usize
egui::InnerResponse::new(sel as usize, res_inner.response.clone())
}
fn get_label_width(ui: &mut egui::Ui, text: &str) -> f32 {
@@ -439,15 +440,46 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
entry: &NoteUnit,
mute: &std::sync::Arc<Muted>,
) -> RenderEntryResponse {
let underlying_note = {
let underlying_note_key = match entry {
NoteUnit::Single(note_ref) => note_ref.key,
NoteUnit::Composite(composite_unit) => match composite_unit {
CompositeUnit::Reaction(reaction_unit) => reaction_unit.note_reacted_to.key,
CompositeUnit::Repost(repost_unit) => repost_unit.note_reposted.key,
},
};
let Ok(note) = self
.note_context
.ndb
.get_note_by_key(self.txn, underlying_note_key)
else {
warn!("failed to query note {:?}", underlying_note_key);
return RenderEntryResponse::Unsuccessful;
};
note
};
let muted = root_note_id_from_selected_id(
self.note_context.ndb,
self.note_context.note_cache,
self.txn,
underlying_note.id(),
)
.is_ok_and(|root_id| mute.is_muted(&underlying_note, root_id.bytes()));
if muted {
return RenderEntryResponse::Success(None);
}
match entry {
NoteUnit::Single(note_ref) => render_note(
NoteUnit::Single(_) => render_note(
ui,
self.note_context,
self.note_options,
self.jobs,
mute,
self.txn,
note_ref,
&underlying_note,
),
NoteUnit::Composite(composite) => match composite {
CompositeUnit::Reaction(reaction_unit) => render_reaction_cluster(
@@ -457,44 +489,199 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
self.jobs,
mute,
self.txn,
&underlying_note,
reaction_unit,
),
CompositeUnit::Repost(repost_unit) => render_repost_cluster(
ui,
self.note_context,
self.note_options,
self.jobs,
mute,
self.txn,
&underlying_note,
repost_unit,
),
},
}
}
}
enum ReferencedNoteType {
Tagged,
Yours,
}
impl CompositeType {
fn image(&self, darkmode: bool) -> egui::Image<'static> {
match self {
CompositeType::Reaction => like_image(),
CompositeType::Repost => {
repost_image(darkmode).tint(Color32::from_rgb(0x68, 0xC3, 0x51))
}
}
}
fn description(
&self,
loc: &mut Localization,
first_name: &str,
total_count: usize,
referenced_type: ReferencedNoteType,
notification: bool,
) -> String {
let count = total_count - 1;
match self {
CompositeType::Reaction => {
reaction_description(loc, first_name, count, referenced_type)
}
CompositeType::Repost => repost_description(
loc,
first_name,
count,
if notification {
DescriptionType::Notification(referenced_type)
} else {
DescriptionType::Other
},
),
}
}
}
fn reaction_description(
loc: &mut Localization,
first_name: &str,
count: usize,
referenced_type: ReferencedNoteType,
) -> String {
match referenced_type {
ReferencedNoteType::Tagged => {
if count == 0 {
tr!(
loc,
"{name} reacted to a note you were tagged in",
"reaction from user to a note you were tagged in",
name = first_name
)
} else {
tr_plural!(
loc,
"{name} and {count} other reacted to a note you were tagged in",
"{name} and {count} others reacted to a note you were tagged in",
"amount of reactions a note you were tagged in received",
count,
name = first_name
)
}
}
ReferencedNoteType::Yours => {
if count == 0 {
tr!(
loc,
"{name} reacted to your note",
"reaction from user to your note",
name = first_name
)
} else {
tr_plural!(
loc,
"{name} and {count} other reacted to your note",
"{name} and {count} others reacted to your note",
"describing the amount of reactions your note received",
count,
name = first_name
)
}
}
}
}
enum DescriptionType {
Notification(ReferencedNoteType),
Other,
}
fn repost_description(
loc: &mut Localization,
first_name: &str,
count: usize,
description_type: DescriptionType,
) -> String {
match description_type {
DescriptionType::Notification(referenced_type) => match referenced_type {
ReferencedNoteType::Tagged => {
if count == 0 {
tr!(
loc,
"{name} reposted a note you were tagged in",
"repost from user",
name = first_name
)
} else {
tr_plural!(
loc,
"{name} and {count} other reposted a note you were tagged in",
"{name} and {count} others reposted a note you were tagged in",
"describing the amount of reposts a note you were tagged in received",
count,
name = first_name
)
}
}
ReferencedNoteType::Yours => {
if count == 0 {
tr!(
loc,
"{name} reposted your note",
"repost from user",
name = first_name
)
} else {
tr_plural!(
loc,
"{name} and {count} other reposted your note",
"{name} and {count} others reposted your note",
"describing the amount of reposts your note received",
count,
name = first_name
)
}
}
},
DescriptionType::Other => {
if count == 0 {
tr!(
loc,
"{name} reposted",
"repost from user",
name = first_name
)
} else {
tr_plural!(
loc,
"{name} and {count} other reposted",
"{name} and {count} others reposted",
"describing the amount of reposts a note has",
count,
name = first_name
)
}
}
}
}
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,
note: &Note,
) -> 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(&note, 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, &note, note_options, jobs).show(ui);
let resp = NoteView::new(note_context, note, note_options, jobs).show(ui);
if let Some(note_action) = resp.action {
action = Some(note_action);
@@ -506,6 +693,7 @@ fn render_note(
RenderEntryResponse::Success(action)
}
#[allow(clippy::too_many_arguments)]
fn render_reaction_cluster(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
@@ -513,16 +701,9 @@ fn render_reaction_cluster(
jobs: &mut JobsCache,
mute: &std::sync::Arc<Muted>,
txn: &Transaction,
underlying_note: &Note,
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()
@@ -534,96 +715,261 @@ fn render_reaction_cluster(
})
.collect();
render_composite_entry(
ui,
note_context,
note_options | NoteOptions::Notification,
jobs,
underlying_note,
profiles_to_show,
CompositeType::Reaction,
)
}
#[allow(clippy::too_many_arguments)]
fn render_composite_entry(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
mut note_options: NoteOptions,
jobs: &mut JobsCache,
underlying_note: &nostrdb::Note<'_>,
profiles_to_show: Vec<ProfileEntry>,
composite_type: CompositeType,
) -> RenderEntryResponse {
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 num_profiles = profiles_to_show.len();
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
let referenced_type = if note_context
.accounts
.get_selected_account()
.key
.pubkey
.bytes()
!= reacted_to_note.pubkey()
!= underlying_note.pubkey()
{
"note you were tagged in"
ReferencedNoteType::Tagged
} else {
"your note"
ReferencedNoteType::Yours
};
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}"));
if !note_options.contains(NoteOptions::TrustMedia) {
let acc = note_context.accounts.get_selected_account();
for entry in &profiles_to_show {
if matches!(acc.is_following(entry.pk), notedeck::IsFollowing::Yes) {
note_options = note_options.union(NoteOptions::TrustMedia);
break;
}
}
}
egui::Frame::new()
.inner_margin(Margin::symmetric(8, 4))
.show(ui, |ui| {
let show_label_newline = ui
.horizontal_wrapped(|ui| {
let pfps_resp = ui
.allocate_ui_with_layout(
vec2(ui.available_width(), 32.0),
Layout::left_to_right(egui::Align::Center),
|ui| {
render_profiles(
ui,
profiles_to_show,
&composite_type,
note_context.img_cache,
note_options.contains(NoteOptions::Notification),
)
},
)
.inner;
if let Some(cur_action) = pfps_resp.action {
action = Some(cur_action);
}
let description = composite_type.description(
note_context.i18n,
&first_name,
num_profiles,
referenced_type,
note_options.contains(NoteOptions::Notification),
);
let galley = ui.painter().layout_no_wrap(
description.clone(),
NotedeckTextStyle::Small.get_font_id(ui.ctx()),
ui.visuals().text_color(),
);
ui.add_space(4.0);
let galley_pos = {
let mut galley_pos = ui.next_widget_position();
galley_pos.y = pfps_resp.resp.rect.right_center().y;
galley_pos.y -= galley.rect.height() / 2.0;
galley_pos
};
let fits_no_wrap = {
let mut rightmost_pos = galley_pos;
rightmost_pos.x += galley.rect.width();
ui.available_rect_before_wrap().contains(rightmost_pos)
};
if fits_no_wrap {
ui.painter()
.galley(galley_pos, galley, ui.visuals().text_color());
None
} else {
Some(description)
}
})
.inner;
if let Some(desc) = show_label_newline {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(48.0);
ui.horizontal_wrapped(|ui| {
ui.add(egui::Label::new(
RichText::new(desc)
.size(get_font_size(ui.ctx(), &NotedeckTextStyle::Small)),
));
});
});
}
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(48.0);
let options = note_options
let resp = ui
.horizontal(|ui| {
if note_options.contains(NoteOptions::Notification) {
note_options = note_options
.difference(NoteOptions::ActionBar | NoteOptions::OptionsButton)
.union(NoteOptions::NotificationPreview);
let resp = NoteView::new(note_context, &reacted_to_note, options, jobs).show(ui);
ui.add_space(48.0);
};
NoteView::new(note_context, underlying_note, note_options, jobs).show(ui)
})
.inner;
if let Some(note_action) = resp.action {
action = Some(note_action);
action.get_or_insert(note_action);
}
});
});
notedeck_ui::hline(ui);
RenderEntryResponse::Success(action)
}
fn render_profiles(
ui: &mut egui::Ui,
profiles_to_show: Vec<ProfileEntry>,
composite_type: &CompositeType,
img_cache: &mut notedeck::Images,
notification: bool,
) -> PfpsResponse {
let mut action = None;
if notification {
ui.add_space(8.0);
}
ui.vertical(|ui| {
ui.add_space(9.0);
ui.add_sized(
vec2(20.0, 20.0),
composite_type.image(ui.visuals().dark_mode),
);
});
if notification {
ui.add_space(16.0);
} else {
ui.add_space(2.0);
}
let resp = ui.horizontal(|ui| {
ScrollArea::horizontal()
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
let mut last_pfp_resp = None;
for entry in profiles_to_show {
let mut resp = ui.add(
&mut ProfilePic::from_profile_or_default(img_cache, entry.record.as_ref())
.size(24.0)
.sense(Sense::click()),
);
if let Some(record) = entry.record.as_ref() {
resp = resp.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(300.0);
ui.add(ProfilePreview::new(record, img_cache));
});
}
last_pfp_resp = Some(resp.clone());
if resp.clicked() {
action = Some(NoteAction::Profile(*entry.pk))
}
}
last_pfp_resp
})
.inner
});
let resp = if let Some(r) = resp.inner {
r
} else {
resp.response
};
PfpsResponse { action, resp }
}
struct PfpsResponse {
action: Option<NoteAction>,
resp: egui::Response,
}
#[allow(clippy::too_many_arguments)]
fn render_repost_cluster(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
note_options: NoteOptions,
jobs: &mut JobsCache,
mute: &std::sync::Arc<Muted>,
txn: &Transaction,
underlying_note: &Note,
repost: &RepostUnit,
) -> RenderEntryResponse {
let profiles_to_show: Vec<ProfileEntry> = repost
.reposts
.values()
.filter(|r| !mute.is_pk_muted(r.bytes()))
.map(|p| ProfileEntry {
record: note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok(),
pk: p,
})
.collect();
render_composite_entry(
ui,
note_context,
note_options,
jobs,
underlying_note,
profiles_to_show,
CompositeType::Repost,
)
}
enum RenderEntryResponse {
Unsuccessful,
Success(Option<NoteAction>),
+11 -1
View File
@@ -1,4 +1,4 @@
use egui::{Pos2, Rect, Response, Sense};
use egui::{vec2, Pos2, Rect, Response, Sense};
pub fn hover_expand(
ui: &mut egui::Ui,
@@ -116,6 +116,16 @@ impl AnimationHelper {
self.rect
}
pub fn scaled_rect(&self) -> egui::Rect {
let min_height = self.rect.height() * (1.0 / self.expansion_multiple);
let min_width = self.rect.width() * (1.0 / self.expansion_multiple);
egui::Rect::from_center_size(
self.center,
vec2(self.scale_1d_pos(min_width), self.scale_1d_pos(min_height)),
)
}
pub fn center(&self) -> Pos2 {
self.rect.center()
}
+18
View File
@@ -183,6 +183,14 @@ pub fn repost_light_image() -> Image<'static> {
Image::new(include_image!("../../../assets/icons/repost_light_4x.png"))
}
pub fn repost_image(dark_mode: bool) -> Image<'static> {
if dark_mode {
repost_dark_image()
} else {
repost_light_image()
}
}
pub fn reply_dark_image() -> Image<'static> {
Image::new(include_image!("../../../assets/icons/reply.png"))
}
@@ -244,3 +252,13 @@ pub fn zap_light_image() -> Image<'static> {
pub fn like_image() -> Image<'static> {
Image::new(include_image!("../../../assets/icons/like_icon_4x.png"))
}
pub fn copy_to_clipboard_image() -> Image<'static> {
Image::new(include_image!(
"../../../assets/icons/copy-to-clipboard.svg"
))
}
pub fn copy_to_clipboard_dark_image() -> Image<'static> {
copy_to_clipboard_image().tint(Color32::BLACK)
}
+1 -19
View File
@@ -6,9 +6,7 @@ use crate::{
use egui::{Color32, Hyperlink, Label, RichText};
use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction};
use notedeck::Localization;
use notedeck::{
time_format, update_imeta_blurhashes, IsFollowing, NoteCache, NoteContext, NotedeckTextStyle,
};
use notedeck::{time_format, update_imeta_blurhashes, NoteCache, NoteContext, NotedeckTextStyle};
use notedeck::{JobsCache, RenderableMedia};
use tracing::warn;
@@ -374,21 +372,6 @@ fn render_undecorated_note_contents<'a>(
ui.add_space(2.0);
let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note")));
let is_self = note.pubkey()
== note_context
.accounts
.get_selected_account()
.key
.pubkey
.bytes();
let trusted_media = is_self
|| note_context
.accounts
.get_selected_account()
.is_following(note.pubkey())
== IsFollowing::Yes;
media_action = image_carousel(
ui,
note_context.img_cache,
@@ -396,7 +379,6 @@ fn render_undecorated_note_contents<'a>(
jobs,
&supported_medias,
carousel_id,
trusted_media,
note_context.i18n,
options,
);
+1 -2
View File
@@ -33,7 +33,6 @@ pub fn image_carousel(
jobs: &mut JobsCache,
medias: &[RenderableMedia],
carousel_id: egui::Id,
trusted_media: bool,
i18n: &mut Localization,
note_options: NoteOptions,
) -> Option<MediaAction> {
@@ -68,7 +67,7 @@ pub fn image_carousel(
job_pool,
jobs,
media,
trusted_media,
note_options.contains(NoteOptions::TrustMedia),
i18n,
size,
if note_options.contains(NoteOptions::NoAnimations) {
+74 -103
View File
@@ -5,10 +5,7 @@ pub mod options;
pub mod reply_description;
use crate::{app_images, secondary_label};
use crate::{
profile::name::one_line_display_name_widget, widgets::x_button, ProfilePic, ProfilePreview,
PulseAlpha, Username,
};
use crate::{widgets::x_button, ProfilePic, ProfilePreview, PulseAlpha, Username};
pub use contents::{render_note_preview, NoteContents};
pub use context::NoteContextButton;
@@ -25,14 +22,12 @@ pub use options::NoteOptions;
pub use reply_description::reply_desc;
use egui::emath::{pos2, Vec2};
use egui::{Id, Pos2, Rect, Response, RichText, Sense};
use egui::{Id, Pos2, Rect, Response, Sense};
use enostr::{KeypairUnowned, NoteId, Pubkey};
use nostrdb::{Ndb, Note, NoteKey, ProfileRecord, Transaction};
use notedeck::{
name::get_display_name,
note::{NoteAction, NoteContext, ZapAction},
tr, AnyZapState, ContextSelection, NoteZapTarget, NoteZapTargetOwned, NotedeckTextStyle,
ZapTarget, Zaps,
tr, AnyZapState, ContextSelection, NoteZapTarget, NoteZapTargetOwned, ZapTarget, Zaps,
};
pub struct NoteView<'a, 'd> {
@@ -306,60 +301,19 @@ impl<'a, 'd> NoteView<'a, 'd> {
}
}
fn show_repost(
&mut self,
ui: &mut egui::Ui,
txn: &Transaction,
note_to_repost: Note<'_>,
) -> NoteResponse {
let profile = self
.note_context
.ndb
.get_profile_by_pubkey(txn, self.note.pubkey());
let style = NotedeckTextStyle::Small;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.add_space(2.0);
ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode));
});
ui.add_space(6.0);
let resp = ui.add(one_line_display_name_widget(
ui.visuals(),
get_display_name(profile.as_ref().ok()),
style,
));
if let Ok(rec) = &profile {
resp.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(300.0);
ui.add(ProfilePreview::new(rec, self.note_context.img_cache));
});
}
let color = ui.style().visuals.noninteractive().fg_stroke.color;
ui.add_space(4.0);
ui.label(
RichText::new(tr!(
self.note_context.i18n,
"Reposted",
"Label for reposted notes"
))
.color(color)
.text_style(style.text_style()),
);
});
NoteView::new(self.note_context, &note_to_repost, self.flags, self.jobs).show(ui)
}
pub fn show_impl(&mut self, ui: &mut egui::Ui) -> NoteResponse {
let txn = self.note.txn().expect("txn");
if let Some(note_to_repost) = get_reposted_note(self.note_context.ndb, txn, self.note) {
self.show_repost(ui, txn, note_to_repost)
} else {
self.show_standard(ui)
}
}
pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse {
if !self.flags.contains(NoteOptions::TrustMedia) {
let acc = self.note_context.accounts.get_selected_account();
if self.note.pubkey() == acc.key.pubkey.bytes()
|| matches!(
acc.is_following(self.note.pubkey()),
notedeck::IsFollowing::Yes
)
{
self.flags = self.flags.union(NoteOptions::TrustMedia);
}
}
if self.options().contains(NoteOptions::Textmode) {
NoteResponse::new(self.textmode_ui(ui))
} else if self.options().contains(NoteOptions::Framed) {
@@ -376,11 +330,11 @@ impl<'a, 'd> NoteView<'a, 'd> {
if is_narrow(ui.ctx()) {
ui.set_width(ui.available_width());
}
self.show_impl(ui)
self.show_standard(ui)
})
.inner
} else {
self.show_impl(ui)
self.show_standard(ui)
}
}
@@ -677,7 +631,7 @@ fn get_zapper<'a>(
})
}
fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> {
pub fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> {
if note.kind() != 6 {
return None;
}
@@ -789,7 +743,7 @@ fn note_hitbox_id(
fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> {
ui.ctx()
.data_mut(|d| d.get_persisted(hitbox_id))
.data_mut(|d| d.get_temp(hitbox_id))
.map(|note_size: Vec2| {
// The hitbox should extend the entire width of the
// container. The hitbox height was cached last layout.
@@ -832,33 +786,14 @@ struct Zapper<'a> {
cur_acc: KeypairUnowned<'a>,
}
#[profiling::function]
fn render_note_actionbar(
fn zap_actionbar_button(
ui: &mut egui::Ui,
zapper: Option<Zapper<'_>>,
note_id: &[u8; 32],
note_pubkey: &[u8; 32],
note_key: NoteKey,
zapper: Option<Zapper<'_>>,
i18n: &mut Localization,
) -> Option<NoteAction> {
ui.set_min_height(26.0);
ui.spacing_mut().item_spacing.x = 24.0;
let reply_resp =
reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
let quote_resp =
quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
let to_noteid = |id: &[u8; 32]| NoteId::new(*id);
if reply_resp.clicked() {
return Some(NoteAction::Reply(to_noteid(note_id)));
}
if quote_resp.clicked() {
return Some(NoteAction::Quote(to_noteid(note_id)));
}
let mut action: Option<NoteAction> = None;
let Zapper { zaps, cur_acc } = zapper?;
let zap_target = ZapTarget::Note(NoteZapTarget {
@@ -869,39 +804,75 @@ fn render_note_actionbar(
let zap_state = zaps.any_zap_state_for(cur_acc.pubkey.bytes(), zap_target);
let target = NoteZapTargetOwned {
note_id: to_noteid(note_id),
note_id: NoteId::new(*note_id),
zap_recipient: Pubkey::new(*note_pubkey),
};
if zap_state.is_err() {
return Some(NoteAction::Zap(ZapAction::ClearError(target)));
}
let zap_resp = {
cur_acc.secret_key.as_ref()?;
match zap_state {
Ok(any_zap_state) => ui.add(zap_button(i18n, any_zap_state, note_id)),
Ok(any_zap_state) => {
let zap_resp = ui.add(zap_button(i18n, any_zap_state, note_id));
if zap_resp.secondary_clicked() {
action = Some(NoteAction::Zap(ZapAction::CustomizeAmount(target.clone())));
}
if zap_resp.clicked() {
action = Some(NoteAction::Zap(ZapAction::Send(ZapTargetAmount {
target,
specified_msats: None,
})));
}
zap_resp
}
Err(err) => {
let (rect, _) = ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click());
ui.add(x_button(rect)).on_hover_text(err.to_string())
let x_button = ui.add(x_button(rect)).on_hover_text(err.to_string());
if x_button.clicked() {
action = Some(NoteAction::Zap(ZapAction::ClearError(target.clone())));
}
x_button
}
}
.on_hover_cursor(egui::CursorIcon::PointingHand);
if zap_resp.secondary_clicked() {
return Some(NoteAction::Zap(ZapAction::CustomizeAmount(target)));
action
}
#[profiling::function]
fn render_note_actionbar(
ui: &mut egui::Ui,
zapper: Option<Zapper<'_>>,
note_id: &[u8; 32],
note_pubkey: &[u8; 32],
note_key: NoteKey,
i18n: &mut Localization,
) -> Option<NoteAction> {
let mut action = None;
ui.set_min_height(26.0);
ui.spacing_mut().item_spacing.x = 24.0;
let reply_resp =
reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
let quote_resp =
quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
if reply_resp.clicked() {
action = Some(NoteAction::Reply(NoteId::new(*note_id)));
}
if !zap_resp.clicked() {
return None;
if quote_resp.clicked() {
action = Some(NoteAction::Quote(NoteId::new(*note_id)));
}
Some(NoteAction::Zap(ZapAction::Send(ZapTargetAmount {
target,
specified_msats: None,
})))
action = zap_actionbar_button(ui, note_id, note_pubkey, zapper, i18n).or(action);
action
}
#[profiling::function]
+7 -1
View File
@@ -39,8 +39,14 @@ bitflags! {
/// no animation override (accessibility)
const NoAnimations = 1 << 17;
/// Styled for a notification preview
/// The note should be displayed as a preview of the underlying note of a composite unit
const NotificationPreview = 1 << 18;
/// The note is a notification
const Notification = 1 << 19;
/// There is enough trust to show media in this note
const TrustMedia = 1 << 20;
}
}