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
This commit is contained in:
William Casarin
2025-01-25 16:17:04 -08:00
10 changed files with 1284 additions and 36 deletions

View File

@@ -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 }

View File

@@ -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();
}
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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::*;

View File

@@ -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(),
}
}