Redesign for the v1 stable release (#3)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m26s
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m26s
Only half done. Will continue in another PR. Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -20,6 +20,9 @@ pub struct Identity {
|
||||
/// The public key of the account
|
||||
pub public_key: Option<PublicKey>,
|
||||
|
||||
/// Whether the identity is owned by the user
|
||||
pub owned: bool,
|
||||
|
||||
/// Status of the current user NIP-65 relays
|
||||
relay_list: RelayState,
|
||||
|
||||
@@ -37,11 +40,18 @@ impl Identity {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
public_key: None,
|
||||
owned: true,
|
||||
relay_list: RelayState::default(),
|
||||
messaging_relays: RelayState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the relay states to their default values.
|
||||
pub fn reset_relay_state(&mut self) {
|
||||
self.relay_list = RelayState::default();
|
||||
self.messaging_relays = RelayState::default();
|
||||
}
|
||||
|
||||
/// Sets the state of the NIP-65 relays.
|
||||
pub fn set_relay_list_state(&mut self, state: RelayState) {
|
||||
self.relay_list = state;
|
||||
@@ -83,4 +93,9 @@ impl Identity {
|
||||
pub fn unset_public_key(&mut self) {
|
||||
self.public_key = None;
|
||||
}
|
||||
|
||||
/// Sets whether the identity is owned by the user.
|
||||
pub fn set_owned(&mut self, owned: bool) {
|
||||
self.owned = owned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::collections::HashSet;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use common::{config_dir, BOOTSTRAP_RELAYS, SEARCH_RELAYS};
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{config_dir, CLIENT_NAME};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_lmdb::NostrLmdb;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
@@ -11,23 +13,50 @@ mod device;
|
||||
mod event;
|
||||
mod gossip;
|
||||
mod identity;
|
||||
mod nip05;
|
||||
|
||||
pub use device::*;
|
||||
pub use event::*;
|
||||
pub use gossip::*;
|
||||
pub use identity::*;
|
||||
pub use nip05::*;
|
||||
|
||||
use crate::identity::Identity;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
NostrRegistry::set_global(cx.new(NostrRegistry::new), cx);
|
||||
}
|
||||
|
||||
/// Default timeout for subscription
|
||||
pub const TIMEOUT: u64 = 3;
|
||||
/// Default delay for searching
|
||||
pub const FIND_DELAY: u64 = 600;
|
||||
/// Default limit for searching
|
||||
pub const FIND_LIMIT: usize = 20;
|
||||
/// Default timeout for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
||||
/// Default Nostr Connect relay
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
||||
/// Default subscription id for device gift wrap events
|
||||
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
|
||||
/// Default subscription id for user gift wrap events
|
||||
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
|
||||
/// Default avatar for new users
|
||||
pub const DEFAULT_AVATAR: &str = "https://image.nostr.build/93bb6084457a42620849b6827f3f34f111ae5a4ac728638a989d4ed4b4bb3ac8.png";
|
||||
/// Default vertex relays
|
||||
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||
/// Default search relays
|
||||
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.noswhere.com"];
|
||||
/// Default bootstrap relays
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nos.social",
|
||||
"wss://user.kindpag.es",
|
||||
];
|
||||
|
||||
/// Default subscription id for gift wrap events
|
||||
pub const GIFTWRAP_SUBSCRIPTION: &str = "giftwrap-events";
|
||||
pub fn init(cx: &mut App) {
|
||||
// Initialize the tokio runtime
|
||||
gpui_tokio::init(cx);
|
||||
|
||||
NostrRegistry::set_global(cx.new(NostrRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
||||
|
||||
@@ -168,6 +197,14 @@ impl NostrRegistry {
|
||||
}),
|
||||
);
|
||||
|
||||
cx.defer(|cx| {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.get_identity(cx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
client,
|
||||
app_keys,
|
||||
@@ -190,6 +227,11 @@ impl NostrRegistry {
|
||||
client.add_relay(url).await?;
|
||||
}
|
||||
|
||||
// Add wot relay to the relay pool
|
||||
for url in WOT_RELAYS.into_iter() {
|
||||
client.add_relay(url).await?;
|
||||
}
|
||||
|
||||
// Connect to all added relays
|
||||
client.connect().await;
|
||||
|
||||
@@ -300,9 +342,15 @@ impl NostrRegistry {
|
||||
let keys = Keys::generate();
|
||||
let secret_key = keys.secret_key();
|
||||
|
||||
// Create directory and write secret key
|
||||
std::fs::create_dir_all(dir.parent().unwrap())?;
|
||||
std::fs::write(&dir, secret_key.to_secret_bytes())?;
|
||||
|
||||
// Set permissions to readonly
|
||||
let mut perms = std::fs::metadata(&dir)?.permissions();
|
||||
perms.set_mode(0o400);
|
||||
std::fs::set_permissions(&dir, perms)?;
|
||||
|
||||
return Ok(keys);
|
||||
}
|
||||
};
|
||||
@@ -385,7 +433,7 @@ impl NostrRegistry {
|
||||
}
|
||||
|
||||
/// Set the signer for the nostr client and verify the public key
|
||||
pub fn set_signer<T>(&mut self, signer: T, cx: &mut Context<Self>)
|
||||
pub fn set_signer<T>(&mut self, signer: T, owned: bool, cx: &mut Context<Self>)
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
@@ -409,6 +457,8 @@ impl NostrRegistry {
|
||||
Ok(public_key) => {
|
||||
identity.update(cx, |this, cx| {
|
||||
this.set_public_key(public_key);
|
||||
this.reset_relay_state();
|
||||
this.set_owned(owned);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
@@ -466,6 +516,18 @@ impl NostrRegistry {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
log::info!("Received relay list event: {event:?}");
|
||||
|
||||
// Construct a filter to continuously receive relay list events
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::RelayList)
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Subscribe to the relay list events
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, vec![filter], None)
|
||||
.await?;
|
||||
|
||||
return Ok(RelayState::Set);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -556,13 +618,23 @@ impl NostrRegistry {
|
||||
|
||||
// Stream events from the write relays
|
||||
let mut stream = client
|
||||
.stream_events_from(urls, vec![filter], Duration::from_secs(TIMEOUT))
|
||||
.stream_events_from(&urls, vec![filter], Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
log::info!("Received messaging relays event: {event:?}");
|
||||
|
||||
// Construct a filter to continuously receive relay list events
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Subscribe to the relay list events
|
||||
client.subscribe_to(&urls, vec![filter], None).await?;
|
||||
|
||||
return Ok(RelayState::Set);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -592,4 +664,327 @@ impl NostrRegistry {
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get contact list for the current user
|
||||
pub fn get_contact_list(&self, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
|
||||
let client = self.client();
|
||||
let public_key = self.identity().read(cx).public_key();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let contacts = client.database().contacts_public_keys(public_key).await?;
|
||||
let results = contacts.into_iter().collect();
|
||||
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the metadata for the current user
|
||||
pub fn set_metadata(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
|
||||
let client = self.client();
|
||||
let public_key = self.identity().read(cx).public_key();
|
||||
let write_relays = self.write_relays(&public_key, cx);
|
||||
let metadata = metadata.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
let signer = client.signer().await?;
|
||||
|
||||
// Sign the new metadata event
|
||||
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
|
||||
|
||||
// Send event to user's write relayss
|
||||
client.send_event_to(urls, &event).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get local stored identity
|
||||
fn get_identity(&mut self, cx: &mut Context<Self>) {
|
||||
let read_credential = cx.read_credentials(CLIENT_NAME);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match read_credential.await {
|
||||
Ok(Some((_, secret))) => {
|
||||
let secret = SecretKey::from_slice(&secret)?;
|
||||
let keys = Keys::new(secret);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
_ => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_bunker(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Create a new identity
|
||||
fn create_identity(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client();
|
||||
|
||||
// Generate new keys
|
||||
let keys = Keys::generate();
|
||||
|
||||
// Get write credential task
|
||||
let write_credential = cx.write_credentials(
|
||||
CLIENT_NAME,
|
||||
&keys.public_key().to_hex(),
|
||||
&keys.secret_key().to_secret_bytes(),
|
||||
);
|
||||
|
||||
// Update the signer
|
||||
self.set_signer(keys, false, cx);
|
||||
|
||||
// Spawn a task to set metadata and write the credentials
|
||||
cx.background_spawn(async move {
|
||||
let name = petname::petname(2, "-").unwrap_or("Cooper".to_string());
|
||||
let avatar = Url::parse(DEFAULT_AVATAR).unwrap();
|
||||
|
||||
// Construct metadata for the identity
|
||||
let metadata = Metadata::new()
|
||||
.display_name(&name)
|
||||
.name(&name)
|
||||
.picture(avatar);
|
||||
|
||||
// Set metadata for the identity
|
||||
if let Err(e) = client.set_metadata(&metadata).await {
|
||||
log::error!("Failed to set metadata: {}", e);
|
||||
}
|
||||
|
||||
// Write the credentials
|
||||
if let Err(e) = write_credential.await {
|
||||
log::error!("Failed to write credentials: {}", e);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Get local stored bunker connection
|
||||
fn get_bunker(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client();
|
||||
let app_keys = self.app_keys().clone();
|
||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||
|
||||
let task: Task<Result<NostrConnect, Error>> = cx.background_spawn(async move {
|
||||
log::info!("Getting bunker connection");
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier("coop:account")
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let uri = NostrConnectUri::parse(event.content)?;
|
||||
let signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None)?;
|
||||
|
||||
Ok(signer)
|
||||
} else {
|
||||
Err(anyhow!("No account found"))
|
||||
}
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(signer) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, true, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get bunker: {e}");
|
||||
// Create a new identity if no stored bunker exists
|
||||
this.update(cx, |this, cx| {
|
||||
this.create_identity(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Store the bunker connection for the next login
|
||||
pub fn persist_bunker(&mut self, uri: NostrConnectUri, cx: &mut App) {
|
||||
let client = self.client();
|
||||
let rng_keys = Keys::generate();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
// Construct the event for application-specific data
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, uri.to_string())
|
||||
.tag(Tag::identifier("coop:account"))
|
||||
.sign(&rng_keys)
|
||||
.await?;
|
||||
|
||||
// Store the event in the database
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Generate a direct nostr connection initiated by the client
|
||||
pub fn client_connect(&self, relay: Option<RelayUrl>) -> (NostrConnect, NostrConnectUri) {
|
||||
let app_keys = self.app_keys();
|
||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||
|
||||
// Determine the relay will be used for Nostr Connect
|
||||
let relay = match relay {
|
||||
Some(relay) => relay,
|
||||
None => RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap(),
|
||||
};
|
||||
|
||||
// Generate the nostr connect uri
|
||||
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
|
||||
|
||||
// Generate the nostr connect
|
||||
let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
|
||||
|
||||
// Handle the auth request
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
(signer, uri)
|
||||
}
|
||||
|
||||
/// Get the public key of a NIP-05 address
|
||||
pub fn get_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
||||
let client = self.client();
|
||||
let http_client = cx.http_client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let profile = addr.profile(&http_client).await?;
|
||||
let public_key = profile.public_key;
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(Duration::from_secs(3)));
|
||||
|
||||
// Construct the filter for the metadata event
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Subscribe to bootstrap relays
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, vec![filter], Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(public_key)
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform a NIP-50 global search for user profiles based on a given query
|
||||
pub fn search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
|
||||
let client = self.client();
|
||||
let query = query.to_string();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut results: Vec<PublicKey> = Vec::with_capacity(FIND_LIMIT);
|
||||
|
||||
// Construct the filter for the search query
|
||||
let filter = Filter::new()
|
||||
.search(query.to_lowercase())
|
||||
.kind(Kind::Metadata)
|
||||
.limit(FIND_LIMIT);
|
||||
|
||||
// Stream events from the search relays
|
||||
let mut stream = client
|
||||
.stream_events_from(SEARCH_RELAYS, vec![filter], Duration::from_secs(3))
|
||||
.await?;
|
||||
|
||||
// Collect the results
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
if let Ok(event) = res {
|
||||
results.push(event.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
if results.is_empty() {
|
||||
return Err(anyhow!("No results for query {query}"));
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform a WoT (via Vertex) search for a given query.
|
||||
pub fn wot_search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
|
||||
let client = self.client();
|
||||
let query = query.to_string();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
|
||||
// Construct a vertex request event
|
||||
let event = EventBuilder::new(Kind::Custom(5315), "")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("param"), vec!["search", &query]),
|
||||
Tag::custom(TagKind::custom("param"), vec!["limit", "10"]),
|
||||
])
|
||||
.sign(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the event to vertex relays
|
||||
let output = client.send_event_to(WOT_RELAYS, &event).await?;
|
||||
|
||||
// Construct a filter to get the response or error from vertex
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::Custom(6315), Kind::Custom(7000)])
|
||||
.event(output.id().to_owned());
|
||||
|
||||
// Stream events from the search relays
|
||||
let mut stream = client
|
||||
.stream_events_from(WOT_RELAYS, vec![filter], Duration::from_secs(3))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
if let Ok(event) = res {
|
||||
match event.kind {
|
||||
Kind::Custom(6315) => {
|
||||
let content: serde_json::Value = serde_json::from_str(&event.content)?;
|
||||
let pubkeys: Vec<PublicKey> = content
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| item.as_object())
|
||||
.filter_map(|obj| obj.get("pubkey").and_then(|v| v.as_str()))
|
||||
.filter_map(|pubkey_str| PublicKey::parse(pubkey_str).ok())
|
||||
.collect();
|
||||
|
||||
return Ok(pubkeys);
|
||||
}
|
||||
Kind::Custom(7000) => {
|
||||
return Err(anyhow!("Search error"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("No results for query: {query}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoopAuthUrlHandler;
|
||||
|
||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
#[allow(mismatched_lifetime_syntaxes)]
|
||||
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
|
||||
Box::pin(async move {
|
||||
webbrowser::open(auth_url.as_str())?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
60
crates/state/src/nip05.rs
Normal file
60
crates/state/src/nip05.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::io::AsyncReadExt;
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait NostrAddress {
|
||||
/// Get the NIP-05 profile
|
||||
async fn profile(&self, client: &Arc<dyn HttpClient>) -> Result<Nip05Profile, Error>;
|
||||
|
||||
/// Verify the NIP-05 address
|
||||
async fn verify(
|
||||
&self,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
public_key: &PublicKey,
|
||||
) -> Result<bool, Error>;
|
||||
}
|
||||
|
||||
impl NostrAddress for Nip05Address {
|
||||
async fn profile(&self, client: &Arc<dyn HttpClient>) -> Result<Nip05Profile, Error> {
|
||||
let mut body = Vec::new();
|
||||
let mut res = client
|
||||
.get(self.url().as_str(), AsyncBody::default(), false)
|
||||
.await?;
|
||||
|
||||
// Read the response body into a vector
|
||||
res.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
// Parse the JSON response
|
||||
let json: Value = serde_json::from_slice(&body)?;
|
||||
|
||||
let profile = Nip05Profile::from_json(self, &json)?;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
async fn verify(
|
||||
&self,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
public_key: &PublicKey,
|
||||
) -> Result<bool, Error> {
|
||||
let mut body = Vec::new();
|
||||
let mut res = client
|
||||
.get(self.url().as_str(), AsyncBody::default(), false)
|
||||
.await?;
|
||||
|
||||
// Read the response body into a vector
|
||||
res.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
// Parse the JSON response
|
||||
let json: Value = serde_json::from_slice(&body)?;
|
||||
|
||||
// Verify the NIP-05 address
|
||||
let verified = nip05::verify_from_json(public_key, self, &json);
|
||||
|
||||
Ok(verified)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user