notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

app.rs (36965B)


      1 use crate::account_manager::AccountManager;
      2 use crate::actionbar::BarResult;
      3 use crate::app_creation::setup_cc;
      4 use crate::app_style::user_requested_visuals_change;
      5 use crate::args::Args;
      6 use crate::column::ColumnKind;
      7 use crate::draft::Drafts;
      8 use crate::error::{Error, FilterError};
      9 use crate::filter::FilterState;
     10 use crate::frame_history::FrameHistory;
     11 use crate::imgcache::ImageCache;
     12 use crate::key_storage::KeyStorageType;
     13 use crate::note::NoteRef;
     14 use crate::notecache::{CachedNote, NoteCache};
     15 use crate::relay_pool_manager::RelayPoolManager;
     16 use crate::route::Route;
     17 use crate::subscriptions::{SubKind, Subscriptions};
     18 use crate::thread::{DecrementResult, Threads};
     19 use crate::timeline::{Timeline, TimelineSource, ViewFilter};
     20 use crate::ui::note::PostAction;
     21 use crate::ui::{self, AccountSelectionWidget, DesktopGlobalPopup};
     22 use crate::ui::{DesktopSidePanel, RelayView, View};
     23 use crate::unknowns::UnknownIds;
     24 use crate::{filter, Result};
     25 use egui_nav::{Nav, NavAction};
     26 use enostr::{ClientMessage, RelayEvent, RelayMessage, RelayPool};
     27 use std::cell::RefCell;
     28 use std::rc::Rc;
     29 use uuid::Uuid;
     30 
     31 use egui::{Context, Frame, Style};
     32 use egui_extras::{Size, StripBuilder};
     33 
     34 use nostrdb::{Config, Filter, Ndb, Note, Transaction};
     35 
     36 use std::collections::HashMap;
     37 use std::path::Path;
     38 use std::time::Duration;
     39 use tracing::{debug, error, info, trace, warn};
     40 
     41 #[derive(Debug, Eq, PartialEq, Clone)]
     42 pub enum DamusState {
     43     Initializing,
     44     Initialized,
     45 }
     46 
     47 /// We derive Deserialize/Serialize so we can persist app state on shutdown.
     48 pub struct Damus {
     49     state: DamusState,
     50     note_cache: NoteCache,
     51     pub pool: RelayPool,
     52 
     53     /// global navigation for account management popups, etc.
     54     pub global_nav: Vec<Route>,
     55 
     56     pub timelines: Vec<Timeline>,
     57     pub selected_timeline: i32,
     58 
     59     pub ndb: Ndb,
     60     pub unknown_ids: UnknownIds,
     61     pub drafts: Drafts,
     62     pub threads: Threads,
     63     pub img_cache: ImageCache,
     64     pub account_manager: AccountManager,
     65     pub subscriptions: Subscriptions,
     66 
     67     frame_history: crate::frame_history::FrameHistory,
     68 
     69     // TODO: make these flags
     70     is_mobile: bool,
     71     pub debug: bool,
     72     pub since_optimize: bool,
     73     pub textmode: bool,
     74     pub show_account_switcher: bool,
     75     pub show_global_popup: bool,
     76 }
     77 
     78 fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) {
     79     let ctx = ctx.clone();
     80     let wakeup = move || {
     81         ctx.request_repaint();
     82     };
     83     if let Err(e) = pool.add_url("ws://localhost:8080".to_string(), wakeup.clone()) {
     84         error!("{:?}", e)
     85     }
     86     if let Err(e) = pool.add_url("wss://relay.damus.io".to_string(), wakeup.clone()) {
     87         error!("{:?}", e)
     88     }
     89     //if let Err(e) = pool.add_url("wss://pyramid.fiatjaf.com".to_string(), wakeup.clone()) {
     90     //error!("{:?}", e)
     91     //}
     92     if let Err(e) = pool.add_url("wss://nos.lol".to_string(), wakeup.clone()) {
     93         error!("{:?}", e)
     94     }
     95     if let Err(e) = pool.add_url("wss://nostr.wine".to_string(), wakeup.clone()) {
     96         error!("{:?}", e)
     97     }
     98     if let Err(e) = pool.add_url("wss://purplepag.es".to_string(), wakeup) {
     99         error!("{:?}", e)
    100     }
    101 }
    102 
    103 fn send_initial_timeline_filter(damus: &mut Damus, timeline: usize, to: &str) {
    104     let can_since_optimize = damus.since_optimize;
    105 
    106     let filter_state = damus.timelines[timeline].filter.clone();
    107 
    108     match filter_state {
    109         FilterState::Broken(err) => {
    110             error!(
    111                 "FetchingRemote state in broken state when sending initial timeline filter? {err}"
    112             );
    113         }
    114 
    115         FilterState::FetchingRemote(_unisub) => {
    116             error!("FetchingRemote state when sending initial timeline filter?");
    117         }
    118 
    119         FilterState::GotRemote(_sub) => {
    120             error!("GotRemote state when sending initial timeline filter?");
    121         }
    122 
    123         FilterState::Ready(filter) => {
    124             let filter = filter.to_owned();
    125             let new_filters = filter.into_iter().map(|f| {
    126                 // limit the size of remote filters
    127                 let default_limit = filter::default_remote_limit();
    128                 let mut lim = f.limit().unwrap_or(default_limit);
    129                 let mut filter = f;
    130                 if lim > default_limit {
    131                     lim = default_limit;
    132                     filter = filter.limit_mut(lim);
    133                 }
    134 
    135                 let notes = damus.timelines[timeline].notes(ViewFilter::NotesAndReplies);
    136 
    137                 // Should we since optimize? Not always. For example
    138                 // if we only have a few notes locally. One way to
    139                 // determine this is by looking at the current filter
    140                 // and seeing what its limit is. If we have less
    141                 // notes than the limit, we might want to backfill
    142                 // older notes
    143                 if can_since_optimize && filter::should_since_optimize(lim, notes.len()) {
    144                     filter = filter::since_optimize_filter(filter, notes);
    145                 } else {
    146                     warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", filter);
    147                 }
    148 
    149                 filter
    150             }).collect();
    151 
    152             let sub_id = damus.gen_subid(&SubKind::Initial);
    153             damus
    154                 .subscriptions()
    155                 .insert(sub_id.clone(), SubKind::Initial);
    156 
    157             let cmd = ClientMessage::req(sub_id, new_filters);
    158             damus.pool.send_to(&cmd, to);
    159         }
    160 
    161         // we need some data first
    162         FilterState::NeedsRemote(filter) => {
    163             let uid = damus.timelines[timeline].uid;
    164             let sub_kind = SubKind::FetchingContactList(uid);
    165             let sub_id = damus.gen_subid(&sub_kind);
    166             let local_sub = damus.ndb.subscribe(&filter).expect("sub");
    167 
    168             damus.timelines[timeline].filter =
    169                 FilterState::fetching_remote(sub_id.clone(), local_sub);
    170 
    171             damus.subscriptions().insert(sub_id.clone(), sub_kind);
    172 
    173             damus.pool.subscribe(sub_id, filter.to_owned());
    174         }
    175     }
    176 }
    177 
    178 fn send_initial_filters(damus: &mut Damus, relay_url: &str) {
    179     info!("Sending initial filters to {}", relay_url);
    180     let timelines = damus.timelines.len();
    181 
    182     for i in 0..timelines {
    183         send_initial_timeline_filter(damus, i, relay_url);
    184     }
    185 }
    186 
    187 enum ContextAction {
    188     SetPixelsPerPoint(f32),
    189 }
    190 
    191 fn handle_key_events(
    192     input: &egui::InputState,
    193     pixels_per_point: f32,
    194     damus: &mut Damus,
    195 ) -> Option<ContextAction> {
    196     let amount = 0.2;
    197 
    198     // We can't do things like setting the pixels_per_point when we are holding
    199     // on to an locked InputState context, so we need to pass actions externally
    200     let mut context_action: Option<ContextAction> = None;
    201 
    202     for event in &input.raw.events {
    203         if let egui::Event::Key {
    204             key, pressed: true, ..
    205         } = event
    206         {
    207             match key {
    208                 egui::Key::Equals => {
    209                     context_action =
    210                         Some(ContextAction::SetPixelsPerPoint(pixels_per_point + amount));
    211                 }
    212                 egui::Key::Minus => {
    213                     context_action =
    214                         Some(ContextAction::SetPixelsPerPoint(pixels_per_point - amount));
    215                 }
    216                 egui::Key::J => {
    217                     damus.select_down();
    218                 }
    219                 egui::Key::K => {
    220                     damus.select_up();
    221                 }
    222                 egui::Key::H => {
    223                     damus.select_left();
    224                 }
    225                 egui::Key::L => {
    226                     damus.select_left();
    227                 }
    228                 _ => {}
    229             }
    230         }
    231     }
    232 
    233     context_action
    234 }
    235 
    236 fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> {
    237     let ppp = ctx.pixels_per_point();
    238     let res = ctx.input(|i| handle_key_events(i, ppp, damus));
    239     if let Some(action) = res {
    240         match action {
    241             ContextAction::SetPixelsPerPoint(amt) => {
    242                 ctx.set_pixels_per_point(amt);
    243             }
    244         }
    245     }
    246 
    247     let ctx2 = ctx.clone();
    248     let wakeup = move || {
    249         ctx2.request_repaint();
    250     };
    251     damus.pool.keepalive_ping(wakeup);
    252 
    253     // pool stuff
    254     while let Some(ev) = damus.pool.try_recv() {
    255         let relay = ev.relay.to_owned();
    256 
    257         match (&ev.event).into() {
    258             RelayEvent::Opened => send_initial_filters(damus, &relay),
    259             // TODO: handle reconnects
    260             RelayEvent::Closed => warn!("{} connection closed", &relay),
    261             RelayEvent::Error(e) => error!("{}: {}", &relay, e),
    262             RelayEvent::Other(msg) => trace!("other event {:?}", &msg),
    263             RelayEvent::Message(msg) => process_message(damus, &relay, &msg),
    264         }
    265     }
    266 
    267     for timeline in 0..damus.timelines.len() {
    268         let src = TimelineSource::column(timeline);
    269 
    270         if let Ok(true) = is_timeline_ready(damus, timeline) {
    271             let txn = Transaction::new(&damus.ndb).expect("txn");
    272             if let Err(err) = src.poll_notes_into_view(&txn, damus) {
    273                 error!("poll_notes_into_view: {err}");
    274             }
    275         } else {
    276             // TODO: show loading?
    277         }
    278     }
    279 
    280     if damus.unknown_ids.ready_to_send() {
    281         unknown_id_send(damus);
    282     }
    283 
    284     Ok(())
    285 }
    286 
    287 fn unknown_id_send(damus: &mut Damus) {
    288     let filter = damus.unknown_ids.filter().expect("filter");
    289     info!(
    290         "Getting {} unknown ids from relays",
    291         damus.unknown_ids.ids().len()
    292     );
    293     let msg = ClientMessage::req("unknownids".to_string(), filter);
    294     damus.unknown_ids.clear();
    295     damus.pool.send(&msg);
    296 }
    297 
    298 /// Check our timeline filter and see if we have any filter data ready.
    299 /// Our timelines may require additional data before it is functional. For
    300 /// example, when we have to fetch a contact list before we do the actual
    301 /// following list query.
    302 fn is_timeline_ready(damus: &mut Damus, timeline: usize) -> Result<bool> {
    303     let sub = match &damus.timelines[timeline].filter {
    304         FilterState::GotRemote(sub) => *sub,
    305         FilterState::Ready(_f) => return Ok(true),
    306         _ => return Ok(false),
    307     };
    308 
    309     // We got at least one eose for our filter request. Let's see
    310     // if nostrdb is done processing it yet.
    311     let res = damus.ndb.poll_for_notes(sub, 1);
    312     if res.is_empty() {
    313         debug!("check_timeline_filter_state: no notes found (yet?) for timeline {timeline}");
    314         return Ok(false);
    315     }
    316 
    317     info!("notes found for contact timeline after GotRemote!");
    318 
    319     let note_key = res[0];
    320 
    321     let filter = {
    322         let txn = Transaction::new(&damus.ndb).expect("txn");
    323         let note = damus.ndb.get_note_by_key(&txn, note_key).expect("note");
    324         filter::filter_from_tags(&note).map(|f| f.into_follow_filter())
    325     };
    326 
    327     // TODO: into_follow_filter is hardcoded to contact lists, let's generalize
    328     match filter {
    329         Err(Error::Filter(e)) => {
    330             error!("got broken when building filter {e}");
    331             damus.timelines[timeline].filter = FilterState::broken(e);
    332         }
    333         Err(err) => {
    334             error!("got broken when building filter {err}");
    335             damus.timelines[timeline].filter = FilterState::broken(FilterError::EmptyContactList);
    336             return Err(err);
    337         }
    338         Ok(filter) => {
    339             // we just switched to the ready state, we should send initial
    340             // queries and setup the local subscription
    341             info!("Found contact list! Setting up local and remote contact list query");
    342             setup_initial_timeline(damus, timeline, &filter).expect("setup init");
    343             damus.timelines[timeline].filter = FilterState::ready(filter.clone());
    344 
    345             let ck = &damus.timelines[timeline].kind;
    346             let subid = damus.gen_subid(&SubKind::Column(ck.clone()));
    347             damus.pool.subscribe(subid, filter)
    348         }
    349     }
    350 
    351     Ok(true)
    352 }
    353 
    354 #[cfg(feature = "profiling")]
    355 fn setup_profiling() {
    356     puffin::set_scopes_on(true); // tell puffin to collect data
    357 }
    358 
    359 fn setup_initial_timeline(damus: &mut Damus, timeline: usize, filters: &[Filter]) -> Result<()> {
    360     damus.timelines[timeline].subscription = Some(damus.ndb.subscribe(filters)?);
    361     let txn = Transaction::new(&damus.ndb)?;
    362     debug!(
    363         "querying nostrdb sub {:?} {:?}",
    364         damus.timelines[timeline].subscription, damus.timelines[timeline].filter
    365     );
    366     let lim = filters[0].limit().unwrap_or(crate::filter::default_limit()) as i32;
    367     let results = damus.ndb.query(&txn, filters, lim)?;
    368 
    369     let filters = {
    370         let views = &damus.timelines[timeline].views;
    371         let filters: Vec<fn(&CachedNote, &Note) -> bool> =
    372             views.iter().map(|v| v.filter.filter()).collect();
    373         filters
    374     };
    375 
    376     for result in results {
    377         for (view, filter) in filters.iter().enumerate() {
    378             if filter(
    379                 damus
    380                     .note_cache_mut()
    381                     .cached_note_or_insert_mut(result.note_key, &result.note),
    382                 &result.note,
    383             ) {
    384                 damus.timelines[timeline].views[view].notes.push(NoteRef {
    385                     key: result.note_key,
    386                     created_at: result.note.created_at(),
    387                 })
    388             }
    389         }
    390     }
    391 
    392     Ok(())
    393 }
    394 
    395 fn setup_initial_nostrdb_subs(damus: &mut Damus) -> Result<()> {
    396     let timelines = damus.timelines.len();
    397     for i in 0..timelines {
    398         let filter = damus.timelines[i].filter.clone();
    399         match filter {
    400             FilterState::Ready(filters) => setup_initial_timeline(damus, i, &filters)?,
    401 
    402             FilterState::Broken(err) => {
    403                 error!("FetchingRemote state broken in setup_initial_nostr_subs: {err}")
    404             }
    405             FilterState::FetchingRemote(_) => {
    406                 error!("FetchingRemote state in setup_initial_nostr_subs")
    407             }
    408             FilterState::GotRemote(_) => {
    409                 error!("GotRemote state in setup_initial_nostr_subs")
    410             }
    411             FilterState::NeedsRemote(_filters) => {
    412                 // can't do anything yet, we defer to first connect to send
    413                 // remote filters
    414             }
    415         }
    416     }
    417 
    418     Ok(())
    419 }
    420 
    421 fn update_damus(damus: &mut Damus, ctx: &egui::Context) {
    422     if damus.state == DamusState::Initializing {
    423         #[cfg(feature = "profiling")]
    424         setup_profiling();
    425 
    426         damus.state = DamusState::Initialized;
    427         // this lets our eose handler know to close unknownids right away
    428         damus
    429             .subscriptions()
    430             .insert("unknownids".to_string(), SubKind::OneShot);
    431         setup_initial_nostrdb_subs(damus).expect("home subscription failed");
    432     }
    433 
    434     if let Err(err) = try_process_event(damus, ctx) {
    435         error!("error processing event: {}", err);
    436     }
    437 }
    438 
    439 fn process_event(damus: &mut Damus, _subid: &str, event: &str) {
    440     #[cfg(feature = "profiling")]
    441     puffin::profile_function!();
    442 
    443     //info!("processing event {}", event);
    444     if let Err(_err) = damus.ndb.process_event(event) {
    445         error!("error processing event {}", event);
    446     }
    447 }
    448 
    449 fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> {
    450     let sub_kind = if let Some(sub_kind) = damus.subscriptions().get(subid) {
    451         sub_kind
    452     } else {
    453         let n_subids = damus.subscriptions().len();
    454         warn!(
    455             "got unknown eose subid {}, {} tracked subscriptions",
    456             subid, n_subids
    457         );
    458         return Ok(());
    459     };
    460 
    461     match *sub_kind {
    462         SubKind::Column(_) => {
    463             // eose on column? whatevs
    464         }
    465         SubKind::Initial => {
    466             let txn = Transaction::new(&damus.ndb)?;
    467             UnknownIds::update(&txn, damus);
    468             // this is possible if this is the first time
    469             if damus.unknown_ids.ready_to_send() {
    470                 unknown_id_send(damus);
    471             }
    472         }
    473 
    474         // oneshot subs just close when they're done
    475         SubKind::OneShot => {
    476             let msg = ClientMessage::close(subid.to_string());
    477             damus.pool.send_to(&msg, relay_url);
    478         }
    479 
    480         SubKind::FetchingContactList(timeline_uid) => {
    481             let timeline_ind = if let Some(i) = damus.find_timeline(timeline_uid) {
    482                 i
    483             } else {
    484                 error!(
    485                     "timeline uid:{} not found for FetchingContactList",
    486                     timeline_uid
    487                 );
    488                 return Ok(());
    489             };
    490 
    491             let local_sub = if let FilterState::FetchingRemote(unisub) =
    492                 &damus.timelines[timeline_ind].filter
    493             {
    494                 unisub.local
    495             } else {
    496                 // TODO: we could have multiple contact list results, we need
    497                 // to check to see if this one is newer and use that instead
    498                 warn!(
    499                     "Expected timeline to have FetchingRemote state but was {:?}",
    500                     damus.timelines[timeline_ind].filter
    501                 );
    502                 return Ok(());
    503             };
    504 
    505             damus.timelines[timeline_ind].filter = FilterState::got_remote(local_sub);
    506 
    507             /*
    508             // see if we're fast enough to catch a processed contact list
    509             let note_keys = damus.ndb.poll_for_notes(local_sub, 1);
    510             if !note_keys.is_empty() {
    511                 debug!("fast! caught contact list from {relay_url} right away");
    512                 let txn = Transaction::new(&damus.ndb)?;
    513                 let note_key = note_keys[0];
    514                 let nr = damus.ndb.get_note_by_key(&txn, note_key)?;
    515                 let filter = filter::filter_from_tags(&nr)?.into_follow_filter();
    516                 setup_initial_timeline(damus, timeline, &filter)
    517                 damus.timelines[timeline_ind].filter = FilterState::ready(filter);
    518             }
    519             */
    520         }
    521     }
    522 
    523     Ok(())
    524 }
    525 
    526 fn process_message(damus: &mut Damus, relay: &str, msg: &RelayMessage) {
    527     match msg {
    528         RelayMessage::Event(subid, ev) => process_event(damus, subid, ev),
    529         RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg),
    530         RelayMessage::OK(cr) => info!("OK {:?}", cr),
    531         RelayMessage::Eose(sid) => {
    532             if let Err(err) = handle_eose(damus, sid, relay) {
    533                 error!("error handling eose: {}", err);
    534             }
    535         }
    536     }
    537 }
    538 
    539 fn render_damus(damus: &mut Damus, ctx: &Context) {
    540     if damus.is_mobile() {
    541         render_damus_mobile(ctx, damus);
    542     } else {
    543         render_damus_desktop(ctx, damus);
    544     }
    545 
    546     ctx.request_repaint_after(Duration::from_secs(1));
    547 
    548     #[cfg(feature = "profiling")]
    549     puffin_egui::profiler_window(ctx);
    550 }
    551 
    552 /*
    553 fn determine_key_storage_type() -> KeyStorageType {
    554     #[cfg(target_os = "macos")]
    555     {
    556         KeyStorageType::MacOS
    557     }
    558 
    559     #[cfg(target_os = "linux")]
    560     {
    561         KeyStorageType::Linux
    562     }
    563 
    564     #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    565     {
    566         KeyStorageType::None
    567     }
    568 }
    569 */
    570 
    571 impl Damus {
    572     /// Called once before the first frame.
    573     pub fn new<P: AsRef<Path>>(
    574         cc: &eframe::CreationContext<'_>,
    575         data_path: P,
    576         args: Vec<String>,
    577     ) -> Self {
    578         // arg parsing
    579         let parsed_args = Args::parse(&args);
    580         let is_mobile = parsed_args.is_mobile.unwrap_or(ui::is_compiled_as_mobile());
    581 
    582         setup_cc(cc, is_mobile, parsed_args.light);
    583 
    584         let dbpath = parsed_args
    585             .dbpath
    586             .unwrap_or(data_path.as_ref().to_str().expect("db path ok").to_string());
    587 
    588         let _ = std::fs::create_dir_all(dbpath.clone());
    589 
    590         let imgcache_dir = data_path.as_ref().join(ImageCache::rel_datadir());
    591         let _ = std::fs::create_dir_all(imgcache_dir.clone());
    592 
    593         let mut config = Config::new();
    594         config.set_ingester_threads(4);
    595 
    596         let mut account_manager = AccountManager::new(
    597             // TODO: should pull this from settings
    598             None,
    599             // TODO: use correct KeyStorage mechanism for current OS arch
    600             KeyStorageType::None,
    601         );
    602 
    603         for key in parsed_args.keys {
    604             info!("adding account: {}", key.pubkey);
    605             account_manager.add_account(key);
    606         }
    607 
    608         // TODO: pull currently selected account from settings
    609         if account_manager.num_accounts() > 0 {
    610             account_manager.select_account(0);
    611         }
    612 
    613         // setup relays if we have them
    614         let pool = if parsed_args.relays.is_empty() {
    615             let mut pool = RelayPool::new();
    616             relay_setup(&mut pool, &cc.egui_ctx);
    617             pool
    618         } else {
    619             let ctx = cc.egui_ctx.clone();
    620             let wakeup = move || {
    621                 ctx.request_repaint();
    622             };
    623             let mut pool = RelayPool::new();
    624             for relay in parsed_args.relays {
    625                 if let Err(e) = pool.add_url(relay.clone(), wakeup.clone()) {
    626                     error!("error adding relay {}: {}", relay, e);
    627                 }
    628             }
    629             pool
    630         };
    631 
    632         let account = account_manager
    633             .get_selected_account()
    634             .as_ref()
    635             .map(|a| a.pubkey.bytes());
    636         let ndb = Ndb::new(&dbpath, &config).expect("ndb");
    637 
    638         let mut timelines: Vec<Timeline> = Vec::with_capacity(parsed_args.columns.len());
    639         for col in parsed_args.columns {
    640             if let Some(timeline) = col.into_timeline(&ndb, account) {
    641                 timelines.push(timeline);
    642             }
    643         }
    644 
    645         let debug = parsed_args.debug;
    646 
    647         Self {
    648             pool,
    649             debug,
    650             is_mobile,
    651             unknown_ids: UnknownIds::default(),
    652             subscriptions: Subscriptions::default(),
    653             since_optimize: parsed_args.since_optimize,
    654             threads: Threads::default(),
    655             drafts: Drafts::default(),
    656             state: DamusState::Initializing,
    657             img_cache: ImageCache::new(imgcache_dir),
    658             note_cache: NoteCache::default(),
    659             selected_timeline: 0,
    660             timelines,
    661             textmode: false,
    662             ndb,
    663             account_manager,
    664             frame_history: FrameHistory::default(),
    665             show_account_switcher: false,
    666             show_global_popup: false,
    667             global_nav: Vec::new(),
    668         }
    669     }
    670 
    671     pub fn gen_subid(&self, kind: &SubKind) -> String {
    672         if self.debug {
    673             format!("{:?}", kind)
    674         } else {
    675             Uuid::new_v4().to_string()
    676         }
    677     }
    678 
    679     pub fn mock<P: AsRef<Path>>(data_path: P, is_mobile: bool) -> Self {
    680         let mut timelines: Vec<Timeline> = vec![];
    681         let filter = Filter::from_json(include_str!("../queries/global.json")).unwrap();
    682         timelines.push(Timeline::new(
    683             ColumnKind::Universe,
    684             FilterState::ready(vec![filter]),
    685         ));
    686 
    687         let imgcache_dir = data_path.as_ref().join(ImageCache::rel_datadir());
    688         let _ = std::fs::create_dir_all(imgcache_dir.clone());
    689         let debug = true;
    690 
    691         let mut config = Config::new();
    692         config.set_ingester_threads(2);
    693         Self {
    694             is_mobile,
    695             debug,
    696             unknown_ids: UnknownIds::default(),
    697             subscriptions: Subscriptions::default(),
    698             since_optimize: true,
    699             threads: Threads::default(),
    700             drafts: Drafts::default(),
    701             state: DamusState::Initializing,
    702             pool: RelayPool::new(),
    703             img_cache: ImageCache::new(imgcache_dir),
    704             note_cache: NoteCache::default(),
    705             selected_timeline: 0,
    706             timelines,
    707             textmode: false,
    708             ndb: Ndb::new(data_path.as_ref().to_str().expect("db path ok"), &config).expect("ndb"),
    709             account_manager: AccountManager::new(None, KeyStorageType::None),
    710             frame_history: FrameHistory::default(),
    711             show_account_switcher: false,
    712             show_global_popup: true,
    713             global_nav: Vec::new(),
    714         }
    715     }
    716 
    717     pub fn find_timeline(&self, uid: u32) -> Option<usize> {
    718         for (i, timeline) in self.timelines.iter().enumerate() {
    719             if timeline.uid == uid {
    720                 return Some(i);
    721             }
    722         }
    723 
    724         None
    725     }
    726 
    727     pub fn subscriptions(&mut self) -> &mut HashMap<String, SubKind> {
    728         &mut self.subscriptions.subs
    729     }
    730 
    731     pub fn note_cache_mut(&mut self) -> &mut NoteCache {
    732         &mut self.note_cache
    733     }
    734 
    735     pub fn note_cache(&self) -> &NoteCache {
    736         &self.note_cache
    737     }
    738 
    739     pub fn selected_timeline(&mut self) -> &mut Timeline {
    740         &mut self.timelines[self.selected_timeline as usize]
    741     }
    742 
    743     pub fn select_down(&mut self) {
    744         self.selected_timeline().current_view_mut().select_down();
    745     }
    746 
    747     pub fn select_up(&mut self) {
    748         self.selected_timeline().current_view_mut().select_up();
    749     }
    750 
    751     pub fn select_left(&mut self) {
    752         if self.selected_timeline - 1 < 0 {
    753             return;
    754         }
    755         self.selected_timeline -= 1;
    756     }
    757 
    758     pub fn select_right(&mut self) {
    759         if self.selected_timeline + 1 >= self.timelines.len() as i32 {
    760             return;
    761         }
    762         self.selected_timeline += 1;
    763     }
    764 
    765     pub fn is_mobile(&self) -> bool {
    766         self.is_mobile
    767     }
    768 }
    769 
    770 /*
    771 fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
    772     let stroke = ui.style().interact(&response).fg_stroke;
    773     let radius = egui::lerp(2.0..=3.0, openness);
    774     ui.painter()
    775         .circle_filled(response.rect.center(), radius, stroke.color);
    776 }
    777 */
    778 
    779 fn top_panel(ctx: &egui::Context) -> egui::TopBottomPanel {
    780     let top_margin = egui::Margin {
    781         top: 4.0,
    782         left: 8.0,
    783         right: 8.0,
    784         ..Default::default()
    785     };
    786 
    787     let frame = Frame {
    788         inner_margin: top_margin,
    789         fill: ctx.style().visuals.panel_fill,
    790         ..Default::default()
    791     };
    792 
    793     egui::TopBottomPanel::top("top_panel")
    794         .frame(frame)
    795         .show_separator_line(false)
    796 }
    797 
    798 fn render_panel(ctx: &egui::Context, app: &mut Damus, timeline_ind: usize) {
    799     top_panel(ctx).show(ctx, |ui| {
    800         ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
    801             ui.visuals_mut().button_frame = false;
    802 
    803             if let Some(new_visuals) =
    804                 user_requested_visuals_change(app.is_mobile(), ctx.style().visuals.dark_mode, ui)
    805             {
    806                 ctx.set_visuals(new_visuals)
    807             }
    808 
    809             if ui
    810                 .add(egui::Button::new("A").frame(false))
    811                 .on_hover_text("Text mode")
    812                 .clicked()
    813             {
    814                 app.textmode = !app.textmode;
    815             }
    816 
    817             /*
    818             if ui
    819                 .add(egui::Button::new("+").frame(false))
    820                 .on_hover_text("Add Timeline")
    821                 .clicked()
    822             {
    823                 app.n_panels += 1;
    824             }
    825 
    826             if app.n_panels != 1
    827                 && ui
    828                     .add(egui::Button::new("-").frame(false))
    829                     .on_hover_text("Remove Timeline")
    830                     .clicked()
    831             {
    832                 app.n_panels -= 1;
    833             }
    834             */
    835 
    836             //#[cfg(feature = "profiling")]
    837             {
    838                 ui.weak(format!(
    839                     "FPS: {:.2}, {:10.1}ms",
    840                     app.frame_history.fps(),
    841                     app.frame_history.mean_frame_time() * 1e3
    842                 ));
    843 
    844                 if !app.timelines.is_empty() {
    845                     ui.weak(format!(
    846                         "{} notes",
    847                         &app.timelines[timeline_ind]
    848                             .notes(ViewFilter::NotesAndReplies)
    849                             .len()
    850                     ));
    851                 }
    852             }
    853         });
    854     });
    855 }
    856 
    857 /// Local thread unsubscribe
    858 fn thread_unsubscribe(app: &mut Damus, id: &[u8; 32]) {
    859     let (unsubscribe, remote_subid) = {
    860         let txn = Transaction::new(&app.ndb).expect("txn");
    861         let root_id = crate::note::root_note_id_from_selected_id(app, &txn, id);
    862 
    863         let thread = app.threads.thread_mut(&app.ndb, &txn, root_id).get_ptr();
    864         let unsub = thread.decrement_sub();
    865 
    866         let mut remote_subid: Option<String> = None;
    867         if let Ok(DecrementResult::LastSubscriber(_subid)) = unsub {
    868             *thread.subscription_mut() = None;
    869             remote_subid = thread.remote_subscription().to_owned();
    870             *thread.remote_subscription_mut() = None;
    871         }
    872 
    873         (unsub, remote_subid)
    874     };
    875 
    876     match unsubscribe {
    877         Ok(DecrementResult::LastSubscriber(sub)) => {
    878             if let Err(e) = app.ndb.unsubscribe(sub) {
    879                 error!(
    880                     "failed to unsubscribe from thread: {e}, subid:{}, {} active subscriptions",
    881                     sub.id(),
    882                     app.ndb.subscription_count()
    883                 );
    884             } else {
    885                 info!(
    886                     "Unsubscribed from thread subid:{}. {} active subscriptions",
    887                     sub.id(),
    888                     app.ndb.subscription_count()
    889                 );
    890             }
    891 
    892             // unsub from remote
    893             if let Some(subid) = remote_subid {
    894                 app.pool.unsubscribe(subid);
    895             }
    896         }
    897 
    898         Ok(DecrementResult::ActiveSubscribers) => {
    899             info!(
    900                 "Keeping thread subscription. {} active subscriptions.",
    901                 app.ndb.subscription_count()
    902             );
    903             // do nothing
    904         }
    905 
    906         Err(e) => {
    907             // something is wrong!
    908             error!(
    909                 "Thread unsubscribe error: {e}. {} active subsciptions.",
    910                 app.ndb.subscription_count()
    911             );
    912         }
    913     }
    914 }
    915 
    916 fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut egui::Ui) {
    917     let navigating = app.timelines[timeline_ind].navigating;
    918     let returning = app.timelines[timeline_ind].returning;
    919     let app_ctx = Rc::new(RefCell::new(app));
    920 
    921     let nav_response = Nav::new(routes)
    922         .navigating(navigating)
    923         .returning(returning)
    924         .title(false)
    925         .show(ui, |ui, nav| match nav.top() {
    926             Route::Timeline(_n) => {
    927                 let app = &mut app_ctx.borrow_mut();
    928                 ui::TimelineView::new(app, timeline_ind).ui(ui);
    929                 None
    930             }
    931 
    932             Route::ManageAccount => {
    933                 ui.label("account management view");
    934                 None
    935             }
    936 
    937             Route::Relays => {
    938                 let pool = &mut app_ctx.borrow_mut().pool;
    939                 let manager = RelayPoolManager::new(pool);
    940                 RelayView::new(manager).ui(ui);
    941                 None
    942             }
    943 
    944             Route::Thread(id) => {
    945                 let app = &mut app_ctx.borrow_mut();
    946                 let result = ui::ThreadView::new(app, timeline_ind, id.bytes()).ui(ui);
    947 
    948                 if let Some(bar_result) = result {
    949                     match bar_result {
    950                         BarResult::NewThreadNotes(new_notes) => {
    951                             let thread = app.threads.thread_expected_mut(new_notes.root_id.bytes());
    952                             new_notes.process(thread);
    953                         }
    954                     }
    955                 }
    956 
    957                 None
    958             }
    959 
    960             Route::Reply(id) => {
    961                 let mut app = app_ctx.borrow_mut();
    962 
    963                 let txn = if let Ok(txn) = Transaction::new(&app.ndb) {
    964                     txn
    965                 } else {
    966                     ui.label("Reply to unknown note");
    967                     return None;
    968                 };
    969 
    970                 let note = if let Ok(note) = app.ndb.get_note_by_id(&txn, id.bytes()) {
    971                     note
    972                 } else {
    973                     ui.label("Reply to unknown note");
    974                     return None;
    975                 };
    976 
    977                 let id = egui::Id::new(("post", timeline_ind, note.key().unwrap()));
    978                 let response = egui::ScrollArea::vertical().show(ui, |ui| {
    979                     ui::PostReplyView::new(&mut app, &note)
    980                         .id_source(id)
    981                         .show(ui)
    982                 });
    983 
    984                 Some(response)
    985             }
    986         });
    987 
    988     let mut app = app_ctx.borrow_mut();
    989     if let Some(reply_response) = nav_response.inner {
    990         if let Some(PostAction::Post(_np)) = reply_response.inner.action {
    991             app.timelines[timeline_ind].returning = true;
    992         }
    993     }
    994 
    995     if let Some(NavAction::Returned) = nav_response.action {
    996         let popped = app.timelines[timeline_ind].routes.pop();
    997         if let Some(Route::Thread(id)) = popped {
    998             thread_unsubscribe(&mut app, id.bytes());
    999         }
   1000         app.timelines[timeline_ind].returning = false;
   1001     } else if let Some(NavAction::Navigated) = nav_response.action {
   1002         app.timelines[timeline_ind].navigating = false;
   1003     }
   1004 }
   1005 
   1006 fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) {
   1007     //render_panel(ctx, app, 0);
   1008 
   1009     #[cfg(feature = "profiling")]
   1010     puffin::profile_function!();
   1011 
   1012     //let routes = app.timelines[0].routes.clone();
   1013 
   1014     main_panel(&ctx.style(), app.is_mobile()).show(ctx, |ui| {
   1015         render_nav(app.timelines[0].routes.clone(), 0, app, ui);
   1016     });
   1017 }
   1018 
   1019 fn main_panel(style: &Style, mobile: bool) -> egui::CentralPanel {
   1020     let inner_margin = egui::Margin {
   1021         top: if mobile { 50.0 } else { 0.0 },
   1022         left: 0.0,
   1023         right: 0.0,
   1024         bottom: 0.0,
   1025     };
   1026     egui::CentralPanel::default().frame(Frame {
   1027         inner_margin,
   1028         fill: style.visuals.panel_fill,
   1029         ..Default::default()
   1030     })
   1031 }
   1032 
   1033 fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) {
   1034     render_panel(ctx, app, 0);
   1035     #[cfg(feature = "profiling")]
   1036     puffin::profile_function!();
   1037 
   1038     let screen_size = ctx.screen_rect().width();
   1039     let calc_panel_width = (screen_size / app.timelines.len() as f32) - 30.0;
   1040     let min_width = 320.0;
   1041     let need_scroll = calc_panel_width < min_width;
   1042     let panel_sizes = if need_scroll {
   1043         Size::exact(min_width)
   1044     } else {
   1045         Size::remainder()
   1046     };
   1047 
   1048     main_panel(&ctx.style(), app.is_mobile()).show(ctx, |ui| {
   1049         ui.spacing_mut().item_spacing.x = 0.0;
   1050         AccountSelectionWidget::ui(app, ui);
   1051         DesktopGlobalPopup::show(app.global_nav.clone(), app, ui);
   1052         if need_scroll {
   1053             egui::ScrollArea::horizontal().show(ui, |ui| {
   1054                 timelines_view(ui, panel_sizes, app, app.timelines.len());
   1055             });
   1056         } else {
   1057             timelines_view(ui, panel_sizes, app, app.timelines.len());
   1058         }
   1059     });
   1060 }
   1061 
   1062 fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, timelines: usize) {
   1063     StripBuilder::new(ui)
   1064         .size(Size::exact(40.0))
   1065         .sizes(sizes, timelines)
   1066         .clip(true)
   1067         .horizontal(|mut strip| {
   1068             strip.cell(|ui| {
   1069                 let rect = ui.available_rect_before_wrap();
   1070                 let side_panel = DesktopSidePanel::new(app).show(ui);
   1071 
   1072                 if side_panel.response.clicked() {
   1073                     info!("clicked {:?}", side_panel.action);
   1074                 }
   1075 
   1076                 DesktopSidePanel::perform_action(app, side_panel.action);
   1077 
   1078                 // vertical sidebar line
   1079                 ui.painter().vline(
   1080                     rect.right(),
   1081                     rect.y_range(),
   1082                     ui.visuals().widgets.noninteractive.bg_stroke,
   1083                 );
   1084             });
   1085 
   1086             for timeline_ind in 0..timelines {
   1087                 strip.cell(|ui| {
   1088                     let rect = ui.available_rect_before_wrap();
   1089                     render_nav(
   1090                         app.timelines[timeline_ind].routes.clone(),
   1091                         timeline_ind,
   1092                         app,
   1093                         ui,
   1094                     );
   1095 
   1096                     // vertical line
   1097                     ui.painter().vline(
   1098                         rect.right(),
   1099                         rect.y_range(),
   1100                         ui.visuals().widgets.noninteractive.bg_stroke,
   1101                     );
   1102                 });
   1103 
   1104                 //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind));
   1105             }
   1106         });
   1107 }
   1108 
   1109 impl eframe::App for Damus {
   1110     /// Called by the frame work to save state before shutdown.
   1111     fn save(&mut self, _storage: &mut dyn eframe::Storage) {
   1112         //eframe::set_value(storage, eframe::APP_KEY, self);
   1113     }
   1114 
   1115     /// Called each time the UI needs repainting, which may be many times per second.
   1116     /// Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`.
   1117     fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
   1118         self.frame_history
   1119             .on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage);
   1120 
   1121         #[cfg(feature = "profiling")]
   1122         puffin::GlobalProfiler::lock().new_frame();
   1123         update_damus(self, ctx);
   1124         render_damus(self, ctx);
   1125     }
   1126 }