From 02070c2868a46776f4f4fd2e924dec7990003555 Mon Sep 17 00:00:00 2001 From: Awiteb Date: Sat, 24 May 2025 06:04:21 +0000 Subject: [PATCH] feat: A `--quote-to` flag to quote the replied to content in the editor Signed-off-by: Awiteb --- CHANGELOG.md | 5 +++ src/cli/commands/issue/new.rs | 2 +- src/cli/commands/reply.rs | 76 +++++++++++++++++++++++++++++++---- src/nostr_utils/utils.rs | 20 ++++++--- 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c42ce1..3c57ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Read the `nostr-address` file in `repo view` command - by Awiteb - Read the `nostr-address` file in `issue new` command - by Awiteb - Read the `nostr-address` file in `reply` command - by Awiteb +- A `--quote-to` flag to quote the replied to content in the editor - by Awiteb + +### Dependencies + +- Add `chrono@0.4.41` to the dependencies - by Awiteb ## [0.1.0] - 2025-05-21 diff --git a/src/cli/commands/issue/new.rs b/src/cli/commands/issue/new.rs index 848c06f..41e9b58 100644 --- a/src/cli/commands/issue/new.rs +++ b/src/cli/commands/issue/new.rs @@ -71,7 +71,7 @@ impl NewArgs { return Ok((None, content.trim().to_owned())); } // If the `self.content` is `None` then the `self.editor` is `true` - let file_content = utils::read_editor(".md")?; + let file_content = utils::read_editor(None, ".md")?; if file_content.contains('\n') { Ok(file_content .split_once('\n') diff --git a/src/cli/commands/reply.rs b/src/cli/commands/reply.rs index 2b8f820..e7e1342 100644 --- a/src/cli/commands/reply.rs +++ b/src/cli/commands/reply.rs @@ -18,9 +18,12 @@ use std::{fs, str::FromStr}; use clap::{ArgGroup, Args}; use nostr::{ - event::{EventBuilder, EventId, Kind, Tag}, + event::{Event, EventBuilder, EventId, Kind, Tag}, filter::Filter, - nips::nip19::{self, FromBech32, Nip19Coordinate}, + nips::{ + nip01::Metadata, + nip19::{self, FromBech32, Nip19Coordinate, ToBech32}, + }, types::RelayUrl, }; @@ -31,6 +34,11 @@ use crate::{ nostr_utils::{NostrClient, utils}, }; +/// Length of a Nostr npub (public key) in characters. +const NPUB_LEN: usize = 63; +/// The max date "9999-01-01 at 00:00 UTC" +const MAX_DATE: i64 = 253370764800; + /// Parses and represents a Nostr `nevent1` or `note1`. #[derive(Debug, Clone)] struct NostrEvent { @@ -74,24 +82,31 @@ impl FromStr for NostrEvent { ArgGroup::new("comment-content") .args(["comment", "editor"]) .required(true) + ), + group( + ArgGroup::new("quote-reply-to") + .args(["comment", "quote_to"]) ) )] pub struct ReplyArgs { /// The issue, patch, or comment to reply to #[arg(long)] - to: NostrEvent, + to: NostrEvent, + /// Quote the replied-to event in the editor + #[arg(long)] + quote_to: bool, /// Repository address in `naddr` format. /// /// If not provided, `n34` will look for the `nostr-address` file and if not /// found, will get it from the root event if found. #[arg(short, long, value_parser = parsers::repo_naddr)] - naddr: Option, + naddr: Option, /// The comment (cannot be used with --editor) #[arg(short, long)] - comment: Option, + comment: Option, /// Open editor to write comment (cannot be used with --content) #[arg(short, long)] - editor: bool, + editor: bool, } impl CommandRunner for ReplyArgs { @@ -148,7 +163,13 @@ impl CommandRunner for ReplyArgs { .await; } - let content = utils::get_content(self.comment.as_ref(), ".txt")?; + let quoted_content = if self.quote_to { + Some(quote_reply_to_content(&client, &reply_to).await) + } else { + None + }; + + let content = utils::get_content(self.comment.as_ref(), quoted_content.as_ref(), ".txt")?; let content_details = client.parse_content(&content).await; write_relays.extend(content_details.write_relays.clone()); @@ -184,3 +205,44 @@ impl CommandRunner for ReplyArgs { Ok(()) } } + +/// Creates a quoted reply string in the format "On yyyy-mm-dd at hh:mm UTC, +/// wrote:" followed by the event content. Uses display name if +/// available, otherwise falls back to a shortened npub string. Dates are +/// formatted in UTC. +async fn quote_reply_to_content(client: &NostrClient, quoted_event: &Event) -> String { + let author_name = client + .fetch_event( + Filter::new() + .kind(Kind::Metadata) + .author(quoted_event.pubkey), + ) + .await + .ok() + .flatten() + .and_then(|e| Metadata::try_from(&e).ok()) + .and_then(|m| m.display_name.or(m.name)) + .unwrap_or_else(|| { + let pubkey = quoted_event + .pubkey + .to_bech32() + .expect("The error is `Infallible`"); + format!("{}...{}", &pubkey[..8], &pubkey[NPUB_LEN - 8..]) + }); + + let fdate = chrono::DateTime::from_timestamp( + quoted_event + .created_at + .as_u64() + .try_into() + .unwrap_or(MAX_DATE), + 0, + ) + .map(|datetime| datetime.format("On %F at %R UTC, ").to_string()) + .unwrap_or_default(); + + format!( + "{fdate}{author_name} wrote:\n> {}", + quoted_event.content.trim().replace("\n", "\n> ") + ) +} diff --git a/src/nostr_utils/utils.rs b/src/nostr_utils/utils.rs index 9683a4d..bc91806 100644 --- a/src/nostr_utils/utils.rs +++ b/src/nostr_utils/utils.rs @@ -162,13 +162,17 @@ pub fn add_read_relays(mut vector: Vec, event: Option<&Event>) -> Vec< /// Opens the user's default editor ($EDITOR) to edit a temporary file with /// given suffix, then reads and returns the file contents. The temporary file /// is automatically deleted. -pub fn read_editor(file_suffix: &str) -> N34Result { +pub fn read_editor(file_pre_content: Option<&str>, file_suffix: &str) -> N34Result { let Ok(editor) = std::env::var("EDITOR") else { return Err(N34Error::EditorNotFound); }; let temp_path = tempfile::NamedTempFile::with_suffix(file_suffix)?.into_temp_path(); + if let Some(pre_content) = file_pre_content { + fs::write(&temp_path, pre_content)?; + } + // Disable the logs to not show up in a terminal text editor crate::EDITOR_OPEN.store(true, Ordering::Relaxed); let exit_status = std::process::Command::new(editor) @@ -195,14 +199,18 @@ pub fn read_editor(file_suffix: &str) -> N34Result { } /// Returns the given content if it's `Option::Some` or call [`read_editor`] -pub fn get_content(content: Option, file_suffix: &str) -> N34Result -where - S: AsRef, -{ +pub fn get_content( + content: Option>, + quoted_content: Option>, + file_suffix: &str, +) -> N34Result { if let Some(content) = content { return Ok(content.as_ref().trim().to_owned()); } - read_editor(file_suffix) + read_editor( + quoted_content.map(|s| s.as_ref().to_owned()).as_deref(), + file_suffix, + ) } /// Path to the `nostr-address` file in current directory.