- Added `SoftKeyboardContext` enum and support for calculating keyboard insets from both virtual and platform sources - Updated `AppContext` to provide `soft_keyboard_rect` for determining visible keyboard area - Adjusted UI rendering to shift content when input boxes intersect with the soft keyboard, preventing overlap - Modified `MainActivity` and Android manifest to use `windowSoftInputMode="adjustResize"` and updated window inset handling - Introduced helper functions (`include_input`, `input_rect`, `clear_input_rect`) in `notedeck_ui` for tracking focused input boxes - Fixed Android JNI keyboard height reporting to clamp negative values Together, these changes allow the app to correctly detect and respond to soft keyboard visibility on Android, ensuring input fields remain accessible when typing. Fixes: https://github.com/damus-io/notedeck/issues/946 Fixes: https://github.com/damus-io/notedeck/issues/1043
978 lines
31 KiB
Rust
978 lines
31 KiB
Rust
use crate::{
|
|
args::{ColumnsArgs, ColumnsFlag},
|
|
column::Columns,
|
|
decks::{Decks, DecksCache},
|
|
draft::Drafts,
|
|
nav::{self, ProcessNavResult},
|
|
onboarding::Onboarding,
|
|
options::AppOptions,
|
|
route::Route,
|
|
storage,
|
|
subscriptions::{SubKind, Subscriptions},
|
|
support::Support,
|
|
timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind},
|
|
toolbar::unseen_notification,
|
|
ui::{self, toolbar::toolbar, DesktopSidePanel, SidePanelAction},
|
|
view_state::ViewState,
|
|
Result,
|
|
};
|
|
use egui_extras::{Size, StripBuilder};
|
|
use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool};
|
|
use nostrdb::Transaction;
|
|
use notedeck::{
|
|
tr, ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, FilterState,
|
|
Images, JobsCache, Localization, NotedeckOptions, SettingsHandler, UnknownIds,
|
|
};
|
|
use notedeck_ui::{
|
|
media::{MediaViewer, MediaViewerFlags, MediaViewerState},
|
|
NoteOptions,
|
|
};
|
|
use std::collections::{BTreeSet, HashMap};
|
|
use std::path::Path;
|
|
use tracing::{debug, error, info, trace, warn};
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
|
pub enum DamusState {
|
|
Initializing,
|
|
Initialized,
|
|
}
|
|
|
|
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
|
|
pub struct Damus {
|
|
state: DamusState,
|
|
|
|
pub decks_cache: DecksCache,
|
|
pub view_state: ViewState,
|
|
pub drafts: Drafts,
|
|
pub timeline_cache: TimelineCache,
|
|
pub subscriptions: Subscriptions,
|
|
pub support: Support,
|
|
pub jobs: JobsCache,
|
|
pub threads: Threads,
|
|
|
|
//frame_history: crate::frame_history::FrameHistory,
|
|
|
|
// TODO: make these bitflags
|
|
/// Were columns loaded from the commandline? If so disable persistence.
|
|
pub options: AppOptions,
|
|
pub note_options: NoteOptions,
|
|
|
|
pub unrecognized_args: BTreeSet<String>,
|
|
|
|
/// keep track of follow packs
|
|
pub onboarding: Onboarding,
|
|
}
|
|
|
|
fn handle_egui_events(input: &egui::InputState, columns: &mut Columns) {
|
|
for event in &input.raw.events {
|
|
match event {
|
|
egui::Event::Key { key, pressed, .. } if *pressed => match key {
|
|
egui::Key::J => {
|
|
//columns.select_down();
|
|
{}
|
|
}
|
|
/*
|
|
egui::Key::K => {
|
|
columns.select_up();
|
|
}
|
|
egui::Key::H => {
|
|
columns.select_left();
|
|
}
|
|
egui::Key::L => {
|
|
columns.select_left();
|
|
}
|
|
*/
|
|
egui::Key::BrowserBack | egui::Key::Escape => {
|
|
columns.get_selected_router().go_back();
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
egui::Event::InsetsChanged => {
|
|
tracing::debug!("insets have changed!");
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn try_process_event(
|
|
damus: &mut Damus,
|
|
app_ctx: &mut AppContext<'_>,
|
|
ctx: &egui::Context,
|
|
) -> Result<()> {
|
|
let current_columns =
|
|
get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, &mut damus.decks_cache);
|
|
ctx.input(|i| handle_egui_events(i, current_columns));
|
|
|
|
let ctx2 = ctx.clone();
|
|
let wakeup = move || {
|
|
ctx2.request_repaint();
|
|
};
|
|
|
|
app_ctx.pool.keepalive_ping(wakeup);
|
|
|
|
// NOTE: we don't use the while let loop due to borrow issues
|
|
#[allow(clippy::while_let_loop)]
|
|
loop {
|
|
let ev = if let Some(ev) = app_ctx.pool.try_recv() {
|
|
ev.into_owned()
|
|
} else {
|
|
break;
|
|
};
|
|
|
|
match (&ev.event).into() {
|
|
RelayEvent::Opened => {
|
|
app_ctx
|
|
.accounts
|
|
.send_initial_filters(app_ctx.pool, &ev.relay);
|
|
|
|
timeline::send_initial_timeline_filters(
|
|
damus.options.contains(AppOptions::SinceOptimize),
|
|
&mut damus.timeline_cache,
|
|
&mut damus.subscriptions,
|
|
app_ctx.pool,
|
|
&ev.relay,
|
|
app_ctx.accounts,
|
|
);
|
|
}
|
|
// TODO: handle reconnects
|
|
RelayEvent::Closed => warn!("{} connection closed", &ev.relay),
|
|
RelayEvent::Error(e) => error!("{}: {}", &ev.relay, e),
|
|
RelayEvent::Other(msg) => trace!("other event {:?}", &msg),
|
|
RelayEvent::Message(msg) => {
|
|
process_message(damus, app_ctx, &ev.relay, &msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (kind, timeline) in &mut damus.timeline_cache {
|
|
let is_ready = timeline::is_timeline_ready(
|
|
app_ctx.ndb,
|
|
app_ctx.pool,
|
|
app_ctx.note_cache,
|
|
timeline,
|
|
app_ctx.accounts,
|
|
);
|
|
|
|
if is_ready {
|
|
let txn = Transaction::new(app_ctx.ndb).expect("txn");
|
|
// only thread timelines are reversed
|
|
let reversed = false;
|
|
|
|
if let Err(err) = timeline.poll_notes_into_view(
|
|
app_ctx.ndb,
|
|
&txn,
|
|
app_ctx.unknown_ids,
|
|
app_ctx.note_cache,
|
|
reversed,
|
|
) {
|
|
error!("poll_notes_into_view: {err}");
|
|
}
|
|
} else {
|
|
// TODO: show loading?
|
|
if matches!(kind, TimelineKind::List(ListKind::Contact(_))) {
|
|
timeline::fetch_contact_list(&mut damus.subscriptions, timeline, app_ctx.accounts);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(follow_packs) = damus.onboarding.get_follow_packs_mut() {
|
|
follow_packs.poll_for_notes(app_ctx.ndb, app_ctx.unknown_ids);
|
|
}
|
|
|
|
if app_ctx.unknown_ids.ready_to_send() {
|
|
unknown_id_send(app_ctx.unknown_ids, app_ctx.pool);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn unknown_id_send(unknown_ids: &mut UnknownIds, pool: &mut RelayPool) {
|
|
debug!("unknown_id_send called on: {:?}", &unknown_ids);
|
|
let filter = unknown_ids.filter().expect("filter");
|
|
debug!(
|
|
"Getting {} unknown ids from relays",
|
|
unknown_ids.ids_iter().len()
|
|
);
|
|
let msg = ClientMessage::req("unknownids".to_string(), filter);
|
|
unknown_ids.clear();
|
|
pool.send(&msg);
|
|
}
|
|
|
|
fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Context) {
|
|
app_ctx.img_cache.urls.cache.handle_io();
|
|
|
|
if damus.columns(app_ctx.accounts).columns().is_empty() {
|
|
damus
|
|
.columns_mut(app_ctx.i18n, app_ctx.accounts)
|
|
.new_column_picker();
|
|
}
|
|
|
|
match damus.state {
|
|
DamusState::Initializing => {
|
|
damus.state = DamusState::Initialized;
|
|
// this lets our eose handler know to close unknownids right away
|
|
damus
|
|
.subscriptions()
|
|
.insert("unknownids".to_string(), SubKind::OneShot);
|
|
if let Err(err) = timeline::setup_initial_nostrdb_subs(
|
|
app_ctx.ndb,
|
|
app_ctx.note_cache,
|
|
&mut damus.timeline_cache,
|
|
) {
|
|
warn!("update_damus init: {err}");
|
|
}
|
|
}
|
|
|
|
DamusState::Initialized => (),
|
|
};
|
|
|
|
if let Err(err) = try_process_event(damus, app_ctx, ctx) {
|
|
error!("error processing event: {}", err);
|
|
}
|
|
}
|
|
|
|
fn handle_eose(
|
|
subscriptions: &Subscriptions,
|
|
timeline_cache: &mut TimelineCache,
|
|
ctx: &mut AppContext<'_>,
|
|
subid: &str,
|
|
relay_url: &str,
|
|
) -> Result<()> {
|
|
let sub_kind = if let Some(sub_kind) = subscriptions.subs.get(subid) {
|
|
sub_kind
|
|
} else {
|
|
let n_subids = subscriptions.subs.len();
|
|
warn!(
|
|
"got unknown eose subid {}, {} tracked subscriptions",
|
|
subid, n_subids
|
|
);
|
|
return Ok(());
|
|
};
|
|
|
|
match sub_kind {
|
|
SubKind::Timeline(_) => {
|
|
// eose on timeline? whatevs
|
|
}
|
|
SubKind::Initial => {
|
|
//let txn = Transaction::new(ctx.ndb)?;
|
|
//unknowns::update_from_columns(
|
|
// &txn,
|
|
// ctx.unknown_ids,
|
|
// timeline_cache,
|
|
// ctx.ndb,
|
|
// ctx.note_cache,
|
|
//);
|
|
//// this is possible if this is the first time
|
|
//if ctx.unknown_ids.ready_to_send() {
|
|
// unknown_id_send(ctx.unknown_ids, ctx.pool);
|
|
//}
|
|
}
|
|
|
|
// oneshot subs just close when they're done
|
|
SubKind::OneShot => {
|
|
let msg = ClientMessage::close(subid.to_string());
|
|
ctx.pool.send_to(&msg, relay_url);
|
|
}
|
|
|
|
SubKind::FetchingContactList(timeline_uid) => {
|
|
let timeline = if let Some(tl) = timeline_cache.get_mut(timeline_uid) {
|
|
tl
|
|
} else {
|
|
error!(
|
|
"timeline uid:{:?} not found for FetchingContactList",
|
|
timeline_uid
|
|
);
|
|
return Ok(());
|
|
};
|
|
|
|
let filter_state = timeline.filter.get_mut(relay_url);
|
|
|
|
let FilterState::FetchingRemote(fetching_remote_type) = filter_state else {
|
|
// TODO: we could have multiple contact list results, we need
|
|
// to check to see if this one is newer and use that instead
|
|
warn!(
|
|
"Expected timeline to have FetchingRemote state but was {:?}",
|
|
timeline.filter
|
|
);
|
|
return Ok(());
|
|
};
|
|
|
|
let new_filter_state = match fetching_remote_type {
|
|
notedeck::filter::FetchingRemoteType::Normal(unified_subscription) => {
|
|
FilterState::got_remote(unified_subscription.local)
|
|
}
|
|
notedeck::filter::FetchingRemoteType::Contact => {
|
|
FilterState::GotRemote(notedeck::filter::GotRemoteType::Contact)
|
|
}
|
|
};
|
|
|
|
// We take the subscription id and pass it to the new state of
|
|
// "GotRemote". This will let future frames know that it can try
|
|
// to look for the contact list in nostrdb.
|
|
timeline
|
|
.filter
|
|
.set_relay_state(relay_url.to_string(), new_filter_state);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn process_message(damus: &mut Damus, ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessage) {
|
|
match msg {
|
|
RelayMessage::Event(_subid, ev) => {
|
|
let relay = if let Some(relay) = ctx.pool.relays.iter().find(|r| r.url() == relay) {
|
|
relay
|
|
} else {
|
|
error!("couldn't find relay {} for note processing!?", relay);
|
|
return;
|
|
};
|
|
|
|
match relay {
|
|
PoolRelay::Websocket(_) => {
|
|
//info!("processing event {}", event);
|
|
if let Err(err) = ctx.ndb.process_event_with(
|
|
ev,
|
|
nostrdb::IngestMetadata::new()
|
|
.client(false)
|
|
.relay(relay.url()),
|
|
) {
|
|
error!("error processing event {ev}: {err}");
|
|
}
|
|
}
|
|
PoolRelay::Multicast(_) => {
|
|
// multicast events are client events
|
|
if let Err(err) = ctx.ndb.process_event_with(
|
|
ev,
|
|
nostrdb::IngestMetadata::new()
|
|
.client(true)
|
|
.relay(relay.url()),
|
|
) {
|
|
error!("error processing multicast event {ev}: {err}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg),
|
|
RelayMessage::OK(cr) => info!("OK {:?}", cr),
|
|
RelayMessage::Eose(sid) => {
|
|
if let Err(err) = handle_eose(
|
|
&damus.subscriptions,
|
|
&mut damus.timeline_cache,
|
|
ctx,
|
|
sid,
|
|
relay,
|
|
) {
|
|
error!("error handling eose: {}", err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_damus(
|
|
damus: &mut Damus,
|
|
app_ctx: &mut AppContext<'_>,
|
|
ui: &mut egui::Ui,
|
|
) -> Option<AppAction> {
|
|
damus
|
|
.note_options
|
|
.set(NoteOptions::Wide, is_narrow(ui.ctx()));
|
|
|
|
let app_action = if notedeck::ui::is_narrow(ui.ctx()) {
|
|
render_damus_mobile(damus, app_ctx, ui)
|
|
} else {
|
|
render_damus_desktop(damus, app_ctx, ui)
|
|
};
|
|
|
|
fullscreen_media_viewer_ui(ui, &mut damus.view_state.media_viewer, app_ctx.img_cache);
|
|
|
|
// We use this for keeping timestamps and things up to date
|
|
//ui.ctx().request_repaint_after(Duration::from_secs(5));
|
|
|
|
app_action
|
|
}
|
|
|
|
/// Present a fullscreen media viewer if the FullscreenMedia AppOptions flag is set. This is
|
|
/// typically set by image carousels using a MediaAction's on_view_media callback when
|
|
/// an image is clicked
|
|
fn fullscreen_media_viewer_ui(
|
|
ui: &mut egui::Ui,
|
|
state: &mut MediaViewerState,
|
|
img_cache: &mut Images,
|
|
) {
|
|
if !state.should_show(ui) {
|
|
if state.scene_rect.is_some() {
|
|
// if we shouldn't show yet we will have a scene
|
|
// rect, then we should clear it for next time
|
|
tracing::debug!("fullscreen_media_viewer_ui: resetting scene rect");
|
|
state.scene_rect = None;
|
|
}
|
|
return;
|
|
}
|
|
|
|
let resp = MediaViewer::new(state).fullscreen(true).ui(img_cache, ui);
|
|
|
|
if resp.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
|
fullscreen_media_close(state);
|
|
}
|
|
}
|
|
|
|
/// Close the fullscreen media player. This also resets the scene_rect state
|
|
fn fullscreen_media_close(state: &mut MediaViewerState) {
|
|
state.flags.set(MediaViewerFlags::Open, false);
|
|
}
|
|
|
|
/*
|
|
fn determine_key_storage_type() -> KeyStorageType {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
KeyStorageType::MacOS
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
KeyStorageType::Linux
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
|
{
|
|
KeyStorageType::None
|
|
}
|
|
}
|
|
*/
|
|
|
|
impl Damus {
|
|
/// Called once before the first frame.
|
|
pub fn new(app_context: &mut AppContext<'_>, args: &[String]) -> Self {
|
|
// arg parsing
|
|
|
|
let (parsed_args, unrecognized_args) =
|
|
ColumnsArgs::parse(args, Some(app_context.accounts.selected_account_pubkey()));
|
|
|
|
let account = app_context.accounts.selected_account_pubkey_bytes();
|
|
|
|
let mut timeline_cache = TimelineCache::default();
|
|
let mut options = AppOptions::default();
|
|
let tmp_columns = !parsed_args.columns.is_empty();
|
|
options.set(AppOptions::TmpColumns, tmp_columns);
|
|
options.set(
|
|
AppOptions::Debug,
|
|
app_context.args.options.contains(NotedeckOptions::Debug),
|
|
);
|
|
options.set(
|
|
AppOptions::SinceOptimize,
|
|
parsed_args.is_flag_set(ColumnsFlag::SinceOptimize),
|
|
);
|
|
|
|
let decks_cache = if tmp_columns {
|
|
info!("DecksCache: loading from command line arguments");
|
|
let mut columns: Columns = Columns::new();
|
|
let txn = Transaction::new(app_context.ndb).unwrap();
|
|
for col in &parsed_args.columns {
|
|
let timeline_kind = col.clone().into_timeline_kind();
|
|
if let Some(add_result) = columns.add_new_timeline_column(
|
|
&mut timeline_cache,
|
|
&txn,
|
|
app_context.ndb,
|
|
app_context.note_cache,
|
|
app_context.pool,
|
|
&timeline_kind,
|
|
) {
|
|
add_result.process(
|
|
app_context.ndb,
|
|
app_context.note_cache,
|
|
&txn,
|
|
&mut timeline_cache,
|
|
app_context.unknown_ids,
|
|
);
|
|
}
|
|
}
|
|
|
|
columns_to_decks_cache(app_context.i18n, columns, account)
|
|
} else if let Some(decks_cache) = crate::storage::load_decks_cache(
|
|
app_context.path,
|
|
app_context.ndb,
|
|
&mut timeline_cache,
|
|
app_context.i18n,
|
|
) {
|
|
info!(
|
|
"DecksCache: loading from disk {}",
|
|
crate::storage::DECKS_CACHE_FILE
|
|
);
|
|
decks_cache
|
|
} else {
|
|
info!("DecksCache: creating new with demo configuration");
|
|
DecksCache::new_with_demo_config(&mut timeline_cache, app_context)
|
|
//for (pk, _) in &app_context.accounts.cache {
|
|
// cache.add_deck_default(*pk);
|
|
//}
|
|
};
|
|
|
|
let support = Support::new(app_context.path);
|
|
let note_options = get_note_options(parsed_args, app_context.settings);
|
|
let jobs = JobsCache::default();
|
|
let threads = Threads::default();
|
|
|
|
Self {
|
|
subscriptions: Subscriptions::default(),
|
|
timeline_cache,
|
|
drafts: Drafts::default(),
|
|
state: DamusState::Initializing,
|
|
note_options,
|
|
options,
|
|
//frame_history: FrameHistory::default(),
|
|
view_state: ViewState::default(),
|
|
support,
|
|
decks_cache,
|
|
unrecognized_args,
|
|
jobs,
|
|
threads,
|
|
onboarding: Onboarding::default(),
|
|
}
|
|
}
|
|
|
|
/// Scroll to the top of the currently selected column. This is called
|
|
/// by the chrome when you click the toolbar
|
|
pub fn scroll_to_top(&mut self) {
|
|
self.options.insert(AppOptions::ScrollToTop)
|
|
}
|
|
|
|
pub fn columns_mut(&mut self, i18n: &mut Localization, accounts: &Accounts) -> &mut Columns {
|
|
get_active_columns_mut(i18n, accounts, &mut self.decks_cache)
|
|
}
|
|
|
|
pub fn columns(&self, accounts: &Accounts) -> &Columns {
|
|
get_active_columns(accounts, &self.decks_cache)
|
|
}
|
|
|
|
pub fn gen_subid(&self, kind: &SubKind) -> String {
|
|
if self.options.contains(AppOptions::Debug) {
|
|
format!("{kind:?}")
|
|
} else {
|
|
Uuid::new_v4().to_string()
|
|
}
|
|
}
|
|
|
|
pub fn mock<P: AsRef<Path>>(data_path: P) -> Self {
|
|
let mut i18n = Localization::default();
|
|
let decks_cache = DecksCache::default_decks_cache(&mut i18n);
|
|
|
|
let path = DataPath::new(&data_path);
|
|
let imgcache_dir = path.path(DataPathType::Cache);
|
|
let _ = std::fs::create_dir_all(imgcache_dir.clone());
|
|
let options = AppOptions::default() | AppOptions::Debug | AppOptions::TmpColumns;
|
|
|
|
let support = Support::new(&path);
|
|
|
|
Self {
|
|
subscriptions: Subscriptions::default(),
|
|
timeline_cache: TimelineCache::default(),
|
|
drafts: Drafts::default(),
|
|
state: DamusState::Initializing,
|
|
note_options: NoteOptions::default(),
|
|
//frame_history: FrameHistory::default(),
|
|
view_state: ViewState::default(),
|
|
support,
|
|
options,
|
|
decks_cache,
|
|
unrecognized_args: BTreeSet::default(),
|
|
jobs: JobsCache::default(),
|
|
threads: Threads::default(),
|
|
onboarding: Onboarding::default(),
|
|
}
|
|
}
|
|
|
|
pub fn subscriptions(&mut self) -> &mut HashMap<String, SubKind> {
|
|
&mut self.subscriptions.subs
|
|
}
|
|
|
|
pub fn unrecognized_args(&self) -> &BTreeSet<String> {
|
|
&self.unrecognized_args
|
|
}
|
|
|
|
pub fn toolbar_height() -> f32 {
|
|
48.0
|
|
}
|
|
|
|
pub fn initially_selected_toolbar_index() -> i32 {
|
|
0
|
|
}
|
|
}
|
|
|
|
fn get_note_options(args: ColumnsArgs, settings_handler: &mut SettingsHandler) -> NoteOptions {
|
|
let mut note_options = NoteOptions::default();
|
|
|
|
note_options.set(
|
|
NoteOptions::Textmode,
|
|
args.is_flag_set(ColumnsFlag::Textmode),
|
|
);
|
|
note_options.set(
|
|
NoteOptions::ScrambleText,
|
|
args.is_flag_set(ColumnsFlag::Scramble),
|
|
);
|
|
note_options.set(
|
|
NoteOptions::HideMedia,
|
|
args.is_flag_set(ColumnsFlag::NoMedia),
|
|
);
|
|
note_options.set(
|
|
NoteOptions::RepliesNewestFirst,
|
|
settings_handler.show_replies_newest_first(),
|
|
);
|
|
note_options
|
|
}
|
|
|
|
/*
|
|
fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
|
|
let stroke = ui.style().interact(&response).fg_stroke;
|
|
let radius = egui::lerp(2.0..=3.0, openness);
|
|
ui.painter()
|
|
.circle_filled(response.rect.center(), radius, stroke.color);
|
|
}
|
|
*/
|
|
|
|
#[profiling::function]
|
|
fn render_damus_mobile(
|
|
app: &mut Damus,
|
|
app_ctx: &mut AppContext<'_>,
|
|
ui: &mut egui::Ui,
|
|
) -> Option<AppAction> {
|
|
//let routes = app.timelines[0].routes.clone();
|
|
|
|
let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize;
|
|
let mut app_action: Option<AppAction> = None;
|
|
|
|
StripBuilder::new(ui)
|
|
.size(Size::remainder()) // top cell
|
|
.size(Size::exact(Damus::toolbar_height())) // bottom cell
|
|
.vertical(|mut strip| {
|
|
strip.cell(|ui| {
|
|
let rect = ui.available_rect_before_wrap();
|
|
if !app.columns(app_ctx.accounts).columns().is_empty() {
|
|
let r = nav::render_nav(
|
|
active_col,
|
|
ui.available_rect_before_wrap(),
|
|
app,
|
|
app_ctx,
|
|
ui,
|
|
)
|
|
.process_render_nav_response(app, app_ctx, ui);
|
|
if let Some(r) = &r {
|
|
match r {
|
|
ProcessNavResult::SwitchOccurred => {
|
|
if !app.options.contains(AppOptions::TmpColumns) {
|
|
storage::save_decks_cache(app_ctx.path, &app.decks_cache);
|
|
}
|
|
}
|
|
|
|
ProcessNavResult::PfpClicked => {
|
|
app_action = Some(AppAction::ToggleChrome);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
hovering_post_button(ui, app, app_ctx, rect);
|
|
});
|
|
|
|
strip.cell(|ui| {
|
|
let unseen_notif = unseen_notification(
|
|
app,
|
|
app_ctx.ndb,
|
|
app_ctx.accounts.get_selected_account().key.pubkey,
|
|
);
|
|
|
|
let resp = toolbar(ui, unseen_notif);
|
|
|
|
if let Some(action) = resp {
|
|
action.process(app, app_ctx);
|
|
}
|
|
});
|
|
});
|
|
|
|
app_action
|
|
}
|
|
|
|
fn hovering_post_button(
|
|
ui: &mut egui::Ui,
|
|
app: &mut Damus,
|
|
app_ctx: &mut AppContext,
|
|
mut rect: egui::Rect,
|
|
) {
|
|
let should_show_compose = should_show_compose_button(&app.decks_cache, app_ctx.accounts);
|
|
let btn_id = ui.id().with("hover_post_btn");
|
|
let button_y = ui
|
|
.ctx()
|
|
.animate_bool_responsive(btn_id, should_show_compose);
|
|
|
|
rect.min.x = rect.max.x - (if is_narrow(ui.ctx()) { 60.0 } else { 100.0 } * button_y);
|
|
rect.min.y = rect.max.y - 100.0;
|
|
rect.max.x += 48.0 * (1.0 - button_y);
|
|
|
|
let darkmode = ui.ctx().style().visuals.dark_mode;
|
|
|
|
// only show the compose button on profile pages and on home
|
|
let compose_resp = ui
|
|
.put(rect, ui::post::compose_note_button(darkmode))
|
|
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
|
if compose_resp.clicked() && !app.columns(app_ctx.accounts).columns().is_empty() {
|
|
// just use the some side panel logic as the desktop
|
|
DesktopSidePanel::perform_action(
|
|
&mut app.decks_cache,
|
|
app_ctx.accounts,
|
|
SidePanelAction::ComposeNote,
|
|
app_ctx.i18n,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Should we show the compose button? When in threads we should hide it, etc
|
|
fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool {
|
|
let Some(col) = decks.selected_column(accounts) else {
|
|
return false;
|
|
};
|
|
|
|
match col.router().top() {
|
|
Route::Timeline(timeline_kind) => {
|
|
match timeline_kind {
|
|
TimelineKind::List(list_kind) => match list_kind {
|
|
ListKind::Contact(_pk) => true,
|
|
},
|
|
|
|
TimelineKind::Algo(_pk) => true,
|
|
TimelineKind::Profile(_pk) => true,
|
|
TimelineKind::Universe => true,
|
|
TimelineKind::Generic(_) => true,
|
|
TimelineKind::Hashtag(_) => true,
|
|
|
|
// no!
|
|
TimelineKind::Search(_) => false,
|
|
TimelineKind::Notifications(_) => false,
|
|
}
|
|
}
|
|
|
|
Route::Thread(_) => false,
|
|
Route::Accounts(_) => false,
|
|
Route::Reply(_) => false,
|
|
Route::Quote(_) => false,
|
|
Route::Relays => false,
|
|
Route::Settings => false,
|
|
Route::ComposeNote => false,
|
|
Route::AddColumn(_) => false,
|
|
Route::EditProfile(_) => false,
|
|
Route::Support => false,
|
|
Route::NewDeck => false,
|
|
Route::Search => false,
|
|
Route::EditDeck(_) => false,
|
|
Route::Wallet(_) => false,
|
|
Route::CustomizeZapAmount(_) => false,
|
|
}
|
|
}
|
|
|
|
#[profiling::function]
|
|
fn render_damus_desktop(
|
|
app: &mut Damus,
|
|
app_ctx: &mut AppContext<'_>,
|
|
ui: &mut egui::Ui,
|
|
) -> Option<AppAction> {
|
|
let screen_size = ui.ctx().screen_rect().width();
|
|
let calc_panel_width = (screen_size
|
|
/ get_active_columns(app_ctx.accounts, &app.decks_cache).num_columns() as f32)
|
|
- 30.0;
|
|
let min_width = 320.0;
|
|
let need_scroll = calc_panel_width < min_width;
|
|
let panel_sizes = if need_scroll {
|
|
Size::exact(min_width)
|
|
} else {
|
|
Size::remainder()
|
|
};
|
|
|
|
ui.spacing_mut().item_spacing.x = 0.0;
|
|
|
|
if need_scroll {
|
|
egui::ScrollArea::horizontal()
|
|
.show(ui, |ui| timelines_view(ui, panel_sizes, app, app_ctx))
|
|
.inner
|
|
} else {
|
|
timelines_view(ui, panel_sizes, app, app_ctx)
|
|
}
|
|
}
|
|
|
|
fn timelines_view(
|
|
ui: &mut egui::Ui,
|
|
sizes: Size,
|
|
app: &mut Damus,
|
|
ctx: &mut AppContext<'_>,
|
|
) -> Option<AppAction> {
|
|
let num_cols = get_active_columns(ctx.accounts, &app.decks_cache).num_columns();
|
|
let mut side_panel_action: Option<nav::SwitchingAction> = None;
|
|
let mut responses = Vec::with_capacity(num_cols);
|
|
|
|
StripBuilder::new(ui)
|
|
.size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH))
|
|
.sizes(sizes, num_cols)
|
|
.clip(true)
|
|
.horizontal(|mut strip| {
|
|
strip.cell(|ui| {
|
|
let rect = ui.available_rect_before_wrap();
|
|
let side_panel = DesktopSidePanel::new(
|
|
ctx.accounts.get_selected_account(),
|
|
&app.decks_cache,
|
|
ctx.i18n,
|
|
)
|
|
.show(ui);
|
|
|
|
if let Some(side_panel) = side_panel {
|
|
if side_panel.response.clicked() || side_panel.response.secondary_clicked() {
|
|
if let Some(action) = DesktopSidePanel::perform_action(
|
|
&mut app.decks_cache,
|
|
ctx.accounts,
|
|
side_panel.action,
|
|
ctx.i18n,
|
|
) {
|
|
side_panel_action = Some(action);
|
|
}
|
|
}
|
|
}
|
|
|
|
// debug
|
|
/*
|
|
ui.painter().rect(
|
|
rect,
|
|
0,
|
|
egui::Color32::RED,
|
|
egui::Stroke::new(1.0, egui::Color32::BLUE),
|
|
egui::StrokeKind::Inside,
|
|
);
|
|
*/
|
|
|
|
// vertical sidebar line
|
|
ui.painter().vline(
|
|
rect.right(),
|
|
rect.y_range(),
|
|
ui.visuals().widgets.noninteractive.bg_stroke,
|
|
);
|
|
});
|
|
|
|
for col_index in 0..num_cols {
|
|
strip.cell(|ui| {
|
|
let rect = ui.available_rect_before_wrap();
|
|
let v_line_stroke = ui.visuals().widgets.noninteractive.bg_stroke;
|
|
let inner_rect = {
|
|
let mut inner = rect;
|
|
inner.set_right(rect.right() - v_line_stroke.width);
|
|
inner
|
|
};
|
|
responses.push(nav::render_nav(col_index, inner_rect, app, ctx, ui));
|
|
|
|
// vertical line
|
|
ui.painter()
|
|
.vline(rect.right(), rect.y_range(), v_line_stroke);
|
|
|
|
// we need borrow ui context for processing, so proces
|
|
// responses in the last cell
|
|
|
|
if col_index == num_cols - 1 {}
|
|
});
|
|
|
|
//strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind));
|
|
}
|
|
});
|
|
|
|
// process the side panel action after so we don't change the number of columns during
|
|
// StripBuilder rendering
|
|
let mut save_cols = false;
|
|
if let Some(action) = side_panel_action {
|
|
save_cols = save_cols
|
|
|| action.process(
|
|
&mut app.timeline_cache,
|
|
&mut app.decks_cache,
|
|
ctx,
|
|
&mut app.subscriptions,
|
|
ui.ctx(),
|
|
);
|
|
}
|
|
|
|
let mut app_action: Option<AppAction> = None;
|
|
|
|
for response in responses {
|
|
let nav_result = response.process_render_nav_response(app, ctx, ui);
|
|
|
|
if let Some(nr) = &nav_result {
|
|
match nr {
|
|
ProcessNavResult::SwitchOccurred => save_cols = true,
|
|
|
|
ProcessNavResult::PfpClicked => {
|
|
app_action = Some(AppAction::ToggleChrome);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if app.options.contains(AppOptions::TmpColumns) {
|
|
save_cols = false;
|
|
}
|
|
|
|
if save_cols {
|
|
storage::save_decks_cache(ctx.path, &app.decks_cache);
|
|
}
|
|
|
|
app_action
|
|
}
|
|
|
|
impl notedeck::App for Damus {
|
|
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
|
|
/*
|
|
self.app
|
|
.frame_history
|
|
.on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage);
|
|
*/
|
|
|
|
update_damus(self, ctx, ui.ctx());
|
|
render_damus(self, ctx, ui)
|
|
}
|
|
}
|
|
|
|
pub fn get_active_columns<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Columns {
|
|
get_decks(accounts, decks_cache).active().columns()
|
|
}
|
|
|
|
pub fn get_decks<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Decks {
|
|
let key = accounts.selected_account_pubkey();
|
|
decks_cache.decks(key)
|
|
}
|
|
|
|
pub fn get_active_columns_mut<'a>(
|
|
i18n: &mut Localization,
|
|
accounts: &Accounts,
|
|
decks_cache: &'a mut DecksCache,
|
|
) -> &'a mut Columns {
|
|
get_decks_mut(i18n, accounts, decks_cache)
|
|
.active_mut()
|
|
.columns_mut()
|
|
}
|
|
|
|
pub fn get_decks_mut<'a>(
|
|
i18n: &mut Localization,
|
|
accounts: &Accounts,
|
|
decks_cache: &'a mut DecksCache,
|
|
) -> &'a mut Decks {
|
|
decks_cache.decks_mut(i18n, accounts.selected_account_pubkey())
|
|
}
|
|
|
|
fn columns_to_decks_cache(i18n: &mut Localization, cols: Columns, key: &[u8; 32]) -> DecksCache {
|
|
let mut account_to_decks: HashMap<Pubkey, Decks> = Default::default();
|
|
let decks = Decks::new(crate::decks::Deck::new_with_columns(
|
|
crate::decks::Deck::default_icon(),
|
|
tr!(i18n, "My Deck", "Title for the user's deck"),
|
|
cols,
|
|
));
|
|
|
|
let account = Pubkey::new(*key);
|
|
account_to_decks.insert(account, decks);
|
|
DecksCache::new(account_to_decks, i18n)
|
|
}
|