mirror of
https://github.com/aljazceru/notedeck.git
synced 2026-01-07 18:34:21 +01:00
Before we were setting filter limits in two different places. Let's unify them so we don't have to sources of truth for filter limits.
1049 lines
31 KiB
Rust
1049 lines
31 KiB
Rust
use crate::abbrev;
|
|
use crate::error::Error;
|
|
use crate::fonts::{setup_fonts, NamedFontFamily};
|
|
use crate::frame_history::FrameHistory;
|
|
use crate::images::fetch_img;
|
|
use crate::imgcache::ImageCache;
|
|
use crate::notecache::NoteCache;
|
|
use crate::timeline;
|
|
use crate::ui::padding;
|
|
use crate::Result;
|
|
use egui::containers::scroll_area::ScrollBarVisibility;
|
|
use std::borrow::Cow;
|
|
|
|
use egui::widgets::Spinner;
|
|
use egui::{
|
|
CollapsingHeader, Color32, Context, Frame, Hyperlink, Image, Label, Margin, RichText, Sense,
|
|
Style, TextureHandle, Vec2, Visuals,
|
|
};
|
|
|
|
use enostr::{ClientMessage, Filter, Pubkey, RelayEvent, RelayMessage};
|
|
use nostrdb::{
|
|
Block, BlockType, Blocks, Config, Mention, Ndb, Note, NoteKey, ProfileRecord, Subscription,
|
|
Transaction,
|
|
};
|
|
use poll_promise::Promise;
|
|
use std::cmp::Ordering;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::hash::{Hash, Hasher};
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
use tracing::{debug, error, info, warn};
|
|
|
|
use enostr::RelayPool;
|
|
|
|
const PURPLE: Color32 = Color32::from_rgb(0xCC, 0x43, 0xC5);
|
|
const DARK_BG: Color32 = egui::Color32::from_rgb(40, 44, 52);
|
|
|
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
|
pub enum DamusState {
|
|
Initializing,
|
|
Initialized,
|
|
}
|
|
|
|
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
|
|
pub struct NoteRef {
|
|
pub key: NoteKey,
|
|
pub created_at: u64,
|
|
}
|
|
|
|
impl PartialOrd for NoteRef {
|
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
|
match self.created_at.cmp(&other.created_at) {
|
|
Ordering::Equal => self.key.cmp(&other.key).into(),
|
|
Ordering::Less => Some(Ordering::Greater),
|
|
Ordering::Greater => Some(Ordering::Less),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Ord for NoteRef {
|
|
fn cmp(&self, other: &Self) -> Ordering {
|
|
self.partial_cmp(other).unwrap()
|
|
}
|
|
}
|
|
|
|
struct Timeline {
|
|
pub notes: Vec<NoteRef>,
|
|
pub subscription: Option<Subscription>,
|
|
}
|
|
|
|
impl Timeline {
|
|
pub fn new() -> Self {
|
|
let mut notes: Vec<NoteRef> = vec![];
|
|
notes.reserve(1000);
|
|
let subscription: Option<Subscription> = None;
|
|
|
|
Timeline {
|
|
notes,
|
|
subscription,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
|
|
pub struct Damus {
|
|
state: DamusState,
|
|
n_panels: u32,
|
|
compose: String,
|
|
initial_filter: Vec<enostr::Filter>,
|
|
|
|
note_cache: HashMap<NoteKey, NoteCache>,
|
|
pool: RelayPool,
|
|
|
|
timelines: Vec<Timeline>,
|
|
|
|
img_cache: ImageCache,
|
|
pub ndb: Ndb,
|
|
|
|
frame_history: crate::frame_history::FrameHistory,
|
|
}
|
|
|
|
pub fn is_mobile(ctx: &egui::Context) -> bool {
|
|
//true
|
|
let screen_size = ctx.screen_rect().size();
|
|
screen_size.x < 550.0
|
|
}
|
|
|
|
fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) {
|
|
let ctx = ctx.clone();
|
|
let wakeup = move || {
|
|
ctx.request_repaint();
|
|
};
|
|
if let Err(e) = pool.add_url("ws://localhost:8080".to_string(), wakeup.clone()) {
|
|
error!("{:?}", e)
|
|
}
|
|
if let Err(e) = pool.add_url("wss://relay.damus.io".to_string(), wakeup.clone()) {
|
|
error!("{:?}", e)
|
|
}
|
|
if let Err(e) = pool.add_url("wss://nos.lol".to_string(), wakeup.clone()) {
|
|
error!("{:?}", e)
|
|
}
|
|
if let Err(e) = pool.add_url("wss://nostr.wine".to_string(), wakeup.clone()) {
|
|
error!("{:?}", e)
|
|
}
|
|
if let Err(e) = pool.add_url("wss://purplepag.es".to_string(), wakeup) {
|
|
error!("{:?}", e)
|
|
}
|
|
}
|
|
|
|
fn get_home_filter(limit: u16) -> Filter {
|
|
Filter::new().limit(limit).kinds(vec![1, 42]).pubkeys(
|
|
[
|
|
Pubkey::from_hex("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")
|
|
.unwrap(),
|
|
]
|
|
.into(),
|
|
)
|
|
}
|
|
|
|
fn send_initial_filters(damus: &mut Damus, relay_url: &str) {
|
|
info!("Sending initial filters to {}", relay_url);
|
|
|
|
let subid = "initial";
|
|
for relay in &mut damus.pool.relays {
|
|
let relay = &mut relay.relay;
|
|
if relay.url == relay_url {
|
|
relay.subscribe(subid.to_string(), damus.initial_filter.clone());
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> {
|
|
let amount = 0.2;
|
|
if ctx.input(|i| i.key_pressed(egui::Key::Equals)) {
|
|
ctx.set_pixels_per_point(ctx.pixels_per_point() + amount);
|
|
} else if ctx.input(|i| i.key_pressed(egui::Key::Minus)) {
|
|
ctx.set_pixels_per_point(ctx.pixels_per_point() - amount);
|
|
}
|
|
|
|
let ctx2 = ctx.clone();
|
|
let wakeup = move || {
|
|
ctx2.request_repaint();
|
|
};
|
|
damus.pool.keepalive_ping(wakeup);
|
|
|
|
// pool stuff
|
|
while let Some(ev) = damus.pool.try_recv() {
|
|
let relay = ev.relay.to_owned();
|
|
|
|
match (&ev.event).into() {
|
|
RelayEvent::Opened => send_initial_filters(damus, &relay),
|
|
// TODO: handle reconnects
|
|
RelayEvent::Closed => warn!("{} connection closed", &relay),
|
|
RelayEvent::Error(e) => error!("wsev->relayev: {}", e),
|
|
RelayEvent::Other(msg) => debug!("other event {:?}", &msg),
|
|
RelayEvent::Message(msg) => process_message(damus, &relay, &msg),
|
|
}
|
|
}
|
|
|
|
let txn = Transaction::new(&damus.ndb)?;
|
|
let mut seen_pubkeys: HashSet<&[u8; 32]> = HashSet::new();
|
|
for timeline in 0..damus.timelines.len() {
|
|
if let Err(err) = poll_notes_for_timeline(damus, &txn, timeline, &mut seen_pubkeys) {
|
|
error!("{}", err);
|
|
}
|
|
}
|
|
|
|
let mut pubkeys_to_fetch: Vec<&[u8; 32]> = vec![];
|
|
for pubkey in seen_pubkeys {
|
|
if let Err(_) = damus.ndb.get_profile_by_pubkey(&txn, pubkey) {
|
|
pubkeys_to_fetch.push(pubkey)
|
|
}
|
|
}
|
|
|
|
if pubkeys_to_fetch.len() > 0 {
|
|
let filter = Filter::new()
|
|
.authors(pubkeys_to_fetch.iter().map(|p| Pubkey::new(*p)).collect())
|
|
.kinds(vec![0]);
|
|
info!(
|
|
"Getting {} unknown author profiles from relays",
|
|
pubkeys_to_fetch.len()
|
|
);
|
|
let msg = ClientMessage::req("profiles".to_string(), vec![filter]);
|
|
damus.pool.send(&msg);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn get_unknown_note_pubkeys<'a>(
|
|
ndb: &Ndb,
|
|
txn: &'a Transaction,
|
|
note: &Note<'a>,
|
|
note_key: NoteKey,
|
|
pubkeys: &mut HashSet<&'a [u8; 32]>,
|
|
) -> Result<()> {
|
|
// the author pubkey
|
|
|
|
if let Err(_) = ndb.get_profile_by_pubkey(txn, note.pubkey()) {
|
|
pubkeys.insert(note.pubkey());
|
|
}
|
|
|
|
let blocks = ndb.get_blocks_by_key(txn, note_key)?;
|
|
for block in blocks.iter(note) {
|
|
let blocktype = block.blocktype();
|
|
match block.blocktype() {
|
|
BlockType::MentionBech32 => match block.as_mention().unwrap() {
|
|
Mention::Pubkey(npub) => {
|
|
if let Err(_) = ndb.get_profile_by_pubkey(txn, npub.pubkey()) {
|
|
pubkeys.insert(npub.pubkey());
|
|
}
|
|
}
|
|
Mention::Profile(nprofile) => {
|
|
if let Err(_) = ndb.get_profile_by_pubkey(txn, nprofile.pubkey()) {
|
|
pubkeys.insert(nprofile.pubkey());
|
|
}
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn poll_notes_for_timeline<'a>(
|
|
damus: &mut Damus,
|
|
txn: &'a Transaction,
|
|
timeline: usize,
|
|
pubkeys: &mut HashSet<&'a [u8; 32]>,
|
|
) -> Result<()> {
|
|
let sub = if let Some(sub) = &damus.timelines[timeline].subscription {
|
|
sub
|
|
} else {
|
|
return Err(Error::NoActiveSubscription);
|
|
};
|
|
|
|
let new_note_ids = damus.ndb.poll_for_notes(&sub, 100);
|
|
if new_note_ids.len() > 0 {
|
|
info!("{} new notes! {:?}", new_note_ids.len(), new_note_ids);
|
|
}
|
|
|
|
let new_refs = new_note_ids
|
|
.iter()
|
|
.map(|key| {
|
|
let note_key = NoteKey::new(*key);
|
|
let note = damus
|
|
.ndb
|
|
.get_note_by_key(&txn, note_key)
|
|
.expect("no note??");
|
|
|
|
let _ = get_unknown_note_pubkeys(&damus.ndb, txn, ¬e, note_key, pubkeys);
|
|
|
|
NoteRef {
|
|
key: NoteKey::new(*key),
|
|
created_at: note.created_at(),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
damus.timelines[timeline].notes =
|
|
timeline::merge_sorted_vecs(&damus.timelines[timeline].notes, &new_refs);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(feature = "profiling")]
|
|
fn setup_profiling() {
|
|
puffin::set_scopes_on(true); // tell puffin to collect data
|
|
}
|
|
|
|
fn setup_initial_nostrdb_subs(damus: &mut Damus) -> Result<()> {
|
|
let filters: Vec<nostrdb::Filter> = damus
|
|
.initial_filter
|
|
.iter()
|
|
.map(|f| crate::filter::convert_enostr_filter(f))
|
|
.collect();
|
|
damus.timelines[0].subscription = Some(damus.ndb.subscribe(filters.clone())?);
|
|
let txn = Transaction::new(&damus.ndb)?;
|
|
let res = damus.ndb.query(
|
|
&txn,
|
|
filters,
|
|
damus.initial_filter[0].limit.unwrap_or(1000) as i32,
|
|
)?;
|
|
damus.timelines[0].notes = res
|
|
.iter()
|
|
.map(|qr| NoteRef {
|
|
key: qr.note_key,
|
|
created_at: qr.note.created_at(),
|
|
})
|
|
.collect();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn update_damus(damus: &mut Damus, ctx: &egui::Context) {
|
|
if damus.state == DamusState::Initializing {
|
|
#[cfg(feature = "profiling")]
|
|
setup_profiling();
|
|
|
|
damus.pool = RelayPool::new();
|
|
relay_setup(&mut damus.pool, ctx);
|
|
damus.state = DamusState::Initialized;
|
|
setup_initial_nostrdb_subs(damus).expect("home subscription failed");
|
|
}
|
|
|
|
if let Err(err) = try_process_event(damus, ctx) {
|
|
error!("error processing event: {}", err);
|
|
}
|
|
}
|
|
|
|
fn process_event(damus: &mut Damus, _subid: &str, event: &str) {
|
|
#[cfg(feature = "profiling")]
|
|
puffin::profile_function!();
|
|
|
|
//info!("processing event {}", event);
|
|
if let Err(_err) = damus.ndb.process_event(&event) {
|
|
error!("error processing event {}", event);
|
|
}
|
|
}
|
|
|
|
fn get_unknown_author_ids<'a>(
|
|
txn: &'a Transaction,
|
|
damus: &Damus,
|
|
timeline: usize,
|
|
) -> Result<Vec<&'a [u8; 32]>> {
|
|
#[cfg(feature = "profiling")]
|
|
puffin::profile_function!();
|
|
|
|
let mut authors: HashSet<&'a [u8; 32]> = HashSet::new();
|
|
|
|
for noteref in &damus.timelines[timeline].notes {
|
|
let note = damus.ndb.get_note_by_key(&txn, noteref.key)?;
|
|
let _ = get_unknown_note_pubkeys(&damus.ndb, txn, ¬e, note.key().unwrap(), &mut authors);
|
|
}
|
|
|
|
Ok(authors.into_iter().collect())
|
|
}
|
|
|
|
fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> {
|
|
if subid == "initial" {
|
|
let txn = Transaction::new(&damus.ndb)?;
|
|
let authors = get_unknown_author_ids(&txn, damus, 0)?;
|
|
let n_authors = authors.len();
|
|
if n_authors > 0 {
|
|
let filter = Filter::new()
|
|
.authors(authors.iter().map(|p| Pubkey::new(*p)).collect())
|
|
.kinds(vec![0]);
|
|
info!(
|
|
"Getting {} unknown author profiles from {}",
|
|
n_authors, relay_url
|
|
);
|
|
let msg = ClientMessage::req("profiles".to_string(), vec![filter]);
|
|
damus.pool.send_to(&msg, relay_url);
|
|
}
|
|
} else if subid == "profiles" {
|
|
let msg = ClientMessage::close("profiles".to_string());
|
|
damus.pool.send_to(&msg, relay_url);
|
|
} else {
|
|
warn!("got unknown eose subid {}", subid);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn process_message(damus: &mut Damus, relay: &str, msg: &RelayMessage) {
|
|
match msg {
|
|
RelayMessage::Event(subid, ev) => process_event(damus, &subid, ev),
|
|
RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg),
|
|
RelayMessage::OK(cr) => info!("OK {:?}", cr),
|
|
RelayMessage::Eose(sid) => {
|
|
if let Err(err) = handle_eose(damus, &sid, relay) {
|
|
error!("error handling eose: {}", err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_damus(damus: &mut Damus, ctx: &Context) {
|
|
ctx.style_mut(set_app_style);
|
|
|
|
if is_mobile(ctx) {
|
|
render_damus_mobile(ctx, damus);
|
|
} else {
|
|
render_damus_desktop(ctx, damus);
|
|
}
|
|
|
|
ctx.request_repaint_after(Duration::from_secs(1));
|
|
|
|
#[cfg(feature = "profiling")]
|
|
puffin_egui::profiler_window(ctx);
|
|
}
|
|
|
|
impl Damus {
|
|
/// Called once before the first frame.
|
|
pub fn new<P: AsRef<Path>>(
|
|
cc: &eframe::CreationContext<'_>,
|
|
data_path: P,
|
|
args: Vec<String>,
|
|
) -> Self {
|
|
// This is also where you can customized the look at feel of egui using
|
|
// `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`.
|
|
|
|
// Load previous app state (if any).
|
|
// Note that you must enable the `persistence` feature for this to work.
|
|
//if let Some(storage) = cc.storage {
|
|
//return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
|
|
//}
|
|
//
|
|
|
|
setup_fonts(&cc.egui_ctx);
|
|
|
|
cc.egui_ctx
|
|
.set_pixels_per_point(cc.egui_ctx.pixels_per_point() + 0.2);
|
|
|
|
egui_extras::install_image_loaders(&cc.egui_ctx);
|
|
|
|
let initial_limit = 100;
|
|
let initial_filter = if args.len() > 1 {
|
|
serde_json::from_str(&args[1]).unwrap()
|
|
} else {
|
|
vec![get_home_filter(initial_limit)]
|
|
};
|
|
|
|
let imgcache_dir = data_path.as_ref().join("cache/img");
|
|
std::fs::create_dir_all(imgcache_dir.clone());
|
|
|
|
let mut config = Config::new();
|
|
config.set_ingester_threads(2);
|
|
Self {
|
|
state: DamusState::Initializing,
|
|
pool: RelayPool::new(),
|
|
img_cache: ImageCache::new(imgcache_dir),
|
|
note_cache: HashMap::new(),
|
|
initial_filter,
|
|
n_panels: 1,
|
|
timelines: vec![Timeline::new()],
|
|
ndb: Ndb::new(data_path.as_ref().to_str().expect("db path ok"), &config).expect("ndb"),
|
|
compose: "".to_string(),
|
|
frame_history: FrameHistory::default(),
|
|
}
|
|
}
|
|
|
|
pub fn get_note_cache_mut(&mut self, note_key: NoteKey, created_at: u64) -> &mut NoteCache {
|
|
self.note_cache
|
|
.entry(note_key)
|
|
.or_insert_with(|| NoteCache::new(created_at))
|
|
}
|
|
}
|
|
|
|
fn paint_circle(ui: &mut egui::Ui, size: f32) {
|
|
let (rect, _response) = ui.allocate_at_least(Vec2::new(size, size), Sense::hover());
|
|
ui.painter()
|
|
.circle_filled(rect.center(), size / 2.0, ui.visuals().weak_text_color());
|
|
}
|
|
|
|
fn render_pfp(ui: &mut egui::Ui, damus: &mut Damus, url: &str) {
|
|
#[cfg(feature = "profiling")]
|
|
puffin::profile_function!();
|
|
|
|
let ui_size = 30.0;
|
|
|
|
// We will want to downsample these so it's not blurry on hi res displays
|
|
let img_size = (ui_size * 2.0) as u32;
|
|
|
|
let m_cached_promise = damus.img_cache.map().get(url);
|
|
if m_cached_promise.is_none() {
|
|
let res = fetch_img(&damus.img_cache, ui.ctx(), url, img_size);
|
|
damus.img_cache.map_mut().insert(url.to_owned(), res);
|
|
}
|
|
|
|
match damus.img_cache.map()[url].ready() {
|
|
None => {
|
|
ui.add(Spinner::new().size(ui_size));
|
|
}
|
|
|
|
// Failed to fetch profile!
|
|
Some(Err(_err)) => {
|
|
let m_failed_promise = damus.img_cache.map().get(url);
|
|
if m_failed_promise.is_none() {
|
|
let no_pfp = fetch_img(&damus.img_cache, ui.ctx(), no_pfp_url(), img_size);
|
|
damus.img_cache.map_mut().insert(url.to_owned(), no_pfp);
|
|
}
|
|
|
|
match damus.img_cache.map().get(url).unwrap().ready() {
|
|
None => {
|
|
paint_circle(ui, ui_size);
|
|
}
|
|
Some(Err(_e)) => {
|
|
//error!("Image load error: {:?}", e);
|
|
paint_circle(ui, ui_size);
|
|
}
|
|
Some(Ok(img)) => {
|
|
pfp_image(ui, img, ui_size);
|
|
}
|
|
}
|
|
}
|
|
Some(Ok(img)) => {
|
|
pfp_image(ui, img, ui_size);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pfp_image<'a>(ui: &mut egui::Ui, img: &TextureHandle, size: f32) -> egui::Response {
|
|
#[cfg(feature = "profiling")]
|
|
puffin::profile_function!();
|
|
|
|
//img.show_max_size(ui, egui::vec2(size, size))
|
|
ui.add(egui::Image::new(img).max_width(size))
|
|
//.with_options()
|
|
}
|
|
|
|
fn ui_abbreviate_name(ui: &mut egui::Ui, name: &str, len: usize) {
|
|
if name.len() > len {
|
|
let closest = abbrev::floor_char_boundary(name, len);
|
|
ui.strong(&name[..closest]);
|
|
ui.strong("...");
|
|
} else {
|
|
ui.add(Label::new(
|
|
RichText::new(name).family(NamedFontFamily::Medium.as_family()),
|
|
));
|
|
}
|
|
}
|
|
|
|
fn render_username(ui: &mut egui::Ui, profile: Option<&ProfileRecord>, _pk: &[u8; 32]) {
|
|
#[cfg(feature = "profiling")]
|
|
puffin::profile_function!();
|
|
|
|
ui.horizontal(|ui| {
|
|
//ui.spacing_mut().item_spacing.x = 0.0;
|
|
if let Some(profile) = profile {
|
|
if let Some(prof) = profile.record.profile() {
|
|
if prof.display_name().is_some() && prof.display_name().unwrap() != "" {
|
|
ui_abbreviate_name(ui, prof.display_name().unwrap(), 20);
|
|
} else if let Some(name) = prof.name() {
|
|
ui_abbreviate_name(ui, name, 20);
|
|
}
|
|
}
|
|
} else {
|
|
ui.strong("nostrich");
|
|
}
|
|
|
|
/*
|
|
ui.label(&pk.as_ref()[0..8]);
|
|
ui.label(":");
|
|
ui.label(&pk.as_ref()[64 - 8..]);
|
|
*/
|
|
});
|
|
}
|
|
|
|
fn no_pfp_url() -> &'static str {
|
|
"https://damus.io/img/no-profile.svg"
|
|
}
|
|
|
|
fn render_notes_in_viewport(
|
|
ui: &mut egui::Ui,
|
|
_damus: &mut Damus,
|
|
viewport: egui::Rect,
|
|
row_height: f32,
|
|
font_id: egui::FontId,
|
|
) {
|
|
let num_rows = 10_000;
|
|
ui.set_height(row_height * num_rows as f32);
|
|
|
|
let first_item = (viewport.min.y / row_height).floor().max(0.0) as usize;
|
|
let last_item = (viewport.max.y / row_height).ceil() as usize + 1;
|
|
let last_item = last_item.min(num_rows);
|
|
|
|
let mut used_rect = egui::Rect::NOTHING;
|
|
|
|
for i in first_item..last_item {
|
|
let _padding = (i % 100) as f32;
|
|
let indent = (((i as f32) / 10.0).sin() * 20.0) + 10.0;
|
|
let x = ui.min_rect().left() + indent;
|
|
let y = ui.min_rect().top() + i as f32 * row_height;
|
|
let text = format!(
|
|
"This is row {}/{}, indented by {} pixels",
|
|
i + 1,
|
|
num_rows,
|
|
indent
|
|
);
|
|
let text_rect = ui.painter().text(
|
|
egui::pos2(x, y),
|
|
egui::Align2::LEFT_TOP,
|
|
text,
|
|
font_id.clone(),
|
|
ui.visuals().text_color(),
|
|
);
|
|
used_rect = used_rect.union(text_rect);
|
|
}
|
|
|
|
ui.allocate_rect(used_rect, egui::Sense::hover()); // make sure it is visible!
|
|
}
|
|
|
|
fn get_profile_name<'a>(record: &'a ProfileRecord) -> Option<&'a str> {
|
|
let profile = record.record.profile()?;
|
|
let display_name = profile.display_name();
|
|
let name = profile.name();
|
|
|
|
if display_name.is_some() && display_name.unwrap() != "" {
|
|
return display_name;
|
|
}
|
|
|
|
if name.is_some() && name.unwrap() != "" {
|
|
return name;
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn render_note_contents(
|
|
ui: &mut egui::Ui,
|
|
damus: &mut Damus,
|
|
txn: &Transaction,
|
|
note: &Note,
|
|
note_key: NoteKey,
|
|
) {
|
|
#[cfg(feature = "profiling")]
|
|
puffin::profile_function!();
|
|
|
|
let mut images: Vec<String> = vec![];
|
|
|
|
ui.horizontal_wrapped(|ui| {
|
|
let blocks = if let Ok(blocks) = damus.ndb.get_blocks_by_key(txn, note_key) {
|
|
blocks
|
|
} else {
|
|
warn!("missing note content blocks? '{}'", note.content());
|
|
ui.weak(note.content());
|
|
return;
|
|
};
|
|
|
|
ui.spacing_mut().item_spacing.x = 0.0;
|
|
|
|
for block in blocks.iter(note) {
|
|
match block.blocktype() {
|
|
BlockType::MentionBech32 => {
|
|
ui.colored_label(PURPLE, "@");
|
|
match block.as_mention().unwrap() {
|
|
Mention::Pubkey(npub) => {
|
|
let profile = damus.ndb.get_profile_by_pubkey(txn, npub.pubkey()).ok();
|
|
if let Some(name) = profile.as_ref().and_then(|p| get_profile_name(p)) {
|
|
ui.colored_label(PURPLE, name);
|
|
} else {
|
|
ui.colored_label(PURPLE, "nostrich");
|
|
}
|
|
}
|
|
_ => {
|
|
ui.colored_label(PURPLE, block.as_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
BlockType::Hashtag => {
|
|
ui.colored_label(PURPLE, "#");
|
|
ui.colored_label(PURPLE, block.as_str());
|
|
}
|
|
|
|
BlockType::Url => {
|
|
/*
|
|
let url = block.as_str().to_lowercase();
|
|
if url.ends_with("png") || url.ends_with("jpg") {
|
|
images.push(url);
|
|
} else {
|
|
*/
|
|
ui.add(Hyperlink::from_label_and_url(
|
|
RichText::new(block.as_str()).color(PURPLE),
|
|
block.as_str(),
|
|
));
|
|
//}
|
|
}
|
|
|
|
BlockType::Text => {
|
|
ui.label(block.as_str());
|
|
}
|
|
|
|
_ => {
|
|
ui.colored_label(PURPLE, block.as_str());
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
for image in images {
|
|
let resp = ui.add(Image::new(image.clone()));
|
|
resp.context_menu(|ui| {
|
|
if ui.button("Copy Link").clicked() {
|
|
ui.ctx().copy_text(image);
|
|
ui.close_menu();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn render_reltime(ui: &mut egui::Ui, note_cache: &mut NoteCache) {
|
|
#[cfg(feature = "profiling")]
|
|
puffin::profile_function!();
|
|
|
|
let color = Color32::from_rgb(0x8A, 0x8A, 0x8A);
|
|
ui.add(Label::new(RichText::new("⋅").size(10.0).color(color)));
|
|
ui.add(Label::new(
|
|
RichText::new(note_cache.reltime_str())
|
|
.size(10.0)
|
|
.color(color),
|
|
));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
#[derive(Hash, Clone, Copy)]
|
|
struct NoteTimelineKey {
|
|
timeline: usize,
|
|
note_key: NoteKey,
|
|
}
|
|
|
|
fn render_note(
|
|
ui: &mut egui::Ui,
|
|
damus: &mut Damus,
|
|
note_key: NoteKey,
|
|
timeline: usize,
|
|
) -> Result<()> {
|
|
#[cfg(feature = "profiling")]
|
|
puffin::profile_function!();
|
|
|
|
let txn = Transaction::new(&damus.ndb)?;
|
|
let note = damus.ndb.get_note_by_key(&txn, note_key)?;
|
|
let id = egui::Id::new(NoteTimelineKey { note_key, timeline });
|
|
|
|
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
|
|
let profile = damus.ndb.get_profile_by_pubkey(&txn, note.pubkey());
|
|
|
|
let mut collapse_state =
|
|
egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false);
|
|
|
|
let inner_resp = padding(6.0, ui, |ui| {
|
|
match profile
|
|
.as_ref()
|
|
.ok()
|
|
.and_then(|p| p.record.profile()?.picture())
|
|
{
|
|
// these have different lifetimes and types,
|
|
// so the calls must be separate
|
|
Some(pic) => render_pfp(ui, damus, pic),
|
|
None => render_pfp(ui, damus, no_pfp_url()),
|
|
}
|
|
|
|
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
|
|
ui.horizontal(|ui| {
|
|
ui.spacing_mut().item_spacing.x = 2.0;
|
|
|
|
render_username(ui, profile.as_ref().ok(), note.pubkey());
|
|
|
|
let note_cache = damus.get_note_cache_mut(note_key, note.created_at());
|
|
render_reltime(ui, note_cache);
|
|
});
|
|
|
|
render_note_contents(ui, damus, &txn, ¬e, note_key);
|
|
|
|
//let header_res = ui.horizontal(|ui| {});
|
|
|
|
collapse_state.show_body_unindented(ui, |ui| render_note_actionbar(ui));
|
|
});
|
|
});
|
|
|
|
let resp = ui.interact(inner_resp.response.rect, id, Sense::hover());
|
|
|
|
if resp.hovered() ^ collapse_state.is_open() {
|
|
info!("clicked {:?}, {}", note_key, collapse_state.is_open());
|
|
collapse_state.toggle(ui);
|
|
collapse_state.store(ui.ctx());
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn render_note_actionbar(ui: &mut egui::Ui) -> egui::InnerResponse<()> {
|
|
ui.horizontal(|ui| {
|
|
if ui
|
|
.add(
|
|
egui::Button::image(egui::Image::new(egui::include_image!(
|
|
"../assets/icons/reply.png"
|
|
)))
|
|
.fill(ui.style().visuals.panel_fill),
|
|
)
|
|
.clicked()
|
|
{}
|
|
|
|
//if ui.add(egui::Button::new("like")).clicked() {}
|
|
})
|
|
}
|
|
|
|
fn render_notes(ui: &mut egui::Ui, damus: &mut Damus, timeline: usize, test_panel_id: usize) {
|
|
#[cfg(feature = "profiling")]
|
|
puffin::profile_function!();
|
|
|
|
let num_notes = damus.timelines[timeline].notes.len();
|
|
|
|
for i in 0..num_notes {
|
|
let _ = render_note(
|
|
ui,
|
|
damus,
|
|
damus.timelines[timeline].notes[i].key,
|
|
test_panel_id,
|
|
);
|
|
|
|
ui.separator();
|
|
}
|
|
}
|
|
|
|
fn timeline_view(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, test_panel_id: usize) {
|
|
padding(4.0, ui, |ui| ui.heading("Notifications"));
|
|
|
|
/*
|
|
let font_id = egui::TextStyle::Body.resolve(ui.style());
|
|
let row_height = ui.fonts(|f| f.row_height(&font_id)) + ui.spacing().item_spacing.y;
|
|
*/
|
|
|
|
egui::ScrollArea::vertical()
|
|
.scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
|
|
.auto_shrink([false; 2])
|
|
/*
|
|
.show_viewport(ui, |ui, viewport| {
|
|
render_notes_in_viewport(ui, app, viewport, row_height, font_id);
|
|
});
|
|
*/
|
|
.show(ui, |ui| {
|
|
render_notes(ui, app, timeline, test_panel_id);
|
|
});
|
|
}
|
|
|
|
fn top_panel(ctx: &egui::Context) -> egui::TopBottomPanel {
|
|
let mut top_margin = Margin::default();
|
|
top_margin.top = 4.0;
|
|
top_margin.left = 8.0;
|
|
top_margin.right = 8.0;
|
|
//top_margin.bottom = -20.0;
|
|
|
|
let frame = Frame {
|
|
inner_margin: top_margin,
|
|
fill: ctx.style().visuals.panel_fill,
|
|
..Default::default()
|
|
};
|
|
|
|
egui::TopBottomPanel::top("top_panel")
|
|
.frame(frame)
|
|
.show_separator_line(false)
|
|
}
|
|
|
|
fn render_panel<'a>(ctx: &egui::Context, app: &'a mut Damus, timeline_ind: usize) {
|
|
top_panel(ctx).show(ctx, |ui| {
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
|
|
ui.visuals_mut().button_frame = false;
|
|
egui::widgets::global_dark_light_mode_switch(ui);
|
|
|
|
if ui
|
|
.add(egui::Button::new("+").frame(false))
|
|
.on_hover_text("Add Timeline")
|
|
.clicked()
|
|
{
|
|
app.n_panels += 1;
|
|
}
|
|
|
|
if app.n_panels != 1
|
|
&& ui
|
|
.add(egui::Button::new("-").frame(false))
|
|
.on_hover_text("Remove Timeline")
|
|
.clicked()
|
|
{
|
|
app.n_panels -= 1;
|
|
}
|
|
|
|
//#[cfg(feature = "profiling")]
|
|
{
|
|
ui.weak(format!(
|
|
"FPS: {:.2}, {:10.1}ms",
|
|
app.frame_history.fps(),
|
|
app.frame_history.mean_frame_time() * 1e3
|
|
));
|
|
|
|
ui.weak(format!(
|
|
"{} notes",
|
|
&app.timelines[timeline_ind].notes.len()
|
|
));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
fn set_app_style(style: &mut Style) {
|
|
let visuals = &mut style.visuals;
|
|
visuals.hyperlink_color = PURPLE;
|
|
if visuals.dark_mode {
|
|
visuals.override_text_color = Some(egui::Color32::from_rgb(250, 250, 250));
|
|
//visuals.panel_fill = egui::Color32::from_rgb(31, 31, 31);
|
|
visuals.panel_fill = egui::Color32::from_rgb(0, 0, 0);
|
|
//visuals.override_text_color = Some(egui::Color32::from_rgb(170, 177, 190));
|
|
//visuals.panel_fill = egui::Color32::from_rgb(40, 44, 52);
|
|
} else {
|
|
visuals.override_text_color = Some(egui::Color32::BLACK);
|
|
};
|
|
}
|
|
|
|
fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) {
|
|
render_panel(ctx, app, 0);
|
|
|
|
#[cfg(feature = "profiling")]
|
|
puffin::profile_function!();
|
|
|
|
let panel_width = ctx.screen_rect().width();
|
|
|
|
main_panel(&ctx.style()).show(ctx, |ui| {
|
|
timeline_panel(ui, panel_width, 0, |ui| {
|
|
timeline_view(ui, app, 0, 0);
|
|
});
|
|
});
|
|
}
|
|
|
|
fn main_panel(style: &Style) -> egui::CentralPanel {
|
|
egui::CentralPanel::default().frame(Frame {
|
|
inner_margin: Margin::same(0.0),
|
|
fill: style.visuals.panel_fill,
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) {
|
|
render_panel(ctx, app, 0);
|
|
#[cfg(feature = "profiling")]
|
|
puffin::profile_function!();
|
|
|
|
let screen_size = ctx.screen_rect().width();
|
|
let calc_panel_width = (screen_size / app.n_panels as f32) - 30.0;
|
|
let min_width = 300.0;
|
|
let need_scroll = calc_panel_width < min_width;
|
|
let panel_width = if need_scroll {
|
|
min_width
|
|
} else {
|
|
calc_panel_width
|
|
};
|
|
|
|
if app.n_panels == 1 {
|
|
let panel_width = ctx.screen_rect().width();
|
|
main_panel(&ctx.style()).show(ctx, |ui| {
|
|
timeline_panel(ui, panel_width, 0, |ui| {
|
|
//postbox(ui, app);
|
|
timeline_view(ui, app, 0, 0);
|
|
});
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
main_panel(&ctx.style()).show(ctx, |ui| {
|
|
egui::ScrollArea::horizontal()
|
|
.auto_shrink([false; 2])
|
|
.show(ui, |ui| {
|
|
for ind in 0..app.n_panels {
|
|
if ind == 0 {
|
|
//postbox(ui, app);
|
|
}
|
|
timeline_panel(ui, panel_width, ind, |ui| {
|
|
// TODO: add new timeline to each panel
|
|
timeline_view(ui, app, 0, ind as usize);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
fn postbox(ui: &mut egui::Ui, app: &mut Damus) {
|
|
let _output = egui::TextEdit::multiline(&mut app.compose)
|
|
.hint_text("Type something!")
|
|
.show(ui);
|
|
|
|
/*
|
|
let width = ui.available_width();
|
|
let height = 100.0;
|
|
let shapes = [Shape::Rect(RectShape {
|
|
rect: epaint::Rect::from_min_max(pos2(10.0, 10.0), pos2(width, height)),
|
|
rounding: epaint::Rounding::same(10.0),
|
|
fill: Color32::from_rgb(0x25, 0x25, 0x25),
|
|
stroke: Stroke::new(2.0, Color32::from_rgb(0x39, 0x39, 0x39)),
|
|
})];
|
|
|
|
ui.painter().extend(shapes);
|
|
*/
|
|
}
|
|
|
|
fn timeline_panel<R>(
|
|
ui: &mut egui::Ui,
|
|
panel_width: f32,
|
|
ind: u32,
|
|
add_contents: impl FnOnce(&mut egui::Ui) -> R,
|
|
) -> egui::InnerResponse<R> {
|
|
egui::SidePanel::left(format!("l{}", ind))
|
|
.resizable(false)
|
|
.frame(Frame::none())
|
|
.max_width(panel_width)
|
|
.min_width(panel_width)
|
|
.show_inside(ui, add_contents)
|
|
}
|
|
|
|
impl eframe::App for Damus {
|
|
/// 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);
|
|
}
|
|
|
|
/// Called each time the UI needs repainting, which may be many times per second.
|
|
/// Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`.
|
|
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
|
self.frame_history
|
|
.on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage);
|
|
|
|
#[cfg(feature = "profiling")]
|
|
puffin::GlobalProfiler::lock().new_frame();
|
|
update_damus(self, ctx);
|
|
render_damus(self, ctx);
|
|
}
|
|
}
|