Introducing Damus Notedeck: a nostr browser
This splits notedeck into: - notedeck - notedeck_chrome - notedeck_columns The `notedeck` crate is the library that `notedeck_chrome` and `notedeck_columns`, use. It contains common functionality related to notedeck apps such as the NoteCache, ImageCache, etc. The `notedeck_chrome` crate is the binary and ui chrome. It is responsible for managing themes, user accounts, signing, data paths, nostrdb, image caches etc. It will eventually have its own ui which has yet to be determined. For now it just manages the browser data, which is passed to apps via a new struct called `AppContext`. `notedeck_columns` is our columns app, with less responsibility now that more things are handled by `notedeck_chrome` There is still much work left to do before this is a proper browser: - process isolation - sandboxing - etc This is the beginning of a new era! We're just getting started. Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
@@ -1,71 +1,33 @@
|
||||
[package]
|
||||
name = "notedeck"
|
||||
version = "0.2.0"
|
||||
authors = ["William Casarin <jb55@jb55.com>", "kernelkind <kernelkind@gmail.com>"]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
default-run = "notedeck"
|
||||
#rust-version = "1.60"
|
||||
license = "GPLv3"
|
||||
description = "A nostr browser"
|
||||
description = "The APIs and data structures used by notedeck apps"
|
||||
|
||||
[dependencies]
|
||||
notedeck_columns = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
tracing-appender = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
eframe = { workspace = true }
|
||||
nostrdb = { workspace = true }
|
||||
url = { workspace = true }
|
||||
strum = { workspace = true }
|
||||
strum_macros = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
enostr = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
image = { workspace = true }
|
||||
base32 = { workspace = true }
|
||||
poll-promise = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
puffin = { workspace = true, optional = true }
|
||||
|
||||
[[bin]]
|
||||
name = "notedeck"
|
||||
path = "src/notedeck.rs"
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "ui_preview"
|
||||
path = "src/preview.rs"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
profiling = ["notedeck_columns/puffin"]
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.11.1"
|
||||
android-activity = { version = "0.4", features = [ "native-activity" ] }
|
||||
winit = { version = "0.30.5", features = [ "android-native-activity" ] }
|
||||
#winit = { git="https://github.com/rust-windowing/winit.git", rev = "2a58b785fed2a3746f7c7eebce95bce67ddfd27c", features = ["android-native-activity"] }
|
||||
|
||||
[package.metadata.bundle]
|
||||
identifier = "com.damus.notedeck"
|
||||
icon = ["assets/app_icon.icns"]
|
||||
|
||||
[package.metadata.android]
|
||||
package = "com.damus.app"
|
||||
apk_name = "damus"
|
||||
#assets = "assets"
|
||||
|
||||
[[package.metadata.android.uses_feature]]
|
||||
name = "android.hardware.vulkan.level"
|
||||
required = true
|
||||
version = 1
|
||||
|
||||
[[package.metadata.android.uses_permission]]
|
||||
name = "android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
max_sdk_version = 18
|
||||
|
||||
[[package.metadata.android.uses_permission]]
|
||||
name = "android.permission.READ_EXTERNAL_STORAGE"
|
||||
max_sdk_version = 18
|
||||
|
||||
[package.metadata.android.signing.release]
|
||||
path = "damus.keystore"
|
||||
keystore_password = "damuskeystore"
|
||||
|
||||
[[package.metadata.android.uses_permission]]
|
||||
name = "android.permission.INTERNET"
|
||||
|
||||
[package.metadata.android.application]
|
||||
label = "Damus"
|
||||
|
||||
[package.metadata.generate-rpm]
|
||||
assets = [
|
||||
{ source = "target/release/notedeck", dest = "/usr/bin/notedeck", mode = "755" },
|
||||
]
|
||||
profiling = ["puffin"]
|
||||
|
||||
569
crates/notedeck/src/accounts.rs
Normal file
569
crates/notedeck/src/accounts.rs
Normal file
@@ -0,0 +1,569 @@
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use crate::{
|
||||
KeyStorageResponse, KeyStorageType, Muted, SingleUnkIdAction, UnknownIds, UserAccount,
|
||||
};
|
||||
use enostr::{ClientMessage, FilledKeypair, Keypair, RelayPool};
|
||||
use nostrdb::{Filter, Ndb, Note, NoteKey, Subscription, Transaction};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
// TODO: remove this
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AccountsAction {
|
||||
Switch(usize),
|
||||
Remove(usize),
|
||||
}
|
||||
|
||||
pub struct AccountRelayData {
|
||||
filter: Filter,
|
||||
subid: String,
|
||||
sub: Option<Subscription>,
|
||||
local: BTreeSet<String>, // used locally but not advertised
|
||||
advertised: BTreeSet<String>, // advertised via NIP-65
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ContainsAccount {
|
||||
pub has_nsec: bool,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
#[must_use = "You must call process_login_action on this to handle unknown ids"]
|
||||
pub struct AddAccountAction {
|
||||
pub accounts_action: Option<AccountsAction>,
|
||||
pub unk_id_action: SingleUnkIdAction,
|
||||
}
|
||||
|
||||
impl AccountRelayData {
|
||||
pub fn new(ndb: &Ndb, pool: &mut RelayPool, pubkey: &[u8; 32]) -> Self {
|
||||
// Construct a filter for the user's NIP-65 relay list
|
||||
let filter = Filter::new()
|
||||
.authors([pubkey])
|
||||
.kinds([10002])
|
||||
.limit(1)
|
||||
.build();
|
||||
|
||||
// Local ndb subscription
|
||||
let ndbsub = ndb
|
||||
.subscribe(&[filter.clone()])
|
||||
.expect("ndb relay list subscription");
|
||||
|
||||
// Query the ndb immediately to see if the user list is already there
|
||||
let txn = Transaction::new(ndb).expect("transaction");
|
||||
let lim = filter.limit().unwrap_or(crate::filter::default_limit()) as i32;
|
||||
let nks = ndb
|
||||
.query(&txn, &[filter.clone()], lim)
|
||||
.expect("query user relays results")
|
||||
.iter()
|
||||
.map(|qr| qr.note_key)
|
||||
.collect::<Vec<NoteKey>>();
|
||||
let relays = Self::harvest_nip65_relays(ndb, &txn, &nks);
|
||||
debug!(
|
||||
"pubkey {}: initial relays {:?}",
|
||||
hex::encode(pubkey),
|
||||
relays
|
||||
);
|
||||
|
||||
// Id for future remote relay subscriptions
|
||||
let subid = Uuid::new_v4().to_string();
|
||||
|
||||
// Add remote subscription to existing relays
|
||||
pool.subscribe(subid.clone(), vec![filter.clone()]);
|
||||
|
||||
AccountRelayData {
|
||||
filter,
|
||||
subid,
|
||||
sub: Some(ndbsub),
|
||||
local: BTreeSet::new(),
|
||||
advertised: relays.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
// standardize the format (ie, trailing slashes) to avoid dups
|
||||
pub fn canonicalize_url(url: &str) -> String {
|
||||
match Url::parse(url) {
|
||||
Ok(parsed_url) => parsed_url.to_string(),
|
||||
Err(_) => url.to_owned(), // If parsing fails, return the original URL.
|
||||
}
|
||||
}
|
||||
|
||||
fn harvest_nip65_relays(ndb: &Ndb, txn: &Transaction, nks: &[NoteKey]) -> Vec<String> {
|
||||
let mut relays = Vec::new();
|
||||
for nk in nks.iter() {
|
||||
if let Ok(note) = ndb.get_note_by_key(txn, *nk) {
|
||||
for tag in note.tags() {
|
||||
match tag.get(0).and_then(|t| t.variant().str()) {
|
||||
Some("r") => {
|
||||
if let Some(url) = tag.get(1).and_then(|f| f.variant().str()) {
|
||||
relays.push(Self::canonicalize_url(url));
|
||||
}
|
||||
}
|
||||
Some("alt") => {
|
||||
// ignore for now
|
||||
}
|
||||
Some(x) => {
|
||||
error!("harvest_nip65_relays: unexpected tag type: {}", x);
|
||||
}
|
||||
None => {
|
||||
error!("harvest_nip65_relays: invalid tag");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
relays
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AccountMutedData {
|
||||
filter: Filter,
|
||||
subid: String,
|
||||
sub: Option<Subscription>,
|
||||
muted: Arc<Muted>,
|
||||
}
|
||||
|
||||
impl AccountMutedData {
|
||||
pub fn new(ndb: &Ndb, pool: &mut RelayPool, pubkey: &[u8; 32]) -> Self {
|
||||
// Construct a filter for the user's NIP-51 muted list
|
||||
let filter = Filter::new()
|
||||
.authors([pubkey])
|
||||
.kinds([10000])
|
||||
.limit(1)
|
||||
.build();
|
||||
|
||||
// Local ndb subscription
|
||||
let ndbsub = ndb
|
||||
.subscribe(&[filter.clone()])
|
||||
.expect("ndb muted subscription");
|
||||
|
||||
// Query the ndb immediately to see if the user's muted list is already there
|
||||
let txn = Transaction::new(ndb).expect("transaction");
|
||||
let lim = filter.limit().unwrap_or(crate::filter::default_limit()) as i32;
|
||||
let nks = ndb
|
||||
.query(&txn, &[filter.clone()], lim)
|
||||
.expect("query user muted results")
|
||||
.iter()
|
||||
.map(|qr| qr.note_key)
|
||||
.collect::<Vec<NoteKey>>();
|
||||
let muted = Self::harvest_nip51_muted(ndb, &txn, &nks);
|
||||
debug!("pubkey {}: initial muted {:?}", hex::encode(pubkey), muted);
|
||||
|
||||
// Id for future remote relay subscriptions
|
||||
let subid = Uuid::new_v4().to_string();
|
||||
|
||||
// Add remote subscription to existing relays
|
||||
pool.subscribe(subid.clone(), vec![filter.clone()]);
|
||||
|
||||
AccountMutedData {
|
||||
filter,
|
||||
subid,
|
||||
sub: Some(ndbsub),
|
||||
muted: Arc::new(muted),
|
||||
}
|
||||
}
|
||||
|
||||
fn harvest_nip51_muted(ndb: &Ndb, txn: &Transaction, nks: &[NoteKey]) -> Muted {
|
||||
let mut muted = Muted::default();
|
||||
for nk in nks.iter() {
|
||||
if let Ok(note) = ndb.get_note_by_key(txn, *nk) {
|
||||
for tag in note.tags() {
|
||||
match tag.get(0).and_then(|t| t.variant().str()) {
|
||||
Some("p") => {
|
||||
if let Some(id) = tag.get(1).and_then(|f| f.variant().id()) {
|
||||
muted.pubkeys.insert(*id);
|
||||
}
|
||||
}
|
||||
Some("t") => {
|
||||
if let Some(str) = tag.get(1).and_then(|f| f.variant().str()) {
|
||||
muted.hashtags.insert(str.to_string());
|
||||
}
|
||||
}
|
||||
Some("word") => {
|
||||
if let Some(str) = tag.get(1).and_then(|f| f.variant().str()) {
|
||||
muted.words.insert(str.to_string());
|
||||
}
|
||||
}
|
||||
Some("e") => {
|
||||
if let Some(id) = tag.get(1).and_then(|f| f.variant().id()) {
|
||||
muted.threads.insert(*id);
|
||||
}
|
||||
}
|
||||
Some("alt") => {
|
||||
// maybe we can ignore these?
|
||||
}
|
||||
Some(x) => error!("query_nip51_muted: unexpected tag: {}", x),
|
||||
None => error!(
|
||||
"query_nip51_muted: bad tag value: {:?}",
|
||||
tag.get_unchecked(0).variant()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
muted
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AccountData {
|
||||
relay: AccountRelayData,
|
||||
muted: AccountMutedData,
|
||||
}
|
||||
|
||||
/// The interface for managing the user's accounts.
|
||||
/// Represents all user-facing operations related to account management.
|
||||
pub struct Accounts {
|
||||
currently_selected_account: Option<usize>,
|
||||
accounts: Vec<UserAccount>,
|
||||
key_store: KeyStorageType,
|
||||
account_data: BTreeMap<[u8; 32], AccountData>,
|
||||
forced_relays: BTreeSet<String>,
|
||||
bootstrap_relays: BTreeSet<String>,
|
||||
needs_relay_config: bool,
|
||||
}
|
||||
|
||||
impl Accounts {
|
||||
pub fn new(key_store: KeyStorageType, forced_relays: Vec<String>) -> Self {
|
||||
let accounts = if let KeyStorageResponse::ReceivedResult(res) = key_store.get_keys() {
|
||||
res.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let currently_selected_account = get_selected_index(&accounts, &key_store);
|
||||
let account_data = BTreeMap::new();
|
||||
let forced_relays: BTreeSet<String> = forced_relays
|
||||
.into_iter()
|
||||
.map(|u| AccountRelayData::canonicalize_url(&u))
|
||||
.collect();
|
||||
let bootstrap_relays = [
|
||||
"wss://relay.damus.io",
|
||||
// "wss://pyramid.fiatjaf.com", // Uncomment if needed
|
||||
"wss://nos.lol",
|
||||
"wss://nostr.wine",
|
||||
"wss://purplepag.es",
|
||||
]
|
||||
.iter()
|
||||
.map(|&url| url.to_string())
|
||||
.map(|u| AccountRelayData::canonicalize_url(&u))
|
||||
.collect();
|
||||
|
||||
Accounts {
|
||||
currently_selected_account,
|
||||
accounts,
|
||||
key_store,
|
||||
account_data,
|
||||
forced_relays,
|
||||
bootstrap_relays,
|
||||
needs_relay_config: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_accounts(&self) -> &Vec<UserAccount> {
|
||||
&self.accounts
|
||||
}
|
||||
|
||||
pub fn get_account(&self, ind: usize) -> Option<&UserAccount> {
|
||||
self.accounts.get(ind)
|
||||
}
|
||||
|
||||
pub fn find_account(&self, pk: &[u8; 32]) -> Option<&UserAccount> {
|
||||
self.accounts.iter().find(|acc| acc.pubkey.bytes() == pk)
|
||||
}
|
||||
|
||||
pub fn remove_account(&mut self, index: usize) {
|
||||
if let Some(account) = self.accounts.get(index) {
|
||||
let _ = self.key_store.remove_key(account);
|
||||
self.accounts.remove(index);
|
||||
|
||||
if let Some(selected_index) = self.currently_selected_account {
|
||||
match selected_index.cmp(&index) {
|
||||
Ordering::Greater => {
|
||||
self.select_account(selected_index - 1);
|
||||
}
|
||||
Ordering::Equal => {
|
||||
if self.accounts.is_empty() {
|
||||
// If no accounts remain, clear the selection
|
||||
self.clear_selected_account();
|
||||
} else if index >= self.accounts.len() {
|
||||
// If the removed account was the last one, select the new last account
|
||||
self.select_account(self.accounts.len() - 1);
|
||||
} else {
|
||||
// Otherwise, select the account at the same position
|
||||
self.select_account(index);
|
||||
}
|
||||
}
|
||||
Ordering::Less => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_account(&self, pubkey: &[u8; 32]) -> Option<ContainsAccount> {
|
||||
for (index, account) in self.accounts.iter().enumerate() {
|
||||
let has_pubkey = account.pubkey.bytes() == pubkey;
|
||||
let has_nsec = account.secret_key.is_some();
|
||||
if has_pubkey {
|
||||
return Some(ContainsAccount { has_nsec, index });
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[must_use = "UnknownIdAction's must be handled. Use .process_unknown_id_action()"]
|
||||
pub fn add_account(&mut self, account: Keypair) -> AddAccountAction {
|
||||
let pubkey = account.pubkey;
|
||||
let switch_to_index = if let Some(contains_acc) = self.contains_account(pubkey.bytes()) {
|
||||
if account.secret_key.is_some() && !contains_acc.has_nsec {
|
||||
info!(
|
||||
"user provided nsec, but we already have npub {}. Upgrading to nsec",
|
||||
pubkey
|
||||
);
|
||||
let _ = self.key_store.add_key(&account);
|
||||
|
||||
self.accounts[contains_acc.index] = account;
|
||||
} else {
|
||||
info!("already have account, not adding {}", pubkey);
|
||||
}
|
||||
contains_acc.index
|
||||
} else {
|
||||
info!("adding new account {}", pubkey);
|
||||
let _ = self.key_store.add_key(&account);
|
||||
self.accounts.push(account);
|
||||
self.accounts.len() - 1
|
||||
};
|
||||
|
||||
AddAccountAction {
|
||||
accounts_action: Some(AccountsAction::Switch(switch_to_index)),
|
||||
unk_id_action: SingleUnkIdAction::pubkey(pubkey),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn num_accounts(&self) -> usize {
|
||||
self.accounts.len()
|
||||
}
|
||||
|
||||
pub fn get_selected_account_index(&self) -> Option<usize> {
|
||||
self.currently_selected_account
|
||||
}
|
||||
|
||||
pub fn selected_or_first_nsec(&self) -> Option<FilledKeypair<'_>> {
|
||||
self.get_selected_account()
|
||||
.and_then(|kp| kp.to_full())
|
||||
.or_else(|| self.accounts.iter().find_map(|a| a.to_full()))
|
||||
}
|
||||
|
||||
pub fn get_selected_account(&self) -> Option<&UserAccount> {
|
||||
if let Some(account_index) = self.currently_selected_account {
|
||||
if let Some(account) = self.get_account(account_index) {
|
||||
Some(account)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_account(&mut self, index: usize) {
|
||||
if let Some(account) = self.accounts.get(index) {
|
||||
self.currently_selected_account = Some(index);
|
||||
self.key_store.select_key(Some(account.pubkey));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_selected_account(&mut self) {
|
||||
self.currently_selected_account = None;
|
||||
self.key_store.select_key(None);
|
||||
}
|
||||
|
||||
pub fn mutefun(&self) -> Box<dyn Fn(&Note) -> bool> {
|
||||
if let Some(index) = self.currently_selected_account {
|
||||
if let Some(account) = self.accounts.get(index) {
|
||||
let pubkey = account.pubkey.bytes();
|
||||
if let Some(account_data) = self.account_data.get(pubkey) {
|
||||
let muted = Arc::clone(&account_data.muted.muted);
|
||||
return Box::new(move |note: &Note| muted.is_muted(note));
|
||||
}
|
||||
}
|
||||
}
|
||||
Box::new(|_: &Note| false)
|
||||
}
|
||||
|
||||
pub fn send_initial_filters(&mut self, pool: &mut RelayPool, relay_url: &str) {
|
||||
for data in self.account_data.values() {
|
||||
pool.send_to(
|
||||
&ClientMessage::req(data.relay.subid.clone(), vec![data.relay.filter.clone()]),
|
||||
relay_url,
|
||||
);
|
||||
pool.send_to(
|
||||
&ClientMessage::req(data.muted.subid.clone(), vec![data.muted.filter.clone()]),
|
||||
relay_url,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns added and removed accounts
|
||||
fn delta_accounts(&self) -> (Vec<[u8; 32]>, Vec<[u8; 32]>) {
|
||||
let mut added = Vec::new();
|
||||
for pubkey in self.accounts.iter().map(|a| a.pubkey.bytes()) {
|
||||
if !self.account_data.contains_key(pubkey) {
|
||||
added.push(*pubkey);
|
||||
}
|
||||
}
|
||||
let mut removed = Vec::new();
|
||||
for pubkey in self.account_data.keys() {
|
||||
if self.contains_account(pubkey).is_none() {
|
||||
removed.push(*pubkey);
|
||||
}
|
||||
}
|
||||
(added, removed)
|
||||
}
|
||||
|
||||
fn handle_added_account(&mut self, ndb: &Ndb, pool: &mut RelayPool, pubkey: &[u8; 32]) {
|
||||
debug!("handle_added_account {}", hex::encode(pubkey));
|
||||
|
||||
// Create the user account data
|
||||
let new_account_data = AccountData {
|
||||
relay: AccountRelayData::new(ndb, pool, pubkey),
|
||||
muted: AccountMutedData::new(ndb, pool, pubkey),
|
||||
};
|
||||
self.account_data.insert(*pubkey, new_account_data);
|
||||
}
|
||||
|
||||
fn handle_removed_account(&mut self, pubkey: &[u8; 32]) {
|
||||
debug!("handle_removed_account {}", hex::encode(pubkey));
|
||||
// FIXME - we need to unsubscribe here
|
||||
self.account_data.remove(pubkey);
|
||||
}
|
||||
|
||||
fn poll_for_updates(&mut self, ndb: &Ndb) -> bool {
|
||||
let mut changed = false;
|
||||
for (pubkey, data) in &mut self.account_data {
|
||||
if let Some(sub) = data.relay.sub {
|
||||
let nks = ndb.poll_for_notes(sub, 1);
|
||||
if !nks.is_empty() {
|
||||
let txn = Transaction::new(ndb).expect("txn");
|
||||
let relays = AccountRelayData::harvest_nip65_relays(ndb, &txn, &nks);
|
||||
debug!(
|
||||
"pubkey {}: updated relays {:?}",
|
||||
hex::encode(pubkey),
|
||||
relays
|
||||
);
|
||||
data.relay.advertised = relays.into_iter().collect();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if let Some(sub) = data.muted.sub {
|
||||
let nks = ndb.poll_for_notes(sub, 1);
|
||||
if !nks.is_empty() {
|
||||
let txn = Transaction::new(ndb).expect("txn");
|
||||
let muted = AccountMutedData::harvest_nip51_muted(ndb, &txn, &nks);
|
||||
debug!("pubkey {}: updated muted {:?}", hex::encode(pubkey), muted);
|
||||
data.muted.muted = Arc::new(muted);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn update_relay_configuration(
|
||||
&mut self,
|
||||
pool: &mut RelayPool,
|
||||
wakeup: impl Fn() + Send + Sync + Clone + 'static,
|
||||
) {
|
||||
// If forced relays are set use them only
|
||||
let mut desired_relays = self.forced_relays.clone();
|
||||
|
||||
// Compose the desired relay lists from the accounts
|
||||
if desired_relays.is_empty() {
|
||||
for data in self.account_data.values() {
|
||||
desired_relays.extend(data.relay.local.iter().cloned());
|
||||
desired_relays.extend(data.relay.advertised.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
// If no relays are specified at this point use the bootstrap list
|
||||
if desired_relays.is_empty() {
|
||||
desired_relays = self.bootstrap_relays.clone();
|
||||
}
|
||||
|
||||
debug!("current relays: {:?}", pool.urls());
|
||||
debug!("desired relays: {:?}", desired_relays);
|
||||
|
||||
let add: BTreeSet<String> = desired_relays.difference(&pool.urls()).cloned().collect();
|
||||
let sub: BTreeSet<String> = pool.urls().difference(&desired_relays).cloned().collect();
|
||||
if !add.is_empty() {
|
||||
debug!("configuring added relays: {:?}", add);
|
||||
let _ = pool.add_urls(add, wakeup);
|
||||
}
|
||||
if !sub.is_empty() {
|
||||
debug!("removing unwanted relays: {:?}", sub);
|
||||
pool.remove_urls(&sub);
|
||||
}
|
||||
|
||||
debug!("current relays: {:?}", pool.urls());
|
||||
}
|
||||
|
||||
pub fn update(&mut self, ndb: &Ndb, pool: &mut RelayPool, ctx: &egui::Context) {
|
||||
// IMPORTANT - This function is called in the UI update loop,
|
||||
// make sure it is fast when idle
|
||||
|
||||
// On the initial update the relays need config even if nothing changes below
|
||||
let mut relays_changed = self.needs_relay_config;
|
||||
|
||||
let ctx2 = ctx.clone();
|
||||
let wakeup = move || {
|
||||
ctx2.request_repaint();
|
||||
};
|
||||
|
||||
// Were any accounts added or removed?
|
||||
let (added, removed) = self.delta_accounts();
|
||||
for pk in added {
|
||||
self.handle_added_account(ndb, pool, &pk);
|
||||
relays_changed = true;
|
||||
}
|
||||
for pk in removed {
|
||||
self.handle_removed_account(&pk);
|
||||
relays_changed = true;
|
||||
}
|
||||
|
||||
// Did any accounts receive updates (ie NIP-65 relay lists)
|
||||
relays_changed = self.poll_for_updates(ndb) || relays_changed;
|
||||
|
||||
// If needed, update the relay configuration
|
||||
if relays_changed {
|
||||
self.update_relay_configuration(pool, wakeup);
|
||||
self.needs_relay_config = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_index(accounts: &[UserAccount], keystore: &KeyStorageType) -> Option<usize> {
|
||||
match keystore.get_selected_key() {
|
||||
KeyStorageResponse::ReceivedResult(Ok(Some(pubkey))) => {
|
||||
return accounts.iter().position(|account| account.pubkey == pubkey);
|
||||
}
|
||||
|
||||
KeyStorageResponse::ReceivedResult(Err(e)) => error!("Error getting selected key: {}", e),
|
||||
KeyStorageResponse::Waiting | KeyStorageResponse::ReceivedResult(Ok(None)) => {}
|
||||
};
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
impl AddAccountAction {
|
||||
// Simple wrapper around processing the unknown action to expose too
|
||||
// much internal logic. This allows us to have a must_use on our
|
||||
// LoginAction type, otherwise the SingleUnkIdAction's must_use will
|
||||
// be lost when returned in the login action
|
||||
pub fn process_action(&mut self, ids: &mut UnknownIds, ndb: &Ndb, txn: &Transaction) {
|
||||
self.unk_id_action.process_action(ids, ndb, txn);
|
||||
}
|
||||
}
|
||||
5
crates/notedeck/src/app.rs
Normal file
5
crates/notedeck/src/app.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use crate::AppContext;
|
||||
|
||||
pub trait App {
|
||||
fn update(&mut self, ctx: &mut AppContext<'_>);
|
||||
}
|
||||
111
crates/notedeck/src/args.rs
Normal file
111
crates/notedeck/src/args.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use enostr::{Keypair, Pubkey, SecretKey};
|
||||
use tracing::error;
|
||||
|
||||
pub struct Args {
|
||||
pub relays: Vec<String>,
|
||||
pub is_mobile: Option<bool>,
|
||||
pub keys: Vec<Keypair>,
|
||||
pub light: bool,
|
||||
pub debug: bool,
|
||||
pub use_keystore: bool,
|
||||
pub dbpath: Option<String>,
|
||||
pub datapath: Option<String>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
pub fn parse(args: &[String]) -> Self {
|
||||
let mut res = Args {
|
||||
relays: vec![],
|
||||
is_mobile: None,
|
||||
keys: vec![],
|
||||
light: false,
|
||||
debug: false,
|
||||
use_keystore: true,
|
||||
dbpath: None,
|
||||
datapath: None,
|
||||
};
|
||||
|
||||
let mut i = 0;
|
||||
let len = args.len();
|
||||
while i < len {
|
||||
let arg = &args[i];
|
||||
|
||||
if arg == "--mobile" {
|
||||
res.is_mobile = Some(true);
|
||||
} else if arg == "--light" {
|
||||
res.light = true;
|
||||
} else if arg == "--dark" {
|
||||
res.light = false;
|
||||
} else if arg == "--debug" {
|
||||
res.debug = true;
|
||||
} else if arg == "--pub" || arg == "--npub" {
|
||||
i += 1;
|
||||
let pubstr = if let Some(next_arg) = args.get(i) {
|
||||
next_arg
|
||||
} else {
|
||||
error!("sec argument missing?");
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Ok(pk) = Pubkey::parse(pubstr) {
|
||||
res.keys.push(Keypair::only_pubkey(pk));
|
||||
} else {
|
||||
error!(
|
||||
"failed to parse {} argument. Make sure to use hex or npub.",
|
||||
arg
|
||||
);
|
||||
}
|
||||
} else if arg == "--sec" || arg == "--nsec" {
|
||||
i += 1;
|
||||
let secstr = if let Some(next_arg) = args.get(i) {
|
||||
next_arg
|
||||
} else {
|
||||
error!("sec argument missing?");
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Ok(sec) = SecretKey::parse(secstr) {
|
||||
res.keys.push(Keypair::from_secret(sec));
|
||||
} else {
|
||||
error!(
|
||||
"failed to parse {} argument. Make sure to use hex or nsec.",
|
||||
arg
|
||||
);
|
||||
}
|
||||
} else if arg == "--dbpath" {
|
||||
i += 1;
|
||||
let path = if let Some(next_arg) = args.get(i) {
|
||||
next_arg
|
||||
} else {
|
||||
error!("dbpath argument missing?");
|
||||
continue;
|
||||
};
|
||||
res.dbpath = Some(path.clone());
|
||||
} else if arg == "--datapath" {
|
||||
i += 1;
|
||||
let path = if let Some(next_arg) = args.get(i) {
|
||||
next_arg
|
||||
} else {
|
||||
error!("datapath argument missing?");
|
||||
continue;
|
||||
};
|
||||
res.datapath = Some(path.clone());
|
||||
} else if arg == "-r" || arg == "--relay" {
|
||||
i += 1;
|
||||
let relay = if let Some(next_arg) = args.get(i) {
|
||||
next_arg
|
||||
} else {
|
||||
error!("relay argument missing?");
|
||||
continue;
|
||||
};
|
||||
res.relays.push(relay.clone());
|
||||
} else if arg == "--no-keystore" {
|
||||
res.use_keystore = false;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
}
|
||||
19
crates/notedeck/src/context.rs
Normal file
19
crates/notedeck/src/context.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use crate::{Accounts, Args, DataPath, ImageCache, NoteCache, ThemeHandler, UnknownIds};
|
||||
|
||||
use enostr::RelayPool;
|
||||
use nostrdb::Ndb;
|
||||
|
||||
// TODO: make this interface more sandboxed
|
||||
|
||||
pub struct AppContext<'a> {
|
||||
pub ndb: &'a Ndb,
|
||||
pub img_cache: &'a mut ImageCache,
|
||||
pub unknown_ids: &'a mut UnknownIds,
|
||||
pub pool: &'a mut RelayPool,
|
||||
pub note_cache: &'a mut NoteCache,
|
||||
pub accounts: &'a mut Accounts,
|
||||
pub path: &'a DataPath,
|
||||
pub args: &'a Args,
|
||||
pub theme: &'a mut ThemeHandler,
|
||||
pub egui: &'a egui::Context,
|
||||
}
|
||||
64
crates/notedeck/src/error.rs
Normal file
64
crates/notedeck/src/error.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use std::io;
|
||||
|
||||
/// App related errors
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("image error: {0}")]
|
||||
Image(#[from] image::error::ImageError),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("subscription error: {0}")]
|
||||
SubscriptionError(SubscriptionError),
|
||||
|
||||
#[error("filter error: {0}")]
|
||||
Filter(FilterError),
|
||||
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Nostrdb(#[from] nostrdb::Error),
|
||||
|
||||
#[error("generic error: {0}")]
|
||||
Generic(String),
|
||||
}
|
||||
|
||||
impl From<String> for Error {
|
||||
fn from(s: String) -> Self {
|
||||
Error::Generic(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, thiserror::Error)]
|
||||
pub enum FilterError {
|
||||
#[error("empty contact list")]
|
||||
EmptyContactList,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Copy, Clone, thiserror::Error)]
|
||||
pub enum SubscriptionError {
|
||||
#[error("no active subscriptions")]
|
||||
NoActive,
|
||||
|
||||
/// When a timeline has an unexpected number
|
||||
/// of active subscriptions. Should only happen if there
|
||||
/// is a bug in notedeck
|
||||
#[error("unexpected subscription count")]
|
||||
UnexpectedSubscriptionCount(i32),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn unexpected_sub_count(c: i32) -> Self {
|
||||
Error::SubscriptionError(SubscriptionError::UnexpectedSubscriptionCount(c))
|
||||
}
|
||||
|
||||
pub fn no_active_sub() -> Self {
|
||||
Error::SubscriptionError(SubscriptionError::NoActive)
|
||||
}
|
||||
|
||||
pub fn empty_contact_list() -> Self {
|
||||
Error::Filter(FilterError::EmptyContactList)
|
||||
}
|
||||
}
|
||||
266
crates/notedeck/src/filter.rs
Normal file
266
crates/notedeck/src/filter.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
use crate::error::{Error, FilterError};
|
||||
use crate::note::NoteRef;
|
||||
use crate::Result;
|
||||
use nostrdb::{Filter, FilterBuilder, Note, Subscription};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// A unified subscription has a local and remote component. The remote subid
|
||||
/// tracks data received remotely, and local
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnifiedSubscription {
|
||||
pub local: Subscription,
|
||||
pub remote: String,
|
||||
}
|
||||
|
||||
/// Each relay can have a different filter state. For example, some
|
||||
/// relays may have the contact list, some may not. Let's capture all of
|
||||
/// these states so that some relays don't stop the states of other
|
||||
/// relays.
|
||||
#[derive(Debug)]
|
||||
pub struct FilterStates {
|
||||
pub initial_state: FilterState,
|
||||
pub states: HashMap<String, FilterState>,
|
||||
}
|
||||
|
||||
impl FilterStates {
|
||||
pub fn get(&mut self, relay: &str) -> &FilterState {
|
||||
// if our initial state is ready, then just use that
|
||||
if let FilterState::Ready(_) = self.initial_state {
|
||||
&self.initial_state
|
||||
} else {
|
||||
// otherwise we look at relay states
|
||||
if !self.states.contains_key(relay) {
|
||||
self.states
|
||||
.insert(relay.to_string(), self.initial_state.clone());
|
||||
}
|
||||
self.states.get(relay).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_any_gotremote(&self) -> Option<(&str, Subscription)> {
|
||||
for (k, v) in self.states.iter() {
|
||||
if let FilterState::GotRemote(sub) = v {
|
||||
return Some((k, *sub));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_any_ready(&self) -> Option<&Vec<Filter>> {
|
||||
if let FilterState::Ready(fs) = &self.initial_state {
|
||||
Some(fs)
|
||||
} else {
|
||||
for (_k, v) in self.states.iter() {
|
||||
if let FilterState::Ready(ref fs) = v {
|
||||
return Some(fs);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(initial_state: FilterState) -> Self {
|
||||
Self {
|
||||
initial_state,
|
||||
states: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_relay_state(&mut self, relay: String, state: FilterState) {
|
||||
if self.states.contains_key(&relay) {
|
||||
let current_state = self.states.get(&relay).unwrap();
|
||||
debug!(
|
||||
"set_relay_state: {:?} -> {:?} on {}",
|
||||
current_state, state, &relay,
|
||||
);
|
||||
}
|
||||
self.states.insert(relay, state);
|
||||
}
|
||||
}
|
||||
|
||||
/// We may need to fetch some data from relays before our filter is ready.
|
||||
/// [`FilterState`] tracks this.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FilterState {
|
||||
NeedsRemote(Vec<Filter>),
|
||||
FetchingRemote(UnifiedSubscription),
|
||||
GotRemote(Subscription),
|
||||
Ready(Vec<Filter>),
|
||||
Broken(FilterError),
|
||||
}
|
||||
|
||||
impl FilterState {
|
||||
/// We tried to fetch a filter but we wither got no data or the data
|
||||
/// was corrupted, preventing us from getting to the Ready state.
|
||||
/// Just mark the timeline as broken so that we can signal to the
|
||||
/// user that something went wrong
|
||||
pub fn broken(reason: FilterError) -> Self {
|
||||
Self::Broken(reason)
|
||||
}
|
||||
|
||||
/// The filter is ready
|
||||
pub fn ready(filter: Vec<Filter>) -> Self {
|
||||
Self::Ready(filter)
|
||||
}
|
||||
|
||||
/// We need some data from relays before we can continue. Example:
|
||||
/// for home timelines where we don't have a contact list yet. We
|
||||
/// need to fetch the contact list before we have the right timeline
|
||||
/// filter.
|
||||
pub fn needs_remote(filter: Vec<Filter>) -> Self {
|
||||
Self::NeedsRemote(filter)
|
||||
}
|
||||
|
||||
/// We got the remote data. Local data should be available to build
|
||||
/// the filter for the [`FilterState::Ready`] state
|
||||
pub fn got_remote(local_sub: Subscription) -> Self {
|
||||
Self::GotRemote(local_sub)
|
||||
}
|
||||
|
||||
/// We have sent off a remote subscription to get data needed for the
|
||||
/// filter. The string is the subscription id
|
||||
pub fn fetching_remote(sub_id: String, local_sub: Subscription) -> Self {
|
||||
let unified_sub = UnifiedSubscription {
|
||||
local: local_sub,
|
||||
remote: sub_id,
|
||||
};
|
||||
Self::FetchingRemote(unified_sub)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_since_optimize(limit: u64, num_notes: usize) -> bool {
|
||||
// rough heuristic for bailing since optimization if we don't have enough notes
|
||||
limit as usize <= num_notes
|
||||
}
|
||||
|
||||
pub fn since_optimize_filter_with(filter: Filter, notes: &[NoteRef], since_gap: u64) -> Filter {
|
||||
// Get the latest entry in the events
|
||||
if notes.is_empty() {
|
||||
return filter;
|
||||
}
|
||||
|
||||
// get the latest note
|
||||
let latest = notes[0];
|
||||
let since = latest.created_at - since_gap;
|
||||
|
||||
filter.since_mut(since)
|
||||
}
|
||||
|
||||
pub fn since_optimize_filter(filter: Filter, notes: &[NoteRef]) -> Filter {
|
||||
since_optimize_filter_with(filter, notes, 60)
|
||||
}
|
||||
|
||||
pub fn default_limit() -> u64 {
|
||||
500
|
||||
}
|
||||
|
||||
pub fn default_remote_limit() -> u64 {
|
||||
250
|
||||
}
|
||||
|
||||
pub struct FilteredTags {
|
||||
pub authors: Option<FilterBuilder>,
|
||||
pub hashtags: Option<FilterBuilder>,
|
||||
}
|
||||
|
||||
impl FilteredTags {
|
||||
pub fn into_follow_filter(self) -> Vec<Filter> {
|
||||
self.into_filter([1], default_limit())
|
||||
}
|
||||
|
||||
// TODO: make this more general
|
||||
pub fn into_filter<I>(self, kinds: I, limit: u64) -> Vec<Filter>
|
||||
where
|
||||
I: IntoIterator<Item = u64> + Copy,
|
||||
{
|
||||
let mut filters: Vec<Filter> = Vec::with_capacity(2);
|
||||
|
||||
if let Some(authors) = self.authors {
|
||||
filters.push(authors.kinds(kinds).limit(limit).build())
|
||||
}
|
||||
|
||||
if let Some(hashtags) = self.hashtags {
|
||||
filters.push(hashtags.kinds(kinds).limit(limit).build())
|
||||
}
|
||||
|
||||
filters
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a filter from tags. This can be used to create a filter
|
||||
/// from a contact list
|
||||
pub fn filter_from_tags(note: &Note) -> Result<FilteredTags> {
|
||||
let mut author_filter = Filter::new();
|
||||
let mut hashtag_filter = Filter::new();
|
||||
let mut author_res: Option<FilterBuilder> = None;
|
||||
let mut hashtag_res: Option<FilterBuilder> = None;
|
||||
let mut author_count = 0i32;
|
||||
let mut hashtag_count = 0i32;
|
||||
|
||||
let tags = note.tags();
|
||||
|
||||
author_filter.start_authors_field()?;
|
||||
hashtag_filter.start_tags_field('t')?;
|
||||
|
||||
for tag in tags {
|
||||
if tag.count() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let t = if let Some(t) = tag.get_unchecked(0).variant().str() {
|
||||
t
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if t == "p" {
|
||||
let author = if let Some(author) = tag.get_unchecked(1).variant().id() {
|
||||
author
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
author_filter.add_id_element(author)?;
|
||||
author_count += 1;
|
||||
} else if t == "t" {
|
||||
let hashtag = if let Some(hashtag) = tag.get_unchecked(1).variant().str() {
|
||||
hashtag
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
hashtag_filter.add_str_element(hashtag)?;
|
||||
hashtag_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
author_filter.end_field();
|
||||
hashtag_filter.end_field();
|
||||
|
||||
if author_count == 0 && hashtag_count == 0 {
|
||||
warn!("no authors or hashtags found in contact list");
|
||||
return Err(Error::empty_contact_list());
|
||||
}
|
||||
|
||||
debug!(
|
||||
"adding {} authors and {} hashtags to contact filter",
|
||||
author_count, hashtag_count
|
||||
);
|
||||
|
||||
// if we hit these ooms, we need to expand filter buffer size
|
||||
if author_count > 0 {
|
||||
author_res = Some(author_filter)
|
||||
}
|
||||
|
||||
if hashtag_count > 0 {
|
||||
hashtag_res = Some(hashtag_filter)
|
||||
}
|
||||
|
||||
Ok(FilteredTags {
|
||||
authors: author_res,
|
||||
hashtags: hashtag_res,
|
||||
})
|
||||
}
|
||||
58
crates/notedeck/src/fonts.rs
Normal file
58
crates/notedeck/src/fonts.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use crate::{ui, NotedeckTextStyle};
|
||||
|
||||
pub enum NamedFontFamily {
|
||||
Medium,
|
||||
Bold,
|
||||
Emoji,
|
||||
}
|
||||
|
||||
impl NamedFontFamily {
|
||||
pub fn as_str(&mut self) -> &'static str {
|
||||
match self {
|
||||
Self::Bold => "bold",
|
||||
Self::Medium => "medium",
|
||||
Self::Emoji => "emoji",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_family(&mut self) -> egui::FontFamily {
|
||||
egui::FontFamily::Name(self.as_str().into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn desktop_font_size(text_style: &NotedeckTextStyle) -> f32 {
|
||||
match text_style {
|
||||
NotedeckTextStyle::Heading => 48.0,
|
||||
NotedeckTextStyle::Heading2 => 24.0,
|
||||
NotedeckTextStyle::Heading3 => 20.0,
|
||||
NotedeckTextStyle::Heading4 => 14.0,
|
||||
NotedeckTextStyle::Body => 16.0,
|
||||
NotedeckTextStyle::Monospace => 13.0,
|
||||
NotedeckTextStyle::Button => 13.0,
|
||||
NotedeckTextStyle::Small => 12.0,
|
||||
NotedeckTextStyle::Tiny => 10.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mobile_font_size(text_style: &NotedeckTextStyle) -> f32 {
|
||||
// TODO: tweak text sizes for optimal mobile viewing
|
||||
match text_style {
|
||||
NotedeckTextStyle::Heading => 48.0,
|
||||
NotedeckTextStyle::Heading2 => 24.0,
|
||||
NotedeckTextStyle::Heading3 => 20.0,
|
||||
NotedeckTextStyle::Heading4 => 14.0,
|
||||
NotedeckTextStyle::Body => 13.0,
|
||||
NotedeckTextStyle::Monospace => 13.0,
|
||||
NotedeckTextStyle::Button => 13.0,
|
||||
NotedeckTextStyle::Small => 12.0,
|
||||
NotedeckTextStyle::Tiny => 10.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_font_size(ctx: &egui::Context, text_style: &NotedeckTextStyle) -> f32 {
|
||||
if ui::is_narrow(ctx) {
|
||||
mobile_font_size(text_style)
|
||||
} else {
|
||||
desktop_font_size(text_style)
|
||||
}
|
||||
}
|
||||
77
crates/notedeck/src/imgcache.rs
Normal file
77
crates/notedeck/src/imgcache.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use crate::Result;
|
||||
use egui::TextureHandle;
|
||||
use poll_promise::Promise;
|
||||
|
||||
use egui::ColorImage;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
|
||||
use std::path;
|
||||
|
||||
pub type ImageCacheValue = Promise<Result<TextureHandle>>;
|
||||
pub type ImageCacheMap = HashMap<String, ImageCacheValue>;
|
||||
|
||||
pub struct ImageCache {
|
||||
pub cache_dir: path::PathBuf,
|
||||
url_imgs: ImageCacheMap,
|
||||
}
|
||||
|
||||
impl ImageCache {
|
||||
pub fn new(cache_dir: path::PathBuf) -> Self {
|
||||
Self {
|
||||
cache_dir,
|
||||
url_imgs: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rel_dir() -> &'static str {
|
||||
"img"
|
||||
}
|
||||
|
||||
/*
|
||||
pub fn fetch(image: &str) -> Result<Image> {
|
||||
let m_cached_promise = img_cache.map().get(image);
|
||||
if m_cached_promise.is_none() {
|
||||
let res = crate::images::fetch_img(
|
||||
img_cache,
|
||||
ui.ctx(),
|
||||
&image,
|
||||
ImageType::Content(width.round() as u32, height.round() as u32),
|
||||
);
|
||||
img_cache.map_mut().insert(image.to_owned(), res);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
pub fn write(cache_dir: &path::Path, url: &str, data: ColorImage) -> Result<()> {
|
||||
let file_path = cache_dir.join(Self::key(url));
|
||||
let file = File::options()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(file_path)?;
|
||||
let encoder = image::codecs::webp::WebPEncoder::new_lossless(file);
|
||||
|
||||
encoder.encode(
|
||||
data.as_raw(),
|
||||
data.size[0] as u32,
|
||||
data.size[1] as u32,
|
||||
image::ColorType::Rgba8.into(),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn key(url: &str) -> String {
|
||||
base32::encode(base32::Alphabet::Crockford, url.as_bytes())
|
||||
}
|
||||
|
||||
pub fn map(&self) -> &ImageCacheMap {
|
||||
&self.url_imgs
|
||||
}
|
||||
|
||||
pub fn map_mut(&mut self) -> &mut ImageCacheMap {
|
||||
&mut self.url_imgs
|
||||
}
|
||||
}
|
||||
44
crates/notedeck/src/lib.rs
Normal file
44
crates/notedeck/src/lib.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
mod accounts;
|
||||
mod app;
|
||||
mod args;
|
||||
mod context;
|
||||
mod error;
|
||||
pub mod filter;
|
||||
pub mod fonts;
|
||||
mod imgcache;
|
||||
mod muted;
|
||||
pub mod note;
|
||||
mod notecache;
|
||||
mod result;
|
||||
pub mod storage;
|
||||
mod style;
|
||||
pub mod theme;
|
||||
mod theme_handler;
|
||||
mod time;
|
||||
mod timecache;
|
||||
pub mod ui;
|
||||
mod unknowns;
|
||||
mod user_account;
|
||||
|
||||
pub use accounts::{AccountData, Accounts, AccountsAction, AddAccountAction};
|
||||
pub use app::App;
|
||||
pub use args::Args;
|
||||
pub use context::AppContext;
|
||||
pub use error::{Error, FilterError};
|
||||
pub use filter::{FilterState, FilterStates, UnifiedSubscription};
|
||||
pub use fonts::NamedFontFamily;
|
||||
pub use imgcache::ImageCache;
|
||||
pub use muted::{MuteFun, Muted};
|
||||
pub use note::NoteRef;
|
||||
pub use notecache::{CachedNote, NoteCache};
|
||||
pub use result::Result;
|
||||
pub use storage::{
|
||||
DataPath, DataPathType, Directory, FileKeyStorage, KeyStorageResponse, KeyStorageType,
|
||||
};
|
||||
pub use style::NotedeckTextStyle;
|
||||
pub use theme::ColorTheme;
|
||||
pub use theme_handler::ThemeHandler;
|
||||
pub use time::time_ago_since;
|
||||
pub use timecache::TimeCached;
|
||||
pub use unknowns::{get_unknown_note_ids, NoteRefsUnkIdAction, SingleUnkIdAction, UnknownIds};
|
||||
pub use user_account::UserAccount;
|
||||
61
crates/notedeck/src/muted.rs
Normal file
61
crates/notedeck/src/muted.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use nostrdb::Note;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
pub type MuteFun = dyn Fn(&Note) -> bool;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Muted {
|
||||
// TODO - implement private mutes
|
||||
pub pubkeys: BTreeSet<[u8; 32]>,
|
||||
pub hashtags: BTreeSet<String>,
|
||||
pub words: BTreeSet<String>,
|
||||
pub threads: BTreeSet<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Muted {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Muted")
|
||||
.field(
|
||||
"pubkeys",
|
||||
&self.pubkeys.iter().map(hex::encode).collect::<Vec<_>>(),
|
||||
)
|
||||
.field("hashtags", &self.hashtags)
|
||||
.field("words", &self.words)
|
||||
.field(
|
||||
"threads",
|
||||
&self.threads.iter().map(hex::encode).collect::<Vec<_>>(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Muted {
|
||||
pub fn is_muted(&self, note: &Note) -> bool {
|
||||
if self.pubkeys.contains(note.pubkey()) {
|
||||
debug!(
|
||||
"{}: MUTED pubkey: {}",
|
||||
hex::encode(note.id()),
|
||||
hex::encode(note.pubkey())
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// FIXME - Implement hashtag muting here
|
||||
|
||||
// TODO - let's not add this for now, we will likely need to
|
||||
// have an optimized data structure in nostrdb to properly
|
||||
// mute words. this mutes substrings which is not ideal.
|
||||
//
|
||||
// let content = note.content().to_lowercase();
|
||||
// for word in &self.words {
|
||||
// if content.contains(&word.to_lowercase()) {
|
||||
// debug!("{}: MUTED word: {}", hex::encode(note.id()), word);
|
||||
// return true;
|
||||
// }
|
||||
// }
|
||||
|
||||
// FIXME - Implement thread muting here
|
||||
false
|
||||
}
|
||||
}
|
||||
73
crates/notedeck/src/note.rs
Normal file
73
crates/notedeck/src/note.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use crate::notecache::NoteCache;
|
||||
use nostrdb::{Ndb, Note, NoteKey, QueryResult, Transaction};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
|
||||
pub struct NoteRef {
|
||||
pub key: NoteKey,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl NoteRef {
|
||||
pub fn new(key: NoteKey, created_at: u64) -> Self {
|
||||
NoteRef { key, created_at }
|
||||
}
|
||||
|
||||
pub fn from_note(note: &Note<'_>) -> Self {
|
||||
let created_at = note.created_at();
|
||||
let key = note.key().expect("todo: implement NoteBuf");
|
||||
NoteRef::new(key, created_at)
|
||||
}
|
||||
|
||||
pub fn from_query_result(qr: QueryResult<'_>) -> Self {
|
||||
NoteRef {
|
||||
key: qr.note_key,
|
||||
created_at: qr.note.created_at(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for NoteRef {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
match self.created_at.cmp(&other.created_at) {
|
||||
Ordering::Equal => self.key.cmp(&other.key),
|
||||
Ordering::Less => Ordering::Greater,
|
||||
Ordering::Greater => Ordering::Less,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for NoteRef {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root_note_id_from_selected_id<'a>(
|
||||
ndb: &Ndb,
|
||||
note_cache: &mut NoteCache,
|
||||
txn: &'a Transaction,
|
||||
selected_note_id: &'a [u8; 32],
|
||||
) -> &'a [u8; 32] {
|
||||
let selected_note_key = if let Ok(key) = ndb
|
||||
.get_notekey_by_id(txn, selected_note_id)
|
||||
.map(NoteKey::new)
|
||||
{
|
||||
key
|
||||
} else {
|
||||
return selected_note_id;
|
||||
};
|
||||
|
||||
let note = if let Ok(note) = ndb.get_note_by_key(txn, selected_note_key) {
|
||||
note
|
||||
} else {
|
||||
return selected_note_id;
|
||||
};
|
||||
|
||||
note_cache
|
||||
.cached_note_or_insert(selected_note_key, ¬e)
|
||||
.reply
|
||||
.borrow(note.tags())
|
||||
.root()
|
||||
.map_or_else(|| selected_note_id, |nr| nr.id)
|
||||
}
|
||||
57
crates/notedeck/src/notecache.rs
Normal file
57
crates/notedeck/src/notecache.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use crate::{time_ago_since, TimeCached};
|
||||
use nostrdb::{Note, NoteKey, NoteReply, NoteReplyBuf};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct NoteCache {
|
||||
pub cache: HashMap<NoteKey, CachedNote>,
|
||||
}
|
||||
|
||||
impl NoteCache {
|
||||
pub fn cached_note_or_insert_mut(&mut self, note_key: NoteKey, note: &Note) -> &mut CachedNote {
|
||||
self.cache
|
||||
.entry(note_key)
|
||||
.or_insert_with(|| CachedNote::new(note))
|
||||
}
|
||||
|
||||
pub fn cached_note(&self, note_key: NoteKey) -> Option<&CachedNote> {
|
||||
self.cache.get(¬e_key)
|
||||
}
|
||||
|
||||
pub fn cache_mut(&mut self) -> &mut HashMap<NoteKey, CachedNote> {
|
||||
&mut self.cache
|
||||
}
|
||||
|
||||
pub fn cached_note_or_insert(&mut self, note_key: NoteKey, note: &Note) -> &CachedNote {
|
||||
self.cache
|
||||
.entry(note_key)
|
||||
.or_insert_with(|| CachedNote::new(note))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CachedNote {
|
||||
reltime: TimeCached<String>,
|
||||
pub reply: NoteReplyBuf,
|
||||
}
|
||||
|
||||
impl CachedNote {
|
||||
pub fn new(note: &Note<'_>) -> Self {
|
||||
let created_at = note.created_at();
|
||||
let reltime = TimeCached::new(
|
||||
Duration::from_secs(1),
|
||||
Box::new(move || time_ago_since(created_at)),
|
||||
);
|
||||
let reply = NoteReply::new(note.tags()).to_owned();
|
||||
CachedNote { reltime, reply }
|
||||
}
|
||||
|
||||
pub fn reltime_str_mut(&mut self) -> &str {
|
||||
self.reltime.get_mut()
|
||||
}
|
||||
|
||||
pub fn reltime_str(&self) -> Option<&str> {
|
||||
self.reltime.get().map(|x| x.as_str())
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
#![warn(clippy::all, rust_2018_idioms)]
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
|
||||
use notedeck_columns::{
|
||||
app_creation::generate_native_options,
|
||||
storage::{DataPath, DataPathType},
|
||||
Damus,
|
||||
};
|
||||
use std::{path::PathBuf, str::FromStr};
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
// Entry point for wasm
|
||||
//#[cfg(target_arch = "wasm32")]
|
||||
//use wasm_bindgen::prelude::*;
|
||||
|
||||
fn setup_logging(path: &DataPath) {
|
||||
#[allow(unused_variables)] // need guard to live for lifetime of program
|
||||
let (maybe_non_blocking, maybe_guard) = {
|
||||
let log_path = path.path(DataPathType::Log);
|
||||
// Setup logging to file
|
||||
|
||||
use tracing_appender::{
|
||||
non_blocking,
|
||||
rolling::{RollingFileAppender, Rotation},
|
||||
};
|
||||
|
||||
let file_appender = RollingFileAppender::new(
|
||||
Rotation::DAILY,
|
||||
log_path,
|
||||
format!("notedeck-{}.log", env!("CARGO_PKG_VERSION")),
|
||||
);
|
||||
|
||||
let (non_blocking, _guard) = non_blocking(file_appender);
|
||||
|
||||
(Some(non_blocking), Some(_guard))
|
||||
};
|
||||
|
||||
// Log to stdout (if you run with `RUST_LOG=debug`).
|
||||
if let Some(non_blocking_writer) = maybe_non_blocking {
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
let console_layer = fmt::layer().with_target(true).with_writer(std::io::stdout);
|
||||
|
||||
// Create the file layer (writes to the file)
|
||||
let file_layer = fmt::layer()
|
||||
.with_ansi(false)
|
||||
.with_writer(non_blocking_writer);
|
||||
|
||||
let env_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("notedeck=info"));
|
||||
|
||||
// Set up the subscriber to combine both layers
|
||||
tracing_subscriber::registry()
|
||||
.with(console_layer)
|
||||
.with(file_layer)
|
||||
.with(env_filter)
|
||||
.init();
|
||||
} else {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let base_path = DataPath::default_base().unwrap_or(PathBuf::from_str(".").unwrap());
|
||||
let path = DataPath::new(&base_path);
|
||||
|
||||
setup_logging(&path);
|
||||
|
||||
let _res = eframe::run_native(
|
||||
"Damus Notedeck",
|
||||
generate_native_options(path),
|
||||
Box::new(|cc| {
|
||||
Ok(Box::new(Damus::new(
|
||||
&cc.egui_ctx,
|
||||
base_path,
|
||||
std::env::args().collect(),
|
||||
)))
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn main() {
|
||||
// Make sure panics are logged using `console.error`.
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
// Redirect tracing to console.log and friends:
|
||||
tracing_wasm::set_as_global_default();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let web_options = eframe::WebOptions::default();
|
||||
eframe::start_web(
|
||||
"the_canvas_id", // hardcode it
|
||||
web_options,
|
||||
Box::new(|cc| Box::new(Damus::new(cc, "."))),
|
||||
)
|
||||
.await
|
||||
.expect("failed to start eframe");
|
||||
});
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
use notedeck_columns::ui::configure_deck::ConfigureDeckView;
|
||||
use notedeck_columns::ui::edit_deck::EditDeckView;
|
||||
use notedeck_columns::ui::{
|
||||
account_login_view::AccountLoginView, accounts::AccountsView, add_column::AddColumnView,
|
||||
DesktopSidePanel, PostView, Preview, PreviewApp, PreviewConfig, ProfilePic, ProfilePreview,
|
||||
RelayView,
|
||||
};
|
||||
use notedeck_columns::{
|
||||
app_creation::{generate_mobile_emulator_native_options, generate_native_options, setup_cc},
|
||||
storage::DataPath,
|
||||
};
|
||||
use std::env;
|
||||
|
||||
struct PreviewRunner {
|
||||
force_mobile: bool,
|
||||
light_mode: bool,
|
||||
}
|
||||
|
||||
impl PreviewRunner {
|
||||
fn new(force_mobile: bool, light_mode: bool) -> Self {
|
||||
PreviewRunner {
|
||||
force_mobile,
|
||||
light_mode,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run<P>(self, preview: P)
|
||||
where
|
||||
P: Into<PreviewApp> + 'static,
|
||||
{
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let native_options = if self.force_mobile {
|
||||
generate_mobile_emulator_native_options()
|
||||
} else {
|
||||
// TODO: tmp preview pathbuf?
|
||||
generate_native_options(DataPath::new("previews"))
|
||||
};
|
||||
|
||||
let is_mobile = self.force_mobile;
|
||||
let light_mode = self.light_mode;
|
||||
|
||||
let _ = eframe::run_native(
|
||||
"UI Preview Runner",
|
||||
native_options,
|
||||
Box::new(move |cc| {
|
||||
let app = Into::<PreviewApp>::into(preview);
|
||||
setup_cc(&cc.egui_ctx, is_mobile, light_mode);
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! previews {
|
||||
// Accept a runner and name variable, followed by one or more identifiers for the views
|
||||
($runner:expr, $name:expr, $is_mobile:expr, $($view:ident),* $(,)?) => {
|
||||
match $name.as_ref() {
|
||||
$(
|
||||
stringify!($view) => {
|
||||
$runner.run($view::preview(PreviewConfig { is_mobile: $is_mobile })).await;
|
||||
}
|
||||
)*
|
||||
_ => println!("Component not found."),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let mut name: Option<String> = None;
|
||||
let mut is_mobile: Option<bool> = None;
|
||||
let mut light_mode: bool = false;
|
||||
|
||||
for arg in env::args() {
|
||||
if arg == "--mobile" {
|
||||
is_mobile = Some(true);
|
||||
} else if arg == "--light" {
|
||||
light_mode = true;
|
||||
} else {
|
||||
name = Some(arg);
|
||||
}
|
||||
}
|
||||
|
||||
let name = if let Some(name) = name {
|
||||
name
|
||||
} else {
|
||||
println!("Please specify a component to test");
|
||||
return;
|
||||
};
|
||||
|
||||
println!(
|
||||
"light mode previews: {}",
|
||||
if light_mode { "enabled" } else { "disabled" }
|
||||
);
|
||||
let is_mobile = is_mobile.unwrap_or(notedeck_columns::ui::is_compiled_as_mobile());
|
||||
let runner = PreviewRunner::new(is_mobile, light_mode);
|
||||
|
||||
previews!(
|
||||
runner,
|
||||
name,
|
||||
is_mobile,
|
||||
RelayView,
|
||||
AccountLoginView,
|
||||
ProfilePreview,
|
||||
ProfilePic,
|
||||
AccountsView,
|
||||
DesktopSidePanel,
|
||||
PostView,
|
||||
AddColumnView,
|
||||
ConfigureDeckView,
|
||||
EditDeckView,
|
||||
);
|
||||
}
|
||||
3
crates/notedeck/src/result.rs
Normal file
3
crates/notedeck/src/result.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
use crate::Error;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
167
crates/notedeck/src/storage/file_key_storage.rs
Normal file
167
crates/notedeck/src/storage/file_key_storage.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use crate::Result;
|
||||
use enostr::{Keypair, Pubkey, SerializableKeypair};
|
||||
|
||||
use super::{
|
||||
file_storage::{delete_file, write_file, Directory},
|
||||
key_storage_impl::KeyStorageResponse,
|
||||
};
|
||||
|
||||
static SELECTED_PUBKEY_FILE_NAME: &str = "selected_pubkey";
|
||||
|
||||
/// An OS agnostic file key storage implementation
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct FileKeyStorage {
|
||||
keys_directory: Directory,
|
||||
selected_key_directory: Directory,
|
||||
}
|
||||
|
||||
impl FileKeyStorage {
|
||||
pub fn new(keys_directory: Directory, selected_key_directory: Directory) -> Self {
|
||||
Self {
|
||||
keys_directory,
|
||||
selected_key_directory,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_key_internal(&self, key: &Keypair) -> Result<()> {
|
||||
write_file(
|
||||
&self.keys_directory.file_path,
|
||||
key.pubkey.hex(),
|
||||
&serde_json::to_string(&SerializableKeypair::from_keypair(key, "", 7))?,
|
||||
)
|
||||
}
|
||||
|
||||
fn get_keys_internal(&self) -> Result<Vec<Keypair>> {
|
||||
let keys = self
|
||||
.keys_directory
|
||||
.get_files()?
|
||||
.values()
|
||||
.filter_map(|str_key| serde_json::from_str::<SerializableKeypair>(str_key).ok())
|
||||
.map(|serializable_keypair| serializable_keypair.to_keypair(""))
|
||||
.collect();
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn remove_key_internal(&self, key: &Keypair) -> Result<()> {
|
||||
delete_file(&self.keys_directory.file_path, key.pubkey.hex())
|
||||
}
|
||||
|
||||
fn get_selected_pubkey(&self) -> Result<Option<Pubkey>> {
|
||||
let pubkey_str = self
|
||||
.selected_key_directory
|
||||
.get_file(SELECTED_PUBKEY_FILE_NAME.to_owned())?;
|
||||
|
||||
Ok(serde_json::from_str(&pubkey_str)?)
|
||||
}
|
||||
|
||||
fn select_pubkey(&self, pubkey: Option<Pubkey>) -> Result<()> {
|
||||
if let Some(pubkey) = pubkey {
|
||||
write_file(
|
||||
&self.selected_key_directory.file_path,
|
||||
SELECTED_PUBKEY_FILE_NAME.to_owned(),
|
||||
&serde_json::to_string(&pubkey.hex())?,
|
||||
)
|
||||
} else if self
|
||||
.selected_key_directory
|
||||
.get_file(SELECTED_PUBKEY_FILE_NAME.to_owned())
|
||||
.is_ok()
|
||||
{
|
||||
// Case where user chose to have no selected pubkey, but one already exists
|
||||
Ok(delete_file(
|
||||
&self.selected_key_directory.file_path,
|
||||
SELECTED_PUBKEY_FILE_NAME.to_owned(),
|
||||
)?)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileKeyStorage {
|
||||
pub fn get_keys(&self) -> KeyStorageResponse<Vec<enostr::Keypair>> {
|
||||
KeyStorageResponse::ReceivedResult(self.get_keys_internal())
|
||||
}
|
||||
|
||||
pub fn add_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> {
|
||||
KeyStorageResponse::ReceivedResult(self.add_key_internal(key))
|
||||
}
|
||||
|
||||
pub fn remove_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> {
|
||||
KeyStorageResponse::ReceivedResult(self.remove_key_internal(key))
|
||||
}
|
||||
|
||||
pub fn get_selected_key(&self) -> KeyStorageResponse<Option<Pubkey>> {
|
||||
KeyStorageResponse::ReceivedResult(self.get_selected_pubkey())
|
||||
}
|
||||
|
||||
pub fn select_key(&self, key: Option<Pubkey>) -> KeyStorageResponse<()> {
|
||||
KeyStorageResponse::ReceivedResult(self.select_pubkey(key))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::Result;
|
||||
use super::*;
|
||||
|
||||
use enostr::Keypair;
|
||||
static CREATE_TMP_DIR: fn() -> Result<PathBuf> =
|
||||
|| Ok(tempfile::TempDir::new()?.path().to_path_buf());
|
||||
|
||||
impl FileKeyStorage {
|
||||
fn mock() -> Result<Self> {
|
||||
Ok(Self {
|
||||
keys_directory: Directory::new(CREATE_TMP_DIR()?),
|
||||
selected_key_directory: Directory::new(CREATE_TMP_DIR()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic() {
|
||||
let kp = enostr::FullKeypair::generate().to_keypair();
|
||||
let storage = FileKeyStorage::mock().unwrap();
|
||||
let resp = storage.add_key(&kp);
|
||||
|
||||
assert_eq!(resp, KeyStorageResponse::ReceivedResult(Ok(())));
|
||||
assert_num_storage(&storage.get_keys(), 1);
|
||||
|
||||
assert_eq!(
|
||||
storage.remove_key(&kp),
|
||||
KeyStorageResponse::ReceivedResult(Ok(()))
|
||||
);
|
||||
assert_num_storage(&storage.get_keys(), 0);
|
||||
}
|
||||
|
||||
fn assert_num_storage(keys_response: &KeyStorageResponse<Vec<Keypair>>, n: usize) {
|
||||
match keys_response {
|
||||
KeyStorageResponse::ReceivedResult(Ok(keys)) => {
|
||||
assert_eq!(keys.len(), n);
|
||||
}
|
||||
KeyStorageResponse::ReceivedResult(Err(_e)) => {
|
||||
panic!("could not get keys");
|
||||
}
|
||||
KeyStorageResponse::Waiting => {
|
||||
panic!("did not receive result");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_key() {
|
||||
let kp = enostr::FullKeypair::generate().to_keypair();
|
||||
|
||||
let storage = FileKeyStorage::mock().unwrap();
|
||||
let _ = storage.add_key(&kp);
|
||||
assert_num_storage(&storage.get_keys(), 1);
|
||||
|
||||
let resp = storage.select_pubkey(Some(kp.pubkey));
|
||||
assert!(resp.is_ok());
|
||||
|
||||
let resp = storage.get_selected_pubkey();
|
||||
|
||||
assert!(resp.is_ok());
|
||||
}
|
||||
}
|
||||
271
crates/notedeck/src/storage/file_storage.rs
Normal file
271
crates/notedeck/src/storage/file_storage.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
fs::{self, File},
|
||||
io::{self, BufRead},
|
||||
path::{Path, PathBuf},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataPath {
|
||||
base: PathBuf,
|
||||
}
|
||||
|
||||
impl DataPath {
|
||||
pub fn new(base: impl AsRef<Path>) -> Self {
|
||||
let base = base.as_ref().to_path_buf();
|
||||
Self { base }
|
||||
}
|
||||
|
||||
pub fn default_base() -> Option<PathBuf> {
|
||||
dirs::data_local_dir().map(|pb| pb.join("notedeck"))
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DataPathType {
|
||||
Log,
|
||||
Setting,
|
||||
Keys,
|
||||
SelectedKey,
|
||||
Db,
|
||||
Cache,
|
||||
}
|
||||
|
||||
impl DataPath {
|
||||
pub fn rel_path(&self, typ: DataPathType) -> PathBuf {
|
||||
match typ {
|
||||
DataPathType::Log => PathBuf::from("logs"),
|
||||
DataPathType::Setting => PathBuf::from("settings"),
|
||||
DataPathType::Keys => PathBuf::from("storage").join("accounts"),
|
||||
DataPathType::SelectedKey => PathBuf::from("storage").join("selected_account"),
|
||||
DataPathType::Db => PathBuf::from("db"),
|
||||
DataPathType::Cache => PathBuf::from("cache"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self, typ: DataPathType) -> PathBuf {
|
||||
self.base.join(self.rel_path(typ))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Directory {
|
||||
pub file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Directory {
|
||||
pub fn new(file_path: PathBuf) -> Self {
|
||||
Self { file_path }
|
||||
}
|
||||
|
||||
/// Get the files in the current directory where the key is the file name and the value is the file contents
|
||||
pub fn get_files(&self) -> Result<HashMap<String, String>> {
|
||||
let dir = fs::read_dir(self.file_path.clone())?;
|
||||
let map = dir
|
||||
.filter_map(|f| f.ok())
|
||||
.filter(|f| f.path().is_file())
|
||||
.filter_map(|f| {
|
||||
let file_name = f.file_name().into_string().ok()?;
|
||||
let contents = fs::read_to_string(f.path()).ok()?;
|
||||
Some((file_name, contents))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub fn get_file_names(&self) -> Result<Vec<String>> {
|
||||
let dir = fs::read_dir(self.file_path.clone())?;
|
||||
let names = dir
|
||||
.filter_map(|f| f.ok())
|
||||
.filter(|f| f.path().is_file())
|
||||
.filter_map(|f| f.file_name().into_string().ok())
|
||||
.collect();
|
||||
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
pub fn get_file(&self, file_name: String) -> Result<String> {
|
||||
let filepath = self.file_path.clone().join(file_name.clone());
|
||||
|
||||
if filepath.exists() && filepath.is_file() {
|
||||
let filepath_str = filepath
|
||||
.to_str()
|
||||
.ok_or_else(|| Error::Generic("Could not turn path to string".to_owned()))?;
|
||||
Ok(fs::read_to_string(filepath_str)?)
|
||||
} else {
|
||||
Err(Error::Generic(format!(
|
||||
"Requested file was not found: {}",
|
||||
file_name
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_file_last_n_lines(&self, file_name: String, n: usize) -> Result<FileResult> {
|
||||
let filepath = self.file_path.clone().join(file_name.clone());
|
||||
|
||||
if filepath.exists() && filepath.is_file() {
|
||||
let file = File::open(&filepath)?;
|
||||
let reader = io::BufReader::new(file);
|
||||
|
||||
let mut queue: VecDeque<String> = VecDeque::with_capacity(n);
|
||||
|
||||
let mut total_lines_in_file = 0;
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
|
||||
queue.push_back(line);
|
||||
|
||||
if queue.len() > n {
|
||||
queue.pop_front();
|
||||
}
|
||||
total_lines_in_file += 1;
|
||||
}
|
||||
|
||||
let output_num_lines = queue.len();
|
||||
let output = queue.into_iter().collect::<Vec<String>>().join("\n");
|
||||
Ok(FileResult {
|
||||
output,
|
||||
output_num_lines,
|
||||
total_lines_in_file,
|
||||
})
|
||||
} else {
|
||||
Err(Error::Generic(format!(
|
||||
"Requested file was not found: {}",
|
||||
file_name
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the file name which is most recently modified in the directory
|
||||
pub fn get_most_recent(&self) -> Result<Option<String>> {
|
||||
let mut most_recent: Option<(SystemTime, String)> = None;
|
||||
|
||||
for entry in fs::read_dir(&self.file_path)? {
|
||||
let entry = entry?;
|
||||
let metadata = entry.metadata()?;
|
||||
if metadata.is_file() {
|
||||
let modified = metadata.modified()?;
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
match most_recent {
|
||||
Some((last_modified, _)) if modified > last_modified => {
|
||||
most_recent = Some((modified, file_name));
|
||||
}
|
||||
None => {
|
||||
most_recent = Some((modified, file_name));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(most_recent.map(|(_, file_name)| file_name))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FileResult {
|
||||
pub output: String,
|
||||
pub output_num_lines: usize,
|
||||
pub total_lines_in_file: usize,
|
||||
}
|
||||
|
||||
/// Write the file to the directory
|
||||
pub fn write_file(directory: &Path, file_name: String, data: &str) -> Result<()> {
|
||||
if !directory.exists() {
|
||||
fs::create_dir_all(directory)?
|
||||
}
|
||||
|
||||
std::fs::write(directory.join(file_name), data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_file(directory: &Path, file_name: String) -> Result<()> {
|
||||
let file_to_delete = directory.join(file_name.clone());
|
||||
if file_to_delete.exists() && file_to_delete.is_file() {
|
||||
fs::remove_file(file_to_delete).map_err(Error::Io)
|
||||
} else {
|
||||
Err(Error::Generic(format!(
|
||||
"Requested file to delete was not found: {}",
|
||||
file_name
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{
|
||||
storage::file_storage::{delete_file, write_file},
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::Directory;
|
||||
|
||||
static CREATE_TMP_DIR: fn() -> Result<PathBuf> =
|
||||
|| Ok(tempfile::TempDir::new()?.path().to_path_buf());
|
||||
|
||||
#[test]
|
||||
fn test_add_get_delete() {
|
||||
if let Ok(path) = CREATE_TMP_DIR() {
|
||||
let directory = Directory::new(path);
|
||||
let file_name = "file_test_name.txt".to_string();
|
||||
let file_contents = "test";
|
||||
let write_res = write_file(&directory.file_path, file_name.clone(), file_contents);
|
||||
assert!(write_res.is_ok());
|
||||
|
||||
if let Ok(asserted_file_contents) = directory.get_file(file_name.clone()) {
|
||||
assert_eq!(asserted_file_contents, file_contents);
|
||||
} else {
|
||||
panic!("File not found");
|
||||
}
|
||||
|
||||
let delete_res = delete_file(&directory.file_path, file_name);
|
||||
assert!(delete_res.is_ok());
|
||||
} else {
|
||||
panic!("could not get interactor")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_multiple() {
|
||||
if let Ok(path) = CREATE_TMP_DIR() {
|
||||
let directory = Directory::new(path);
|
||||
|
||||
for i in 0..10 {
|
||||
let file_name = format!("file{}.txt", i);
|
||||
let write_res = write_file(&directory.file_path, file_name, "test");
|
||||
assert!(write_res.is_ok());
|
||||
}
|
||||
|
||||
if let Ok(files) = directory.get_files() {
|
||||
for i in 0..10 {
|
||||
let file_name = format!("file{}.txt", i);
|
||||
assert!(files.contains_key(&file_name));
|
||||
assert_eq!(files.get(&file_name).unwrap(), "test");
|
||||
}
|
||||
} else {
|
||||
panic!("Files not found");
|
||||
}
|
||||
|
||||
if let Ok(file_names) = directory.get_file_names() {
|
||||
for i in 0..10 {
|
||||
let file_name = format!("file{}.txt", i);
|
||||
assert!(file_names.contains(&file_name));
|
||||
}
|
||||
} else {
|
||||
panic!("File names not found");
|
||||
}
|
||||
|
||||
for i in 0..10 {
|
||||
let file_name = format!("file{}.txt", i);
|
||||
assert!(delete_file(&directory.file_path, file_name).is_ok());
|
||||
}
|
||||
} else {
|
||||
panic!("could not get interactor")
|
||||
}
|
||||
}
|
||||
}
|
||||
88
crates/notedeck/src/storage/key_storage_impl.rs
Normal file
88
crates/notedeck/src/storage/key_storage_impl.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use enostr::{Keypair, Pubkey};
|
||||
|
||||
use super::file_key_storage::FileKeyStorage;
|
||||
use crate::Result;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::security_framework_key_storage::SecurityFrameworkKeyStorage;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum KeyStorageType {
|
||||
None,
|
||||
FileSystem(FileKeyStorage),
|
||||
#[cfg(target_os = "macos")]
|
||||
SecurityFramework(SecurityFrameworkKeyStorage),
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub enum KeyStorageResponse<R> {
|
||||
Waiting,
|
||||
ReceivedResult(Result<R>),
|
||||
}
|
||||
|
||||
impl<R: PartialEq> PartialEq for KeyStorageResponse<R> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(KeyStorageResponse::Waiting, KeyStorageResponse::Waiting) => true,
|
||||
(
|
||||
KeyStorageResponse::ReceivedResult(Ok(r1)),
|
||||
KeyStorageResponse::ReceivedResult(Ok(r2)),
|
||||
) => r1 == r2,
|
||||
(
|
||||
KeyStorageResponse::ReceivedResult(Err(_)),
|
||||
KeyStorageResponse::ReceivedResult(Err(_)),
|
||||
) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyStorageType {
|
||||
pub fn get_keys(&self) -> KeyStorageResponse<Vec<Keypair>> {
|
||||
match self {
|
||||
Self::None => KeyStorageResponse::ReceivedResult(Ok(Vec::new())),
|
||||
Self::FileSystem(f) => f.get_keys(),
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::SecurityFramework(f) => f.get_keys(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> {
|
||||
let _ = key;
|
||||
match self {
|
||||
Self::None => KeyStorageResponse::ReceivedResult(Ok(())),
|
||||
Self::FileSystem(f) => f.add_key(key),
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::SecurityFramework(f) => f.add_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> {
|
||||
let _ = key;
|
||||
match self {
|
||||
Self::None => KeyStorageResponse::ReceivedResult(Ok(())),
|
||||
Self::FileSystem(f) => f.remove_key(key),
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::SecurityFramework(f) => f.remove_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_selected_key(&self) -> KeyStorageResponse<Option<Pubkey>> {
|
||||
match self {
|
||||
Self::None => KeyStorageResponse::ReceivedResult(Ok(None)),
|
||||
Self::FileSystem(f) => f.get_selected_key(),
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::SecurityFramework(_) => unimplemented!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_key(&self, key: Option<Pubkey>) -> KeyStorageResponse<()> {
|
||||
match self {
|
||||
Self::None => KeyStorageResponse::ReceivedResult(Ok(())),
|
||||
Self::FileSystem(f) => f.select_key(key),
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::SecurityFramework(_) => unimplemented!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
11
crates/notedeck/src/storage/mod.rs
Normal file
11
crates/notedeck/src/storage/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod file_key_storage;
|
||||
mod file_storage;
|
||||
|
||||
pub use file_key_storage::FileKeyStorage;
|
||||
pub use file_storage::{delete_file, write_file, DataPath, DataPathType, Directory};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod security_framework_key_storage;
|
||||
|
||||
pub mod key_storage_impl;
|
||||
pub use key_storage_impl::{KeyStorageResponse, KeyStorageType};
|
||||
198
crates/notedeck/src/storage/security_framework_key_storage.rs
Normal file
198
crates/notedeck/src/storage/security_framework_key_storage.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use enostr::{Keypair, Pubkey, SecretKey};
|
||||
use security_framework::{
|
||||
item::{ItemClass, ItemSearchOptions, Limit, SearchResult},
|
||||
passwords::{delete_generic_password, set_generic_password},
|
||||
};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
use super::KeyStorageResponse;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct SecurityFrameworkKeyStorage {
|
||||
pub service_name: Cow<'static, str>,
|
||||
}
|
||||
|
||||
impl SecurityFrameworkKeyStorage {
|
||||
pub fn new(service_name: String) -> Self {
|
||||
SecurityFrameworkKeyStorage {
|
||||
service_name: Cow::Owned(service_name),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_key_internal(&self, key: &Keypair) -> Result<()> {
|
||||
match set_generic_password(
|
||||
&self.service_name,
|
||||
key.pubkey.hex().as_str(),
|
||||
key.secret_key
|
||||
.as_ref()
|
||||
.map_or_else(|| &[] as &[u8], |sc| sc.as_secret_bytes()),
|
||||
) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(Error::Generic(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_pubkey_strings(&self) -> Vec<String> {
|
||||
let search_results = ItemSearchOptions::new()
|
||||
.class(ItemClass::generic_password())
|
||||
.service(&self.service_name)
|
||||
.load_attributes(true)
|
||||
.limit(Limit::All)
|
||||
.search();
|
||||
|
||||
let mut accounts = Vec::new();
|
||||
|
||||
if let Ok(search_results) = search_results {
|
||||
for result in search_results {
|
||||
if let Some(map) = result.simplify_dict() {
|
||||
if let Some(val) = map.get("acct") {
|
||||
accounts.push(val.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accounts
|
||||
}
|
||||
|
||||
fn get_pubkeys(&self) -> Vec<Pubkey> {
|
||||
self.get_pubkey_strings()
|
||||
.iter_mut()
|
||||
.filter_map(|pubkey_str| Pubkey::from_hex(pubkey_str.as_str()).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_privkey_bytes_for(&self, account: &str) -> Option<Vec<u8>> {
|
||||
let search_result = ItemSearchOptions::new()
|
||||
.class(ItemClass::generic_password())
|
||||
.service(&self.service_name)
|
||||
.load_data(true)
|
||||
.account(account)
|
||||
.search();
|
||||
|
||||
if let Ok(results) = search_result {
|
||||
if let Some(SearchResult::Data(vec)) = results.first() {
|
||||
return Some(vec.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn get_secret_key_for_pubkey(&self, pubkey: &Pubkey) -> Option<SecretKey> {
|
||||
if let Some(bytes) = self.get_privkey_bytes_for(pubkey.hex().as_str()) {
|
||||
SecretKey::from_slice(bytes.as_slice()).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_all_keypairs(&self) -> Vec<Keypair> {
|
||||
self.get_pubkeys()
|
||||
.iter()
|
||||
.map(|pubkey| {
|
||||
let maybe_secret = self.get_secret_key_for_pubkey(pubkey);
|
||||
Keypair::new(*pubkey, maybe_secret)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn delete_key(&self, pubkey: &Pubkey) -> Result<()> {
|
||||
match delete_generic_password(&self.service_name, pubkey.hex().as_str()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
error!("delete key error {}", e);
|
||||
Err(Error::Generic(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecurityFrameworkKeyStorage {
|
||||
pub fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> {
|
||||
KeyStorageResponse::ReceivedResult(self.add_key_internal(key))
|
||||
}
|
||||
|
||||
pub fn get_keys(&self) -> KeyStorageResponse<Vec<Keypair>> {
|
||||
KeyStorageResponse::ReceivedResult(Ok(self.get_all_keypairs()))
|
||||
}
|
||||
|
||||
pub fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> {
|
||||
KeyStorageResponse::ReceivedResult(self.delete_key(&key.pubkey))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use enostr::FullKeypair;
|
||||
|
||||
static TEST_SERVICE_NAME: &str = "NOTEDECKTEST";
|
||||
static STORAGE: SecurityFrameworkKeyStorage = SecurityFrameworkKeyStorage {
|
||||
service_name: Cow::Borrowed(TEST_SERVICE_NAME),
|
||||
};
|
||||
|
||||
// individual tests are ignored so test runner doesn't run them all concurrently
|
||||
// TODO: a way to run them all serially should be devised
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn add_and_remove_test_pubkey_only() {
|
||||
let num_keys_before_test = STORAGE.get_pubkeys().len();
|
||||
|
||||
let keypair = FullKeypair::generate().to_keypair();
|
||||
let add_result = STORAGE.add_key_internal(&keypair);
|
||||
assert!(add_result.is_ok());
|
||||
|
||||
let get_pubkeys_result = STORAGE.get_pubkeys();
|
||||
assert_eq!(get_pubkeys_result.len() - num_keys_before_test, 1);
|
||||
|
||||
let remove_result = STORAGE.delete_key(&keypair.pubkey);
|
||||
assert!(remove_result.is_ok());
|
||||
|
||||
let keys = STORAGE.get_pubkeys();
|
||||
assert_eq!(keys.len() - num_keys_before_test, 0);
|
||||
}
|
||||
|
||||
fn add_and_remove_full_n(n: usize) {
|
||||
let num_keys_before_test = STORAGE.get_all_keypairs().len();
|
||||
// there must be zero keys in storage for the test to work as intended
|
||||
assert_eq!(num_keys_before_test, 0);
|
||||
|
||||
let expected_keypairs: Vec<Keypair> = (0..n)
|
||||
.map(|_| FullKeypair::generate().to_keypair())
|
||||
.collect();
|
||||
|
||||
expected_keypairs.iter().for_each(|keypair| {
|
||||
let add_result = STORAGE.add_key_internal(keypair);
|
||||
assert!(add_result.is_ok());
|
||||
});
|
||||
|
||||
let asserted_keypairs = STORAGE.get_all_keypairs();
|
||||
assert_eq!(expected_keypairs, asserted_keypairs);
|
||||
|
||||
expected_keypairs.iter().for_each(|keypair| {
|
||||
let remove_result = STORAGE.delete_key(&keypair.pubkey);
|
||||
assert!(remove_result.is_ok());
|
||||
});
|
||||
|
||||
let num_keys_after_test = STORAGE.get_all_keypairs().len();
|
||||
assert_eq!(num_keys_after_test, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn add_and_remove_full() {
|
||||
add_and_remove_full_n(1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn add_and_remove_full_10() {
|
||||
add_and_remove_full_n(10);
|
||||
}
|
||||
}
|
||||
46
crates/notedeck/src/style.rs
Normal file
46
crates/notedeck/src/style.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use egui::{FontFamily, TextStyle};
|
||||
|
||||
use strum_macros::EnumIter;
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug, EnumIter)]
|
||||
pub enum NotedeckTextStyle {
|
||||
Heading,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Body,
|
||||
Monospace,
|
||||
Button,
|
||||
Small,
|
||||
Tiny,
|
||||
}
|
||||
|
||||
impl NotedeckTextStyle {
|
||||
pub fn text_style(&self) -> TextStyle {
|
||||
match self {
|
||||
Self::Heading => TextStyle::Heading,
|
||||
Self::Heading2 => TextStyle::Name("Heading2".into()),
|
||||
Self::Heading3 => TextStyle::Name("Heading3".into()),
|
||||
Self::Heading4 => TextStyle::Name("Heading4".into()),
|
||||
Self::Body => TextStyle::Body,
|
||||
Self::Monospace => TextStyle::Monospace,
|
||||
Self::Button => TextStyle::Button,
|
||||
Self::Small => TextStyle::Small,
|
||||
Self::Tiny => TextStyle::Name("Tiny".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn font_family(&self) -> FontFamily {
|
||||
match self {
|
||||
Self::Heading => FontFamily::Proportional,
|
||||
Self::Heading2 => FontFamily::Proportional,
|
||||
Self::Heading3 => FontFamily::Proportional,
|
||||
Self::Heading4 => FontFamily::Proportional,
|
||||
Self::Body => FontFamily::Proportional,
|
||||
Self::Monospace => FontFamily::Monospace,
|
||||
Self::Button => FontFamily::Proportional,
|
||||
Self::Small => FontFamily::Proportional,
|
||||
Self::Tiny => FontFamily::Proportional,
|
||||
}
|
||||
}
|
||||
}
|
||||
101
crates/notedeck/src/theme.rs
Normal file
101
crates/notedeck/src/theme.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use egui::{
|
||||
style::{Selection, WidgetVisuals, Widgets},
|
||||
Color32, Rounding, Shadow, Stroke, Visuals,
|
||||
};
|
||||
|
||||
pub struct ColorTheme {
|
||||
// VISUALS
|
||||
pub panel_fill: Color32,
|
||||
pub extreme_bg_color: Color32,
|
||||
pub text_color: Color32,
|
||||
pub err_fg_color: Color32,
|
||||
pub warn_fg_color: Color32,
|
||||
pub hyperlink_color: Color32,
|
||||
pub selection_color: Color32,
|
||||
|
||||
// WINDOW
|
||||
pub window_fill: Color32,
|
||||
pub window_stroke_color: Color32,
|
||||
|
||||
// NONINTERACTIVE WIDGET
|
||||
pub noninteractive_bg_fill: Color32,
|
||||
pub noninteractive_weak_bg_fill: Color32,
|
||||
pub noninteractive_bg_stroke_color: Color32,
|
||||
pub noninteractive_fg_stroke_color: Color32,
|
||||
|
||||
// INACTIVE WIDGET
|
||||
pub inactive_bg_stroke_color: Color32,
|
||||
pub inactive_bg_fill: Color32,
|
||||
pub inactive_weak_bg_fill: Color32,
|
||||
}
|
||||
|
||||
const WIDGET_ROUNDING: Rounding = Rounding::same(8.0);
|
||||
|
||||
pub fn create_themed_visuals(theme: ColorTheme, default: Visuals) -> Visuals {
|
||||
Visuals {
|
||||
hyperlink_color: theme.hyperlink_color,
|
||||
override_text_color: Some(theme.text_color),
|
||||
panel_fill: theme.panel_fill,
|
||||
selection: Selection {
|
||||
bg_fill: theme.selection_color,
|
||||
stroke: Stroke {
|
||||
width: 1.0,
|
||||
color: theme.selection_color,
|
||||
},
|
||||
},
|
||||
warn_fg_color: theme.warn_fg_color,
|
||||
widgets: Widgets {
|
||||
noninteractive: WidgetVisuals {
|
||||
bg_fill: theme.noninteractive_bg_fill,
|
||||
weak_bg_fill: theme.noninteractive_weak_bg_fill,
|
||||
bg_stroke: Stroke {
|
||||
width: 1.0,
|
||||
color: theme.noninteractive_bg_stroke_color,
|
||||
},
|
||||
fg_stroke: Stroke {
|
||||
width: 1.0,
|
||||
color: theme.noninteractive_fg_stroke_color,
|
||||
},
|
||||
rounding: WIDGET_ROUNDING,
|
||||
..default.widgets.noninteractive
|
||||
},
|
||||
inactive: WidgetVisuals {
|
||||
bg_fill: theme.inactive_bg_fill,
|
||||
weak_bg_fill: theme.inactive_weak_bg_fill,
|
||||
bg_stroke: Stroke {
|
||||
width: 1.0,
|
||||
color: theme.inactive_bg_stroke_color,
|
||||
},
|
||||
rounding: WIDGET_ROUNDING,
|
||||
..default.widgets.inactive
|
||||
},
|
||||
hovered: WidgetVisuals {
|
||||
rounding: WIDGET_ROUNDING,
|
||||
..default.widgets.hovered
|
||||
},
|
||||
active: WidgetVisuals {
|
||||
rounding: WIDGET_ROUNDING,
|
||||
..default.widgets.active
|
||||
},
|
||||
open: WidgetVisuals {
|
||||
..default.widgets.open
|
||||
},
|
||||
},
|
||||
extreme_bg_color: theme.extreme_bg_color,
|
||||
error_fg_color: theme.err_fg_color,
|
||||
window_rounding: Rounding::same(8.0),
|
||||
window_fill: theme.window_fill,
|
||||
window_shadow: Shadow {
|
||||
offset: [0.0, 8.0].into(),
|
||||
blur: 24.0,
|
||||
spread: 0.0,
|
||||
color: egui::Color32::from_rgba_unmultiplied(0x6D, 0x6D, 0x6D, 0x14),
|
||||
},
|
||||
window_stroke: Stroke {
|
||||
width: 1.0,
|
||||
color: theme.window_stroke_color,
|
||||
},
|
||||
image_loading_spinners: false,
|
||||
..default
|
||||
}
|
||||
}
|
||||
76
crates/notedeck/src/theme_handler.rs
Normal file
76
crates/notedeck/src/theme_handler.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use egui::ThemePreference;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{storage, DataPath, DataPathType, Directory};
|
||||
|
||||
pub struct ThemeHandler {
|
||||
directory: Directory,
|
||||
fallback_theme: ThemePreference,
|
||||
}
|
||||
|
||||
const THEME_FILE: &str = "theme.txt";
|
||||
|
||||
impl ThemeHandler {
|
||||
pub fn new(path: &DataPath) -> Self {
|
||||
let directory = Directory::new(path.path(DataPathType::Setting));
|
||||
let fallback_theme = ThemePreference::Dark;
|
||||
Self {
|
||||
directory,
|
||||
fallback_theme,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&self) -> ThemePreference {
|
||||
match self.directory.get_file(THEME_FILE.to_owned()) {
|
||||
Ok(contents) => match deserialize_theme(contents) {
|
||||
Some(theme) => theme,
|
||||
None => {
|
||||
error!(
|
||||
"Could not deserialize theme. Using fallback {:?} instead",
|
||||
self.fallback_theme
|
||||
);
|
||||
self.fallback_theme
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Could not read {} file: {:?}\nUsing fallback {:?} instead",
|
||||
THEME_FILE, e, self.fallback_theme
|
||||
);
|
||||
self.fallback_theme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, theme: ThemePreference) {
|
||||
match storage::write_file(
|
||||
&self.directory.file_path,
|
||||
THEME_FILE.to_owned(),
|
||||
&theme_to_serialized(&theme),
|
||||
) {
|
||||
Ok(_) => info!(
|
||||
"Successfully saved {:?} theme change to {}",
|
||||
theme, THEME_FILE
|
||||
),
|
||||
Err(_) => error!("Could not save {:?} theme change to {}", theme, THEME_FILE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_to_serialized(theme: &ThemePreference) -> String {
|
||||
match theme {
|
||||
ThemePreference::Dark => "dark",
|
||||
ThemePreference::Light => "light",
|
||||
ThemePreference::System => "system",
|
||||
}
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn deserialize_theme(serialized_theme: String) -> Option<ThemePreference> {
|
||||
match serialized_theme.as_str() {
|
||||
"dark" => Some(ThemePreference::Dark),
|
||||
"light" => Some(ThemePreference::Light),
|
||||
"system" => Some(ThemePreference::System),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
55
crates/notedeck/src/time.rs
Normal file
55
crates/notedeck/src/time.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub fn time_ago_since(timestamp: u64) -> String {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs();
|
||||
|
||||
// Determine if the timestamp is in the future or the past
|
||||
let duration = if now >= timestamp {
|
||||
now.saturating_sub(timestamp)
|
||||
} else {
|
||||
timestamp.saturating_sub(now)
|
||||
};
|
||||
|
||||
let future = timestamp > now;
|
||||
let relstr = if future { "+" } else { "" };
|
||||
|
||||
let years = duration / 31_536_000; // seconds in a year
|
||||
if years >= 1 {
|
||||
return format!("{}{}yr", relstr, years);
|
||||
}
|
||||
|
||||
let months = duration / 2_592_000; // seconds in a month (30.44 days)
|
||||
if months >= 1 {
|
||||
return format!("{}{}mth", relstr, months);
|
||||
}
|
||||
|
||||
let weeks = duration / 604_800; // seconds in a week
|
||||
if weeks >= 1 {
|
||||
return format!("{}{}wk", relstr, weeks);
|
||||
}
|
||||
|
||||
let days = duration / 86_400; // seconds in a day
|
||||
if days >= 1 {
|
||||
return format!("{}{}d", relstr, days);
|
||||
}
|
||||
|
||||
let hours = duration / 3600; // seconds in an hour
|
||||
if hours >= 1 {
|
||||
return format!("{}{}h", relstr, hours);
|
||||
}
|
||||
|
||||
let minutes = duration / 60; // seconds in a minute
|
||||
if minutes >= 1 {
|
||||
return format!("{}{}m", relstr, minutes);
|
||||
}
|
||||
|
||||
let seconds = duration;
|
||||
if seconds >= 3 {
|
||||
return format!("{}{}s", relstr, seconds);
|
||||
}
|
||||
|
||||
"now".to_string()
|
||||
}
|
||||
41
crates/notedeck/src/timecache.rs
Normal file
41
crates/notedeck/src/timecache.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TimeCached<T> {
|
||||
last_update: Instant,
|
||||
expires_in: Duration,
|
||||
value: Option<T>,
|
||||
refresh: Rc<dyn Fn() -> T + 'static>,
|
||||
}
|
||||
|
||||
impl<T> TimeCached<T> {
|
||||
pub fn new(expires_in: Duration, refresh: impl Fn() -> T + 'static) -> Self {
|
||||
TimeCached {
|
||||
last_update: Instant::now(),
|
||||
expires_in,
|
||||
value: None,
|
||||
refresh: Rc::new(refresh),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_update(&self) -> bool {
|
||||
self.value.is_none() || self.last_update.elapsed() > self.expires_in
|
||||
}
|
||||
|
||||
pub fn update(&mut self) {
|
||||
self.last_update = Instant::now();
|
||||
self.value = Some((self.refresh)());
|
||||
}
|
||||
|
||||
pub fn get(&self) -> Option<&T> {
|
||||
self.value.as_ref()
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self) -> &T {
|
||||
if self.needs_update() {
|
||||
self.update();
|
||||
}
|
||||
self.value.as_ref().unwrap() // This unwrap is safe because we just set the value if it was None.
|
||||
}
|
||||
}
|
||||
24
crates/notedeck/src/ui.rs
Normal file
24
crates/notedeck/src/ui.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
/// Determine if the screen is narrow. This is useful for detecting mobile
|
||||
/// contexts, but with the nuance that we may also have a wide android tablet.
|
||||
pub fn is_narrow(ctx: &egui::Context) -> bool {
|
||||
let screen_size = ctx.input(|c| c.screen_rect().size());
|
||||
screen_size.x < 550.0
|
||||
}
|
||||
|
||||
pub fn is_oled() -> bool {
|
||||
is_compiled_as_mobile()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[allow(unreachable_code)]
|
||||
pub fn is_compiled_as_mobile() -> bool {
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
356
crates/notedeck/src/unknowns.rs
Normal file
356
crates/notedeck/src/unknowns.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
use crate::{
|
||||
note::NoteRef,
|
||||
notecache::{CachedNote, NoteCache},
|
||||
Result,
|
||||
};
|
||||
|
||||
use enostr::{Filter, NoteId, Pubkey};
|
||||
use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction};
|
||||
use std::collections::HashSet;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::error;
|
||||
|
||||
#[must_use = "process_action should be used on this result"]
|
||||
pub enum SingleUnkIdAction {
|
||||
NoAction,
|
||||
NeedsProcess(UnknownId),
|
||||
}
|
||||
|
||||
#[must_use = "process_action should be used on this result"]
|
||||
pub enum NoteRefsUnkIdAction {
|
||||
NoAction,
|
||||
NeedsProcess(Vec<NoteRef>),
|
||||
}
|
||||
|
||||
impl NoteRefsUnkIdAction {
|
||||
pub fn new(refs: Vec<NoteRef>) -> Self {
|
||||
NoteRefsUnkIdAction::NeedsProcess(refs)
|
||||
}
|
||||
|
||||
pub fn no_action() -> Self {
|
||||
Self::NoAction
|
||||
}
|
||||
|
||||
pub fn process_action(
|
||||
&self,
|
||||
txn: &Transaction,
|
||||
ndb: &Ndb,
|
||||
unk_ids: &mut UnknownIds,
|
||||
note_cache: &mut NoteCache,
|
||||
) {
|
||||
match self {
|
||||
Self::NoAction => {}
|
||||
Self::NeedsProcess(refs) => {
|
||||
UnknownIds::update_from_note_refs(txn, ndb, unk_ids, note_cache, refs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SingleUnkIdAction {
|
||||
pub fn new(id: UnknownId) -> Self {
|
||||
SingleUnkIdAction::NeedsProcess(id)
|
||||
}
|
||||
|
||||
pub fn no_action() -> Self {
|
||||
Self::NoAction
|
||||
}
|
||||
|
||||
pub fn pubkey(pubkey: Pubkey) -> Self {
|
||||
SingleUnkIdAction::new(UnknownId::Pubkey(pubkey))
|
||||
}
|
||||
|
||||
pub fn note_id(note_id: NoteId) -> Self {
|
||||
SingleUnkIdAction::new(UnknownId::Id(note_id))
|
||||
}
|
||||
|
||||
/// Some functions may return unknown id actions that need to be processed.
|
||||
/// For example, when we add a new account we need to make sure we have the
|
||||
/// profile for that account. This function ensures we add this to the
|
||||
/// unknown id tracker without adding side effects to functions.
|
||||
pub fn process_action(&self, ids: &mut UnknownIds, ndb: &Ndb, txn: &Transaction) {
|
||||
match self {
|
||||
Self::NeedsProcess(id) => {
|
||||
ids.add_unknown_id_if_missing(ndb, txn, id);
|
||||
}
|
||||
Self::NoAction => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown Id searcher
|
||||
#[derive(Default)]
|
||||
pub struct UnknownIds {
|
||||
ids: HashSet<UnknownId>,
|
||||
first_updated: Option<Instant>,
|
||||
last_updated: Option<Instant>,
|
||||
}
|
||||
|
||||
impl UnknownIds {
|
||||
/// Simple debouncer
|
||||
pub fn ready_to_send(&self) -> bool {
|
||||
if self.ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// we trigger on first set
|
||||
if self.first_updated == self.last_updated {
|
||||
return true;
|
||||
}
|
||||
|
||||
let last_updated = if let Some(last) = self.last_updated {
|
||||
last
|
||||
} else {
|
||||
// if we've
|
||||
return true;
|
||||
};
|
||||
|
||||
Instant::now() - last_updated >= Duration::from_secs(2)
|
||||
}
|
||||
|
||||
pub fn ids(&self) -> &HashSet<UnknownId> {
|
||||
&self.ids
|
||||
}
|
||||
|
||||
pub fn ids_mut(&mut self) -> &mut HashSet<UnknownId> {
|
||||
&mut self.ids
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.ids = HashSet::default();
|
||||
}
|
||||
|
||||
pub fn filter(&self) -> Option<Vec<Filter>> {
|
||||
let ids: Vec<&UnknownId> = self.ids.iter().collect();
|
||||
get_unknown_ids_filter(&ids)
|
||||
}
|
||||
|
||||
/// We've updated some unknown ids, update the last_updated time to now
|
||||
pub fn mark_updated(&mut self) {
|
||||
let now = Instant::now();
|
||||
if self.first_updated.is_none() {
|
||||
self.first_updated = Some(now);
|
||||
}
|
||||
self.last_updated = Some(now);
|
||||
}
|
||||
|
||||
pub fn update_from_note_key(
|
||||
txn: &Transaction,
|
||||
ndb: &Ndb,
|
||||
unknown_ids: &mut UnknownIds,
|
||||
note_cache: &mut NoteCache,
|
||||
key: NoteKey,
|
||||
) -> bool {
|
||||
let note = if let Ok(note) = ndb.get_note_by_key(txn, key) {
|
||||
note
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
|
||||
UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, ¬e)
|
||||
}
|
||||
|
||||
/// Should be called on freshly polled notes from subscriptions
|
||||
pub fn update_from_note_refs(
|
||||
txn: &Transaction,
|
||||
ndb: &Ndb,
|
||||
unknown_ids: &mut UnknownIds,
|
||||
note_cache: &mut NoteCache,
|
||||
note_refs: &[NoteRef],
|
||||
) {
|
||||
for note_ref in note_refs {
|
||||
Self::update_from_note_key(txn, ndb, unknown_ids, note_cache, note_ref.key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_from_note(
|
||||
txn: &Transaction,
|
||||
ndb: &Ndb,
|
||||
unknown_ids: &mut UnknownIds,
|
||||
note_cache: &mut NoteCache,
|
||||
note: &Note,
|
||||
) -> bool {
|
||||
let before = unknown_ids.ids().len();
|
||||
let key = note.key().expect("note key");
|
||||
//let cached_note = note_cache.cached_note_or_insert(key, note).clone();
|
||||
let cached_note = note_cache.cached_note_or_insert(key, note);
|
||||
if let Err(e) = get_unknown_note_ids(ndb, cached_note, txn, note, unknown_ids.ids_mut()) {
|
||||
error!("UnknownIds::update_from_note {e}");
|
||||
}
|
||||
let after = unknown_ids.ids().len();
|
||||
|
||||
if before != after {
|
||||
unknown_ids.mark_updated();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_unknown_id_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, unk_id: &UnknownId) {
|
||||
match unk_id {
|
||||
UnknownId::Pubkey(pk) => self.add_pubkey_if_missing(ndb, txn, pk),
|
||||
UnknownId::Id(note_id) => self.add_note_id_if_missing(ndb, txn, note_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_pubkey_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, pubkey: &Pubkey) {
|
||||
// we already have this profile, skip
|
||||
if ndb.get_profile_by_pubkey(txn, pubkey).is_ok() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.ids.insert(UnknownId::Pubkey(*pubkey));
|
||||
self.mark_updated();
|
||||
}
|
||||
|
||||
pub fn add_note_id_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, note_id: &NoteId) {
|
||||
// we already have this note, skip
|
||||
if ndb.get_note_by_id(txn, note_id.bytes()).is_ok() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.ids.insert(UnknownId::Id(*note_id));
|
||||
self.mark_updated();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Hash, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnknownId {
|
||||
Pubkey(Pubkey),
|
||||
Id(NoteId),
|
||||
}
|
||||
|
||||
impl UnknownId {
|
||||
pub fn is_pubkey(&self) -> Option<&Pubkey> {
|
||||
match self {
|
||||
UnknownId::Pubkey(pk) => Some(pk),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_id(&self) -> Option<&NoteId> {
|
||||
match self {
|
||||
UnknownId::Id(id) => Some(id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Look for missing notes in various parts of notes that we see:
|
||||
///
|
||||
/// - pubkeys and notes mentioned inside the note
|
||||
/// - notes being replied to
|
||||
///
|
||||
/// We return all of this in a HashSet so that we can fetch these from
|
||||
/// remote relays.
|
||||
///
|
||||
pub fn get_unknown_note_ids<'a>(
|
||||
ndb: &Ndb,
|
||||
cached_note: &CachedNote,
|
||||
txn: &'a Transaction,
|
||||
note: &Note<'a>,
|
||||
ids: &mut HashSet<UnknownId>,
|
||||
) -> Result<()> {
|
||||
#[cfg(feature = "profiling")]
|
||||
puffin::profile_function!();
|
||||
|
||||
// the author pubkey
|
||||
if ndb.get_profile_by_pubkey(txn, note.pubkey()).is_err() {
|
||||
ids.insert(UnknownId::Pubkey(Pubkey::new(*note.pubkey())));
|
||||
}
|
||||
|
||||
// pull notes that notes are replying to
|
||||
if cached_note.reply.root.is_some() {
|
||||
let note_reply = cached_note.reply.borrow(note.tags());
|
||||
if let Some(root) = note_reply.root() {
|
||||
if ndb.get_note_by_id(txn, root.id).is_err() {
|
||||
ids.insert(UnknownId::Id(NoteId::new(*root.id)));
|
||||
}
|
||||
}
|
||||
|
||||
if !note_reply.is_reply_to_root() {
|
||||
if let Some(reply) = note_reply.reply() {
|
||||
if ndb.get_note_by_id(txn, reply.id).is_err() {
|
||||
ids.insert(UnknownId::Id(NoteId::new(*reply.id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let blocks = ndb.get_blocks_by_key(txn, note.key().expect("note key"))?;
|
||||
for block in blocks.iter(note) {
|
||||
if block.blocktype() != BlockType::MentionBech32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
match block.as_mention().unwrap() {
|
||||
Mention::Pubkey(npub) => {
|
||||
if ndb.get_profile_by_pubkey(txn, npub.pubkey()).is_err() {
|
||||
ids.insert(UnknownId::Pubkey(Pubkey::new(*npub.pubkey())));
|
||||
}
|
||||
}
|
||||
Mention::Profile(nprofile) => {
|
||||
if ndb.get_profile_by_pubkey(txn, nprofile.pubkey()).is_err() {
|
||||
ids.insert(UnknownId::Pubkey(Pubkey::new(*nprofile.pubkey())));
|
||||
}
|
||||
}
|
||||
Mention::Event(ev) => match ndb.get_note_by_id(txn, ev.id()) {
|
||||
Err(_) => {
|
||||
ids.insert(UnknownId::Id(NoteId::new(*ev.id())));
|
||||
if let Some(pk) = ev.pubkey() {
|
||||
if ndb.get_profile_by_pubkey(txn, pk).is_err() {
|
||||
ids.insert(UnknownId::Pubkey(Pubkey::new(*pk)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(note) => {
|
||||
if ndb.get_profile_by_pubkey(txn, note.pubkey()).is_err() {
|
||||
ids.insert(UnknownId::Pubkey(Pubkey::new(*note.pubkey())));
|
||||
}
|
||||
}
|
||||
},
|
||||
Mention::Note(note) => match ndb.get_note_by_id(txn, note.id()) {
|
||||
Err(_) => {
|
||||
ids.insert(UnknownId::Id(NoteId::new(*note.id())));
|
||||
}
|
||||
Ok(note) => {
|
||||
if ndb.get_profile_by_pubkey(txn, note.pubkey()).is_err() {
|
||||
ids.insert(UnknownId::Pubkey(Pubkey::new(*note.pubkey())));
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_unknown_ids_filter(ids: &[&UnknownId]) -> Option<Vec<Filter>> {
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ids = &ids[0..500.min(ids.len())];
|
||||
let mut filters: Vec<Filter> = vec![];
|
||||
|
||||
let pks: Vec<&[u8; 32]> = ids
|
||||
.iter()
|
||||
.flat_map(|id| id.is_pubkey().map(|pk| pk.bytes()))
|
||||
.collect();
|
||||
if !pks.is_empty() {
|
||||
let pk_filter = Filter::new().authors(pks).kinds([0]).build();
|
||||
filters.push(pk_filter);
|
||||
}
|
||||
|
||||
let note_ids: Vec<&[u8; 32]> = ids
|
||||
.iter()
|
||||
.flat_map(|id| id.is_id().map(|id| id.bytes()))
|
||||
.collect();
|
||||
if !note_ids.is_empty() {
|
||||
filters.push(Filter::new().ids(note_ids).build());
|
||||
}
|
||||
|
||||
Some(filters)
|
||||
}
|
||||
9
crates/notedeck/src/user_account.rs
Normal file
9
crates/notedeck/src/user_account.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use enostr::Keypair;
|
||||
|
||||
//pub struct UserAccount {
|
||||
//pub key: Keypair,
|
||||
//pub relays: RelayPool,
|
||||
//pub relays: Vec<String>,
|
||||
//}
|
||||
|
||||
pub type UserAccount = Keypair;
|
||||
Reference in New Issue
Block a user