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:
William Casarin
2024-12-11 04:22:05 -08:00
parent aa14fb092d
commit ec755493d9
146 changed files with 2820 additions and 2794 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
.build-result
.buildcmd
build.log
perf.data
perf.data.old
.privenv

50
Cargo.lock generated
View File

@@ -1184,6 +1184,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"thiserror 2.0.6",
"tracing",
"url",
]
@@ -2484,7 +2485,7 @@ dependencies = [
"cc",
"flatbuffers",
"libc",
"thiserror 2.0.3",
"thiserror 2.0.6",
"tokio",
"tracing",
"tracing-subscriber",
@@ -2492,13 +2493,47 @@ dependencies = [
[[package]]
name = "notedeck"
version = "0.1.0"
dependencies = [
"base32",
"dirs",
"egui",
"enostr",
"hex",
"image",
"nostrdb",
"poll-promise",
"puffin 0.19.1 (git+https://github.com/jb55/puffin?rev=70ff86d5503815219b01a009afd3669b7903a057)",
"security-framework",
"serde",
"serde_json",
"strum",
"strum_macros",
"tempfile",
"thiserror 2.0.6",
"tracing",
"url",
"uuid",
]
[[package]]
name = "notedeck_chrome"
version = "0.2.0"
dependencies = [
"android-activity 0.4.3",
"android_logger",
"eframe",
"egui",
"egui_extras",
"enostr",
"nostrdb",
"notedeck",
"notedeck_columns",
"serde_json",
"strum",
"tempfile",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"winit",
@@ -2508,7 +2543,6 @@ dependencies = [
name = "notedeck_columns"
version = "0.2.0"
dependencies = [
"base32",
"bitflags 2.6.0",
"dirs",
"eframe",
@@ -2525,6 +2559,7 @@ dependencies = [
"indexmap",
"log",
"nostrdb",
"notedeck",
"open",
"poll-promise",
"puffin 0.19.1 (git+https://github.com/jb55/puffin?rev=70ff86d5503815219b01a009afd3669b7903a057)",
@@ -2537,6 +2572,7 @@ dependencies = [
"strum",
"strum_macros",
"tempfile",
"thiserror 2.0.6",
"tokio",
"tracing",
"tracing-appender",
@@ -4226,11 +4262,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.3"
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa"
checksum = "8fec2a1820ebd077e2b90c4df007bebf344cd394098a13c563957d0afc83ea47"
dependencies = [
"thiserror-impl 2.0.3",
"thiserror-impl 2.0.6",
]
[[package]]
@@ -4246,9 +4282,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.3"
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568"
checksum = "d65750cab40f4ff1929fb1ba509e9914eb756131cef4210da8d5d700d26f6312"
dependencies = [
"proc-macro2",
"quote",

View File

@@ -2,7 +2,8 @@
resolver = "2"
members = [
"crates/notedeck",
"crates/notedeck_columns", # Replace with the name of your subcrate
"crates/notedeck_chrome",
"crates/notedeck_columns",
"crates/enostr",
]
@@ -28,6 +29,7 @@ log = "0.4.17"
nostr = { version = "0.30.0" }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "71154e4100775f6932ee517da4350c433ba14ec7" }
notedeck = { path = "crates/notedeck" }
notedeck_chrome = { path = "crates/notedeck_chrome" }
notedeck_columns = { path = "crates/notedeck_columns" }
open = "5.3.0"
poll-promise = { version = "0.3.0", features = ["tokio"] }
@@ -39,13 +41,16 @@ serde_derive = "1"
serde_json = "1.0.89"
strum = "0.26"
strum_macros = "0.26"
thiserror = "2.0.6"
tokio = { version = "1.16", features = ["macros", "rt-multi-thread", "fs"] }
tracing = "0.1.40"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tempfile = "3.13.0"
url = "2.5.2"
urlencoding = "2.1.3"
uuid = { version = "1.10.0", features = ["v4"] }
security-framework = "2.11.0"
[profile.small]
inherits = 'release'

View File

@@ -2,6 +2,9 @@
all:
cargo check
check:
cargo check
tags: fake
find . -type d -name target -prune -o -type f -name '*.rs' -print | xargs ctags

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 266 KiB

View File

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 286 KiB

View File

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 806 B

After

Width:  |  Height:  |  Size: 806 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 554 B

After

Width:  |  Height:  |  Size: 554 B

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 340 B

After

Width:  |  Height:  |  Size: 340 B

View File

Before

Width:  |  Height:  |  Size: 912 B

After

Width:  |  Height:  |  Size: 912 B

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 969 B

After

Width:  |  Height:  |  Size: 969 B

View File

Before

Width:  |  Height:  |  Size: 808 B

After

Width:  |  Height:  |  Size: 808 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -9,9 +9,10 @@ edition = "2021"
ewebsock = { version = "0.2.0", features = ["tls"] }
serde_derive = "1"
serde = { version = "1", features = ["derive"] } # You only need this if you want app persistence
serde_json = "1.0.89"
nostr = { version = "0.30.0" }
serde_json = { workspace = true }
nostr = { workspace = true }
nostrdb = { workspace = true }
hex = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }

View File

@@ -1,38 +1,39 @@
//use nostr::prelude::secp256k1;
use std::array::TryFromSliceError;
use std::fmt;
use thiserror::Error;
#[derive(Debug)]
#[derive(Error, Debug)]
pub enum Error {
#[error("message is empty")]
Empty,
DecodeFailed,
HexDecodeFailed,
InvalidBech32,
InvalidByteSize,
InvalidSignature,
InvalidPublicKey,
// Secp(secp256k1::Error),
Json(serde_json::Error),
Nostrdb(nostrdb::Error),
Generic(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "message is empty"),
Self::DecodeFailed => write!(f, "decoding failed"),
Self::InvalidSignature => write!(f, "invalid signature"),
Self::HexDecodeFailed => write!(f, "hex decoding failed"),
Self::InvalidByteSize => write!(f, "invalid byte size"),
Self::InvalidBech32 => write!(f, "invalid bech32 string"),
Self::InvalidPublicKey => write!(f, "invalid public key"),
//Self::Secp(e) => write!(f, "{e}"),
Self::Json(e) => write!(f, "{e}"),
Self::Nostrdb(e) => write!(f, "{e}"),
Self::Generic(e) => write!(f, "{e}"),
}
}
#[error("decoding failed")]
DecodeFailed,
#[error("hex decoding failed")]
HexDecodeFailed,
#[error("invalid bech32")]
InvalidBech32,
#[error("invalid byte size")]
InvalidByteSize,
#[error("invalid signature")]
InvalidSignature,
#[error("invalid public key")]
InvalidPublicKey,
// Secp(secp256k1::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("nostrdb error: {0}")]
Nostrdb(#[from] nostrdb::Error),
#[error("{0}")]
Generic(String),
}
impl From<String> for Error {
@@ -52,23 +53,3 @@ impl From<hex::FromHexError> for Error {
Error::HexDecodeFailed
}
}
/*
impl From<secp256k1::Error> for Error {
fn from(e: secp256k1::Error) -> Self {
Error::Secp(e)
}
}
*/
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
Error::Json(e)
}
}
impl From<nostrdb::Error> for Error {
fn from(e: nostrdb::Error) -> Self {
Error::Nostrdb(e)
}
}

View File

@@ -139,7 +139,6 @@ impl<'a> RelayMessage<'a> {
#[cfg(test)]
mod tests {
use super::*;
use crate::Note;
#[test]
fn test_handle_valid_notice() -> Result<()> {

View File

@@ -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"]

View 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);
}
}

View 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
View 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
}
}

View 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,
}

View 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)
}
}

View 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)
}
}

View File

@@ -29,6 +29,21 @@ impl ImageCache {
"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()

View 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;

View File

@@ -1,5 +1,4 @@
use crate::time::time_ago_since;
use crate::timecache::TimeCached;
use crate::{time_ago_since, TimeCached};
use nostrdb::{Note, NoteKey, NoteReply, NoteReplyBuf};
use std::collections::HashMap;
use std::time::Duration;

View File

@@ -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");
});
}

View File

@@ -1,3 +1,3 @@
use crate::error::Error;
use crate::Error;
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -1,11 +1,9 @@
use eframe::Result;
use crate::Result;
use enostr::{Keypair, Pubkey, SerializableKeypair};
use crate::Error;
use super::{
file_storage::{delete_file, write_file, Directory},
key_storage_impl::{KeyStorageError, KeyStorageResponse},
key_storage_impl::KeyStorageResponse,
};
static SELECTED_PUBKEY_FILE_NAME: &str = "selected_pubkey";
@@ -25,21 +23,18 @@ impl FileKeyStorage {
}
}
fn add_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> {
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))
.map_err(|e| KeyStorageError::Addition(Error::Generic(e.to_string())))?,
&serde_json::to_string(&SerializableKeypair::from_keypair(key, "", 7))?,
)
.map_err(KeyStorageError::Addition)
}
fn get_keys_internal(&self) -> Result<Vec<Keypair>, KeyStorageError> {
fn get_keys_internal(&self) -> Result<Vec<Keypair>> {
let keys = self
.keys_directory
.get_files()
.map_err(KeyStorageError::Retrieval)?
.get_files()?
.values()
.filter_map(|str_key| serde_json::from_str::<SerializableKeypair>(str_key).ok())
.map(|serializable_keypair| serializable_keypair.to_keypair(""))
@@ -47,41 +42,35 @@ impl FileKeyStorage {
Ok(keys)
}
fn remove_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> {
fn remove_key_internal(&self, key: &Keypair) -> Result<()> {
delete_file(&self.keys_directory.file_path, key.pubkey.hex())
.map_err(KeyStorageError::Removal)
}
fn get_selected_pubkey(&self) -> Result<Option<Pubkey>, KeyStorageError> {
fn get_selected_pubkey(&self) -> Result<Option<Pubkey>> {
let pubkey_str = self
.selected_key_directory
.get_file(SELECTED_PUBKEY_FILE_NAME.to_owned())
.map_err(KeyStorageError::Selection)?;
.get_file(SELECTED_PUBKEY_FILE_NAME.to_owned())?;
serde_json::from_str(&pubkey_str)
.map_err(|e| KeyStorageError::Selection(Error::Generic(e.to_string())))
Ok(serde_json::from_str(&pubkey_str)?)
}
fn select_pubkey(&self, pubkey: Option<Pubkey>) -> Result<(), KeyStorageError> {
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())
.map_err(|e| KeyStorageError::Selection(Error::Generic(e.to_string())))?,
&serde_json::to_string(&pubkey.hex())?,
)
.map_err(KeyStorageError::Selection)
} 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
delete_file(
Ok(delete_file(
&self.selected_key_directory.file_path,
SELECTED_PUBKEY_FILE_NAME.to_owned(),
)
.map_err(KeyStorageError::Selection)
)?)
} else {
Ok(())
}
@@ -114,13 +103,15 @@ impl FileKeyStorage {
mod tests {
use std::path::PathBuf;
use super::Result;
use super::*;
use enostr::Keypair;
static CREATE_TMP_DIR: fn() -> Result<PathBuf, Error> =
static CREATE_TMP_DIR: fn() -> Result<PathBuf> =
|| Ok(tempfile::TempDir::new()?.path().to_path_buf());
impl FileKeyStorage {
fn mock() -> Result<Self, Error> {
fn mock() -> Result<Self> {
Ok(Self {
keys_directory: Directory::new(CREATE_TMP_DIR()?),
selected_key_directory: Directory::new(CREATE_TMP_DIR()?),

View File

@@ -6,7 +6,7 @@ use std::{
time::SystemTime,
};
use crate::Error;
use crate::{Error, Result};
#[derive(Debug, Clone)]
pub struct DataPath {
@@ -61,7 +61,7 @@ impl Directory {
}
/// 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>, Error> {
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())
@@ -76,7 +76,7 @@ impl Directory {
Ok(map)
}
pub fn get_file_names(&self) -> Result<Vec<String>, Error> {
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())
@@ -87,7 +87,7 @@ impl Directory {
Ok(names)
}
pub fn get_file(&self, file_name: String) -> Result<String, Error> {
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() {
@@ -103,7 +103,7 @@ impl Directory {
}
}
pub fn get_file_last_n_lines(&self, file_name: String, n: usize) -> Result<FileResult, Error> {
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() {
@@ -140,7 +140,7 @@ impl Directory {
}
/// Get the file name which is most recently modified in the directory
pub fn get_most_recent(&self) -> Result<Option<String>, Error> {
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)? {
@@ -173,7 +173,7 @@ pub struct FileResult {
}
/// Write the file to the directory
pub fn write_file(directory: &Path, file_name: String, data: &str) -> Result<(), Error> {
pub fn write_file(directory: &Path, file_name: String, data: &str) -> Result<()> {
if !directory.exists() {
fs::create_dir_all(directory)?
}
@@ -182,7 +182,7 @@ pub fn write_file(directory: &Path, file_name: String, data: &str) -> Result<(),
Ok(())
}
pub fn delete_file(directory: &Path, file_name: String) -> Result<(), Error> {
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)
@@ -200,12 +200,12 @@ mod tests {
use crate::{
storage::file_storage::{delete_file, write_file},
Error,
Result,
};
use super::Directory;
static CREATE_TMP_DIR: fn() -> Result<PathBuf, Error> =
static CREATE_TMP_DIR: fn() -> Result<PathBuf> =
|| Ok(tempfile::TempDir::new()?.path().to_path_buf());
#[test]

View File

@@ -1,7 +1,7 @@
use enostr::{Keypair, Pubkey};
use super::file_key_storage::FileKeyStorage;
use crate::Error;
use crate::Result;
#[cfg(target_os = "macos")]
use super::security_framework_key_storage::SecurityFrameworkKeyStorage;
@@ -18,7 +18,7 @@ pub enum KeyStorageType {
#[derive(Debug)]
pub enum KeyStorageResponse<R> {
Waiting,
ReceivedResult(Result<R, KeyStorageError>),
ReceivedResult(Result<R>),
}
impl<R: PartialEq> PartialEq for KeyStorageResponse<R> {
@@ -86,27 +86,3 @@ impl KeyStorageType {
}
}
}
#[allow(dead_code)]
#[derive(Debug)]
pub enum KeyStorageError {
Retrieval(Error),
Addition(Error),
Selection(Error),
Removal(Error),
OSError(Error),
}
impl std::fmt::Display for KeyStorageError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Retrieval(e) => write!(f, "Failed to retrieve keys: {:?}", e),
Self::Addition(key) => write!(f, "Failed to add key: {:?}", key),
Self::Selection(pubkey) => write!(f, "Failed to select key: {:?}", pubkey),
Self::Removal(key) => write!(f, "Failed to remove key: {:?}", key),
Self::OSError(e) => write!(f, "OS had an error: {:?}", e),
}
}
}
impl std::error::Error for KeyStorageError {}

View 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};

View File

@@ -7,9 +7,9 @@ use security_framework::{
};
use tracing::error;
use crate::Error;
use crate::{Error, Result};
use super::{key_storage_impl::KeyStorageError, KeyStorageResponse};
use super::KeyStorageResponse;
#[derive(Debug, PartialEq)]
pub struct SecurityFrameworkKeyStorage {
@@ -23,7 +23,7 @@ impl SecurityFrameworkKeyStorage {
}
}
fn add_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> {
fn add_key_internal(&self, key: &Keypair) -> Result<()> {
match set_generic_password(
&self.service_name,
key.pubkey.hex().as_str(),
@@ -32,7 +32,7 @@ impl SecurityFrameworkKeyStorage {
.map_or_else(|| &[] as &[u8], |sc| sc.as_secret_bytes()),
) {
Ok(_) => Ok(()),
Err(e) => Err(KeyStorageError::Addition(Error::Generic(e.to_string()))),
Err(e) => Err(Error::Generic(e.to_string())),
}
}
@@ -101,12 +101,12 @@ impl SecurityFrameworkKeyStorage {
.collect()
}
fn delete_key(&self, pubkey: &Pubkey) -> Result<(), KeyStorageError> {
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(KeyStorageError::Removal(Error::Generic(e.to_string())))
Err(Error::Generic(e.to_string()))
}
}
}

View 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,
}
}
}

View 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
}
}

View File

@@ -1,7 +1,7 @@
use egui::ThemePreference;
use tracing::{error, info};
use crate::storage::{write_file, DataPath, DataPathType, Directory};
use crate::{storage, DataPath, DataPathType, Directory};
pub struct ThemeHandler {
directory: Directory,
@@ -43,7 +43,7 @@ impl ThemeHandler {
}
pub fn save(&self, theme: ThemePreference) {
match write_file(
match storage::write_file(
&self.directory.file_path,
THEME_FILE.to_owned(),
&theme_to_serialized(&theme),

24
crates/notedeck/src/ui.rs Normal file
View 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
}
}

View 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, &note)
}
/// 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)
}

View File

@@ -0,0 +1,82 @@
[package]
name = "notedeck_chrome"
version = "0.2.0"
authors = ["William Casarin <jb55@jb55.com>", "kernelkind <kernelkind@gmail.com>"]
edition = "2021"
default-run = "notedeck"
#rust-version = "1.60"
license = "GPLv3"
description = "The nostr browser"
[dependencies]
eframe = { workspace = true }
egui = { workspace = true }
egui_extras = { workspace = true }
enostr = { workspace = true }
nostrdb = { workspace = true }
notedeck = { workspace = true }
notedeck_columns = { workspace = true }
serde_json = { workspace = true }
strum = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[[bin]]
name = "notedeck"
path = "src/notedeck.rs"
[[bin]]
name = "ui_preview"
path = "src/preview.rs"
[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" },
]

View File

@@ -3,7 +3,7 @@ use std::time::{Duration, Instant};
use egui::Context;
use tracing::info;
use crate::storage::{write_file, DataPath, DataPathType, Directory};
use notedeck::{storage, DataPath, DataPathType, Directory};
pub struct AppSizeHandler {
directory: Directory,
@@ -71,7 +71,7 @@ fn try_save_size(
maybe_saved_size: &mut Option<egui::Vec2>,
) {
if let Ok(serialized_rect) = serde_json::to_string(&cur_size) {
if write_file(
if storage::write_file(
&interactor.file_path,
FILE_NAME.to_owned(),
&serialized_rect,

View File

@@ -2,25 +2,7 @@ use egui::{FontData, FontDefinitions, FontTweak};
use std::collections::BTreeMap;
use tracing::debug;
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())
}
}
use notedeck::fonts::NamedFontFamily;
// Use gossip's approach to font loading. This includes japanese fonts
// for rending stuff from japanese users.
@@ -31,26 +13,28 @@ pub fn setup_fonts(ctx: &egui::Context) {
font_data.insert(
"Onest".to_owned(),
FontData::from_static(include_bytes!(
"../assets/fonts/onest/OnestRegular1602-hint.ttf"
"../../../assets/fonts/onest/OnestRegular1602-hint.ttf"
)),
);
font_data.insert(
"OnestMedium".to_owned(),
FontData::from_static(include_bytes!(
"../assets/fonts/onest/OnestMedium1602-hint.ttf"
"../../../assets/fonts/onest/OnestMedium1602-hint.ttf"
)),
);
font_data.insert(
"DejaVuSans".to_owned(),
FontData::from_static(include_bytes!("../assets/fonts/DejaVuSansSansEmoji.ttf")),
FontData::from_static(include_bytes!(
"../../../assets/fonts/DejaVuSansSansEmoji.ttf"
)),
);
font_data.insert(
"OnestBold".to_owned(),
FontData::from_static(include_bytes!(
"../assets/fonts/onest/OnestBold1602-hint.ttf"
"../../../assets/fonts/onest/OnestBold1602-hint.ttf"
)),
);
@@ -76,37 +60,43 @@ pub fn setup_fonts(ctx: &egui::Context) {
font_data.insert(
"Inconsolata".to_owned(),
FontData::from_static(include_bytes!("../assets/fonts/Inconsolata-Regular.ttf")).tweak(
FontTweak {
FontData::from_static(include_bytes!(
"../../../assets/fonts/Inconsolata-Regular.ttf"
))
.tweak(FontTweak {
scale: 1.22, // This font is smaller than DejaVuSans
y_offset_factor: -0.18, // and too low
y_offset: 0.0,
baseline_offset_factor: 0.0,
},
),
}),
);
font_data.insert(
"NotoSansCJK".to_owned(),
FontData::from_static(include_bytes!("../assets/fonts/NotoSansCJK-Regular.ttc")),
FontData::from_static(include_bytes!(
"../../../assets/fonts/NotoSansCJK-Regular.ttc"
)),
);
font_data.insert(
"NotoSansThai".to_owned(),
FontData::from_static(include_bytes!("../assets/fonts/NotoSansThai-Regular.ttf")),
FontData::from_static(include_bytes!(
"../../../assets/fonts/NotoSansThai-Regular.ttf"
)),
);
// Some good looking emojis. Use as first priority:
font_data.insert(
"NotoEmoji".to_owned(),
FontData::from_static(include_bytes!("../assets/fonts/NotoEmoji-Regular.ttf")).tweak(
FontTweak {
FontData::from_static(include_bytes!(
"../../../assets/fonts/NotoEmoji-Regular.ttf"
))
.tweak(FontTweak {
scale: 1.1, // make them a touch larger
y_offset_factor: 0.0,
y_offset: 0.0,
baseline_offset_factor: 0.0,
},
),
}),
);
let base_fonts = vec![

View File

@@ -0,0 +1,4 @@
pub mod app_size;
pub mod fonts;
pub mod setup;
pub mod theme;

View File

@@ -0,0 +1,392 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
use notedeck_chrome::{
app_size::AppSizeHandler,
setup::{generate_native_options, setup_cc},
theme,
};
use notedeck_columns::Damus;
use notedeck::{
Accounts, AppContext, Args, DataPath, DataPathType, Directory, FileKeyStorage, ImageCache,
KeyStorageType, NoteCache, ThemeHandler, UnknownIds,
};
use enostr::RelayPool;
use nostrdb::{Config, Ndb, Transaction};
use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;
use std::{path::PathBuf, str::FromStr};
use tracing::info;
use tracing_subscriber::EnvFilter;
/// Our browser app state
struct Notedeck {
ndb: Ndb,
img_cache: ImageCache,
unknown_ids: UnknownIds,
pool: RelayPool,
note_cache: NoteCache,
accounts: Accounts,
path: DataPath,
args: Args,
theme: ThemeHandler,
tabs: Tabs,
app_rect_handler: AppSizeHandler,
egui: egui::Context,
}
struct Tabs {
app: Option<Rc<RefCell<dyn notedeck::App>>>,
}
impl Tabs {
pub fn new(app: Option<Rc<RefCell<dyn notedeck::App>>>) -> Self {
Self { app }
}
}
impl eframe::App for Notedeck {
/// Called by the frame work to save state before shutdown.
fn save(&mut self, _storage: &mut dyn eframe::Storage) {
//eframe::set_value(storage, eframe::APP_KEY, self);
}
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// TODO: render chrome
// render app
if let Some(app) = &self.tabs.app {
let app = app.clone();
app.borrow_mut().update(&mut self.app_context());
}
self.app_rect_handler.try_save_app_size(ctx);
}
}
impl Notedeck {
pub fn new<P: AsRef<Path>>(ctx: &egui::Context, data_path: P, args: &[String]) -> Self {
let parsed_args = Args::parse(args);
let is_mobile = parsed_args
.is_mobile
.unwrap_or(notedeck::ui::is_compiled_as_mobile());
// Some people have been running notedeck in debug, let's catch that!
if !cfg!(test) && cfg!(debug_assertions) && !parsed_args.debug {
println!("--- WELCOME TO DAMUS NOTEDECK! ---");
println!("It looks like are running notedeck in debug mode, unless you are a developer, this is not likely what you want.");
println!("If you are a developer, run `cargo run -- --debug` to skip this message.");
println!("For everyone else, try again with `cargo run --release`. Enjoy!");
println!("---------------------------------");
panic!();
}
setup_cc(ctx, is_mobile, parsed_args.light);
let data_path = parsed_args
.datapath
.unwrap_or(data_path.as_ref().to_str().expect("db path ok").to_string());
let path = DataPath::new(&data_path);
let dbpath_str = parsed_args
.dbpath
.unwrap_or_else(|| path.path(DataPathType::Db).to_str().unwrap().to_string());
let _ = std::fs::create_dir_all(&dbpath_str);
let imgcache_dir = path.path(DataPathType::Cache).join(ImageCache::rel_dir());
let _ = std::fs::create_dir_all(imgcache_dir.clone());
let mapsize = if cfg!(target_os = "windows") {
// 16 Gib on windows because it actually creates the file
1024usize * 1024usize * 1024usize * 16usize
} else {
// 1 TiB for everything else since its just virtually mapped
1024usize * 1024usize * 1024usize * 1024usize
};
let theme = ThemeHandler::new(&path);
ctx.options_mut(|o| {
let cur_theme = theme.load();
info!("Loaded theme {:?} from disk", cur_theme);
o.theme_preference = cur_theme;
});
ctx.set_visuals_of(
egui::Theme::Dark,
theme::dark_mode(notedeck::ui::is_compiled_as_mobile()),
);
ctx.set_visuals_of(egui::Theme::Light, theme::light_mode());
let config = Config::new().set_ingester_threads(4).set_mapsize(mapsize);
let keystore = if parsed_args.use_keystore {
let keys_path = path.path(DataPathType::Keys);
let selected_key_path = path.path(DataPathType::SelectedKey);
KeyStorageType::FileSystem(FileKeyStorage::new(
Directory::new(keys_path),
Directory::new(selected_key_path),
))
} else {
KeyStorageType::None
};
let mut accounts = Accounts::new(keystore, parsed_args.relays);
let num_keys = parsed_args.keys.len();
let mut unknown_ids = UnknownIds::default();
let ndb = Ndb::new(&dbpath_str, &config).expect("ndb");
{
let txn = Transaction::new(&ndb).expect("txn");
for key in parsed_args.keys {
info!("adding account: {}", key.pubkey);
accounts
.add_account(key)
.process_action(&mut unknown_ids, &ndb, &txn);
}
}
if num_keys != 0 {
accounts.select_account(0);
}
// AccountManager will setup the pool on first update
let pool = RelayPool::new();
let img_cache = ImageCache::new(imgcache_dir);
let note_cache = NoteCache::default();
let unknown_ids = UnknownIds::default();
let egui = ctx.clone();
let tabs = Tabs::new(None);
let parsed_args = Args::parse(args);
let app_rect_handler = AppSizeHandler::new(&path);
Self {
ndb,
img_cache,
app_rect_handler,
unknown_ids,
pool,
note_cache,
accounts,
path: path.clone(),
args: parsed_args,
theme,
egui,
tabs,
}
}
pub fn app_context(&mut self) -> AppContext<'_> {
AppContext {
ndb: &self.ndb,
img_cache: &mut self.img_cache,
unknown_ids: &mut self.unknown_ids,
pool: &mut self.pool,
note_cache: &mut self.note_cache,
accounts: &mut self.accounts,
path: &self.path,
args: &self.args,
theme: &mut self.theme,
egui: &self.egui,
}
}
pub fn add_app<T: notedeck::App + 'static>(&mut self, app: T) {
self.tabs.app = Some(Rc::new(RefCell::new(app)));
}
}
// 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| {
let args: Vec<String> = std::env::args().collect();
let mut notedeck = Notedeck::new(&cc.egui_ctx, base_path, &args);
let damus = Damus::new(&mut notedeck.app_context(), &args);
notedeck.add_app(damus);
Ok(Box::new(notedeck))
}),
);
}
/*
* TODO: nostrdb not supported on web
*
#[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");
});
}
*/
#[cfg(test)]
mod tests {
use super::{Damus, Notedeck};
use std::path::{Path, PathBuf};
fn create_tmp_dir() -> PathBuf {
tempfile::TempDir::new()
.expect("tmp path")
.path()
.to_path_buf()
}
fn rmrf(path: impl AsRef<Path>) {
let _ = std::fs::remove_dir_all(path);
}
/// Ensure dbpath actually sets the dbpath correctly.
#[tokio::test]
async fn test_dbpath() {
let datapath = create_tmp_dir();
let dbpath = create_tmp_dir();
let args: Vec<String> = vec![
"--datapath",
&datapath.to_str().unwrap(),
"--dbpath",
&dbpath.to_str().unwrap(),
]
.iter()
.map(|s| s.to_string())
.collect();
let ctx = egui::Context::default();
let _app = Notedeck::new(&ctx, &datapath, &args);
assert!(Path::new(&dbpath.join("data.mdb")).exists());
assert!(Path::new(&dbpath.join("lock.mdb")).exists());
assert!(!Path::new(&datapath.join("db")).exists());
rmrf(datapath);
rmrf(dbpath);
}
#[tokio::test]
async fn test_column_args() {
let tmpdir = create_tmp_dir();
let npub = "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s";
let args: Vec<String> = vec![
"--no-keystore",
"--pub",
npub,
"-c",
"notifications",
"-c",
"contacts",
]
.iter()
.map(|s| s.to_string())
.collect();
let ctx = egui::Context::default();
let mut notedeck = Notedeck::new(&ctx, &tmpdir, &args);
let mut app_ctx = notedeck.app_context();
let app = Damus::new(&mut app_ctx, &args);
assert_eq!(app.columns(app_ctx.accounts).columns().len(), 2);
let tl1 = app
.columns(app_ctx.accounts)
.column(0)
.router()
.top()
.timeline_id();
let tl2 = app
.columns(app_ctx.accounts)
.column(1)
.router()
.top()
.timeline_id();
assert_eq!(tl1.is_some(), true);
assert_eq!(tl2.is_some(), true);
let timelines = app.columns(app_ctx.accounts).timelines();
assert!(timelines[0].kind.is_notifications());
assert!(timelines[1].kind.is_contacts());
rmrf(tmpdir);
}
}

View File

@@ -1,13 +1,12 @@
use notedeck::DataPath;
use notedeck_chrome::setup::{
generate_mobile_emulator_native_options, generate_native_options, setup_cc,
};
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,
account_login_view::AccountLoginView, PostView, Preview, PreviewApp, PreviewConfig, ProfilePic,
ProfilePreview, RelayView,
};
use std::env;
@@ -93,7 +92,7 @@ async fn main() {
"light mode previews: {}",
if light_mode { "enabled" } else { "disabled" }
);
let is_mobile = is_mobile.unwrap_or(notedeck_columns::ui::is_compiled_as_mobile());
let is_mobile = is_mobile.unwrap_or(notedeck::ui::is_compiled_as_mobile());
let runner = PreviewRunner::new(is_mobile, light_mode);
previews!(
@@ -104,10 +103,7 @@ async fn main() {
AccountLoginView,
ProfilePreview,
ProfilePic,
AccountsView,
DesktopSidePanel,
PostView,
AddColumnView,
ConfigureDeckView,
EditDeckView,
);

View File

@@ -0,0 +1,79 @@
use crate::{app_size::AppSizeHandler, fonts, theme};
use eframe::NativeOptions;
use notedeck::DataPath;
pub fn setup_cc(ctx: &egui::Context, is_mobile: bool, light: bool) {
fonts::setup_fonts(ctx);
//ctx.set_pixels_per_point(ctx.pixels_per_point() + UI_SCALE_FACTOR);
//ctx.set_pixels_per_point(1.0);
//
//
//ctx.tessellation_options_mut(|to| to.feathering = false);
egui_extras::install_image_loaders(ctx);
if light {
ctx.set_visuals(theme::light_mode())
} else {
ctx.set_visuals(theme::dark_mode(is_mobile));
}
ctx.all_styles_mut(|style| theme::add_custom_style(is_mobile, style));
}
//pub const UI_SCALE_FACTOR: f32 = 0.2;
pub fn generate_native_options(paths: DataPath) -> NativeOptions {
let window_builder = Box::new(move |builder: egui::ViewportBuilder| {
let builder = builder
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_icon(std::sync::Arc::new(
eframe::icon_data::from_png_bytes(app_icon()).expect("icon"),
));
if let Some(window_size) = AppSizeHandler::new(&paths).get_app_size() {
builder.with_inner_size(window_size)
} else {
builder
}
});
eframe::NativeOptions {
window_builder: Some(window_builder),
viewport: egui::ViewportBuilder::default().with_icon(std::sync::Arc::new(
eframe::icon_data::from_png_bytes(app_icon()).expect("icon"),
)),
..Default::default()
}
}
fn generate_native_options_with_builder_modifiers(
apply_builder_modifiers: fn(egui::ViewportBuilder) -> egui::ViewportBuilder,
) -> NativeOptions {
let window_builder =
Box::new(move |builder: egui::ViewportBuilder| apply_builder_modifiers(builder));
eframe::NativeOptions {
window_builder: Some(window_builder),
..Default::default()
}
}
pub fn app_icon() -> &'static [u8; 271986] {
std::include_bytes!("../../../assets/damus-app-icon.png")
}
pub fn generate_mobile_emulator_native_options() -> eframe::NativeOptions {
generate_native_options_with_builder_modifiers(|builder| {
builder
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_inner_size([405.0, 915.0])
.with_icon(eframe::icon_data::from_png_bytes(app_icon()).expect("icon"))
})
}

View File

@@ -0,0 +1,132 @@
use egui::{style::Interaction, Color32, FontId, Style, Visuals};
use notedeck::{ColorTheme, NotedeckTextStyle};
use strum::IntoEnumIterator;
pub const PURPLE: Color32 = Color32::from_rgb(0xCC, 0x43, 0xC5);
const PURPLE_ALT: Color32 = Color32::from_rgb(0x82, 0x56, 0xDD);
//pub const DARK_BG: Color32 = egui::Color32::from_rgb(40, 44, 52);
pub const GRAY_SECONDARY: Color32 = Color32::from_rgb(0x8A, 0x8A, 0x8A);
const BLACK: Color32 = Color32::from_rgb(0x00, 0x00, 0x00);
const RED_700: Color32 = Color32::from_rgb(0xC7, 0x37, 0x5A);
const ORANGE_700: Color32 = Color32::from_rgb(0xF6, 0xB1, 0x4A);
// BACKGROUNDS
const SEMI_DARKER_BG: Color32 = Color32::from_rgb(0x39, 0x39, 0x39);
const DARKER_BG: Color32 = Color32::from_rgb(0x1F, 0x1F, 0x1F);
const DARK_BG: Color32 = Color32::from_rgb(0x2C, 0x2C, 0x2C);
const DARK_ISH_BG: Color32 = Color32::from_rgb(0x25, 0x25, 0x25);
const SEMI_DARK_BG: Color32 = Color32::from_rgb(0x44, 0x44, 0x44);
const LIGHTER_GRAY: Color32 = Color32::from_rgb(0xf8, 0xf8, 0xf8);
const LIGHT_GRAY: Color32 = Color32::from_rgb(0xc8, 0xc8, 0xc8); // 78%
const DARKER_GRAY: Color32 = Color32::from_rgb(0xa5, 0xa5, 0xa5); // 65%
const EVEN_DARKER_GRAY: Color32 = Color32::from_rgb(0x89, 0x89, 0x89); // 54%
pub fn desktop_dark_color_theme() -> ColorTheme {
ColorTheme {
// VISUALS
panel_fill: DARKER_BG,
extreme_bg_color: DARK_ISH_BG,
text_color: Color32::WHITE,
err_fg_color: RED_700,
warn_fg_color: ORANGE_700,
hyperlink_color: PURPLE,
selection_color: PURPLE_ALT,
// WINDOW
window_fill: DARK_ISH_BG,
window_stroke_color: DARK_BG,
// NONINTERACTIVE WIDGET
noninteractive_bg_fill: DARK_ISH_BG,
noninteractive_weak_bg_fill: DARK_BG,
noninteractive_bg_stroke_color: SEMI_DARKER_BG,
noninteractive_fg_stroke_color: GRAY_SECONDARY,
// INACTIVE WIDGET
inactive_bg_stroke_color: SEMI_DARKER_BG,
inactive_bg_fill: Color32::from_rgb(0x25, 0x25, 0x25),
inactive_weak_bg_fill: SEMI_DARK_BG,
}
}
pub fn mobile_dark_color_theme() -> ColorTheme {
ColorTheme {
panel_fill: Color32::BLACK,
noninteractive_weak_bg_fill: Color32::from_rgb(0x1F, 0x1F, 0x1F),
..desktop_dark_color_theme()
}
}
pub fn light_color_theme() -> ColorTheme {
ColorTheme {
// VISUALS
panel_fill: Color32::WHITE,
extreme_bg_color: LIGHTER_GRAY,
text_color: BLACK,
err_fg_color: RED_700,
warn_fg_color: ORANGE_700,
hyperlink_color: PURPLE,
selection_color: PURPLE_ALT,
// WINDOW
window_fill: Color32::WHITE,
window_stroke_color: DARKER_GRAY,
// NONINTERACTIVE WIDGET
noninteractive_bg_fill: Color32::WHITE,
noninteractive_weak_bg_fill: LIGHTER_GRAY,
noninteractive_bg_stroke_color: LIGHT_GRAY,
noninteractive_fg_stroke_color: GRAY_SECONDARY,
// INACTIVE WIDGET
inactive_bg_stroke_color: EVEN_DARKER_GRAY,
inactive_bg_fill: LIGHT_GRAY,
inactive_weak_bg_fill: EVEN_DARKER_GRAY,
}
}
pub fn light_mode() -> Visuals {
notedeck::theme::create_themed_visuals(light_color_theme(), Visuals::light())
}
pub fn dark_mode(mobile: bool) -> Visuals {
notedeck::theme::create_themed_visuals(
if mobile {
mobile_dark_color_theme()
} else {
desktop_dark_color_theme()
},
Visuals::dark(),
)
}
/// Create custom text sizes for any FontSizes
pub fn add_custom_style(is_mobile: bool, style: &mut Style) {
let font_size = if is_mobile {
notedeck::fonts::mobile_font_size
} else {
notedeck::fonts::desktop_font_size
};
style.text_styles = NotedeckTextStyle::iter()
.map(|text_style| {
(
text_style.text_style(),
FontId::new(font_size(&text_style), text_style.font_family()),
)
})
.collect();
style.interaction = Interaction {
tooltip_delay: 0.1,
show_tooltips_only_when_still: false,
..Interaction::default()
};
#[cfg(debug_assertions)]
{
style.debug.show_interactive_widgets = true;
style.debug.debug_on_hover_with_all_modifiers = true;
}
}

View File

@@ -11,10 +11,11 @@ description = "A tweetdeck-style notedeck app"
crate-type = ["lib", "cdylib"]
[dependencies]
base32 = { workspace = true }
notedeck = { workspace = true }
bitflags = { workspace = true }
dirs = { workspace = true }
eframe = { workspace = true }
thiserror = { workspace = true }
egui = { workspace = true }
egui_extras = { workspace = true }
egui_nav = { workspace = true }
@@ -47,7 +48,7 @@ urlencoding = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
tempfile = "3.13.0"
tempfile = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "2.11.0"

View File

@@ -1,245 +1,23 @@
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use enostr::FullKeypair;
use nostrdb::Ndb;
use url::Url;
use uuid::Uuid;
use enostr::{ClientMessage, FilledKeypair, FullKeypair, Keypair, RelayPool};
use nostrdb::{Filter, Ndb, Note, NoteKey, Subscription, Transaction};
use notedeck::{Accounts, AccountsAction, AddAccountAction, ImageCache, SingleUnkIdAction};
use crate::app::get_active_columns_mut;
use crate::decks::DecksCache;
use crate::{
imgcache::ImageCache,
login_manager::AcquireKeyState,
muted::Muted,
route::Route,
storage::{KeyStorageResponse, KeyStorageType},
ui::{
account_login_view::{AccountLoginResponse, AccountLoginView},
accounts::{AccountsView, AccountsViewResponse},
},
unknowns::SingleUnkIdAction,
unknowns::UnknownIds,
user_account::UserAccount,
};
use tracing::{debug, error, info};
use tracing::info;
mod route;
pub use route::{AccountsAction, AccountsRoute, AccountsRouteResponse};
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
}
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,
}
#[must_use = "You must call process_login_action on this to handle unknown ids"]
pub struct RenderAccountAction {
pub accounts_action: Option<AccountsAction>,
pub unk_id_action: SingleUnkIdAction,
}
impl RenderAccountAction {
// 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);
}
}
pub use route::{AccountsRoute, AccountsRouteResponse};
/// Render account management views from a route
#[allow(clippy::too_many_arguments)]
@@ -252,7 +30,7 @@ pub fn render_accounts_route(
decks: &mut DecksCache,
login_state: &mut AcquireKeyState,
route: AccountsRoute,
) -> RenderAccountAction {
) -> AddAccountAction {
let resp = match route {
AccountsRoute::Accounts => AccountsView::new(ndb, accounts, img_cache)
.ui(ui)
@@ -269,7 +47,7 @@ pub fn render_accounts_route(
match resp {
AccountsRouteResponse::Accounts(response) => {
let action = process_accounts_view_response(accounts, decks, col, response);
RenderAccountAction {
AddAccountAction {
accounts_action: action,
unk_id_action: SingleUnkIdAction::no_action(),
}
@@ -285,7 +63,7 @@ pub fn render_accounts_route(
}
}
} else {
RenderAccountAction {
AddAccountAction {
accounts_action: None,
unk_id_action: SingleUnkIdAction::no_action(),
}
@@ -321,343 +99,11 @@ pub fn process_accounts_view_response(
selection
}
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) -> RenderAccountAction {
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
};
RenderAccountAction {
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
}
pub fn process_login_view_response(
manager: &mut Accounts,
decks: &mut DecksCache,
response: AccountLoginResponse,
) -> RenderAccountAction {
) -> AddAccountAction {
let (r, pubkey) = match response {
AccountLoginResponse::CreateNew => {
let kp = FullKeypair::generate().to_keypair();
@@ -674,9 +120,3 @@ pub fn process_login_view_response(
r
}
#[derive(Default)]
struct ContainsAccount {
pub has_nsec: bool,
pub index: usize,
}

View File

@@ -11,9 +11,3 @@ pub enum AccountsRoute {
Accounts,
AddAccount,
}
#[derive(Debug)]
pub enum AccountsAction {
Switch(usize),
Remove(usize),
}

Some files were not shown because too many files have changed in this diff Show More