feat: add support for NIP-09
This commit is contained in:
@@ -325,8 +325,8 @@ pub async fn login(
|
||||
.reconcile(
|
||||
Filter::new()
|
||||
.authors(authors.clone())
|
||||
.kinds(vec![Kind::Metadata, Kind::ContactList])
|
||||
.limit(authors.len() * 10),
|
||||
.kinds(vec![Kind::Metadata, Kind::ContactList, Kind::EventDeletion])
|
||||
.limit(authors.len() * 20),
|
||||
NegentropyOptions::default(),
|
||||
)
|
||||
.await
|
||||
@@ -409,8 +409,13 @@ pub async fn login(
|
||||
.reconcile(
|
||||
Filter::new()
|
||||
.authors(trusted)
|
||||
.kinds(vec![Kind::Metadata, Kind::TextNote, Kind::Repost])
|
||||
.limit(20000),
|
||||
.kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::TextNote,
|
||||
Kind::Repost,
|
||||
Kind::EventDeletion,
|
||||
])
|
||||
.limit(30000),
|
||||
NegentropyOptions::default(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -5,7 +5,7 @@ use specta::Type;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use tauri::State;
|
||||
|
||||
use crate::common::{create_event_tags, filter_converstation, parse_event, Meta};
|
||||
use crate::common::{create_tags, parse_event, process_event, Meta};
|
||||
use crate::{Nostr, DEFAULT_DIFFICULTY, FETCH_LIMIT};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
@@ -215,22 +215,7 @@ pub async fn get_all_events_by_author(
|
||||
.get_events_of(vec![filter], EventSource::Database)
|
||||
.await
|
||||
{
|
||||
Ok(events) => {
|
||||
let fils = filter_converstation(events);
|
||||
let futures = fils.iter().map(|ev| async move {
|
||||
let raw = ev.as_json();
|
||||
let parsed = if ev.kind == Kind::TextNote {
|
||||
Some(parse_event(&ev.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
RichEvent { raw, parsed }
|
||||
});
|
||||
let rich_events = join_all(futures).await;
|
||||
|
||||
Ok(rich_events)
|
||||
}
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -264,23 +249,7 @@ pub async fn get_all_events_by_authors(
|
||||
.get_events_of(vec![filter], EventSource::Database)
|
||||
.await
|
||||
{
|
||||
Ok(events) => {
|
||||
let fils = filter_converstation(events);
|
||||
let futures = fils.iter().map(|ev| async move {
|
||||
let raw = ev.as_json();
|
||||
let parsed = if ev.kind == Kind::TextNote {
|
||||
Some(parse_event(&ev.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
RichEvent { raw, parsed }
|
||||
});
|
||||
|
||||
let rich_events = join_all(futures).await;
|
||||
|
||||
Ok(rich_events)
|
||||
}
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -312,22 +281,7 @@ pub async fn get_all_events_by_hashtags(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(events) => {
|
||||
let fils = filter_converstation(events);
|
||||
let futures = fils.iter().map(|ev| async move {
|
||||
let raw = ev.as_json();
|
||||
let parsed = if ev.kind == Kind::TextNote {
|
||||
Some(parse_event(&ev.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
RichEvent { raw, parsed }
|
||||
});
|
||||
let rich_events = join_all(futures).await;
|
||||
|
||||
Ok(rich_events)
|
||||
}
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -351,22 +305,7 @@ pub async fn get_local_events(
|
||||
.until(as_of);
|
||||
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => {
|
||||
let fils = filter_converstation(events);
|
||||
let futures = fils.iter().map(|ev| async move {
|
||||
let raw = ev.as_json();
|
||||
let parsed = if ev.kind == Kind::TextNote {
|
||||
Some(parse_event(&ev.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
RichEvent { raw, parsed }
|
||||
});
|
||||
let rich_events = join_all(futures).await;
|
||||
|
||||
Ok(rich_events)
|
||||
}
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -396,22 +335,7 @@ pub async fn get_global_events(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(events) => {
|
||||
let fils = filter_converstation(events);
|
||||
let futures = fils.iter().map(|ev| async move {
|
||||
let raw = ev.as_json();
|
||||
let parsed = if ev.kind == Kind::TextNote {
|
||||
Some(parse_event(&ev.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
RichEvent { raw, parsed }
|
||||
});
|
||||
let rich_events = join_all(futures).await;
|
||||
|
||||
Ok(rich_events)
|
||||
}
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -427,7 +351,7 @@ pub async fn publish(
|
||||
let client = &state.client;
|
||||
|
||||
// Create tags from content
|
||||
let mut tags = create_event_tags(&content);
|
||||
let mut tags = create_tags(&content);
|
||||
|
||||
// Add client tag
|
||||
// TODO: allow user config this setting
|
||||
@@ -464,10 +388,8 @@ pub async fn reply(
|
||||
let client = &state.client;
|
||||
let database = client.database();
|
||||
|
||||
// Create tags from content
|
||||
let mut tags = create_event_tags(&content);
|
||||
|
||||
let reply_id = EventId::parse(&to).map_err(|err| err.to_string())?;
|
||||
let mut tags = create_tags(&content);
|
||||
|
||||
match database.query(vec![Filter::new().id(reply_id)]).await {
|
||||
Ok(events) => {
|
||||
@@ -651,23 +573,7 @@ pub async fn search(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(events) => {
|
||||
let fils = filter_converstation(events);
|
||||
let futures = fils.iter().map(|ev| async move {
|
||||
let raw = ev.as_json();
|
||||
let parsed = if ev.kind == Kind::TextNote {
|
||||
Some(parse_event(&ev.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
RichEvent { raw, parsed }
|
||||
});
|
||||
|
||||
let rich_events = join_all(futures).await;
|
||||
|
||||
Ok(rich_events)
|
||||
}
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -689,3 +595,16 @@ pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<boo
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
|
||||
let builder = EventBuilder::delete(vec![event_id]);
|
||||
|
||||
match client.send_event_builder(builder).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use futures::future::join_all;
|
||||
use linkify::LinkFinder;
|
||||
use nostr_sdk::prelude::*;
|
||||
use reqwest::Client as ReqClient;
|
||||
@@ -7,7 +8,7 @@ use std::collections::HashSet;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::Settings;
|
||||
use crate::RichEvent;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct Meta {
|
||||
@@ -18,6 +19,7 @@ pub struct Meta {
|
||||
pub hashtags: Vec<String>,
|
||||
}
|
||||
|
||||
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
|
||||
const NOSTR_EVENTS: [&str; 10] = [
|
||||
"@nevent1",
|
||||
"@note1",
|
||||
@@ -42,135 +44,12 @@ const NOSTR_MENTIONS: [&str; 10] = [
|
||||
"Nostr:nprofile1",
|
||||
"Nostr:naddr1",
|
||||
];
|
||||
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
|
||||
|
||||
pub fn get_latest_event(events: &[Event]) -> Option<&Event> {
|
||||
events.iter().next()
|
||||
}
|
||||
|
||||
pub fn filter_converstation(events: Vec<Event>) -> Vec<Event> {
|
||||
events
|
||||
.into_iter()
|
||||
.filter_map(|ev| {
|
||||
if ev.kind == Kind::TextNote {
|
||||
let tags: Vec<&str> = ev
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E))
|
||||
})
|
||||
.filter_map(|t| t.content())
|
||||
.collect();
|
||||
|
||||
if tags.is_empty() {
|
||||
Some(ev)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(ev)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<Event>>()
|
||||
}
|
||||
|
||||
pub fn dedup_event(events: &[Event]) -> Vec<Event> {
|
||||
let mut seen_ids = HashSet::new();
|
||||
events
|
||||
.iter()
|
||||
.filter(|&event| {
|
||||
let e = TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E));
|
||||
let e_tags: Vec<&Tag> = event.tags.iter().filter(|el| el.kind() == e).collect();
|
||||
let ids: Vec<&str> = e_tags.iter().filter_map(|tag| tag.content()).collect();
|
||||
let is_dup = ids.iter().any(|id| seen_ids.contains(*id));
|
||||
|
||||
for id in &ids {
|
||||
seen_ids.insert(*id);
|
||||
}
|
||||
|
||||
!is_dup
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn parse_event(content: &str) -> Meta {
|
||||
let mut finder = LinkFinder::new();
|
||||
finder.url_must_have_scheme(false);
|
||||
|
||||
// Get urls
|
||||
let urls: Vec<_> = finder.links(content).collect();
|
||||
// Get words
|
||||
let words: Vec<_> = content.split_whitespace().collect();
|
||||
|
||||
let hashtags = words
|
||||
.iter()
|
||||
.filter(|&&word| word.starts_with('#'))
|
||||
.map(|&s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let events = words
|
||||
.iter()
|
||||
.filter(|&&word| NOSTR_EVENTS.iter().any(|&el| word.starts_with(el)))
|
||||
.map(|&s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mentions = words
|
||||
.iter()
|
||||
.filter(|&&word| NOSTR_MENTIONS.iter().any(|&el| word.starts_with(el)))
|
||||
.map(|&s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut images = Vec::new();
|
||||
let mut text = content.to_string();
|
||||
|
||||
if !urls.is_empty() {
|
||||
let client = ReqClient::new();
|
||||
|
||||
for url in urls {
|
||||
let url_str = url.as_str();
|
||||
|
||||
if let Ok(parsed_url) = Url::from_str(url_str) {
|
||||
if let Some(ext) = parsed_url
|
||||
.path_segments()
|
||||
.and_then(|segments| segments.last().and_then(|s| s.split('.').last()))
|
||||
{
|
||||
if IMAGES.contains(&ext) {
|
||||
text = text.replace(url_str, "");
|
||||
images.push(url_str.to_string());
|
||||
// Process the next item.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the content type of URL via HEAD request
|
||||
if let Ok(res) = client.head(url_str).send().await {
|
||||
if let Some(content_type) = res.headers().get("Content-Type") {
|
||||
if content_type.to_str().unwrap_or("").starts_with("image") {
|
||||
text = text.replace(url_str, "");
|
||||
images.push(url_str.to_string());
|
||||
// Process the next item.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the resulting content string to remove extra spaces
|
||||
let cleaned_text = text.trim().to_string();
|
||||
|
||||
Meta {
|
||||
content: cleaned_text,
|
||||
events,
|
||||
mentions,
|
||||
hashtags,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_event_tags(content: &str) -> Vec<Tag> {
|
||||
pub fn create_tags(content: &str) -> Vec<Tag> {
|
||||
let mut tags: Vec<Tag> = vec![];
|
||||
let mut tag_set: HashSet<String> = HashSet::new();
|
||||
|
||||
@@ -251,6 +130,72 @@ pub fn create_event_tags(content: &str) -> Vec<Tag> {
|
||||
tags
|
||||
}
|
||||
|
||||
pub async fn process_event(client: &Client, events: Vec<Event>) -> Vec<RichEvent> {
|
||||
// Remove event thread if event is TextNote
|
||||
let events: Vec<Event> = events
|
||||
.into_iter()
|
||||
.filter_map(|ev| {
|
||||
if ev.kind == Kind::TextNote {
|
||||
let tags = ev
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.is_reply() || t.is_root())
|
||||
.filter_map(|t| t.content())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if tags.is_empty() {
|
||||
Some(ev)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(ev)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Get deletion request by event's authors
|
||||
let ids: Vec<EventId> = events.iter().map(|ev| ev.id).collect();
|
||||
let filter = Filter::new().events(ids).kind(Kind::EventDeletion);
|
||||
|
||||
let mut final_events: Vec<Event> = events.clone();
|
||||
|
||||
if let Ok(requests) = client.database().query(vec![filter]).await {
|
||||
if !requests.is_empty() {
|
||||
let ids: Vec<&str> = requests
|
||||
.iter()
|
||||
.flat_map(|ev| ev.get_tags_content(TagKind::e()))
|
||||
.collect();
|
||||
|
||||
// Remove event if event is deleted by author
|
||||
final_events = events
|
||||
.into_iter()
|
||||
.filter_map(|ev| {
|
||||
if ids.iter().any(|&i| i == ev.id.to_hex()) {
|
||||
None
|
||||
} else {
|
||||
Some(ev)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
|
||||
// Convert raw event to rich event
|
||||
let futures = final_events.iter().map(|ev| async move {
|
||||
let raw = ev.as_json();
|
||||
let parsed = if ev.kind == Kind::TextNote {
|
||||
Some(parse_event(&ev.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
RichEvent { raw, parsed }
|
||||
});
|
||||
|
||||
join_all(futures).await
|
||||
}
|
||||
|
||||
pub async fn init_nip65(client: &Client, public_key: &str) {
|
||||
let author = PublicKey::from_str(public_key).unwrap();
|
||||
let filter = Filter::new().author(author).kind(Kind::RelayList).limit(1);
|
||||
@@ -286,73 +231,78 @@ pub async fn init_nip65(client: &Client, public_key: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_settings(client: &Client) -> Result<Settings, String> {
|
||||
let ident = "lume_v4:settings";
|
||||
let signer = client
|
||||
.signer()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get signer: {:?}", e))?;
|
||||
let public_key = signer
|
||||
.public_key()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get public key: {:?}", e))?;
|
||||
pub async fn parse_event(content: &str) -> Meta {
|
||||
let mut finder = LinkFinder::new();
|
||||
finder.url_must_have_scheme(false);
|
||||
|
||||
let filter = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(ident)
|
||||
.limit(1);
|
||||
// Get urls
|
||||
let urls: Vec<_> = finder.links(content).collect();
|
||||
// Get words
|
||||
let words: Vec<_> = content.split_whitespace().collect();
|
||||
|
||||
match client
|
||||
.get_events_of(
|
||||
vec![filter],
|
||||
EventSource::both(Some(Duration::from_secs(5))),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(events) => {
|
||||
if let Some(event) = events.first() {
|
||||
match signer.nip44_decrypt(&public_key, &event.content).await {
|
||||
Ok(decrypted) => match serde_json::from_str(&decrypted) {
|
||||
Ok(parsed) => Ok(parsed),
|
||||
Err(_) => Err("Could not parse settings payload".into()),
|
||||
},
|
||||
Err(e) => Err(format!("Failed to decrypt settings content: {:?}", e)),
|
||||
let hashtags = words
|
||||
.iter()
|
||||
.filter(|&&word| word.starts_with('#'))
|
||||
.map(|&s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let events = words
|
||||
.iter()
|
||||
.filter(|&&word| NOSTR_EVENTS.iter().any(|&el| word.starts_with(el)))
|
||||
.map(|&s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mentions = words
|
||||
.iter()
|
||||
.filter(|&&word| NOSTR_MENTIONS.iter().any(|&el| word.starts_with(el)))
|
||||
.map(|&s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut images = Vec::new();
|
||||
let mut text = content.to_string();
|
||||
|
||||
if !urls.is_empty() {
|
||||
let client = ReqClient::new();
|
||||
|
||||
for url in urls {
|
||||
let url_str = url.as_str();
|
||||
|
||||
if let Ok(parsed_url) = Url::from_str(url_str) {
|
||||
if let Some(ext) = parsed_url
|
||||
.path_segments()
|
||||
.and_then(|segments| segments.last().and_then(|s| s.split('.').last()))
|
||||
{
|
||||
if IMAGES.contains(&ext) {
|
||||
text = text.replace(url_str, "");
|
||||
images.push(url_str.to_string());
|
||||
// Process the next item.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the content type of URL via HEAD request
|
||||
if let Ok(res) = client.head(url_str).send().await {
|
||||
if let Some(content_type) = res.headers().get("Content-Type") {
|
||||
if content_type.to_str().unwrap_or("").starts_with("image") {
|
||||
text = text.replace(url_str, "");
|
||||
images.push(url_str.to_string());
|
||||
// Process the next item.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err("Settings not found.".into())
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"Failed to get events for ApplicationSpecificData: {:?}",
|
||||
e
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_event() {
|
||||
let content = "Check this image: https://example.com/image.jpg #cool @npub1";
|
||||
let meta = parse_event(content).await;
|
||||
|
||||
assert_eq!(meta.content, "Check this image: #cool @npub1");
|
||||
assert_eq!(meta.images, vec!["https://example.com/image.jpg"]);
|
||||
assert_eq!(meta.hashtags, vec!["#cool"]);
|
||||
assert_eq!(meta.mentions, vec!["@npub1"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_video() {
|
||||
let content = "Check this video: https://example.com/video.mp4 #cool @npub1";
|
||||
let meta = parse_event(content).await;
|
||||
|
||||
assert_eq!(meta.content, "Check this video: #cool @npub1");
|
||||
assert_eq!(meta.images, Vec::<String>::new());
|
||||
assert_eq!(meta.hashtags, vec!["#cool"]);
|
||||
assert_eq!(meta.mentions, vec!["@npub1"]);
|
||||
}
|
||||
|
||||
// Clean up the resulting content string to remove extra spaces
|
||||
let cleaned_text = text.trim().to_string();
|
||||
|
||||
Meta {
|
||||
content: cleaned_text,
|
||||
events,
|
||||
mentions,
|
||||
hashtags,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ fn main() {
|
||||
get_local_events,
|
||||
get_global_events,
|
||||
is_deleted_event,
|
||||
request_delete,
|
||||
search,
|
||||
publish,
|
||||
reply,
|
||||
|
||||
@@ -390,6 +390,14 @@ async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async requestDelete(id: string) : Promise<Result<null, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("request_delete", { id }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async search(query: string, until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("search", { query, until }) };
|
||||
|
||||
@@ -13,14 +13,17 @@ export const MentionNote = memo(function MentionNote({
|
||||
|
||||
return (
|
||||
<div className="relative my-2">
|
||||
<div className="pl-3 before:content-[''] before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:border-l-[2px] before:border-black/10 dark:before:border-white/10">
|
||||
<div className="min-h-[64px] pl-3 before:content-[''] before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:border-l-[2px] before:border-black/10 dark:before:border-white/10">
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
<div className="h-[64px] flex items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : isError || !event ? (
|
||||
<p className="text-sm font-medium text-red-500">
|
||||
{error.message ||
|
||||
"Quoted note is not found with your current relay set"}
|
||||
</p>
|
||||
<div className="h-[64px] flex items-center">
|
||||
<p className="text-sm font-medium text-red-500">
|
||||
{error.message || "Note can be found with your current relay set"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Note.Provider event={event}>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
|
||||
@@ -1,51 +1,68 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { DotsThree } from "@phosphor-icons/react";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useCallback } from "react";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteMenu() {
|
||||
const event = useNoteContext();
|
||||
const { account }: { account: string } = useSearch({ strict: false });
|
||||
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
const list = [
|
||||
MenuItem.new({
|
||||
text: "Copy Sharable Link",
|
||||
action: async () => {
|
||||
const eventId = await event.idAsBech32();
|
||||
await writeText(`https://njump.me/${eventId}`);
|
||||
},
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Copy Event ID",
|
||||
text: "Copy ID",
|
||||
action: async () => {
|
||||
const eventId = await event.idAsBech32();
|
||||
await writeText(eventId);
|
||||
},
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Copy Public Key",
|
||||
text: "Copy author",
|
||||
action: async () => {
|
||||
const pubkey = await event.pubkeyAsBech32();
|
||||
await writeText(pubkey);
|
||||
},
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
MenuItem.new({
|
||||
text: "Copy Raw Event",
|
||||
text: "Copy sharable link",
|
||||
action: async () => {
|
||||
event.meta = undefined;
|
||||
const raw = JSON.stringify(event);
|
||||
await writeText(raw);
|
||||
const eventId = await event.idAsBech32();
|
||||
await writeText(`https://njump.me/${eventId}`);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
MenuItem.new({
|
||||
text: "Copy raw event",
|
||||
action: async () => {
|
||||
event.meta = undefined;
|
||||
await writeText(JSON.stringify(event));
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
if (account?.length) {
|
||||
const pubkey = nip19.decode(account).data;
|
||||
|
||||
if (event.pubkey === pubkey) {
|
||||
list.push(
|
||||
MenuItem.new({
|
||||
text: "Request delete",
|
||||
action: async () => {
|
||||
await commands.requestDelete(event.id);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const items = await Promise.all(list);
|
||||
const menu = await Menu.new({ items });
|
||||
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
|
||||
import { RepostNote, Spinner, TextNote } from "@/components";
|
||||
import type { LumeEvent } from "@/system";
|
||||
import { Kind } from "@/types";
|
||||
import { ArrowDown } from "@phosphor-icons/react";
|
||||
@@ -55,25 +55,14 @@ export function Screen() {
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
if (event.isQuote) {
|
||||
return (
|
||||
<Quote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TextNote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<TextNote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[data],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
|
||||
import { RepostNote, Spinner, TextNote } from "@/components";
|
||||
import type { LumeEvent } from "@/system";
|
||||
import { Kind } from "@/types";
|
||||
import { ArrowDown } from "@phosphor-icons/react";
|
||||
@@ -58,25 +58,14 @@ export function Screen() {
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
if (event.isQuote) {
|
||||
return (
|
||||
<Quote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TextNote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<TextNote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[data],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
|
||||
import { RepostNote, Spinner, TextNote } from "@/components";
|
||||
import type { LumeEvent } from "@/system";
|
||||
import { Kind } from "@/types";
|
||||
import { ArrowDown } from "@phosphor-icons/react";
|
||||
@@ -58,25 +58,14 @@ export function Screen() {
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
if (event.isQuote) {
|
||||
return (
|
||||
<Quote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TextNote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<TextNote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[data],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { events, commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
|
||||
import { RepostNote, Spinner, TextNote } from "@/components";
|
||||
import { LumeEvent } from "@/system";
|
||||
import { Kind, type Meta } from "@/types";
|
||||
import { ArrowDown, ArrowUp } from "@phosphor-icons/react";
|
||||
@@ -69,25 +69,14 @@ export function Screen() {
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
if (event.isQuote) {
|
||||
return (
|
||||
<Quote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TextNote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<TextNote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[data],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
|
||||
import { RepostNote, Spinner, TextNote } from "@/components";
|
||||
import { LumeEvent } from "@/system";
|
||||
import { Kind, type NostrEvent } from "@/types";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
@@ -50,25 +50,14 @@ function Screen() {
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
if (event.isQuote) {
|
||||
return (
|
||||
<Quote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TextNote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<TextNote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[data],
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Spinner } from "@/components";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { User } from "@/components/user";
|
||||
import { RepostNote, Spinner, TextNote, User } from "@/components";
|
||||
import type { LumeEvent } from "@/system";
|
||||
import { Kind } from "@/types";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
@@ -48,16 +44,7 @@ function Screen() {
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
if (event.isQuote) {
|
||||
return (
|
||||
<Quote
|
||||
key={event.id}
|
||||
event={event}
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<TextNote
|
||||
key={event.id}
|
||||
@@ -65,7 +52,6 @@ function Screen() {
|
||||
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[events],
|
||||
|
||||
Reference in New Issue
Block a user