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.