many improvements
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ target
|
|||||||
src/camera.rs
|
src/camera.rs
|
||||||
*.patch
|
*.patch
|
||||||
*.txt
|
*.txt
|
||||||
|
/tags
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -651,10 +651,12 @@ dependencies = [
|
|||||||
"poll-promise",
|
"poll-promise",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tracing-wasm",
|
"tracing-wasm",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -24,14 +24,15 @@ serde_derive = "1"
|
|||||||
serde = { version = "1", features = ["derive"] } # You only need this if you want app persistence
|
serde = { version = "1", features = ["derive"] } # You only need this if you want app persistence
|
||||||
tracing = "0.1.37"
|
tracing = "0.1.37"
|
||||||
#wasm-bindgen = "0.2.83"
|
#wasm-bindgen = "0.2.83"
|
||||||
#wasm-bindgen-futures = "0.4"
|
|
||||||
enostr = { path = "enostr" }
|
enostr = { path = "enostr" }
|
||||||
|
serde_json = "1.0.89"
|
||||||
|
|
||||||
|
|
||||||
# web:
|
# web:
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
console_error_panic_hook = "0.1.6"
|
console_error_panic_hook = "0.1.6"
|
||||||
tracing-wasm = "0.2"
|
tracing-wasm = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
|
||||||
# native:
|
# native:
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{Error, Result};
|
use crate::{Error, Pubkey, Result};
|
||||||
use serde_derive::{Deserialize, Serialize};
|
use serde_derive::{Deserialize, Serialize};
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
@@ -6,10 +6,10 @@ use std::hash::{Hash, Hasher};
|
|||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct Event {
|
pub struct Event {
|
||||||
/// 32-bytes sha256 of the the serialized event data
|
/// 32-bytes sha256 of the the serialized event data
|
||||||
pub id: String,
|
pub id: EventId,
|
||||||
/// 32-bytes hex-encoded public key of the event creator
|
/// 32-bytes hex-encoded public key of the event creator
|
||||||
#[serde(rename = "pubkey")]
|
#[serde(rename = "pubkey")]
|
||||||
pub pubkey: String,
|
pub pubkey: Pubkey,
|
||||||
/// unix timestamp in seconds
|
/// unix timestamp in seconds
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
/// integer
|
/// integer
|
||||||
@@ -26,7 +26,7 @@ pub struct Event {
|
|||||||
// Implement Hash trait
|
// Implement Hash trait
|
||||||
impl Hash for Event {
|
impl Hash for Event {
|
||||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
self.id.hash(state);
|
self.id.0.hash(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ impl Event {
|
|||||||
sig: &str,
|
sig: &str,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let event = Event {
|
let event = Event {
|
||||||
id: id.to_string(),
|
id: id.to_string().into(),
|
||||||
pubkey: pubkey.to_string(),
|
pubkey: pubkey.to_string().into(),
|
||||||
created_at,
|
created_at,
|
||||||
kind,
|
kind,
|
||||||
tags,
|
tags,
|
||||||
@@ -79,3 +79,18 @@ impl std::str::FromStr for Event {
|
|||||||
Event::from_json(s)
|
Event::from_json(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone, Hash)]
|
||||||
|
pub struct EventId(String);
|
||||||
|
|
||||||
|
impl From<String> for EventId {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
EventId(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<EventId> for String {
|
||||||
|
fn from(evid: EventId) -> Self {
|
||||||
|
evid.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
|
use crate::{EventId, Pubkey};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
|
||||||
pub struct Filter {
|
pub struct Filter {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
ids: Option<Vec<String>>,
|
ids: Option<Vec<EventId>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
authors: Option<Vec<String>>,
|
authors: Option<Vec<Pubkey>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
kinds: Option<Vec<u64>>,
|
kinds: Option<Vec<u64>>,
|
||||||
#[serde(rename = "#e")]
|
#[serde(rename = "#e")]
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
events: Option<Vec<String>>,
|
events: Option<Vec<EventId>>,
|
||||||
#[serde(rename = "#p")]
|
#[serde(rename = "#p")]
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pubkeys: Option<Vec<String>>,
|
pubkeys: Option<Vec<Pubkey>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
since: Option<u64>, // unix timestamp seconds
|
since: Option<u64>, // unix timestamp seconds
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -36,12 +37,12 @@ impl Filter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ids(mut self, ids: Vec<String>) -> Self {
|
pub fn ids(mut self, ids: Vec<EventId>) -> Self {
|
||||||
self.ids = Some(ids);
|
self.ids = Some(ids);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn authors(mut self, authors: Vec<String>) -> Self {
|
pub fn authors(mut self, authors: Vec<Pubkey>) -> Self {
|
||||||
self.authors = Some(authors);
|
self.authors = Some(authors);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -51,12 +52,12 @@ impl Filter {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn events(mut self, events: Vec<String>) -> Self {
|
pub fn events(mut self, events: Vec<EventId>) -> Self {
|
||||||
self.events = Some(events);
|
self.events = Some(events);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pubkeys(mut self, pubkeys: Vec<String>) -> Self {
|
pub fn pubkeys(mut self, pubkeys: Vec<Pubkey>) -> Self {
|
||||||
self.pubkeys = Some(pubkeys);
|
self.pubkeys = Some(pubkeys);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ mod client;
|
|||||||
mod error;
|
mod error;
|
||||||
mod event;
|
mod event;
|
||||||
mod filter;
|
mod filter;
|
||||||
|
mod profile;
|
||||||
|
mod pubkey;
|
||||||
mod relay;
|
mod relay;
|
||||||
|
|
||||||
pub use client::ClientMessage;
|
pub use client::ClientMessage;
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use event::Event;
|
pub use event::{Event, EventId};
|
||||||
|
pub use ewebsock;
|
||||||
pub use filter::Filter;
|
pub use filter::Filter;
|
||||||
|
pub use profile::Profile;
|
||||||
|
pub use pubkey::Pubkey;
|
||||||
pub use relay::message::{RelayEvent, RelayMessage};
|
pub use relay::message::{RelayEvent, RelayMessage};
|
||||||
pub use relay::pool::{PoolEvent, RelayPool};
|
pub use relay::pool::{PoolEvent, RelayPool};
|
||||||
pub use relay::Relay;
|
pub use relay::Relay;
|
||||||
|
|||||||
38
enostr/src/profile.rs
Normal file
38
enostr/src/profile.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Profile(Value);
|
||||||
|
|
||||||
|
impl Profile {
|
||||||
|
pub fn new(value: Value) -> Profile {
|
||||||
|
Profile(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> Option<&str> {
|
||||||
|
return self.0["name"].as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_name(&self) -> Option<&str> {
|
||||||
|
return self.0["display_name"].as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lud06(&self) -> Option<&str> {
|
||||||
|
return self.0["lud06"].as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lud16(&self) -> Option<&str> {
|
||||||
|
return self.0["lud16"].as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn about(&self) -> Option<&str> {
|
||||||
|
return self.0["about"].as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn picture(&self) -> Option<&str> {
|
||||||
|
return self.0["picture"].as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn website(&self) -> Option<&str> {
|
||||||
|
return self.0["website"].as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
enostr/src/pubkey.rs
Normal file
22
enostr/src/pubkey.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use serde_derive::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone, Hash)]
|
||||||
|
pub struct Pubkey(String);
|
||||||
|
|
||||||
|
impl AsRef<str> for Pubkey {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for Pubkey {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Pubkey(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Pubkey> for String {
|
||||||
|
fn from(pk: Pubkey) -> Self {
|
||||||
|
pk.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,9 +58,12 @@ impl Relay {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscribe(&mut self, subid: String, filters: Vec<Filter>) {
|
pub fn send(&mut self, msg: &ClientMessage) {
|
||||||
let cmd = ClientMessage::req(subid, filters);
|
let txt = WsMessage::Text(msg.to_json());
|
||||||
let txt = WsMessage::Text(cmd.to_json());
|
|
||||||
self.sender.send(txt);
|
self.sender.send(txt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(&mut self, subid: String, filters: Vec<Filter>) {
|
||||||
|
self.send(&ClientMessage::req(subid, filters));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use crate::relay::message::RelayEvent;
|
use crate::relay::message::RelayEvent;
|
||||||
use crate::relay::Relay;
|
use crate::relay::Relay;
|
||||||
use crate::Result;
|
use crate::{ClientMessage, Result};
|
||||||
use tracing::error;
|
use ewebsock::WsMessage;
|
||||||
|
use tracing::{debug, error};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PoolEvent<'a> {
|
pub struct PoolEvent<'a> {
|
||||||
@@ -34,6 +35,21 @@ impl RelayPool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn send(&mut self, cmd: &ClientMessage) {
|
||||||
|
for relay in &mut self.relays {
|
||||||
|
relay.send(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_to(&mut self, cmd: &ClientMessage, relay_url: &str) {
|
||||||
|
for relay in &mut self.relays {
|
||||||
|
if relay.url == relay_url {
|
||||||
|
relay.send(cmd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Adds a websocket url to the RelayPool.
|
// Adds a websocket url to the RelayPool.
|
||||||
pub fn add_url(
|
pub fn add_url(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -47,11 +63,23 @@ impl RelayPool {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn try_recv(&self) -> Option<PoolEvent<'_>> {
|
/// Attempts to receive a pool event from a list of relays. The function searches each relay in the list in order, attempting to receive a message from each. If a message is received, return it. If no message is received from any relays, None is returned.
|
||||||
for relay in &self.relays {
|
pub fn try_recv(&mut self) -> Option<PoolEvent<'_>> {
|
||||||
|
for relay in &mut self.relays {
|
||||||
if let Some(msg) = relay.receiver.try_recv() {
|
if let Some(msg) = relay.receiver.try_recv() {
|
||||||
match msg.try_into() {
|
match msg.try_into() {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
|
// let's just handle pongs here.
|
||||||
|
// We only need to do this natively.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
match event {
|
||||||
|
RelayEvent::Other(WsMessage::Ping(ref bs)) => {
|
||||||
|
debug!("pong {}", &relay.url);
|
||||||
|
relay.sender.send(WsMessage::Pong(bs.to_owned()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
return Some(PoolEvent {
|
return Some(PoolEvent {
|
||||||
event,
|
event,
|
||||||
relay: &relay.url,
|
relay: &relay.url,
|
||||||
@@ -68,6 +96,4 @@ impl RelayPool {
|
|||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn connect() {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
259
src/app.rs
259
src/app.rs
@@ -1,10 +1,12 @@
|
|||||||
use egui_extras::RetainedImage;
|
use egui_extras::RetainedImage;
|
||||||
|
|
||||||
|
use crate::contacts::Contacts;
|
||||||
|
use crate::Result;
|
||||||
use egui::Context;
|
use egui::Context;
|
||||||
use enostr::{Filter, RelayEvent, RelayMessage};
|
use enostr::{ClientMessage, EventId, Filter, Profile, Pubkey, RelayEvent, RelayMessage};
|
||||||
use poll_promise::Promise;
|
use poll_promise::Promise;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::hash::Hash;
|
use std::hash::{Hash, Hasher};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use enostr::{Event, RelayPool};
|
use enostr::{Event, RelayPool};
|
||||||
@@ -15,7 +17,15 @@ enum UrlKey<'a> {
|
|||||||
Failed(&'a str),
|
Failed(&'a str),
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageCache<'a> = HashMap<UrlKey<'a>, Promise<ehttp::Result<RetainedImage>>>;
|
impl UrlKey<'_> {
|
||||||
|
fn to_u64(&self) -> u64 {
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
self.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageCache = HashMap<u64, Promise<Result<RetainedImage>>>;
|
||||||
|
|
||||||
#[derive(Eq, PartialEq, Clone)]
|
#[derive(Eq, PartialEq, Clone)]
|
||||||
pub enum DamusState {
|
pub enum DamusState {
|
||||||
@@ -24,35 +34,28 @@ pub enum DamusState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
|
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
|
||||||
pub struct Damus<'a> {
|
pub struct Damus {
|
||||||
// Example stuff:
|
|
||||||
label: String,
|
|
||||||
state: DamusState,
|
state: DamusState,
|
||||||
composing: bool,
|
contacts: Contacts,
|
||||||
n_panels: u32,
|
n_panels: u32,
|
||||||
|
|
||||||
pool: RelayPool,
|
pool: RelayPool,
|
||||||
|
|
||||||
all_events: HashMap<String, Event>,
|
all_events: HashMap<EventId, Event>,
|
||||||
events: Vec<String>,
|
events: Vec<EventId>,
|
||||||
|
|
||||||
img_cache: ImageCache<'a>,
|
img_cache: ImageCache,
|
||||||
|
|
||||||
value: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Damus<'_> {
|
impl Default for Damus {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
// Example stuff:
|
|
||||||
label: "Hello World!".to_owned(),
|
|
||||||
state: DamusState::Initializing,
|
state: DamusState::Initializing,
|
||||||
composing: false,
|
contacts: Contacts::new(),
|
||||||
all_events: HashMap::new(),
|
all_events: HashMap::new(),
|
||||||
pool: RelayPool::default(),
|
pool: RelayPool::default(),
|
||||||
events: vec![],
|
events: vec![],
|
||||||
img_cache: HashMap::new(),
|
img_cache: HashMap::new(),
|
||||||
value: 2.7,
|
|
||||||
n_panels: 1,
|
n_panels: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,14 +68,17 @@ pub fn is_mobile(ctx: &egui::Context) -> bool {
|
|||||||
|
|
||||||
fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) {
|
fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) {
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
let wakeup = move || ctx.request_repaint();
|
let wakeup = move || {
|
||||||
|
debug!("Woke up");
|
||||||
|
ctx.request_repaint();
|
||||||
|
};
|
||||||
if let Err(e) = pool.add_url("wss://relay.damus.io".to_string(), wakeup) {
|
if let Err(e) = pool.add_url("wss://relay.damus.io".to_string(), wakeup) {
|
||||||
error!("{:?}", e)
|
error!("{:?}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_initial_filters(pool: &mut RelayPool, relay_url: &str) {
|
fn send_initial_filters(pool: &mut RelayPool, relay_url: &str) {
|
||||||
let filter = Filter::new().limit(20).kinds(vec![1, 42]);
|
let filter = Filter::new().limit(100).kinds(vec![1, 42]);
|
||||||
let subid = "initial";
|
let subid = "initial";
|
||||||
for relay in &mut pool.relays {
|
for relay in &mut pool.relays {
|
||||||
if relay.url == relay_url {
|
if relay.url == relay_url {
|
||||||
@@ -92,8 +98,9 @@ fn try_process_event(damus: &mut Damus) {
|
|||||||
|
|
||||||
match ev.event {
|
match ev.event {
|
||||||
RelayEvent::Opened => send_initial_filters(&mut damus.pool, &relay),
|
RelayEvent::Opened => send_initial_filters(&mut damus.pool, &relay),
|
||||||
RelayEvent::Closed => warn!("{} connection closed", &relay), /* TODO: handle reconnects */
|
// TODO: handle reconnects
|
||||||
RelayEvent::Other(msg) => debug!("Other ws message: {:?}", msg),
|
RelayEvent::Closed => warn!("{} connection closed", &relay),
|
||||||
|
RelayEvent::Other(msg) => debug!("other event {:?}", &msg),
|
||||||
RelayEvent::Message(msg) => process_message(damus, &relay, msg),
|
RelayEvent::Message(msg) => process_message(damus, &relay, msg),
|
||||||
}
|
}
|
||||||
//info!("recv {:?}", ev)
|
//info!("recv {:?}", ev)
|
||||||
@@ -109,22 +116,90 @@ fn update_damus(damus: &mut Damus, ctx: &egui::Context) {
|
|||||||
try_process_event(damus);
|
try_process_event(damus);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_event(damus: &mut Damus, subid: &str, event: Event) {
|
fn process_metadata_event(damus: &mut Damus, ev: &Event) {
|
||||||
|
if let Some(prev_id) = damus.contacts.events.get(&ev.pubkey) {
|
||||||
|
if let Some(prev_ev) = damus.all_events.get(prev_id) {
|
||||||
|
// This profile event is older, ignore it
|
||||||
|
if prev_ev.created_at >= ev.created_at {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile: core::result::Result<serde_json::Value, serde_json::Error> =
|
||||||
|
serde_json::from_str(&ev.content);
|
||||||
|
|
||||||
|
match profile {
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Invalid profile data '{}': {:?}", &ev.content, &e);
|
||||||
|
}
|
||||||
|
Ok(v) if !v.is_object() => {
|
||||||
|
debug!("Invalid profile data: '{}'", &ev.content);
|
||||||
|
}
|
||||||
|
Ok(profile) => {
|
||||||
|
damus
|
||||||
|
.contacts
|
||||||
|
.events
|
||||||
|
.insert(ev.pubkey.clone(), ev.id.clone());
|
||||||
|
|
||||||
|
damus
|
||||||
|
.contacts
|
||||||
|
.profiles
|
||||||
|
.insert(ev.pubkey.clone(), Profile::new(profile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_event(damus: &mut Damus, _subid: &str, event: Event) {
|
||||||
if damus.all_events.get(&event.id).is_some() {
|
if damus.all_events.get(&event.id).is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if event.kind == 0 {
|
||||||
|
process_metadata_event(damus, &event);
|
||||||
|
}
|
||||||
|
|
||||||
let cloned_id = event.id.clone();
|
let cloned_id = event.id.clone();
|
||||||
damus.all_events.insert(cloned_id.clone(), event);
|
damus.all_events.insert(cloned_id.clone(), event);
|
||||||
damus.events.push(cloned_id);
|
damus.events.push(cloned_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_unknown_author_ids(damus: &Damus) -> Vec<Pubkey> {
|
||||||
|
let mut authors: HashSet<Pubkey> = HashSet::new();
|
||||||
|
|
||||||
|
for (_evid, ev) in damus.all_events.iter() {
|
||||||
|
if !damus.contacts.profiles.contains_key(&ev.pubkey) {
|
||||||
|
authors.insert(ev.pubkey.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authors.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) {
|
||||||
|
if subid == "initial" {
|
||||||
|
let authors = get_unknown_author_ids(damus);
|
||||||
|
let n_authors = authors.len();
|
||||||
|
let filter = Filter::new().authors(authors).kinds(vec![0]);
|
||||||
|
info!(
|
||||||
|
"Getting {} unknown author profiles from {}",
|
||||||
|
n_authors, relay_url
|
||||||
|
);
|
||||||
|
let msg = ClientMessage::req("profiles".to_string(), vec![filter]);
|
||||||
|
damus.pool.send_to(&msg, relay_url);
|
||||||
|
} else if subid == "profiles" {
|
||||||
|
info!("Got profiles from {}", relay_url);
|
||||||
|
let msg = ClientMessage::close("profiles".to_string());
|
||||||
|
damus.pool.send_to(&msg, relay_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn process_message(damus: &mut Damus, relay: &str, msg: RelayMessage) {
|
fn process_message(damus: &mut Damus, relay: &str, msg: RelayMessage) {
|
||||||
match msg {
|
match msg {
|
||||||
RelayMessage::Event(subid, ev) => process_event(damus, &subid, ev),
|
RelayMessage::Event(subid, ev) => process_event(damus, &subid, ev),
|
||||||
RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg),
|
RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg),
|
||||||
RelayMessage::OK(cr) => info!("OK {:?}", cr),
|
RelayMessage::OK(cr) => info!("OK {:?}", cr),
|
||||||
RelayMessage::Eose(sid) => info!("EOSE {}", sid),
|
RelayMessage::Eose(sid) => handle_eose(damus, &sid, relay),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +211,7 @@ fn render_damus(damus: &mut Damus, ctx: &Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Damus<'_> {
|
impl Damus {
|
||||||
pub fn add_test_events(&mut self) {
|
pub fn add_test_events(&mut self) {
|
||||||
add_test_events(self);
|
add_test_events(self);
|
||||||
}
|
}
|
||||||
@@ -157,58 +232,63 @@ impl Damus<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn parse_response(response: ehttp::Response) -> Result<RetainedImage, String> {
|
fn parse_response(response: ehttp::Response) -> Result<RetainedImage> {
|
||||||
let content_type = response.content_type().unwrap_or_default();
|
let content_type = response.content_type().unwrap_or_default();
|
||||||
|
|
||||||
if content_type.starts_with("image/svg") {
|
if content_type.starts_with("image/svg") {
|
||||||
RetainedImage::from_svg_bytes(&response.url, &response.bytes)
|
Ok(RetainedImage::from_svg_bytes(
|
||||||
|
&response.url,
|
||||||
|
&response.bytes,
|
||||||
|
)?)
|
||||||
} else if content_type.starts_with("image/") {
|
} else if content_type.starts_with("image/") {
|
||||||
RetainedImage::from_image_bytes(&response.url, &response.bytes)
|
Ok(RetainedImage::from_image_bytes(
|
||||||
|
&response.url,
|
||||||
|
&response.bytes,
|
||||||
|
)?)
|
||||||
} else {
|
} else {
|
||||||
Err(format!(
|
Err(format!("Expected image, found content-type {:?}", content_type).into())
|
||||||
"Expected image, found content-type {:?}",
|
|
||||||
content_type
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_img(ctx: &egui::Context, url: &str) -> Promise<ehttp::Result<RetainedImage>> {
|
fn fetch_img(ctx: &egui::Context, url: &str) -> Promise<Result<RetainedImage>> {
|
||||||
|
// TODO: fetch image from local cache
|
||||||
|
fetch_img_from_net(ctx, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_img_from_net(ctx: &egui::Context, url: &str) -> Promise<Result<RetainedImage>> {
|
||||||
let (sender, promise) = Promise::new();
|
let (sender, promise) = Promise::new();
|
||||||
let request = ehttp::Request::get(url);
|
let request = ehttp::Request::get(url);
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
ehttp::fetch(request, move |response| {
|
ehttp::fetch(request, move |response| {
|
||||||
let image = response.and_then(parse_response);
|
let image = response.map_err(Into::into).and_then(parse_response);
|
||||||
sender.send(image); // send the results back to the UI thread.
|
sender.send(image); // send the results back to the UI thread.
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
});
|
});
|
||||||
promise
|
promise
|
||||||
}
|
}
|
||||||
|
|
||||||
fn robohash(hash: &str) -> String {
|
fn render_pfp(ui: &mut egui::Ui, img_cache: &mut ImageCache, url: &str) {
|
||||||
return format!("https://robohash.org/{}", hash);
|
let urlkey = UrlKey::Orig(url).to_u64();
|
||||||
}
|
|
||||||
|
|
||||||
fn render_pfp<'a>(ui: &mut egui::Ui, img_cache: &mut ImageCache<'a>, pk: &str, url: &'a str) {
|
|
||||||
let urlkey = UrlKey::Orig(url);
|
|
||||||
let m_cached_promise = img_cache.get(&urlkey);
|
let m_cached_promise = img_cache.get(&urlkey);
|
||||||
if m_cached_promise.is_none() {
|
if m_cached_promise.is_none() {
|
||||||
debug!("urlkey: {:?}", &urlkey);
|
debug!("urlkey: {:?}", &urlkey);
|
||||||
img_cache.insert(UrlKey::Orig(url), fetch_img(ui.ctx(), &url));
|
img_cache.insert(urlkey, fetch_img(ui.ctx(), url));
|
||||||
}
|
}
|
||||||
|
|
||||||
let pfp_size = 50.0;
|
let pfp_size = 50.0;
|
||||||
|
let no_pfp_url = "https://damus.io/img/no-profile.svg";
|
||||||
|
|
||||||
match img_cache[&urlkey].ready() {
|
match img_cache[&urlkey].ready() {
|
||||||
None => {
|
None => {
|
||||||
ui.spinner(); // still loading
|
ui.spinner(); // still loading
|
||||||
}
|
}
|
||||||
Some(Err(err)) => {
|
Some(Err(_err)) => {
|
||||||
error!("Initial image load failed: {}", err);
|
let failed_key = UrlKey::Failed(url).to_u64();
|
||||||
let failed_key = UrlKey::Failed(&url);
|
|
||||||
let m_failed_promise = img_cache.get_mut(&failed_key);
|
let m_failed_promise = img_cache.get_mut(&failed_key);
|
||||||
if m_failed_promise.is_none() {
|
if m_failed_promise.is_none() {
|
||||||
debug!("failed key: {:?}", &failed_key);
|
debug!("failed key: {:?}", &failed_key);
|
||||||
img_cache.insert(UrlKey::Failed(url), fetch_img(ui.ctx(), &robohash(pk)));
|
let no_pfp = fetch_img(ui.ctx(), no_pfp_url);
|
||||||
|
img_cache.insert(failed_key, no_pfp);
|
||||||
}
|
}
|
||||||
|
|
||||||
match img_cache[&failed_key].ready() {
|
match img_cache[&failed_key].ready() {
|
||||||
@@ -216,7 +296,7 @@ fn render_pfp<'a>(ui: &mut egui::Ui, img_cache: &mut ImageCache<'a>, pk: &str, u
|
|||||||
ui.spinner(); // still loading
|
ui.spinner(); // still loading
|
||||||
}
|
}
|
||||||
Some(Err(e)) => {
|
Some(Err(e)) => {
|
||||||
error!("Image load error: {}", e);
|
error!("Image load error: {:?}", e);
|
||||||
ui.label("❌");
|
ui.label("❌");
|
||||||
}
|
}
|
||||||
Some(Ok(img)) => {
|
Some(Ok(img)) => {
|
||||||
@@ -243,45 +323,47 @@ fn render_username(ui: &mut egui::Ui, pk: &str) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_event(ui: &mut egui::Ui, img_cache: &mut ImageCache<'_>, ev: &Event) {
|
fn render_events(ui: &mut egui::Ui, damus: &mut Damus) {
|
||||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
|
for evid in &damus.events {
|
||||||
let damus_pic = "https://damus.io/img/damus.svg".into();
|
if !damus.all_events.contains_key(evid) {
|
||||||
//let damus_pic = "https://192.168.87.26/img/damus.svg".into();
|
return;
|
||||||
let jb55_pic = "https://cdn.jb55.com/img/red-me.jpg".into();
|
}
|
||||||
//let jb55_pic = "http://192.168.87.26/img/red-me.jpg".into();
|
|
||||||
let pic = if ev.pubkey == "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
|
|
||||||
{
|
|
||||||
jb55_pic
|
|
||||||
} else {
|
|
||||||
damus_pic
|
|
||||||
};
|
|
||||||
|
|
||||||
render_pfp(ui, img_cache, &ev.pubkey, pic);
|
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
|
||||||
|
let ev = damus.all_events.get(evid).unwrap();
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
|
let m_pic = damus
|
||||||
render_username(ui, &ev.pubkey);
|
.contacts
|
||||||
|
.profiles
|
||||||
|
.get(&ev.pubkey)
|
||||||
|
.and_then(|p| p.picture());
|
||||||
|
|
||||||
ui.label(&ev.content);
|
if let Some(pic) = m_pic {
|
||||||
})
|
render_pfp(ui, &mut damus.img_cache, pic);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
|
||||||
|
render_username(ui, ev.pubkey.as_ref());
|
||||||
|
|
||||||
|
ui.label(&ev.content);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn timeline_view(ui: &mut egui::Ui, app: &mut Damus<'_>) {
|
fn timeline_view(ui: &mut egui::Ui, app: &mut Damus) {
|
||||||
ui.heading("Timeline");
|
ui.heading("Timeline");
|
||||||
|
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false; 2])
|
.auto_shrink([false; 2])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
for evid in &app.events {
|
render_events(ui, app);
|
||||||
if let Some(ev) = app.all_events.get(evid) {
|
|
||||||
render_event(ui, &mut app.img_cache, ev);
|
|
||||||
ui.separator();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_panel(ctx: &egui::Context, app: &mut Damus<'_>) {
|
fn render_panel(ctx: &egui::Context, app: &mut Damus) {
|
||||||
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.visuals_mut().button_frame = false;
|
ui.visuals_mut().button_frame = false;
|
||||||
@@ -295,27 +377,26 @@ fn render_panel(ctx: &egui::Context, app: &mut Damus<'_>) {
|
|||||||
app.n_panels += 1;
|
app.n_panels += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.n_panels != 1 {
|
if app.n_panels != 1
|
||||||
if ui
|
&& ui
|
||||||
.add(egui::Button::new("-").frame(false))
|
.add(egui::Button::new("-").frame(false))
|
||||||
.on_hover_text("Remove Timeline")
|
.on_hover_text("Remove Timeline")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
app.n_panels -= 1;
|
app.n_panels -= 1;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus<'_>) {
|
fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) {
|
||||||
let panel_width = ctx.input().screen_rect.width();
|
let panel_width = ctx.input().screen_rect.width();
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
timeline_panel(ui, app, panel_width, 0);
|
timeline_panel(ui, app, panel_width, 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus<'_>) {
|
fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) {
|
||||||
render_panel(ctx, app);
|
render_panel(ctx, app);
|
||||||
|
|
||||||
let screen_size = ctx.input().screen_rect.width();
|
let screen_size = ctx.input().screen_rect.width();
|
||||||
@@ -348,7 +429,7 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus<'_>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn timeline_panel(ui: &mut egui::Ui, app: &mut Damus<'_>, panel_width: f32, ind: u32) {
|
fn timeline_panel(ui: &mut egui::Ui, app: &mut Damus, panel_width: f32, ind: u32) {
|
||||||
egui::SidePanel::left(format!("l{}", ind))
|
egui::SidePanel::left(format!("l{}", ind))
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
.max_width(panel_width)
|
.max_width(panel_width)
|
||||||
@@ -358,15 +439,15 @@ fn timeline_panel(ui: &mut egui::Ui, app: &mut Damus<'_>, panel_width: f32, ind:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_test_events(damus: &mut Damus<'_>) {
|
fn add_test_events(damus: &mut Damus) {
|
||||||
// Examples of how to create different panels and windows.
|
// Examples of how to create different panels and windows.
|
||||||
// Pick whichever suits you.
|
// Pick whichever suits you.
|
||||||
// Tip: a good default choice is to just keep the `CentralPanel`.
|
// Tip: a good default choice is to just keep the `CentralPanel`.
|
||||||
// For inspiration and more examples, go to https://emilk.github.io/egui
|
// For inspiration and more examples, go to https://emilk.github.io/egui
|
||||||
|
|
||||||
let test_event = Event {
|
let test_event = Event {
|
||||||
id: "6938e3cd841f3111dbdbd909f87fd52c3d1f1e4a07fd121d1243196e532811cb".to_string(),
|
id: "6938e3cd841f3111dbdbd909f87fd52c3d1f1e4a07fd121d1243196e532811cb".to_string().into(),
|
||||||
pubkey: "f0a6ff7f70b872de6d82c8daec692a433fd23b6a49f25923c6f034df715cdeec".to_string(),
|
pubkey: "f0a6ff7f70b872de6d82c8daec692a433fd23b6a49f25923c6f034df715cdeec".to_string().into(),
|
||||||
created_at: 1667781968,
|
created_at: 1667781968,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
@@ -375,8 +456,8 @@ fn add_test_events(damus: &mut Damus<'_>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let test_event2 = Event {
|
let test_event2 = Event {
|
||||||
id: "6938e3cd841f3111dbdbd909f87fd52c3d1f1e4a07fd121d1243196e532811cb".to_string(),
|
id: "6938e3cd841f3111dbdbd909f87fd52c3d1f1e4a07fd121d1243196e532811cb".to_string().into(),
|
||||||
pubkey: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245".to_string(),
|
pubkey: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245".to_string().into(),
|
||||||
created_at: 1667781968,
|
created_at: 1667781968,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
@@ -391,9 +472,7 @@ fn add_test_events(damus: &mut Damus<'_>) {
|
|||||||
.all_events
|
.all_events
|
||||||
.insert(test_event2.id.clone(), test_event2.clone());
|
.insert(test_event2.id.clone(), test_event2.clone());
|
||||||
|
|
||||||
if damus.events.len() == 0 {
|
if damus.events.is_empty() {
|
||||||
damus.events.push(test_event.id.clone());
|
|
||||||
damus.events.push(test_event2.id.clone());
|
|
||||||
damus.events.push(test_event.id.clone());
|
damus.events.push(test_event.id.clone());
|
||||||
damus.events.push(test_event2.id.clone());
|
damus.events.push(test_event2.id.clone());
|
||||||
damus.events.push(test_event.id.clone());
|
damus.events.push(test_event.id.clone());
|
||||||
@@ -401,10 +480,12 @@ fn add_test_events(damus: &mut Damus<'_>) {
|
|||||||
damus.events.push(test_event.id.clone());
|
damus.events.push(test_event.id.clone());
|
||||||
damus.events.push(test_event2.id.clone());
|
damus.events.push(test_event2.id.clone());
|
||||||
damus.events.push(test_event.id.clone());
|
damus.events.push(test_event.id.clone());
|
||||||
|
damus.events.push(test_event2.id);
|
||||||
|
damus.events.push(test_event.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for Damus<'_> {
|
impl eframe::App for Damus {
|
||||||
/// Called by the frame work to save state before shutdown.
|
/// Called by the frame work to save state before shutdown.
|
||||||
fn save(&mut self, _storage: &mut dyn eframe::Storage) {
|
fn save(&mut self, _storage: &mut dyn eframe::Storage) {
|
||||||
//eframe::set_value(storage, eframe::APP_KEY, self);
|
//eframe::set_value(storage, eframe::APP_KEY, self);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#![warn(clippy::all, rust_2018_idioms)]
|
#![warn(clippy::all, rust_2018_idioms)]
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
|
||||||
use damus::Damus;
|
use damus::Damus;
|
||||||
use eframe;
|
|
||||||
|
|
||||||
// Entry point for wasm
|
// Entry point for wasm
|
||||||
//#[cfg(target_arch = "wasm32")]
|
//#[cfg(target_arch = "wasm32")]
|
||||||
|
|||||||
16
src/contacts.rs
Normal file
16
src/contacts.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use enostr::{EventId, Profile, Pubkey};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct Contacts {
|
||||||
|
pub events: HashMap<Pubkey, EventId>,
|
||||||
|
pub profiles: HashMap<Pubkey, Profile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Contacts {
|
||||||
|
pub fn new() -> Contacts {
|
||||||
|
Contacts {
|
||||||
|
events: HashMap::new(),
|
||||||
|
profiles: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/error.rs
11
src/error.rs
@@ -1,8 +1,13 @@
|
|||||||
use enostr;
|
#[derive(Eq, PartialEq, Debug)]
|
||||||
|
|
||||||
#[derive(Eq, PartialEq)]
|
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
Nostr(enostr::Error),
|
Nostr(enostr::Error),
|
||||||
|
Generic(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for Error {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Error::Generic(s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<enostr::Error> for Error {
|
impl From<enostr::Error> for Error {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod app;
|
mod app;
|
||||||
//mod camera;
|
//mod camera;
|
||||||
|
mod contacts;
|
||||||
mod error;
|
mod error;
|
||||||
|
|
||||||
pub use app::Damus;
|
pub use app::Damus;
|
||||||
|
|||||||
Reference in New Issue
Block a user