notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 6dd0e5207eb2c78eae3e0bab72267c5f1445d4d5
parent 0c3db9a31eb6362c6f6610dd7e78359782e17f77
Author: William Casarin <jb55@jb55.com>
Date:   Sat, 25 Jan 2025 16:17:04 -0800

Merge image uploading from kernel

kernelkind (8):
      upload media button
      get file binary
      import base64
      notedeck_columns: use sha2 & base64
      use rfd for desktop file selection
      add utils for uploading media
      draft fields for media upload feat
      ui: user can upload images

Diffstat:
MCargo.lock | 476++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCargo.toml | 1+
Aassets/icons/media_upload_dark_4x.png | 0
Mcrates/notedeck_columns/Cargo.toml | 5+++++
Mcrates/notedeck_columns/src/draft.rs | 10+++++++++-
Mcrates/notedeck_columns/src/images.rs | 5+++++
Mcrates/notedeck_columns/src/lib.rs | 1+
Acrates/notedeck_columns/src/media_upload.rs | 447+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/post.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/notedeck_columns/src/ui/note/post.rs | 297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
10 files changed, 1284 insertions(+), 36 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -208,6 +208,171 @@ dependencies = [ ] [[package]] +name = "ashpd" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c39d707614dbcc6bed00015539f488d8e3fe3e66ed60961efc0c90f4b380b3" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle 0.6.2", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", + "tracing", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "async-signal" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] name = "async-trait" version = "0.1.83" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -494,6 +659,19 @@ dependencies = [ ] [[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] name = "built" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1010,7 +1188,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "pollster", + "pollster 0.3.0", "puffin 0.19.1 (registry+https://github.com/rust-lang/crates.io-index)", "raw-window-handle 0.6.2", "static_assertions", @@ -1188,6 +1366,12 @@ dependencies = [ ] [[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] name = "enostr" version = "0.1.0" dependencies = [ @@ -1228,6 +1412,27 @@ dependencies = [ ] [[package]] +name = "enumflags2" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba2f4b465f5318854c6f8dd686ede6c0a9dc67d4b1ac241cf0eb51521a309147" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] name = "enumn" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1286,6 +1491,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f" [[package]] +name = "event-listener" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] name = "ewebsock" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1447,6 +1673,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] +name = "futures-lite" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] name = "futures-macro" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2426,6 +2665,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + +[[package]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2546,6 +2798,7 @@ dependencies = [ name = "notedeck_columns" version = "0.2.0" dependencies = [ + "base64 0.22.1", "bitflags 2.6.0", "dirs", "eframe", @@ -2565,10 +2818,12 @@ dependencies = [ "poll-promise", "puffin 0.19.1 (git+https://github.com/jb55/puffin?rev=70ff86d5503815219b01a009afd3669b7903a057)", "puffin_egui", + "rfd", "security-framework", "serde", "serde_derive", "serde_json", + "sha2", "strum", "strum_macros", "tempfile", @@ -2681,7 +2936,7 @@ version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 1.0.109", @@ -2693,7 +2948,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96667db765a921f7b295ffee8b60472b686a51d4f21c2ee4ffdb94c7013b65a6" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.90", @@ -2705,7 +2960,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.90", @@ -2971,6 +3226,16 @@ dependencies = [ ] [[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] name = "overload" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2986,6 +3251,12 @@ dependencies = [ ] [[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] name = "parking_lot" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3086,6 +3357,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] name = "pkg-config" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3137,6 +3419,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" [[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3189,6 +3477,15 @@ dependencies = [ ] [[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit 0.22.22", +] + +[[package]] name = "proc-macro2" version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3508,6 +3805,30 @@ dependencies = [ ] [[package]] +name = "rfd" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a24763657bff09769a8ccf12c8b8a50416fb035fe199263b4c5071e4e3f006f" +dependencies = [ + "ashpd", + "block2", + "core-foundation 0.10.0", + "core-foundation-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "pollster 0.4.0", + "raw-window-handle 0.6.2", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] name = "rgb" version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3754,6 +4075,17 @@ dependencies = [ ] [[package]] +name = "serde_repr" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] name = "serde_spanned" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3800,6 +4132,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] name = "simd-adler32" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4380,6 +4721,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] name = "unicase" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5335,6 +5687,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61" [[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] name = "xkbcommon-dl" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5390,6 +5752,69 @@ dependencies = [ ] [[package]] +name = "zbus" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "192a0d989036cd60a1e91a54c9851fb9ad5bd96125d41803eed79d2e2ef74bd7" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-util", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.59.0", + "winnow 0.6.20", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3685b5c81fce630efc3e143a4ded235b107f1b1cdf186c3f115529e5e5ae4265" +dependencies = [ + "proc-macro-crate 3.2.0", + "proc-macro2", + "quote", + "syn 2.0.90", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519629a3f80976d89c575895b05677cbc45eaf9f70d62a364d819ba646409cc8" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.6.20", + "zvariant", +] + +[[package]] name = "zerocopy" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5482,3 +5907,46 @@ checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55e6b9b5f1361de2d5e7d9fd1ee5f6f7fcb6060618a1f82f3472f58f2b8d4be9" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "url", + "winnow 0.6.20", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "573a8dd76961957108b10f7a45bac6ab1ea3e9b7fe01aff88325dc57bb8f5c8b" +dependencies = [ + "proc-macro-crate 3.2.0", + "proc-macro2", + "quote", + "syn 2.0.90", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd46446ea2a1f353bfda53e35f17633afa79f4fe290a611c94645c69fe96a50" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "static_assertions", + "syn 2.0.90", + "winnow 0.6.20", +] diff --git a/Cargo.toml b/Cargo.toml @@ -10,6 +10,7 @@ members = [ [workspace.dependencies] base32 = "0.4.0" +base64 = "0.22.1" bech32 = { version = "0.11", default-features = false } bitflags = "2.5.0" dirs = "5.0.1" diff --git a/assets/icons/media_upload_dark_4x.png b/assets/icons/media_upload_dark_4x.png Binary files differ. diff --git a/crates/notedeck_columns/Cargo.toml b/crates/notedeck_columns/Cargo.toml @@ -43,6 +43,11 @@ tracing-subscriber = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true } +sha2 = { workspace = true } +base64 = { workspace = true } + +[target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] +rfd = "0.15" [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/notedeck_columns/src/draft.rs b/crates/notedeck_columns/src/draft.rs @@ -1,9 +1,14 @@ -use crate::ui::note::PostType; +use poll_promise::Promise; + +use crate::{media_upload::Nip94Event, ui::note::PostType, Error}; use std::collections::HashMap; #[derive(Default)] pub struct Draft { pub buffer: String, + pub uploaded_media: Vec<Nip94Event>, // media uploads to include + pub uploading_media: Vec<Promise<Result<Nip94Event, Error>>>, // promises that aren't ready yet + pub upload_errors: Vec<String>, // media upload errors to show the user } #[derive(Default)] @@ -42,5 +47,8 @@ impl Draft { pub fn clear(&mut self) { self.buffer = "".to_string(); + self.upload_errors = Vec::new(); + self.uploaded_media = Vec::new(); + self.uploading_media = Vec::new(); } } diff --git a/crates/notedeck_columns/src/images.rs b/crates/notedeck_columns/src/images.rs @@ -4,6 +4,7 @@ use notedeck::ImageCache; use notedeck::Result; use poll_promise::Promise; use std::path; +use std::path::PathBuf; use tokio::fs; //pub type ImageCacheKey = String; @@ -198,6 +199,10 @@ fn fetch_img_from_disk( }) } +pub fn fetch_binary_from_disk(path: PathBuf) -> Result<Vec<u8>> { + std::fs::read(path).map_err(|e| notedeck::Error::Generic(e.to_string())) +} + /// Controls type-specific handling #[derive(Debug, Clone, Copy)] pub enum ImageType { diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs @@ -18,6 +18,7 @@ mod frame_history; mod images; mod key_parsing; pub mod login_manager; +mod media_upload; mod multi_subscriber; mod nav; mod post; diff --git a/crates/notedeck_columns/src/media_upload.rs b/crates/notedeck_columns/src/media_upload.rs @@ -0,0 +1,447 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +use base64::{prelude::BASE64_URL_SAFE, Engine}; +use ehttp::Request; +use nostrdb::{Note, NoteBuilder}; +use poll_promise::Promise; +use sha2::{Digest, Sha256}; +use url::Url; + +use crate::{images::fetch_binary_from_disk, Error}; + +pub const NOSTR_BUILD_URL: fn() -> Url = || Url::parse("http://nostr.build").unwrap(); +const NIP96_WELL_KNOWN: &str = ".well-known/nostr/nip96.json"; + +fn get_upload_url(nip96_url: Url) -> Promise<Result<String, Error>> { + let request = Request::get(nip96_url); + let (sender, promise) = Promise::new(); + + ehttp::fetch(request, move |response| { + let result = match response { + Ok(resp) => { + if resp.status == 200 { + if let Some(text) = resp.text() { + get_api_url_from_json(text) + } else { + Err(Error::Generic( + "ehttp::Response payload is not text".to_owned(), + )) + } + } else { + Err(Error::Generic(format!( + "ehttp::Response status: {}", + resp.status + ))) + } + } + Err(e) => Err(Error::Generic(e)), + }; + + sender.send(result); + }); + + promise +} + +fn get_api_url_from_json(json: &str) -> Result<String, Error> { + match serde_json::from_str::<serde_json::Value>(json) { + Ok(json) => { + if let Some(url) = json + .get("api_url") + .and_then(|url| url.as_str()) + .map(|url| url.to_string()) + { + Ok(url) + } else { + Err(Error::Generic( + "api_url key not found in ehttp::Response".to_owned(), + )) + } + } + Err(e) => Err(Error::Generic(e.to_string())), + } +} + +fn get_upload_url_from_provider(mut provider_url: Url) -> Promise<Result<String, Error>> { + provider_url.set_path(NIP96_WELL_KNOWN); + get_upload_url(provider_url) +} + +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 { + NoteBuilder::new() + .kind(27235) + .start_tag() + .tag_str("u") + .tag_str(&upload_url) + .start_tag() + .tag_str("method") + .tag_str("POST") + .start_tag() + .tag_str("payload") + .tag_str(&payload_hash) + .sign(seckey) + .build() + .expect("build note") +} + +fn create_nip96_request( + upload_url: &str, + media_path: MediaPath, + 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() + ) + .into_bytes(); + body.extend(file_contents); + body.extend(format!("\r\n--{}--\r\n", boundary).as_bytes()); + + let headers = { + let mut map = BTreeMap::new(); + map.insert( + "Content-Type".to_owned(), + format!("multipart/form-data; boundary={boundary}"), + ); + map.insert("Authorization".to_owned(), format!("Nostr {nip98_base64}")); + map + }; + + Request { + method: "POST".to_string(), + url: upload_url.to_string(), + headers, + body: body.into(), + } +} + +fn sha256_hex(contents: &Vec<u8>) -> String { + let mut hasher = Sha256::new(); + hasher.update(contents); + let hash = hasher.finalize(); + hex::encode(hash) +} + +pub fn nip96_upload( + seckey: [u8; 32], + upload_url: String, + media_path: MediaPath, +) -> 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) +} + +pub fn nostrbuild_nip96_upload( + seckey: [u8; 32], + media_path: MediaPath, +) -> Promise<Result<Nip94Event, Error>> { + let (sender, promise) = Promise::new(); + std::thread::spawn(move || { + let upload_url = match get_nostr_build_upload_url().block_and_take() { + Ok(url) => url, + Err(e) => { + sender.send(Err(Error::Generic(format!( + "could not get nostrbuild upload url: {e}" + )))); + return; + } + }; + + let res = nip96_upload(seckey, upload_url, media_path).block_and_take(); + sender.send(res); + }); + promise +} + +fn internal_nip96_upload( + seckey: [u8; 32], + upload_url: String, + media_path: MediaPath, + file_contents: Vec<u8>, +) -> Promise<Result<Nip94Event, Error>> { + let file_hash = sha256_hex(&file_contents); + let nip98_note = create_nip98_note(&seckey, upload_url.to_owned(), file_hash); + + let nip98_base64 = match nip98_note.json() { + Ok(json) => BASE64_URL_SAFE.encode(json), + 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 (sender, promise) = Promise::new(); + + ehttp::fetch(request, move |response| { + let maybe_uploaded_media = match response { + Ok(response) => { + if response.ok { + match String::from_utf8(response.bytes.clone()) { + Ok(str_response) => find_nip94_ev_in_json(str_response), + Err(e) => Err(Error::Generic(e.to_string())), + } + } else { + Err(Error::Generic(format!( + "ehttp Response was unsuccessful. Code {} with message: {}", + response.status, response.status_text + ))) + } + } + Err(e) => Err(Error::Generic(e)), + }; + + sender.send(maybe_uploaded_media); + }); + + promise +} + +fn find_nip94_ev_in_json(json: String) -> Result<Nip94Event, Error> { + match serde_json::from_str::<serde_json::Value>(&json) { + Ok(v) => { + let tags = v["nip94_event"]["tags"].clone(); + let content = v["nip94_event"]["content"] + .as_str() + .unwrap_or_default() + .to_string(); + match serde_json::from_value::<Vec<Vec<String>>>(tags) { + Ok(tags) => Nip94Event::from_tags_and_content(tags, content) + .map_err(|e| Error::Generic(e.to_owned())), + Err(e) => Err(Error::Generic(e.to_string())), + } + } + Err(e) => Err(Error::Generic(e.to_string())), + } +} + +#[derive(Debug)] +pub struct MediaPath { + full_path: PathBuf, + file_name: String, + media_type: SupportedMediaType, +} + +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 = SupportedMediaType::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!( + "{:?} does not have an extension", + path + ))) + } + } +} + +#[derive(Debug)] +pub enum SupportedMediaType { + Png, + Jpeg, + Webp, +} + +impl SupportedMediaType { + pub fn mime_extension(&self) -> &str { + match &self { + SupportedMediaType::Png => "png", + SupportedMediaType::Jpeg => "jpeg", + SupportedMediaType::Webp => "webp", + } + } + + pub fn to_mime(&self) -> String { + format!("{}/{}", self.mime_type(), self.mime_extension()) + } + + fn mime_type(&self) -> String { + match &self { + SupportedMediaType::Png | SupportedMediaType::Jpeg | SupportedMediaType::Webp => { + "image" + } + } + .to_string() + } + + fn from_extension(ext: &str) -> Result<Self, Error> { + match ext.to_lowercase().as_str() { + "jpeg" | "jpg" => Ok(SupportedMediaType::Jpeg), + "png" => Ok(SupportedMediaType::Png), + "webp" => Ok(SupportedMediaType::Webp), + unsupported_type => Err(Error::Generic(format!( + "{unsupported_type} is not a valid file type to upload." + ))), + } + } +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct Nip94Event { + pub url: String, + pub ox: Option<String>, + pub x: Option<String>, + pub media_type: Option<String>, + pub dimensions: Option<(u32, u32)>, + pub blurhash: Option<String>, + pub thumb: Option<String>, + pub content: String, +} + +impl Nip94Event { + pub fn new(url: String, width: u32, height: u32) -> Self { + Self { + url, + ox: None, + x: None, + media_type: None, + dimensions: Some((width, height)), + blurhash: None, + thumb: None, + content: String::new(), + } + } +} + +const URL: &str = "url"; +const OX: &str = "ox"; +const X: &str = "x"; +const M: &str = "m"; +const DIM: &str = "dim"; +const BLURHASH: &str = "blurhash"; +const THUMB: &str = "thumb"; + +impl Nip94Event { + fn from_tags_and_content( + tags: Vec<Vec<String>>, + content: String, + ) -> Result<Self, &'static str> { + let mut url = None; + let mut ox = None; + let mut x = None; + let mut media_type = None; + let mut dimensions = None; + let mut blurhash = None; + let mut thumb = None; + + for tag in tags { + match tag.as_slice() { + [key, value] if key == URL => url = Some(value.to_string()), + [key, value] if key == OX => ox = Some(value.to_string()), + [key, value] if key == X => x = Some(value.to_string()), + [key, value] if key == M => media_type = Some(value.to_string()), + [key, value] if key == DIM => { + if let Some((w, h)) = value.split_once('x') { + if let (Ok(w), Ok(h)) = (w.parse::<u32>(), h.parse::<u32>()) { + dimensions = Some((w, h)); + } + } + } + [key, value] if key == BLURHASH => blurhash = Some(value.to_string()), + [key, value] if key == THUMB => thumb = Some(value.to_string()), + _ => {} + } + } + + Ok(Self { + url: url.ok_or("Missing url")?, + ox, + x, + media_type, + dimensions, + blurhash, + thumb, + content, + }) + } +} + +#[cfg(test)] +mod tests { + use std::{fs, path::PathBuf, str::FromStr}; + + use enostr::FullKeypair; + + use crate::media_upload::{ + get_upload_url_from_provider, nostrbuild_nip96_upload, MediaPath, NOSTR_BUILD_URL, + }; + + use super::internal_nip96_upload; + + #[test] + fn test_nostrbuild_upload_url() { + let promise = get_upload_url_from_provider(NOSTR_BUILD_URL()); + + let url = promise.block_until_ready(); + + assert!(url.is_ok()); + } + + #[test] + #[ignore] // this test should not run automatically since it sends data to a real server + 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 img_bytes = include_bytes!("../../../assets/damus_rounded_80.png"); + let promise = get_upload_url_from_provider(NOSTR_BUILD_URL()); + let kp = FullKeypair::generate(); + println!("Using pubkey: {:?}", kp.pubkey); + + if let Ok(upload_url) = promise.block_until_ready() { + let promise = internal_nip96_upload( + kp.secret_key.secret_bytes(), + upload_url.to_string(), + media_path, + img_bytes.to_vec(), + ); + let res = promise.block_until_ready(); + assert!(res.is_ok()) + } else { + panic!() + } + } + + #[tokio::test] + #[ignore] // this test should not run automatically since it sends data to a real server + async fn test_nostrbuild_nip96() { + // just a random image to test image upload + let file_path = + fs::canonicalize(PathBuf::from_str("../../assets/damus_rounded_80.png").unwrap()) + .unwrap(); + let media_path = MediaPath::new(file_path).unwrap(); + let kp = FullKeypair::generate(); + println!("Using pubkey: {:?}", kp.pubkey); + + let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), media_path); + + let out = promise.block_and_take(); + assert!(out.is_ok()); + } +} diff --git a/crates/notedeck_columns/src/post.rs b/crates/notedeck_columns/src/post.rs @@ -2,9 +2,12 @@ use enostr::FullKeypair; use nostrdb::{Note, NoteBuilder, NoteReply}; use std::collections::HashSet; +use crate::media_upload::Nip94Event; + pub struct NewPost { pub content: String, pub account: FullKeypair, + pub media: Vec<Nip94Event>, } fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> { @@ -15,26 +18,36 @@ fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> { } impl NewPost { - pub fn new(content: String, account: FullKeypair) -> Self { - NewPost { content, account } + pub fn new(content: String, account: FullKeypair, media: Vec<Nip94Event>) -> Self { + NewPost { + content, + account, + media, + } } pub fn to_note(&self, seckey: &[u8; 32]) -> Note { - let mut builder = add_client_tag(NoteBuilder::new()) - .kind(1) - .content(&self.content); + let mut content = self.content.clone(); + append_urls(&mut content, &self.media); + + let mut builder = add_client_tag(NoteBuilder::new()).kind(1).content(&content); for hashtag in Self::extract_hashtags(&self.content) { builder = builder.start_tag().tag_str("t").tag_str(&hashtag); } + if !self.media.is_empty() { + builder = add_imeta_tags(builder, &self.media); + } + builder.sign(seckey).build().expect("note should be ok") } pub fn to_reply(&self, seckey: &[u8; 32], replying_to: &Note) -> Note { - let builder = add_client_tag(NoteBuilder::new()) - .kind(1) - .content(&self.content); + let mut content = self.content.clone(); + append_urls(&mut content, &self.media); + + let builder = add_client_tag(NoteBuilder::new()).kind(1).content(&content); let nip10 = NoteReply::new(replying_to.tags()); @@ -96,6 +109,10 @@ impl NewPost { builder = builder.start_tag().tag_str("p").tag_str(&hex::encode(id)); } + if !self.media.is_empty() { + builder = add_imeta_tags(builder, &self.media); + } + builder .sign(seckey) .build() @@ -103,18 +120,24 @@ impl NewPost { } pub fn to_quote(&self, seckey: &[u8; 32], quoting: &Note) -> Note { - let new_content = format!( + let mut new_content = format!( "{}\nnostr:{}", self.content, enostr::NoteId::new(*quoting.id()).to_bech().unwrap() ); + append_urls(&mut new_content, &self.media); + let mut builder = NoteBuilder::new().kind(1).content(&new_content); for hashtag in Self::extract_hashtags(&self.content) { builder = builder.start_tag().tag_str("t").tag_str(&hashtag); } + if !self.media.is_empty() { + builder = add_imeta_tags(builder, &self.media); + } + builder .start_tag() .tag_str("q") @@ -143,6 +166,43 @@ impl NewPost { } } +fn append_urls(content: &mut String, media: &Vec<Nip94Event>) { + for ev in media { + content.push(' '); + content.push_str(&ev.url); + } +} + +fn add_imeta_tags<'a>(builder: NoteBuilder<'a>, media: &Vec<Nip94Event>) -> NoteBuilder<'a> { + let mut builder = builder; + for item in media { + builder = builder + .start_tag() + .tag_str("imeta") + .tag_str(&format!("url {}", item.url)); + + if let Some(ox) = &item.ox { + builder = builder.tag_str(&format!("ox {ox}")); + }; + if let Some(x) = &item.x { + builder = builder.tag_str(&format!("x {x}")); + } + if let Some(media_type) = &item.media_type { + builder = builder.tag_str(&format!("m {media_type}")); + } + if let Some(dims) = &item.dimensions { + builder = builder.tag_str(&format!("dim {}x{}", dims.0, dims.1)); + } + if let Some(bh) = &item.blurhash { + builder = builder.tag_str(&format!("blurhash {bh}")); + } + if let Some(thumb) = &item.thumb { + builder = builder.tag_str(&format!("thumb {thumb}")); + } + } + builder +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -1,13 +1,16 @@ use crate::draft::{Draft, Drafts}; +use crate::images::fetch_img; +use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; use crate::post::NewPost; use crate::ui::{self, Preview, PreviewConfig}; use crate::Result; use egui::widgets::text_edit::TextEdit; -use egui::{Frame, Layout}; +use egui::{vec2, Frame, Layout, Margin, Pos2, ScrollArea, Sense}; use enostr::{FilledKeypair, FullKeypair, NoteId, RelayPool}; use nostrdb::{Ndb, Transaction}; use notedeck::{ImageCache, NoteCache}; +use tracing::error; use super::contents::render_note_preview; @@ -156,7 +159,6 @@ impl<'a> PostView<'a> { let stroke = if focused { ui.visuals().selection.stroke } else { - //ui.visuals().selection.stroke ui.visuals().noninteractive().bg_stroke }; @@ -181,27 +183,48 @@ impl<'a> PostView<'a> { ui.vertical(|ui| { let edit_response = ui.horizontal(|ui| self.editbox(txn, ui)).inner; + if let PostType::Quote(id) = self.post_type { + let avail_size = ui.available_size_before_wrap(); + ui.with_layout(Layout::left_to_right(egui::Align::TOP), |ui| { + Frame::none().show(ui, |ui| { + ui.vertical(|ui| { + ui.set_max_width(avail_size.x * 0.8); + render_note_preview( + ui, + self.ndb, + self.note_cache, + self.img_cache, + txn, + id.bytes(), + nostrdb::NoteKey::new(0), + ); + }); + }); + }); + } + + Frame::none() + .inner_margin(Margin::symmetric(0.0, 8.0)) + .show(ui, |ui| { + ScrollArea::horizontal().show(ui, |ui| { + ui.with_layout(Layout::left_to_right(egui::Align::Min), |ui| { + ui.add_space(4.0); + self.show_media(ui); + }); + }); + }); + + self.transfer_uploads(ui); + self.show_upload_errors(ui); + let action = ui .horizontal(|ui| { - if let PostType::Quote(id) = self.post_type { - let avail_size = ui.available_size_before_wrap(); - ui.with_layout(Layout::left_to_right(egui::Align::TOP), |ui| { - Frame::none().show(ui, |ui| { - ui.vertical(|ui| { - ui.set_max_width(avail_size.x * 0.8); - render_note_preview( - ui, - self.ndb, - self.note_cache, - self.img_cache, - txn, - id.bytes(), - nostrdb::NoteKey::new(0), - ); - }); - }); - }); - } + ui.with_layout( + egui::Layout::left_to_right(egui::Align::BOTTOM), + |ui| { + self.show_upload_media_button(ui); + }, + ); ui.with_layout(egui::Layout::right_to_left(egui::Align::BOTTOM), |ui| { if ui @@ -214,6 +237,7 @@ impl<'a> PostView<'a> { let new_post = NewPost::new( self.draft.buffer.clone(), self.poster.to_full(), + self.draft.uploaded_media.clone(), ); Some(PostAction::new(self.post_type.clone(), new_post)) } else { @@ -233,6 +257,134 @@ impl<'a> PostView<'a> { }) .inner } + + fn show_media(&mut self, ui: &mut egui::Ui) { + let mut to_remove = Vec::new(); + for (i, media) in self.draft.uploaded_media.iter().enumerate() { + let (width, height) = if let Some(dims) = media.dimensions { + (dims.0, dims.1) + } else { + (300, 300) + }; + let m_cached_promise = self.img_cache.map().get(&media.url); + if m_cached_promise.is_none() { + let promise = fetch_img( + &self.img_cache, + ui.ctx(), + &media.url, + crate::images::ImageType::Content(width, height), + ); + self.img_cache + .map_mut() + .insert(media.url.to_owned(), promise); + } + + match self.img_cache.map()[&media.url].ready() { + Some(Ok(texture)) => { + let media_size = vec2(width as f32, height as f32); + let max_size = vec2(300.0, 300.0); + let size = if media_size.x > max_size.x || media_size.y > max_size.y { + max_size + } else { + media_size + }; + + let img_resp = ui.add(egui::Image::new(texture).max_size(size).rounding(12.0)); + + let remove_button_rect = { + let top_left = img_resp.rect.left_top(); + let spacing = 13.0; + let center = Pos2::new(top_left.x + spacing, top_left.y + spacing); + egui::Rect::from_center_size(center, egui::vec2(26.0, 26.0)) + }; + if show_remove_upload_button(ui, remove_button_rect).clicked() { + to_remove.push(i); + } + ui.advance_cursor_after_rect(img_resp.rect); + } + Some(Err(e)) => { + self.draft.upload_errors.push(e.to_string()); + error!("{e}"); + } + None => { + ui.spinner(); + } + } + } + to_remove.reverse(); + for i in to_remove { + self.draft.uploaded_media.remove(i); + } + } + + fn show_upload_media_button(&mut self, ui: &mut egui::Ui) { + if ui.add(media_upload_button()).clicked() { + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + { + if let Some(file) = rfd::FileDialog::new().pick_file() { + match MediaPath::new(file) { + Ok(media_path) => { + let promise = nostrbuild_nip96_upload( + self.poster.secret_key.secret_bytes(), + media_path, + ); + self.draft.uploading_media.push(promise); + } + Err(e) => { + error!("{e}"); + self.draft.upload_errors.push(e.to_string()); + } + } + } + } + } + } + + fn transfer_uploads(&mut self, ui: &mut egui::Ui) { + let mut indexes_to_remove = Vec::new(); + for (i, promise) in self.draft.uploading_media.iter().enumerate() { + match promise.ready() { + Some(Ok(media)) => { + self.draft.uploaded_media.push(media.clone()); + indexes_to_remove.push(i); + } + Some(Err(e)) => { + self.draft.upload_errors.push(e.to_string()); + error!("{e}"); + } + None => { + ui.spinner(); + } + } + } + + indexes_to_remove.reverse(); + for i in indexes_to_remove { + let _ = self.draft.uploading_media.remove(i); + } + } + + fn show_upload_errors(&mut self, ui: &mut egui::Ui) { + let mut to_remove = Vec::new(); + for (i, error) in self.draft.upload_errors.iter().enumerate() { + if ui + .add( + egui::Label::new(egui::RichText::new(error).color(ui.visuals().warn_fg_color)) + .sense(Sense::click()) + .selectable(false), + ) + .on_hover_text_at_pointer("Dismiss") + .clicked() + { + to_remove.push(i); + } + } + to_remove.reverse(); + + for i in to_remove { + self.draft.upload_errors.remove(i); + } + } } fn post_button(interactive: bool) -> impl egui::Widget { @@ -252,7 +404,86 @@ fn post_button(interactive: bool) -> impl egui::Widget { } } +fn media_upload_button() -> impl egui::Widget { + |ui: &mut egui::Ui| -> egui::Response { + let resp = ui.allocate_response(egui::vec2(32.0, 32.0), egui::Sense::click()); + let painter = ui.painter(); + let (fill_color, stroke) = if resp.hovered() { + ( + ui.visuals().widgets.hovered.bg_fill, + ui.visuals().widgets.hovered.bg_stroke, + ) + } else if resp.clicked() { + ( + ui.visuals().widgets.active.bg_fill, + ui.visuals().widgets.active.bg_stroke, + ) + } else { + ( + ui.visuals().widgets.inactive.bg_fill, + ui.visuals().widgets.inactive.bg_stroke, + ) + }; + + painter.rect_filled(resp.rect, 8.0, fill_color); + painter.rect_stroke(resp.rect, 8.0, stroke); + egui::Image::new(egui::include_image!( + "../../../../../assets/icons/media_upload_dark_4x.png" + )) + .max_size(egui::vec2(16.0, 16.0)) + .paint_at(ui, resp.rect.shrink(8.0)); + resp + } +} + +fn show_remove_upload_button(ui: &mut egui::Ui, desired_rect: egui::Rect) -> egui::Response { + let resp = ui.allocate_rect(desired_rect, egui::Sense::click()); + let size = 24.0; + let (fill_color, stroke) = if resp.hovered() { + ( + ui.visuals().widgets.hovered.bg_fill, + ui.visuals().widgets.hovered.bg_stroke, + ) + } else if resp.clicked() { + ( + ui.visuals().widgets.active.bg_fill, + ui.visuals().widgets.active.bg_stroke, + ) + } else { + ( + ui.visuals().widgets.inactive.bg_fill, + ui.visuals().widgets.inactive.bg_stroke, + ) + }; + let center = desired_rect.center(); + let painter = ui.painter_at(desired_rect); + let radius = size / 2.0; + + painter.circle_filled(center, radius, fill_color); + painter.circle_stroke(center, radius, stroke); + + painter.line_segment( + [ + Pos2::new(center.x - 4.0, center.y - 4.0), + Pos2::new(center.x + 4.0, center.y + 4.0), + ], + egui::Stroke::new(1.33, ui.visuals().text_color()), + ); + + painter.line_segment( + [ + Pos2::new(center.x + 4.0, center.y - 4.0), + Pos2::new(center.x - 4.0, center.y + 4.0), + ], + egui::Stroke::new(1.33, ui.visuals().text_color()), + ); + resp +} + mod preview { + + use crate::media_upload::Nip94Event; + use super::*; use notedeck::{App, AppContext}; @@ -263,8 +494,30 @@ mod preview { impl PostPreview { fn new() -> Self { + let mut draft = Draft::new(); + // can use any url here + draft.uploaded_media.push(Nip94Event::new( + "https://image.nostr.build/41b40657dd6abf7c275dffc86b29bd863e9337a74870d4ee1c33a72a91c9d733.jpg".to_owned(), + 612, + 407, + )); + draft.uploaded_media.push(Nip94Event::new( + "https://image.nostr.build/thumb/fdb46182b039d29af0f5eac084d4d30cd4ad2580ea04fe6c7e79acfe095f9852.png".to_owned(), + 80, + 80, + )); + draft.uploaded_media.push(Nip94Event::new( + "https://i.nostr.build/7EznpHsnBZ36Akju.png".to_owned(), + 2438, + 1476, + )); + draft.uploaded_media.push(Nip94Event::new( + "https://i.nostr.build/qCCw8szrjTydTiMV.png".to_owned(), + 2002, + 2272, + )); PostPreview { - draft: Draft::new(), + draft, poster: FullKeypair::generate(), } }