114 Commits

Author SHA1 Message Date
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
tyiu b84ad4f1cd Import translations
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-08-28 09:08:37 -04:00
tyiu 736ce50f64 Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-08-28 09:08:13 -04:00
William Casarin e9ca793509 macos: fix build script
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-28 07:11:04 +08:00
William Casarin ea65af8d5b v0.7.1
William Casarin (2):
      fix android-activity crash on unhandled app cmds

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-28 06:54:44 +08:00
William Casarin 11aa2142cf fix android-activity crash on unhandled app cmds
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-26 17:46:13 -07:00
Fernando López Guevara 6ee2b28e70 media: handle upload on android 2025-08-26 17:10:38 -07:00
William Casarin 31ee64827a v0.7.0
First official Android Release!

- Keyboard visibiliy
- core lightning node ui (experimental)
- Onboarding follow packs
- Reaction notifications!
- Japanese translations

Terry Yiu (3):
      Remove unused strings from translation files
      Import translations
      Add Japanese and Portuguese (Portugal) languages

William Casarin (38):
      battery: disable render every 100ms
      dave: switch to logical time
      force oled with --mobile flag
      gif: disable continuous gif rendering
      ui: add AnimationMode to control GIF rendering behavior
      debug: add repaint causes debug tool
      Merge thread scroll fix by kernel
      chrome: add virtual keyboard ui
      android: fix dark/light mode and folding screen crash
      notedeck app: add clndash
      clndash: initial peer channel listing
      default logs
      clndash: channels ui
      clndash: summary cards
      clndash: include listpeerchannel errors
      clndash: invoice loading
      clndash: zap rendering
      clndash: fix invoice order, return more stuff
      clndash: reorganize
      clndash: configurable host
      clndash: add readme
      clndash: readme
      clndash: tweak readme
      clndash: tweak links in readme
      clndash: specify you need --clndash
      clndash: dont forget CLNDASH_ID
      remove hjkl bindings
      Merge Japanese and Portuguese translations from Terry
      clippy: fix lint errors
      Implement soft keyboard visibility on Android
      chrome: greatly improve soft-keyboard visibility & layout handling
      args: parse hashtag columns from cli
      debug: fix memory debug builds
      Merge remote-tracking branch 'github/pr/1087' into notifications
      Merge remote-tracking branch 'github/pr/1081' into notifications  especially if it merges an updated upstream into a topic branch.
      tweak follow pack design
      chrome: remove dev log
      v0.7.0

kernelkind (53):
      TMP: use new egui-nav to fix scroll offset issues
      add `scroll_offset` to `NoteAction::Note`
      add `ThreadNote::set_scroll_offset`
      set scroll offset when routing to thread
      appease clippy
      make search icon more customizable
      make compose button animate horiz rather than vert
      add toolbar icons to `notedeck_ui`
      add select_by_route
      add toolbar related logic
      add toolbar defaults
      copy toolbar rendering to `notedeck_ui`
      use toolbar in columns rather than chrome
      clippy: allow collapsible match
      add flags to `ScaledTexture`
      extract a pub `render_media` from image_carousel
      add impl for `ScaledTextureFlags::RESPECT_MAX_DIMS`
      add nip51 set caching structs
      nip 51 set widget
      add onboarding 'manager'
      TMP: temporary author for trusted pks list
      add onboarding view
      add onboarding related state to app
      integrate onboarding
      fix contact list bug
      use the onboarding follow pack curator pubkey
      make `TimelineCache::notes` private
      remove commented out code...
      move `HybridSet` to own file
      ui: add like icon
      appease clippy
      add muted helper
      unknownids: use pk bytes
      replace `HybridSet` with `NoteUnits`
      add reactions kind to notifications filter
      add `TimelineUnits`
      note: account for mutes in the notifications dot
      make since optimize accept Option<&NoteRef> instead of notes
      prop `UnknownIds` for initial timeline
      ui: add rendering for `NoteUnit`s
      upgrade `TimelineOpenResult` to hold new pubkeys too
      use `TimelineUnits` instead of `Vec<NoteRef>`
      ui: remove unnecessary reverse
      introduce failing test for reaction duplication bug
      fix duplicate ReactionUnit for multiple kth indices
      fix reaction target bug
      ui: reactions closer approximation of iOS design
      expose indexmap to notedeck
      use indexmap
      add Nip51SetCache helper methods
      add virtual list to `Onboarding`
      prop `Onboarding` as mut
      render follow pack by index from virtual list

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-26 15:05:12 -07:00
William Casarin f243adc855 chrome: remove dev log
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-26 14:40:14 -07:00
William Casarin 5224a5d8ae tweak follow pack design
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-26 10:26:14 -07:00
William Casarin 2c96dd99a8 Merge remote-tracking branch 'github/pr/1081' into notifications
especially if it merges an updated upstream into a topic branch.
2025-08-26 10:01:09 -07:00
William Casarin e7843bad2f Merge remote-tracking branch 'github/pr/1087' into notifications 2025-08-26 09:58:50 -07:00
William Casarin c2f012ff75 debug: fix memory debug builds
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-26 09:53:37 -07:00
William Casarin 76fd7a9753 args: parse hashtag columns from cli
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-26 09:53:37 -07:00
kernelkind 8b5464641d render follow pack by index from virtual list
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 21:16:05 -04:00
kernelkind c06d18f76b prop Onboarding as mut
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 21:14:52 -04:00
kernelkind 84e60e0642 add virtual list to Onboarding
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 21:14:02 -04:00
kernelkind 23f35c60bb add Nip51SetCache helper methods
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 21:13:17 -04:00
kernelkind 30c2ebdcc2 use indexmap
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 21:12:41 -04:00
kernelkind 1658600604 expose indexmap to notedeck
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 21:11:28 -04:00
kernelkind 529377a706 ui: reactions closer approximation of iOS design
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 20:15:40 -04:00
kernelkind 30af03cfcc fix reaction target bug
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 20:02:34 -04:00
kernelkind bb878d3772 fix duplicate ReactionUnit for multiple kth indices
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 20:02:34 -04:00
kernelkind 5c9eb492b6 introduce failing test for reaction duplication bug
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 20:02:34 -04:00
kernelkind 0b584a773f ui: remove unnecessary reverse
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:57:00 -04:00
kernelkind 78504a6673 use TimelineUnits instead of Vec<NoteRef>
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:56:56 -04:00
kernelkind ae204cbd5c upgrade TimelineOpenResult to hold new pubkeys too
for handling unknown profiles

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:56:53 -04:00
kernelkind 7d4e9799e5 ui: add rendering for NoteUnits
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:56:48 -04:00
kernelkind 55d7cd3379 prop UnknownIds for initial timeline
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:32:10 -04:00
kernelkind 697040d862 make since optimize accept Option<&NoteRef> instead of notes
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:32:07 -04:00
kernelkind 49866418a6 note: account for mutes in the notifications dot
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:32:03 -04:00
kernelkind 9b784dfdf7 add TimelineUnits
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:31:59 -04:00
kernelkind c1d6c0f535 add reactions kind to notifications filter
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:31:54 -04:00
kernelkind 1a93663b1a replace HybridSet with NoteUnits
This will unify the collections that hold the notes to timelines
and threads and allow the notifications timeline to have grouped
notifications, among other things

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:31:50 -04:00
kernelkind 4992e25b3a unknownids: use pk bytes
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:31:47 -04:00
kernelkind 7b1ace328f add muted helper
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:31:43 -04:00
kernelkind 2973a0c6c5 appease clippy
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:31:42 -04:00
kernelkind 4f63629715 ui: add like icon
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:31:37 -04:00
kernelkind 686dea9831 move HybridSet to own file
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:31:34 -04:00
kernelkind 01171ff9d7 remove commented out code...
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:31:27 -04:00
kernelkind b421e7e45f make TimelineCache::notes private
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-25 10:31:20 -04:00
William Casarin ccc188c0ae chrome: greatly improve soft-keyboard visibility & layout handling
CI / Rustfmt + Clippy (push) Has been cancelled
CI / Check (android) (push) Has been cancelled
CI / Test (Linux) (push) Has been cancelled
CI / Test (macOS) (push) Has been cancelled
CI / Test (Windows) (push) Has been cancelled
CI / rpm/deb (aarch64) (push) Has been cancelled
CI / rpm/deb (x86_64) (push) Has been cancelled
CI / macOS dmg (aarch64) (push) Has been cancelled
CI / macOS dmg (x86_64) (push) Has been cancelled
CI / Windows Installer (aarch64) (push) Has been cancelled
CI / Windows Installer (x86_64) (push) Has been cancelled
CI / Upload Artifacts to Server (push) Has been cancelled
This reworks how we detect and respond to the on-screen keyboard so inputs
don’t get buried and the UI doesn’t “jump”.

- Add SoftKeyboardAnim + AnimState FSM for smooth IME open/close animation
- Centralize logic in keyboard_visibility() with clear edge states
- Animate keyboard height via animate_value_with_time instead of layer
  transforms
- Add ChromeOptions::KeyboardVisibility flag when focused input would be
  occluded
- Add SidebarOptions::Compact to collapse sidebar while typing
- Hide mobile toolbar when keyboard is open (columns app)
- Use .stick_to_bottom(true) in reply + profile editors; remove old spacer hack
- Virtual keyboard toggle moved to F1 in Debug builds
- Introduce SoftKeyboardContext::platform(ctx) helper
- Cleanup dead/commented code and wire up soft_kb_anim_state in Chrome

Result: inputs stay visible, open/close is smooth, and UI adjusts gracefully
when typing.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-20 15:28:28 -07:00
kernelkind 86641c6121 use the onboarding follow pack curator pubkey
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-19 15:05:28 -04:00
William Casarin 77ac91e810 Implement soft keyboard visibility on Android
- Added `SoftKeyboardContext` enum and support for calculating keyboard
  insets from both virtual and platform sources

- Updated `AppContext` to provide `soft_keyboard_rect` for determining
  visible keyboard area

- Adjusted UI rendering to shift content when input boxes intersect with
  the soft keyboard, preventing overlap

- Modified `MainActivity` and Android manifest to use
  `windowSoftInputMode="adjustResize"` and updated window inset handling

- Introduced helper functions (`include_input`, `input_rect`,
  `clear_input_rect`) in `notedeck_ui` for tracking focused input boxes

- Fixed Android JNI keyboard height reporting to clamp negative values

Together, these changes allow the app to correctly detect and respond
to soft keyboard visibility on Android, ensuring input fields remain
accessible when typing.

Fixes: https://github.com/damus-io/notedeck/issues/946
Fixes: https://github.com/damus-io/notedeck/issues/1043
2025-08-19 11:29:45 -07:00
William Casarin 3aa4d00053 clippy: fix lint errors
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-19 09:45:36 -07:00
kernelkind 9ef72ec7de fix contact list bug
not a great solution but we're going to get a new sub manager
soon so it'll probably get replaced anyway

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:07:24 -04:00
kernelkind 1566cd5cf4 integrate onboarding
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:07:21 -04:00
kernelkind bdcd31cda0 add onboarding related state to app
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:07:19 -04:00
kernelkind a782d01ec2 add onboarding view
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:07:16 -04:00
kernelkind 8d4c0cfdbe TMP: temporary author for trusted pks list
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:07:14 -04:00
kernelkind f8f720c193 add onboarding 'manager'
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:07:09 -04:00
kernelkind 2a439b1f30 nip 51 set widget
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:07:04 -04:00
kernelkind 8399c951fa add nip51 set caching structs
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:07:00 -04:00
kernelkind ac1bbeac1b add impl for ScaledTextureFlags::RESPECT_MAX_DIMS
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:06:57 -04:00
kernelkind dc91b6ffae extract a pub render_media from image_carousel
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:06:55 -04:00
kernelkind 28bd13d110 add flags to ScaledTexture
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:06:48 -04:00
kernelkind 0b12b08c59 clippy: allow collapsible match
clippy being annoying

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-17 15:06:42 -04:00
William Casarin c79d5f1b9e Merge Japanese and Portuguese translations from Terry
CI / Rustfmt + Clippy (push) Has been cancelled
CI / Check (android) (push) Has been cancelled
CI / Test (Linux) (push) Has been cancelled
CI / Test (macOS) (push) Has been cancelled
CI / Test (Windows) (push) Has been cancelled
CI / rpm/deb (aarch64) (push) Has been cancelled
CI / rpm/deb (x86_64) (push) Has been cancelled
CI / macOS dmg (aarch64) (push) Has been cancelled
CI / macOS dmg (x86_64) (push) Has been cancelled
CI / Windows Installer (aarch64) (push) Has been cancelled
CI / Windows Installer (x86_64) (push) Has been cancelled
CI / Upload Artifacts to Server (push) Has been cancelled
Terry Yiu (3):
      Remove unused strings from translation files
      Import translations
      Add Japanese and Portuguese (Portugal) languages
2025-08-16 12:33:21 -07:00
William Casarin 507cf113a3 remove hjkl bindings
these interfere with input

we'll need to come back to this

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-15 11:55:29 -07:00
kernelkind b750c0a927 use toolbar in columns rather than chrome
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-13 19:20:38 -04:00
kernelkind 49ef85aef6 copy toolbar rendering to notedeck_ui
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-13 19:18:28 -04:00
kernelkind 29f59459d2 add toolbar defaults
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-13 19:18:28 -04:00
kernelkind cd0bd53b3d add toolbar related logic
copied from chrome

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-13 19:18:28 -04:00
kernelkind 5c0546deab add select_by_route
selects the column containing the desired route. Add it if it
doesn't exist and it's easy to do

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-13 19:18:28 -04:00
kernelkind 1469f9a074 add toolbar icons to notedeck_ui
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-13 19:07:42 -04:00
kernelkind 3d8018bb9a make compose button animate horiz rather than vert
it animating over the toolbar made the bar dissapear for
some reason

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-13 19:06:18 -04:00
kernelkind 361d0e3708 make search icon more customizable
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-08-13 19:04:16 -04:00
William Casarin c5df47dc73 clndash: dont forget CLNDASH_ID
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 12:37:28 -07:00
William Casarin ea85799007 clndash: specify you need --clndash
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 12:17:13 -07:00
William Casarin 9ba071c5ed clndash: tweak links in readme
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 12:15:47 -07:00
William Casarin 81393f8468 clndash: tweak readme
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 12:14:24 -07:00
William Casarin 87d9308435 clndash: readme
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 12:13:24 -07:00
William Casarin 1f8fd395ed clndash: add readme
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 12:09:13 -07:00
William Casarin 2f3a3de7cc clndash: configurable host
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 12:03:14 -07:00
William Casarin 35e9354217 clndash: reorganize
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 10:36:44 -07:00
William Casarin 08a97c946d clndash: fix invoice order, return more stuff
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-10 21:33:11 -07:00
William Casarin 2fde5addeb clndash: zap rendering
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-10 17:46:09 -07:00
William Casarin f77e7898b6 clndash: invoice loading
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-10 16:28:21 -07:00
107 changed files with 6471 additions and 2037 deletions
Generated
+46 -30
View File
@@ -105,7 +105,8 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android-activity"
version = "0.6.0"
source = "git+https://github.com/damus-io/android-activity?rev=a8948332c7c551303d32eb26a59d0abd676e47a5#a8948332c7c551303d32eb26a59d0abd676e47a5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046"
dependencies = [
"android-properties",
"bitflags 2.9.1",
@@ -125,7 +126,7 @@ dependencies = [
[[package]]
name = "android-activity"
version = "0.6.0"
source = "git+https://github.com/damus-io/android-activity?rev=c3c0decc83c4d6c94d2c448391fc8dd51b13f3d9#c3c0decc83c4d6c94d2c448391fc8dd51b13f3d9"
source = "git+https://github.com/damus-io/android-activity?rev=4ee16f1585e4a75031dc10785163d4b920f95805#4ee16f1585e4a75031dc10785163d4b920f95805"
dependencies = [
"android-properties",
"bitflags 2.9.1",
@@ -192,7 +193,7 @@ dependencies = [
"objc2-foundation 0.3.1",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
"x11rb",
]
@@ -1402,7 +1403,7 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.1"
source = "git+https://github.com/damus-io/winit?rev=eaff639ab0a14fccf595241f687be883154b267c#eaff639ab0a14fccf595241f687be883154b267c"
source = "git+https://github.com/damus-io/winit?rev=a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d#a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d"
[[package]]
name = "dpi"
@@ -1419,17 +1420,17 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecolor"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
dependencies = [
"bytemuck",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd)",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72)",
"serde",
]
[[package]]
name = "eframe"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
dependencies = [
"ahash",
"bytemuck",
@@ -1465,13 +1466,13 @@ dependencies = [
[[package]]
name = "egui"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
dependencies = [
"accesskit",
"ahash",
"backtrace",
"bitflags 2.9.1",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd)",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72)",
"epaint",
"log",
"nohash-hasher",
@@ -1483,7 +1484,7 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
dependencies = [
"ahash",
"bytemuck",
@@ -1502,7 +1503,7 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
dependencies = [
"ahash",
"arboard",
@@ -1520,7 +1521,7 @@ dependencies = [
[[package]]
name = "egui_extras"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
dependencies = [
"ahash",
"egui",
@@ -1537,7 +1538,7 @@ dependencies = [
[[package]]
name = "egui_glow"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
dependencies = [
"ahash",
"bytemuck",
@@ -1554,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",
@@ -1616,7 +1617,7 @@ checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b"
[[package]]
name = "emath"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
dependencies = [
"bytemuck",
"serde",
@@ -1714,13 +1715,13 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
dependencies = [
"ab_glyph",
"ahash",
"bytemuck",
"ecolor",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd)",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72)",
"epaint_default_fonts",
"log",
"nohash-hasher",
@@ -1732,7 +1733,7 @@ dependencies = [
[[package]]
name = "epaint_default_fonts"
version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
source = "git+https://github.com/damus-io/egui?rev=c9073832236dadec21f263ef1f1ffa4d7a159f72#c9073832236dadec21f263ef1f1ffa4d7a159f72"
[[package]]
name = "equator"
@@ -2370,6 +2371,9 @@ name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
dependencies = [
"serde",
]
[[package]]
name = "hex-conservative"
@@ -3035,6 +3039,7 @@ dependencies = [
"bech32",
"bitcoin",
"lightning-types",
"serde",
]
[[package]]
@@ -3072,9 +3077,9 @@ checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
[[package]]
name = "lnsocket"
version = "0.4.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a373bcde8b65d6db11a0cd0f70dd4a24af854dd7a112b0a51258593c65f48ff"
checksum = "724c7fba2188a49ab31316e52dd410d4d3168b8e6482aa2ac3889dd840d28712"
dependencies = [
"bitcoin",
"hashbrown 0.13.2",
@@ -3500,14 +3505,16 @@ dependencies = [
[[package]]
name = "notedeck"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"android-activity 0.6.0 (git+https://github.com/damus-io/android-activity?rev=4ee16f1585e4a75031dc10785163d4b920f95805)",
"base32",
"bech32",
"bincode",
"bitflags 2.9.1",
"blurhash",
"chrono",
"crossbeam-channel",
"dirs",
"eframe",
"egui",
@@ -3521,10 +3528,12 @@ dependencies = [
"hashbrown 0.15.4",
"hex",
"image",
"indexmap 2.9.0",
"jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
"lightning-invoice",
"md5",
"mime_guess",
"ndk-context",
"nostr 0.37.0",
"nostrdb",
"nwc",
@@ -3552,7 +3561,7 @@ dependencies = [
[[package]]
name = "notedeck_chrome"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"bitflags 2.9.1",
"eframe",
@@ -3584,12 +3593,17 @@ dependencies = [
[[package]]
name = "notedeck_clndash"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"eframe",
"egui",
"egui_extras",
"hex",
"lightning-invoice",
"lnsocket",
"nostrdb",
"notedeck",
"notedeck_ui",
"serde",
"serde_json",
"tokio",
@@ -3598,7 +3612,7 @@ dependencies = [
[[package]]
name = "notedeck_columns"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"base64 0.22.1",
"bech32",
@@ -3618,6 +3632,8 @@ dependencies = [
"human_format",
"image",
"indexmap 2.9.0",
"jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
"ndk-context",
"nostrdb",
"notedeck",
"notedeck_ui",
@@ -3652,7 +3668,7 @@ dependencies = [
[[package]]
name = "notedeck_dave"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"async-openai",
"bytemuck",
@@ -3677,7 +3693,7 @@ dependencies = [
[[package]]
name = "notedeck_notebook"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"egui",
"jsoncanvas",
@@ -3686,7 +3702,7 @@ dependencies = [
[[package]]
name = "notedeck_ui"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"bitflags 2.9.1",
"eframe",
@@ -7436,10 +7452,10 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winit"
version = "0.30.8"
source = "git+https://github.com/damus-io/winit?rev=eaff639ab0a14fccf595241f687be883154b267c#eaff639ab0a14fccf595241f687be883154b267c"
source = "git+https://github.com/damus-io/winit?rev=a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d#a07ea4c4d76cdc5306da8e8aabb05cab1a7d8e4d"
dependencies = [
"ahash",
"android-activity 0.6.0 (git+https://github.com/damus-io/android-activity?rev=c3c0decc83c4d6c94d2c448391fc8dd51b13f3d9)",
"android-activity 0.6.0 (git+https://github.com/damus-io/android-activity?rev=4ee16f1585e4a75031dc10785163d4b920f95805)",
"atomic-waker",
"bitflags 2.9.1",
"block2 0.5.1",
@@ -7491,7 +7507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4409c10174df8779dc29a4788cac85ed84024ccbc1743b776b21a520ee1aaf4"
dependencies = [
"ahash",
"android-activity 0.6.0 (git+https://github.com/damus-io/android-activity?rev=a8948332c7c551303d32eb26a59d0abd676e47a5)",
"android-activity 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)",
"atomic-waker",
"bitflags 2.9.1",
"block2 0.5.1",
+13 -13
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
package.version = "0.6.0"
package.version = "0.7.1"
members = [
"crates/notedeck",
"crates/notedeck_chrome",
@@ -27,7 +27,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" }
@@ -37,7 +37,7 @@ ewebsock = { version = "0.2.0", features = ["tls"] }
fluent = "0.17.0"
fluent-resmgr = "0.0.8"
fluent-langneg = "0.13"
hex = "0.4.3"
hex = { version = "0.4.3", features = ["serde"] }
image = { version = "0.25", features = ["jpeg", "png", "webp"] }
indexmap = "2.6.0"
log = "0.4.17"
@@ -81,14 +81,14 @@ mime_guess = "2.0.5"
pretty_assertions = "1.4.1"
jni = "0.21.1"
profiling = "1.0"
lightning-invoice = "0.33.1"
lightning-invoice = { version = "0.33.1", features = ["serde"] }
secp256k1 = "0.30.0"
hashbrown = "0.15.2"
openai-api-rs = "6.0.3"
re_memory = "0.23.4"
oot_bitset = "0.1.1"
blurhash = "0.2.3"
android-activity = { git = "https://github.com/damus-io/android-activity", rev = "4ee16f1585e4a75031dc10785163d4b920f95805", features = [ "game-activity" ] }
[profile.small]
inherits = 'release'
@@ -106,15 +106,15 @@ strip = true # Strip symbols from binary*
#egui_extras = { path = "/home/jb55/dev/github/emilk/egui/crates/egui_extras" }
#epaint = { path = "/home/jb55/dev/github/emilk/egui/crates/epaint" }
egui = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
eframe = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
egui-winit = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
egui-wgpu = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
egui_extras = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
epaint = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
egui = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
eframe = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
egui-winit = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
egui-wgpu = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
egui_extras = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
epaint = { git = "https://github.com/damus-io/egui", rev = "c9073832236dadec21f263ef1f1ffa4d7a159f72" }
puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
puffin_egui = { git = "https://github.com/jb55/puffin", package = "puffin_egui", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
#winit = { git = "https://github.com/damus-io/winit", rev = "14d61a74bee0c9863abe7ef28efae2c4d8bd3743" }
#winit = { git = "https://github.com/damus-io/winit", rev = "701a43d3c6479b0a3869acd2cebbfd410d399a59" }
#winit = { path = "/home/jb55/dev/github/rust-windowing/winit" }
android-activity = { git = "https://github.com/damus-io/android-activity", rev = "a8948332c7c551303d32eb26a59d0abd676e47a5" }
#android-activity = { git = "https://github.com/damus-io/android-activity", rev = "f56c974aa5182d5fbd361879f5899eb8f11a37ec" }
#android-activity = { path = "/home/jb55/dev/github/rust-mobile/android-activity/android-activity" }
+1 -1
View File
@@ -27,4 +27,4 @@ push-android-config:
android: jni
cd $(ANDROID_DIR) && ./gradlew installDebug
adb shell am start -n com.damus.notedeck/.MainActivity
adb logcat -v color -s RustStdoutStderr -s threaded_app | tee logcat.txt
adb logcat -v color -s GameActivity -s RustStdoutStderr -s threaded_app | tee logcat.txt
+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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+4
View File
@@ -239,6 +239,8 @@ Notifications_ef56 = Benachrichtigungen
now_2181 = Gerade eben
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = An
# Column title for finding users to follow
Onboarding_4a25 = Neue Leute finden
# Button label to open email client
Open_Email_25e9 = E-Mail öffnen
# Instruction to open email client
@@ -315,6 +317,8 @@ Searching_for___query_5d18 = Suche nach '{ $query }'
See_notes_from_your_contacts_ac16 = Notizen von deinen Kontakten ansehen
# Description for universe column
See_the_whole_nostr_universe_7694 = Sieh dir das ganze Nostr-Universum an
# Button to select all profiles in follow pack
Select_All_a319 = Alle auswählen
# Button label to send a zap
Send_1ea4 = Senden
# Column title for app settings
+68
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?
@@ -352,6 +376,9 @@ now_2181 = now
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = On
# Column title for finding users to follow
Onboarding_4a25 = Onboarding
# Button label to open email client
Open_Email_25e9 = Open Email
@@ -382,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
@@ -460,12 +490,18 @@ Search_notes_42a6 = Search notes...
# Search in progress message
Searching_for___query_5d18 = Searching for '{$query}'
# label describing secret key
SECRET_ACCOUNT_LOGIN_KEY_8440 = SECRET ACCOUNT LOGIN KEY
# Description for Home column
See_notes_from_your_contacts_ac16 = See notes from your contacts
# Description for universe column
See_the_whole_nostr_universe_7694 = See the whole nostr universe
# Button to select all profiles in follow pack
Select_All_a319 = Select All
# Button label to send a zap
Send_1ea4 = Send
@@ -603,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
}
+68
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?{"]"}
@@ -352,6 +376,9 @@ now_2181 = {"["}ñów{"]"}
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = {"["}Óñ{"]"}
# Column title for finding users to follow
Onboarding_4a25 = {"["}Óñbóàrdíñg{"]"}
# Button label to open email client
Open_Email_25e9 = {"["}Ópéñ Émàíl{"]"}
@@ -382,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é{"]"}
@@ -460,12 +490,18 @@ Search_notes_42a6 = {"["}Séàrçh ñótés...{"]"}
# Search in progress message
Searching_for___query_5d18 = {"["}Séàrçhíñg fór '{$query}'{"]"}
# label describing secret key
SECRET_ACCOUNT_LOGIN_KEY_8440 = {"["}SÉÇRÉT ÀÇÇÓÚÑT LÓGÍÑ KÉY{"]"}
# Description for Home column
See_notes_from_your_contacts_ac16 = {"["}Séé ñótés fróm yóúr çóñtàçts{"]"}
# Description for universe column
See_the_whole_nostr_universe_7694 = {"["}Séé thé whólé ñóstr úñívérsé{"]"}
# Button to select all profiles in follow pack
Select_All_a319 = {"["}Séléçt Àll{"]"}
# Button label to send a zap
Send_1ea4 = {"["}Séñd{"]"}
@@ -603,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é{"]"}
}
+4
View File
@@ -237,6 +237,8 @@ Notifications_ef56 = Notifications
now_2181 = maintenant
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = Activé
# Column title for finding users to follow
Onboarding_4a25 = Utilisateurs recommandés
# Button label to open email client
Open_Email_25e9 = Ouvrir Email
# Instruction to open email client
@@ -313,6 +315,8 @@ Searching_for___query_5d18 = Recherche par '{ $query }'
See_notes_from_your_contacts_ac16 = Afficher les notes de vos contacts
# Description for universe column
See_the_whole_nostr_universe_7694 = Voir l'ensemble de l'univers nostr
# Button to select all profiles in follow pack
Select_All_a319 = Tout sélectionner
# Button label to send a zap
Send_1ea4 = Envoyer
# Column title for app settings
+4
View File
@@ -237,6 +237,8 @@ Notifications_ef56 = Notificações
now_2181 = Agora
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = Ligar
# Column title for finding users to follow
Onboarding_4a25 = Interação
# Button label to open email client
Open_Email_25e9 = Abrir E-mail
# Instruction to open email client
@@ -313,6 +315,8 @@ Searching_for___query_5d18 = Pesquisando por '{ $query }'
See_notes_from_your_contacts_ac16 = Veja notas dos seus contatos
# Description for universe column
See_the_whole_nostr_universe_7694 = Veja todo o universo Nostr
# Button to select all profiles in follow pack
Select_All_a319 = Selecionar todos
# Button label to send a zap
Send_1ea4 = Enviar
# Column title for app settings
+4
View File
@@ -237,6 +237,8 @@ Notifications_ef56 = Notificações
now_2181 = agora
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = Ativado
# Column title for finding users to follow
Onboarding_4a25 = Introdução
# Button label to open email client
Open_Email_25e9 = Abrir e-mail
# Instruction to open email client
@@ -313,6 +315,8 @@ Searching_for___query_5d18 = Procurando por '{ $query }'
See_notes_from_your_contacts_ac16 = Ver notas dos meus contactos
# Description for universe column
See_the_whole_nostr_universe_7694 = Ver notas de todo o universo nostr
# Button to select all profiles in follow pack
Select_All_a319 = Selecionar todos
# Button label to send a zap
Send_1ea4 = Enviar
# Column title for app settings
+15 -11
View File
@@ -46,7 +46,7 @@ Algorithmic_feeds_to_aid_in_note_discovery_d344 = ฟีดแบบอัลก
# Label for zap amount input field
Amount_70f0 = จำนวน
# Label for appearance settings section
Appearance_4c7f = รูปลักษณ
Appearance_4c7f = ลักษณ
# Button to send message to Dave AI assistant
Ask_b7f4 = ถาม
# Placeholder text for Dave AI input field
@@ -90,11 +90,11 @@ Copy_a688 = คัดลอก
# Button to copy media link to clipboard
Copy_Link_dc7c = คัดลอกลิงก์
# Copy the unique note identifier to clipboard
Copy_Note_ID_6b45 = คัดลอก ID โน้ต
Copy_Note_ID_6b45 = คัดลอก โน้ต ID
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = คัดลอก JSON ของโน้ต
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = คัดลอก Pubkey
Copy_Pubkey_9cc4 = คัดลอก npub
# Copy the text content of the note to clipboard
Copy_Text_f81c = คัดลอกข้อความ
# Relative time in days
@@ -164,7 +164,7 @@ Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__ns
# Label for find user button
Find_User_bd12 = ค้นหาผู้ใช้
# Label for font size, Appearance settings section
Font_size_dd73 = Font size:
Font_size_dd73 = ขนาดตัวอักษร:
# Title for hashtags column
Hashtags_f8e0 = แฮชแท็ก
# Title for Home column
@@ -238,7 +238,9 @@ Notifications_ef56 = การแจ้งเตือน
# Relative time for very recent events (less than 3 seconds)
now_2181 = เมื่อสักครู่
# Setting to turn on sorting replies so that the newest are shown first
On_f412 = On
On_f412 = เปิด
# Column title for finding users to follow
Onboarding_4a25 = เริ่มใช้
# Button label to open email client
Open_Email_25e9 = เปิดอีเมล
# Instruction to open email client
@@ -254,7 +256,7 @@ Please_create_a_name_for_the_deck_and_select_an_icon_0add = กรุณาต
# Error message for missing deck icon
Please_select_an_icon_655b = กรุณาเลือกไอคอน
# Button label to post a note
Post_now_8a49 = โพสต์เลย
Post_now_8a49 = โพสต์
# Instruction for copying logs
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = กดปุ่มด้านล่างเพื่อคัดลอกข้อมูลบันทึกล่าสุด ไปยังคลิปบอร์ด จากนั้นนำไปวางในอีเมลของคุณ
# Profile picture URL field label
@@ -292,7 +294,7 @@ Repost_this_note_8e56 = รีโพสต์โน้ตนี้
# Label for reposted notes
Reposted_61c8 = รีโพสต์แล้ว
# Label for reset note body font size, Appearance settings section
Reset_4e60 = Reset
Reset_4e60 = รีเซ็ต
# Label for reset zoom level, Appearance settings section
Reset_62d4 = รีเซ็ต
# Heading for support section
@@ -315,6 +317,8 @@ Searching_for___query_5d18 = กำลังค้นหา '{ $query }'
See_notes_from_your_contacts_ac16 = ดูโน้ตจากผู้ติดต่อของคุณ
# Description for universe column
See_the_whole_nostr_universe_7694 = ท่องจักรวาล Nostr ทั้งหมด
# Button to select all profiles in follow pack
Select_All_a319 = เลือกทั้งหมด
# Button label to send a zap
Send_1ea4 = ส่ง
# Column title for app settings
@@ -328,7 +332,7 @@ Someone_else_s_Notes_7e5f = โน้ตของผู้อื่น
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = การแจ้งเตือนของผู้อื่น
# Label for Sort replies newest first, others settings section
Sort_replies_newest_first_b6c3 = Sort replies newest first:
Sort_replies_newest_first_b6c3 = เรียงการตอบกลับจากใหม่ที่สุดไปเก่าที่สุด
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = ดึงโน้ตล่าสุดของผู้ใช้แต่ละคนในรายชื่อผู้ติดต่อ
# Description for hashtags column
@@ -354,7 +358,7 @@ Subscribe_to_someone_else_s_notes_d1e9 = ติดตามโน้ตของ
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = ติดตามโน้ตของผู้อื่น
# Support email address
Support_email_44d9 = Support email:
Support_email_44d9 = อีเมลฝ่ายสนับสนุน:
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = เปลี่ยนเป็นโหมดมืด
# Hover text for light mode toggle button
@@ -376,7 +380,7 @@ Universe_ffaa = จักรวาล
# Checkbox label for using wallet only for current account
Use_this_wallet_for_the_current_account_only_61dc = ใช้วอลเล็ตนี้สำหรับบัญชีปัจจุบันเท่านั้น
# Username and domain identification message
username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" ที่ "{ $domain }" จะถูกใช้สำหรับการระบุตัวตน
username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" @ "{ $domain }" จะถูกใช้สำหรับการระบุตัวตน
# Profile username field label
Username_daa7 = ชื่อผู้ใช้
# Label for view folder button, Storage settings section
@@ -388,7 +392,7 @@ We_recommend_short_names_083e = เราแนะนำให้ใช้ชื
# Profile website field label
Website_7980 = เว็บไซต์
# Placeholder for note input field
Write_a_banger_note_here_bad2 = เขียนโน้ตปังๆ ที่นี่...
Write_a_banger_note_here_bad2 = เขียนโน้ตที่นี่...
# Placeholder text for key input field
Your_key_here_81bd = ใส่คีย์ของคุณที่นี่...
# Title for your notes column
+1 -1
View File
@@ -68,7 +68,7 @@ impl From<RelayEvent<'_>> for OwnedRelayEvent {
}
#[derive(PartialEq, Eq, Hash, Clone)]
pub struct RelaySub {
pub struct _RelaySub {
pub(crate) subid: String,
pub(crate) filter: String,
}
+4
View File
@@ -50,6 +50,8 @@ md5 = { workspace = true }
bitflags = { workspace = true }
regex = "1"
chrono = { workspace = true }
indexmap = {workspace = true}
crossbeam-channel = "0.5"
[dev-dependencies]
tempfile = { workspace = true }
@@ -57,6 +59,8 @@ tokio = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
android-activity = { workspace = true }
ndk-context = "0.1"
[features]
puffin = ["puffin_egui", "dep:puffin"]
+5
View File
@@ -267,6 +267,11 @@ impl Accounts {
Box::new(move |note: &Note, thread: &[u8; 32]| muted.is_muted(note, thread))
}
pub fn mute(&self) -> Box<Arc<crate::Muted>> {
let account_data = self.get_selected_account_data();
Box::new(Arc::clone(&account_data.muted.muted))
}
pub fn send_initial_filters(&mut self, pool: &mut RelayPool, relay_url: &str) {
let data = &self.get_selected_account().data;
// send the active account's relay list subscription
+1 -1
View File
@@ -42,7 +42,7 @@ impl Contacts {
pub(super) fn query(&mut self, ndb: &Ndb, txn: &Transaction) {
let binding = ndb
.query(txn, &[self.filter.clone()], 1)
.query(txn, std::slice::from_ref(&self.filter), 1)
.expect("query user relays results");
let Some(res) = binding.first() else {
+1 -1
View File
@@ -33,7 +33,7 @@ impl AccountMutedData {
.limit()
.unwrap_or(crate::filter::default_limit()) as i32;
let nks = ndb
.query(txn, &[self.filter.clone()], lim)
.query(txn, std::slice::from_ref(&self.filter), lim)
.expect("query user muted results")
.iter()
.map(|qr| qr.note_key)
+1 -1
View File
@@ -36,7 +36,7 @@ impl AccountRelayData {
.limit()
.unwrap_or(crate::filter::default_limit()) as i32;
let nks = ndb
.query(txn, &[self.filter.clone()], lim)
.query(txn, std::slice::from_ref(&self.filter), lim)
.expect("query user relays results")
.iter()
.map(|qr| qr.note_key)
+17 -2
View File
@@ -22,6 +22,9 @@ use std::rc::Rc;
use tracing::{error, info};
use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
#[cfg(target_os = "android")]
use android_activity::AndroidApp;
pub enum AppAction {
Note(NoteAction),
ToggleChrome,
@@ -51,6 +54,9 @@ pub struct Notedeck {
frame_history: FrameHistory,
job_pool: JobPool,
i18n: Localization,
#[cfg(target_os = "android")]
android_app: Option<AndroidApp>,
}
/// Our chrome, which is basically nothing
@@ -138,6 +144,11 @@ fn setup_puffin() {
}
impl Notedeck {
#[cfg(target_os = "android")]
pub fn set_android_context(&mut self, context: AndroidApp) {
self.android_app = Some(context);
}
pub fn new<P: AsRef<Path>>(ctx: &egui::Context, data_path: P, args: &[String]) -> Self {
#[cfg(feature = "puffin")]
setup_puffin();
@@ -241,8 +252,8 @@ impl Notedeck {
let setting_locale: Result<LanguageIdentifier, LanguageIdentifierError> =
settings.locale().parse();
if setting_locale.is_ok() {
if let Err(err) = i18n.set_locale(setting_locale.unwrap()) {
if let Ok(setting_locale) = setting_locale {
if let Err(err) = i18n.set_locale(setting_locale) {
error!("{err}");
}
}
@@ -272,6 +283,8 @@ impl Notedeck {
zaps,
job_pool,
i18n,
#[cfg(target_os = "android")]
android_app: None,
}
}
@@ -335,6 +348,8 @@ impl Notedeck {
frame_history: &mut self.frame_history,
job_pool: &mut self.job_pool,
i18n: &mut self.i18n,
#[cfg(target_os = "android")]
android: self.android_app.as_ref().unwrap().clone(),
}
}
+61
View File
@@ -8,6 +8,9 @@ use egui_winit::clipboard::Clipboard;
use enostr::RelayPool;
use nostrdb::Ndb;
#[cfg(target_os = "android")]
use android_activity::AndroidApp;
use egui::{Pos2, Rect};
// TODO: make this interface more sandboxed
pub struct AppContext<'a> {
@@ -26,4 +29,62 @@ pub struct AppContext<'a> {
pub frame_history: &'a mut FrameHistory,
pub job_pool: &'a mut JobPool,
pub i18n: &'a mut Localization,
#[cfg(target_os = "android")]
pub android: AndroidApp,
}
#[derive(Debug, Clone)]
pub enum SoftKeyboardContext {
Virtual,
Platform { ppp: f32 },
}
impl SoftKeyboardContext {
pub fn platform(context: &egui::Context) -> Self {
Self::Platform {
ppp: context.pixels_per_point(),
}
}
}
impl<'a> AppContext<'a> {
pub fn soft_keyboard_rect(&self, screen_rect: Rect, ctx: SoftKeyboardContext) -> Option<Rect> {
match ctx {
SoftKeyboardContext::Virtual => {
let height = 400.0;
skb_rect_from_screen_rect(screen_rect, height)
}
#[allow(unused_variables)]
SoftKeyboardContext::Platform { ppp } => {
#[cfg(target_os = "android")]
{
use android_activity::InsetType;
// not sure why I need this, it seems to be consistently off by some amount of
// pixels ?
let fudge = 0.0;
let inset = self.android.get_window_insets(InsetType::Ime);
let height = (inset.bottom as f32 / ppp) - fudge;
skb_rect_from_screen_rect(screen_rect, height)
}
#[cfg(not(target_os = "android"))]
{
None
}
}
}
}
}
#[inline]
fn skb_rect_from_screen_rect(screen_rect: Rect, height: f32) -> Option<Rect> {
if height == 0.0 {
return None;
}
let min = Pos2::new(0.0, screen_rect.max.y - height);
Some(Rect::from_min_max(min, screen_rect.max))
}
+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)
+16 -6
View File
@@ -86,6 +86,13 @@ impl FilterStates {
}
self.states.insert(relay, state);
}
/// For contacts, since that sub is managed elsewhere
pub fn set_all_states(&mut self, state: FilterState) {
for cur_state in self.states.values_mut() {
*cur_state = state.clone();
}
}
}
/// We may need to fetch some data from relays before our filter is ready.
@@ -176,21 +183,24 @@ pub fn should_since_optimize(limit: u64, num_notes: usize) -> bool {
limit as usize <= num_notes
}
pub fn since_optimize_filter_with(filter: Filter, notes: &[NoteRef], since_gap: u64) -> Filter {
pub fn since_optimize_filter_with(
filter: Filter,
latest_note: Option<&NoteRef>,
since_gap: u64,
) -> Filter {
// Get the latest entry in the events
if notes.is_empty() {
let Some(latest) = latest_note else {
return filter;
}
};
// get the latest note
let latest = notes[0];
let since = latest.created_at - since_gap;
filter.since_mut(since)
}
pub fn since_optimize_filter(filter: Filter, notes: &[NoteRef]) -> Filter {
since_optimize_filter_with(filter, notes, 60)
pub fn since_optimize_filter(filter: Filter, latest: Option<&NoteRef>) -> Filter {
since_optimize_filter_with(filter, latest, 60)
}
pub fn default_limit() -> u64 {
+3 -3
View File
@@ -35,7 +35,7 @@ impl TexturesCache {
&mut self,
url: &str,
closure: impl FnOnce() -> Promise<Option<Result<TexturedImage>>>,
) -> LoadableTextureState {
) -> LoadableTextureState<'_> {
let internal = self.handle_and_get_state_internal(url, true, closure);
internal.into()
@@ -45,7 +45,7 @@ impl TexturesCache {
&mut self,
url: &str,
closure: impl FnOnce() -> Promise<Option<Result<TexturedImage>>>,
) -> TextureState {
) -> TextureState<'_> {
let internal = self.handle_and_get_state_internal(url, false, closure);
internal.into()
@@ -96,7 +96,7 @@ impl TexturesCache {
});
}
pub fn get_and_handle(&mut self, url: &str) -> Option<LoadableTextureState> {
pub fn get_and_handle(&mut self, url: &str) -> Option<LoadableTextureState<'_>> {
self.cache.get_mut(url).map(|state| {
handle_occupied(state, true);
state.into()
+3 -1
View File
@@ -16,6 +16,7 @@ mod jobs;
pub mod media;
mod muted;
pub mod name;
mod nip51_set;
pub mod note;
mod notecache;
mod options;
@@ -45,7 +46,7 @@ pub use account::relay::RelayAction;
pub use account::FALLBACK_PUBKEY;
pub use app::{App, AppAction, Notedeck};
pub use args::Args;
pub use context::AppContext;
pub use context::{AppContext, SoftKeyboardContext};
pub use error::{show_one_error_message, Error, FilterError, ZapError};
pub use filter::{FilterState, FilterStates, UnifiedSubscription};
pub use fonts::NamedFontFamily;
@@ -65,6 +66,7 @@ pub use media::{
};
pub use muted::{MuteFun, Muted};
pub use name::NostrName;
pub use nip51_set::{create_nip51_set, Nip51Set, Nip51SetCache};
pub use note::{
BroadcastContext, ContextSelection, NoteAction, NoteContext, NoteContextSelection, NoteRef,
RootIdError, RootNoteId, RootNoteIdBuf, ScrollInfo, ZapAction,
+4
View File
@@ -80,4 +80,8 @@ impl Muted {
false
}
pub fn is_pk_muted(&self, pk: &[u8; 32]) -> bool {
self.pubkeys.contains(pk)
}
}
+206
View File
@@ -0,0 +1,206 @@
use enostr::{Pubkey, RelayPool};
use indexmap::IndexMap;
use nostrdb::{Filter, Ndb, Note, Transaction};
use uuid::Uuid;
use crate::{UnifiedSubscription, UnknownIds};
/// Keeps track of most recent NIP-51 sets
#[derive(Debug)]
pub struct Nip51SetCache {
pub sub: UnifiedSubscription,
cached_notes: IndexMap<PackId, Nip51Set>,
}
type PackId = String;
impl Nip51SetCache {
pub fn new(
pool: &mut RelayPool,
ndb: &Ndb,
txn: &Transaction,
unknown_ids: &mut UnknownIds,
nip51_set_filter: Vec<Filter>,
) -> Option<Self> {
let subid = Uuid::new_v4().to_string();
let mut cached_notes = IndexMap::default();
let notes: Option<Vec<Note>> = if let Ok(results) = ndb.query(txn, &nip51_set_filter, 500) {
Some(results.into_iter().map(|r| r.note).collect())
} else {
None
};
if let Some(notes) = notes {
add(notes, &mut cached_notes, ndb, txn, unknown_ids);
}
let sub = match ndb.subscribe(&nip51_set_filter) {
Ok(sub) => sub,
Err(e) => {
tracing::error!("Could not ndb subscribe: {e}");
return None;
}
};
pool.subscribe(subid.clone(), nip51_set_filter);
Some(Self {
sub: UnifiedSubscription {
local: sub,
remote: subid,
},
cached_notes,
})
}
pub fn poll_for_notes(&mut self, ndb: &Ndb, unknown_ids: &mut UnknownIds) {
let new_notes = ndb.poll_for_notes(self.sub.local, 5);
if new_notes.is_empty() {
return;
}
let txn = Transaction::new(ndb).expect("txn");
let notes: Vec<Note> = new_notes
.into_iter()
.filter_map(|new_note_key| ndb.get_note_by_key(&txn, new_note_key).ok())
.collect();
add(notes, &mut self.cached_notes, ndb, &txn, unknown_ids);
}
pub fn iter(&self) -> impl IntoIterator<Item = &Nip51Set> {
self.cached_notes.values()
}
pub fn len(&self) -> usize {
self.cached_notes.len()
}
pub fn is_empty(&self) -> bool {
self.cached_notes.is_empty()
}
pub fn at_index(&self, index: usize) -> Option<&Nip51Set> {
self.cached_notes.get_index(index).map(|(_, s)| s)
}
}
fn add(
notes: Vec<Note>,
cache: &mut IndexMap<PackId, Nip51Set>,
ndb: &Ndb,
txn: &Transaction,
unknown_ids: &mut UnknownIds,
) {
for note in notes {
let Some(new_pack) = create_nip51_set(note) else {
continue;
};
if let Some(cur_cached) = cache.get(&new_pack.identifier) {
if new_pack.created_at <= cur_cached.created_at {
continue;
}
}
for pk in &new_pack.pks {
unknown_ids.add_pubkey_if_missing(ndb, txn, pk);
}
cache.insert(new_pack.identifier.clone(), new_pack);
}
}
pub fn create_nip51_set(note: Note) -> Option<Nip51Set> {
let mut identifier = None;
let mut title = None;
let mut image = None;
let mut description = None;
let mut pks = Vec::new();
for tag in note.tags() {
if tag.count() < 2 {
continue;
}
let Some(first) = tag.get_str(0) else {
continue;
};
match first {
"p" => {
let Some(pk) = tag.get_id(1) else {
continue;
};
pks.push(Pubkey::new(*pk));
}
"d" => {
let Some(id) = tag.get_str(1) else {
continue;
};
identifier = Some(id.to_owned());
}
"image" => {
let Some(cur_img) = tag.get_str(1) else {
continue;
};
image = Some(cur_img.to_owned());
}
"title" => {
let Some(cur_title) = tag.get_str(1) else {
continue;
};
title = Some(cur_title.to_owned());
}
"description" => {
let Some(cur_desc) = tag.get_str(1) else {
continue;
};
description = Some(cur_desc.to_owned());
}
_ => {
continue;
}
};
}
let identifier = identifier?;
Some(Nip51Set {
identifier,
title,
image,
description,
pks,
created_at: note.created_at(),
})
}
/// NIP-51 Set. Read only (do not use for writing)
pub struct Nip51Set {
pub identifier: String, // 'd' tag
pub title: Option<String>,
pub image: Option<String>,
pub description: Option<String>,
pub pks: Vec<Pubkey>,
created_at: u64,
}
impl std::fmt::Debug for Nip51Set {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Nip51Set")
.field("identifier", &self.identifier)
.field("title", &self.title)
.field("image", &self.image)
.field("description", &self.description)
.field("pks", &self.pks.len())
.field("created_at", &self.created_at)
.finish()
}
}
+88 -2
View File
@@ -1,5 +1,14 @@
use crate::platform::{file::emit_selected_file, SelectedMedia};
use jni::{
objects::{JByteArray, JClass, JObject, JObjectArray, JString},
JNIEnv,
};
use std::sync::atomic::{AtomicI32, Ordering};
use tracing::debug;
use tracing::{debug, error, info};
pub fn get_jvm() -> jni::JavaVM {
unsafe { jni::JavaVM::from_raw(ndk_context::android_context().vm().cast()) }.unwrap()
}
// Thread-safe static global
static KEYBOARD_HEIGHT: AtomicI32 = AtomicI32::new(0);
@@ -16,7 +25,7 @@ pub extern "C" fn Java_com_damus_notedeck_KeyboardHeightHelper_nativeKeyboardHei
debug!("updating virtual keyboard height {}", height);
// Convert and store atomically
KEYBOARD_HEIGHT.store(height, Ordering::SeqCst);
KEYBOARD_HEIGHT.store(height.max(0), Ordering::SeqCst);
}
/// Gets the current Android virtual keyboard height. Useful for transforming
@@ -24,3 +33,80 @@ pub extern "C" fn Java_com_damus_notedeck_KeyboardHeightHelper_nativeKeyboardHei
pub fn virtual_keyboard_height() -> i32 {
KEYBOARD_HEIGHT.load(Ordering::SeqCst)
}
#[no_mangle]
pub extern "C" fn Java_com_damus_notedeck_MainActivity_nativeOnFilePickedFailed(
mut env: JNIEnv,
_class: JClass,
juri: JString,
je: JString,
) {
let _uri: String = env.get_string(&juri).unwrap().into();
let _error: String = env.get_string(&je).unwrap().into();
}
#[no_mangle]
pub extern "C" fn Java_com_damus_notedeck_MainActivity_nativeOnFilePickedWithContent(
mut env: JNIEnv,
_class: JClass,
// [display_name, size, mime_type]
juri_info: JObjectArray,
jcontent: JByteArray,
) {
debug!("File picked with content");
let display_name: Option<String> = {
let obj = env.get_object_array_element(&juri_info, 0).unwrap();
if obj.is_null() {
None
} else {
Some(env.get_string(&JString::from(obj)).unwrap().into())
}
};
if let Some(display_name) = display_name {
let length = env.get_array_length(&jcontent).unwrap() as usize;
let mut content: Vec<i8> = vec![0; length];
env.get_byte_array_region(&jcontent, 0, &mut content)
.unwrap();
debug!("selected file: {display_name:?} ({length:?} bytes)",);
emit_selected_file(SelectedMedia::from_bytes(
display_name,
content.into_iter().map(|b| b as u8).collect(),
));
} else {
error!("Received null file name");
}
}
pub fn try_open_file_picker() {
match open_file_picker() {
Ok(()) => {
info!("File picker opened successfully");
}
Err(e) => {
error!("Failed to open file picker: {}", e);
}
}
}
pub fn open_file_picker() -> std::result::Result<(), Box<dyn std::error::Error>> {
// Get the Java VM from AndroidApp
let vm = get_jvm();
// Attach current thread to get JNI environment
let mut env = vm.attach_current_thread()?;
let context = unsafe { JObject::from_raw(ndk_context::android_context().context().cast()) };
// Call the openFilePicker method on the MainActivity
env.call_method(
context,
"openFilePicker",
"()V", // Method signature: no parameters, void return
&[], // No arguments
)?;
Ok(())
}
+99
View File
@@ -0,0 +1,99 @@
use std::{path::PathBuf, str::FromStr};
use crossbeam_channel::{unbounded, Receiver, Sender};
use once_cell::sync::Lazy;
use crate::{Error, SupportedMimeType};
#[derive(Debug)]
pub enum MediaFrom {
PathBuf(PathBuf),
Memory(Vec<u8>),
}
#[derive(Debug)]
pub struct SelectedMedia {
pub from: MediaFrom,
pub file_name: String,
pub media_type: SupportedMimeType,
}
impl SelectedMedia {
pub fn from_path(path: PathBuf) -> Result<Self, Error> {
if let Some(ex) = path.extension().and_then(|f| f.to_str()) {
let media_type = SupportedMimeType::from_extension(ex)?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&format!("file.{ex}"))
.to_owned();
Ok(SelectedMedia {
from: MediaFrom::PathBuf(path),
file_name,
media_type,
})
} else {
Err(Error::Generic(format!(
"{path:?} does not have an extension"
)))
}
}
pub fn from_bytes(file_name: String, content: Vec<u8>) -> Result<Self, Error> {
if let Some(ex) = PathBuf::from_str(&file_name)
.unwrap()
.extension()
.and_then(|f| f.to_str())
{
let media_type = SupportedMimeType::from_extension(ex)?;
Ok(SelectedMedia {
from: MediaFrom::Memory(content),
file_name,
media_type,
})
} else {
Err(Error::Generic(format!(
"{file_name:?} does not have an extension"
)))
}
}
}
pub struct SelectedMediaChannel {
sender: Sender<Result<SelectedMedia, Error>>,
receiver: Receiver<Result<SelectedMedia, Error>>,
}
impl Default for SelectedMediaChannel {
fn default() -> Self {
let (sender, receiver) = unbounded();
Self { sender, receiver }
}
}
impl SelectedMediaChannel {
pub fn new_selected_file(&self, media: Result<SelectedMedia, Error>) {
let _ = self.sender.send(media);
}
pub fn try_receive(&self) -> Option<Result<SelectedMedia, Error>> {
self.receiver.try_recv().ok()
}
pub fn receive(&self) -> Option<Result<SelectedMedia, Error>> {
self.receiver.recv().ok()
}
}
pub static SELECTED_MEDIA_CHANNEL: Lazy<SelectedMediaChannel> =
Lazy::new(SelectedMediaChannel::default);
pub fn emit_selected_file(media: Result<SelectedMedia, Error>) {
SELECTED_MEDIA_CHANNEL.new_selected_file(media);
}
pub fn get_next_selected_file() -> Option<Result<SelectedMedia, Error>> {
SELECTED_MEDIA_CHANNEL.try_receive()
}
+17
View File
@@ -1,5 +1,12 @@
use crate::{platform::file::SelectedMedia, Error};
#[cfg(target_os = "android")]
pub mod android;
pub mod file;
pub fn get_next_selected_file() -> Option<Result<SelectedMedia, Error>> {
file::get_next_selected_file()
}
const VIRT_HEIGHT: i32 = 400;
@@ -20,3 +27,13 @@ pub fn virtual_keyboard_height(virt: bool) -> i32 {
0
}
}
pub fn virtual_keyboard_rect(ui: &egui::Ui, virt: bool) -> Option<egui::Rect> {
let height = virtual_keyboard_height(virt);
if height <= 0 {
return None;
}
let screen_rect = ui.ctx().screen_rect();
let min = egui::Pos2::new(0.0, screen_rect.max.y - height as f32);
Some(egui::Rect::from_min_max(min, screen_rect.max))
}
+1
View File
@@ -77,6 +77,7 @@ impl PartialEq for RelaySpec {
impl Eq for RelaySpec {}
#[allow(clippy::non_canonical_partial_ord_impl)]
impl PartialOrd for RelaySpec {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.url.cmp(&other.url))
+2 -2
View File
@@ -195,13 +195,13 @@ impl UnknownIds {
}
}
pub fn add_pubkey_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, pubkey: &Pubkey) {
pub fn add_pubkey_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, pubkey: &[u8; 32]) {
// we already have this profile, skip
if ndb.get_profile_by_pubkey(txn, pubkey).is_ok() {
return;
}
let unknown_id = UnknownId::Pubkey(*pubkey);
let unknown_id = UnknownId::Pubkey(Pubkey::new(*pubkey));
if self.ids.contains_key(&unknown_id) {
return;
}
+3 -1
View File
@@ -238,7 +238,9 @@ impl SupportedMimeType {
{
Ok(Self { mime })
} else {
Err(Error::Generic("Unsupported mime type".to_owned()))
Err(Error::Generic(
format!("{extension} Unsupported mime type",),
))
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ impl UserAccount {
}
}
pub fn keypair(&self) -> KeypairUnowned {
pub fn keypair(&self) -> KeypairUnowned<'_> {
KeypairUnowned {
pubkey: &self.key.pubkey,
secret_key: self.key.secret_key.as_ref(),
+81 -48
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 {
return NextState::Event(EventResponse {
id,
event: Err(ZappingError::InvalidZapAddress),
});
let promise = match m_promise {
Ok(promise) => promise,
Err(e) => {
return NextState::Event(EventResponse {
id,
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)
}
+210 -100
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
}))
}
) -> 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),
};
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),
};
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
}))
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,
request_noteid: noteid,
})
let res = fetch_ln_invoice(&query).await;
FetchedInvoiceResponse {
invoice: res.map(|r| FetchedInvoice {
invoice: r.invoice,
request_noteid: noteid,
}),
pay_entry: Some(pay_entry),
}
}
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"));
}
}
@@ -10,6 +10,7 @@
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|fontScale|smallestScreenSize"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
>
<intent-filter>
@@ -37,4 +38,4 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
</manifest>
@@ -1,48 +0,0 @@
package com.damus.notedeck;
import android.app.Activity;
import android.content.res.Configuration;
import android.util.Log;
import android.view.View;
public class KeyboardHeightHelper {
private static final String TAG = "KeyboardHeightHelper";
private KeyboardHeightProvider keyboardHeightProvider;
private Activity activity;
// Static JNI method not tied to any specific activity
private static native void nativeKeyboardHeightChanged(int height);
public KeyboardHeightHelper(Activity activity) {
this.activity = activity;
keyboardHeightProvider = new KeyboardHeightProvider(activity);
// Create observer implementation
KeyboardHeightObserver observer = (height, orientation) -> {
Log.d(TAG, "Keyboard height: " + height + "px, orientation: " +
(orientation == Configuration.ORIENTATION_PORTRAIT ? "portrait" : "landscape"));
// Call the generic native method
nativeKeyboardHeightChanged(height);
};
// Set up the provider
keyboardHeightProvider.setKeyboardHeightObserver(observer);
}
public void start() {
// Start the keyboard height provider after the view is ready
final View contentView = activity.findViewById(android.R.id.content);
contentView.post(() -> {
keyboardHeightProvider.start();
});
}
public void stop() {
keyboardHeightProvider.setKeyboardHeightObserver(null);
}
public void close() {
keyboardHeightProvider.close();
}
}
@@ -1,35 +0,0 @@
/*
* This file is part of Siebe Projects samples.
*
* Siebe Projects samples is free software: you can redistribute it and/or modify
* it under the terms of the Lesser GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Siebe Projects samples is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Lesser GNU General Public License for more details.
*
* You should have received a copy of the Lesser GNU General Public License
* along with Siebe Projects samples. If not, see <http://www.gnu.org/licenses/>.
*/
package com.damus.notedeck;
/**
* The observer that will be notified when the height of
* the keyboard has changed
*/
public interface KeyboardHeightObserver {
/**
* Called when the keyboard height has changed, 0 means keyboard is closed,
* >= 1 means keyboard is opened.
*
* @param height The height of the keyboard in pixels
* @param orientation The orientation either: Configuration.ORIENTATION_PORTRAIT or
* Configuration.ORIENTATION_LANDSCAPE
*/
void onKeyboardHeightChanged(int height, int orientation);
}
@@ -1,174 +0,0 @@
/*
* This file is part of Siebe Projects samples.
*
* Siebe Projects samples is free software: you can redistribute it and/or modify
* it under the terms of the Lesser GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Siebe Projects samples is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Lesser GNU General Public License for more details.
*
* You should have received a copy of the Lesser GNU General Public License
* along with Siebe Projects samples. If not, see <http://www.gnu.org/licenses/>.
*/
package com.damus.notedeck;
import android.app.Activity;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.util.Log;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.view.WindowManager.LayoutParams;
import android.widget.PopupWindow;
/**
* The keyboard height provider, this class uses a PopupWindow
* to calculate the window height when the floating keyboard is opened and closed.
*/
public class KeyboardHeightProvider extends PopupWindow {
/** The tag for logging purposes */
private final static String TAG = "sample_KeyboardHeightProvider";
/** The keyboard height observer */
private KeyboardHeightObserver observer;
/** The cached landscape height of the keyboard */
private int keyboardLandscapeHeight;
/** The cached portrait height of the keyboard */
private int keyboardPortraitHeight;
/** The view that is used to calculate the keyboard height */
private View popupView;
/** The parent view */
private View parentView;
/** The root activity that uses this KeyboardHeightProvider */
private Activity activity;
/**
* Construct a new KeyboardHeightProvider
*
* @param activity The parent activity
*/
public KeyboardHeightProvider(Activity activity) {
super(activity);
this.activity = activity;
//LayoutInflater inflator = (LayoutInflater) activity.getSystemService(Activity.LAYOUT_INFLATER_SERVICE);
//this.popupView = inflator.inflate(android.R.layout.popupwindow, null, false);
this.popupView = new View(activity);
setContentView(popupView);
setSoftInputMode(LayoutParams.SOFT_INPUT_ADJUST_RESIZE | LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED);
parentView = activity.findViewById(android.R.id.content);
setWidth(0);
setHeight(LayoutParams.MATCH_PARENT);
popupView.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
if (popupView != null) {
handleOnGlobalLayout();
}
}
});
}
/**
* Start the KeyboardHeightProvider, this must be called after the onResume of the Activity.
* PopupWindows are not allowed to be registered before the onResume has finished
* of the Activity.
*/
public void start() {
if (!isShowing() && parentView.getWindowToken() != null) {
setBackgroundDrawable(new ColorDrawable(0));
showAtLocation(parentView, Gravity.NO_GRAVITY, 0, 0);
}
}
/**
* Close the keyboard height provider,
* this provider will not be used anymore.
*/
public void close() {
this.observer = null;
dismiss();
}
/**
* Set the keyboard height observer to this provider. The
* observer will be notified when the keyboard height has changed.
* For example when the keyboard is opened or closed.
*
* @param observer The observer to be added to this provider.
*/
public void setKeyboardHeightObserver(KeyboardHeightObserver observer) {
this.observer = observer;
}
/**
* Popup window itself is as big as the window of the Activity.
* The keyboard can then be calculated by extracting the popup view bottom
* from the activity window height.
*/
private void handleOnGlobalLayout() {
Point screenSize = new Point();
activity.getWindowManager().getDefaultDisplay().getSize(screenSize);
Rect rect = new Rect();
popupView.getWindowVisibleDisplayFrame(rect);
// REMIND, you may like to change this using the fullscreen size of the phone
// and also using the status bar and navigation bar heights of the phone to calculate
// the keyboard height. But this worked fine on a Nexus.
int orientation = getScreenOrientation();
int keyboardHeight = screenSize.y - rect.bottom;
if (keyboardHeight == 0) {
notifyKeyboardHeightChanged(0, orientation);
}
else if (orientation == Configuration.ORIENTATION_PORTRAIT) {
this.keyboardPortraitHeight = keyboardHeight;
notifyKeyboardHeightChanged(keyboardPortraitHeight, orientation);
}
else {
this.keyboardLandscapeHeight = keyboardHeight;
notifyKeyboardHeightChanged(keyboardLandscapeHeight, orientation);
}
}
private int getScreenOrientation() {
return activity.getResources().getConfiguration().orientation;
}
private void notifyKeyboardHeightChanged(int height, int orientation) {
if (observer != null) {
observer.onKeyboardHeightChanged(height, orientation);
}
}
}
@@ -1,13 +1,18 @@
package com.damus.notedeck;
import android.content.ClipData;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import androidx.core.graphics.Insets;
import androidx.core.view.DisplayCutoutCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
@@ -15,52 +20,38 @@ import androidx.core.view.WindowInsetsControllerCompat;
import com.google.androidgamesdk.GameActivity;
import java.io.ByteArrayOutputStream;
import java.io.FileDescriptor;
import java.io.IOException;
import java.io.InputStream;
public class MainActivity extends GameActivity {
static {
System.loadLibrary("notedeck_chrome");
}
static final int REQUEST_CODE_PICK_FILE = 420;
private native void nativeOnKeyboardHeightChanged(int height);
private KeyboardHeightHelper keyboardHelper;
@Override
protected void onCreate(Bundle savedInstanceState) {
// Shrink view so it does not get covered by insets.
private native void nativeOnFilePickedFailed(String uri, String e);
private native void nativeOnFilePickedWithContent(Object[] uri_info, byte[] content);
setupInsets();
//setupFullscreen()
keyboardHelper = new KeyboardHeightHelper(this);
super.onCreate(savedInstanceState);
}
private void setupFullscreen() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
if (controller != null) {
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
controller.hide(WindowInsetsCompat.Type.systemBars());
}
//focus(getContent())
}
// not sure if this does anything
private void focus(View content) {
content.setFocusable(true);
content.setFocusableInTouchMode(true);
content.requestFocus();
}
private View getContent() {
return getWindow().getDecorView().findViewById(android.R.id.content);
public void openFilePicker() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
intent.addCategory(Intent.CATEGORY_OPENABLE);
startActivityForResult(intent, REQUEST_CODE_PICK_FILE);
}
private void setupInsets() {
// NOTE(jb55): This is needed for keyboard visibility. Without this the
// window still gets the right insets, but theyre consumed before they
// reach the NDK side.
//WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
// NOTE(jb55): This is needed for keyboard visibility. If the bars are
// permanently gone, Android routes the keyboard over the GL surface and
// doesnt change insets.
//WindowInsetsControllerCompat ic = WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
//ic.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
View content = getContent();
ViewCompat.setOnApplyWindowInsetsListener(content, (v, windowInsets) -> {
Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
@@ -72,38 +63,176 @@ public class MainActivity extends GameActivity {
mlp.rightMargin = insets.right;
v.setLayoutParams(mlp);
return WindowInsetsCompat.CONSUMED;
return windowInsets;
});
WindowCompat.setDecorFitsSystemWindows(getWindow(), true);
}
@Override
public void onResume() {
super.onResume();
keyboardHelper.start();
}
@Override
public void onPause() {
super.onPause();
keyboardHelper.stop();
}
@Override
public void onDestroy() {
super.onDestroy();
keyboardHelper.close();
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// Offset the location so it fits the view with margins caused by insets.
private void processSelectedFile(Uri uri) {
try {
nativeOnFilePickedWithContent(this.getUriInfo(uri), readUriContent(uri));
} catch (Exception e) {
Log.e("MainActivity", "Error processing file: " + uri.toString(), e);
int[] location = new int[2];
findViewById(android.R.id.content).getLocationOnScreen(location);
event.offsetLocation(-location[0], -location[1]);
return super.onTouchEvent(event);
nativeOnFilePickedFailed(uri.toString(), e.toString());
}
}
private Object[] getUriInfo(Uri uri) throws Exception {
if (!uri.getScheme().equals("content")) {
throw new Exception("uri should start with content://");
}
Cursor cursor = getContentResolver().query(uri, null, null, null, null);
while (cursor.moveToNext()) {
Object[] info = new Object[3];
int col_idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
info[0] = cursor.getString(col_idx);
col_idx = cursor.getColumnIndex(OpenableColumns.SIZE);
info[1] = cursor.getLong(col_idx);
col_idx = cursor.getColumnIndex("mime_type");
info[2] = cursor.getString(col_idx);
return info;
}
return null;
}
private byte[] readUriContent(Uri uri) {
InputStream inputStream = null;
ByteArrayOutputStream buffer = null;
try {
inputStream = getContentResolver().openInputStream(uri);
if (inputStream == null) {
Log.e("MainActivity", "Could not open input stream for URI: " + uri);
return null;
}
buffer = new ByteArrayOutputStream();
byte[] data = new byte[8192]; // 8KB buffer
int bytesRead;
while ((bytesRead = inputStream.read(data)) != -1) {
buffer.write(data, 0, bytesRead);
}
byte[] result = buffer.toByteArray();
Log.d("MainActivity", "Successfully read " + result.length + " bytes");
return result;
} catch (IOException e) {
Log.e("MainActivity", "IOException while reading URI: " + uri, e);
return null;
} catch (SecurityException e) {
Log.e("MainActivity", "SecurityException while reading URI: " + uri, e);
return null;
} finally {
// Close streams
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
Log.e("MainActivity", "Error closing input stream", e);
}
}
if (buffer != null) {
try {
buffer.close();
} catch (IOException e) {
Log.e("MainActivity", "Error closing buffer", e);
}
}
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
// Shrink view so it does not get covered by insets.
setupInsets();
//setupFullscreen()
super.onCreate(savedInstanceState);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_PICK_FILE && resultCode == RESULT_OK) {
if (data == null) return;
if (data.getClipData() != null) {
// Multiple files selected
ClipData clipData = data.getClipData();
for (int i = 0; i < clipData.getItemCount(); i++) {
Uri uri = clipData.getItemAt(i).getUri();
processSelectedFile(uri);
}
} else if (data.getData() != null) {
// Single file selected
Uri uri = data.getData();
processSelectedFile(uri);
}
}
}
private void setupFullscreen() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
if (controller != null) {
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
controller.hide(WindowInsetsCompat.Type.systemBars());
}
//focus(getContent())
}
// not sure if this does anything
private void focus(View content) {
content.setFocusable(true);
content.setFocusableInTouchMode(true);
content.requestFocus();
}
private View getContent() {
return getWindow().getDecorView().findViewById(android.R.id.content);
}
@Override
public void onResume() {
super.onResume();
}
@Override
public void onPause() {
super.onPause();
}
@Override
public void onDestroy() {
super.onDestroy();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// Offset the location so it fits the view with margins caused by insets.
int[] location = new int[2];
findViewById(android.R.id.content).getLocationOnScreen(location);
event.offsetLocation(-location[0], -location[1]);
return super.onTouchEvent(event);
}
}
+8 -7
View File
@@ -8,16 +8,15 @@ use notedeck::Notedeck;
#[no_mangle]
#[tokio::main]
pub async fn android_main(app: AndroidApp) {
pub async fn android_main(android_app: AndroidApp) {
//use tracing_logcat::{LogcatMakeWriter, LogcatTag};
use tracing_subscriber::{prelude::*, EnvFilter};
std::env::set_var("RUST_BACKTRACE", "full");
//std::env::set_var("DAVE_ENDPOINT", "http://ollama.jb55.com/v1");
//std::env::set_var("DAVE_MODEL", "hhao/qwen2.5-coder-tools:latest");
std::env::set_var(
"RUST_LOG",
"egui=debug,egui-winit=debug,notedeck=debug,notedeck_columns=debug,notedeck_chrome=debug,enostr=debug,android_activity=debug",
"egui=debug,egui-winit=debug,winit=debug,notedeck=debug,notedeck_columns=debug,notedeck_chrome=debug,enostr=debug,android_activity=debug",
);
//std::env::set_var(
@@ -42,7 +41,7 @@ pub async fn android_main(app: AndroidApp) {
.with(fmt_layer)
.init();
let path = app.internal_data_path().expect("data path");
let path = android_app.internal_data_path().expect("data path");
let mut options = eframe::NativeOptions {
depth_buffer: 24,
..eframe::NativeOptions::default()
@@ -55,16 +54,18 @@ pub async fn android_main(app: AndroidApp) {
// builder.with_android_app(app_clone_for_event_loop);
//}));
options.android_app = Some(app.clone());
options.android_app = Some(android_app.clone());
let app_args = get_app_args(app);
let app_args = get_app_args();
let _res = eframe::run_native(
"Damus Notedeck",
options,
Box::new(move |cc| {
let ctx = &cc.egui_ctx;
let mut notedeck = Notedeck::new(ctx, path, &app_args);
notedeck.set_android_context(android_app);
notedeck.setup(ctx);
let chrome = Chrome::new_with_apps(cc, &app_args, &mut notedeck)?;
notedeck.set_app(chrome);
@@ -103,7 +104,7 @@ Using internal storage would be better but it seems hard to get the config file
the device ...
*/
fn get_app_args(_app: AndroidApp) -> Vec<String> {
fn get_app_args() -> Vec<String> {
vec!["argv0-placeholder".to_string()]
/*
use serde_json::value;
+299 -353
View File
@@ -3,43 +3,34 @@
//use wasm_bindgen::prelude::*;
use crate::app::NotedeckApp;
use crate::ChromeOptions;
use bitflags::bitflags;
use eframe::CreationContext;
use egui::{vec2, Button, Color32, Label, Layout, Rect, RichText, ThemePreference, Widget};
use egui_extras::{Size, StripBuilder};
use nostrdb::{ProfileRecord, Transaction};
use notedeck::Error;
use notedeck::SoftKeyboardContext;
use notedeck::{
tr, App, AppAction, AppContext, Localization, Notedeck, NotedeckOptions, NotedeckTextStyle,
UserAccount, WalletType,
};
use notedeck_columns::{
column::SelectionResult,
timeline::{kind::ListKind, TimelineKind},
Damus,
};
use notedeck_columns::{timeline::TimelineKind, Damus};
use notedeck_dave::{Dave, DaveAvatar};
use notedeck_ui::{app_images, AnimationHelper, ProfilePic};
use notedeck_ui::{
app_images, expanding_button, AnimationHelper, ProfilePic, ICON_EXPANSION_MULTIPLE, ICON_WIDTH,
};
use std::collections::HashMap;
static ICON_WIDTH: f32 = 40.0;
pub static ICON_EXPANSION_MULTIPLE: f32 = 1.2;
#[derive(Default)]
pub struct Chrome {
active: i32,
tab_selected: i32,
options: ChromeOptions,
apps: Vec<NotedeckApp>,
pub repaint_causes: HashMap<egui::RepaintCause, u64>,
}
/// When you click the toolbar button, these actions
/// are returned
#[derive(Debug, Eq, PartialEq)]
pub enum ToolbarAction {
Notifications,
Dave,
Home,
/// The state of the soft keyboard animation
soft_kb_anim_state: AnimState,
pub repaint_causes: HashMap<egui::RepaintCause, u64>,
}
pub enum ChromePanelAction {
@@ -47,41 +38,19 @@ pub enum ChromePanelAction {
Settings,
Account,
Wallet,
Toolbar(ToolbarAction),
SaveTheme(ThemePreference),
Profile(notedeck::enostr::Pubkey),
}
impl ChromePanelAction {
fn columns_switch(ctx: &mut AppContext, chrome: &mut Chrome, kind: &TimelineKind) {
chrome.switch_to_columns();
let Some(columns_app) = chrome.get_columns_app() else {
return;
};
if let Some(active_columns) = columns_app
.decks_cache
.active_columns_mut(ctx.i18n, ctx.accounts)
{
match active_columns.select_by_kind(kind) {
SelectionResult::NewSelection(_index) => {
// great! no need to go to top yet
}
SelectionResult::AlreadySelected(_n) => {
// we already selected this, so scroll to top
columns_app.scroll_to_top();
}
SelectionResult::Failed => {
// oh no, something went wrong
// TODO(jb55): handle tab selection failure
}
}
}
bitflags! {
#[repr(transparent)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SidebarOptions: u8 {
const Compact = 1 << 0;
}
}
impl ChromePanelAction {
fn columns_navigate(ctx: &mut AppContext, chrome: &mut Chrome, route: notedeck_columns::Route) {
chrome.switch_to_columns();
@@ -107,30 +76,6 @@ impl ChromePanelAction {
ctx.settings.set_theme(*theme);
}
Self::Toolbar(toolbar_action) => match toolbar_action {
ToolbarAction::Dave => chrome.switch_to_dave(),
ToolbarAction::Home => {
Self::columns_switch(
ctx,
chrome,
&TimelineKind::List(ListKind::Contact(
ctx.accounts.get_selected_account().key.pubkey,
)),
);
}
ToolbarAction::Notifications => {
Self::columns_switch(
ctx,
chrome,
&TimelineKind::Notifications(
ctx.accounts.get_selected_account().key.pubkey,
),
);
}
},
Self::Support => {
Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Support);
}
@@ -224,24 +169,6 @@ impl Chrome {
None
}
fn get_dave(&mut self) -> Option<&mut Dave> {
for app in &mut self.apps {
if let NotedeckApp::Dave(dave) = app {
return Some(dave);
}
}
None
}
fn switch_to_dave(&mut self) {
for (i, app) in self.apps.iter().enumerate() {
if let NotedeckApp::Dave(_) = app {
self.active = i as i32;
}
}
}
fn switch_to_columns(&mut self) {
for (i, app) in self.apps.iter().enumerate() {
if let NotedeckApp::Columns(_) = app {
@@ -260,6 +187,7 @@ impl Chrome {
app_ctx: &mut AppContext,
builder: StripBuilder,
amt_open: f32,
amt_keyboard_open: f32,
) -> Option<ChromePanelAction> {
let mut got_action: Option<ChromePanelAction> = None;
@@ -290,9 +218,17 @@ impl Chrome {
self.topdown_sidebar(ui, app_ctx.i18n);
})
});
vstrip.cell(|ui| {
ui.with_layout(Layout::bottom_up(egui::Align::Center), |ui| {
if let Some(action) = bottomup_sidebar(self, app_ctx, ui) {
let options = if amt_keyboard_open > 0.0 {
SidebarOptions::Compact
} else {
SidebarOptions::default()
};
if let Some(action) =
bottomup_sidebar(self, app_ctx, ui, options)
{
got_action = Some(action);
}
});
@@ -336,107 +272,6 @@ impl Chrome {
.animate_bool(open_id, self.options.contains(ChromeOptions::IsOpen))
* side_panel_width
}
fn toolbar_height() -> f32 {
48.0
}
/// On narrow layouts, we have a toolbar
fn toolbar_chrome(
&mut self,
ctx: &mut AppContext,
ui: &mut egui::Ui,
) -> Option<ChromePanelAction> {
let mut got_action: Option<ChromePanelAction> = None;
let amt_open = self.amount_open(ui);
StripBuilder::new(ui)
.size(Size::remainder()) // top cell
.size(Size::exact(Self::toolbar_height())) // bottom cell
.vertical(|mut strip| {
strip.strip(|builder| {
// the chrome panel is nested above the toolbar
got_action = self.panel(ctx, builder, amt_open);
});
strip.cell(|ui| {
let pk = ctx.accounts.get_selected_account().key.pubkey;
let unseen_notification =
unseen_notification(self.get_columns_app(), ctx.ndb, pk);
if let Some(action) = self.toolbar(ui, unseen_notification) {
got_action = Some(ChromePanelAction::Toolbar(action))
}
});
});
got_action
}
fn toolbar(&mut self, ui: &mut egui::Ui, unseen_notification: bool) -> Option<ToolbarAction> {
use egui_tabs::{TabColor, Tabs};
let rect = ui.available_rect_before_wrap();
ui.painter().hline(
rect.x_range(),
rect.top(),
ui.visuals().widgets.noninteractive.bg_stroke,
);
if !ui.visuals().dark_mode {
ui.painter().rect(
rect,
0,
notedeck_ui::colors::ALMOST_WHITE,
egui::Stroke::new(0.0, Color32::TRANSPARENT),
egui::StrokeKind::Inside,
);
}
let rs = Tabs::new(3)
.selected(self.tab_selected)
.hover_bg(TabColor::none())
.selected_fg(TabColor::none())
.selected_bg(TabColor::none())
.height(Self::toolbar_height())
.layout(Layout::centered_and_justified(egui::Direction::TopDown))
.show(ui, |ui, state| {
let index = state.index();
let mut action: Option<ToolbarAction> = None;
let btn_size: f32 = 20.0;
if index == 0 {
if home_button(ui, btn_size).clicked() {
action = Some(ToolbarAction::Home);
}
} else if index == 1 {
if let Some(dave) = self.get_dave() {
let rect = dave_toolbar_rect(ui, btn_size * 2.0);
if dave_button(dave.avatar_mut(), ui, rect).clicked() {
action = Some(ToolbarAction::Dave);
}
}
} else if index == 2
&& notifications_button(ui, btn_size, unseen_notification).clicked()
{
action = Some(ToolbarAction::Notifications);
}
action
})
.inner();
for maybe_r in rs {
if maybe_r.inner.is_some() {
return maybe_r.inner;
}
}
None
}
/// Show the side menu or bar, depending on if we're on a narrow
/// or wide screen.
///
@@ -445,25 +280,53 @@ impl Chrome {
fn show(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<ChromePanelAction> {
ui.spacing_mut().item_spacing.x = 0.0;
if ctx.args.options.contains(NotedeckOptions::Debug)
&& ui.ctx().input(|i| i.key_pressed(egui::Key::Backtick))
{
self.options.toggle(ChromeOptions::VirtualKeyboard);
}
let amt_open = self.amount_open(ui);
let skb_anim =
keyboard_visibility(ui, ctx, &mut self.options, &mut self.soft_kb_anim_state);
let r = if notedeck::ui::is_narrow(ui.ctx()) {
self.toolbar_chrome(ctx, ui)
let virtual_keyboard = self.options.contains(ChromeOptions::VirtualKeyboard);
let keyboard_height = if self.options.contains(ChromeOptions::KeyboardVisibility) {
skb_anim.anim_height
} else {
let amt_open = self.amount_open(ui);
self.panel(ctx, StripBuilder::new(ui), amt_open)
0.0
};
// virtual keyboard
if self.options.contains(ChromeOptions::VirtualKeyboard) {
virtual_keyboard_ui(ui);
// if the soft keyboard is open, shrink the chrome contents
let mut action: Option<ChromePanelAction> = None;
// build a strip to carve out the soft keyboard inset
StripBuilder::new(ui)
.size(Size::remainder())
.size(Size::exact(keyboard_height))
.vertical(|mut strip| {
// the actual content, shifted up because of the soft keyboard
strip.cell(|ui| {
action = self.panel(ctx, StripBuilder::new(ui), amt_open, keyboard_height);
});
// the filler space taken up by the soft keyboard
strip.cell(|ui| {
// keyboard-visibility virtual keyboard
if virtual_keyboard && keyboard_height > 0.0 {
virtual_keyboard_ui(ui, ui.available_rect_before_wrap())
}
});
});
// hovering virtual keyboard
if virtual_keyboard {
if let Some(mut kb_rect) = skb_anim.skb_rect {
let kb_height = if self.options.contains(ChromeOptions::KeyboardVisibility) {
keyboard_height
} else {
400.0
};
kb_rect.min.y = kb_rect.max.y - kb_height;
tracing::debug!("hovering virtual kb_height:{keyboard_height} kb_rect:{kb_rect}");
virtual_keyboard_ui(ui, kb_rect)
}
}
r
action
}
fn topdown_sidebar(&mut self, ui: &mut egui::Ui, i18n: &mut Localization) {
@@ -514,38 +377,6 @@ impl Chrome {
}
}
fn unseen_notification(
columns: Option<&mut Damus>,
ndb: &nostrdb::Ndb,
current_pk: notedeck::enostr::Pubkey,
) -> bool {
let Some(columns) = columns else {
return false;
};
let Some(tl) = columns
.timeline_cache
.get_mut(&TimelineKind::Notifications(current_pk))
else {
return false;
};
let freshness = &mut tl.current_view_mut().freshness;
freshness.update(|timestamp_last_viewed| {
let filter = notedeck_columns::timeline::kind::notifications_filter(&current_pk)
.since_mut(timestamp_last_viewed);
let txn = Transaction::new(ndb).expect("txn");
let Some(res) = ndb.query(&txn, &[filter], 1).ok() else {
return false;
};
!res.is_empty()
});
freshness.has_unseen()
}
impl notedeck::App for Chrome {
fn update(&mut self, ctx: &mut notedeck::AppContext, ui: &mut egui::Ui) -> Option<AppAction> {
if let Some(action) = self.show(ctx, ui) {
@@ -593,52 +424,6 @@ fn expand_side_panel_button() -> impl Widget {
}
}
fn expanding_button(
name: &'static str,
img_size: f32,
light_img: egui::Image,
dark_img: egui::Image,
ui: &mut egui::Ui,
unseen_indicator: bool,
) -> egui::Response {
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
let img = if ui.visuals().dark_mode {
dark_img
} else {
light_img
};
let helper = AnimationHelper::new(ui, name, egui::vec2(max_size, max_size));
let cur_img_size = helper.scale_1d_pos(img_size);
let paint_rect = helper
.get_animation_rect()
.shrink((max_size - cur_img_size) / 2.0);
img.paint_at(ui, paint_rect);
if unseen_indicator {
paint_unseen_indicator(ui, paint_rect, helper.scale_1d_pos(3.0));
}
helper.take_animation_response()
}
fn paint_unseen_indicator(ui: &mut egui::Ui, rect: egui::Rect, radius: f32) {
let center = rect.center();
let top_right = rect.right_top();
let distance = center.distance(top_right);
let midpoint = {
let mut cur = center;
cur.x += distance / 2.0;
cur.y -= distance / 2.0;
cur
};
let painter = ui.painter_at(rect);
painter.circle_filled(midpoint, radius, notedeck_ui::colors::PINK);
}
fn support_button(ui: &mut egui::Ui) -> egui::Response {
expanding_button(
"help-button",
@@ -661,28 +446,6 @@ fn settings_button(ui: &mut egui::Ui) -> egui::Response {
)
}
fn notifications_button(ui: &mut egui::Ui, size: f32, unseen_indicator: bool) -> egui::Response {
expanding_button(
"notifications-button",
size,
app_images::notifications_light_image(),
app_images::notifications_dark_image(),
ui,
unseen_indicator,
)
}
fn home_button(ui: &mut egui::Ui, size: f32) -> egui::Response {
expanding_button(
"home-button",
size,
app_images::home_light_image(),
app_images::home_dark_image(),
ui,
false,
)
}
fn columns_button(ui: &mut egui::Ui) -> egui::Response {
expanding_button(
"columns-button",
@@ -735,14 +498,6 @@ fn dave_sidebar_rect(ui: &mut egui::Ui) -> Rect {
egui::Rect::from_center_size(egui::pos2(center_x, center_y), size)
}
fn dave_toolbar_rect(ui: &mut egui::Ui, size: f32) -> Rect {
let size = vec2(size, size);
let available = ui.available_rect_before_wrap();
let center_x = available.center().x;
let center_y = available.center().y;
egui::Rect::from_center_size(egui::pos2(center_x, center_y), size)
}
fn dave_button(avatar: Option<&mut DaveAvatar>, ui: &mut egui::Ui, rect: Rect) -> egui::Response {
if let Some(avatar) = avatar {
avatar.render(rect, ui)
@@ -919,38 +674,6 @@ fn pfp_button(ctx: &mut AppContext, ui: &mut egui::Ui) -> egui::Response {
ui.put(helper.get_animation_rect(), &mut widget);
helper.take_animation_response()
// let selected = ctx.accounts.cache.selected();
// pfp_resp.context_menu(|ui| {
// for (pk, account) in &ctx.accounts.cache {
// let profile = ctx.ndb.get_profile_by_pubkey(&txn, pk).ok();
// let is_selected = *pk == selected.key.pubkey;
// let has_nsec = account.key.secret_key.is_some();
// let profile_peview_view = {
// let max_size = egui::vec2(ui.available_width(), 77.0);
// let resp = ui.allocate_response(max_size, egui::Sense::click());
// ui.allocate_new_ui(UiBuilder::new().max_rect(resp.rect), |ui| {
// ui.add(
// &mut ProfilePic::new(ctx.img_cache, get_profile_url(profile.as_ref()))
// .size(24.0),
// )
// })
// };
// // if let Some(op) = profile_peview_view {
// // return_op = Some(match op {
// // ProfilePreviewAction::SwitchTo => AccountsViewResponse::SelectAccount(*pk),
// // ProfilePreviewAction::RemoveAccount => AccountsViewResponse::RemoveAccount(*pk),
// // });
// // }
// }
// // if ui.menu_image_button(image, add_contents).clicked() {
// // // ui.ctx().copy_text(url.to_owned());
// // ui.close_menu();
// // }
// });
}
/// The section of the chrome sidebar that starts at the
@@ -959,10 +682,23 @@ fn bottomup_sidebar(
chrome: &mut Chrome,
ctx: &mut AppContext,
ui: &mut egui::Ui,
options: SidebarOptions,
) -> Option<ChromePanelAction> {
ui.add_space(8.0);
let pfp_resp = pfp_button(ctx, ui).on_hover_cursor(egui::CursorIcon::PointingHand);
// we skip this whole function in compact mode
if options.contains(SidebarOptions::Compact) {
return if pfp_resp.clicked() {
Some(ChromePanelAction::Profile(
ctx.accounts.get_selected_account().key.pubkey,
))
} else {
None
};
}
let accounts_resp = accounts_button(ui).on_hover_cursor(egui::CursorIcon::PointingHand);
let settings_resp = settings_button(ui).on_hover_cursor(egui::CursorIcon::PointingHand);
@@ -1040,14 +776,14 @@ fn bottomup_sidebar(
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
chrome.show_memory_debug = !chrome.show_memory_debug;
chrome.options.toggle(ChromeOptions::MemoryDebug);
}
}
if let Some(resident) = mem_use.resident {
ui.weak(format!("{}", format_bytes(resident as f64)));
}
if chrome.show_memory_debug {
if chrome.options.contains(ChromeOptions::MemoryDebug) {
egui::Window::new("Memory Debug").show(ui.ctx(), memory_debug_ui);
}
}
@@ -1211,12 +947,7 @@ fn repaint_causes_window(ui: &mut egui::Ui, causes: &HashMap<egui::RepaintCause,
});
}
fn virtual_keyboard_ui(ui: &mut egui::Ui) {
let height = notedeck::platform::virtual_keyboard_height(true);
let screen_rect = ui.ctx().screen_rect();
let min = egui::Pos2::new(0.0, screen_rect.max.y - height as f32);
let rect = Rect::from_min_max(min, screen_rect.max);
fn virtual_keyboard_ui(ui: &mut egui::Ui, rect: egui::Rect) {
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, Color32::from_black_alpha(200));
@@ -1228,3 +959,218 @@ fn virtual_keyboard_ui(ui: &mut egui::Ui) {
.response
});
}
struct SoftKeyboardAnim {
skb_rect: Option<Rect>,
anim_height: f32,
}
#[derive(Copy, Default, Clone, Eq, PartialEq, Debug)]
enum AnimState {
/// It finished opening
Opened,
/// We started to open
StartOpen,
/// We started to close
StartClose,
/// We finished openning
FinishedOpen,
/// We finished to close
FinishedClose,
/// It finished closing
#[default]
Closed,
/// We are animating towards open
Opening,
/// We are animating towards close
Closing,
}
impl SoftKeyboardAnim {
/// Advance the FSM based on current (anim_height) vs target (skb_rect.height()).
/// Start*/Finished* are one-tick edge states used for signaling.
fn changed(&self, state: AnimState) -> AnimState {
const EPS: f32 = 0.01;
let target = self.skb_rect.map_or(0.0, |r| r.height());
let current = self.anim_height;
let done = (current - target).abs() <= EPS;
let going_up = target > current + EPS;
let going_down = current > target + EPS;
let target_is_closed = target <= EPS;
match state {
// Resting states: emit a Start* edge only when a move is requested,
// and pick direction by the sign of (target - current).
AnimState::Opened => {
if done {
AnimState::Opened
} else if going_up {
AnimState::StartOpen
} else {
AnimState::StartClose
}
}
AnimState::Closed => {
if done {
AnimState::Closed
} else if going_up {
AnimState::StartOpen
} else {
AnimState::StartClose
}
}
// Edge → flow
AnimState::StartOpen => AnimState::Opening,
AnimState::StartClose => AnimState::Closing,
// Flow states: finish when we hit the target; if the target jumps across,
// emit the opposite Start* to signal a reversal.
AnimState::Opening => {
if done {
if target_is_closed {
AnimState::FinishedClose
} else {
AnimState::FinishedOpen
}
} else if going_down {
// target moved below current mid-flight → reversal
AnimState::StartClose
} else {
AnimState::Opening
}
}
AnimState::Closing => {
if done {
if target_is_closed {
AnimState::FinishedClose
} else {
AnimState::FinishedOpen
}
} else if going_up {
// target moved above current mid-flight → reversal
AnimState::StartOpen
} else {
AnimState::Closing
}
}
// Finish edges collapse to the stable resting states on the next tick.
AnimState::FinishedOpen => AnimState::Opened,
AnimState::FinishedClose => AnimState::Closed,
}
}
}
/// How "open" the softkeyboard is. This is an animated value
fn soft_keyboard_anim(
ui: &mut egui::Ui,
ctx: &mut AppContext,
chrome_options: &mut ChromeOptions,
) -> SoftKeyboardAnim {
let skb_ctx = if chrome_options.contains(ChromeOptions::VirtualKeyboard) {
SoftKeyboardContext::Virtual
} else {
SoftKeyboardContext::Platform {
ppp: ui.ctx().pixels_per_point(),
}
};
// move screen up if virtual keyboard intersects with input_rect
let screen_rect = ui.ctx().screen_rect();
let mut skb_rect: Option<Rect> = None;
let keyboard_height =
if let Some(vkb_rect) = ctx.soft_keyboard_rect(screen_rect, skb_ctx.clone()) {
skb_rect = Some(vkb_rect);
vkb_rect.height()
} else {
0.0
};
let anim_height =
ui.ctx()
.animate_value_with_time(egui::Id::new("keyboard_anim"), keyboard_height, 0.1);
SoftKeyboardAnim {
anim_height,
skb_rect,
}
}
fn try_toggle_virtual_keyboard(
ctx: &egui::Context,
options: NotedeckOptions,
chrome_options: &mut ChromeOptions,
) {
// handle virtual keyboard toggle here because why not
if options.contains(NotedeckOptions::Debug) && ctx.input(|i| i.key_pressed(egui::Key::F1)) {
chrome_options.toggle(ChromeOptions::VirtualKeyboard);
}
}
/// All the logic which handles our keyboard visibility
fn keyboard_visibility(
ui: &mut egui::Ui,
ctx: &mut AppContext,
options: &mut ChromeOptions,
soft_kb_anim_state: &mut AnimState,
) -> SoftKeyboardAnim {
try_toggle_virtual_keyboard(ui.ctx(), ctx.args.options, options);
let soft_kb_anim = soft_keyboard_anim(ui, ctx, options);
let prev_state = *soft_kb_anim_state;
let current_state = soft_kb_anim.changed(prev_state);
*soft_kb_anim_state = current_state;
if prev_state != current_state {
tracing::debug!("soft kb state {prev_state:?} -> {current_state:?}");
}
match current_state {
// we finished
AnimState::FinishedOpen => {}
// on first open, we setup our scroll target
AnimState::StartOpen => {
// when we first open the keyboard, check to see if the target soft
// keyboard rect (the height at full open) intersects with any
// input response rects from last frame
//
// If we do, then we set a bit that we need keyboard visibility.
// We will use this bit to resize the screen based on the soft
// keyboard animation state
if let Some(skb_rect) = soft_kb_anim.skb_rect {
if let Some(input_rect) = notedeck_ui::input_rect(ui) {
options.set(
ChromeOptions::KeyboardVisibility,
input_rect.intersects(skb_rect),
)
}
}
}
AnimState::FinishedClose => {
// clear last input box position state
notedeck_ui::clear_input_rect(ui);
}
AnimState::Closing => {}
AnimState::Opened => {}
AnimState::Closed => {}
AnimState::Opening => {}
AnimState::StartClose => {}
};
soft_kb_anim
}
-1
View File
@@ -183,7 +183,6 @@ mod tests {
let ctx = egui::Context::default();
let mut notedeck = Notedeck::new(&ctx, &tmpdir, &args);
let unrecognized_args = notedeck.unrecognized_args().clone();
let mut app_ctx = notedeck.app_context();
let app = Damus::new(&mut app_ctx, &args);
+3
View File
@@ -20,6 +20,9 @@ bitflags! {
/// Repaint debug
const RepaintDebug = 1 << 3;
/// We need soft keyboard visibility
const KeyboardVisibility = 1 << 4;
}
}
+7 -1
View File
@@ -8,8 +8,14 @@ egui = { workspace = true }
notedeck = { workspace = true }
#notedeck_ui = { workspace = true }
eframe = { workspace = true }
lnsocket = "0.4.0"
tracing = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
egui_extras = { workspace = true }
lightning-invoice = { workspace = true }
hex = { workspace = true }
nostrdb = { workspace = true }
notedeck_ui = { workspace = true }
lnsocket = "0.5.1"
+77
View File
@@ -0,0 +1,77 @@
# ⚡ clndash
Your Core Lightning dashboard, **without the server nonsense**.
clndash is a weird little experiment: a [notedeck][notedeck] app that talks to your node **directly over the Lightning Network** using [lnsocket][lnsocket] + [Commando][commando] RPCs.
No HTTP. No nginx. No VPS.
Just open clndash, point it at your node, and boom — youre in.
<img src="https://jb55.com/s/476285c50d06c3ce.png" width="50%" />
---
## 🤯 Why?
Because sometimes you just want to *see your channels* and *check invoices* without SSH-ing into a box and typing `lightning-cli`.
And because LN is already a secure, encrypted connection layer — why not just use that?
---
## 🔥 Features (as of today)
* **Plug-and-play LN connection** powered by [lnsocket][lnsocket]
* **Commando RPC** all dashboard data is fetched directly from your CLN node over Lightning
* **Channel overview** total capacity, inbound/outbound liquidity, largest channel, and pretty bars
* **Invoices** shows recent paid invoices (with zap previews if they came from Nostr)
* **No extra daemons** you dont need to run a server to use it
---
## 🪄 Nostr Bonus
Because its a notedeck app, clndash can **render zaps** inline.
Yes, your Core Lightning dashboard can now show you when someone on Nostr just sent you sats and why.
---
## 🏗 Still Baking
This is WIP.
Youll probably hit bugs. UI might be janky. Some features may vanish or suddenly mutate.
If youre reading this and still excited — youre the exact audience.
---
## 🛠 How to connect
1. Get your nodes **public address** (host\:port) and a **Commando rune** with safe permissions.
2. Set them as environment variables:
```bash
export CLNDASH_ID="03f3c108ccd536b8526841f0a5c58212bb9e6584a1eb493080e7c1cc34f82dad71"
export CLNDASH_HOST="node.example.com:9735"
export CLNDASH_RUNE="your_rune_here"
```
3. Run clndash inside notedeck by calling notedeck with the `--clndash` argument.
4. Bask in the glow of real-time LN data over an LN connection.
---
## ⚠️ Disclaimer
* Dont give it a rune that can spend your funds.
* Dont blame me if you break something — this is experimental territory.
* If it connects on the first try, buy yourself a beer.
---
If you like living on the edge of LN/Nostr tooling, youll like this.
If you dont… youll probably want to wait a bit.
[commando]: https://docs.corelightning.org/reference/commando
[lnsocket]: https://github.com/jb55/lnsocket-rs
[notedeck]: https://github.com/damus-io/notedeck
+124
View File
@@ -0,0 +1,124 @@
use crate::event::LoadingState;
use crate::ui;
use egui::Color32;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
pub struct ListPeerChannel {
pub short_channel_id: String,
pub our_reserve_msat: i64,
pub to_us_msat: i64,
pub total_msat: i64,
pub their_reserve_msat: i64,
}
pub struct Channel {
pub to_us: i64,
pub to_them: i64,
pub original: ListPeerChannel,
}
pub struct Channels {
pub max_total_msat: i64,
pub avail_in: i64,
pub avail_out: i64,
pub channels: Vec<Channel>,
}
pub fn channels_ui(ui: &mut egui::Ui, channels: &LoadingState<Channels, lnsocket::Error>) {
match channels {
LoadingState::Loaded(channels) => {
if channels.channels.is_empty() {
ui.label("no channels yet...");
return;
}
for channel in &channels.channels {
channel_ui(ui, channel, channels.max_total_msat);
}
ui.label(format!(
"available out {}",
ui::human_sat(channels.avail_out)
));
ui.label(format!("available in {}", ui::human_sat(channels.avail_in)));
}
LoadingState::Failed(err) => {
ui.label(format!("error fetching channels: {err}"));
}
LoadingState::Loading => {
ui.label("fetching channels...");
}
}
}
pub fn channel_ui(ui: &mut egui::Ui, c: &Channel, max_total_msat: i64) {
// ---------- numbers ----------
let short_channel_id = &c.original.short_channel_id;
let cap_ratio = (c.original.total_msat as f32 / max_total_msat.max(1) as f32).clamp(0.0, 1.0);
// Feel free to switch to log scaling if you have whales:
//let cap_ratio = ((c.original.total_msat as f32 + 1.0).log10() / (max_total_msat as f32 + 1.0).log10()).clamp(0.0, 1.0);
// ---------- colors & style ----------
let out_color = Color32::from_rgb(84, 69, 201); // blue
let in_color = Color32::from_rgb(158, 56, 180); // purple
// Thickness scales with capacity, but keeps a nice minimum
let thickness = 10.0 + cap_ratio * 22.0; // 10 → 32 px
let row_h = thickness + 14.0;
// ---------- layout ----------
let (rect, response) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), row_h),
egui::Sense::hover(),
);
let painter = ui.painter_at(rect);
let bar_rect = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.center().y - thickness * 0.5),
egui::pos2(rect.right(), rect.center().y + thickness * 0.5),
);
let corner_radius = (thickness * 0.5) as u8;
let out_radius = egui::CornerRadius {
ne: 0,
nw: corner_radius,
sw: corner_radius,
se: 0,
};
let in_radius = egui::CornerRadius {
ne: corner_radius,
nw: 0,
sw: 0,
se: corner_radius,
};
/*
painter.rect_filled(bar_rect, rounding, track_color);
painter.rect_stroke(bar_rect, rounding, track_stroke, egui::StrokeKind::Middle);
*/
// Split widths
let usable = (c.to_us + c.to_them).max(1) as f32;
let out_w = (bar_rect.width() * (c.to_us as f32 / usable)).round();
let split_x = bar_rect.left() + out_w;
// Outbound fill (left)
let out_rect = egui::Rect::from_min_max(bar_rect.min, egui::pos2(split_x, bar_rect.max.y));
if out_rect.width() > 0.5 {
painter.rect_filled(out_rect, out_radius, out_color);
}
// Inbound fill (right)
let in_rect = egui::Rect::from_min_max(egui::pos2(split_x, bar_rect.min.y), bar_rect.max);
if in_rect.width() > 0.5 {
painter.rect_filled(in_rect, in_radius, in_color);
}
// Tooltip
response.on_hover_text_at_pointer(format!(
"Channel ID {short_channel_id}\nOutbound (ours): {} sats\nInbound (theirs): {} sats\nCapacity: {} sats",
ui::human_sat(c.to_us),
ui::human_sat(c.to_them),
ui::human_sat(c.original.total_msat),
));
}
+80
View File
@@ -0,0 +1,80 @@
use crate::channels::Channels;
use crate::invoice::Invoice;
use serde::Serialize;
use serde_json::Value;
pub enum ConnectionState {
Dead(String),
Connecting,
Active,
}
pub enum LoadingState<T, E> {
Loading,
Failed(E),
Loaded(T),
}
impl<T, E> Default for LoadingState<T, E> {
fn default() -> Self {
Self::Loading
}
}
impl<T, E> LoadingState<T, E> {
fn _as_ref(&self) -> LoadingState<&T, &E> {
match self {
Self::Loading => LoadingState::<&T, &E>::Loading,
Self::Failed(err) => LoadingState::<&T, &E>::Failed(err),
Self::Loaded(t) => LoadingState::<&T, &E>::Loaded(t),
}
}
pub fn from_result(res: Result<T, E>) -> LoadingState<T, E> {
match res {
Ok(r) => LoadingState::Loaded(r),
Err(err) => LoadingState::Failed(err),
}
}
/*
fn unwrap(self) -> T {
let Self::Loaded(t) = self else {
panic!("unwrap in LoadingState");
};
t
}
*/
}
#[derive(Serialize, Debug, Clone)]
pub struct _WaitRequest {
pub indexname: String,
pub subsystem: String,
pub nextvalue: u64,
}
#[derive(Clone, Debug)]
pub enum Request {
GetInfo,
ListPeerChannels,
PaidInvoices(u32),
}
/// Responses from the socket
pub enum ClnResponse {
GetInfo(Value),
ListPeerChannels(Result<Channels, lnsocket::Error>),
PaidInvoices(Result<Vec<Invoice>, lnsocket::Error>),
}
pub enum Event {
/// We lost the socket somehow
Ended {
reason: String,
},
Connected,
Response(ClnResponse),
}
+77
View File
@@ -0,0 +1,77 @@
use crate::event::LoadingState;
use crate::ui;
use lightning_invoice::Bolt11Invoice;
use notedeck::AppContext;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Deserialize, Serialize)]
pub struct Invoice {
pub lastpay_index: Option<u64>,
pub label: String,
pub bolt11: Bolt11Invoice,
pub payment_hash: String,
pub amount_msat: u64,
pub status: String,
pub description: String,
pub expires_at: u64,
pub created_index: u64,
pub updated_index: u64,
}
pub fn invoices_ui(
ui: &mut egui::Ui,
invoice_notes: &HashMap<String, [u8; 32]>,
ctx: &mut AppContext,
invoices: &LoadingState<Vec<Invoice>, lnsocket::Error>,
) {
match invoices {
LoadingState::Loading => {
ui.label("loading invoices...");
}
LoadingState::Failed(err) => {
ui.label(format!("failed to load invoices: {err}"));
}
LoadingState::Loaded(invoices) => {
use egui_extras::{Column, TableBuilder};
TableBuilder::new(ui)
.column(Column::auto().resizable(true))
.column(Column::remainder())
.vscroll(false)
.header(20.0, |mut header| {
header.col(|ui| {
ui.strong("description");
});
header.col(|ui| {
ui.strong("amount");
});
})
.body(|mut body| {
for invoice in invoices {
body.row(20.0, |mut row| {
row.col(|ui| {
if invoice.description.starts_with("{") {
ui.label("Zap!").on_hover_ui_at_pointer(|ui| {
ui::note_hover_ui(ui, &invoice.label, ctx, invoice_notes);
});
} else {
ui.label(&invoice.description);
}
});
row.col(|ui| match invoice.bolt11.amount_milli_satoshis() {
None => {
ui.label("any");
}
Some(amt) => {
ui.label(ui::human_verbose_sat(amt as i64));
}
});
});
}
});
}
}
}
+134 -370
View File
@@ -1,33 +1,50 @@
use egui::{Color32, Label, RichText};
use crate::channels::Channel;
use crate::channels::Channels;
use crate::channels::ListPeerChannel;
use crate::event::ClnResponse;
use crate::event::ConnectionState;
use crate::event::Event;
use crate::event::LoadingState;
use crate::event::Request;
use crate::invoice::Invoice;
use crate::summary::Summary;
use crate::watch::fetch_paid_invoices;
use lnsocket::bitcoin::secp256k1::{PublicKey, SecretKey, rand};
use lnsocket::{CommandoClient, LNSocket};
use nostrdb::Ndb;
use notedeck::{AppAction, AppContext};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::json;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
struct Channel {
to_us: i64,
to_them: i64,
original: ListPeerChannel,
}
struct Channels {
max_total_msat: i64,
avail_in: i64,
avail_out: i64,
channels: Vec<Channel>,
}
mod channels;
mod event;
mod invoice;
mod summary;
mod ui;
mod watch;
#[derive(Default)]
pub struct ClnDash {
initialized: bool,
connection_state: ConnectionState,
get_info: Option<String>,
channels: Option<Result<Channels, lnsocket::Error>>,
summary: LoadingState<Summary, lnsocket::Error>,
get_info: LoadingState<String, lnsocket::Error>,
channels: LoadingState<Channels, lnsocket::Error>,
invoices: LoadingState<Vec<Invoice>, lnsocket::Error>,
channel: Option<CommChannel>,
last_summary: Option<Summary>,
// invoice label to zapreq id
invoice_zap_reqs: HashMap<String, [u8; 32]>,
}
#[derive(serde::Deserialize)]
pub struct ZapReqId {
#[serde(with = "hex::serde")]
id: [u8; 32],
}
impl Default for ConnectionState {
@@ -41,46 +58,8 @@ struct CommChannel {
event_rx: UnboundedReceiver<Event>,
}
/// Responses from the socket
enum ClnResponse {
GetInfo(Value),
ListPeerChannels(Result<Channels, lnsocket::Error>),
}
#[derive(Deserialize, Serialize)]
struct ListPeerChannel {
short_channel_id: String,
our_reserve_msat: i64,
to_us_msat: i64,
total_msat: i64,
their_reserve_msat: i64,
}
enum ConnectionState {
Dead(String),
Connecting,
Active,
}
#[derive(Eq, PartialEq, Clone, Debug)]
enum Request {
GetInfo,
ListPeerChannels,
}
enum Event {
/// We lost the socket somehow
Ended {
reason: String,
},
Connected,
Response(ClnResponse),
}
impl notedeck::App for ClnDash {
fn update(&mut self, _ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
if !self.initialized {
self.connection_state = ConnectionState::Connecting;
@@ -88,51 +67,25 @@ impl notedeck::App for ClnDash {
self.initialized = true;
}
self.process_events();
self.process_events(ctx.ndb);
self.show(ui);
self.show(ui, ctx);
None
}
}
fn connection_state_ui(ui: &mut egui::Ui, state: &ConnectionState) {
match state {
ConnectionState::Active => {
ui.add(Label::new(RichText::new("Connected").color(Color32::GREEN)));
}
ConnectionState::Connecting => {
ui.add(Label::new(
RichText::new("Connecting").color(Color32::YELLOW),
));
}
ConnectionState::Dead(reason) => {
ui.add(Label::new(
RichText::new(format!("Disconnected: {reason}")).color(Color32::RED),
));
}
}
}
impl ClnDash {
fn show(&mut self, ui: &mut egui::Ui) {
fn show(&mut self, ui: &mut egui::Ui, ctx: &mut AppContext) {
egui::Frame::new()
.inner_margin(egui::Margin::same(20))
.show(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
connection_state_ui(ui, &self.connection_state);
if let Some(Ok(ch)) = self.channels.as_ref() {
let summary = compute_summary(ch);
summary_cards_ui(ui, &summary, self.last_summary.as_ref());
ui.add_space(8.0);
}
channels_ui(ui, &self.channels);
if let Some(info) = self.get_info.as_ref() {
get_info_ui(ui, info);
}
ui::connection_state_ui(ui, &self.connection_state);
crate::summary::summary_ui(ui, self.last_summary.as_ref(), &self.summary);
crate::invoice::invoices_ui(ui, &self.invoice_zap_reqs, ctx, &self.invoices);
crate::channels::channels_ui(ui, &self.channels);
crate::ui::get_info_ui(ui, &self.get_info);
});
});
}
@@ -144,30 +97,30 @@ impl ClnDash {
tokio::spawn(async move {
let key = SecretKey::new(&mut rand::thread_rng());
let their_pubkey = PublicKey::from_str(
"03f3c108ccd536b8526841f0a5c58212bb9e6584a1eb493080e7c1cc34f82dad71",
)
let their_pubkey = PublicKey::from_str(&std::env::var("CLNDASH_ID").unwrap_or(
"03f3c108ccd536b8526841f0a5c58212bb9e6584a1eb493080e7c1cc34f82dad71".to_string(),
))
.unwrap();
let lnsocket =
match LNSocket::connect_and_init(key, their_pubkey, "ln.damus.io:9735").await {
Err(err) => {
let _ = event_tx.send(Event::Ended {
reason: err.to_string(),
});
return;
}
let host = std::env::var("CLNDASH_HOST").unwrap_or("ln.damus.io:9735".to_string());
let lnsocket = match LNSocket::connect_and_init(key, their_pubkey, &host).await {
Err(err) => {
let _ = event_tx.send(Event::Ended {
reason: err.to_string(),
});
return;
}
Ok(lnsocket) => {
let _ = event_tx.send(Event::Connected);
lnsocket
}
};
Ok(lnsocket) => {
let _ = event_tx.send(Event::Connected);
lnsocket
}
};
let rune = std::env::var("RUNE").unwrap_or(
let rune = std::env::var("CLNDASH_RUNE").unwrap_or(
"Vns1Zxvidr4J8pP2ZCg3Wjp2SyGyyf5RHgvFG8L36yM9MzMmbWV0aG9kPWdldGluZm8=".to_string(),
);
let commando = CommandoClient::spawn(lnsocket, &rune);
let commando = Arc::new(CommandoClient::spawn(lnsocket, &rune));
loop {
match req_rx.recv().await {
@@ -181,25 +134,47 @@ impl ClnDash {
Some(req) => {
tracing::debug!("calling {req:?}");
match req {
Request::GetInfo => match commando.call("getinfo", json!({})).await {
Ok(v) => {
let _ = event_tx.send(Event::Response(ClnResponse::GetInfo(v)));
}
Err(err) => {
tracing::error!("get_info error {}", err);
}
},
Request::GetInfo => {
let event_tx = event_tx.clone();
let commando = commando.clone();
tokio::spawn(async move {
match commando.call("getinfo", json!({})).await {
Ok(v) => {
let _ = event_tx
.send(Event::Response(ClnResponse::GetInfo(v)));
}
Err(err) => {
tracing::error!("get_info error {}", err);
}
}
});
}
Request::PaidInvoices(n) => {
let event_tx = event_tx.clone();
let commando = commando.clone();
tokio::spawn(async move {
let invoices = fetch_paid_invoices(commando, n).await;
let _ = event_tx
.send(Event::Response(ClnResponse::PaidInvoices(invoices)));
});
}
Request::ListPeerChannels => {
let peer_channels =
commando.call("listpeerchannels", json!({})).await;
let channels = peer_channels.map(|v| {
let peer_channels: Vec<ListPeerChannel> =
serde_json::from_value(v["channels"].clone()).unwrap();
to_channels(peer_channels)
let event_tx = event_tx.clone();
let commando = commando.clone();
tokio::spawn(async move {
let peer_channels =
commando.call("listpeerchannels", json!({})).await;
let channels = peer_channels.map(|v| {
let peer_channels: Vec<ListPeerChannel> =
serde_json::from_value(v["channels"].clone()).unwrap();
to_channels(peer_channels)
});
let _ = event_tx.send(Event::Response(
ClnResponse::ListPeerChannels(channels),
));
});
let _ = event_tx
.send(Event::Response(ClnResponse::ListPeerChannels(channels)));
}
}
}
@@ -208,7 +183,7 @@ impl ClnDash {
});
}
fn process_events(&mut self) {
fn process_events(&mut self, ndb: &Ndb) {
let Some(channel) = &mut self.channel else {
return;
};
@@ -223,20 +198,49 @@ impl ClnDash {
self.connection_state = ConnectionState::Active;
let _ = channel.req_tx.send(Request::GetInfo);
let _ = channel.req_tx.send(Request::ListPeerChannels);
let _ = channel.req_tx.send(Request::PaidInvoices(100));
}
Event::Response(resp) => match resp {
ClnResponse::ListPeerChannels(chans) => {
if let Some(Ok(prev)) = self.channels.as_ref() {
self.last_summary = Some(compute_summary(prev));
if let LoadingState::Loaded(prev) = &self.channels {
self.last_summary = Some(crate::summary::compute_summary(prev));
}
self.channels = Some(chans);
self.summary = match &chans {
Ok(chans) => {
LoadingState::Loaded(crate::summary::compute_summary(chans))
}
Err(err) => LoadingState::Failed(err.clone()),
};
self.channels = LoadingState::from_result(chans);
}
ClnResponse::GetInfo(value) => {
if let Ok(s) = serde_json::to_string_pretty(&value) {
self.get_info = Some(s);
let res = serde_json::to_string_pretty(&value);
self.get_info =
LoadingState::from_result(res.map_err(|_| lnsocket::Error::Json));
}
ClnResponse::PaidInvoices(invoices) => {
// process zap requests
if let Ok(invoices) = &invoices {
for invoice in invoices {
let zap_req_id: Option<ZapReqId> =
serde_json::from_str(&invoice.description).ok();
if let Some(zap_req_id) = zap_req_id {
self.invoice_zap_reqs
.insert(invoice.label.clone(), zap_req_id.id);
let _ = ndb.process_event(&format!(
"[\"EVENT\",\"a\",{}]",
&invoice.description
));
}
}
}
self.invoices = LoadingState::from_result(invoices);
}
},
}
@@ -244,119 +248,6 @@ impl ClnDash {
}
}
fn get_info_ui(ui: &mut egui::Ui, info: &str) {
ui.horizontal_wrapped(|ui| {
ui.add(Label::new(info).wrap_mode(egui::TextWrapMode::Wrap));
});
}
fn channel_ui(ui: &mut egui::Ui, c: &Channel, max_total_msat: i64) {
// ---------- numbers ----------
let short_channel_id = &c.original.short_channel_id;
let cap_ratio = (c.original.total_msat as f32 / max_total_msat.max(1) as f32).clamp(0.0, 1.0);
// Feel free to switch to log scaling if you have whales:
//let cap_ratio = ((c.original.total_msat as f32 + 1.0).log10() / (max_total_msat as f32 + 1.0).log10()).clamp(0.0, 1.0);
// ---------- colors & style ----------
let out_color = Color32::from_rgb(84, 69, 201); // blue
let in_color = Color32::from_rgb(158, 56, 180); // purple
// Thickness scales with capacity, but keeps a nice minimum
let thickness = 10.0 + cap_ratio * 22.0; // 10 → 32 px
let row_h = thickness + 14.0;
// ---------- layout ----------
let (rect, response) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), row_h),
egui::Sense::hover(),
);
let painter = ui.painter_at(rect);
let bar_rect = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.center().y - thickness * 0.5),
egui::pos2(rect.right(), rect.center().y + thickness * 0.5),
);
let corner_radius = (thickness * 0.5) as u8;
let out_radius = egui::CornerRadius {
ne: 0,
nw: corner_radius,
sw: corner_radius,
se: 0,
};
let in_radius = egui::CornerRadius {
ne: corner_radius,
nw: 0,
sw: 0,
se: corner_radius,
};
/*
painter.rect_filled(bar_rect, rounding, track_color);
painter.rect_stroke(bar_rect, rounding, track_stroke, egui::StrokeKind::Middle);
*/
// Split widths
let usable = (c.to_us + c.to_them).max(1) as f32;
let out_w = (bar_rect.width() * (c.to_us as f32 / usable)).round();
let split_x = bar_rect.left() + out_w;
// Outbound fill (left)
let out_rect = egui::Rect::from_min_max(bar_rect.min, egui::pos2(split_x, bar_rect.max.y));
if out_rect.width() > 0.5 {
painter.rect_filled(out_rect, out_radius, out_color);
}
// Inbound fill (right)
let in_rect = egui::Rect::from_min_max(egui::pos2(split_x, bar_rect.min.y), bar_rect.max);
if in_rect.width() > 0.5 {
painter.rect_filled(in_rect, in_radius, in_color);
}
// Tooltip
response.on_hover_text_at_pointer(format!(
"Channel ID {short_channel_id}\nOutbound (ours): {} sats\nInbound (theirs): {} sats\nCapacity: {} sats",
human_sat(c.to_us),
human_sat(c.to_them),
human_sat(c.original.total_msat),
));
}
// ---------- helper ----------
fn human_sat(msat: i64) -> String {
let sats = msat / 1000;
if sats >= 1_000_000 {
format!("{:.1}M", sats as f64 / 1_000_000.0)
} else if sats >= 1_000 {
format!("{:.1}k", sats as f64 / 1_000.0)
} else {
sats.to_string()
}
}
fn channels_ui(ui: &mut egui::Ui, channels: &Option<Result<Channels, lnsocket::Error>>) {
match channels {
Some(Ok(channels)) => {
if channels.channels.is_empty() {
ui.label("no channels yet...");
return;
}
for channel in &channels.channels {
channel_ui(ui, channel, channels.max_total_msat);
}
ui.label(format!("available out {}", human_sat(channels.avail_out)));
ui.label(format!("available in {}", human_sat(channels.avail_in)));
}
Some(Err(err)) => {
ui.label(format!("error fetching channels: {err}"));
}
None => {
ui.label("no channels yet...");
}
}
}
fn to_channels(peer_channels: Vec<ListPeerChannel>) -> Channels {
let mut avail_out: i64 = 0;
let mut avail_in: i64 = 0;
@@ -397,130 +288,3 @@ fn to_channels(peer_channels: Vec<ListPeerChannel>) -> Channels {
channels,
}
}
fn summary_cards_ui(ui: &mut egui::Ui, s: &Summary, prev: Option<&Summary>) {
let old = prev.cloned().unwrap_or_default();
let items: [(&str, String, Option<String>); 6] = [
(
"Total capacity",
human_sat(s.total_msat),
prev.map(|_| delta_str(s.total_msat, old.total_msat)),
),
(
"Avail out",
human_sat(s.avail_out_msat),
prev.map(|_| delta_str(s.avail_out_msat, old.avail_out_msat)),
),
(
"Avail in",
human_sat(s.avail_in_msat),
prev.map(|_| delta_str(s.avail_in_msat, old.avail_in_msat)),
),
("# Channels", s.channel_count.to_string(), None),
("Largest", human_sat(s.largest_msat), None),
(
"Outbound %",
format!("{:.0}%", s.outbound_pct * 100.0),
None,
),
];
// --- responsive columns ---
let min_card = 160.0;
let cols = ((ui.available_width() / min_card).floor() as usize).max(1);
egui::Grid::new("summary_grid")
.num_columns(cols)
.min_col_width(min_card)
.spacing(egui::vec2(8.0, 8.0))
.show(ui, |ui| {
let items_len = items.len();
for (i, (t, v, d)) in items.into_iter().enumerate() {
card_cell(ui, t, v, d, min_card);
// End the row when we filled a row worth of cells
if (i + 1) % cols == 0 {
ui.end_row();
}
}
// If the last row wasn't full, close it anyway
if items_len % cols != 0 {
ui.end_row();
}
});
}
fn card_cell(ui: &mut egui::Ui, title: &str, value: String, delta: Option<String>, min_card: f32) {
let weak = ui.visuals().weak_text_color();
egui::Frame::group(ui.style())
.fill(ui.visuals().extreme_bg_color)
.corner_radius(egui::CornerRadius::same(10))
.inner_margin(egui::Margin::same(10))
.stroke(ui.visuals().widgets.noninteractive.bg_stroke)
.show(ui, |ui| {
ui.set_min_width(min_card);
ui.vertical(|ui| {
ui.add(
egui::Label::new(egui::RichText::new(title).small().color(weak))
.wrap_mode(egui::TextWrapMode::Wrap),
);
ui.add_space(4.0);
ui.add(
egui::Label::new(egui::RichText::new(value).strong().size(18.0))
.wrap_mode(egui::TextWrapMode::Wrap),
);
if let Some(d) = delta {
ui.add_space(2.0);
ui.add(
egui::Label::new(egui::RichText::new(d).small().color(weak))
.wrap_mode(egui::TextWrapMode::Wrap),
);
}
});
ui.set_min_height(20.0);
});
}
#[derive(Clone, Default)]
struct Summary {
total_msat: i64,
avail_out_msat: i64,
avail_in_msat: i64,
channel_count: usize,
largest_msat: i64,
outbound_pct: f32, // fraction of total capacity
}
fn compute_summary(ch: &Channels) -> Summary {
let total_msat: i64 = ch.channels.iter().map(|c| c.original.total_msat).sum();
let largest_msat: i64 = ch
.channels
.iter()
.map(|c| c.original.total_msat)
.max()
.unwrap_or(0);
let outbound_pct = if total_msat > 0 {
ch.avail_out as f32 / total_msat as f32
} else {
0.0
};
Summary {
total_msat,
avail_out_msat: ch.avail_out,
avail_in_msat: ch.avail_in,
channel_count: ch.channels.len(),
largest_msat,
outbound_pct,
}
}
fn delta_str(new: i64, old: i64) -> String {
let d = new - old;
match d.cmp(&0) {
std::cmp::Ordering::Greater => format!("{}", human_sat(d)),
std::cmp::Ordering::Less => format!("{}", human_sat(-d)),
std::cmp::Ordering::Equal => "·".into(),
}
}
+140
View File
@@ -0,0 +1,140 @@
use crate::channels::Channels;
use crate::event::LoadingState;
use crate::ui;
#[derive(Clone, Default)]
pub struct Summary {
pub total_msat: i64,
pub avail_out_msat: i64,
pub avail_in_msat: i64,
pub channel_count: usize,
pub largest_msat: i64,
pub outbound_pct: f32, // fraction of total capacity
}
pub fn compute_summary(ch: &Channels) -> Summary {
let total_msat: i64 = ch.channels.iter().map(|c| c.original.total_msat).sum();
let largest_msat: i64 = ch
.channels
.iter()
.map(|c| c.original.total_msat)
.max()
.unwrap_or(0);
let outbound_pct = if total_msat > 0 {
ch.avail_out as f32 / total_msat as f32
} else {
0.0
};
Summary {
total_msat,
avail_out_msat: ch.avail_out,
avail_in_msat: ch.avail_in,
channel_count: ch.channels.len(),
largest_msat,
outbound_pct,
}
}
pub fn summary_ui(
ui: &mut egui::Ui,
last_summary: Option<&Summary>,
summary: &LoadingState<Summary, lnsocket::Error>,
) {
match summary {
LoadingState::Loading => {
ui.label("loading summary");
}
LoadingState::Failed(err) => {
ui.label(format!("Failed to get summary: {err}"));
}
LoadingState::Loaded(summary) => {
summary_cards_ui(ui, summary, last_summary);
ui.add_space(8.0);
}
}
}
pub fn summary_cards_ui(ui: &mut egui::Ui, s: &Summary, prev: Option<&Summary>) {
let old = prev.cloned().unwrap_or_default();
let items: [(&str, String, Option<String>); 6] = [
(
"Total capacity",
ui::human_sat(s.total_msat),
prev.map(|_| ui::delta_str(s.total_msat, old.total_msat)),
),
(
"Avail out",
ui::human_sat(s.avail_out_msat),
prev.map(|_| ui::delta_str(s.avail_out_msat, old.avail_out_msat)),
),
(
"Avail in",
ui::human_sat(s.avail_in_msat),
prev.map(|_| ui::delta_str(s.avail_in_msat, old.avail_in_msat)),
),
("# Channels", s.channel_count.to_string(), None),
("Largest", ui::human_sat(s.largest_msat), None),
(
"Outbound %",
format!("{:.0}%", s.outbound_pct * 100.0),
None,
),
];
// --- responsive columns ---
let min_card = 160.0;
let cols = ((ui.available_width() / min_card).floor() as usize).max(1);
egui::Grid::new("summary_grid")
.num_columns(cols)
.min_col_width(min_card)
.spacing(egui::vec2(8.0, 8.0))
.show(ui, |ui| {
let items_len = items.len();
for (i, (t, v, d)) in items.into_iter().enumerate() {
card_cell(ui, t, v, d, min_card);
// End the row when we filled a row worth of cells
if (i + 1) % cols == 0 {
ui.end_row();
}
}
// If the last row wasn't full, close it anyway
if items_len % cols != 0 {
ui.end_row();
}
});
}
fn card_cell(ui: &mut egui::Ui, title: &str, value: String, delta: Option<String>, min_card: f32) {
let weak = ui.visuals().weak_text_color();
egui::Frame::group(ui.style())
.fill(ui.visuals().extreme_bg_color)
.corner_radius(egui::CornerRadius::same(10))
.inner_margin(egui::Margin::same(10))
.stroke(ui.visuals().widgets.noninteractive.bg_stroke)
.show(ui, |ui| {
ui.set_min_width(min_card);
ui.vertical(|ui| {
ui.add(
egui::Label::new(egui::RichText::new(title).small().color(weak))
.wrap_mode(egui::TextWrapMode::Wrap),
);
ui.add_space(4.0);
ui.add(
egui::Label::new(egui::RichText::new(value).strong().size(18.0))
.wrap_mode(egui::TextWrapMode::Wrap),
);
if let Some(d) = delta {
ui.add_space(2.0);
ui.add(
egui::Label::new(egui::RichText::new(d).small().color(weak))
.wrap_mode(egui::TextWrapMode::Wrap),
);
}
});
ui.set_min_height(20.0);
});
}
+145
View File
@@ -0,0 +1,145 @@
use crate::event::ConnectionState;
use crate::event::LoadingState;
use egui::Color32;
use egui::Label;
use egui::RichText;
use egui::Widget;
use notedeck::AppContext;
use std::collections::HashMap;
pub fn note_hover_ui(
ui: &mut egui::Ui,
label: &str,
ctx: &mut AppContext,
invoice_notes: &HashMap<String, [u8; 32]>,
) -> Option<notedeck::NoteAction> {
let zap_req_id = invoice_notes.get(label)?;
let Ok(txn) = nostrdb::Transaction::new(ctx.ndb) else {
return None;
};
let Ok(zapreq_note) = ctx.ndb.get_note_by_id(&txn, zap_req_id) else {
return None;
};
for tag in zapreq_note.tags() {
let Some("e") = tag.get_str(0) else {
continue;
};
let Some(target_id) = tag.get_id(1) else {
continue;
};
let Ok(note) = ctx.ndb.get_note_by_id(&txn, target_id) else {
return None;
};
let author = ctx
.ndb
.get_profile_by_pubkey(&txn, zapreq_note.pubkey())
.ok();
// TODO(jb55): make this less horrible
let mut note_context = notedeck::NoteContext {
ndb: ctx.ndb,
accounts: ctx.accounts,
img_cache: ctx.img_cache,
note_cache: ctx.note_cache,
zaps: ctx.zaps,
pool: ctx.pool,
job_pool: ctx.job_pool,
unknown_ids: ctx.unknown_ids,
clipboard: ctx.clipboard,
i18n: ctx.i18n,
global_wallet: ctx.global_wallet,
};
let mut jobs = notedeck::JobsCache::default();
let options = notedeck_ui::NoteOptions::default();
notedeck_ui::ProfilePic::from_profile_or_default(note_context.img_cache, author.as_ref())
.ui(ui);
let nostr_name = notedeck::name::get_display_name(author.as_ref());
ui.label(format!("{} zapped you", nostr_name.name()));
return notedeck_ui::NoteView::new(&mut note_context, &note, options, &mut jobs)
.preview_style()
.hide_media(true)
.show(ui)
.action;
}
None
}
pub fn get_info_ui(ui: &mut egui::Ui, info: &LoadingState<String, lnsocket::Error>) {
ui.horizontal_wrapped(|ui| match info {
LoadingState::Loading => {}
LoadingState::Failed(err) => {
ui.label(format!("failed to fetch node info: {err}"));
}
LoadingState::Loaded(info) => {
ui.add(Label::new(info).wrap_mode(egui::TextWrapMode::Wrap));
}
});
}
pub fn connection_state_ui(ui: &mut egui::Ui, state: &ConnectionState) {
match state {
ConnectionState::Active => {
ui.add(Label::new(RichText::new("Connected").color(Color32::GREEN)));
}
ConnectionState::Connecting => {
ui.add(Label::new(
RichText::new("Connecting").color(Color32::YELLOW),
));
}
ConnectionState::Dead(reason) => {
ui.add(Label::new(
RichText::new(format!("Disconnected: {reason}")).color(Color32::RED),
));
}
}
}
// ---------- helper ----------
pub fn human_sat(msat: i64) -> String {
let sats = msat / 1000;
if sats >= 1_000_000 {
format!("{:.1}M", sats as f64 / 1_000_000.0)
} else if sats >= 1_000 {
format!("{:.1}k", sats as f64 / 1_000.0)
} else {
sats.to_string()
}
}
pub fn human_verbose_sat(msat: i64) -> String {
if msat < 1_000 {
// less than 1 sat
format!("{msat} msat")
} else {
let sats = msat / 1_000;
if sats < 100_000_000 {
// less than 1 BTC
format!("{sats} sat")
} else {
let btc = sats / 100_000_000;
format!("{btc} BTC")
}
}
}
pub fn delta_str(new: i64, old: i64) -> String {
let d = new - old;
match d.cmp(&0) {
std::cmp::Ordering::Greater => format!("{}", human_sat(d)),
std::cmp::Ordering::Less => format!("{}", human_sat(-d)),
std::cmp::Ordering::Equal => "·".into(),
}
}
+198
View File
@@ -0,0 +1,198 @@
use crate::invoice::Invoice;
use lnsocket::CallOpts;
use lnsocket::CommandoClient;
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
#[derive(Deserialize)]
struct UpdatedInvoicesResponse {
updated: u64,
}
#[derive(Deserialize)]
struct PayIndexInvoices {
invoices: Vec<PayIndexScan>,
}
#[derive(Deserialize)]
struct PayIndexScan {
pay_index: Option<u64>,
}
async fn find_lastpay_index(commando: Arc<CommandoClient>) -> Result<Option<u64>, lnsocket::Error> {
const PAGE: u64 = 250;
// 1) get the current updated tail
let created_value = commando
.call(
"wait",
json!({"subsystem":"invoices","indexname":"updated","nextvalue":0}),
)
.await?;
let response: UpdatedInvoicesResponse =
serde_json::from_value(created_value).map_err(|_| lnsocket::Error::Json)?;
// start our window at the tail
let mut start_at = response
.updated
.saturating_add(1) // +1 because we want max(1, updated - PAGE + 1)
.saturating_sub(PAGE)
.max(1);
loop {
// 2) fetch a window (indexed by "updated")
let val = commando
.call_with_opts(
"listinvoices",
json!({
"index": "updated",
"start": start_at,
"limit": PAGE,
}),
// only fetch the one field we care about
CallOpts::default().filter(json!({
"invoices": [{"pay_index": true}]
})),
)
.await?;
let parsed: PayIndexInvoices =
serde_json::from_value(val).map_err(|_| lnsocket::Error::Json)?;
if let Some(pi) = parsed.invoices.iter().filter_map(|inv| inv.pay_index).max() {
return Ok(Some(pi));
}
// 4) no paid invoice in this slice—step back or bail
if start_at == 1 {
return Ok(None);
}
start_at = start_at.saturating_sub(PAGE).max(1);
}
}
pub async fn fetch_paid_invoices(
commando: Arc<CommandoClient>,
limit: u32,
) -> Result<Vec<Invoice>, lnsocket::Error> {
use tokio::task::JoinSet;
// look for an invoice with the last paid index
let Some(lastpay_index) = find_lastpay_index(commando.clone()).await? else {
// no paid invoices
return Ok(vec![]);
};
let mut set: JoinSet<Result<Invoice, lnsocket::Error>> = JoinSet::new();
let start = lastpay_index.saturating_sub(limit as u64);
// 3) Fire off at most `concurrency` `waitanyinvoice` calls at a time,
// collect all successful responses into a Vec.
// fire them ALL at once
for idx in start..lastpay_index {
let c = commando.clone();
set.spawn(async move {
let val = c
.call(
"waitanyinvoice",
serde_json::json!({ "lastpay_index": idx }),
)
.await?;
let parsed: Invoice = serde_json::from_value(val).map_err(|_| lnsocket::Error::Json)?;
Ok(parsed)
});
}
let mut results = Vec::with_capacity(limit as usize);
while let Some(res) = set.join_next().await {
results.push(res.map_err(|_| lnsocket::Error::Io(std::io::ErrorKind::Interrupted))??);
}
results.sort_by(|a, b| a.updated_index.cmp(&b.updated_index).reverse());
Ok(results)
}
// wip watch subsystem
/*
async fn watch_subsystem(
commando: CommandoClient,
subsystem: WaitSubsystem,
index: WaitIndex,
event_tx: UnboundedSender<Event>,
mut cancel_rx: Receiver<()>,
) {
// Step 1: Fetch current index value so we can back up ~20
let mut nextvalue: u64 = match commando
.call(
"wait",
serde_json::json!({
"indexname": index.as_str(),
"subsystem": subsystem.as_str(),
"nextvalue": 0
}),
)
.await
{
Ok(v) => {
// You showed the result has `updated` as the current highest index
let current = v.get("updated").and_then(|x| x.as_u64()).unwrap_or(0);
current.saturating_sub(20) // back up 20, clamp at 0
}
Err(err) => {
tracing::warn!("initial wait(…nextvalue=0) failed: {}", err);
0
}
};
loop {
// You can add a timeout to avoid hanging forever in weird network states.
let fut = commando.call(
"wait",
serde_json::to_value(WaitRequest {
indexname: "invoices".into(),
subsystem: "lightningd".into(),
nextvalue,
})
.unwrap(),
);
tokio::select! {
_ = &mut cancel_rx => {
// graceful shutdown
break;
}
res = fut => {
match res {
Ok(v) => {
// Typical shape: { "nextvalue": n, "invoicestatus": { ... } } (varies by plugin/index)
// Adjust these lookups for your nodes actual wait payload.
if let Some(nv) = v.get("nextvalue").and_then(|x| x.as_u64()) {
nextvalue = nv + 1;
} else {
// Defensive: never get stuck — bump at least by 1
nextvalue += 1;
}
// Inspect/route
let kind = v.get("status").and_then(|s| s.as_str());
let ev = match kind {
Some("paid") => ClnResponse::Invoice(InvoiceEvent::Paid(v.clone())),
Some("created") => ClnResponse::Invoice(InvoiceEvent::Created(v.clone())),
_ => ClnResponse::Invoice(InvoiceEvent::Other(v.clone())),
};
let _ = event_tx.send(Event::Response(ev));
}
Err(err) => {
tracing::warn!("wait(invoices) error: {err}");
// small backoff so we don't tight-loop on persistent errors
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
}
}
}
}
*/
+4
View File
@@ -10,6 +10,10 @@ description = "A tweetdeck-style notedeck app"
[lib]
crate-type = ["lib", "cdylib"]
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
ndk-context = "0.1"
[dependencies]
opener = { workspace = true }
rmpv = { workspace = true }
+112 -55
View File
@@ -1,15 +1,19 @@
use enostr::{FullKeypair, Pubkey};
use nostrdb::{Ndb, Transaction};
use notedeck::{Accounts, AppContext, Localization, SingleUnkIdAction, UnknownIds};
use notedeck::{Accounts, AppContext, JobsCache, Localization, SingleUnkIdAction, UnknownIds};
use notedeck_ui::nip51_set::Nip51SetUiCache;
pub use crate::accounts::route::AccountsResponse;
use crate::app::get_active_columns_mut;
use crate::decks::DecksCache;
use crate::onboarding::Onboarding;
use crate::profile::send_new_contact_list;
use crate::subscriptions::Subscriptions;
use crate::ui::onboarding::{FollowPackOnboardingView, FollowPacksResponse, OnboardingResponse};
use crate::{
login_manager::AcquireKeyState,
route::Route,
timeline::TimelineCache,
ui::{
account_login_view::{AccountLoginResponse, AccountLoginView},
accounts::{AccountsView, AccountsViewResponse},
@@ -37,6 +41,7 @@ pub struct SwitchAccountAction {
/// The account to switch to
pub switch_to: Pubkey,
pub switching_to_new: bool,
}
impl SwitchAccountAction {
@@ -44,8 +49,14 @@ impl SwitchAccountAction {
SwitchAccountAction {
source_column,
switch_to,
switching_to_new: false,
}
}
pub fn switching_to_new(mut self) -> Self {
self.switching_to_new = true;
self
}
}
#[derive(Debug)]
@@ -65,13 +76,13 @@ pub struct AddAccountAction {
pub fn render_accounts_route(
ui: &mut egui::Ui,
app_ctx: &mut AppContext,
col: usize,
decks: &mut DecksCache,
timeline_cache: &mut TimelineCache,
jobs: &mut JobsCache,
login_state: &mut AcquireKeyState,
onboarding: &mut Onboarding,
follow_packs_ui: &mut Nip51SetUiCache,
route: AccountsRoute,
) -> AddAccountAction {
let resp = match route {
) -> Option<AccountsResponse> {
match route {
AccountsRoute::Accounts => AccountsView::new(
app_ctx.ndb,
app_ctx.accounts,
@@ -80,47 +91,33 @@ pub fn render_accounts_route(
)
.ui(ui)
.inner
.map(AccountsRouteResponse::Accounts),
.map(AccountsRouteResponse::Accounts)
.map(AccountsResponse::Account),
AccountsRoute::AddAccount => {
AccountLoginView::new(login_state, app_ctx.clipboard, app_ctx.i18n)
.ui(ui)
.inner
.map(AccountsRouteResponse::AddAccount)
.map(AccountsResponse::Account)
}
};
if let Some(resp) = resp {
match resp {
AccountsRouteResponse::Accounts(response) => {
let action = process_accounts_view_response(
app_ctx.i18n,
app_ctx.accounts,
decks,
col,
response,
);
AddAccountAction {
accounts_action: action,
unk_id_action: SingleUnkIdAction::no_action(),
}
AccountsRoute::Onboarding => FollowPackOnboardingView::new(
onboarding,
follow_packs_ui,
app_ctx.ndb,
app_ctx.img_cache,
app_ctx.i18n,
app_ctx.job_pool,
jobs,
)
.ui(ui)
.map(|r| match r {
OnboardingResponse::FollowPacks(follow_packs_response) => {
AccountsResponse::Account(AccountsRouteResponse::AddAccount(
AccountLoginResponse::Onboarding(follow_packs_response),
))
}
AccountsRouteResponse::AddAccount(response) => {
let action =
process_login_view_response(app_ctx, timeline_cache, decks, col, response);
*login_state = Default::default();
let router = get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, decks)
.column_mut(col)
.router_mut();
router.go_back();
action
}
}
} else {
AddAccountAction {
accounts_action: None,
unk_id_action: SingleUnkIdAction::no_action(),
}
OnboardingResponse::ViewProfile(pubkey) => AccountsResponse::ViewProfile(pubkey),
}),
}
}
@@ -155,31 +152,53 @@ pub fn process_accounts_view_response(
pub fn process_login_view_response(
app_ctx: &mut AppContext,
timeline_cache: &mut TimelineCache,
decks: &mut DecksCache,
subs: &mut Subscriptions,
onboarding: &mut Onboarding,
col: usize,
response: AccountLoginResponse,
) -> AddAccountAction {
let (r, pubkey) = match response {
AccountLoginResponse::CreateNew => {
let kp = FullKeypair::generate();
let pubkey = kp.pubkey;
send_new_contact_list(kp.to_filled(), app_ctx.ndb, app_ctx.pool);
(app_ctx.accounts.add_account(kp.to_keypair()), pubkey)
}
AccountLoginResponse::LoginWith(keypair) => {
let pubkey = keypair.pubkey;
(app_ctx.accounts.add_account(keypair), pubkey)
}
};
let cur_router = get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, decks)
.column_mut(col)
.router_mut();
decks.add_deck_default(app_ctx, timeline_cache, pubkey);
let r = match response {
AccountLoginResponse::LoginWith(keypair) => {
cur_router.go_back();
app_ctx.accounts.add_account(keypair)
}
AccountLoginResponse::CreatingNew => {
cur_router.route_to(Route::Accounts(AccountsRoute::Onboarding));
onboarding.process(app_ctx.pool, app_ctx.ndb, subs, app_ctx.unknown_ids);
None
}
AccountLoginResponse::Onboarding(onboarding_response) => match onboarding_response {
FollowPacksResponse::NoFollowPacks => {
onboarding.process(app_ctx.pool, app_ctx.ndb, subs, app_ctx.unknown_ids);
None
}
FollowPacksResponse::UserSelectedPacks(nip51_sets_ui_state) => {
let pks_to_follow = nip51_sets_ui_state.get_all_selected();
let kp = FullKeypair::generate();
send_new_contact_list(kp.to_filled(), app_ctx.ndb, app_ctx.pool, pks_to_follow);
cur_router.go_back();
onboarding.end_onboarding(app_ctx.pool, app_ctx.ndb);
app_ctx.accounts.add_account(kp.to_keypair())
}
},
};
if let Some(action) = r {
AddAccountAction {
accounts_action: Some(AccountsAction::Switch(SwitchAccountAction {
source_column: col,
switch_to: action.switch_to,
switching_to_new: true,
})),
unk_id_action: action.unk_id_action,
}
@@ -190,3 +209,41 @@ pub fn process_login_view_response(
}
}
}
impl AccountsRouteResponse {
pub fn process(
self,
app_ctx: &mut AppContext,
app: &mut crate::Damus,
col: usize,
) -> AddAccountAction {
match self {
AccountsRouteResponse::Accounts(response) => {
let action = process_accounts_view_response(
app_ctx.i18n,
app_ctx.accounts,
&mut app.decks_cache,
col,
response,
);
AddAccountAction {
accounts_action: action,
unk_id_action: notedeck::SingleUnkIdAction::no_action(),
}
}
AccountsRouteResponse::AddAccount(response) => {
let action = process_login_view_response(
app_ctx,
&mut app.decks_cache,
&mut app.subscriptions,
&mut app.onboarding,
col,
response,
);
app.view_state.login = Default::default();
action
}
}
}
}
@@ -7,10 +7,16 @@ pub enum AccountsRouteResponse {
AddAccount(AccountLoginResponse),
}
pub enum AccountsResponse {
ViewProfile(enostr::Pubkey),
Account(AccountsRouteResponse),
}
#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)]
pub enum AccountsRoute {
Accounts,
AddAccount,
Onboarding,
}
impl AccountsRoute {
@@ -19,6 +25,7 @@ impl AccountsRoute {
match self {
Self::Accounts => &["accounts", "show"],
Self::AddAccount => &["accounts", "new"],
Self::Onboarding => &["accounts", "onboarding"],
}
}
}
+37 -13
View File
@@ -1,12 +1,12 @@
use std::collections::HashSet;
use crate::{
column::Columns,
nav::{RouterAction, RouterType},
route::Route,
timeline::{
thread::{
selected_has_at_least_n_replies, InsertionResponse, NoteSeenFlags, ThreadNode, Threads,
},
ThreadSelection, TimelineCache, TimelineKind,
thread::{selected_has_at_least_n_replies, NoteSeenFlags, ThreadNode, Threads},
InsertionResponse, ThreadSelection, TimelineCache, TimelineKind,
},
view_state::ViewState,
};
@@ -30,8 +30,9 @@ pub enum NotesOpenResult {
Thread(NewThreadNotes),
}
pub enum TimelineOpenResult {
NewNotes(NewNotes),
pub struct TimelineOpenResult {
new_notes: Option<NewNotes>,
new_pks: Option<HashSet<Pubkey>>,
}
struct NoteActionResponse {
@@ -270,7 +271,24 @@ fn clear_zap_error(sender: &Pubkey, zaps: &mut Zaps, target: &NoteZapTargetOwned
impl TimelineOpenResult {
pub fn new_notes(notes: Vec<NoteKey>, id: TimelineKind) -> Self {
Self::NewNotes(NewNotes::new(notes, id))
Self {
new_notes: Some(NewNotes { id, notes }),
new_pks: None,
}
}
pub fn new_pks(pks: HashSet<Pubkey>) -> Self {
Self {
new_notes: None,
new_pks: Some(pks),
}
}
pub fn insert_pks(&mut self, pks: HashSet<Pubkey>) {
match &mut self.new_pks {
Some(cur_pks) => cur_pks.extend(pks),
None => self.new_pks = Some(pks),
}
}
pub fn process(
@@ -281,11 +299,17 @@ impl TimelineOpenResult {
storage: &mut TimelineCache,
unknown_ids: &mut UnknownIds,
) {
match self {
// update the thread for next render if we have new notes
TimelineOpenResult::NewNotes(new_notes) => {
new_notes.process(storage, ndb, txn, unknown_ids, note_cache);
}
// update the thread for next render if we have new notes
if let Some(new_notes) = &self.new_notes {
new_notes.process(storage, ndb, txn, unknown_ids, note_cache);
}
let Some(pks) = &self.new_pks else {
return;
};
for pk in pks {
unknown_ids.add_pubkey_if_missing(ndb, txn, pk);
}
}
}
@@ -387,7 +411,7 @@ pub fn process_thread_notes(
created_at,
};
if thread.replies.contains(&note_ref) {
if thread.replies.contains_key(&note_ref.key) {
continue;
}
+110 -37
View File
@@ -4,13 +4,15 @@ use crate::{
decks::{Decks, DecksCache},
draft::Drafts,
nav::{self, ProcessNavResult},
onboarding::Onboarding,
options::AppOptions,
route::Route,
storage,
subscriptions::{SubKind, Subscriptions},
support::Support,
timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind},
ui::{self, DesktopSidePanel, SidePanelAction},
toolbar::unseen_notification,
ui::{self, toolbar::toolbar, DesktopSidePanel, SidePanelAction},
view_state::ViewState,
Result,
};
@@ -57,18 +59,20 @@ pub struct Damus {
pub note_options: NoteOptions,
pub unrecognized_args: BTreeSet<String>,
/// keep track of follow packs
pub onboarding: Onboarding,
}
fn handle_key_events(input: &egui::InputState, columns: &mut Columns) {
fn handle_egui_events(input: &egui::InputState, columns: &mut Columns) {
for event in &input.raw.events {
if let egui::Event::Key {
key, pressed: true, ..
} = event
{
match key {
match event {
egui::Event::Key { key, pressed, .. } if *pressed => match key {
egui::Key::J => {
columns.select_down();
//columns.select_down();
{}
}
/*
egui::Key::K => {
columns.select_up();
}
@@ -78,11 +82,18 @@ fn handle_key_events(input: &egui::InputState, columns: &mut Columns) {
egui::Key::L => {
columns.select_left();
}
*/
egui::Key::BrowserBack | egui::Key::Escape => {
columns.get_selected_router().go_back();
}
_ => {}
},
egui::Event::InsetsChanged => {
tracing::debug!("insets have changed!");
}
_ => {}
}
}
}
@@ -94,7 +105,7 @@ fn try_process_event(
) -> Result<()> {
let current_columns =
get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, &mut damus.decks_cache);
ctx.input(|i| handle_key_events(i, current_columns));
ctx.input(|i| handle_egui_events(i, current_columns));
let ctx2 = ctx.clone();
let wakeup = move || {
@@ -137,13 +148,14 @@ fn try_process_event(
}
}
for (_kind, timeline) in &mut damus.timeline_cache {
for (kind, timeline) in &mut damus.timeline_cache {
let is_ready = timeline::is_timeline_ready(
app_ctx.ndb,
app_ctx.pool,
app_ctx.note_cache,
timeline,
app_ctx.accounts,
app_ctx.unknown_ids,
);
if is_ready {
@@ -162,9 +174,16 @@ fn try_process_event(
}
} else {
// TODO: show loading?
if matches!(kind, TimelineKind::List(ListKind::Contact(_))) {
timeline::fetch_contact_list(&mut damus.subscriptions, timeline, app_ctx.accounts);
}
}
}
if let Some(follow_packs) = damus.onboarding.get_follow_packs_mut() {
follow_packs.poll_for_notes(app_ctx.ndb, app_ctx.unknown_ids);
}
if app_ctx.unknown_ids.ready_to_send() {
unknown_id_send(app_ctx.unknown_ids, app_ctx.pool);
}
@@ -204,6 +223,7 @@ fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Con
app_ctx.ndb,
app_ctx.note_cache,
&mut damus.timeline_cache,
app_ctx.unknown_ids,
) {
warn!("update_damus init: {err}");
}
@@ -513,6 +533,7 @@ impl Damus {
unrecognized_args,
jobs,
threads,
onboarding: Onboarding::default(),
}
}
@@ -563,6 +584,7 @@ impl Damus {
unrecognized_args: BTreeSet::default(),
jobs: JobsCache::default(),
threads: Threads::default(),
onboarding: Onboarding::default(),
}
}
@@ -573,6 +595,14 @@ impl Damus {
pub fn unrecognized_args(&self) -> &BTreeSet<String> {
&self.unrecognized_args
}
pub fn toolbar_height() -> f32 {
48.0
}
pub fn initially_selected_toolbar_index() -> i32 {
0
}
}
fn get_note_options(args: ColumnsArgs, settings_handler: &mut SettingsHandler) -> NoteOptions {
@@ -614,36 +644,71 @@ fn render_damus_mobile(
) -> Option<AppAction> {
//let routes = app.timelines[0].routes.clone();
let rect = ui.available_rect_before_wrap();
let mut app_action: Option<AppAction> = None;
let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize;
let mut app_action: Option<AppAction> = None;
// don't show toolbar if soft keyboard is open
let skb_rect = app_ctx.soft_keyboard_rect(
ui.ctx().screen_rect(),
notedeck::SoftKeyboardContext::platform(ui.ctx()),
);
let toolbar_height = if skb_rect.is_none() {
Damus::toolbar_height()
} else {
0.0
};
if !app.columns(app_ctx.accounts).columns().is_empty() {
let r = nav::render_nav(
active_col,
ui.available_rect_before_wrap(),
app,
app_ctx,
ui,
)
.process_render_nav_response(app, app_ctx, ui);
if let Some(r) = &r {
match r {
ProcessNavResult::SwitchOccurred => {
if !app.options.contains(AppOptions::TmpColumns) {
storage::save_decks_cache(app_ctx.path, &app.decks_cache);
StripBuilder::new(ui)
.size(Size::remainder()) // top cell
.size(Size::exact(toolbar_height)) // bottom cell
.vertical(|mut strip| {
strip.cell(|ui| {
let rect = ui.available_rect_before_wrap();
if !app.columns(app_ctx.accounts).columns().is_empty() {
let r = nav::render_nav(
active_col,
ui.available_rect_before_wrap(),
app,
app_ctx,
ui,
)
.process_render_nav_response(app, app_ctx, ui);
if let Some(r) = &r {
match r {
ProcessNavResult::SwitchOccurred => {
if !app.options.contains(AppOptions::TmpColumns) {
storage::save_decks_cache(app_ctx.path, &app.decks_cache);
}
}
ProcessNavResult::PfpClicked => {
app_action = Some(AppAction::ToggleChrome);
}
}
}
}
ProcessNavResult::PfpClicked => {
app_action = Some(AppAction::ToggleChrome);
}
}
}
}
hovering_post_button(ui, app, app_ctx, rect);
});
hovering_post_button(ui, app, app_ctx, rect);
strip.cell(|ui| 'brk: {
if toolbar_height <= 0.0 {
break 'brk;
}
let unseen_notif = unseen_notification(
app,
app_ctx.ndb,
app_ctx.accounts.get_selected_account().key.pubkey,
);
if skb_rect.is_none() {
let resp = toolbar(ui, unseen_notif);
if let Some(action) = resp {
action.process(app, app_ctx);
}
}
});
});
app_action
}
@@ -659,8 +724,10 @@ fn hovering_post_button(
let button_y = ui
.ctx()
.animate_bool_responsive(btn_id, should_show_compose);
rect.min.x = rect.max.x - if is_narrow(ui.ctx()) { 60.0 } else { 100.0 };
rect.min.y = rect.max.y - 100.0 * button_y;
rect.min.x = rect.max.x - (if is_narrow(ui.ctx()) { 60.0 } else { 100.0 } * button_y);
rect.min.y = rect.max.y - 100.0;
rect.max.x += 48.0 * (1.0 - button_y);
let darkmode = ui.ctx().style().visuals.dark_mode;
@@ -837,7 +904,13 @@ fn timelines_view(
let mut save_cols = false;
if let Some(action) = side_panel_action {
save_cols = save_cols
|| action.process(&mut app.timeline_cache, &mut app.decks_cache, ctx, ui.ctx());
|| action.process(
&mut app.timeline_cache,
&mut app.decks_cache,
ctx,
&mut app.subscriptions,
ui.ctx(),
);
}
let mut app_action: Option<AppAction> = None;
+10 -1
View File
@@ -140,7 +140,16 @@ impl ColumnsArgs {
} else if column_name == "universe" {
debug!("got universe column");
res.columns
.push(ArgColumn::Timeline(TimelineKind::Universe))
.push(ArgColumn::Timeline(TimelineKind::Universe));
} else if let Some(hashtag) = column_name.strip_prefix("hashtag:") {
let hashtags: Vec<String> = hashtag
.split(",")
.map(str::trim)
.filter(|p| !p.is_empty())
.map(ToOwned::to_owned)
.collect();
res.columns
.push(ArgColumn::Timeline(TimelineKind::Hashtag(hashtags)));
} else if let Some(profile_pk_str) = column_name.strip_prefix("profile:") {
if let Ok(pubkey) = Pubkey::parse(profile_pk_str) {
info!("got profile column for user {}", pubkey.hex());
+20 -12
View File
@@ -75,25 +75,33 @@ impl Columns {
/// Select the column based on the timeline kind.
///
/// TODO: add timeline if missing?
pub fn select_by_kind(&mut self, kind: &TimelineKind) -> SelectionResult {
pub fn select_by_route(&mut self, desired_route: Route) -> SelectionResult {
for (i, col) in self.columns.iter().enumerate() {
for route in col.router().routes() {
if let Some(timeline) = route.timeline_id() {
if timeline == kind {
tracing::info!("selecting {kind:?} column");
if self.selected as usize == i {
return SelectionResult::AlreadySelected(i);
} else {
self.select_column(i as i32);
return SelectionResult::NewSelection(i);
}
if *route == desired_route {
if self.selected as usize == i {
return SelectionResult::AlreadySelected(i);
} else {
self.select_column(i as i32);
return SelectionResult::NewSelection(i);
}
}
}
}
tracing::error!("failed to select {kind:?} column");
SelectionResult::Failed
if matches!(&desired_route, Route::Timeline(_))
|| matches!(&desired_route, Route::Thread(_))
{
// these require additional handling to add state
tracing::error!("failed to select {desired_route:?} column");
return SelectionResult::Failed;
}
self.add_column(Column::new(vec![desired_route]));
let selected_index = self.columns.len() - 1;
self.select_column(selected_index as i32);
SelectionResult::NewSelection(selected_index)
}
pub fn add_new_timeline_column(
+1 -1
View File
@@ -190,7 +190,7 @@ impl DecksCache {
&self.fallback_pubkey
}
pub fn get_all_decks_mut(&mut self) -> ValuesMut<Pubkey, Decks> {
pub fn get_all_decks_mut(&mut self) -> ValuesMut<'_, Pubkey, Decks> {
self.account_to_decks.values_mut()
}
+2
View File
@@ -18,6 +18,7 @@ pub mod login_manager;
mod media_upload;
mod multi_subscriber;
mod nav;
mod onboarding;
pub mod options;
mod post;
mod profile;
@@ -27,6 +28,7 @@ mod subscriptions;
mod support;
mod test_data;
pub mod timeline;
mod toolbar;
pub mod ui;
mod unknowns;
mod view_state;
+43 -61
View File
@@ -1,18 +1,17 @@
#![cfg_attr(target_os = "android", allow(dead_code, unused_variables))]
use std::path::PathBuf;
use crate::Error;
use base64::{prelude::BASE64_URL_SAFE, Engine};
use ehttp::Request;
use nostrdb::{Note, NoteBuilder};
use notedeck::SupportedMimeType;
use notedeck::{
media::images::fetch_binary_from_disk,
platform::file::{MediaFrom, SelectedMedia},
};
use poll_promise::Promise;
use sha2::{Digest, Sha256};
use url::Url;
use crate::Error;
use notedeck::media::images::fetch_binary_from_disk;
pub const NOSTR_BUILD_URL: fn() -> Url = || Url::parse("http://nostr.build").unwrap();
const NIP96_WELL_KNOWN: &str = ".well-known/nostr/nip96.json";
@@ -75,7 +74,7 @@ pub fn get_nostr_build_upload_url() -> Promise<Result<String, Error>> {
get_upload_url_from_provider(NOSTR_BUILD_URL())
}
fn create_nip98_note(seckey: &[u8; 32], upload_url: String, payload_hash: String) -> Note {
fn create_nip98_note(seckey: &[u8; 32], upload_url: String, payload_hash: String) -> Note<'_> {
NoteBuilder::new()
.kind(27235)
.start_tag()
@@ -94,15 +93,15 @@ fn create_nip98_note(seckey: &[u8; 32], upload_url: String, payload_hash: String
fn create_nip96_request(
upload_url: &str,
media_path: MediaPath,
file_name: &str,
media_type: &str,
file_contents: Vec<u8>,
nip98_base64: &str,
) -> ehttp::Request {
let boundary = "----boundary";
let mut body = format!(
"--{}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n",
boundary, media_path.file_name, media_path.media_type.to_mime()
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{file_name}\"\r\nContent-Type: {media_type}\r\n\r\n",
)
.into_bytes();
body.extend(file_contents);
@@ -134,25 +133,14 @@ fn sha256_hex(contents: &Vec<u8>) -> String {
pub fn nip96_upload(
seckey: [u8; 32],
upload_url: String,
media_path: MediaPath,
selected_media: SelectedMedia,
) -> Promise<Result<Nip94Event, Error>> {
let bytes_res = fetch_binary_from_disk(media_path.full_path.clone());
let file_bytes = match bytes_res {
Ok(bytes) => bytes,
Err(e) => {
return Promise::from_ready(Err(Error::Generic(format!(
"could not read contents of file to upload: {e}"
))));
}
};
internal_nip96_upload(seckey, upload_url, media_path, file_bytes)
internal_nip96_upload(seckey, upload_url, selected_media)
}
pub fn nostrbuild_nip96_upload(
seckey: [u8; 32],
media_path: MediaPath,
selected_media: SelectedMedia,
) -> Promise<Result<Nip94Event, Error>> {
let (sender, promise) = Promise::new();
std::thread::spawn(move || {
@@ -166,7 +154,7 @@ pub fn nostrbuild_nip96_upload(
}
};
let res = nip96_upload(seckey, upload_url, media_path).block_and_take();
let res = nip96_upload(seckey, upload_url, selected_media).block_and_take();
sender.send(res);
});
promise
@@ -175,9 +163,21 @@ pub fn nostrbuild_nip96_upload(
fn internal_nip96_upload(
seckey: [u8; 32],
upload_url: String,
media_path: MediaPath,
file_contents: Vec<u8>,
selected_media: SelectedMedia,
) -> Promise<Result<Nip94Event, Error>> {
let file_name = selected_media.file_name;
let mime_type = selected_media.media_type.to_mime();
let bytes_res = bytes_from_media(selected_media.from);
let file_contents = match bytes_res {
Ok(bytes) => bytes,
Err(e) => {
return Promise::from_ready(Err(Error::Generic(format!(
"could not read contents of file to upload: {e}"
))));
}
};
let file_hash = sha256_hex(&file_contents);
let nip98_note = create_nip98_note(&seckey, upload_url.to_owned(), file_hash);
@@ -186,7 +186,13 @@ fn internal_nip96_upload(
Err(e) => return Promise::from_ready(Err(Error::Generic(e.to_string()))),
};
let request = create_nip96_request(&upload_url, media_path, file_contents, &nip98_base64);
let request = create_nip96_request(
&upload_url,
&file_name,
mime_type,
file_contents,
&nip98_base64,
);
let (sender, promise) = Promise::new();
@@ -232,33 +238,10 @@ fn find_nip94_ev_in_json(json: String) -> Result<Nip94Event, Error> {
}
}
#[derive(Debug)]
pub struct MediaPath {
full_path: PathBuf,
file_name: String,
media_type: SupportedMimeType,
}
impl MediaPath {
pub fn new(path: PathBuf) -> Result<Self, Error> {
if let Some(ex) = path.extension().and_then(|f| f.to_str()) {
let media_type = SupportedMimeType::from_extension(ex)?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&format!("file.{ex}"))
.to_owned();
Ok(MediaPath {
full_path: path,
file_name,
media_type,
})
} else {
Err(Error::Generic(format!(
"{path:?} does not have an extension"
)))
}
pub fn bytes_from_media(media: MediaFrom) -> Result<Vec<u8>, notedeck::Error> {
match media {
MediaFrom::PathBuf(full_path) => fetch_binary_from_disk(full_path.clone()),
MediaFrom::Memory(bytes) => Ok(bytes),
}
}
@@ -349,7 +332,7 @@ mod tests {
use enostr::FullKeypair;
use crate::media_upload::{
get_upload_url_from_provider, nostrbuild_nip96_upload, MediaPath, NOSTR_BUILD_URL,
get_upload_url_from_provider, nostrbuild_nip96_upload, SelectedMedia, NOSTR_BUILD_URL,
};
use super::internal_nip96_upload;
@@ -368,7 +351,7 @@ mod tests {
fn test_internal_nip96() {
// just a random image to test image upload
let file_path = PathBuf::from_str("../../../assets/damus_rounded_80.png").unwrap();
let media_path = MediaPath::new(file_path).unwrap();
let selected_media = SelectedMedia::from_path(file_path).unwrap();
let img_bytes = include_bytes!("../../../assets/damus_rounded_80.png");
let promise = get_upload_url_from_provider(NOSTR_BUILD_URL());
let kp = FullKeypair::generate();
@@ -378,8 +361,7 @@ mod tests {
let promise = internal_nip96_upload(
kp.secret_key.secret_bytes(),
upload_url.to_string(),
media_path,
img_bytes.to_vec(),
selected_media,
);
let res = promise.block_until_ready();
assert!(res.is_ok())
@@ -395,11 +377,11 @@ mod tests {
let file_path =
fs::canonicalize(PathBuf::from_str("../../assets/damus_rounded_80.png").unwrap())
.unwrap();
let media_path = MediaPath::new(file_path).unwrap();
let selected_media = SelectedMedia::from_path(file_path).unwrap();
let kp = FullKeypair::generate();
println!("Using pubkey: {:?}", kp.pubkey);
let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), media_path);
let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), selected_media);
let out = promise.block_and_take();
assert!(out.is_ok());
+59 -22
View File
@@ -1,5 +1,5 @@
use crate::{
accounts::{render_accounts_route, AccountsAction},
accounts::{render_accounts_route, AccountsAction, AccountsResponse},
app::{get_active_columns_mut, get_decks_mut},
column::ColumnsAction,
deck_state::DeckState,
@@ -8,7 +8,9 @@ use crate::{
options::AppOptions,
profile::{ProfileAction, SaveProfileChanges},
route::{Route, Router, SingletonRouter},
subscriptions::Subscriptions,
timeline::{
kind::ListKind,
route::{render_thread_route, render_timeline_route},
TimelineCache, TimelineKind,
},
@@ -19,6 +21,7 @@ use crate::{
configure_deck::ConfigureDeckView,
edit_deck::{EditDeckResponse, EditDeckView},
note::{custom_zap::CustomZapView, NewPostAction, PostAction, PostType, QuoteRepostView},
onboarding::FollowPackOnboardingView,
profile::EditProfileView,
search::{FocusState, SearchView},
settings::SettingsAction,
@@ -80,19 +83,36 @@ impl SwitchingAction {
timeline_cache: &mut TimelineCache,
decks_cache: &mut DecksCache,
ctx: &mut AppContext<'_>,
subs: &mut Subscriptions,
ui_ctx: &egui::Context,
) -> bool {
match &self {
SwitchingAction::Accounts(account_action) => match account_action {
AccountsAction::Switch(switch_action) => {
let txn = Transaction::new(ctx.ndb).expect("txn");
ctx.accounts.select_account(
&switch_action.switch_to,
ctx.ndb,
&txn,
ctx.pool,
ui_ctx,
);
{
let txn = Transaction::new(ctx.ndb).expect("txn");
ctx.accounts.select_account(
&switch_action.switch_to,
ctx.ndb,
&txn,
ctx.pool,
ui_ctx,
);
let contacts_sub = ctx.accounts.get_subs().contacts.remote.clone();
// this is cringe but we're gonna get a new sub manager soon...
subs.subs.insert(
contacts_sub,
crate::subscriptions::SubKind::FetchingContactList(TimelineKind::List(
ListKind::Contact(*ctx.accounts.selected_account_pubkey()),
)),
);
}
if switch_action.switching_to_new {
decks_cache.add_deck_default(ctx, timeline_cache, switch_action.switch_to);
}
// pop nav after switch
get_active_columns_mut(ctx.i18n, ctx.accounts, decks_cache)
.column_mut(switch_action.source_column)
@@ -468,6 +488,7 @@ fn process_render_nav_action(
&mut app.timeline_cache,
&mut app.decks_cache,
ctx,
&mut app.subscriptions,
ui.ctx(),
) {
return Some(ProcessNavResult::SwitchOccurred);
@@ -564,21 +585,33 @@ fn render_nav_body(
&mut note_context,
&mut app.jobs,
),
Route::Accounts(amr) => {
let mut action = render_accounts_route(
Route::Accounts(amr) => 's: {
let Some(action) = render_accounts_route(
ui,
ctx,
col,
&mut app.decks_cache,
&mut app.timeline_cache,
&mut app.jobs,
&mut app.view_state.login,
&mut app.onboarding,
&mut app.view_state.follow_packs,
*amr,
);
let txn = Transaction::new(ctx.ndb).expect("txn");
action.process_action(ctx.unknown_ids, ctx.ndb, &txn);
action
.accounts_action
.map(|f| RenderNavAction::SwitchingAction(SwitchingAction::Accounts(f)))
) else {
break 's None;
};
match action {
AccountsResponse::ViewProfile(pubkey) => {
Some(RenderNavAction::NoteAction(NoteAction::Profile(pubkey)))
}
AccountsResponse::Account(accounts_route_response) => {
let mut action = accounts_route_response.process(ctx, app, col);
let txn = Transaction::new(ctx.ndb).expect("txn");
action.process_action(ctx.unknown_ids, ctx.ndb, &txn);
action
.accounts_action
.map(|f| RenderNavAction::SwitchingAction(SwitchingAction::Accounts(f)))
}
}
}
Route::Relays => RelayView::new(ctx.pool, &mut app.view_state.id_string_map, ctx.i18n)
.ui(ui)
@@ -799,7 +832,7 @@ fn render_nav_body(
return action;
};
if EditProfileView::new(ctx.i18n, state, ctx.img_cache).ui(ui) {
if EditProfileView::new(ctx.i18n, state, ctx.img_cache, ctx.clipboard).ui(ui) {
if let Some(state) = app.view_state.pubkey_to_profile_state.get(kp.pubkey) {
action = Some(RenderNavAction::ProfileAction(ProfileAction::SaveChanges(
SaveProfileChanges::new(kp.to_full(), state.clone()),
@@ -927,7 +960,7 @@ pub fn render_nav(
ctx.ndb,
ctx.img_cache,
get_active_columns_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache),
&[route.clone()],
std::slice::from_ref(route),
col,
ctx.i18n,
)
@@ -1061,6 +1094,9 @@ fn get_scroll_id(
Route::Accounts(accounts_route) => match accounts_route {
crate::accounts::AccountsRoute::Accounts => Some(AccountsView::scroll_id()),
crate::accounts::AccountsRoute::AddAccount => None,
crate::accounts::AccountsRoute::Onboarding => {
Some(FollowPackOnboardingView::scroll_id())
}
},
Route::Reply(note_id) => Some(PostReplyView::scroll_id(col, note_id.bytes())),
Route::Quote(note_id) => Some(QuoteRepostView::scroll_id(col, note_id.bytes())),
@@ -1085,6 +1121,7 @@ fn route_uses_frame(route: &Route) -> bool {
Route::Accounts(accounts_route) => match accounts_route {
crate::accounts::AccountsRoute::Accounts => true,
crate::accounts::AccountsRoute::AddAccount => false,
crate::accounts::AccountsRoute::Onboarding => false,
},
Route::Relays => true,
Route::Timeline(_) => false,
+154
View File
@@ -0,0 +1,154 @@
use std::{cell::RefCell, rc::Rc};
use egui_virtual_list::VirtualList;
use enostr::{Pubkey, RelayPool};
use nostrdb::{Filter, Ndb, NoteKey, Transaction};
use notedeck::{create_nip51_set, filter::default_limit, Nip51SetCache, UnknownIds};
use uuid::Uuid;
use crate::subscriptions::Subscriptions;
#[derive(Debug)]
enum OnboardingState {
AwaitingTrustedPksList(Vec<Filter>),
HaveFollowPacks(Nip51SetCache),
}
/// Manages the onboarding process. Responsible for retriving the kind 30000 list of trusted pubkeys
/// and then retrieving all follow packs from the trusted pks updating when new ones arrive
#[derive(Default)]
pub struct Onboarding {
state: Option<Result<OnboardingState, OnboardingError>>,
pub list: Rc<RefCell<VirtualList>>,
}
impl Onboarding {
pub fn get_follow_packs(&self) -> Option<&Nip51SetCache> {
let Some(Ok(OnboardingState::HaveFollowPacks(packs))) = &self.state else {
return None;
};
Some(packs)
}
pub fn get_follow_packs_mut(&mut self) -> Option<&mut Nip51SetCache> {
let Some(Ok(OnboardingState::HaveFollowPacks(packs))) = &mut self.state else {
return None;
};
Some(packs)
}
pub fn process(
&mut self,
pool: &mut RelayPool,
ndb: &Ndb,
subs: &mut Subscriptions,
unknown_ids: &mut UnknownIds,
) {
match &self.state {
Some(res) => {
let Ok(OnboardingState::AwaitingTrustedPksList(filter)) = res else {
return;
};
let txn = Transaction::new(ndb).expect("txns");
let Ok(res) = ndb.query(&txn, filter, 1) else {
return;
};
if res.is_empty() {
return;
}
let key = res.first().expect("checked empty").note_key;
let new_state = get_trusted_authors(ndb, &txn, key).and_then(|trusted_pks| {
let pks: Vec<&[u8; 32]> = trusted_pks.iter().map(|f| f.bytes()).collect();
Nip51SetCache::new(pool, ndb, &txn, unknown_ids, vec![follow_packs_filter(pks)])
.map(OnboardingState::HaveFollowPacks)
.ok_or(OnboardingError::InvalidNip51Set)
});
self.state = Some(new_state);
}
None => {
let filter = vec![trusted_pks_list_filter()];
let subid = Uuid::new_v4().to_string();
pool.subscribe(subid.clone(), filter.clone());
subs.subs
.insert(subid, crate::subscriptions::SubKind::OneShot);
let new_state = Some(Ok(OnboardingState::AwaitingTrustedPksList(filter)));
self.state = new_state;
}
}
}
// Unsubscribe and clear state
pub fn end_onboarding(&mut self, pool: &mut RelayPool, ndb: &mut Ndb) {
let Some(Ok(OnboardingState::HaveFollowPacks(state))) = &mut self.state else {
self.state = None;
return;
};
let unified = &state.sub;
pool.unsubscribe(unified.remote.clone());
let _ = ndb.unsubscribe(unified.local);
self.state = None;
}
}
#[derive(Debug)]
pub enum OnboardingError {
InvalidNip51Set,
InvalidTrustedPksListKind,
NdbCouldNotFindNote,
}
// author providing the list of trusted follow pack authors
const FOLLOW_PACK_AUTHOR: [u8; 32] = [
0x89, 0x5c, 0x2a, 0x90, 0xa8, 0x60, 0xac, 0x18, 0x43, 0x4a, 0xa6, 0x9e, 0x7b, 0x0d, 0xa8, 0x46,
0x57, 0x21, 0x21, 0x6f, 0xa3, 0x6e, 0x42, 0xc0, 0x22, 0xe3, 0x93, 0x57, 0x9c, 0x48, 0x6c, 0xba,
];
fn trusted_pks_list_filter() -> Filter {
Filter::new()
.kinds([30000])
.limit(1)
.authors(&[FOLLOW_PACK_AUTHOR])
.tags(["trusted-follow-pack-authors"], 'd')
.build()
}
pub fn follow_packs_filter(pks: Vec<&[u8; 32]>) -> Filter {
Filter::new()
.kinds([39089])
.limit(default_limit())
.authors(pks)
.build()
}
/// gets the pubkeys from a kind 30000 follow set
fn get_trusted_authors(
ndb: &Ndb,
txn: &Transaction,
key: NoteKey,
) -> Result<Vec<Pubkey>, OnboardingError> {
let Ok(note) = ndb.get_note_by_key(txn, key) else {
return Result::Err(OnboardingError::NdbCouldNotFindNote);
};
if note.kind() != 30000 {
return Result::Err(OnboardingError::InvalidTrustedPksListKind);
}
let Some(nip51set) = create_nip51_set(note) else {
return Result::Err(OnboardingError::InvalidNip51Set);
};
Ok(nip51set.pks)
}
+3 -3
View File
@@ -56,7 +56,7 @@ impl NewPost {
}
}
pub fn to_note(&self, seckey: &[u8; 32]) -> Note {
pub fn to_note(&self, seckey: &[u8; 32]) -> Note<'_> {
let mut content = self.content.clone();
append_urls(&mut content, &self.media);
@@ -77,7 +77,7 @@ impl NewPost {
builder.sign(seckey).build().expect("note should be ok")
}
pub fn to_reply(&self, seckey: &[u8; 32], replying_to: &Note) -> Note {
pub fn to_reply(&self, seckey: &[u8; 32], replying_to: &Note) -> Note<'_> {
let mut content = self.content.clone();
append_urls(&mut content, &self.media);
@@ -157,7 +157,7 @@ impl NewPost {
.expect("expected build to work")
}
pub fn to_quote(&self, seckey: &[u8; 32], quoting: &Note) -> Note {
pub fn to_quote(&self, seckey: &[u8; 32], quoting: &Note) -> Note<'_> {
let mut new_content = format!(
"{}\nnostr:{}",
self.content,
+21 -9
View File
@@ -15,7 +15,7 @@ impl SaveProfileChanges {
pub fn new(kp: FullKeypair, state: ProfileState) -> Self {
Self { kp, state }
}
pub fn to_note(&self) -> Note {
pub fn to_note(&self) -> Note<'_> {
let sec = &self.kp.secret_key.to_secret_bytes();
add_client_tag(NoteBuilder::new())
.kind(0)
@@ -218,18 +218,30 @@ fn send_note_builder(builder: NoteBuilder, ndb: &Ndb, pool: &mut RelayPool, kp:
pool.send(event);
}
pub fn send_new_contact_list(kp: FilledKeypair, ndb: &Ndb, pool: &mut RelayPool) {
let builder = construct_new_contact_list(kp.pubkey);
pub fn send_new_contact_list(
kp: FilledKeypair,
ndb: &Ndb,
pool: &mut RelayPool,
mut pks_to_follow: Vec<Pubkey>,
) {
if !pks_to_follow.contains(kp.pubkey) {
pks_to_follow.push(*kp.pubkey);
}
let builder = construct_new_contact_list(pks_to_follow);
send_note_builder(builder, ndb, pool, kp);
}
fn construct_new_contact_list<'a>(pk: &'a Pubkey) -> NoteBuilder<'a> {
NoteBuilder::new()
fn construct_new_contact_list<'a>(pks: Vec<Pubkey>) -> NoteBuilder<'a> {
let mut builder = NoteBuilder::new()
.content("")
.kind(3)
.options(NoteBuildOptions::default())
.start_tag()
.tag_str("p")
.tag_str(&pk.hex())
.options(NoteBuildOptions::default());
for pk in pks {
builder = builder.start_tag().tag_str("p").tag_str(&pk.hex());
}
builder
}
+5
View File
@@ -278,6 +278,11 @@ impl Route {
"Add Account",
"Column title for adding new account"
)),
AccountsRoute::Onboarding => ColumnTitle::formatted(tr!(
i18n,
"Onboarding",
"Column title for finding users to follow"
)),
},
Route::ComposeNote => ColumnTitle::formatted(tr!(
i18n,
+35 -15
View File
@@ -1,7 +1,7 @@
use crate::{
actionbar::TimelineOpenResult,
error::Error,
timeline::{Timeline, TimelineKind},
timeline::{Timeline, TimelineKind, UnknownPksOwned},
};
use notedeck::{filter, FilterState, NoteCache, NoteRef};
@@ -90,17 +90,19 @@ impl TimelineCache {
ndb: &Ndb,
notes: &[NoteRef],
note_cache: &mut NoteCache,
) {
) -> Option<UnknownPksOwned> {
let mut timeline = if let Some(timeline) = id.clone().into_timeline(txn, ndb) {
timeline
} else {
error!("Error creating timeline from {:?}", &id);
return;
return None;
};
// insert initial notes into timeline
timeline.insert_new(txn, ndb, note_cache, notes);
let res = timeline.insert_new(txn, ndb, note_cache, notes);
self.timelines.insert(id, timeline);
res
}
pub fn insert(&mut self, id: TimelineKind, timeline: Timeline) {
@@ -113,19 +115,22 @@ impl TimelineCache {
}
/// Get and/or update the notes associated with this timeline
pub fn notes<'a>(
fn notes<'a>(
&'a mut self,
ndb: &Ndb,
note_cache: &mut NoteCache,
txn: &Transaction,
id: &TimelineKind,
) -> Vitality<'a, Timeline> {
) -> GetNotesResponse<'a> {
// we can't use the naive hashmap entry API here because lookups
// require a copy, wait until we have a raw entry api. We could
// also use hashbrown?
if self.timelines.contains_key(id) {
return Vitality::Stale(self.get_expected_mut(id));
return GetNotesResponse {
vitality: Vitality::Stale(self.get_expected_mut(id)),
unknown_pks: None,
};
}
let notes = if let FilterState::Ready(filters) = id.filters(txn, ndb) {
@@ -149,9 +154,12 @@ impl TimelineCache {
info!("found NotesHolder with {} notes", notes.len());
}
self.insert_new(id.to_owned(), txn, ndb, &notes, note_cache);
let unknown_pks = self.insert_new(id.to_owned(), txn, ndb, &notes, note_cache);
Vitality::Fresh(self.get_expected_mut(id))
GetNotesResponse {
vitality: Vitality::Fresh(self.get_expected_mut(id)),
unknown_pks,
}
}
/// Open a timeline, this is another way of saying insert a timeline
@@ -166,11 +174,12 @@ impl TimelineCache {
pool: &mut RelayPool,
id: &TimelineKind,
) -> Option<TimelineOpenResult> {
let (open_result, timeline) = match self.notes(ndb, note_cache, txn, id) {
let notes_resp = self.notes(ndb, note_cache, txn, id);
let (mut open_result, timeline) = match notes_resp.vitality {
Vitality::Stale(timeline) => {
// The timeline cache is stale, let's update it
let notes = find_new_notes(
timeline.all_or_any_notes(),
timeline.all_or_any_entries().latest(),
timeline.subscription.get_filter()?.local(),
txn,
ndb,
@@ -207,6 +216,13 @@ impl TimelineCache {
timeline.subscription.increment();
if let Some(unknowns) = notes_resp.unknown_pks {
match &mut open_result {
Some(o) => o.insert_pks(unknowns.pks),
None => open_result = Some(TimelineOpenResult::new_pks(unknowns.pks)),
}
}
open_result
}
@@ -231,18 +247,22 @@ impl TimelineCache {
}
}
pub struct GetNotesResponse<'a> {
vitality: Vitality<'a, Timeline>,
unknown_pks: Option<UnknownPksOwned>,
}
/// Look for new thread notes since our last fetch
fn find_new_notes(
notes: &[NoteRef],
latest: Option<&NoteRef>,
filters: &[Filter],
txn: &Transaction,
ndb: &Ndb,
) -> Vec<NoteRef> {
if notes.is_empty() {
let Some(last_note) = latest else {
return vec![];
}
};
let last_note = notes[0];
let filters = filter::make_filters_since(filters, last_note.created_at + 1);
if let Ok(results) = ndb.query(txn, &filters, 1000) {
+4 -4
View File
@@ -532,7 +532,7 @@ impl TimelineKind {
let contact_filter = contacts_filter(pk.bytes());
let results = ndb
.query(txn, &[contact_filter.clone()], 1)
.query(txn, std::slice::from_ref(&contact_filter), 1)
.expect("contact query failed?");
let kind_fn = TimelineKind::last_per_pubkey;
@@ -625,7 +625,7 @@ impl TimelineKind {
pub fn notifications_filter(pk: &Pubkey) -> Filter {
Filter::new()
.pubkeys([pk.bytes()])
.kinds([1])
.kinds([1, 7, 6])
.limit(default_limit())
.build()
}
@@ -681,7 +681,7 @@ fn contact_filter_state(txn: &Transaction, ndb: &Ndb, pk: &Pubkey) -> FilterStat
let contact_filter = contacts_filter(pk);
let results = ndb
.query(txn, &[contact_filter.clone()], 1)
.query(txn, std::slice::from_ref(&contact_filter), 1)
.expect("contact query failed?");
if results.is_empty() {
@@ -706,7 +706,7 @@ fn last_per_pubkey_filter_state(ndb: &Ndb, pk: &Pubkey) -> FilterState {
let txn = Transaction::new(ndb).expect("txn");
let results = ndb
.query(&txn, &[contact_filter.clone()], 1)
.query(&txn, std::slice::from_ref(&contact_filter), 1)
.expect("contact query failed?");
if results.is_empty() {
+148 -107
View File
@@ -2,7 +2,7 @@ use crate::{
error::Error,
multi_subscriber::TimelineSub,
subscriptions::{self, SubKind, Subscriptions},
timeline::kind::ListKind,
timeline::{kind::ListKind, note_units::InsertManyResponse, timeline_units::NotePayload},
Result,
};
@@ -19,6 +19,7 @@ use enostr::{PoolRelay, Pubkey, RelayPool};
use nostrdb::{Filter, Ndb, Note, NoteKey, Transaction};
use std::{
cell::RefCell,
collections::HashSet,
time::{Duration, UNIX_EPOCH},
};
use std::{rc::Rc, time::SystemTime};
@@ -27,37 +28,17 @@ use tracing::{debug, error, info, warn};
pub mod cache;
pub mod kind;
mod note_units;
pub mod route;
pub mod thread;
mod timeline_units;
mod unit;
pub use cache::TimelineCache;
pub use kind::{ColumnTitle, PubkeySource, ThreadSelection, TimelineKind};
//#[derive(Debug, Hash, Clone, Eq, PartialEq)]
//pub type TimelineId = TimelineKind;
/*
impl TimelineId {
pub fn kind(&self) -> &TimelineKind {
&self.kind
}
pub fn new(id: TimelineKind) -> Self {
TimelineId(id)
}
pub fn profile(pubkey: Pubkey) -> Self {
TimelineId::new(TimelineKind::Profile(PubkeySource::pubkey(pubkey)))
}
}
impl fmt::Display for TimelineId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "TimelineId({})", self.0)
}
}
*/
pub use note_units::{CompositeType, InsertionResponse, NoteUnits};
pub use timeline_units::{TimelineUnits, UnknownPks};
pub use unit::{CompositeUnit, NoteUnit, ReactionUnit, RepostUnit};
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub enum ViewFilter {
@@ -103,7 +84,7 @@ impl ViewFilter {
/// be captured by a Filter itself.
#[derive(Default, Debug)]
pub struct TimelineTab {
pub notes: Vec<NoteRef>,
pub units: TimelineUnits,
pub selection: i32,
pub filter: ViewFilter,
pub list: Rc<RefCell<VirtualList>>,
@@ -136,10 +117,9 @@ impl TimelineTab {
list.hide_on_resize(None);
list.over_scan(50.0);
let list = Rc::new(RefCell::new(list));
let notes: Vec<NoteRef> = Vec::with_capacity(cap);
TimelineTab {
notes,
units: TimelineUnits::with_capacity(cap),
selection,
filter,
list,
@@ -147,45 +127,54 @@ impl TimelineTab {
}
}
fn insert(&mut self, new_refs: &[NoteRef], reversed: bool) {
if new_refs.is_empty() {
return;
fn insert<'a>(
&mut self,
payloads: Vec<&'a NotePayload>,
ndb: &Ndb,
txn: &Transaction,
reversed: bool,
) -> Option<UnknownPks<'a>> {
if payloads.is_empty() {
return None;
}
let num_prev_items = self.notes.len();
let (notes, merge_kind) = crate::timeline::merge_sorted_vecs(&self.notes, new_refs);
self.notes = notes;
let new_items = self.notes.len() - num_prev_items;
let num_refs = payloads.len();
// TODO: technically items could have been added inbetween
if new_items > 0 {
let mut list = self.list.borrow_mut();
let resp = self.units.merge_new_notes(payloads, ndb, txn);
match merge_kind {
// TODO: update egui_virtual_list to support spliced inserts
MergeKind::Spliced => {
debug!(
"spliced when inserting {} new notes, resetting virtual list",
new_refs.len()
);
list.reset();
}
MergeKind::FrontInsert => {
// only run this logic if we're reverse-chronological
// reversed in this case means chronological, since the
// default is reverse-chronological. yeah it's confusing.
if !reversed {
debug!("inserting {} new notes at start", new_refs.len());
list.items_inserted_at_start(new_items);
}
let InsertManyResponse::Some {
entries_merged,
merge_kind,
} = resp.insertion_response
else {
return resp.tl_response;
};
let mut list = self.list.borrow_mut();
match merge_kind {
// TODO: update egui_virtual_list to support spliced inserts
MergeKind::Spliced => {
debug!("spliced when inserting {num_refs} new notes, resetting virtual list",);
list.reset();
}
MergeKind::FrontInsert => {
// only run this logic if we're reverse-chronological
// reversed in this case means chronological, since the
// default is reverse-chronological. yeah it's confusing.
if !reversed {
debug!("inserting {num_refs} new notes at start");
list.items_inserted_at_start(entries_merged);
}
}
}
};
resp.tl_response
}
pub fn select_down(&mut self) {
debug!("select_down {}", self.selection + 1);
if self.selection + 1 > self.notes.len() as i32 {
if self.selection + 1 > self.units.len() as i32 {
return;
}
@@ -202,6 +191,14 @@ impl TimelineTab {
}
}
impl<'a> UnknownPks<'a> {
pub fn process(&self, unknown_ids: &mut UnknownIds, ndb: &Ndb, txn: &Transaction) {
for pk in &self.unknown_pks {
unknown_ids.add_pubkey_if_missing(ndb, txn, pk);
}
}
}
/// A column in a deck. Holds navigation state, loaded notes, column kind, etc.
#[derive(Debug)]
pub struct Timeline {
@@ -293,15 +290,20 @@ impl Timeline {
/// Get the note refs for NotesAndReplies. If we only have Notes, then
/// just return that instead
pub fn all_or_any_notes(&self) -> &[NoteRef] {
self.notes(ViewFilter::NotesAndReplies).unwrap_or_else(|| {
self.notes(ViewFilter::Notes)
.expect("should have at least notes")
})
pub fn all_or_any_entries(&self) -> &TimelineUnits {
self.entries(ViewFilter::NotesAndReplies)
.unwrap_or_else(|| {
self.entries(ViewFilter::Notes)
.expect("should have at least notes")
})
}
pub fn notes(&self, view: ViewFilter) -> Option<&[NoteRef]> {
self.view(view).map(|v| &*v.notes)
pub fn entries(&self, view: ViewFilter) -> Option<&TimelineUnits> {
self.view(view).map(|v| &v.units)
}
pub fn latest_note(&self, view: ViewFilter) -> Option<&NoteRef> {
self.view(view).and_then(|v| v.units.latest())
}
pub fn view(&self, view: ViewFilter) -> Option<&TimelineTab> {
@@ -320,7 +322,7 @@ impl Timeline {
ndb: &Ndb,
note_cache: &mut NoteCache,
notes: &[NoteRef],
) {
) -> Option<UnknownPksOwned> {
let filters = {
let views = &self.views;
let filters: Vec<fn(&CachedNote, &Note) -> bool> =
@@ -328,6 +330,7 @@ impl Timeline {
filters
};
let mut unknown_pks = HashSet::new();
for note_ref in notes {
for (view, filter) in filters.iter().enumerate() {
if let Ok(note) = ndb.get_note_by_key(txn, note_ref.key) {
@@ -335,11 +338,32 @@ impl Timeline {
note_cache.cached_note_or_insert_mut(note_ref.key, &note),
&note,
) {
self.views[view].notes.push(*note_ref)
if let Some(resp) = self.views[view]
.units
.merge_new_notes(
vec![&NotePayload {
note,
key: note_ref.key,
}],
ndb,
txn,
)
.tl_response
{
let pks: HashSet<Pubkey> = resp
.unknown_pks
.into_iter()
.map(|r| Pubkey::new(*r))
.collect();
unknown_pks.extend(pks);
}
}
}
}
}
Some(UnknownPksOwned { pks: unknown_pks })
}
/// The main function used for inserting notes into timelines. Handles
@@ -354,7 +378,7 @@ impl Timeline {
note_cache: &mut NoteCache,
reversed: bool,
) -> Result<()> {
let mut new_refs: Vec<(Note, NoteRef)> = Vec::with_capacity(new_note_ids.len());
let mut payloads: Vec<NotePayload> = Vec::with_capacity(new_note_ids.len());
for key in new_note_ids {
let note = if let Ok(note) = ndb.get_note_by_key(txn, *key) {
@@ -371,35 +395,32 @@ impl Timeline {
// into the timeline
UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, &note);
let created_at = note.created_at();
new_refs.push((
note,
NoteRef {
key: *key,
created_at,
},
));
payloads.push(NotePayload { note, key: *key });
}
for view in &mut self.views {
match view.filter {
ViewFilter::NotesAndReplies => {
let refs: Vec<NoteRef> = new_refs.iter().map(|(_note, nr)| *nr).collect();
view.insert(&refs, reversed);
let res: Vec<&NotePayload<'_>> = payloads.iter().collect();
if let Some(res) = view.insert(res, ndb, txn, reversed) {
res.process(unknown_ids, ndb, txn);
}
}
ViewFilter::Notes => {
let mut filtered_refs = Vec::with_capacity(new_refs.len());
for (note, nr) in &new_refs {
let cached_note = note_cache.cached_note_or_insert(nr.key, note);
let mut filtered_payloads = Vec::with_capacity(payloads.len());
for payload in &payloads {
let cached_note =
note_cache.cached_note_or_insert(payload.key, &payload.note);
if ViewFilter::filter_notes(cached_note, note) {
filtered_refs.push(*nr);
if ViewFilter::filter_notes(cached_note, &payload.note) {
filtered_payloads.push(payload);
}
}
view.insert(&filtered_refs, reversed);
if let Some(res) = view.insert(filtered_payloads, ndb, txn, reversed) {
res.process(unknown_ids, ndb, txn);
}
}
}
}
@@ -436,6 +457,18 @@ impl Timeline {
}
}
pub struct UnknownPksOwned {
pub pks: HashSet<Pubkey>,
}
impl UnknownPksOwned {
pub fn process(&self, ndb: &Ndb, txn: &Transaction, unknown_ids: &mut UnknownIds) {
self.pks
.iter()
.for_each(|p| unknown_ids.add_pubkey_if_missing(ndb, txn, p));
}
}
pub enum MergeKind {
FrontInsert,
Spliced,
@@ -492,10 +525,11 @@ pub fn setup_new_timeline(
note_cache: &mut NoteCache,
since_optimize: bool,
accounts: &Accounts,
unknown_ids: &mut UnknownIds,
) {
// if we're ready, setup local subs
if is_timeline_ready(ndb, pool, note_cache, timeline, accounts) {
if let Err(err) = setup_timeline_nostrdb_sub(ndb, txn, note_cache, timeline) {
if is_timeline_ready(ndb, pool, note_cache, timeline, accounts, unknown_ids) {
if let Err(err) = setup_timeline_nostrdb_sub(ndb, txn, note_cache, timeline, unknown_ids) {
error!("setup_new_timeline: {err}");
}
}
@@ -564,7 +598,7 @@ pub fn send_initial_timeline_filter(
filter = filter.limit_mut(lim);
}
let notes = timeline.all_or_any_notes();
let entries = timeline.all_or_any_entries();
// Should we since optimize? Not always. For example
// if we only have a few notes locally. One way to
@@ -572,8 +606,8 @@ pub fn send_initial_timeline_filter(
// and seeing what its limit is. If we have less
// notes than the limit, we might want to backfill
// older notes
if can_since_optimize && filter::should_since_optimize(lim, notes.len()) {
filter = filter::since_optimize_filter(filter, notes);
if can_since_optimize && filter::should_since_optimize(lim, entries.len()) {
filter = filter::since_optimize_filter(filter, entries.latest());
} else {
warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", &timeline.kind);
}
@@ -593,18 +627,14 @@ pub fn send_initial_timeline_filter(
}
// we need some data first
FilterState::NeedsRemote => fetch_contact_list(subs, relay, timeline, accounts),
FilterState::NeedsRemote => fetch_contact_list(subs, timeline, accounts),
}
}
pub fn fetch_contact_list(
subs: &mut Subscriptions,
relay: &mut PoolRelay,
timeline: &mut Timeline,
accounts: &Accounts,
) {
let sub_kind = SubKind::FetchingContactList(timeline.kind.clone());
let sub = &accounts.get_subs().contacts;
pub fn fetch_contact_list(subs: &mut Subscriptions, timeline: &mut Timeline, accounts: &Accounts) {
if timeline.filter.get_any_ready().is_some() {
return;
}
let new_filter_state = match accounts.get_selected_account().data.contacts.get_state() {
ContactState::Unreceived => {
@@ -617,10 +647,14 @@ pub fn fetch_contact_list(
} => FilterState::GotRemote(filter::GotRemoteType::Contact),
};
timeline
.filter
.set_relay_state(relay.url().to_string(), new_filter_state);
timeline.filter.set_all_states(new_filter_state);
let sub = &accounts.get_subs().contacts;
if subs.subs.contains_key(&sub.remote) {
return;
}
let sub_kind = SubKind::FetchingContactList(timeline.kind.clone());
subs.subs.insert(sub.remote.clone(), sub_kind);
}
@@ -629,6 +663,7 @@ fn setup_initial_timeline(
txn: &Transaction,
timeline: &mut Timeline,
note_cache: &mut NoteCache,
unknown_ids: &mut UnknownIds,
filters: &HybridFilter,
) -> Result<()> {
// some timelines are one-shot and a refreshed, like last_per_pubkey algo feed
@@ -654,7 +689,9 @@ fn setup_initial_timeline(
.map(NoteRef::from_query_result)
.collect();
timeline.insert_new(txn, ndb, note_cache, &notes);
if let Some(pks) = timeline.insert_new(txn, ndb, note_cache, &notes) {
pks.process(ndb, txn, unknown_ids);
}
Ok(())
}
@@ -663,10 +700,11 @@ pub fn setup_initial_nostrdb_subs(
ndb: &Ndb,
note_cache: &mut NoteCache,
timeline_cache: &mut TimelineCache,
unknown_ids: &mut UnknownIds,
) -> Result<()> {
for (_kind, timeline) in timeline_cache {
let txn = Transaction::new(ndb).expect("txn");
if let Err(err) = setup_timeline_nostrdb_sub(ndb, &txn, note_cache, timeline) {
if let Err(err) = setup_timeline_nostrdb_sub(ndb, &txn, note_cache, timeline, unknown_ids) {
error!("setup_initial_nostrdb_subs: {err}");
}
}
@@ -679,6 +717,7 @@ fn setup_timeline_nostrdb_sub(
txn: &Transaction,
note_cache: &mut NoteCache,
timeline: &mut Timeline,
unknown_ids: &mut UnknownIds,
) -> Result<()> {
let filter_state = timeline
.filter
@@ -686,7 +725,7 @@ fn setup_timeline_nostrdb_sub(
.ok_or(Error::App(notedeck::Error::empty_contact_list()))?
.to_owned();
setup_initial_timeline(ndb, txn, timeline, note_cache, &filter_state)?;
setup_initial_timeline(ndb, txn, timeline, note_cache, unknown_ids, &filter_state)?;
Ok(())
}
@@ -701,6 +740,7 @@ pub fn is_timeline_ready(
note_cache: &mut NoteCache,
timeline: &mut Timeline,
accounts: &Accounts,
unknown_ids: &mut UnknownIds,
) -> bool {
// TODO: we should debounce the filter states a bit to make sure we have
// seen all of the different contact lists from each relay
@@ -774,7 +814,8 @@ pub fn is_timeline_ready(
// queries and setup the local subscription
info!("Found contact list! Setting up local and remote contact list query");
let txn = Transaction::new(ndb).expect("txn");
setup_initial_timeline(ndb, &txn, timeline, note_cache, &filter).expect("setup init");
setup_initial_timeline(ndb, &txn, timeline, note_cache, unknown_ids, &filter)
.expect("setup init");
timeline
.filter
.set_relay_state(relay_id, FilterState::ready_hybrid(filter.clone()));
@@ -0,0 +1,668 @@
use std::collections::{HashMap, HashSet};
use nostrdb::NoteKey;
use notedeck::NoteRef;
use crate::timeline::{
unit::{CompositeUnit, NoteUnit, NoteUnitFragment},
MergeKind,
};
type StorageIndex = usize;
/// Provides efficient access to `NoteUnit`s
/// Useful for threads and timelines
/// when reversed=false, sorts from newest to oldest
#[derive(Debug, Default)]
pub struct NoteUnits {
reversed: bool,
storage: Vec<NoteUnit>,
lookup: HashMap<UnitKey, StorageIndex>, // the key to index in `NoteUnits::storage`
order: Vec<StorageIndex>, // the sorted order of the `NoteUnit`s in `NoteUnits::storage`
}
impl NoteUnits {
pub fn values(&self) -> Values<'_> {
Values {
set: self,
front: 0,
back: self.order.len(),
}
}
pub fn contains_key(&self, k: &UnitKey) -> bool {
self.lookup.contains_key(k)
}
pub fn new_with_cap(cap: usize, reversed: bool) -> Self {
Self {
reversed,
storage: Vec::with_capacity(cap),
lookup: HashMap::with_capacity(cap),
order: Vec::with_capacity(cap),
}
}
pub fn len(&self) -> usize {
self.storage.len()
}
pub fn is_empty(&self) -> bool {
self.storage.is_empty()
}
/// Get the kth index from 0..Self::len
pub fn kth(&self, k: usize) -> Option<&NoteUnit> {
if k >= self.order.len() {
return None;
}
let idx = if self.reversed {
self.order[self.order.len() - 1 - k]
} else {
self.order[k]
};
Some(&self.storage[idx])
}
/// Core bulk insert for already-built `NoteUnit`s
/// Merges new `NoteUnit`s into `Self::storage`
/// Updates `Self::order`
fn merge_many_internal(
&mut self,
mut units: Vec<NoteUnit>,
touched_indices: &[usize],
) -> InsertManyResponse {
units.retain(|e| !self.lookup.contains_key(&e.key()));
if units.is_empty() && touched_indices.is_empty() {
return InsertManyResponse::Zero;
}
let mut touched = Vec::new();
if !touched_indices.is_empty() {
touched = touched_indices.to_vec();
touched.sort_unstable(); // sort for later reinsertion
touched.dedup();
self.order.retain(|i| touched.binary_search(i).is_err()); // temporarily remove touched from Self::order
}
units.sort_unstable();
units.dedup_by_key(|u| u.key());
let base = self.storage.len();
let mut new_order = Vec::with_capacity(units.len());
self.storage.reserve(units.len());
for (i, unit) in units.into_iter().enumerate() {
let idx = base + i;
let key = unit.key();
self.storage.push(unit);
self.lookup.insert(key, idx);
new_order.push(idx);
}
let inserted_new = new_order.len();
let front_insertion = inserted_new > 0
&& if self.order.is_empty() || new_order.is_empty() {
true
} else if !self.reversed {
let first_new = *new_order.first().unwrap();
let last_old = *self.order.last().unwrap();
self.storage[first_new] >= self.storage[last_old]
} else {
let last_new = *new_order.last().unwrap();
let first_old = *self.order.first().unwrap();
self.storage[last_new] <= self.storage[first_old]
};
let mut merged = Vec::with_capacity(self.order.len() + new_order.len());
let (mut i, mut j) = (0, 0);
while i < self.order.len() && j < new_order.len() {
let index_left = self.order[i];
let index_right = new_order[j];
let left_item = &self.storage[index_left];
let right_item = &self.storage[index_right];
if left_item <= right_item {
// left_item is newer than right_item
merged.push(index_left);
i += 1;
} else {
merged.push(index_right);
j += 1;
}
}
merged.extend_from_slice(&self.order[i..]);
merged.extend_from_slice(&new_order[j..]);
// reinsert touched
for touched_index in touched {
let pos = merged
.binary_search_by(|&i2| self.storage[i2].cmp(&self.storage[touched_index]))
.unwrap_or_else(|p| p);
merged.insert(pos, touched_index);
}
self.order = merged;
if inserted_new == 0 {
InsertManyResponse::Zero
} else if front_insertion {
InsertManyResponse::Some {
entries_merged: inserted_new,
merge_kind: MergeKind::FrontInsert,
}
} else {
InsertManyResponse::Some {
entries_merged: inserted_new,
merge_kind: MergeKind::Spliced,
}
}
}
/// Merges `NoteUnitFragment`s
/// `NoteUnitFragment::Single` is added normally
/// if `NoteUnitFragment::Composite` exists already, it will fold the fragment into the `CompositeUnit`
/// otherwise, it will generate the `NoteUnit::CompositeUnit` from the `NoteUnitFragment::Composite`
pub fn merge_fragments(&mut self, frags: Vec<NoteUnitFragment>) -> InsertManyResponse {
let mut to_build: HashMap<CompositeKey, CompositeUnit> = HashMap::new(); // new composites by key
let mut singles_to_build: Vec<NoteRef> = Vec::new();
let mut singles_seen: HashSet<NoteKey> = HashSet::new();
let mut touched = Vec::new();
for frag in frags {
match frag {
NoteUnitFragment::Single(note_ref) => {
let key = note_ref.key;
if self.lookup.contains_key(&UnitKey::Single(key)) {
continue;
}
if singles_seen.insert(key) {
singles_to_build.push(note_ref);
}
}
NoteUnitFragment::Composite(c_frag) => {
let key = c_frag.get_underlying_noteref().key;
let composite_type = c_frag.get_type();
if let Some(&storage_idx) = self.lookup.get(&UnitKey::Composite(c_frag.key())) {
if let Some(NoteUnit::Composite(c_unit)) = self.storage.get_mut(storage_idx)
{
if c_frag.get_latest_ref() < c_unit.get_latest_ref() {
touched.push(storage_idx);
}
c_frag.fold_into(c_unit);
continue;
}
}
// aggregate for new composite
use std::collections::hash_map::Entry;
match to_build.entry(CompositeKey {
key,
composite_type,
}) {
Entry::Occupied(mut o) => {
c_frag.fold_into(o.get_mut());
}
Entry::Vacant(v) => {
v.insert(c_frag.into());
}
}
}
}
}
let mut items: Vec<NoteUnit> = Vec::with_capacity(singles_to_build.len() + to_build.len());
items.extend(singles_to_build.into_iter().map(NoteUnit::Single));
items.extend(to_build.into_values().map(NoteUnit::Composite));
self.merge_many_internal(items, &touched)
}
/// Convienience method to merge a single note
pub fn merge_single_unit(&mut self, note_ref: NoteRef) -> InsertionResponse {
match self.merge_many_internal(vec![NoteUnit::Single(note_ref)], &[]) {
InsertManyResponse::Zero => InsertionResponse::AlreadyExists,
InsertManyResponse::Some {
entries_merged: _,
merge_kind,
} => InsertionResponse::Merged(merge_kind),
}
}
pub fn latest_ref(&self) -> Option<&NoteRef> {
if self.reversed {
self.order.last().map(|&i| &self.storage[i])
} else {
self.order.first().map(|&i| &self.storage[i])
}
.map(NoteUnit::get_latest_ref)
}
}
#[derive(Hash, PartialEq, Eq, Debug)]
pub struct CompositeKey {
pub key: NoteKey,
pub composite_type: CompositeType,
}
#[derive(Hash, PartialEq, Eq, Debug)]
pub enum CompositeType {
Reaction,
Repost,
}
#[derive(Hash, PartialEq, Eq, Debug)]
pub enum UnitKey {
Single(NoteKey),
Composite(CompositeKey),
}
pub enum InsertManyResponse {
Zero,
Some {
entries_merged: usize,
merge_kind: MergeKind,
},
}
pub struct Values<'a> {
set: &'a NoteUnits,
front: usize,
back: usize,
}
impl<'a> Iterator for Values<'a> {
type Item = &'a NoteUnit;
fn next(&mut self) -> Option<Self::Item> {
if self.front >= self.back {
return None;
}
let idx = if !self.set.reversed {
let i = self.front;
self.front += 1;
self.set.order[i]
} else {
self.back -= 1;
self.set.order[self.back]
};
Some(&self.set.storage[idx])
}
}
impl<'a> DoubleEndedIterator for Values<'a> {
fn next_back(&mut self) -> Option<Self::Item> {
if self.front >= self.back {
return None;
}
let idx = if !self.set.reversed {
self.back -= 1;
self.set.order[self.back]
} else {
let i = self.front;
self.front += 1;
self.set.order[i]
};
Some(&self.set.storage[idx])
}
}
pub enum InsertionResponse {
AlreadyExists,
Merged(MergeKind),
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashSet};
use egui::ahash::HashMap;
use enostr::Pubkey;
use nostrdb::NoteKey;
use notedeck::NoteRef;
use pretty_assertions::assert_eq;
use uuid::Uuid;
use crate::timeline::{
unit::{
CompositeFragment, CompositeUnit, NoteUnit, NoteUnitFragment, Reaction,
ReactionFragment, ReactionUnit, RepostFragment,
},
NoteUnits, RepostUnit,
};
#[derive(Default)]
struct UnitBuilder {
counter: u64,
frags: HashMap<String, NoteUnitFragment>,
units: NoteUnits,
}
impl UnitBuilder {
fn counter(&mut self) -> u64 {
let res = self.counter;
self.counter += 1;
res
}
fn random_sender(&mut self) -> Pubkey {
let mut out = [0u8; 32];
out[..8].copy_from_slice(&self.counter().to_le_bytes());
Pubkey::new(out)
}
fn build_reac_frag(&mut self, reacted_to: NoteRef) -> NoteUnitFragment {
NoteUnitFragment::Composite(CompositeFragment::Reaction(ReactionFragment {
noteref_reacted_to: reacted_to,
reaction_note_ref: NoteRef {
key: NoteKey::new(self.counter()),
created_at: self.counter(),
},
reaction: Reaction {
reaction: "+".to_owned(),
sender: self.random_sender(),
},
}))
}
fn build_repost_frag(&mut self, reposting: NoteRef) -> NoteUnitFragment {
NoteUnitFragment::Composite(CompositeFragment::Repost(RepostFragment {
reposted_noteref: reposting,
repost_noteref: self.new_noteref(),
reposter: self.random_sender(),
}))
}
fn insert_repost(&mut self, reposting: NoteRef) -> String {
let repost = self.build_repost_frag(reposting);
let id = Uuid::new_v4().to_string();
self.frags.insert(id.clone(), repost.clone());
self.units.merge_fragments(vec![repost]);
id
}
fn insert_reac_frag(&mut self, reacted_to: NoteRef) -> String {
let frag = self.build_reac_frag(reacted_to);
let id = Uuid::new_v4().to_string();
self.frags.insert(id.clone(), frag.clone());
self.units.merge_fragments(vec![frag]);
id
}
fn insert_reac_frag_pair(&mut self, reacted_to: NoteRef) -> (String, String) {
let frag1 = self.build_reac_frag(reacted_to);
let frag2 = self.build_reac_frag(reacted_to);
self.units
.merge_fragments(vec![frag1.clone(), frag2.clone()]);
let id1 = Uuid::new_v4().to_string();
self.frags.insert(id1.clone(), frag1);
let id2 = Uuid::new_v4().to_string();
self.frags.insert(id2.clone(), frag2);
(id1, id2)
}
fn new_noteref(&mut self) -> NoteRef {
NoteRef {
key: NoteKey::new(self.counter()),
created_at: self.counter(),
}
}
fn insert_note(&mut self) -> String {
let note_ref = NoteRef {
key: NoteKey::new(self.counter()),
created_at: self.counter(),
};
let id = Uuid::new_v4().to_string();
self.frags
.insert(id.clone(), NoteUnitFragment::Single(note_ref.clone()));
self.units.merge_single_unit(note_ref);
id
}
fn expected_reactions(&mut self, ids: Vec<&String>) -> NoteUnit {
let mut reactions = BTreeMap::new();
let mut reaction_id = None;
let mut senders = HashSet::new();
for id in ids {
let NoteUnitFragment::Composite(CompositeFragment::Reaction(reac)) =
self.frags.get(id).unwrap()
else {
panic!("got something other than reaction");
};
if let Some(prev_reac_id) = reaction_id {
if prev_reac_id != reac.noteref_reacted_to {
panic!("internal error");
}
}
reaction_id = Some(reac.noteref_reacted_to);
reactions.insert(reac.reaction_note_ref, reac.reaction.clone());
senders.insert(reac.reaction.sender);
}
NoteUnit::Composite(CompositeUnit::Reaction(ReactionUnit {
note_reacted_to: reaction_id.unwrap(),
reactions,
senders: senders,
}))
}
fn expected_reposts(&mut self, ids: Vec<&String>) -> NoteUnit {
let mut reposts = BTreeMap::new();
let mut reposted_id = None;
let mut senders = HashSet::new();
for id in ids {
let NoteUnitFragment::Composite(CompositeFragment::Repost(repost)) =
self.frags.get(id).unwrap()
else {
panic!("got something other than repost");
};
if let Some(prev_reposted_id) = reposted_id {
if prev_reposted_id != repost.reposted_noteref {
panic!("internal error");
}
}
reposted_id = Some(repost.reposted_noteref);
reposts.insert(repost.repost_noteref, repost.reposter);
senders.insert(repost.reposter);
}
NoteUnit::Composite(CompositeUnit::Repost(RepostUnit {
note_reposted: reposted_id.unwrap(),
reposts,
senders,
}))
}
fn expected_single(&mut self, id: &String) -> NoteUnit {
let Some(NoteUnitFragment::Single(note_ref)) = self.frags.get(id) else {
panic!("fail");
};
NoteUnit::Single(*note_ref)
}
fn asserted_at(&self, index: usize) -> NoteUnit {
self.units.kth(index).unwrap().clone()
}
fn aeq(&mut self, units_kth: usize, expect: Expect) {
assert_eq!(
self.asserted_at(units_kth),
match expect {
Expect::Single(id) => self.expected_single(id),
Expect::Reaction(items) => self.expected_reactions(items),
Expect::Repost(items) => self.expected_reposts(items),
}
);
}
}
enum Expect<'a> {
Single(&'a String),
Reaction(Vec<&'a String>),
Repost(Vec<&'a String>),
}
#[test]
fn test_reactions1() {
let mut builder = UnitBuilder::default();
let reaction_note = builder.new_noteref();
let single0 = builder.insert_note();
builder.aeq(0, Expect::Single(&single0));
let reac1 = builder.insert_reac_frag(reaction_note);
builder.aeq(0, Expect::Reaction(vec![&reac1]));
builder.aeq(1, Expect::Single(&single0));
let single1 = builder.insert_note();
builder.aeq(0, Expect::Single(&single1));
builder.aeq(1, Expect::Reaction(vec![&reac1]));
builder.aeq(2, Expect::Single(&single0));
let reac2 = builder.insert_reac_frag(reaction_note);
builder.aeq(0, Expect::Reaction(vec![&reac2, &reac1]));
builder.aeq(1, Expect::Single(&single1));
builder.aeq(2, Expect::Single(&single0));
let single2 = builder.insert_note();
builder.aeq(0, Expect::Single(&single2));
builder.aeq(1, Expect::Reaction(vec![&reac2, &reac1]));
builder.aeq(2, Expect::Single(&single1));
builder.aeq(3, Expect::Single(&single0));
let reac3 = builder.insert_reac_frag(reaction_note);
builder.aeq(0, Expect::Reaction(vec![&reac1, &reac2, &reac3]));
builder.aeq(1, Expect::Single(&single2));
builder.aeq(2, Expect::Single(&single1));
builder.aeq(3, Expect::Single(&single0));
}
#[test]
fn test_reactions2() {
let mut builder = UnitBuilder::default();
let reaction_note1 = builder.new_noteref();
let reaction_note2 = builder.new_noteref();
let single0 = builder.insert_note();
builder.aeq(0, Expect::Single(&single0));
let reac1_1 = builder.insert_reac_frag(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac1_1]));
builder.aeq(1, Expect::Single(&single0));
let reac2_1 = builder.insert_reac_frag(reaction_note2);
builder.aeq(0, Expect::Reaction(vec![&reac2_1]));
builder.aeq(1, Expect::Reaction(vec![&reac1_1]));
builder.aeq(2, Expect::Single(&single0));
let single1 = builder.insert_note();
builder.aeq(0, Expect::Single(&single1));
builder.aeq(1, Expect::Reaction(vec![&reac2_1]));
builder.aeq(2, Expect::Reaction(vec![&reac1_1]));
builder.aeq(3, Expect::Single(&single0));
let reac1_2 = builder.insert_reac_frag(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac1_2, &reac1_1]));
builder.aeq(1, Expect::Single(&single1));
builder.aeq(2, Expect::Reaction(vec![&reac2_1]));
builder.aeq(3, Expect::Single(&single0));
let single2 = builder.insert_note();
builder.aeq(0, Expect::Single(&single2));
builder.aeq(1, Expect::Reaction(vec![&reac1_2, &reac1_1]));
builder.aeq(2, Expect::Single(&single1));
builder.aeq(3, Expect::Reaction(vec![&reac2_1]));
builder.aeq(4, Expect::Single(&single0));
let reac1_3 = builder.insert_reac_frag(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac1_2, &reac1_1, &reac1_3]));
builder.aeq(1, Expect::Single(&single2));
builder.aeq(2, Expect::Single(&single1));
builder.aeq(3, Expect::Reaction(vec![&reac2_1]));
builder.aeq(4, Expect::Single(&single0));
let reac2_2 = builder.insert_reac_frag(reaction_note2);
builder.aeq(0, Expect::Reaction(vec![&reac2_1, &reac2_2]));
builder.aeq(1, Expect::Reaction(vec![&reac1_2, &reac1_1, &reac1_3]));
builder.aeq(2, Expect::Single(&single2));
builder.aeq(3, Expect::Single(&single1));
builder.aeq(4, Expect::Single(&single0));
}
#[test]
fn test_reactions3() {
let mut builder = UnitBuilder::default();
let reaction_note1 = builder.new_noteref();
let single1 = builder.insert_note();
builder.aeq(0, Expect::Single(&single1));
let reac0 = builder.insert_reac_frag(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac0]));
builder.aeq(1, Expect::Single(&single1));
let (reac1, reac2) = builder.insert_reac_frag_pair(reaction_note1);
builder.aeq(0, Expect::Reaction(vec![&reac0, &reac1, &reac2]));
builder.aeq(1, Expect::Single(&single1));
let single2 = builder.insert_note();
builder.aeq(0, Expect::Single(&single2));
builder.aeq(1, Expect::Reaction(vec![&reac0, &reac1, &reac2]));
builder.aeq(2, Expect::Single(&single1));
}
#[test]
fn test_repost() {
let mut builder = UnitBuilder::default();
let repost_note = builder.new_noteref();
let single1 = builder.insert_note();
builder.aeq(0, Expect::Single(&single1));
let repost1 = builder.insert_repost(repost_note);
builder.aeq(0, Expect::Repost(vec![&repost1]));
builder.aeq(1, Expect::Single(&single1));
let single2 = builder.insert_note();
builder.aeq(0, Expect::Single(&single2));
builder.aeq(1, Expect::Repost(vec![&repost1]));
builder.aeq(2, Expect::Single(&single1));
let reac1 = builder.insert_reac_frag(repost_note);
builder.aeq(0, Expect::Reaction(vec![&reac1]));
builder.aeq(1, Expect::Single(&single2));
builder.aeq(2, Expect::Repost(vec![&repost1]));
builder.aeq(3, Expect::Single(&single1));
let repost2 = builder.insert_repost(repost_note);
builder.aeq(0, Expect::Repost(vec![&repost1, &repost2]));
builder.aeq(1, Expect::Reaction(vec![&reac1]));
builder.aeq(2, Expect::Single(&single2));
builder.aeq(3, Expect::Single(&single1));
let reac2 = builder.insert_reac_frag(repost_note);
builder.aeq(0, Expect::Reaction(vec![&reac1, &reac2]));
builder.aeq(1, Expect::Repost(vec![&repost1, &repost2]));
builder.aeq(2, Expect::Single(&single2));
builder.aeq(3, Expect::Single(&single1));
}
}
+38 -101
View File
@@ -1,8 +1,3 @@
use std::{
collections::{BTreeSet, HashSet},
hash::Hash,
};
use egui_nav::ReturnType;
use egui_virtual_list::VirtualList;
use enostr::{NoteId, RelayPool};
@@ -13,13 +8,17 @@ use notedeck::{NoteCache, NoteRef, UnknownIds};
use crate::{
actionbar::{process_thread_notes, NewThreadNotes},
multi_subscriber::ThreadSubs,
timeline::MergeKind,
timeline::{
note_units::{NoteUnits, UnitKey},
unit::NoteUnit,
InsertionResponse,
},
};
use super::ThreadSelection;
pub struct ThreadNode {
pub replies: HybridSet<NoteRef>,
pub replies: SingleNoteUnits,
pub prev: ParentState,
pub have_all_ancestors: bool,
pub list: VirtualList,
@@ -33,103 +32,10 @@ pub enum ParentState {
Parent(NoteId),
}
/// Affords:
/// - O(1) contains
/// - O(log n) sorted insertion
pub struct HybridSet<T> {
reversed: bool,
lookup: HashSet<T>, // fast deduplication
ordered: BTreeSet<T>, // sorted iteration
}
impl<T> Default for HybridSet<T> {
fn default() -> Self {
Self {
reversed: Default::default(),
lookup: Default::default(),
ordered: Default::default(),
}
}
}
pub enum InsertionResponse {
AlreadyExists,
Merged(MergeKind),
}
impl<T: Copy + Ord + Eq + Hash> HybridSet<T> {
pub fn insert(&mut self, val: T) -> InsertionResponse {
if !self.lookup.insert(val) {
return InsertionResponse::AlreadyExists;
}
let front_insertion = match self.ordered.iter().next() {
Some(first) => (val >= *first) == self.reversed,
None => true,
};
self.ordered.insert(val); // O(log n)
InsertionResponse::Merged(if front_insertion {
MergeKind::FrontInsert
} else {
MergeKind::Spliced
})
}
}
impl<T: Eq + Hash> HybridSet<T> {
pub fn contains(&self, val: &T) -> bool {
self.lookup.contains(val) // O(1)
}
}
impl<T> HybridSet<T> {
pub fn iter(&self) -> HybridIter<'_, T> {
HybridIter {
inner: self.ordered.iter(),
reversed: self.reversed,
}
}
pub fn new(reversed: bool) -> Self {
Self {
reversed,
..Default::default()
}
}
}
impl<'a, T> IntoIterator for &'a HybridSet<T> {
type Item = &'a T;
type IntoIter = HybridIter<'a, T>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
pub struct HybridIter<'a, T> {
inner: std::collections::btree_set::Iter<'a, T>,
reversed: bool,
}
impl<'a, T> Iterator for HybridIter<'a, T> {
type Item = &'a T;
fn next(&mut self) -> Option<Self::Item> {
if self.reversed {
self.inner.next_back()
} else {
self.inner.next()
}
}
}
impl ThreadNode {
pub fn new(parent: ParentState) -> Self {
Self {
replies: HybridSet::new(true),
replies: SingleNoteUnits::new(true),
prev: parent,
have_all_ancestors: false,
list: VirtualList::new(),
@@ -487,3 +393,34 @@ impl NoteSeenFlags {
self.flags.contains_key(&note_id)
}
}
#[derive(Default)]
pub struct SingleNoteUnits {
units: NoteUnits,
}
impl SingleNoteUnits {
pub fn new(reversed: bool) -> Self {
Self {
units: NoteUnits::new_with_cap(0, reversed),
}
}
pub fn insert(&mut self, note_ref: NoteRef) -> InsertionResponse {
self.units.merge_single_unit(note_ref)
}
pub fn values(&self) -> impl Iterator<Item = &NoteRef> {
self.units.values().filter_map(|entry| {
if let NoteUnit::Single(note_ref) = entry {
Some(note_ref)
} else {
None
}
})
}
pub fn contains_key(&self, k: &NoteKey) -> bool {
self.units.contains_key(&UnitKey::Single(*k))
}
}
@@ -0,0 +1,242 @@
use std::collections::HashSet;
use enostr::Pubkey;
use nostrdb::{Ndb, Note, NoteKey, Transaction};
use notedeck::NoteRef;
use notedeck_ui::note::get_reposted_note;
use crate::timeline::{
note_units::{InsertManyResponse, NoteUnits},
unit::{
CompositeFragment, NoteUnit, NoteUnitFragment, Reaction, ReactionFragment, RepostFragment,
},
};
#[derive(Debug, Default)]
pub struct TimelineUnits {
pub units: NoteUnits,
}
impl TimelineUnits {
pub fn with_capacity(cap: usize) -> Self {
Self {
units: NoteUnits::new_with_cap(cap, false),
}
}
pub fn from_refs_single(refs: Vec<NoteRef>) -> Self {
let mut entries = TimelineUnits::default();
refs.into_iter().for_each(|r| entries.merge_single_note(r));
entries
}
pub fn len(&self) -> usize {
self.units.len()
}
pub fn is_empty(&self) -> bool {
self.units.len() == 0
}
/// returns number of new entries merged
pub fn merge_new_notes<'a>(
&mut self,
payloads: Vec<&'a NotePayload>,
ndb: &Ndb,
txn: &Transaction,
) -> MergeResponse<'a> {
let mut unknown_pks = HashSet::with_capacity(payloads.len());
let new_fragments = payloads
.into_iter()
.filter_map(|p| to_fragment(p, ndb, txn))
.map(|f| {
if let Some(pk) = f.unknown_pk {
unknown_pks.insert(pk);
}
f.fragment
})
.collect();
let tl_response = if unknown_pks.is_empty() {
None
} else {
Some(UnknownPks { unknown_pks })
};
MergeResponse {
insertion_response: self.units.merge_fragments(new_fragments),
tl_response,
}
}
pub fn latest(&self) -> Option<&NoteRef> {
self.units.latest_ref()
}
pub fn merge_single_note(&mut self, note_ref: NoteRef) {
self.units.merge_single_unit(note_ref);
}
/// Used in the view
pub fn get(&self, index: usize) -> Option<&NoteUnit> {
self.units.kth(index)
}
}
pub struct MergeResponse<'a> {
pub insertion_response: InsertManyResponse,
pub tl_response: Option<UnknownPks<'a>>,
}
pub struct UnknownPks<'a> {
pub(crate) unknown_pks: HashSet<&'a [u8; 32]>,
}
pub struct NoteUnitFragmentResponse<'a> {
pub fragment: NoteUnitFragment,
pub unknown_pk: Option<&'a [u8; 32]>,
}
pub struct NotePayload<'a> {
pub note: Note<'a>,
pub key: NoteKey,
}
impl<'a> NotePayload<'a> {
pub fn noteref(&self) -> NoteRef {
NoteRef {
key: self.key,
created_at: self.note.created_at(),
}
}
}
fn to_fragment<'a>(
payload: &'a NotePayload,
ndb: &Ndb,
txn: &Transaction,
) -> Option<NoteUnitFragmentResponse<'a>> {
match payload.note.kind() {
1 => Some(NoteUnitFragmentResponse {
fragment: NoteUnitFragment::Single(NoteRef {
key: payload.key,
created_at: payload.note.created_at(),
}),
unknown_pk: None,
}),
7 => to_reaction(payload, ndb, txn).map(|r| NoteUnitFragmentResponse {
fragment: NoteUnitFragment::Composite(CompositeFragment::Reaction(r.fragment)),
unknown_pk: Some(r.pk),
}),
6 => to_repost(payload, ndb, txn).map(RepostResponse::into),
_ => None,
}
}
fn to_reaction<'a>(
payload: &'a NotePayload,
ndb: &Ndb,
txn: &Transaction,
) -> Option<ReactionResponse<'a>> {
let reaction = payload.note.content();
let mut note_reacted_to = None;
for tag in payload.note.tags() {
if tag.count() < 2 {
continue;
}
let Some("e") = tag.get_str(0) else {
continue;
};
let Some(react_to_id) = tag.get_id(1) else {
continue;
};
note_reacted_to = Some(react_to_id);
}
let reacted_to_noteid = note_reacted_to?;
let reaction_note_ref = payload.noteref();
let reacted_to_note = ndb.get_note_by_id(txn, reacted_to_noteid).ok()?;
let noteref_reacted_to = NoteRef {
key: reacted_to_note.key()?,
created_at: reacted_to_note.created_at(),
};
Some(ReactionResponse {
fragment: ReactionFragment {
noteref_reacted_to,
reaction_note_ref,
reaction: Reaction {
reaction: reaction.to_string(),
sender: Pubkey::new(*payload.note.pubkey()),
},
},
pk: payload.note.pubkey(),
})
}
pub struct ReactionResponse<'a> {
fragment: ReactionFragment,
pk: &'a [u8; 32], // reaction sender
}
pub struct RepostResponse<'a> {
fragment: RepostFragment,
reposter_pk: &'a [u8; 32],
}
impl<'a> From<RepostResponse<'a>> for NoteUnitFragmentResponse<'a> {
fn from(value: RepostResponse<'a>) -> Self {
Self {
fragment: NoteUnitFragment::Composite(CompositeFragment::Repost(value.fragment)),
unknown_pk: Some(value.reposter_pk),
}
}
}
fn to_repost<'a>(
payload: &'a NotePayload,
ndb: &Ndb,
txn: &Transaction,
) -> Option<RepostResponse<'a>> {
let reposted_note = match get_reposted_note(ndb, txn, &payload.note) {
Some(r) => r,
None => {
tracing::error!(
"Could not get reposted note for note id {}",
enostr::NoteId::new(*payload.note.id()).hex()
);
return None;
}
};
let reposted_key = match reposted_note.key() {
Some(r) => r,
None => {
tracing::error!(
"Could not get key of reposted note {}",
enostr::NoteId::new(*reposted_note.id()).hex()
);
return None;
}
};
Some(RepostResponse {
fragment: RepostFragment {
reposted_noteref: NoteRef {
key: reposted_key,
created_at: reposted_note.created_at(),
},
repost_noteref: payload.noteref(),
reposter: Pubkey::new(*payload.note.pubkey()),
},
reposter_pk: payload.note.pubkey(),
})
}
@@ -0,0 +1,302 @@
use std::collections::{BTreeMap, HashSet};
use enostr::Pubkey;
use notedeck::NoteRef;
use crate::timeline::note_units::{CompositeKey, CompositeType, UnitKey};
/// A `NoteUnit` represents a cohesive piece of data derived from notes
#[derive(Debug, Clone)]
pub enum NoteUnit {
Single(NoteRef), // A single note
Composite(CompositeUnit),
}
impl NoteUnit {
pub fn key(&self) -> UnitKey {
match self {
NoteUnit::Single(note_ref) => UnitKey::Single(note_ref.key),
NoteUnit::Composite(clustered_entry) => UnitKey::Composite(clustered_entry.key()),
}
}
pub fn get_underlying_noteref(&self) -> &NoteRef {
match self {
NoteUnit::Single(note_ref) => note_ref,
NoteUnit::Composite(clustered) => match clustered {
CompositeUnit::Reaction(reaction_entry) => &reaction_entry.note_reacted_to,
CompositeUnit::Repost(repost_unit) => &repost_unit.note_reposted,
},
}
}
pub fn get_latest_ref(&self) -> &NoteRef {
match self {
NoteUnit::Single(note_ref) => note_ref,
NoteUnit::Composite(composite_unit) => composite_unit.get_latest_ref(),
}
}
}
impl Ord for NoteUnit {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.get_latest_ref().cmp(other.get_latest_ref())
}
}
impl PartialEq for NoteUnit {
fn eq(&self, other: &Self) -> bool {
self.get_latest_ref() == other.get_latest_ref()
}
}
impl Eq for NoteUnit {}
impl PartialOrd for NoteUnit {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
/// Combines potentially many notes into one cohesive piece of data
#[derive(Debug, Clone)]
pub enum CompositeUnit {
Reaction(ReactionUnit),
Repost(RepostUnit),
}
impl CompositeUnit {
pub fn get_latest_ref(&self) -> &NoteRef {
match self {
CompositeUnit::Reaction(reaction_unit) => reaction_unit.get_latest_ref(),
CompositeUnit::Repost(repost_unit) => repost_unit.get_latest_ref(),
}
}
}
impl PartialEq for CompositeUnit {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Reaction(l0), Self::Reaction(r0)) => l0 == r0,
(Self::Repost(l0), Self::Repost(r0)) => l0 == r0,
_ => false,
}
}
}
impl CompositeUnit {
pub fn key(&self) -> CompositeKey {
match self {
CompositeUnit::Reaction(reaction_entry) => CompositeKey {
key: reaction_entry.note_reacted_to.key,
composite_type: CompositeType::Reaction,
},
CompositeUnit::Repost(repost_unit) => CompositeKey {
key: repost_unit.note_reposted.key,
composite_type: CompositeType::Repost,
},
}
}
}
impl From<CompositeFragment> for CompositeUnit {
fn from(value: CompositeFragment) -> Self {
match value {
CompositeFragment::Reaction(reaction_fragment) => {
CompositeUnit::Reaction(reaction_fragment.into())
}
CompositeFragment::Repost(repost_fragment) => {
CompositeUnit::Repost(repost_fragment.into())
}
}
}
}
/// Represents all the reactions to a specific note `ReactionUnit::note_reacted_to`
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct ReactionUnit {
pub note_reacted_to: NoteRef, // NOTE: this should not be modified after it's created
pub reactions: BTreeMap<NoteRef, Reaction>,
pub senders: HashSet<Pubkey>, // useful for making sure the same user can't add more than one reaction to a note
}
impl ReactionUnit {
pub fn get_latest_ref(&self) -> &NoteRef {
self.reactions
.first_key_value()
.map(|(r, _)| r)
.unwrap_or(&self.note_reacted_to)
}
}
impl From<ReactionFragment> for ReactionUnit {
fn from(frag: ReactionFragment) -> Self {
let mut senders = HashSet::new();
senders.insert(frag.reaction.sender);
let mut reactions = BTreeMap::new();
reactions.insert(frag.reaction_note_ref, frag.reaction);
Self {
note_reacted_to: frag.noteref_reacted_to,
reactions,
senders,
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct RepostUnit {
pub note_reposted: NoteRef,
pub reposts: BTreeMap<NoteRef, Pubkey>, // repost note to sender
pub senders: HashSet<Pubkey>,
}
impl RepostUnit {
pub fn get_latest_ref(&self) -> &NoteRef {
self.reposts
.first_key_value()
.map(|(r, _)| r)
.unwrap_or(&self.note_reposted)
}
}
impl From<RepostFragment> for RepostUnit {
fn from(value: RepostFragment) -> Self {
let mut reposts = BTreeMap::new();
reposts.insert(value.repost_noteref, value.reposter);
let mut senders = HashSet::new();
senders.insert(value.reposter);
Self {
note_reposted: value.reposted_noteref,
reposts,
senders,
}
}
}
#[derive(Clone)]
pub enum NoteUnitFragment {
Single(NoteRef),
Composite(CompositeFragment),
}
#[derive(Debug, Clone)]
pub enum CompositeFragment {
Reaction(ReactionFragment),
Repost(RepostFragment),
}
impl CompositeFragment {
pub fn fold_into(self, unit: &mut CompositeUnit) {
match self {
CompositeFragment::Reaction(reaction_fragment) => {
let CompositeUnit::Reaction(reaction_unit) = unit else {
tracing::error!("Attempting to fold a reaction fragment into a unit which isn't ReactionUnit. Doing nothing, this should never occur");
return;
};
reaction_fragment.fold_into(reaction_unit);
}
CompositeFragment::Repost(repost_fragment) => {
let CompositeUnit::Repost(repost_unit) = unit else {
tracing::error!("Attempting to fold a repost fragment into a unit which isn't RepostUnit. Doing nothing, this should never occur");
return;
};
repost_fragment.fold_into(repost_unit);
}
}
}
pub fn key(&self) -> CompositeKey {
match self {
CompositeFragment::Reaction(reaction) => CompositeKey {
key: reaction.noteref_reacted_to.key,
composite_type: CompositeType::Reaction,
},
CompositeFragment::Repost(repost) => CompositeKey {
key: repost.reposted_noteref.key,
composite_type: CompositeType::Repost,
},
}
}
pub fn get_underlying_noteref(&self) -> &NoteRef {
match self {
CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.noteref_reacted_to,
CompositeFragment::Repost(repost_fragment) => &repost_fragment.reposted_noteref,
}
}
pub fn get_latest_ref(&self) -> &NoteRef {
match self {
CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.reaction_note_ref,
CompositeFragment::Repost(repost_fragment) => &repost_fragment.repost_noteref,
}
}
pub fn get_type(&self) -> CompositeType {
match self {
CompositeFragment::Reaction(_) => CompositeType::Reaction,
CompositeFragment::Repost(_) => CompositeType::Repost,
}
}
}
/// A singluar reaction to a note
#[derive(Debug, Clone)]
pub struct ReactionFragment {
pub noteref_reacted_to: NoteRef,
pub reaction_note_ref: NoteRef,
pub reaction: Reaction,
}
impl ReactionFragment {
/// Add all the contents of Self into `CompositeUnit`
pub fn fold_into(self, unit: &mut ReactionUnit) {
if self.noteref_reacted_to != unit.note_reacted_to {
tracing::error!("Attempting to fold a reaction fragment into a ReactionUnit which as a different note reacted to: {:?} != {:?}. This should never occur", self.noteref_reacted_to, unit.note_reacted_to);
return;
}
if unit.senders.contains(&self.reaction.sender) {
return;
}
unit.senders.insert(self.reaction.sender);
unit.reactions.insert(self.reaction_note_ref, self.reaction);
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Reaction {
pub reaction: String, // can't use char because some emojis are 'grapheme clusters'
pub sender: Pubkey,
}
/// Represents a singular repost
#[derive(Debug, Clone)]
pub struct RepostFragment {
pub reposted_noteref: NoteRef,
pub repost_noteref: NoteRef,
pub reposter: Pubkey,
}
impl RepostFragment {
pub fn fold_into(self, unit: &mut RepostUnit) {
if self.reposted_noteref != unit.note_reposted {
tracing::error!("Attempting to fold a repost fragment into a RepostUnit which has a different note reposted: {:?} != {:?}. This should never occur", self.reposted_noteref, unit.note_reposted);
return;
}
if unit.senders.contains(&self.reposter) {
return;
}
unit.senders.insert(self.reposter);
unit.reposts.insert(self.repost_noteref, self.reposter);
}
}
+76
View File
@@ -0,0 +1,76 @@
use nostrdb::Transaction;
use notedeck::AppContext;
use crate::{
timeline::{kind::ListKind, TimelineKind},
Damus, Route,
};
// TODO(kernelkind): should account for mutes
pub fn unseen_notification(
columns: &mut Damus,
ndb: &nostrdb::Ndb,
current_pk: notedeck::enostr::Pubkey,
) -> bool {
let Some(tl) = columns
.timeline_cache
.get_mut(&TimelineKind::Notifications(current_pk))
else {
return false;
};
let freshness = &mut tl.current_view_mut().freshness;
freshness.update(|timestamp_last_viewed| {
let filter = crate::timeline::kind::notifications_filter(&current_pk)
.since_mut(timestamp_last_viewed);
let txn = Transaction::new(ndb).expect("txn");
let Some(res) = ndb.query(&txn, &[filter], 1).ok() else {
return false;
};
!res.is_empty()
});
freshness.has_unseen()
}
/// When you click the toolbar button, these actions
/// are returned
#[derive(Debug, Eq, PartialEq)]
pub enum ToolbarAction {
Notifications,
Search,
Home,
}
impl ToolbarAction {
pub fn process(&self, app: &mut Damus, ctx: &mut AppContext) {
let cur_acc_pk = ctx.accounts.get_selected_account().key.pubkey;
let route = match &self {
ToolbarAction::Notifications => {
Route::timeline(TimelineKind::Notifications(cur_acc_pk))
}
ToolbarAction::Search => Route::Search,
ToolbarAction::Home => {
Route::timeline(TimelineKind::List(ListKind::Contact(cur_acc_pk)))
}
};
let Some(cols) = app.decks_cache.active_columns_mut(ctx.i18n, ctx.accounts) else {
return;
};
match cols.select_by_route(route) {
crate::column::SelectionResult::AlreadySelected(_) => {} // great! no need to go to top yet
crate::column::SelectionResult::NewSelection(_) => {
// we already selected this, so scroll to top
app.scroll_to_top();
}
crate::column::SelectionResult::Failed => {
// oh no, something went wrong
// TODO(jb55): handle tab selection failure
}
}
}
}
@@ -1,4 +1,5 @@
use crate::login_manager::AcquireKeyState;
use crate::ui::onboarding::FollowPacksResponse;
use crate::ui::{Preview, PreviewConfig};
use egui::{
Align, Button, Color32, Frame, InnerResponse, Layout, Margin, RichText, TextEdit, Vec2,
@@ -18,7 +19,8 @@ pub struct AccountLoginView<'a> {
}
pub enum AccountLoginResponse {
CreateNew,
CreatingNew,
Onboarding(FollowPacksResponse),
LoginWith(Keypair),
}
@@ -57,7 +59,7 @@ impl<'a> AccountLoginView<'a> {
let text_edit_width = available_width - button_width;
let textedit_resp = ui.add_sized([text_edit_width, 40.0], login_textedit(self.manager, self.i18n));
input_context(&textedit_resp, self.clipboard, self.manager.input_buffer(), PasteBehavior::Clear);
input_context(ui, &textedit_resp, self.clipboard, self.manager.input_buffer(), PasteBehavior::Clear);
if eye_button(ui, self.manager.password_visible()).clicked() {
self.manager.toggle_password_visibility();
@@ -96,7 +98,7 @@ impl<'a> AccountLoginView<'a> {
});
if self.manager.check_for_create_new() {
return Some(AccountLoginResponse::CreateNew);
return Some(AccountLoginResponse::CreatingNew);
}
if let Some(keypair) = self.manager.get_login_keypair() {
@@ -155,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()
@@ -709,6 +709,7 @@ pub fn render_add_column_routes(
ctx.note_cache,
app.options.contains(AppOptions::SinceOptimize),
ctx.accounts,
ctx.unknown_ids,
);
app.columns_mut(ctx.i18n, ctx.accounts)
@@ -749,6 +750,7 @@ pub fn render_add_column_routes(
ctx.note_cache,
app.options.contains(AppOptions::SinceOptimize),
ctx.accounts,
ctx.unknown_ids,
);
app.columns_mut(ctx.i18n, ctx.accounts)
+2
View File
@@ -7,6 +7,7 @@ pub mod edit_deck;
pub mod images;
pub mod mentions_picker;
pub mod note;
pub mod onboarding;
pub mod post;
pub mod preview;
pub mod profile;
@@ -17,6 +18,7 @@ pub mod side_panel;
pub mod support;
pub mod thread;
pub mod timeline;
pub mod toolbar;
pub mod wallet;
pub mod widgets;
@@ -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] = [
+31 -21
View File
@@ -1,11 +1,9 @@
use crate::draft::{Draft, Drafts, MentionHint};
#[cfg(not(target_os = "android"))]
use crate::media_upload::{nostrbuild_nip96_upload, MediaPath};
use crate::media_upload::nostrbuild_nip96_upload;
use crate::post::{downcast_post_buffer, MentionType, NewPost};
use crate::ui::mentions_picker::MentionPickerView;
use crate::ui::{self, Preview, PreviewConfig};
use crate::Result;
use egui::{
text::{CCursorRange, LayoutJob},
text_edit::TextEditOutput,
@@ -16,19 +14,22 @@ use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool};
use nostrdb::{Ndb, Transaction};
use notedeck::media::gif::ensure_latest_texture;
use notedeck::media::AnimationMode;
#[cfg(target_os = "android")]
use notedeck::platform::android::try_open_file_picker;
use notedeck::platform::get_next_selected_file;
use notedeck::{get_render_state, JobsCache, PixelDimensions, RenderState};
use notedeck::{
name::get_display_name, supported_mime_hosted_at_url, tr, Localization, NoteAction, NoteContext,
};
use notedeck_ui::{
app_images,
context_menu::{input_context, PasteBehavior},
note::render_note_preview,
NoteOptions, ProfilePic,
};
use notedeck::{
name::get_display_name, supported_mime_hosted_at_url, tr, Localization, NoteAction, NoteContext,
};
use tracing::error;
#[cfg(not(target_os = "android"))]
use {notedeck::platform::file::emit_selected_file, notedeck::platform::file::SelectedMedia};
pub struct PostView<'a, 'd> {
note_context: &'a mut NoteContext<'d>,
@@ -209,6 +210,7 @@ impl<'a, 'd> PostView<'a, 'd> {
let out = textedit.show(ui);
input_context(
ui,
&out.response,
self.note_context.clipboard,
&mut self.draft.buffer.text_buffer,
@@ -340,6 +342,22 @@ impl<'a, 'd> PostView<'a, 'd> {
}
pub fn ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> PostResponse {
while let Some(selected_file) = get_next_selected_file() {
match selected_file {
Ok(selected_media) => {
let promise = nostrbuild_nip96_upload(
self.poster.secret_key.secret_bytes(),
selected_media,
);
self.draft.uploading_media.push(promise);
}
Err(e) => {
error!("{e}");
self.draft.upload_errors.push(e.to_string());
}
}
}
ScrollArea::vertical()
.id_salt(PostView::scroll_id())
.show(ui, |ui| self.ui_no_scroll(txn, ui))
@@ -520,22 +538,14 @@ impl<'a, 'd> PostView<'a, 'd> {
{
if let Some(files) = rfd::FileDialog::new().pick_files() {
for file in files {
match MediaPath::new(file) {
Ok(media_path) => {
let promise = nostrbuild_nip96_upload(
self.poster.secret_key.secret_bytes(),
media_path,
);
self.draft.uploading_media.push(promise);
}
Err(e) => {
error!("{e}");
self.draft.upload_errors.push(e.to_string());
}
}
emit_selected_file(SelectedMedia::from_path(file));
}
}
}
#[cfg(target_os = "android")]
{
try_open_file_picker();
}
}
}
+2 -1
View File
@@ -55,6 +55,7 @@ impl<'a, 'd> PostReplyView<'a, 'd> {
pub fn show(&mut self, ui: &mut egui::Ui) -> PostResponse {
ScrollArea::vertical()
.id_salt(self.scroll_id)
.stick_to_bottom(true)
.show(ui, |ui| self.show_internal(ui))
.inner
}
@@ -121,7 +122,7 @@ impl<'a, 'd> PostReplyView<'a, 'd> {
// large and things start breaking. I think this is an ok
// solution but there could be a better one.
//
ui.add_space(500.0);
//ui.add_space(500.0);
post_response
})
@@ -0,0 +1,119 @@
use std::mem;
use egui::{Layout, ScrollArea};
use nostrdb::Ndb;
use notedeck::{tr, Images, JobPool, JobsCache, Localization};
use notedeck_ui::{
colors,
nip51_set::{Nip51SetUiCache, Nip51SetWidget, Nip51SetWidgetAction, Nip51SetWidgetFlags},
};
use crate::{onboarding::Onboarding, ui::widgets::styled_button};
/// Display Follow Packs for the user to choose from authors trusted by the Damus team
pub struct FollowPackOnboardingView<'a> {
onboarding: &'a mut Onboarding,
ui_state: &'a mut Nip51SetUiCache,
ndb: &'a Ndb,
images: &'a mut Images,
loc: &'a mut Localization,
job_pool: &'a mut JobPool,
jobs: &'a mut JobsCache,
}
pub enum OnboardingResponse {
FollowPacks(FollowPacksResponse),
ViewProfile(enostr::Pubkey),
}
pub enum FollowPacksResponse {
NoFollowPacks,
UserSelectedPacks(Nip51SetUiCache),
}
impl<'a> FollowPackOnboardingView<'a> {
pub fn new(
onboarding: &'a mut Onboarding,
ui_state: &'a mut Nip51SetUiCache,
ndb: &'a Ndb,
images: &'a mut Images,
loc: &'a mut Localization,
job_pool: &'a mut JobPool,
jobs: &'a mut JobsCache,
) -> Self {
Self {
onboarding,
ui_state,
ndb,
images,
loc,
job_pool,
jobs,
}
}
pub fn scroll_id() -> egui::Id {
egui::Id::new("follow_pack_onboarding")
}
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<OnboardingResponse> {
let Some(follow_pack_state) = self.onboarding.get_follow_packs() else {
return Some(OnboardingResponse::FollowPacks(
FollowPacksResponse::NoFollowPacks,
));
};
let max_height = ui.available_height() - 48.0;
let mut action = None;
ScrollArea::vertical()
.id_salt(Self::scroll_id())
.max_height(max_height)
.show(ui, |ui| {
egui::Frame::new().inner_margin(8.0).show(ui, |ui| {
self.onboarding.list.borrow_mut().ui_custom_layout(
ui,
follow_pack_state.len(),
|ui, index| {
let resp = Nip51SetWidget::new(
follow_pack_state,
self.ui_state,
self.ndb,
self.loc,
self.images,
self.job_pool,
self.jobs,
)
.with_flags(Nip51SetWidgetFlags::TRUST_IMAGES)
.render_at_index(ui, index);
if let Some(cur_action) = resp.action {
match cur_action {
Nip51SetWidgetAction::ViewProfile(pubkey) => {
action = Some(OnboardingResponse::ViewProfile(pubkey));
}
}
}
if resp.rendered {
1
} else {
0
}
},
);
})
});
ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
ui.add_space(4.0);
if ui.add(styled_button(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)),
));
}
});
action
}
}
+36 -21
View File
@@ -1,12 +1,15 @@
use core::f32;
use egui::{vec2, Button, CornerRadius, Layout, Margin, RichText, ScrollArea, TextEdit};
use egui_winit::clipboard::Clipboard;
use enostr::ProfileState;
use notedeck::{profile::unwrap_profile_url, tr, Images, Localization, NotedeckTextStyle};
use notedeck_ui::context_menu::{input_context, PasteBehavior};
use notedeck_ui::{profile::banner, ProfilePic};
pub struct EditProfileView<'a> {
state: &'a mut ProfileState,
clipboard: &'a mut Clipboard,
img_cache: &'a mut Images,
i18n: &'a mut Localization,
}
@@ -16,11 +19,13 @@ impl<'a> EditProfileView<'a> {
i18n: &'a mut Localization,
state: &'a mut ProfileState,
img_cache: &'a mut Images,
clipboard: &'a mut Clipboard,
) -> Self {
Self {
i18n,
state,
img_cache,
clipboard,
}
}
@@ -32,6 +37,7 @@ impl<'a> EditProfileView<'a> {
pub fn ui(&mut self, ui: &mut egui::Ui) -> bool {
ScrollArea::vertical()
.id_salt(EditProfileView::scroll_id())
.stick_to_bottom(true)
.show(ui, |ui| {
banner(ui, self.state.banner(), 188.0);
@@ -95,14 +101,14 @@ impl<'a> EditProfileView<'a> {
)
.as_str(),
));
ui.add(singleline_textedit(self.state.str_mut("display_name")));
singleline_textedit(ui, self.state.str_mut("display_name"), self.clipboard);
});
in_frame(ui, |ui| {
ui.add(label(
tr!(self.i18n, "Username", "Profile username field label").as_str(),
));
ui.add(singleline_textedit(self.state.str_mut("name")));
singleline_textedit(ui, self.state.str_mut("name"), self.clipboard);
});
in_frame(ui, |ui| {
@@ -114,28 +120,28 @@ impl<'a> EditProfileView<'a> {
)
.as_str(),
));
ui.add(multiline_textedit(self.state.str_mut("picture")));
multiline_textedit(ui, self.state.str_mut("picture"), self.clipboard);
});
in_frame(ui, |ui| {
ui.add(label(
tr!(self.i18n, "Banner", "Profile banner URL field label").as_str(),
));
ui.add(multiline_textedit(self.state.str_mut("banner")));
multiline_textedit(ui, self.state.str_mut("banner"), self.clipboard);
});
in_frame(ui, |ui| {
ui.add(label(
tr!(self.i18n, "About", "Profile about/bio field label").as_str(),
));
ui.add(multiline_textedit(self.state.str_mut("about")));
multiline_textedit(ui, self.state.str_mut("about"), self.clipboard);
});
in_frame(ui, |ui| {
ui.add(label(
tr!(self.i18n, "Website", "Profile website field label").as_str(),
));
ui.add(singleline_textedit(self.state.str_mut("website")));
singleline_textedit(ui, self.state.str_mut("website"), self.clipboard);
});
in_frame(ui, |ui| {
@@ -147,7 +153,7 @@ impl<'a> EditProfileView<'a> {
)
.as_str(),
));
ui.add(multiline_textedit(self.state.str_mut("lud16")));
multiline_textedit(ui, self.state.str_mut("lud16"), self.clipboard);
});
in_frame(ui, |ui| {
@@ -159,7 +165,8 @@ impl<'a> EditProfileView<'a> {
)
.as_str(),
));
ui.add(singleline_textedit(self.state.str_mut("nip05")));
singleline_textedit(ui, self.state.str_mut("nip05"), self.clipboard);
let Some(nip05) = self.state.nip05() else {
return;
@@ -208,21 +215,29 @@ fn label(text: &str) -> impl egui::Widget + '_ {
}
}
fn singleline_textedit(data: &mut String) -> impl egui::Widget + '_ {
TextEdit::singleline(data)
.min_size(vec2(0.0, 40.0))
.vertical_align(egui::Align::Center)
.margin(Margin::symmetric(12, 10))
.desired_width(f32::INFINITY)
fn singleline_textedit(ui: &mut egui::Ui, data: &mut String, clipboard: &mut Clipboard) {
let r = ui.add(
TextEdit::singleline(data)
.min_size(vec2(0.0, 40.0))
.vertical_align(egui::Align::Center)
.margin(Margin::symmetric(12, 10))
.desired_width(f32::INFINITY),
);
input_context(ui, &r, clipboard, data, PasteBehavior::Clear);
}
fn multiline_textedit(data: &mut String) -> impl egui::Widget + '_ {
TextEdit::multiline(data)
// .min_size(vec2(0.0, 40.0))
.vertical_align(egui::Align::TOP)
.margin(Margin::symmetric(12, 10))
.desired_width(f32::INFINITY)
.desired_rows(1)
fn multiline_textedit(ui: &mut egui::Ui, data: &mut String, clipboard: &mut Clipboard) {
let r = ui.add(
TextEdit::multiline(data)
// .min_size(vec2(0.0, 40.0))
.vertical_align(egui::Align::TOP)
.margin(Margin::symmetric(12, 10))
.desired_width(f32::INFINITY)
.desired_rows(1),
);
input_context(ui, &r, clipboard, data, PasteBehavior::Clear);
}
fn in_frame(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)) {
+20 -13
View File
@@ -73,7 +73,7 @@ impl<'a, 'd> ProfileView<'a, 'd> {
scroll_area = scroll_area.vertical_scroll_offset(offset);
}
let output = scroll_area.show(ui, |ui| {
let output = scroll_area.show(ui, |ui| 's: {
let mut action = None;
let txn = Transaction::new(self.note_context.ndb).expect("txn");
let profile = self
@@ -85,15 +85,13 @@ impl<'a, 'd> ProfileView<'a, 'd> {
if let Some(profile_view_action) = self.profile_body(ui, profile.as_ref()) {
action = Some(profile_view_action);
}
let profile_timeline = self
let Some(profile_timeline) = self
.timeline_cache
.notes(
self.note_context.ndb,
self.note_context.note_cache,
&txn,
&TimelineKind::Profile(*self.pubkey),
)
.get_ptr();
.get_mut(&TimelineKind::Profile(*self.pubkey))
else {
break 's action;
};
profile_timeline.selected_view = tabs_ui(
ui,
@@ -116,7 +114,6 @@ impl<'a, 'd> ProfileView<'a, 'd> {
if let Some(note_action) = TimelineTabView::new(
profile_timeline.current_view(),
reversed,
self.note_options,
&txn,
self.note_context,
@@ -166,7 +163,10 @@ impl<'a, 'd> ProfileView<'a, 'd> {
.border(ProfilePic::border_stroke(ui)),
);
if ui.add(copy_key_widget(&pfp_rect)).clicked() {
if ui
.add(copy_key_widget(&pfp_rect, self.note_context.i18n))
.clicked()
{
let to_copy = if let Some(bech) = self.pubkey.npub() {
bech
} else {
@@ -300,7 +300,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)]
@@ -314,7 +317,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() {
+1 -1
View File
@@ -274,7 +274,7 @@ struct RelayInfo<'a> {
pub status: RelayStatus,
}
fn get_relay_infos(pool: &RelayPool) -> Vec<RelayInfo> {
fn get_relay_infos(pool: &RelayPool) -> Vec<RelayInfo<'_>> {
pool.relays
.iter()
.map(|relay| RelayInfo {
+7 -6
View File
@@ -2,7 +2,10 @@ use egui::{vec2, Align, Color32, CornerRadius, RichText, Stroke, TextEdit};
use enostr::{NoteId, Pubkey};
use state::TypingType;
use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView};
use crate::{
timeline::{TimelineTab, TimelineUnits},
ui::timeline::TimelineTabView,
};
use egui_winit::clipboard::Clipboard;
use nostrdb::{Filter, Ndb, Transaction};
use notedeck::{tr, tr_plural, JobsCache, Localization, NoteAction, NoteContext, NoteRef};
@@ -125,7 +128,7 @@ impl<'a, 'd> SearchView<'a, 'd> {
"Got {count} result for '{query}'", // one
"Got {count} results for '{query}'", // other
"Search results count", // comment
self.query.notes.notes.len(), // count
self.query.notes.units.len(), // count
query = &self.query.string
));
note_action = self.show_search_results(ui);
@@ -153,10 +156,8 @@ impl<'a, 'd> SearchView<'a, 'd> {
egui::ScrollArea::vertical()
.id_salt(SearchView::scroll_id())
.show(ui, |ui| {
let reversed = false;
TimelineTabView::new(
&self.query.notes,
reversed,
self.note_options,
self.txn,
self.note_context,
@@ -190,7 +191,7 @@ fn execute_search(
return;
};
tab.notes = note_refs;
tab.units = TimelineUnits::from_refs_single(note_refs);
tab.list.borrow_mut().reset();
ctx.request_repaint();
}
@@ -311,7 +312,7 @@ fn search_box(
.frame(false),
);
input_context(&response, clipboard, input, PasteBehavior::Append);
input_context(ui, &response, clipboard, input, PasteBehavior::Append);
let mut requested_focus = false;
if focus_state == FocusState::ShouldRequestFocus {
+167 -8
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";
@@ -147,7 +152,7 @@ impl<'a> SettingsView<'a> {
"Label for appearance settings section",
);
settings_group(ui, title, |ui| {
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(richtext_small(tr!(
self.note_context.i18n,
"Font size:",
@@ -207,7 +212,7 @@ impl<'a> SettingsView<'a> {
let current_zoom = ui.ctx().zoom_factor();
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(richtext_small(tr!(
self.note_context.i18n,
"Zoom Level:",
@@ -260,7 +265,7 @@ impl<'a> SettingsView<'a> {
}
});
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(richtext_small(tr!(
self.note_context.i18n,
"Language:",
@@ -288,7 +293,7 @@ impl<'a> SettingsView<'a> {
});
});
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(richtext_small(tr!(
self.note_context.i18n,
"Theme:",
@@ -441,7 +446,7 @@ impl<'a> SettingsView<'a> {
"Label for others settings section"
);
settings_group(ui, title, |ui| {
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
ui.label(richtext_small(tr!(
self.note_context.i18n,
"Sort replies newest first:",
@@ -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)
}
+17 -9
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)))
@@ -332,11 +332,11 @@ fn add_column_button() -> impl Widget {
}
}
pub fn search_button() -> impl Widget {
|ui: &mut egui::Ui| -> egui::Response {
pub fn search_button_impl(color: egui::Color32, line_width: f32) -> impl Widget {
move |ui: &mut egui::Ui| -> egui::Response {
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
let min_line_width_circle = 1.5; // width of the magnifying glass
let min_line_width_handle = 1.5;
let min_line_width_circle = line_width; // width of the magnifying glass
let min_line_width_handle = line_width;
let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size));
let painter = ui.painter_at(helper.get_animation_rect());
@@ -359,8 +359,8 @@ pub fn search_button() -> impl Widget {
let handle_pos_2 =
circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length));
let circle_stroke = Stroke::new(cur_line_width_circle, colors::MID_GRAY);
let handle_stroke = Stroke::new(cur_line_width_handle, colors::MID_GRAY);
let circle_stroke = Stroke::new(cur_line_width_circle, color);
let handle_stroke = Stroke::new(cur_line_width_handle, color);
painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke);
painter.circle(
@@ -377,9 +377,13 @@ pub fn search_button() -> impl Widget {
}
}
pub fn search_button() -> impl Widget {
search_button_impl(colors::MID_GRAY, 1.5)
}
// 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;
@@ -399,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"
))
}
}
+1 -1
View File
@@ -111,7 +111,7 @@ impl<'a, 'd> ThreadView<'a, 'd> {
parent_state = ParentState::Unknown;
}
for note_ref in &cur_node.replies {
for note_ref in cur_node.replies.values() {
if let Ok(note) = self.note_context.ndb.get_note_by_key(txn, note_ref.key) {
note_builder.add_reply(note);
}
+419 -57
View File
@@ -1,13 +1,20 @@
use egui::containers::scroll_area::ScrollBarVisibility;
use egui::{vec2, Direction, Layout, Pos2, Stroke};
use egui::{vec2, Direction, Layout, Margin, Pos2, ScrollArea, Sense, Stroke};
use egui_tabs::TabColor;
use nostrdb::Transaction;
use enostr::Pubkey;
use nostrdb::{ProfileRecord, Transaction};
use notedeck::name::get_display_name;
use notedeck::ui::is_narrow;
use notedeck::JobsCache;
use notedeck::{tr_plural, JobsCache, Muted, NoteRef};
use notedeck_ui::app_images::{like_image, repost_image};
use notedeck_ui::ProfilePic;
use std::f32::consts::PI;
use tracing::{error, warn};
use crate::timeline::{TimelineCache, TimelineKind, TimelineTab, ViewFilter};
use crate::timeline::{
CompositeType, CompositeUnit, NoteUnit, ReactionUnit, RepostUnit, TimelineCache, TimelineKind,
TimelineTab, ViewFilter,
};
use notedeck::{
note::root_note_id_from_selected_id, tr, Localization, NoteAction, NoteContext, ScrollInfo,
};
@@ -20,7 +27,6 @@ pub struct TimelineView<'a, 'd> {
timeline_id: &'a TimelineKind,
timeline_cache: &'a mut TimelineCache,
note_options: NoteOptions,
reverse: bool,
note_context: &'a mut NoteContext<'d>,
jobs: &'a mut JobsCache,
col: usize,
@@ -37,13 +43,11 @@ impl<'a, 'd> TimelineView<'a, 'd> {
jobs: &'a mut JobsCache,
col: usize,
) -> Self {
let reverse = false;
let scroll_to_top = false;
TimelineView {
timeline_id,
timeline_cache,
note_options,
reverse,
note_context,
jobs,
col,
@@ -56,7 +60,6 @@ impl<'a, 'd> TimelineView<'a, 'd> {
ui,
self.timeline_id,
self.timeline_cache,
self.reverse,
self.note_options,
self.note_context,
self.jobs,
@@ -70,11 +73,6 @@ impl<'a, 'd> TimelineView<'a, 'd> {
self
}
pub fn reversed(mut self) -> Self {
self.reverse = true;
self
}
pub fn scroll_id(
timeline_cache: &TimelineCache,
timeline_id: &TimelineKind,
@@ -90,7 +88,6 @@ fn timeline_ui(
ui: &mut egui::Ui,
timeline_id: &TimelineKind,
timeline_cache: &mut TimelineCache,
reversed: bool,
note_options: NoteOptions,
note_context: &mut NoteContext,
jobs: &mut JobsCache,
@@ -186,7 +183,6 @@ fn timeline_ui(
TimelineTabView::new(
timeline.current_view(),
reversed,
note_options,
&txn,
note_context,
@@ -380,7 +376,6 @@ fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef {
pub struct TimelineTabView<'a, 'd> {
tab: &'a TimelineTab,
reversed: bool,
note_options: NoteOptions,
txn: &'a Transaction,
note_context: &'a mut NoteContext<'d>,
@@ -391,7 +386,6 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
#[allow(clippy::too_many_arguments)]
pub fn new(
tab: &'a TimelineTab,
reversed: bool,
note_options: NoteOptions,
txn: &'a Transaction,
note_context: &'a mut NoteContext<'d>,
@@ -399,7 +393,6 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
) -> Self {
Self {
tab,
reversed,
note_options,
txn,
note_context,
@@ -409,57 +402,30 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
pub fn show(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> {
let mut action: Option<NoteAction> = None;
let len = self.tab.notes.len();
let len = self.tab.units.len();
let is_muted = self.note_context.accounts.mutefun();
let mute = self.note_context.accounts.mute();
self.tab
.list
.borrow_mut()
.ui_custom_layout(ui, len, |ui, start_index| {
.ui_custom_layout(ui, len, |ui, index| {
// tracing::info!("rendering index: {index}");
ui.spacing_mut().item_spacing.y = 0.0;
ui.spacing_mut().item_spacing.x = 4.0;
let ind = if self.reversed {
len - start_index - 1
} else {
start_index
let Some(entry) = self.tab.units.get(index) else {
return 0;
};
let note_key = self.tab.notes[ind].key;
match self.render_entry(ui, entry, &mute) {
RenderEntryResponse::Unsuccessful => return 0,
let note =
if let Ok(note) = self.note_context.ndb.get_note_by_key(self.txn, note_key) {
note
} else {
warn!("failed to query note {:?}", note_key);
return 0;
};
// should we mute the thread? we might not have it!
let muted = if let Ok(root_id) = root_note_id_from_selected_id(
self.note_context.ndb,
self.note_context.note_cache,
self.txn,
note.id(),
) {
is_muted(&note, root_id.bytes())
} else {
false
};
if !muted {
notedeck_ui::padding(8.0, ui, |ui| {
let resp =
NoteView::new(self.note_context, &note, self.note_options, self.jobs)
.show(ui);
if let Some(note_action) = resp.action {
action = Some(note_action)
RenderEntryResponse::Success(note_action) => {
if let Some(cur_action) = note_action {
action = Some(cur_action);
}
});
notedeck_ui::hline(ui);
}
}
1
@@ -467,4 +433,400 @@ impl<'a, 'd> TimelineTabView<'a, 'd> {
action
}
fn render_entry(
&mut self,
ui: &mut egui::Ui,
entry: &NoteUnit,
mute: &std::sync::Arc<Muted>,
) -> RenderEntryResponse {
match entry {
NoteUnit::Single(note_ref) => render_note(
ui,
self.note_context,
self.note_options,
self.jobs,
mute,
self.txn,
note_ref,
),
NoteUnit::Composite(composite) => match composite {
CompositeUnit::Reaction(reaction_unit) => render_reaction_cluster(
ui,
self.note_context,
self.note_options,
self.jobs,
mute,
self.txn,
reaction_unit,
),
CompositeUnit::Repost(repost_unit) => render_repost_cluster(
ui,
self.note_context,
self.note_options,
self.jobs,
mute,
self.txn,
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),
}
}
fn description(
&self,
loc: &mut Localization,
first_name: &str,
total_count: usize,
referenced_type: ReferencedNoteType,
) -> 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, referenced_type),
}
}
}
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
)
}
}
}
}
fn repost_description(
loc: &mut Localization,
first_name: &str,
count: usize,
referenced_type: ReferencedNoteType,
) -> String {
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
)
}
}
}
}
fn render_note(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
note_options: NoteOptions,
jobs: &mut JobsCache,
mute: &std::sync::Arc<Muted>,
txn: &Transaction,
note_ref: &NoteRef,
) -> RenderEntryResponse {
let note = if let Ok(note) = note_context.ndb.get_note_by_key(txn, note_ref.key) {
note
} else {
warn!("failed to query note {:?}", note_ref.key);
return RenderEntryResponse::Unsuccessful;
};
let muted = if let Ok(root_id) =
root_note_id_from_selected_id(note_context.ndb, note_context.note_cache, txn, note.id())
{
mute.is_muted(&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);
if let Some(note_action) = resp.action {
action = Some(note_action);
}
});
notedeck_ui::hline(ui);
RenderEntryResponse::Success(action)
}
fn render_reaction_cluster(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
note_options: NoteOptions,
jobs: &mut JobsCache,
mute: &std::sync::Arc<Muted>,
txn: &Transaction,
reaction: &ReactionUnit,
) -> RenderEntryResponse {
let reacted_to_key = reaction.note_reacted_to.key;
let reacted_to_note = if let Ok(note) = note_context.ndb.get_note_by_key(txn, reacted_to_key) {
note
} else {
warn!("failed to query note {:?}", reacted_to_key);
return RenderEntryResponse::Unsuccessful;
};
let profiles_to_show: Vec<ProfileEntry> = reaction
.reactions
.values()
.filter(|r| !mute.is_pk_muted(r.sender.bytes()))
.map(|r| &r.sender)
.map(|p| ProfileEntry {
record: note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok(),
pk: p,
})
.collect();
render_composite_entry(
ui,
note_context,
note_options,
jobs,
reacted_to_note,
profiles_to_show,
CompositeType::Reaction,
)
}
fn render_composite_entry(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
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 = 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),
composite_type.image(ui.visuals().dark_mode),
);
});
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 referenced_type = if note_context
.accounts
.get_selected_account()
.key
.pubkey
.bytes()
!= underlying_note.pubkey()
{
ReferencedNoteType::Tagged
} else {
ReferencedNoteType::Yours
};
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.add_space(52.0);
ui.horizontal_wrapped(|ui| {
ui.label(composite_type.description(
note_context.i18n,
&first_name,
num_profiles,
referenced_type,
))
});
});
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(48.0);
let options = note_options
.difference(NoteOptions::ActionBar | NoteOptions::OptionsButton)
.union(NoteOptions::NotificationPreview);
let resp = NoteView::new(note_context, &underlying_note, options, jobs).show(ui);
if let Some(note_action) = resp.action {
action = Some(note_action);
}
});
});
notedeck_ui::hline(ui);
RenderEntryResponse::Success(action)
}
fn render_repost_cluster(
ui: &mut egui::Ui,
note_context: &mut NoteContext,
note_options: NoteOptions,
jobs: &mut JobsCache,
mute: &std::sync::Arc<Muted>,
txn: &Transaction,
repost: &RepostUnit,
) -> RenderEntryResponse {
let reposted_key = repost.note_reposted.key;
let reposted_note = if let Ok(note) = note_context.ndb.get_note_by_key(txn, reposted_key) {
note
} else {
warn!("failed to query note {:?}", reposted_key);
return RenderEntryResponse::Unsuccessful;
};
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,
reposted_note,
profiles_to_show,
CompositeType::Repost,
)
}
enum RenderEntryResponse {
Unsuccessful,
Success(Option<NoteAction>),
}
struct ProfileEntry<'a> {
record: Option<ProfileRecord<'a>>,
pk: &'a Pubkey,
}
+66
View File
@@ -0,0 +1,66 @@
use egui::{Color32, Layout};
use notedeck_ui::icons::{home_button, notifications_button};
use crate::{toolbar::ToolbarAction, ui::side_panel::search_button_impl, Damus};
pub fn toolbar(ui: &mut egui::Ui, unseen_notification: bool) -> Option<ToolbarAction> {
use egui_tabs::{TabColor, Tabs};
let rect = ui.available_rect_before_wrap();
ui.painter().hline(
rect.x_range(),
rect.top(),
ui.visuals().widgets.noninteractive.bg_stroke,
);
if !ui.visuals().dark_mode {
ui.painter().rect(
rect,
0,
notedeck_ui::colors::ALMOST_WHITE,
egui::Stroke::new(0.0, Color32::TRANSPARENT),
egui::StrokeKind::Inside,
);
}
let rs = Tabs::new(3)
.selected(Damus::initially_selected_toolbar_index())
.hover_bg(TabColor::none())
.selected_fg(TabColor::none())
.selected_bg(TabColor::none())
.height(Damus::toolbar_height())
.layout(Layout::centered_and_justified(egui::Direction::TopDown))
.show(ui, |ui, state| {
let index = state.index();
let mut action: Option<ToolbarAction> = None;
let btn_size: f32 = 20.0;
if index == 0 {
if home_button(ui, btn_size).clicked() {
action = Some(ToolbarAction::Home);
}
} else if index == 1
&& ui
.add(search_button_impl(ui.visuals().text_color(), 2.0))
.clicked()
{
action = Some(ToolbarAction::Search)
} else if index == 2
&& notifications_button(ui, btn_size, unseen_notification).clicked()
{
action = Some(ToolbarAction::Notifications);
}
action
})
.inner();
for maybe_r in rs {
if maybe_r.inner.is_some() {
return maybe_r.inner;
}
}
None
}
+15 -9
View File
@@ -30,7 +30,7 @@ pub enum DefaultZapState<'a> {
Valid(&'a Msats), // in milisats
}
pub fn get_default_zap_state(default_zap: &mut DefaultZapMsats) -> DefaultZapState {
pub fn get_default_zap_state(default_zap: &mut DefaultZapMsats) -> DefaultZapState<'_> {
if default_zap.pending.is_rewriting {
return DefaultZapState::Pending(&mut default_zap.pending);
}
@@ -237,8 +237,10 @@ fn show_no_wallet(
.password(true);
// add paste context menu
let text_edit_resp = ui.add(text_edit);
input_context(
&ui.add(text_edit),
ui,
&text_edit_resp,
clipboard,
&mut state.buf,
PasteBehavior::Clear,
@@ -388,13 +390,17 @@ fn show_default_zap(
};
let id = ui.id().with("default_zap_amount");
ui.add(
egui::TextEdit::singleline(text)
.desired_width(desired_width)
.margin(egui::Margin::same(8))
.font(font)
.id(id),
);
{
let r = ui.add(
egui::TextEdit::singleline(text)
.desired_width(desired_width)
.margin(egui::Margin::same(8))
.font(font)
.id(id));
notedeck_ui::include_input(ui, &r);
}
ui.memory_mut(|m| m.request_focus(id));
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use enostr::Pubkey;
use notedeck_ui::nip51_set::Nip51SetUiCache;
use crate::deck_state::DeckState;
use crate::login_manager::AcquireKeyState;
@@ -25,6 +26,9 @@ pub struct ViewState {
/// fullscreen media viewier, as well as any other state we want to
/// keep track of
pub media_viewer: MediaViewerState,
/// Keep track of checkbox state of follow pack onboarding
pub follow_packs: Nip51SetUiCache,
}
impl ViewState {
+1
View File
@@ -342,6 +342,7 @@ impl<'a> DaveUi<'a> {
)
.frame(false),
);
notedeck_ui::include_input(ui, &r);
if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
DaveResponse::send()
+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()
}
+22
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"))
}
@@ -240,3 +248,17 @@ pub fn zap_dark_image() -> Image<'static> {
pub fn zap_light_image() -> Image<'static> {
zap_dark_image().tint(Color32::BLACK)
}
pub fn like_image() -> Image<'static> {
Image::new(include_image!("../../../assets/icons/like_icon_4x.png"))
}
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)
}
+4
View File
@@ -20,6 +20,7 @@ fn handle_paste(clipboard: &mut Clipboard, input: &mut String, paste_behavior: P
}
pub fn input_context(
ui: &mut egui::Ui,
response: &egui::Response,
clipboard: &mut Clipboard,
input: &mut String,
@@ -46,4 +47,7 @@ pub fn input_context(
if response.middle_clicked() {
handle_paste(clipboard, input, paste_behavior)
}
// for keyboard visibility
crate::include_input(ui, response)
}
+77
View File
@@ -1,5 +1,10 @@
use egui::{vec2, Color32, Stroke};
use crate::{app_images, AnimationHelper};
pub static ICON_WIDTH: f32 = 40.0;
pub static ICON_EXPANSION_MULTIPLE: f32 = 1.2;
/// Creates a magnifying glass icon widget
pub fn search_icon(size: f32, height: f32) -> impl egui::Widget {
move |ui: &mut egui::Ui| {
@@ -25,3 +30,75 @@ pub fn search_icon(size: f32, height: f32) -> impl egui::Widget {
response
}
}
pub fn notifications_button(
ui: &mut egui::Ui,
size: f32,
unseen_indicator: bool,
) -> egui::Response {
expanding_button(
"notifications-button",
size,
app_images::notifications_light_image(),
app_images::notifications_dark_image(),
ui,
unseen_indicator,
)
}
pub fn home_button(ui: &mut egui::Ui, size: f32) -> egui::Response {
expanding_button(
"home-button",
size,
app_images::home_light_image(),
app_images::home_dark_image(),
ui,
false,
)
}
pub fn expanding_button(
name: &'static str,
img_size: f32,
light_img: egui::Image,
dark_img: egui::Image,
ui: &mut egui::Ui,
unseen_indicator: bool,
) -> egui::Response {
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
let img = if ui.visuals().dark_mode {
dark_img
} else {
light_img
};
let helper = AnimationHelper::new(ui, name, egui::vec2(max_size, max_size));
let cur_img_size = helper.scale_1d_pos(img_size);
let paint_rect = helper
.get_animation_rect()
.shrink((max_size - cur_img_size) / 2.0);
img.paint_at(ui, paint_rect);
if unseen_indicator {
paint_unseen_indicator(ui, paint_rect, helper.scale_1d_pos(3.0));
}
helper.take_animation_response()
}
fn paint_unseen_indicator(ui: &mut egui::Ui, rect: egui::Rect, radius: f32) {
let center = rect.center();
let top_right = rect.right_top();
let distance = center.distance(top_right);
let midpoint = {
let mut cur = center;
cur.x += distance / 2.0;
cur.y -= distance / 2.0;
cur
};
let painter = ui.painter_at(rect);
painter.circle_filled(midpoint, radius, crate::colors::PINK);
}

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