split notedeck into crates

This splits notedeck into crates, separating the browser chrome and
individual apps:

* notedeck: binary file, browser chrome
* notedeck_columns: our columns app
* enostr: same as before

We still need to do more work to cleanly separate the chrome apis
from the app apis. Soon I will create notedeck-notebook to see what
makes sense to be shared between the apps.

Some obvious ones that come to mind:

1. ImageCache

We will likely want to move this to the notedeck crate, as most apps
will want some kind of image cache. In web browsers, web pages do not
need to worry about this, so we will likely have to do something similar

2. Ndb

Since NdbRef is threadsafe and Ndb is an Arc<NdbRef>, it can be safely
copied to each app. This will simplify things. In the future we might
want to create an abstraction over this? Maybe each app shouldn't have
access to the same database... we assume the data in DBs are all public
anyways, but if we have unwrapped giftwraps that could be a problem.

3. RelayPool / Subscription Manager

The browser should probably maintain these. Then apps can use ken's
high level subscription manager api and not have to worry about
connection pool details

4. Accounts

Accounts and key management should be handled by the chrome. Apps should
only have a simple signer interface.

That's all for now, just something to think about!

Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
William Casarin
2024-12-11 02:53:05 -08:00
parent 10cbdf15f0
commit 74c5f0c748
156 changed files with 194 additions and 252 deletions

View File

@@ -0,0 +1,164 @@
use crate::app_style::NotedeckTextStyle;
use crate::key_parsing::AcquireKeyError;
use crate::login_manager::AcquireKeyState;
use crate::ui::{Preview, PreviewConfig, View};
use egui::TextEdit;
use egui::{Align, Button, Color32, Frame, InnerResponse, Margin, RichText, Vec2};
use enostr::Keypair;
pub struct AccountLoginView<'a> {
manager: &'a mut AcquireKeyState,
}
pub enum AccountLoginResponse {
CreateNew,
LoginWith(Keypair),
}
impl<'a> AccountLoginView<'a> {
pub fn new(state: &'a mut AcquireKeyState) -> Self {
AccountLoginView { manager: state }
}
pub fn ui(&mut self, ui: &mut egui::Ui) -> InnerResponse<Option<AccountLoginResponse>> {
Frame::none()
.outer_margin(12.0)
.show(ui, |ui| self.show(ui))
}
fn show(&mut self, ui: &mut egui::Ui) -> Option<AccountLoginResponse> {
ui.vertical(|ui| {
ui.vertical_centered(|ui| {
ui.add_space(32.0);
ui.label(login_title_text());
});
ui.horizontal(|ui| {
ui.label(login_textedit_info_text());
});
ui.vertical_centered_justified(|ui| {
ui.add(login_textedit(self.manager));
self.loading_and_error(ui);
if ui.add(login_button()).clicked() {
self.manager.apply_acquire();
}
});
ui.horizontal(|ui| {
ui.label(
RichText::new("New to Nostr?")
.color(ui.style().visuals.noninteractive().fg_stroke.color)
.text_style(NotedeckTextStyle::Body.text_style()),
);
if ui
.add(Button::new(RichText::new("Create Account")).frame(false))
.clicked()
{
self.manager.should_create_new();
}
});
});
if self.manager.check_for_create_new() {
return Some(AccountLoginResponse::CreateNew);
}
if let Some(keypair) = self.manager.check_for_successful_login() {
return Some(AccountLoginResponse::LoginWith(keypair));
}
None
}
fn loading_and_error(&mut self, ui: &mut egui::Ui) {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
if self.manager.is_awaiting_network() {
ui.add(egui::Spinner::new());
}
});
if let Some(err) = self.manager.check_for_error() {
show_error(ui, err);
}
ui.add_space(8.0);
}
}
fn show_error(ui: &mut egui::Ui, err: &AcquireKeyError) {
ui.horizontal(|ui| {
let error_label = match err {
AcquireKeyError::InvalidKey => {
egui::Label::new(RichText::new("Invalid key.").color(ui.visuals().error_fg_color))
}
AcquireKeyError::Nip05Failed(e) => {
egui::Label::new(RichText::new(e).color(ui.visuals().error_fg_color))
}
};
ui.add(error_label.truncate());
});
}
fn login_title_text() -> RichText {
RichText::new("Login")
.text_style(NotedeckTextStyle::Heading2.text_style())
.strong()
}
fn login_textedit_info_text() -> RichText {
RichText::new("Enter your key")
.strong()
.text_style(NotedeckTextStyle::Body.text_style())
}
fn login_button() -> Button<'static> {
Button::new(
RichText::new("Login now — let's do this!")
.text_style(NotedeckTextStyle::Body.text_style())
.strong(),
)
.fill(Color32::from_rgb(0xF8, 0x69, 0xB6)) // TODO: gradient
.min_size(Vec2::new(0.0, 40.0))
}
fn login_textedit(manager: &mut AcquireKeyState) -> TextEdit {
manager.get_acquire_textedit(|text| {
egui::TextEdit::singleline(text)
.hint_text(
RichText::new("Enter your public key (npub), nostr address (e.g. vrod@damus.io), or private key (nsec) here...")
.text_style(NotedeckTextStyle::Body.text_style()),
)
.vertical_align(Align::Center)
.min_size(Vec2::new(0.0, 40.0))
.margin(Margin::same(12.0))
})
}
mod preview {
use super::*;
pub struct AccountLoginPreview {
manager: AcquireKeyState,
}
impl View for AccountLoginPreview {
fn ui(&mut self, ui: &mut egui::Ui) {
AccountLoginView::new(&mut self.manager).ui(ui);
}
}
impl Preview for AccountLoginView<'_> {
type Prev = AccountLoginPreview;
fn preview(cfg: PreviewConfig) -> Self::Prev {
let _ = cfg;
let manager = AcquireKeyState::new();
AccountLoginPreview { manager }
}
}
}

View File

@@ -0,0 +1,242 @@
use crate::colors::PINK;
use crate::imgcache::ImageCache;
use crate::{
accounts::Accounts,
ui::{Preview, PreviewConfig, View},
Damus,
};
use egui::{
Align, Button, Frame, Image, InnerResponse, Layout, RichText, ScrollArea, Ui, UiBuilder, Vec2,
};
use nostrdb::{Ndb, Transaction};
use super::profile::preview::SimpleProfilePreview;
pub struct AccountsView<'a> {
ndb: &'a Ndb,
accounts: &'a Accounts,
img_cache: &'a mut ImageCache,
}
#[derive(Clone, Debug)]
pub enum AccountsViewResponse {
SelectAccount(usize),
RemoveAccount(usize),
RouteToLogin,
}
#[derive(Debug)]
enum ProfilePreviewAction {
RemoveAccount,
SwitchTo,
}
impl<'a> AccountsView<'a> {
pub fn new(ndb: &'a Ndb, accounts: &'a Accounts, img_cache: &'a mut ImageCache) -> Self {
AccountsView {
ndb,
accounts,
img_cache,
}
}
pub fn ui(&mut self, ui: &mut Ui) -> InnerResponse<Option<AccountsViewResponse>> {
Frame::none().outer_margin(12.0).show(ui, |ui| {
if let Some(resp) = Self::top_section_buttons_widget(ui).inner {
return Some(resp);
}
ui.add_space(8.0);
scroll_area()
.show(ui, |ui| {
Self::show_accounts(ui, self.accounts, self.ndb, self.img_cache)
})
.inner
})
}
fn show_accounts(
ui: &mut Ui,
accounts: &Accounts,
ndb: &Ndb,
img_cache: &mut ImageCache,
) -> Option<AccountsViewResponse> {
let mut return_op: Option<AccountsViewResponse> = None;
ui.allocate_ui_with_layout(
Vec2::new(ui.available_size_before_wrap().x, 32.0),
Layout::top_down(egui::Align::Min),
|ui| {
let txn = if let Ok(txn) = Transaction::new(ndb) {
txn
} else {
return;
};
for i in 0..accounts.num_accounts() {
let (account_pubkey, has_nsec) = match accounts.get_account(i) {
Some(acc) => (acc.pubkey.bytes(), acc.secret_key.is_some()),
None => continue,
};
let profile = ndb.get_profile_by_pubkey(&txn, account_pubkey).ok();
let is_selected = if let Some(selected) = accounts.get_selected_account_index()
{
i == selected
} else {
false
};
let profile_peview_view = {
let max_size = egui::vec2(ui.available_width(), 77.0);
let resp = ui.allocate_response(max_size, egui::Sense::click());
ui.allocate_new_ui(UiBuilder::new().max_rect(resp.rect), |ui| {
let preview =
SimpleProfilePreview::new(profile.as_ref(), img_cache, has_nsec);
show_profile_card(ui, preview, max_size, is_selected, resp)
})
.inner
};
if let Some(op) = profile_peview_view {
return_op = Some(match op {
ProfilePreviewAction::SwitchTo => {
AccountsViewResponse::SelectAccount(i)
}
ProfilePreviewAction::RemoveAccount => {
AccountsViewResponse::RemoveAccount(i)
}
});
}
}
},
);
return_op
}
fn top_section_buttons_widget(
ui: &mut egui::Ui,
) -> InnerResponse<Option<AccountsViewResponse>> {
ui.allocate_ui_with_layout(
Vec2::new(ui.available_size_before_wrap().x, 32.0),
Layout::left_to_right(egui::Align::Center),
|ui| {
if ui.add(add_account_button()).clicked() {
Some(AccountsViewResponse::RouteToLogin)
} else {
None
}
},
)
}
}
fn show_profile_card(
ui: &mut egui::Ui,
preview: SimpleProfilePreview,
max_size: egui::Vec2,
is_selected: bool,
card_resp: egui::Response,
) -> Option<ProfilePreviewAction> {
let mut op: Option<ProfilePreviewAction> = None;
ui.add_sized(max_size, |ui: &mut egui::Ui| {
let mut frame = Frame::none();
if is_selected || card_resp.hovered() {
frame = frame.fill(ui.visuals().noninteractive().weak_bg_fill);
}
if is_selected {
frame = frame.stroke(ui.visuals().noninteractive().fg_stroke);
}
frame
.rounding(8.0)
.inner_margin(8.0)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(preview);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if card_resp.clicked() {
op = Some(ProfilePreviewAction::SwitchTo);
}
if ui
.add_sized(egui::Vec2::new(84.0, 32.0), sign_out_button())
.clicked()
{
op = Some(ProfilePreviewAction::RemoveAccount)
}
});
});
})
.response
});
ui.add_space(8.0);
op
}
fn scroll_area() -> ScrollArea {
egui::ScrollArea::vertical()
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2])
}
fn add_account_button() -> Button<'static> {
let img_data = egui::include_image!("../../assets/icons/add_account_icon_4x.png");
let img = Image::new(img_data).fit_to_exact_size(Vec2::new(48.0, 48.0));
Button::image_and_text(
img,
RichText::new(" Add account")
.size(16.0)
// TODO: this color should not be hard coded. Find some way to add it to the visuals
.color(PINK),
)
.frame(false)
}
fn sign_out_button() -> egui::Button<'static> {
egui::Button::new(RichText::new("Sign out"))
}
// PREVIEWS
mod preview {
use super::*;
use crate::{accounts::process_accounts_view_response, test_data};
pub struct AccountsPreview {
app: Damus,
}
impl AccountsPreview {
fn new() -> Self {
let app = test_data::test_app();
AccountsPreview { app }
}
}
impl View for AccountsPreview {
fn ui(&mut self, ui: &mut egui::Ui) {
ui.add_space(24.0);
// TODO(jb55): maybe just use render_nav here so we can step through routes
if let Some(response) =
AccountsView::new(&self.app.ndb, &self.app.accounts, &mut self.app.img_cache)
.ui(ui)
.inner
{
process_accounts_view_response(
&mut self.app.accounts,
&mut self.app.decks_cache,
0,
response,
);
}
}
}
impl Preview for AccountsView<'_> {
type Prev = AccountsPreview;
fn preview(_cfg: PreviewConfig) -> Self::Prev {
AccountsPreview::new()
}
}
}

View File

@@ -0,0 +1,481 @@
use core::f32;
use std::collections::HashMap;
use egui::{
pos2, vec2, Align, Button, Color32, FontId, Id, ImageSource, Margin, Pos2, Rect, RichText,
Separator, Ui, Vec2,
};
use nostrdb::Ndb;
use tracing::error;
use crate::{
app_style::{get_font_size, NotedeckTextStyle},
login_manager::AcquireKeyState,
timeline::{PubkeySource, Timeline, TimelineKind},
ui::anim::ICON_EXPANSION_MULTIPLE,
user_account::UserAccount,
Damus,
};
use super::{anim::AnimationHelper, padding};
pub enum AddColumnResponse {
Timeline(Timeline),
UndecidedNotification,
ExternalNotification,
Hashtag,
}
pub enum NotificationColumnType {
Home,
External,
}
#[derive(Clone, Debug)]
enum AddColumnOption {
Universe,
UndecidedNotification,
ExternalNotification,
Notification(PubkeySource),
Home(PubkeySource),
UndecidedHashtag,
Hashtag(String),
}
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
pub enum AddColumnRoute {
Base,
UndecidedNotification,
ExternalNotification,
Hashtag,
}
impl AddColumnOption {
pub fn take_as_response(
self,
ndb: &Ndb,
cur_account: Option<&UserAccount>,
) -> Option<AddColumnResponse> {
match self {
AddColumnOption::Universe => TimelineKind::Universe
.into_timeline(ndb, None)
.map(AddColumnResponse::Timeline),
AddColumnOption::Notification(pubkey) => TimelineKind::Notifications(pubkey)
.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes()))
.map(AddColumnResponse::Timeline),
AddColumnOption::UndecidedNotification => {
Some(AddColumnResponse::UndecidedNotification)
}
AddColumnOption::Home(pubkey) => {
let tlk = TimelineKind::contact_list(pubkey);
tlk.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes()))
.map(AddColumnResponse::Timeline)
}
AddColumnOption::ExternalNotification => Some(AddColumnResponse::ExternalNotification),
AddColumnOption::UndecidedHashtag => Some(AddColumnResponse::Hashtag),
AddColumnOption::Hashtag(hashtag) => TimelineKind::Hashtag(hashtag)
.into_timeline(ndb, None)
.map(AddColumnResponse::Timeline),
}
}
}
pub struct AddColumnView<'a> {
key_state_map: &'a mut HashMap<Id, AcquireKeyState>,
ndb: &'a Ndb,
cur_account: Option<&'a UserAccount>,
}
impl<'a> AddColumnView<'a> {
pub fn new(
key_state_map: &'a mut HashMap<Id, AcquireKeyState>,
ndb: &'a Ndb,
cur_account: Option<&'a UserAccount>,
) -> Self {
Self {
key_state_map,
ndb,
cur_account,
}
}
pub fn ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
let mut selected_option: Option<AddColumnResponse> = None;
for column_option_data in self.get_base_options() {
let option = column_option_data.option.clone();
if self.column_option_ui(ui, column_option_data).clicked() {
selected_option = option.take_as_response(self.ndb, self.cur_account);
}
ui.add(Separator::default().spacing(0.0));
}
selected_option
}
fn notifications_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
let mut selected_option: Option<AddColumnResponse> = None;
for column_option_data in self.get_notifications_options() {
let option = column_option_data.option.clone();
if self.column_option_ui(ui, column_option_data).clicked() {
selected_option = option.take_as_response(self.ndb, self.cur_account);
}
ui.add(Separator::default().spacing(0.0));
}
selected_option
}
fn external_notification_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
padding(16.0, ui, |ui| {
let id = ui.id().with("external_notif");
let key_state = self.key_state_map.entry(id).or_default();
let text_edit = key_state.get_acquire_textedit(|text| {
egui::TextEdit::singleline(text)
.hint_text(
RichText::new("Enter the user's key (npub, hex, nip05) here...")
.text_style(NotedeckTextStyle::Body.text_style()),
)
.vertical_align(Align::Center)
.desired_width(f32::INFINITY)
.min_size(Vec2::new(0.0, 40.0))
.margin(Margin::same(12.0))
});
ui.add(text_edit);
if ui.button("Add").clicked() {
key_state.apply_acquire();
}
if key_state.is_awaiting_network() {
ui.spinner();
}
if let Some(error) = key_state.check_for_error() {
error!("acquire key error: {}", error);
ui.colored_label(
Color32::RED,
"Please enter a valid npub, public hex key or nip05",
);
}
if let Some(keypair) = key_state.check_for_successful_login() {
key_state.should_create_new();
AddColumnOption::Notification(PubkeySource::Explicit(keypair.pubkey))
.take_as_response(self.ndb, self.cur_account)
} else {
None
}
})
.inner
}
fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response {
let icon_padding = 8.0;
let min_icon_width = 32.0;
let height_padding = 12.0;
let max_width = ui.available_width();
let title_style = NotedeckTextStyle::Body;
let desc_style = NotedeckTextStyle::Button;
let title_min_font_size = get_font_size(ui.ctx(), &title_style);
let desc_min_font_size = get_font_size(ui.ctx(), &desc_style);
let max_height = {
let max_wrap_width =
max_width - ((icon_padding * 2.0) + (min_icon_width * ICON_EXPANSION_MULTIPLE));
let title_max_font = FontId::new(
title_min_font_size * ICON_EXPANSION_MULTIPLE,
title_style.font_family(),
);
let desc_max_font = FontId::new(
desc_min_font_size * ICON_EXPANSION_MULTIPLE,
desc_style.font_family(),
);
let max_desc_galley = ui.fonts(|f| {
f.layout(
data.description.to_string(),
desc_max_font,
Color32::WHITE,
max_wrap_width,
)
});
let max_title_galley = ui.fonts(|f| {
f.layout(
data.title.to_string(),
title_max_font,
Color32::WHITE,
max_wrap_width,
)
});
let desc_font_max_size = max_desc_galley.rect.height();
let title_font_max_size = max_title_galley.rect.height();
title_font_max_size + desc_font_max_size + (2.0 * height_padding)
};
let helper = AnimationHelper::new(ui, data.title, vec2(max_width, max_height));
let animation_rect = helper.get_animation_rect();
let cur_icon_width = helper.scale_1d_pos(min_icon_width);
let painter = ui.painter_at(animation_rect);
let cur_icon_size = vec2(cur_icon_width, cur_icon_width);
let cur_icon_x_pos = animation_rect.left() + (icon_padding) + (cur_icon_width / 2.0);
let title_cur_font = FontId::new(
helper.scale_1d_pos(title_min_font_size),
title_style.font_family(),
);
let desc_cur_font = FontId::new(
helper.scale_1d_pos(desc_min_font_size),
desc_style.font_family(),
);
let wrap_width = max_width - (cur_icon_width + (icon_padding * 2.0));
let text_color = ui.ctx().style().visuals.text_color();
let fallback_color = ui.ctx().style().visuals.weak_text_color();
let title_galley = painter.layout(
data.title.to_string(),
title_cur_font,
text_color,
wrap_width,
);
let desc_galley = painter.layout(
data.description.to_string(),
desc_cur_font,
text_color,
wrap_width,
);
let galley_heights = title_galley.rect.height() + desc_galley.rect.height();
let cur_height_padding = (animation_rect.height() - galley_heights) / 2.0;
let corner_x_pos = cur_icon_x_pos + (cur_icon_width / 2.0) + icon_padding;
let title_corner_pos = Pos2::new(corner_x_pos, animation_rect.top() + cur_height_padding);
let desc_corner_pos = Pos2::new(
corner_x_pos,
title_corner_pos.y + title_galley.rect.height(),
);
let icon_cur_y = animation_rect.top() + cur_height_padding + (galley_heights / 2.0);
let icon_img = egui::Image::new(data.icon).fit_to_exact_size(cur_icon_size);
let icon_rect = Rect::from_center_size(pos2(cur_icon_x_pos, icon_cur_y), cur_icon_size);
icon_img.paint_at(ui, icon_rect);
painter.galley(title_corner_pos, title_galley, fallback_color);
painter.galley(desc_corner_pos, desc_galley, fallback_color);
helper.take_animation_response()
}
fn get_base_options(&self) -> Vec<ColumnOptionData> {
let mut vec = Vec::new();
vec.push(ColumnOptionData {
title: "Universe",
description: "See the whole nostr universe",
icon: egui::include_image!("../../assets/icons/universe_icon_dark_4x.png"),
option: AddColumnOption::Universe,
});
if let Some(acc) = self.cur_account {
let source = if acc.secret_key.is_some() {
PubkeySource::DeckAuthor
} else {
PubkeySource::Explicit(acc.pubkey)
};
vec.push(ColumnOptionData {
title: "Home timeline",
description: "See recommended notes first",
icon: egui::include_image!("../../assets/icons/home_icon_dark_4x.png"),
option: AddColumnOption::Home(source.clone()),
});
}
vec.push(ColumnOptionData {
title: "Notifications",
description: "Stay up to date with notifications and mentions",
icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"),
option: AddColumnOption::UndecidedNotification,
});
vec.push(ColumnOptionData {
title: "Hashtag",
description: "Stay up to date with a certain hashtag",
icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"),
option: AddColumnOption::UndecidedHashtag,
});
vec
}
fn get_notifications_options(&self) -> Vec<ColumnOptionData> {
let mut vec = Vec::new();
if let Some(acc) = self.cur_account {
let source = if acc.secret_key.is_some() {
PubkeySource::DeckAuthor
} else {
PubkeySource::Explicit(acc.pubkey)
};
vec.push(ColumnOptionData {
title: "Your Notifications",
description: "Stay up to date with your notifications and mentions",
icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"),
option: AddColumnOption::Notification(source),
});
}
vec.push(ColumnOptionData {
title: "Someone else's Notifications",
description: "Stay up to date with someone else's notifications and mentions",
icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"),
option: AddColumnOption::ExternalNotification,
});
vec
}
}
struct ColumnOptionData {
title: &'static str,
description: &'static str,
icon: ImageSource<'static>,
option: AddColumnOption,
}
pub fn render_add_column_routes(
ui: &mut egui::Ui,
app: &mut Damus,
col: usize,
route: &AddColumnRoute,
) {
let mut add_column_view = AddColumnView::new(
&mut app.view_state.id_state_map,
&app.ndb,
app.accounts.get_selected_account(),
);
let resp = match route {
AddColumnRoute::Base => add_column_view.ui(ui),
AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui),
AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui),
AddColumnRoute::Hashtag => hashtag_ui(ui, &app.ndb, &mut app.view_state.id_string_map),
};
if let Some(resp) = resp {
match resp {
AddColumnResponse::Timeline(mut timeline) => {
crate::timeline::setup_new_timeline(
&mut timeline,
&app.ndb,
&mut app.subscriptions,
&mut app.pool,
&mut app.note_cache,
app.since_optimize,
&app.accounts.mutefun(),
);
app.columns_mut().add_timeline_to_column(col, timeline);
}
AddColumnResponse::UndecidedNotification => {
app.columns_mut().column_mut(col).router_mut().route_to(
crate::route::Route::AddColumn(AddColumnRoute::UndecidedNotification),
);
}
AddColumnResponse::ExternalNotification => {
app.columns_mut().column_mut(col).router_mut().route_to(
crate::route::Route::AddColumn(AddColumnRoute::ExternalNotification),
);
}
AddColumnResponse::Hashtag => {
app.columns_mut()
.column_mut(col)
.router_mut()
.route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag));
}
};
}
}
pub fn hashtag_ui(
ui: &mut Ui,
ndb: &Ndb,
id_string_map: &mut HashMap<Id, String>,
) -> Option<AddColumnResponse> {
padding(16.0, ui, |ui| {
let id = ui.id().with("hashtag");
let text_buffer = id_string_map.entry(id).or_default();
let text_edit = egui::TextEdit::singleline(text_buffer)
.hint_text(
RichText::new("Enter the desired hashtag here")
.text_style(NotedeckTextStyle::Body.text_style()),
)
.vertical_align(Align::Center)
.desired_width(f32::INFINITY)
.min_size(Vec2::new(0.0, 40.0))
.margin(Margin::same(12.0));
ui.add(text_edit);
ui.add_space(8.0);
if ui
.add_sized(
egui::vec2(50.0, 40.0),
Button::new("Add").rounding(8.0).fill(crate::colors::PINK),
)
.clicked()
{
let resp = AddColumnOption::Hashtag(text_buffer.to_owned()).take_as_response(ndb, None);
id_string_map.remove(&id);
resp
} else {
None
}
})
.inner
}
mod preview {
use crate::{
test_data,
ui::{Preview, PreviewConfig, View},
Damus,
};
use super::AddColumnView;
pub struct AddColumnPreview {
app: Damus,
}
impl AddColumnPreview {
fn new() -> Self {
let app = test_data::test_app();
AddColumnPreview { app }
}
}
impl View for AddColumnPreview {
fn ui(&mut self, ui: &mut egui::Ui) {
AddColumnView::new(
&mut self.app.view_state.id_state_map,
&self.app.ndb,
self.app.accounts.get_selected_account(),
)
.ui(ui);
}
}
impl Preview for AddColumnView<'_> {
type Prev = AddColumnPreview;
fn preview(_cfg: PreviewConfig) -> Self::Prev {
AddColumnPreview::new()
}
}
}

View File

@@ -0,0 +1,138 @@
use egui::{Pos2, Rect, Response, Sense};
pub fn hover_expand(
ui: &mut egui::Ui,
id: egui::Id,
size: f32,
expand_size: f32,
anim_speed: f32,
) -> (egui::Rect, f32, egui::Response) {
// Allocate space for the profile picture with a fixed size
let default_size = size + expand_size;
let (rect, response) =
ui.allocate_exact_size(egui::vec2(default_size, default_size), egui::Sense::click());
let val = ui
.ctx()
.animate_bool_with_time(id, response.hovered(), anim_speed);
let size = size + val * expand_size;
(rect, size, response)
}
pub fn hover_expand_small(ui: &mut egui::Ui, id: egui::Id) -> (egui::Rect, f32, egui::Response) {
let size = 10.0;
let expand_size = 5.0;
let anim_speed = 0.05;
hover_expand(ui, id, size, expand_size, anim_speed)
}
pub static ICON_EXPANSION_MULTIPLE: f32 = 1.2;
pub static ANIM_SPEED: f32 = 0.05;
pub struct AnimationHelper {
rect: Rect,
center: Pos2,
response: Response,
animation_progress: f32,
expansion_multiple: f32,
}
impl AnimationHelper {
pub fn new(
ui: &mut egui::Ui,
animation_name: impl std::hash::Hash,
max_size: egui::Vec2,
) -> Self {
let id = ui.id().with(animation_name);
let (rect, response) = ui.allocate_exact_size(max_size, Sense::click());
let animation_progress =
ui.ctx()
.animate_bool_with_time(id, response.hovered(), ANIM_SPEED);
Self {
rect,
center: rect.center(),
response,
animation_progress,
expansion_multiple: ICON_EXPANSION_MULTIPLE,
}
}
pub fn no_animation(ui: &mut egui::Ui, size: egui::Vec2) -> Self {
let (rect, response) = ui.allocate_exact_size(size, Sense::hover());
Self {
rect,
center: rect.center(),
response,
animation_progress: 0.0,
expansion_multiple: ICON_EXPANSION_MULTIPLE,
}
}
pub fn new_from_rect(
ui: &mut egui::Ui,
animation_name: impl std::hash::Hash,
animation_rect: egui::Rect,
) -> Self {
let id = ui.id().with(animation_name);
let response = ui.allocate_rect(animation_rect, Sense::click());
let animation_progress =
ui.ctx()
.animate_bool_with_time(id, response.hovered(), ANIM_SPEED);
Self {
rect: animation_rect,
center: animation_rect.center(),
response,
animation_progress,
expansion_multiple: ICON_EXPANSION_MULTIPLE,
}
}
pub fn scale_1d_pos(&self, min_object_size: f32) -> f32 {
let max_object_size = min_object_size * self.expansion_multiple;
if self.response.is_pointer_button_down_on() {
min_object_size
} else {
min_object_size + ((max_object_size - min_object_size) * self.animation_progress)
}
}
pub fn scale_radius(&self, min_diameter: f32) -> f32 {
self.scale_1d_pos((min_diameter - 1.0) / 2.0)
}
pub fn get_animation_rect(&self) -> egui::Rect {
self.rect
}
pub fn center(&self) -> Pos2 {
self.rect.center()
}
pub fn take_animation_response(self) -> egui::Response {
self.response
}
// Scale a minimum position from center to the current animation position
pub fn scale_from_center(&self, x_min: f32, y_min: f32) -> Pos2 {
Pos2::new(
self.center.x + self.scale_1d_pos(x_min),
self.center.y + self.scale_1d_pos(y_min),
)
}
pub fn scale_pos_from_center(&self, min_pos: Pos2) -> Pos2 {
self.scale_from_center(min_pos.x, min_pos.y)
}
/// New method for min/max scaling when needed
pub fn scale_1d_pos_min_max(&self, min_object_size: f32, max_object_size: f32) -> f32 {
min_object_size + ((max_object_size - min_object_size) * self.animation_progress)
}
}

View File

@@ -0,0 +1,271 @@
use crate::{
app_style::NotedeckTextStyle,
column::Columns,
imgcache::ImageCache,
nav::RenderNavAction,
route::Route,
timeline::{TimelineId, TimelineRoute},
ui::{
self,
anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
},
};
use egui::{RichText, Stroke, UiBuilder};
use enostr::Pubkey;
use nostrdb::{Ndb, Transaction};
pub struct NavTitle<'a> {
ndb: &'a Ndb,
img_cache: &'a mut ImageCache,
columns: &'a Columns,
deck_author: Option<&'a Pubkey>,
routes: &'a [Route],
}
impl<'a> NavTitle<'a> {
pub fn new(
ndb: &'a Ndb,
img_cache: &'a mut ImageCache,
columns: &'a Columns,
deck_author: Option<&'a Pubkey>,
routes: &'a [Route],
) -> Self {
NavTitle {
ndb,
img_cache,
columns,
deck_author,
routes,
}
}
pub fn show(&mut self, ui: &mut egui::Ui) -> Option<RenderNavAction> {
ui::padding(8.0, ui, |ui| {
let mut rect = ui.available_rect_before_wrap();
rect.set_height(48.0);
let mut child_ui = ui.new_child(
UiBuilder::new()
.max_rect(rect)
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
let r = self.title_bar(&mut child_ui);
ui.advance_cursor_after_rect(rect);
r
})
.inner
}
fn title_bar(&mut self, ui: &mut egui::Ui) -> Option<RenderNavAction> {
let item_spacing = 8.0;
ui.spacing_mut().item_spacing.x = item_spacing;
let chev_x = 8.0;
let back_button_resp =
prev(self.routes).map(|r| self.back_button(ui, r, egui::Vec2::new(chev_x, 15.0)));
// add some space where chevron would have been. this makes the ui
// less bumpy when navigating
if back_button_resp.is_none() {
ui.add_space(chev_x + item_spacing);
}
let delete_button_resp =
self.title(ui, self.routes.last().unwrap(), back_button_resp.is_some());
if delete_button_resp.map_or(false, |r| r.clicked()) {
Some(RenderNavAction::RemoveColumn)
} else if back_button_resp.map_or(false, |r| r.clicked()) {
Some(RenderNavAction::Back)
} else {
None
}
}
fn back_button(
&mut self,
ui: &mut egui::Ui,
prev: &Route,
chev_size: egui::Vec2,
) -> egui::Response {
//let color = ui.visuals().hyperlink_color;
let color = ui.style().visuals.noninteractive().fg_stroke.color;
//let spacing_prev = ui.spacing().item_spacing.x;
//ui.spacing_mut().item_spacing.x = 0.0;
let chev_resp = chevron(ui, 2.0, chev_size, Stroke::new(2.0, color));
//ui.spacing_mut().item_spacing.x = spacing_prev;
// NOTE(jb55): include graphic in back label as well because why
// not it looks cool
self.title_pfp(ui, prev, 32.0);
let back_label = ui.add(
egui::Label::new(
RichText::new(prev.title(self.columns).to_string())
.color(color)
.text_style(NotedeckTextStyle::Body.text_style()),
)
.selectable(false)
.sense(egui::Sense::click()),
);
back_label.union(chev_resp)
}
fn delete_column_button(&self, ui: &mut egui::Ui, icon_width: f32) -> egui::Response {
let img_size = 16.0;
let max_size = icon_width * ICON_EXPANSION_MULTIPLE;
let img_data = if ui.visuals().dark_mode {
egui::include_image!("../../../assets/icons/column_delete_icon_4x.png")
} else {
egui::include_image!("../../../assets/icons/column_delete_icon_light_4x.png")
};
let img = egui::Image::new(img_data).max_width(img_size);
let helper =
AnimationHelper::new(ui, "delete-column-button", egui::vec2(max_size, max_size));
let cur_img_size = helper.scale_1d_pos_min_max(0.0, img_size);
let animation_rect = helper.get_animation_rect();
let animation_resp = helper.take_animation_response();
img.paint_at(ui, animation_rect.shrink((max_size - cur_img_size) / 2.0));
animation_resp
}
fn pubkey_pfp<'txn, 'me>(
&'me mut self,
txn: &'txn Transaction,
pubkey: &[u8; 32],
pfp_size: f32,
) -> Option<ui::ProfilePic<'me, 'txn>> {
self.ndb
.get_profile_by_pubkey(txn, pubkey)
.as_ref()
.ok()
.and_then(move |p| {
Some(ui::ProfilePic::from_profile(self.img_cache, p)?.size(pfp_size))
})
}
fn timeline_pfp(&mut self, ui: &mut egui::Ui, id: TimelineId, pfp_size: f32) {
let txn = Transaction::new(self.ndb).unwrap();
if let Some(pfp) = self
.columns
.find_timeline(id)
.and_then(|tl| tl.kind.pubkey_source())
.and_then(|pksrc| self.deck_author.map(|da| pksrc.to_pubkey(da)))
.and_then(|pk| self.pubkey_pfp(&txn, pk.bytes(), pfp_size))
{
ui.add(pfp);
} else {
ui.add(
ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size),
);
}
}
fn title_pfp(&mut self, ui: &mut egui::Ui, top: &Route, pfp_size: f32) {
match top {
Route::Timeline(tlr) => match tlr {
TimelineRoute::Timeline(tlid) => {
self.timeline_pfp(ui, *tlid, pfp_size);
}
TimelineRoute::Thread(_note_id) => {}
TimelineRoute::Reply(_note_id) => {}
TimelineRoute::Quote(_note_id) => {}
TimelineRoute::Profile(pubkey) => {
let txn = Transaction::new(self.ndb).unwrap();
if let Some(pfp) = self.pubkey_pfp(&txn, pubkey.bytes(), pfp_size) {
ui.add(pfp);
} else {
ui.add(
ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url())
.size(pfp_size),
);
}
}
},
Route::Accounts(_as) => {}
Route::ComposeNote => {}
Route::AddColumn(_add_col_route) => {}
Route::Support => {}
Route::Relays => {}
Route::NewDeck => {}
Route::EditDeck(_) => {}
}
}
fn title_label(&self, ui: &mut egui::Ui, top: &Route) {
ui.add(
egui::Label::new(
RichText::new(top.title(self.columns))
.text_style(NotedeckTextStyle::Body.text_style()),
)
.selectable(false),
);
}
fn title(
&mut self,
ui: &mut egui::Ui,
top: &Route,
navigating: bool,
) -> Option<egui::Response> {
if !navigating {
self.title_pfp(ui, top, 32.0);
self.title_label(ui, top);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if navigating {
self.title_label(ui, top);
self.title_pfp(ui, top, 32.0);
None
} else {
Some(self.delete_column_button(ui, 32.0))
}
})
.inner
}
}
fn prev<R>(xs: &[R]) -> Option<&R> {
xs.get(xs.len().checked_sub(2)?)
}
fn chevron(
ui: &mut egui::Ui,
pad: f32,
size: egui::Vec2,
stroke: impl Into<Stroke>,
) -> egui::Response {
let (r, painter) = ui.allocate_painter(size, egui::Sense::click());
let min = r.rect.min;
let max = r.rect.max;
let apex = egui::Pos2::new(min.x + pad, min.y + size.y / 2.0);
let top = egui::Pos2::new(max.x - pad, min.y + pad);
let bottom = egui::Pos2::new(max.x - pad, max.y - pad);
let stroke = stroke.into();
painter.line_segment([apex, top], stroke);
painter.line_segment([apex, bottom], stroke);
r
}

View File

@@ -0,0 +1,3 @@
mod header;
pub use header::NavTitle;

View File

@@ -0,0 +1,332 @@
use egui::{vec2, Button, Color32, Label, RichText, Stroke, Ui, Widget};
use crate::{
app_style::{deck_icon_font_sized, get_font_size, NotedeckTextStyle},
colors::PINK,
deck_state::DeckState,
fonts::NamedFontFamily,
};
use super::{
anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
padding,
};
pub struct ConfigureDeckView<'a> {
state: &'a mut DeckState,
create_button_text: String,
}
pub struct ConfigureDeckResponse {
pub icon: char,
pub name: String,
}
static CREATE_TEXT: &str = "Create Deck";
impl<'a> ConfigureDeckView<'a> {
pub fn new(state: &'a mut DeckState) -> Self {
Self {
state,
create_button_text: CREATE_TEXT.to_owned(),
}
}
pub fn with_create_text(mut self, text: &str) -> Self {
self.create_button_text = text.to_owned();
self
}
pub fn ui(&mut self, ui: &mut Ui) -> Option<ConfigureDeckResponse> {
let title_font = egui::FontId::new(
get_font_size(ui.ctx(), &NotedeckTextStyle::Heading4),
egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()),
);
padding(16.0, ui, |ui| {
ui.add(Label::new(
RichText::new("Deck name").font(title_font.clone()),
));
ui.add_space(8.0);
ui.text_edit_singleline(&mut self.state.deck_name);
ui.add_space(8.0);
ui.add(Label::new(
RichText::new("We recommend short names")
.color(ui.visuals().noninteractive().fg_stroke.color)
.size(get_font_size(ui.ctx(), &NotedeckTextStyle::Small)),
));
ui.add_space(32.0);
ui.add(Label::new(RichText::new("Icon").font(title_font)));
if ui
.add(deck_icon(
ui.id().with("config-deck"),
self.state.selected_glyph,
38.0,
64.0,
false,
))
.clicked()
{
self.state.selecting_glyph = !self.state.selecting_glyph;
}
if self.state.selecting_glyph {
let max_height = if ui.available_height() - 100.0 > 0.0 {
ui.available_height() - 100.0
} else {
ui.available_height()
};
egui::Frame::window(ui.style()).show(ui, |ui| {
let glyphs = self.state.get_glyph_options(ui);
if let Some(selected_glyph) = glyph_options_ui(ui, 16.0, max_height, glyphs) {
self.state.selected_glyph = Some(selected_glyph);
self.state.selecting_glyph = false;
}
});
ui.add_space(16.0);
}
if self.state.warn_no_icon && self.state.selected_glyph.is_some() {
self.state.warn_no_icon = false;
}
if self.state.warn_no_title && !self.state.deck_name.is_empty() {
self.state.warn_no_title = false;
}
show_warnings(ui, self.state.warn_no_icon, self.state.warn_no_title);
let mut resp = None;
if ui
.add(create_deck_button(&self.create_button_text))
.clicked()
{
if self.state.deck_name.is_empty() {
self.state.warn_no_title = true;
}
if self.state.selected_glyph.is_none() {
self.state.warn_no_icon = true;
}
if !self.state.deck_name.is_empty() {
if let Some(glyph) = self.state.selected_glyph {
resp = Some(ConfigureDeckResponse {
icon: glyph,
name: self.state.deck_name.clone(),
});
}
}
}
resp
})
.inner
}
}
fn show_warnings(ui: &mut Ui, warn_no_icon: bool, warn_no_title: bool) {
if warn_no_icon || warn_no_title {
let messages = [
if warn_no_title {
"create a name for the deck"
} else {
""
},
if warn_no_icon { "select an icon" } else { "" },
];
let message = messages
.iter()
.filter(|&&m| !m.is_empty())
.copied()
.collect::<Vec<_>>()
.join(" and ");
ui.add(
egui::Label::new(
RichText::new(format!("Please {}.", message)).color(ui.visuals().error_fg_color),
)
.wrap(),
);
}
}
fn create_deck_button(text: &str) -> impl Widget + '_ {
move |ui: &mut egui::Ui| {
let size = vec2(108.0, 40.0);
ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Center), |ui| {
ui.add(Button::new(text).fill(PINK).min_size(size))
})
.inner
}
}
pub fn deck_icon(
id: egui::Id,
glyph: Option<char>,
font_size: f32,
full_size: f32,
highlight: bool,
) -> impl Widget {
move |ui: &mut egui::Ui| -> egui::Response {
let max_size = full_size * ICON_EXPANSION_MULTIPLE;
let helper = AnimationHelper::new(ui, id, vec2(max_size, max_size));
let painter = ui.painter_at(helper.get_animation_rect());
let bg_center = helper.get_animation_rect().center();
let (stroke, fill_color) = if highlight {
(
ui.visuals().selection.stroke,
ui.visuals().widgets.noninteractive.weak_bg_fill,
)
} else {
(
Stroke::new(
ui.visuals().widgets.inactive.bg_stroke.width,
ui.visuals().widgets.inactive.weak_bg_fill,
),
ui.visuals().widgets.noninteractive.weak_bg_fill,
)
};
let radius = helper.scale_1d_pos((full_size / 2.0) - stroke.width);
painter.circle(bg_center, radius, fill_color, stroke);
if let Some(glyph) = glyph {
let font =
deck_icon_font_sized(helper.scale_1d_pos(font_size / std::f32::consts::SQRT_2));
let glyph_galley =
painter.layout_no_wrap(glyph.to_string(), font, ui.visuals().text_color());
let top_left = {
let mut glyph_rect = glyph_galley.rect;
glyph_rect.set_center(bg_center);
glyph_rect.left_top()
};
painter.galley(top_left, glyph_galley, Color32::WHITE);
}
helper.take_animation_response()
}
}
fn glyph_icon_max_size(ui: &egui::Ui, glyph: &char, font_size: f32) -> egui::Vec2 {
let painter = ui.painter();
let font = deck_icon_font_sized(font_size * ICON_EXPANSION_MULTIPLE);
let glyph_galley = painter.layout_no_wrap(glyph.to_string(), font, Color32::WHITE);
glyph_galley.rect.size()
}
fn glyph_icon(glyph: char, font_size: f32, max_size: egui::Vec2, color: Color32) -> impl Widget {
move |ui: &mut egui::Ui| {
let helper = AnimationHelper::new(ui, ("glyph", glyph), max_size);
let painter = ui.painter_at(helper.get_animation_rect());
let font = deck_icon_font_sized(helper.scale_1d_pos(font_size));
let glyph_galley = painter.layout_no_wrap(glyph.to_string(), font, color);
let top_left = {
let mut glyph_rect = glyph_galley.rect;
glyph_rect.set_center(helper.get_animation_rect().center());
glyph_rect.left_top()
};
painter.galley(top_left, glyph_galley, Color32::WHITE);
helper.take_animation_response()
}
}
fn glyph_options_ui(
ui: &mut egui::Ui,
font_size: f32,
max_height: f32,
glyphs: &[char],
) -> Option<char> {
let mut selected_glyph = None;
egui::ScrollArea::vertical()
.max_height(max_height)
.show(ui, |ui| {
let max_width = ui.available_width();
let mut row_glyphs = Vec::new();
let mut cur_width = 0.0;
let spacing = ui.spacing().item_spacing.x;
for (index, glyph) in glyphs.iter().enumerate() {
let next_glyph_size = glyph_icon_max_size(ui, glyph, font_size);
if cur_width + spacing + next_glyph_size.x > max_width {
if let Some(selected) = paint_row(ui, &row_glyphs, font_size) {
selected_glyph = Some(selected);
}
row_glyphs.clear();
cur_width = 0.0;
}
cur_width += spacing;
cur_width += next_glyph_size.x;
row_glyphs.push(*glyph);
if index == glyphs.len() - 1 {
if let Some(selected) = paint_row(ui, &row_glyphs, font_size) {
selected_glyph = Some(selected);
}
}
}
});
selected_glyph
}
fn paint_row(ui: &mut egui::Ui, row_glyphs: &[char], font_size: f32) -> Option<char> {
let mut selected_glyph = None;
ui.horizontal(|ui| {
for glyph in row_glyphs {
let glyph_size = glyph_icon_max_size(ui, glyph, font_size);
if ui
.add(glyph_icon(
*glyph,
font_size,
glyph_size,
ui.visuals().text_color(),
))
.clicked()
{
selected_glyph = Some(*glyph);
}
}
});
selected_glyph
}
mod preview {
use crate::{
deck_state::DeckState,
ui::{Preview, PreviewConfig, View},
};
use super::ConfigureDeckView;
pub struct ConfigureDeckPreview {
state: DeckState,
}
impl ConfigureDeckPreview {
fn new() -> Self {
let state = DeckState::default();
ConfigureDeckPreview { state }
}
}
impl View for ConfigureDeckPreview {
fn ui(&mut self, ui: &mut egui::Ui) {
ConfigureDeckView::new(&mut self.state).ui(ui);
}
}
impl Preview for ConfigureDeckView<'_> {
type Prev = ConfigureDeckPreview;
fn preview(_cfg: PreviewConfig) -> Self::Prev {
ConfigureDeckPreview::new()
}
}
}

View File

@@ -0,0 +1,91 @@
use egui::Widget;
use crate::deck_state::DeckState;
use super::{
configure_deck::{ConfigureDeckResponse, ConfigureDeckView},
padding,
};
pub struct EditDeckView<'a> {
config_view: ConfigureDeckView<'a>,
}
static EDIT_TEXT: &str = "Edit Deck";
pub enum EditDeckResponse {
Edit(ConfigureDeckResponse),
Delete,
}
impl<'a> EditDeckView<'a> {
pub fn new(state: &'a mut DeckState) -> Self {
let config_view = ConfigureDeckView::new(state).with_create_text(EDIT_TEXT);
Self { config_view }
}
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<EditDeckResponse> {
let mut edit_deck_resp = None;
padding(egui::Margin::symmetric(16.0, 4.0), ui, |ui| {
if ui.add(delete_button()).clicked() {
edit_deck_resp = Some(EditDeckResponse::Delete);
}
});
if let Some(config_resp) = self.config_view.ui(ui) {
edit_deck_resp = Some(EditDeckResponse::Edit(config_resp))
}
edit_deck_resp
}
}
fn delete_button() -> impl Widget {
|ui: &mut egui::Ui| {
let size = egui::vec2(108.0, 40.0);
ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Center), |ui| {
ui.add(
egui::Button::new("Delete Deck")
.fill(ui.visuals().error_fg_color)
.min_size(size),
)
})
.inner
}
}
mod preview {
use crate::{
deck_state::DeckState,
ui::{Preview, PreviewConfig, View},
};
use super::EditDeckView;
pub struct EditDeckPreview {
state: DeckState,
}
impl EditDeckPreview {
fn new() -> Self {
let state = DeckState::default();
EditDeckPreview { state }
}
}
impl View for EditDeckPreview {
fn ui(&mut self, ui: &mut egui::Ui) {
EditDeckView::new(&mut self.state).ui(ui);
}
}
impl Preview for EditDeckView<'_> {
type Prev = EditDeckPreview;
fn preview(_cfg: PreviewConfig) -> Self::Prev {
EditDeckPreview::new()
}
}
}

View File

@@ -0,0 +1,92 @@
use crate::{colors, imgcache::ImageCache, ui};
use nostrdb::{Ndb, Transaction};
pub struct Mention<'a> {
ndb: &'a Ndb,
img_cache: &'a mut ImageCache,
txn: &'a Transaction,
pk: &'a [u8; 32],
selectable: bool,
size: f32,
}
impl<'a> Mention<'a> {
pub fn new(
ndb: &'a Ndb,
img_cache: &'a mut ImageCache,
txn: &'a Transaction,
pk: &'a [u8; 32],
) -> Self {
let size = 16.0;
let selectable = true;
Mention {
ndb,
img_cache,
txn,
pk,
selectable,
size,
}
}
pub fn selectable(mut self, selectable: bool) -> Self {
self.selectable = selectable;
self
}
pub fn size(mut self, size: f32) -> Self {
self.size = size;
self
}
}
impl egui::Widget for Mention<'_> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
mention_ui(
self.ndb,
self.img_cache,
self.txn,
self.pk,
ui,
self.size,
self.selectable,
)
}
}
fn mention_ui(
ndb: &Ndb,
img_cache: &mut ImageCache,
txn: &Transaction,
pk: &[u8; 32],
ui: &mut egui::Ui,
size: f32,
selectable: bool,
) -> egui::Response {
#[cfg(feature = "profiling")]
puffin::profile_function!();
ui.horizontal(|ui| {
let profile = ndb.get_profile_by_pubkey(txn, pk).ok();
let name: String =
if let Some(name) = profile.as_ref().and_then(crate::profile::get_profile_name) {
format!("@{}", name.username())
} else {
"??".to_string()
};
let resp = ui.add(
egui::Label::new(egui::RichText::new(name).color(colors::PURPLE).size(size))
.selectable(selectable),
);
if let Some(rec) = profile.as_ref() {
resp.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(300.0);
ui.add(ui::ProfilePreview::new(rec, img_cache));
});
}
})
.response
}

View File

@@ -0,0 +1,86 @@
pub mod account_login_view;
pub mod accounts;
pub mod add_column;
pub mod anim;
pub mod column;
pub mod configure_deck;
pub mod edit_deck;
pub mod mention;
pub mod note;
pub mod preview;
pub mod profile;
pub mod relay;
pub mod side_panel;
pub mod support;
pub mod thread;
pub mod timeline;
pub mod username;
pub use accounts::AccountsView;
pub use mention::Mention;
pub use note::{NoteResponse, NoteView, PostReplyView, PostView};
pub use preview::{Preview, PreviewApp, PreviewConfig};
pub use profile::{ProfilePic, ProfilePreview};
pub use relay::RelayView;
pub use side_panel::{DesktopSidePanel, SidePanelAction};
pub use thread::ThreadView;
pub use timeline::TimelineView;
pub use username::Username;
use egui::Margin;
/// This is kind of like the Widget trait but is meant for larger top-level
/// views that are typically stateful.
///
/// The Widget trait forces us to add mutable
/// implementations at the type level, which screws us when generating Previews
/// for a Widget. I would have just Widget instead of making this Trait otherwise.
///
/// There is some precendent for this, it looks like there's a similar trait
/// in the egui demo library.
pub trait View {
fn ui(&mut self, ui: &mut egui::Ui);
}
pub fn padding<R>(
amount: impl Into<Margin>,
ui: &mut egui::Ui,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::InnerResponse<R> {
egui::Frame::none()
.inner_margin(amount)
.show(ui, add_contents)
}
pub fn hline(ui: &egui::Ui) {
// pixel perfect horizontal line
let rect = ui.available_rect_before_wrap();
let resize_y = ui.painter().round_to_pixel(rect.top()) - 0.5;
let stroke = ui.style().visuals.widgets.noninteractive.bg_stroke;
ui.painter().hline(rect.x_range(), resize_y, stroke);
}
#[inline]
#[allow(unreachable_code)]
pub fn is_compiled_as_mobile() -> bool {
#[cfg(any(target_os = "android", target_os = "ios"))]
{
true
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
false
}
}
/// Determine if the screen is narrow. This is useful for detecting mobile
/// contexts, but with the nuance that we may also have a wide android tablet.
pub fn is_narrow(ctx: &egui::Context) -> bool {
let screen_size = ctx.input(|c| c.screen_rect().size());
screen_size.x < 550.0
}
pub fn is_oled() -> bool {
is_compiled_as_mobile()
}

View File

@@ -0,0 +1,306 @@
use crate::actionbar::NoteAction;
use crate::images::ImageType;
use crate::imgcache::ImageCache;
use crate::notecache::NoteCache;
use crate::ui::note::{NoteOptions, NoteResponse};
use crate::ui::ProfilePic;
use crate::{colors, ui};
use egui::{Color32, Hyperlink, Image, RichText};
use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction};
use tracing::warn;
pub struct NoteContents<'a> {
ndb: &'a Ndb,
img_cache: &'a mut ImageCache,
note_cache: &'a mut NoteCache,
txn: &'a Transaction,
note: &'a Note<'a>,
note_key: NoteKey,
options: NoteOptions,
action: Option<NoteAction>,
}
impl<'a> NoteContents<'a> {
pub fn new(
ndb: &'a Ndb,
img_cache: &'a mut ImageCache,
note_cache: &'a mut NoteCache,
txn: &'a Transaction,
note: &'a Note,
note_key: NoteKey,
options: ui::note::NoteOptions,
) -> Self {
NoteContents {
ndb,
img_cache,
note_cache,
txn,
note,
note_key,
options,
action: None,
}
}
pub fn action(&self) -> &Option<NoteAction> {
&self.action
}
}
impl egui::Widget for &mut NoteContents<'_> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
let result = render_note_contents(
ui,
self.ndb,
self.img_cache,
self.note_cache,
self.txn,
self.note,
self.note_key,
self.options,
);
self.action = result.action;
result.response
}
}
/// Render an inline note preview with a border. These are used when
/// notes are references within a note
pub fn render_note_preview(
ui: &mut egui::Ui,
ndb: &Ndb,
note_cache: &mut NoteCache,
img_cache: &mut ImageCache,
txn: &Transaction,
id: &[u8; 32],
parent: NoteKey,
) -> NoteResponse {
#[cfg(feature = "profiling")]
puffin::profile_function!();
let note = if let Ok(note) = ndb.get_note_by_id(txn, id) {
// TODO: support other preview kinds
if note.kind() == 1 {
note
} else {
return NoteResponse::new(ui.colored_label(
Color32::RED,
format!("TODO: can't preview kind {}", note.kind()),
));
}
} else {
return NoteResponse::new(ui.colored_label(Color32::RED, "TODO: COULD NOT LOAD"));
/*
return ui
.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.colored_label(colors::PURPLE, "@");
ui.colored_label(colors::PURPLE, &id_str[4..16]);
})
.response;
*/
};
egui::Frame::none()
.fill(ui.visuals().noninteractive().weak_bg_fill)
.inner_margin(egui::Margin::same(8.0))
.outer_margin(egui::Margin::symmetric(0.0, 8.0))
.rounding(egui::Rounding::same(10.0))
.stroke(egui::Stroke::new(
1.0,
ui.visuals().noninteractive().bg_stroke.color,
))
.show(ui, |ui| {
ui::NoteView::new(ndb, note_cache, img_cache, &note)
.actionbar(false)
.small_pfp(true)
.wide(true)
.note_previews(false)
.options_button(true)
.parent(parent)
.show(ui)
})
.inner
}
fn is_image_link(url: &str) -> bool {
url.ends_with("png") || url.ends_with("jpg") || url.ends_with("jpeg")
}
#[allow(clippy::too_many_arguments)]
fn render_note_contents(
ui: &mut egui::Ui,
ndb: &Ndb,
img_cache: &mut ImageCache,
note_cache: &mut NoteCache,
txn: &Transaction,
note: &Note,
note_key: NoteKey,
options: NoteOptions,
) -> NoteResponse {
#[cfg(feature = "profiling")]
puffin::profile_function!();
let selectable = options.has_selectable_text();
let mut images: Vec<String> = vec![];
let mut inline_note: Option<(&[u8; 32], &str)> = None;
let hide_media = options.has_hide_media();
let response = ui.horizontal_wrapped(|ui| {
let blocks = if let Ok(blocks) = 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 => match block.as_mention().unwrap() {
Mention::Profile(profile) => {
ui.add(ui::Mention::new(ndb, img_cache, txn, profile.pubkey()));
}
Mention::Pubkey(npub) => {
ui.add(ui::Mention::new(ndb, img_cache, txn, npub.pubkey()));
}
Mention::Note(note) if options.has_note_previews() => {
inline_note = Some((note.id(), block.as_str()));
}
Mention::Event(note) if options.has_note_previews() => {
inline_note = Some((note.id(), block.as_str()));
}
_ => {
ui.colored_label(colors::PURPLE, format!("@{}", &block.as_str()[4..16]));
}
},
BlockType::Hashtag => {
#[cfg(feature = "profiling")]
puffin::profile_scope!("hashtag contents");
ui.colored_label(colors::PURPLE, format!("#{}", block.as_str()));
}
BlockType::Url => {
let lower_url = block.as_str().to_lowercase();
if !hide_media && is_image_link(&lower_url) {
images.push(block.as_str().to_string());
} else {
#[cfg(feature = "profiling")]
puffin::profile_scope!("url contents");
ui.add(Hyperlink::from_label_and_url(
RichText::new(block.as_str()).color(colors::PURPLE),
block.as_str(),
));
}
}
BlockType::Text => {
#[cfg(feature = "profiling")]
puffin::profile_scope!("text contents");
ui.add(egui::Label::new(block.as_str()).selectable(selectable));
}
_ => {
ui.colored_label(colors::PURPLE, block.as_str());
}
}
}
});
let note_action = if let Some((id, _block_str)) = inline_note {
render_note_preview(ui, ndb, note_cache, img_cache, txn, id, note_key).action
} else {
None
};
if !images.is_empty() && !options.has_textmode() {
ui.add_space(2.0);
let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note")));
image_carousel(ui, img_cache, images, carousel_id);
ui.add_space(2.0);
}
NoteResponse::new(response.response).with_action(note_action)
}
fn image_carousel(
ui: &mut egui::Ui,
img_cache: &mut ImageCache,
images: Vec<String>,
carousel_id: egui::Id,
) {
// let's make sure everything is within our area
let height = 360.0;
let width = ui.available_size().x;
let spinsz = if height > width { width } else { height };
ui.add_sized([width, height], |ui: &mut egui::Ui| {
egui::ScrollArea::horizontal()
.id_salt(carousel_id)
.show(ui, |ui| {
ui.horizontal(|ui| {
for image in images {
// If the cache is empty, initiate the fetch
let m_cached_promise = img_cache.map().get(&image);
if m_cached_promise.is_none() {
let res = crate::images::fetch_img(
img_cache,
ui.ctx(),
&image,
ImageType::Content(width.round() as u32, height.round() as u32),
);
img_cache.map_mut().insert(image.to_owned(), res);
}
// What is the state of the fetch?
match img_cache.map()[&image].ready() {
// Still waiting
None => {
ui.allocate_space(egui::vec2(spinsz, spinsz));
//ui.add(egui::Spinner::new().size(spinsz));
}
// Failed to fetch image!
Some(Err(_err)) => {
// FIXME - use content-specific error instead
let no_pfp = crate::images::fetch_img(
img_cache,
ui.ctx(),
ProfilePic::no_pfp_url(),
ImageType::Profile(128),
);
img_cache.map_mut().insert(image.to_owned(), no_pfp);
// spin until next pass
ui.allocate_space(egui::vec2(spinsz, spinsz));
//ui.add(egui::Spinner::new().size(spinsz));
}
// Use the previously resolved image
Some(Ok(img)) => {
let img_resp = ui.add(
Image::new(img)
.max_height(height)
.rounding(5.0)
.fit_to_original_size(1.0),
);
img_resp.context_menu(|ui| {
if ui.button("Copy Link").clicked() {
ui.ctx().copy_text(image);
ui.close_menu();
}
});
}
}
}
})
.response
})
.inner
});
}

View File

@@ -0,0 +1,183 @@
use crate::colors;
use egui::{Rect, Vec2};
use enostr::{NoteId, Pubkey};
use nostrdb::{Note, NoteKey};
#[derive(Clone)]
#[allow(clippy::enum_variant_names)]
pub enum NoteContextSelection {
CopyText,
CopyPubkey,
CopyNoteId,
}
impl NoteContextSelection {
pub fn process(&self, ui: &mut egui::Ui, note: &Note<'_>) {
match self {
NoteContextSelection::CopyText => {
ui.output_mut(|w| {
w.copied_text = note.content().to_string();
});
}
NoteContextSelection::CopyPubkey => {
ui.output_mut(|w| {
if let Some(bech) = Pubkey::new(*note.pubkey()).to_bech() {
w.copied_text = bech;
}
});
}
NoteContextSelection::CopyNoteId => {
ui.output_mut(|w| {
if let Some(bech) = NoteId::new(*note.id()).to_bech() {
w.copied_text = bech;
}
});
}
}
}
}
pub struct NoteContextButton {
put_at: Option<Rect>,
note_key: NoteKey,
}
impl egui::Widget for NoteContextButton {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
let r = if let Some(r) = self.put_at {
r
} else {
let mut place = ui.available_rect_before_wrap();
let size = Self::max_width();
place.set_width(size);
place.set_height(size);
place
};
Self::show(ui, self.note_key, r)
}
}
impl NoteContextButton {
pub fn new(note_key: NoteKey) -> Self {
let put_at: Option<Rect> = None;
NoteContextButton { note_key, put_at }
}
pub fn place_at(mut self, rect: Rect) -> Self {
self.put_at = Some(rect);
self
}
pub fn max_width() -> f32 {
Self::max_radius() * 3.0 + Self::max_distance_between_circles() * 2.0
}
pub fn size() -> Vec2 {
let width = Self::max_width();
egui::vec2(width, width)
}
fn max_radius() -> f32 {
8.0
}
fn min_radius() -> f32 {
Self::max_radius() / Self::expansion_multiple()
}
fn max_distance_between_circles() -> f32 {
2.0
}
fn expansion_multiple() -> f32 {
2.0
}
fn min_distance_between_circles() -> f32 {
Self::max_distance_between_circles() / Self::expansion_multiple()
}
pub fn show(ui: &mut egui::Ui, note_key: NoteKey, put_at: Rect) -> egui::Response {
#[cfg(feature = "profiling")]
puffin::profile_function!();
let id = ui.id().with(("more_options_anim", note_key));
let min_radius = Self::min_radius();
let anim_speed = 0.05;
let response = ui.interact(put_at, id, egui::Sense::click());
let hovered = response.hovered();
let animation_progress = ui.ctx().animate_bool_with_time(id, hovered, anim_speed);
if hovered {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let min_distance = Self::min_distance_between_circles();
let cur_distance = min_distance
+ (Self::max_distance_between_circles() - min_distance) * animation_progress;
let cur_radius = min_radius + (Self::max_radius() - min_radius) * animation_progress;
let center = put_at.center();
let left_circle_center = center - egui::vec2(cur_distance + cur_radius, 0.0);
let right_circle_center = center + egui::vec2(cur_distance + cur_radius, 0.0);
let translated_radius = (cur_radius - 1.0) / 2.0;
// This works in both themes
let color = colors::GRAY_SECONDARY;
// Draw circles
let painter = ui.painter_at(put_at);
painter.circle_filled(left_circle_center, translated_radius, color);
painter.circle_filled(center, translated_radius, color);
painter.circle_filled(right_circle_center, translated_radius, color);
response
}
pub fn menu(
ui: &mut egui::Ui,
button_response: egui::Response,
) -> Option<NoteContextSelection> {
#[cfg(feature = "profiling")]
puffin::profile_function!();
let mut context_selection: Option<NoteContextSelection> = None;
stationary_arbitrary_menu_button(ui, button_response, |ui| {
ui.set_max_width(200.0);
if ui.button("Copy text").clicked() {
context_selection = Some(NoteContextSelection::CopyText);
ui.close_menu();
}
if ui.button("Copy user public key").clicked() {
context_selection = Some(NoteContextSelection::CopyPubkey);
ui.close_menu();
}
if ui.button("Copy note id").clicked() {
context_selection = Some(NoteContextSelection::CopyNoteId);
ui.close_menu();
}
});
context_selection
}
}
fn stationary_arbitrary_menu_button<R>(
ui: &mut egui::Ui,
button_response: egui::Response,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::InnerResponse<Option<R>> {
let bar_id = ui.id();
let mut bar_state = egui::menu::BarState::load(ui.ctx(), bar_id);
let inner = bar_state.bar_menu(&button_response, add_contents);
bar_state.store(ui.ctx(), bar_id);
egui::InnerResponse::new(inner.map(|r| r.inner), button_response)
}

View File

@@ -0,0 +1,757 @@
pub mod contents;
pub mod context;
pub mod options;
pub mod post;
pub mod quote_repost;
pub mod reply;
pub use contents::NoteContents;
pub use context::{NoteContextButton, NoteContextSelection};
pub use options::NoteOptions;
pub use post::{PostAction, PostResponse, PostType, PostView};
pub use quote_repost::QuoteRepostView;
pub use reply::PostReplyView;
use crate::{
actionbar::NoteAction,
app_style::NotedeckTextStyle,
colors,
imgcache::ImageCache,
notecache::{CachedNote, NoteCache},
ui::{self, View},
};
use egui::emath::{pos2, Vec2};
use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense};
use enostr::{NoteId, Pubkey};
use nostrdb::{Ndb, Note, NoteKey, NoteReply, Transaction};
use super::profile::preview::{get_display_name, one_line_display_name_widget};
pub struct NoteView<'a> {
ndb: &'a Ndb,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
parent: Option<NoteKey>,
note: &'a nostrdb::Note<'a>,
flags: NoteOptions,
}
pub struct NoteResponse {
pub response: egui::Response,
pub context_selection: Option<NoteContextSelection>,
pub action: Option<NoteAction>,
}
impl NoteResponse {
pub fn new(response: egui::Response) -> Self {
Self {
response,
context_selection: None,
action: None,
}
}
pub fn with_action(mut self, action: Option<NoteAction>) -> Self {
self.action = action;
self
}
pub fn select_option(mut self, context_selection: Option<NoteContextSelection>) -> Self {
self.context_selection = context_selection;
self
}
}
impl View for NoteView<'_> {
fn ui(&mut self, ui: &mut egui::Ui) {
self.show(ui);
}
}
fn reply_desc(
ui: &mut egui::Ui,
txn: &Transaction,
note_reply: &NoteReply,
ndb: &Ndb,
img_cache: &mut ImageCache,
) {
#[cfg(feature = "profiling")]
puffin::profile_function!();
let size = 10.0;
let selectable = false;
ui.add(
Label::new(
RichText::new("replying to")
.size(size)
.color(colors::GRAY_SECONDARY),
)
.selectable(selectable),
);
let reply = if let Some(reply) = note_reply.reply() {
reply
} else {
return;
};
let reply_note = if let Ok(reply_note) = ndb.get_note_by_id(txn, reply.id) {
reply_note
} else {
ui.add(
Label::new(
RichText::new("a note")
.size(size)
.color(colors::GRAY_SECONDARY),
)
.selectable(selectable),
);
return;
};
if note_reply.is_reply_to_root() {
// We're replying to the root, let's show this
ui.add(
ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey())
.size(size)
.selectable(selectable),
);
ui.add(
Label::new(
RichText::new("'s note")
.size(size)
.color(colors::GRAY_SECONDARY),
)
.selectable(selectable),
);
} else if let Some(root) = note_reply.root() {
// replying to another post in a thread, not the root
if let Ok(root_note) = ndb.get_note_by_id(txn, root.id) {
if root_note.pubkey() == reply_note.pubkey() {
// simply "replying to bob's note" when replying to bob in his thread
ui.add(
ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey())
.size(size)
.selectable(selectable),
);
ui.add(
Label::new(
RichText::new("'s note")
.size(size)
.color(colors::GRAY_SECONDARY),
)
.selectable(selectable),
);
} else {
// replying to bob in alice's thread
ui.add(
ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey())
.size(size)
.selectable(selectable),
);
ui.add(
Label::new(RichText::new("in").size(size).color(colors::GRAY_SECONDARY))
.selectable(selectable),
);
ui.add(
ui::Mention::new(ndb, img_cache, txn, root_note.pubkey())
.size(size)
.selectable(selectable),
);
ui.add(
Label::new(
RichText::new("'s thread")
.size(size)
.color(colors::GRAY_SECONDARY),
)
.selectable(selectable),
);
}
} else {
ui.add(
ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey())
.size(size)
.selectable(selectable),
);
ui.add(
Label::new(
RichText::new("in someone's thread")
.size(size)
.color(colors::GRAY_SECONDARY),
)
.selectable(selectable),
);
}
}
}
impl<'a> NoteView<'a> {
pub fn new(
ndb: &'a Ndb,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
note: &'a nostrdb::Note<'a>,
) -> Self {
let flags = NoteOptions::actionbar | NoteOptions::note_previews;
let parent: Option<NoteKey> = None;
Self {
ndb,
note_cache,
img_cache,
parent,
note,
flags,
}
}
pub fn note_options(mut self, options: NoteOptions) -> Self {
*self.options_mut() = options;
self
}
pub fn textmode(mut self, enable: bool) -> Self {
self.options_mut().set_textmode(enable);
self
}
pub fn actionbar(mut self, enable: bool) -> Self {
self.options_mut().set_actionbar(enable);
self
}
pub fn small_pfp(mut self, enable: bool) -> Self {
self.options_mut().set_small_pfp(enable);
self
}
pub fn medium_pfp(mut self, enable: bool) -> Self {
self.options_mut().set_medium_pfp(enable);
self
}
pub fn note_previews(mut self, enable: bool) -> Self {
self.options_mut().set_note_previews(enable);
self
}
pub fn selectable_text(mut self, enable: bool) -> Self {
self.options_mut().set_selectable_text(enable);
self
}
pub fn wide(mut self, enable: bool) -> Self {
self.options_mut().set_wide(enable);
self
}
pub fn options_button(mut self, enable: bool) -> Self {
self.options_mut().set_options_button(enable);
self
}
pub fn options(&self) -> NoteOptions {
self.flags
}
pub fn options_mut(&mut self) -> &mut NoteOptions {
&mut self.flags
}
pub fn parent(mut self, parent: NoteKey) -> Self {
self.parent = Some(parent);
self
}
fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response {
let note_key = self.note.key().expect("todo: implement non-db notes");
let txn = self.note.txn().expect("todo: implement non-db notes");
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey());
//ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let cached_note = self
.note_cache
.cached_note_or_insert_mut(note_key, self.note);
let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0));
ui.allocate_rect(rect, Sense::hover());
ui.put(rect, |ui: &mut egui::Ui| {
render_reltime(ui, cached_note, false).response
});
let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0));
ui.allocate_rect(rect, Sense::hover());
ui.put(rect, |ui: &mut egui::Ui| {
ui.add(
ui::Username::new(profile.as_ref().ok(), self.note.pubkey())
.abbreviated(6)
.pk_colored(true),
)
});
ui.add(&mut NoteContents::new(
self.ndb,
self.img_cache,
self.note_cache,
txn,
self.note,
note_key,
self.flags,
));
//});
})
.response
}
pub fn expand_size() -> f32 {
5.0
}
fn pfp(
&mut self,
note_key: NoteKey,
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
ui: &mut egui::Ui,
) -> egui::Response {
if !self.options().has_wide() {
ui.spacing_mut().item_spacing.x = 16.0;
} else {
ui.spacing_mut().item_spacing.x = 4.0;
}
let pfp_size = self.options().pfp_size();
let sense = Sense::click();
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) => {
let anim_speed = 0.05;
let profile_key = profile.as_ref().unwrap().record().note_key();
let note_key = note_key.as_u64();
let (rect, size, resp) = ui::anim::hover_expand(
ui,
egui::Id::new((profile_key, note_key)),
pfp_size,
ui::NoteView::expand_size(),
anim_speed,
);
ui.put(rect, ui::ProfilePic::new(self.img_cache, pic).size(size))
.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(300.0);
ui.add(ui::ProfilePreview::new(
profile.as_ref().unwrap(),
self.img_cache,
));
});
resp
}
None => ui
.add(
ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url())
.size(pfp_size),
)
.interact(sense),
}
}
pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse {
if self.options().has_textmode() {
NoteResponse::new(self.textmode_ui(ui))
} else {
let txn = self.note.txn().expect("txn");
if let Some(note_to_repost) = get_reposted_note(self.ndb, txn, self.note) {
let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey());
let style = NotedeckTextStyle::Small;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.add_space(2.0);
ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode));
});
ui.add_space(6.0);
let resp = ui.add(one_line_display_name_widget(
get_display_name(profile.as_ref().ok()),
style,
));
if let Ok(rec) = &profile {
resp.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(300.0);
ui.add(ui::ProfilePreview::new(rec, self.img_cache));
});
}
ui.add_space(4.0);
ui.label(
RichText::new("Reposted")
.color(colors::GRAY_SECONDARY)
.text_style(style.text_style()),
);
});
NoteView::new(self.ndb, self.note_cache, self.img_cache, &note_to_repost).show(ui)
} else {
self.show_standard(ui)
}
}
}
fn note_header(
ui: &mut egui::Ui,
note_cache: &mut NoteCache,
note: &Note,
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
options: NoteOptions,
container_right: Pos2,
) -> NoteResponse {
#[cfg(feature = "profiling")]
puffin::profile_function!();
let note_key = note.key().unwrap();
let inner_response = ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.add(ui::Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20));
let cached_note = note_cache.cached_note_or_insert_mut(note_key, note);
render_reltime(ui, cached_note, true);
if options.has_options_button() {
let context_pos = {
let size = NoteContextButton::max_width();
let min = Pos2::new(container_right.x - size, container_right.y);
Rect::from_min_size(min, egui::vec2(size, size))
};
let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos));
NoteContextButton::menu(ui, resp.clone())
} else {
None
}
});
NoteResponse::new(inner_response.response).select_option(inner_response.inner)
}
fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse {
#[cfg(feature = "profiling")]
puffin::profile_function!();
let note_key = self.note.key().expect("todo: support non-db notes");
let txn = self.note.txn().expect("todo: support non-db notes");
let mut note_action: Option<NoteAction> = None;
let mut selected_option: Option<NoteContextSelection> = None;
let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent);
let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey());
let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id);
let container_right = {
let r = ui.available_rect_before_wrap();
let x = r.max.x;
let y = r.min.y;
Pos2::new(x, y)
};
// wide design
let response = if self.options().has_wide() {
ui.vertical(|ui| {
ui.horizontal(|ui| {
if self.pfp(note_key, &profile, ui).clicked() {
note_action =
Some(NoteAction::OpenProfile(Pubkey::new(*self.note.pubkey())));
};
let size = ui.available_size();
ui.vertical(|ui| {
ui.add_sized([size.x, self.options().pfp_size()], |ui: &mut egui::Ui| {
ui.horizontal_centered(|ui| {
selected_option = NoteView::note_header(
ui,
self.note_cache,
self.note,
&profile,
self.options(),
container_right,
)
.context_selection;
})
.response
});
let note_reply = self
.note_cache
.cached_note_or_insert_mut(note_key, self.note)
.reply
.borrow(self.note.tags());
if note_reply.reply().is_some() {
ui.horizontal(|ui| {
reply_desc(ui, txn, &note_reply, self.ndb, self.img_cache);
});
}
});
});
let mut contents = NoteContents::new(
self.ndb,
self.img_cache,
self.note_cache,
txn,
self.note,
note_key,
self.options(),
);
ui.add(&mut contents);
if let Some(action) = contents.action() {
note_action = Some(*action);
}
if self.options().has_actionbar() {
if let Some(action) = render_note_actionbar(ui, self.note.id(), note_key).inner
{
note_action = Some(action);
}
}
})
.response
} else {
// main design
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
if self.pfp(note_key, &profile, ui).clicked() {
note_action = Some(NoteAction::OpenProfile(Pubkey::new(*self.note.pubkey())));
};
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
selected_option = NoteView::note_header(
ui,
self.note_cache,
self.note,
&profile,
self.options(),
container_right,
)
.context_selection;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let note_reply = self
.note_cache
.cached_note_or_insert_mut(note_key, self.note)
.reply
.borrow(self.note.tags());
if note_reply.reply().is_some() {
reply_desc(ui, txn, &note_reply, self.ndb, self.img_cache);
}
});
let mut contents = NoteContents::new(
self.ndb,
self.img_cache,
self.note_cache,
txn,
self.note,
note_key,
self.options(),
);
ui.add(&mut contents);
if let Some(action) = contents.action() {
note_action = Some(*action);
}
if self.options().has_actionbar() {
if let Some(action) =
render_note_actionbar(ui, self.note.id(), note_key).inner
{
note_action = Some(action);
}
}
});
})
.response
};
let note_action = if note_hitbox_clicked(ui, hitbox_id, &response.rect, maybe_hitbox) {
Some(NoteAction::OpenThread(NoteId::new(*self.note.id())))
} else {
note_action
};
NoteResponse::new(response)
.with_action(note_action)
.select_option(selected_option)
}
}
fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> {
let new_note_id: &[u8; 32] = if note.kind() == 6 {
let mut res = None;
for tag in note.tags().iter() {
if tag.count() == 0 {
continue;
}
if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) {
if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) {
res = Some(note_id);
break;
}
}
}
res?
} else {
return None;
};
let note = ndb.get_note_by_id(txn, new_note_id).ok();
note.filter(|note| note.kind() == 1)
}
fn note_hitbox_id(
note_key: NoteKey,
note_options: NoteOptions,
parent: Option<NoteKey>,
) -> egui::Id {
Id::new(("note_size", note_key, note_options, parent))
}
fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> {
ui.ctx()
.data_mut(|d| d.get_persisted(hitbox_id))
.map(|note_size: Vec2| {
// The hitbox should extend the entire width of the
// container. The hitbox height was cached last layout.
let container_rect = ui.max_rect();
let rect = Rect {
min: pos2(container_rect.min.x, container_rect.min.y),
max: pos2(container_rect.max.x, container_rect.min.y + note_size.y),
};
let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click());
response
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox"));
response
})
}
fn note_hitbox_clicked(
ui: &mut egui::Ui,
hitbox_id: egui::Id,
note_rect: &Rect,
maybe_hitbox: Option<Response>,
) -> bool {
// Stash the dimensions of the note content so we can render the
// hitbox in the next frame
ui.ctx().data_mut(|d| {
d.insert_persisted(hitbox_id, note_rect.size());
});
// If there was an hitbox and it was clicked open the thread
match maybe_hitbox {
Some(hitbox) => hitbox.clicked(),
_ => false,
}
}
fn render_note_actionbar(
ui: &mut egui::Ui,
note_id: &[u8; 32],
note_key: NoteKey,
) -> egui::InnerResponse<Option<NoteAction>> {
#[cfg(feature = "profiling")]
puffin::profile_function!();
ui.horizontal(|ui| {
let reply_resp = reply_button(ui, note_key);
let quote_resp = quote_repost_button(ui, note_key);
if reply_resp.clicked() {
Some(NoteAction::Reply(NoteId::new(*note_id)))
} else if quote_resp.clicked() {
Some(NoteAction::Quote(NoteId::new(*note_id)))
} else {
None
}
})
}
fn secondary_label(ui: &mut egui::Ui, s: impl Into<String>) {
ui.add(Label::new(
RichText::new(s).size(10.0).color(colors::GRAY_SECONDARY),
));
}
fn render_reltime(
ui: &mut egui::Ui,
note_cache: &mut CachedNote,
before: bool,
) -> egui::InnerResponse<()> {
#[cfg(feature = "profiling")]
puffin::profile_function!();
ui.horizontal(|ui| {
if before {
secondary_label(ui, "");
}
secondary_label(ui, note_cache.reltime_str_mut());
if !before {
secondary_label(ui, "");
}
})
}
fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response {
let img_data = if ui.style().visuals.dark_mode {
egui::include_image!("../../../assets/icons/reply.png")
} else {
egui::include_image!("../../../assets/icons/reply-dark.png")
};
let (rect, size, resp) =
ui::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key)));
// align rect to note contents
let expand_size = 5.0; // from hover_expand_small
let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
let put_resp = ui.put(rect, egui::Image::new(img_data).max_width(size));
resp.union(put_resp)
}
fn repost_icon(dark_mode: bool) -> egui::Image<'static> {
let img_data = if dark_mode {
egui::include_image!("../../../assets/icons/repost_icon_4x.png")
} else {
egui::include_image!("../../../assets/icons/repost_light_4x.png")
};
egui::Image::new(img_data)
}
fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response {
let (rect, size, resp) =
ui::anim::hover_expand_small(ui, ui.id().with(("repost_anim", note_key)));
let expand_size = 5.0;
let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
let put_resp = ui.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size));
resp.union(put_resp)
}

View File

@@ -0,0 +1,111 @@
use crate::ui::ProfilePic;
use bitflags::bitflags;
bitflags! {
// Attributes can be applied to flags types
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NoteOptions: u64 {
const actionbar = 0b0000000000000001;
const note_previews = 0b0000000000000010;
const small_pfp = 0b0000000000000100;
const medium_pfp = 0b0000000000001000;
const wide = 0b0000000000010000;
const selectable_text = 0b0000000000100000;
const textmode = 0b0000000001000000;
const options_button = 0b0000000010000000;
const hide_media = 0b0000000100000000;
}
}
impl Default for NoteOptions {
fn default() -> NoteOptions {
NoteOptions::options_button | NoteOptions::note_previews | NoteOptions::actionbar
}
}
macro_rules! create_setter {
($fn_name:ident, $option:ident) => {
#[inline]
pub fn $fn_name(&mut self, enable: bool) {
if enable {
*self |= NoteOptions::$option;
} else {
*self &= !NoteOptions::$option;
}
}
};
}
impl NoteOptions {
create_setter!(set_small_pfp, small_pfp);
create_setter!(set_medium_pfp, medium_pfp);
create_setter!(set_note_previews, note_previews);
create_setter!(set_selectable_text, selectable_text);
create_setter!(set_textmode, textmode);
create_setter!(set_actionbar, actionbar);
create_setter!(set_wide, wide);
create_setter!(set_options_button, options_button);
create_setter!(set_hide_media, hide_media);
pub fn new(is_universe_timeline: bool) -> Self {
let mut options = NoteOptions::default();
options.set_hide_media(is_universe_timeline);
options
}
#[inline]
pub fn has_actionbar(self) -> bool {
(self & NoteOptions::actionbar) == NoteOptions::actionbar
}
#[inline]
pub fn has_hide_media(self) -> bool {
(self & NoteOptions::hide_media) == NoteOptions::hide_media
}
#[inline]
pub fn has_selectable_text(self) -> bool {
(self & NoteOptions::selectable_text) == NoteOptions::selectable_text
}
#[inline]
pub fn has_textmode(self) -> bool {
(self & NoteOptions::textmode) == NoteOptions::textmode
}
#[inline]
pub fn has_note_previews(self) -> bool {
(self & NoteOptions::note_previews) == NoteOptions::note_previews
}
#[inline]
pub fn has_small_pfp(self) -> bool {
(self & NoteOptions::small_pfp) == NoteOptions::small_pfp
}
#[inline]
pub fn has_medium_pfp(self) -> bool {
(self & NoteOptions::medium_pfp) == NoteOptions::medium_pfp
}
#[inline]
pub fn has_wide(self) -> bool {
(self & NoteOptions::wide) == NoteOptions::wide
}
#[inline]
pub fn has_options_button(self) -> bool {
(self & NoteOptions::options_button) == NoteOptions::options_button
}
pub fn pfp_size(&self) -> f32 {
if self.has_small_pfp() {
ProfilePic::small_size()
} else if self.has_medium_pfp() {
ProfilePic::medium_size()
} else {
ProfilePic::default_size()
}
}
}

View File

@@ -0,0 +1,305 @@
use crate::draft::{Draft, Drafts};
use crate::imgcache::ImageCache;
use crate::notecache::NoteCache;
use crate::post::NewPost;
use crate::ui::{self, Preview, PreviewConfig, View};
use crate::Result;
use egui::widgets::text_edit::TextEdit;
use egui::{Frame, Layout};
use enostr::{FilledKeypair, FullKeypair, NoteId, RelayPool};
use nostrdb::{Config, Ndb, Transaction};
use tracing::info;
use super::contents::render_note_preview;
pub struct PostView<'a> {
ndb: &'a Ndb,
draft: &'a mut Draft,
post_type: PostType,
img_cache: &'a mut ImageCache,
note_cache: &'a mut NoteCache,
poster: FilledKeypair<'a>,
id_source: Option<egui::Id>,
}
#[derive(Clone)]
pub enum PostType {
New,
Quote(NoteId),
Reply(NoteId),
}
pub struct PostAction {
post_type: PostType,
post: NewPost,
}
impl PostAction {
pub fn new(post_type: PostType, post: NewPost) -> Self {
PostAction { post_type, post }
}
pub fn execute(
&self,
ndb: &Ndb,
txn: &Transaction,
pool: &mut RelayPool,
drafts: &mut Drafts,
) -> Result<()> {
let seckey = self.post.account.secret_key.to_secret_bytes();
let note = match self.post_type {
PostType::New => self.post.to_note(&seckey),
PostType::Reply(target) => {
let replying_to = ndb.get_note_by_id(txn, target.bytes())?;
self.post.to_reply(&seckey, &replying_to)
}
PostType::Quote(target) => {
let quoting = ndb.get_note_by_id(txn, target.bytes())?;
self.post.to_quote(&seckey, &quoting)
}
};
let raw_msg = format!("[\"EVENT\",{}]", note.json().unwrap());
info!("sending {}", raw_msg);
pool.send(&enostr::ClientMessage::raw(raw_msg));
drafts.get_from_post_type(&self.post_type).clear();
Ok(())
}
}
pub struct PostResponse {
pub action: Option<PostAction>,
pub edit_response: egui::Response,
}
impl<'a> PostView<'a> {
pub fn new(
ndb: &'a Ndb,
draft: &'a mut Draft,
post_type: PostType,
img_cache: &'a mut ImageCache,
note_cache: &'a mut NoteCache,
poster: FilledKeypair<'a>,
) -> Self {
let id_source: Option<egui::Id> = None;
PostView {
ndb,
draft,
img_cache,
note_cache,
poster,
id_source,
post_type,
}
}
pub fn id_source(mut self, id_source: impl std::hash::Hash) -> Self {
self.id_source = Some(egui::Id::new(id_source));
self
}
fn editbox(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> egui::Response {
ui.spacing_mut().item_spacing.x = 12.0;
let pfp_size = 24.0;
// TODO: refactor pfp control to do all of this for us
let poster_pfp = self
.ndb
.get_profile_by_pubkey(txn, self.poster.pubkey.bytes())
.as_ref()
.ok()
.and_then(|p| Some(ui::ProfilePic::from_profile(self.img_cache, p)?.size(pfp_size)));
if let Some(pfp) = poster_pfp {
ui.add(pfp);
} else {
ui.add(
ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size),
);
}
let response = ui.add_sized(
ui.available_size(),
TextEdit::multiline(&mut self.draft.buffer)
.hint_text(egui::RichText::new("Write a banger note here...").weak())
.frame(false),
);
let focused = response.has_focus();
ui.ctx().data_mut(|d| d.insert_temp(self.id(), focused));
response
}
fn focused(&self, ui: &egui::Ui) -> bool {
ui.ctx()
.data(|d| d.get_temp::<bool>(self.id()).unwrap_or(false))
}
fn id(&self) -> egui::Id {
self.id_source.unwrap_or_else(|| egui::Id::new("post"))
}
pub fn outer_margin() -> f32 {
16.0
}
pub fn inner_margin() -> f32 {
12.0
}
pub fn ui(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> PostResponse {
let focused = self.focused(ui);
let stroke = if focused {
ui.visuals().selection.stroke
} else {
//ui.visuals().selection.stroke
ui.visuals().noninteractive().bg_stroke
};
let mut frame = egui::Frame::default()
.inner_margin(egui::Margin::same(PostView::inner_margin()))
.outer_margin(egui::Margin::same(PostView::outer_margin()))
.fill(ui.visuals().extreme_bg_color)
.stroke(stroke)
.rounding(12.0);
if focused {
frame = frame.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 0.0),
blur: 8.0,
spread: 0.0,
color: stroke.color,
});
}
frame
.show(ui, |ui| {
ui.vertical(|ui| {
let edit_response = ui.horizontal(|ui| self.editbox(txn, ui)).inner;
let action = ui
.horizontal(|ui| {
if let PostType::Quote(id) = self.post_type {
let avail_size = ui.available_size_before_wrap();
ui.with_layout(Layout::left_to_right(egui::Align::TOP), |ui| {
Frame::none().show(ui, |ui| {
ui.vertical(|ui| {
ui.set_max_width(avail_size.x * 0.8);
render_note_preview(
ui,
self.ndb,
self.note_cache,
self.img_cache,
txn,
id.bytes(),
nostrdb::NoteKey::new(0),
);
});
});
});
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::BOTTOM), |ui| {
if ui
.add_sized(
[91.0, 32.0],
post_button(!self.draft.buffer.is_empty()),
)
.clicked()
{
let new_post = NewPost::new(
self.draft.buffer.clone(),
self.poster.to_full(),
);
Some(PostAction::new(self.post_type.clone(), new_post))
} else {
None
}
})
.inner
})
.inner;
PostResponse {
action,
edit_response,
}
})
.inner
})
.inner
}
}
fn post_button(interactive: bool) -> impl egui::Widget {
move |ui: &mut egui::Ui| {
let button = egui::Button::new("Post now");
if interactive {
ui.add(button)
} else {
ui.add(
button
.sense(egui::Sense::hover())
.fill(ui.visuals().widgets.noninteractive.bg_fill)
.stroke(ui.visuals().widgets.noninteractive.bg_stroke),
)
.on_hover_cursor(egui::CursorIcon::NotAllowed)
}
}
}
mod preview {
use super::*;
pub struct PostPreview {
ndb: Ndb,
img_cache: ImageCache,
note_cache: NoteCache,
draft: Draft,
poster: FullKeypair,
}
impl PostPreview {
fn new() -> Self {
let ndb = Ndb::new(".", &Config::new()).expect("ndb");
PostPreview {
ndb,
img_cache: ImageCache::new(".".into()),
note_cache: NoteCache::default(),
draft: Draft::new(),
poster: FullKeypair::generate(),
}
}
}
impl View for PostPreview {
fn ui(&mut self, ui: &mut egui::Ui) {
let txn = Transaction::new(&self.ndb).expect("txn");
PostView::new(
&self.ndb,
&mut self.draft,
PostType::New,
&mut self.img_cache,
&mut self.note_cache,
self.poster.to_filled(),
)
.ui(&txn, ui);
}
}
impl Preview for PostView<'_> {
type Prev = PostPreview;
fn preview(_cfg: PreviewConfig) -> Self::Prev {
PostPreview::new()
}
}
}

View File

@@ -0,0 +1,64 @@
use enostr::{FilledKeypair, NoteId};
use nostrdb::Ndb;
use crate::{draft::Draft, imgcache::ImageCache, notecache::NoteCache, ui};
use super::{PostResponse, PostType};
pub struct QuoteRepostView<'a> {
ndb: &'a Ndb,
poster: FilledKeypair<'a>,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
draft: &'a mut Draft,
quoting_note: &'a nostrdb::Note<'a>,
id_source: Option<egui::Id>,
}
impl<'a> QuoteRepostView<'a> {
pub fn new(
ndb: &'a Ndb,
poster: FilledKeypair<'a>,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
draft: &'a mut Draft,
quoting_note: &'a nostrdb::Note<'a>,
) -> Self {
let id_source: Option<egui::Id> = None;
QuoteRepostView {
ndb,
poster,
note_cache,
img_cache,
draft,
quoting_note,
id_source,
}
}
pub fn show(&mut self, ui: &mut egui::Ui) -> PostResponse {
let id = self.id();
let quoting_note_id = self.quoting_note.id();
ui::PostView::new(
self.ndb,
self.draft,
PostType::Quote(NoteId::new(quoting_note_id.to_owned())),
self.img_cache,
self.note_cache,
self.poster,
)
.id_source(id)
.ui(self.quoting_note.txn().unwrap(), ui)
}
pub fn id_source(mut self, id: egui::Id) -> Self {
self.id_source = Some(id);
self
}
pub fn id(&self) -> egui::Id {
self.id_source
.unwrap_or_else(|| egui::Id::new("quote-repost-view"))
}
}

View File

@@ -0,0 +1,135 @@
use crate::draft::Draft;
use crate::imgcache::ImageCache;
use crate::notecache::NoteCache;
use crate::ui;
use crate::ui::note::{PostResponse, PostType};
use enostr::{FilledKeypair, NoteId};
use nostrdb::Ndb;
pub struct PostReplyView<'a> {
ndb: &'a Ndb,
poster: FilledKeypair<'a>,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
draft: &'a mut Draft,
note: &'a nostrdb::Note<'a>,
id_source: Option<egui::Id>,
}
impl<'a> PostReplyView<'a> {
pub fn new(
ndb: &'a Ndb,
poster: FilledKeypair<'a>,
draft: &'a mut Draft,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
note: &'a nostrdb::Note<'a>,
) -> Self {
let id_source: Option<egui::Id> = None;
PostReplyView {
ndb,
poster,
draft,
note,
note_cache,
img_cache,
id_source,
}
}
pub fn id_source(mut self, id: egui::Id) -> Self {
self.id_source = Some(id);
self
}
pub fn id(&self) -> egui::Id {
self.id_source
.unwrap_or_else(|| egui::Id::new("post-reply-view"))
}
pub fn show(&mut self, ui: &mut egui::Ui) -> PostResponse {
ui.vertical(|ui| {
let avail_rect = ui.available_rect_before_wrap();
// This is the offset of the post view's pfp. We use this
// to indent things so that the reply line is aligned
let pfp_offset = ui::PostView::outer_margin()
+ ui::PostView::inner_margin()
+ ui::ProfilePic::small_size() / 2.0;
let note_offset = pfp_offset
- ui::ProfilePic::medium_size() / 2.0
- ui::NoteView::expand_size() / 2.0;
egui::Frame::none()
.outer_margin(egui::Margin::same(note_offset))
.show(ui, |ui| {
ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, self.note)
.actionbar(false)
.medium_pfp(true)
.options_button(true)
.show(ui);
});
let id = self.id();
let replying_to = self.note.id();
let rect_before_post = ui.min_rect();
let post_response = {
ui::PostView::new(
self.ndb,
self.draft,
PostType::Reply(NoteId::new(*replying_to)),
self.img_cache,
self.note_cache,
self.poster,
)
.id_source(id)
.ui(self.note.txn().unwrap(), ui)
};
//
// reply line
//
// Position and draw the reply line
let mut rect = ui.min_rect();
// Position the line right above the poster's profile pic in
// the post box. Use the PostView's margin values to
// determine this offset.
rect.min.x = avail_rect.min.x + pfp_offset;
// honestly don't know what the fuck I'm doing here. just trying
// to get the line under the profile picture
rect.min.y = avail_rect.min.y
+ (ui::ProfilePic::medium_size() / 2.0
+ ui::ProfilePic::medium_size()
+ ui::NoteView::expand_size() * 2.0)
+ 1.0;
// For some reason we need to nudge the reply line's height a
// few more pixels?
let nudge = if post_response.edit_response.has_focus() {
// we nudge by one less pixel if focused, otherwise it
// overlaps the focused PostView purple border color
2.0
} else {
// we have to nudge by one more pixel when not focused
// otherwise it looks like there's a gap(?)
3.0
};
rect.max.y = rect_before_post.max.y + ui::PostView::outer_margin() + nudge;
ui.painter().vline(
rect.left(),
rect.y_range(),
ui.visuals().widgets.noninteractive.bg_stroke,
);
post_response
})
.inner
}
}

View File

@@ -0,0 +1,37 @@
use crate::ui::View;
pub struct PreviewConfig {
pub is_mobile: bool,
}
pub trait Preview {
type Prev: View;
fn preview(cfg: PreviewConfig) -> Self::Prev;
}
pub struct PreviewApp {
view: Box<dyn View>,
}
impl<V> From<V> for PreviewApp
where
V: View + 'static,
{
fn from(v: V) -> Self {
PreviewApp::new(v)
}
}
impl PreviewApp {
pub fn new(view: impl View + 'static) -> PreviewApp {
let view = Box::new(view);
Self { view }
}
}
impl eframe::App for PreviewApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| self.view.ui(ui));
}
}

View File

@@ -0,0 +1,87 @@
pub mod picture;
pub mod preview;
use crate::ui::note::NoteOptions;
use egui::{ScrollArea, Widget};
use enostr::Pubkey;
use nostrdb::{Ndb, Transaction};
pub use picture::ProfilePic;
pub use preview::ProfilePreview;
use crate::{
actionbar::NoteAction, imgcache::ImageCache, muted::MuteFun, notecache::NoteCache,
notes_holder::NotesHolderStorage, profile::Profile,
};
use super::timeline::{tabs_ui, TimelineTabView};
pub struct ProfileView<'a> {
pubkey: &'a Pubkey,
col_id: usize,
profiles: &'a mut NotesHolderStorage<Profile>,
note_options: NoteOptions,
ndb: &'a Ndb,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
}
impl<'a> ProfileView<'a> {
pub fn new(
pubkey: &'a Pubkey,
col_id: usize,
profiles: &'a mut NotesHolderStorage<Profile>,
ndb: &'a Ndb,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
note_options: NoteOptions,
) -> Self {
ProfileView {
pubkey,
col_id,
profiles,
ndb,
note_cache,
img_cache,
note_options,
}
}
pub fn ui(&mut self, ui: &mut egui::Ui, is_muted: &MuteFun) -> Option<NoteAction> {
let scroll_id = egui::Id::new(("profile_scroll", self.col_id, self.pubkey));
ScrollArea::vertical()
.id_salt(scroll_id)
.show(ui, |ui| {
let txn = Transaction::new(self.ndb).expect("txn");
if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, self.pubkey.bytes()) {
ProfilePreview::new(&profile, self.img_cache).ui(ui);
}
let profile = self
.profiles
.notes_holder_mutated(
self.ndb,
self.note_cache,
&txn,
self.pubkey.bytes(),
is_muted,
)
.get_ptr();
profile.timeline.selected_view = tabs_ui(ui);
let reversed = false;
TimelineTabView::new(
profile.timeline.current_view(),
reversed,
self.note_options,
&txn,
self.ndb,
self.note_cache,
self.img_cache,
)
.show(ui)
})
.inner
}
}

View File

@@ -0,0 +1,220 @@
use crate::images::ImageType;
use crate::imgcache::ImageCache;
use crate::ui::{Preview, PreviewConfig, View};
use egui::{vec2, Sense, TextureHandle};
use nostrdb::{Ndb, Transaction};
pub struct ProfilePic<'cache, 'url> {
cache: &'cache mut ImageCache,
url: &'url str,
size: f32,
}
impl egui::Widget for ProfilePic<'_, '_> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
render_pfp(ui, self.cache, self.url, self.size)
}
}
impl<'cache, 'url> ProfilePic<'cache, 'url> {
pub fn new(cache: &'cache mut ImageCache, url: &'url str) -> Self {
let size = Self::default_size();
ProfilePic { cache, url, size }
}
pub fn from_profile(
cache: &'cache mut ImageCache,
profile: &nostrdb::ProfileRecord<'url>,
) -> Option<Self> {
profile
.record()
.profile()
.and_then(|p| p.picture())
.map(|url| ProfilePic::new(cache, url))
}
#[inline]
pub fn default_size() -> f32 {
38.0
}
#[inline]
pub fn medium_size() -> f32 {
32.0
}
#[inline]
pub fn small_size() -> f32 {
24.0
}
#[inline]
pub fn no_pfp_url() -> &'static str {
"https://damus.io/img/no-profile.svg"
}
#[inline]
pub fn size(mut self, size: f32) -> Self {
self.size = size;
self
}
}
fn render_pfp(
ui: &mut egui::Ui,
img_cache: &mut ImageCache,
url: &str,
ui_size: f32,
) -> egui::Response {
#[cfg(feature = "profiling")]
puffin::profile_function!();
// We will want to downsample these so it's not blurry on hi res displays
let img_size = 128u32;
let m_cached_promise = img_cache.map().get(url);
if m_cached_promise.is_none() {
let res = crate::images::fetch_img(img_cache, ui.ctx(), url, ImageType::Profile(img_size));
img_cache.map_mut().insert(url.to_owned(), res);
}
match img_cache.map()[url].ready() {
None => paint_circle(ui, ui_size),
// Failed to fetch profile!
Some(Err(_err)) => {
let m_failed_promise = img_cache.map().get(url);
if m_failed_promise.is_none() {
let no_pfp = crate::images::fetch_img(
img_cache,
ui.ctx(),
ProfilePic::no_pfp_url(),
ImageType::Profile(img_size),
);
img_cache.map_mut().insert(url.to_owned(), no_pfp);
}
match 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(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 paint_circle(ui: &mut egui::Ui, size: f32) -> egui::Response {
let (rect, response) = ui.allocate_at_least(vec2(size, size), Sense::hover());
ui.painter()
.circle_filled(rect.center(), size / 2.0, ui.visuals().weak_text_color());
response
}
mod preview {
use super::*;
use crate::ui;
use nostrdb::*;
use std::collections::HashSet;
pub struct ProfilePicPreview {
cache: ImageCache,
ndb: Ndb,
keys: Vec<ProfileKey>,
}
impl ProfilePicPreview {
fn new() -> Self {
let config = Config::new();
let ndb = Ndb::new(".", &config).expect("ndb");
let txn = Transaction::new(&ndb).unwrap();
let filters = vec![Filter::new().kinds(vec![0]).build()];
let cache = ImageCache::new("cache/img".into());
let mut pks = HashSet::new();
let mut keys = HashSet::new();
for query_result in ndb.query(&txn, &filters, 2000).unwrap() {
pks.insert(query_result.note.pubkey());
}
for pk in pks {
let profile = if let Ok(profile) = ndb.get_profile_by_pubkey(&txn, pk) {
profile
} else {
continue;
};
if profile
.record()
.profile()
.and_then(|p| p.picture())
.is_none()
{
continue;
}
keys.insert(profile.key().expect("should not be owned"));
}
let keys = keys.into_iter().collect();
ProfilePicPreview { cache, ndb, keys }
}
}
impl View for ProfilePicPreview {
fn ui(&mut self, ui: &mut egui::Ui) {
egui::ScrollArea::both().show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
let txn = Transaction::new(&self.ndb).unwrap();
for key in &self.keys {
let profile = self.ndb.get_profile_by_key(&txn, *key).unwrap();
let url = profile
.record()
.profile()
.expect("should have profile")
.picture()
.expect("should have picture");
let expand_size = 10.0;
let anim_speed = 0.05;
let (rect, size, _resp) = ui::anim::hover_expand(
ui,
egui::Id::new(profile.key().unwrap()),
ui::ProfilePic::default_size(),
expand_size,
anim_speed,
);
ui.put(rect, ui::ProfilePic::new(&mut self.cache, url).size(size))
.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(300.0);
ui.add(ui::ProfilePreview::new(&profile, &mut self.cache));
});
}
});
});
}
}
impl Preview for ProfilePic<'_, '_> {
type Prev = ProfilePicPreview;
fn preview(_cfg: PreviewConfig) -> Self::Prev {
ProfilePicPreview::new()
}
}
}

View File

@@ -0,0 +1,322 @@
use crate::app_style::{get_font_size, NotedeckTextStyle};
use crate::imgcache::ImageCache;
use crate::storage::{DataPath, DataPathType};
use crate::ui::ProfilePic;
use crate::user_account::UserAccount;
use crate::{colors, images, DisplayName};
use egui::load::TexturePoll;
use egui::{Frame, Label, RichText, Sense, Widget};
use egui_extras::Size;
use enostr::{NoteId, Pubkey};
use nostrdb::{Ndb, ProfileRecord, Transaction};
pub struct ProfilePreview<'a, 'cache> {
profile: &'a ProfileRecord<'a>,
cache: &'cache mut ImageCache,
banner_height: Size,
}
impl<'a, 'cache> ProfilePreview<'a, 'cache> {
pub fn new(profile: &'a ProfileRecord<'a>, cache: &'cache mut ImageCache) -> Self {
let banner_height = Size::exact(80.0);
ProfilePreview {
profile,
cache,
banner_height,
}
}
pub fn banner_height(&mut self, size: Size) {
self.banner_height = size;
}
fn banner_texture(
ui: &mut egui::Ui,
profile: &ProfileRecord<'_>,
) -> Option<egui::load::SizedTexture> {
// TODO: cache banner
let banner = profile.record().profile().and_then(|p| p.banner());
if let Some(banner) = banner {
let texture_load_res =
egui::Image::new(banner).load_for_size(ui.ctx(), ui.available_size());
if let Ok(texture_poll) = texture_load_res {
match texture_poll {
TexturePoll::Pending { .. } => {}
TexturePoll::Ready { texture, .. } => return Some(texture),
}
}
}
None
}
fn banner(ui: &mut egui::Ui, profile: &ProfileRecord<'_>) -> egui::Response {
if let Some(texture) = Self::banner_texture(ui, profile) {
images::aspect_fill(
ui,
Sense::hover(),
texture.id,
texture.size.x / texture.size.y,
)
} else {
// TODO: default banner texture
ui.label("")
}
}
fn body(self, ui: &mut egui::Ui) {
crate::ui::padding(12.0, ui, |ui| {
ui.add(ProfilePic::new(self.cache, get_profile_url(Some(self.profile))).size(80.0));
ui.add(display_name_widget(
get_display_name(Some(self.profile)),
false,
));
ui.add(about_section_widget(self.profile));
});
}
}
impl egui::Widget for ProfilePreview<'_, '_> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
ui.vertical(|ui| {
ui.add_sized([ui.available_size().x, 80.0], |ui: &mut egui::Ui| {
ProfilePreview::banner(ui, self.profile)
});
self.body(ui);
})
.response
}
}
pub struct SimpleProfilePreview<'a, 'cache> {
profile: Option<&'a ProfileRecord<'a>>,
cache: &'cache mut ImageCache,
is_nsec: bool,
}
impl<'a, 'cache> SimpleProfilePreview<'a, 'cache> {
pub fn new(
profile: Option<&'a ProfileRecord<'a>>,
cache: &'cache mut ImageCache,
is_nsec: bool,
) -> Self {
SimpleProfilePreview {
profile,
cache,
is_nsec,
}
}
}
impl egui::Widget for SimpleProfilePreview<'_, '_> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
Frame::none()
.show(ui, |ui| {
ui.add(ProfilePic::new(self.cache, get_profile_url(self.profile)).size(48.0));
ui.vertical(|ui| {
ui.add(display_name_widget(get_display_name(self.profile), true));
if !self.is_nsec {
ui.add(
Label::new(
RichText::new("Read only")
.size(get_font_size(ui.ctx(), &NotedeckTextStyle::Tiny))
.color(ui.visuals().warn_fg_color),
)
.selectable(false),
);
}
});
})
.response
}
}
mod previews {
use super::*;
use crate::test_data::test_profile_record;
use crate::ui::{Preview, PreviewConfig, View};
pub struct ProfilePreviewPreview<'a> {
profile: ProfileRecord<'a>,
cache: ImageCache,
}
impl ProfilePreviewPreview<'_> {
pub fn new() -> Self {
let profile = test_profile_record();
let path = DataPath::new("previews")
.path(DataPathType::Cache)
.join(ImageCache::rel_dir());
let cache = ImageCache::new(path);
ProfilePreviewPreview { profile, cache }
}
}
impl Default for ProfilePreviewPreview<'_> {
fn default() -> Self {
ProfilePreviewPreview::new()
}
}
impl View for ProfilePreviewPreview<'_> {
fn ui(&mut self, ui: &mut egui::Ui) {
ProfilePreview::new(&self.profile, &mut self.cache).ui(ui);
}
}
impl<'a> Preview for ProfilePreview<'a, '_> {
/// A preview of the profile preview :D
type Prev = ProfilePreviewPreview<'a>;
fn preview(_cfg: PreviewConfig) -> Self::Prev {
ProfilePreviewPreview::new()
}
}
}
pub fn get_display_name<'a>(profile: Option<&ProfileRecord<'a>>) -> DisplayName<'a> {
if let Some(name) = profile.and_then(|p| crate::profile::get_profile_name(p)) {
name
} else {
DisplayName::One("??")
}
}
pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str {
if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) {
url
} else {
ProfilePic::no_pfp_url()
}
}
pub fn get_profile_url_owned(profile: Option<ProfileRecord<'_>>) -> &str {
if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) {
url
} else {
ProfilePic::no_pfp_url()
}
}
pub fn get_account_url<'a>(
txn: &'a nostrdb::Transaction,
ndb: &nostrdb::Ndb,
account: Option<&UserAccount>,
) -> &'a str {
if let Some(selected_account) = account {
if let Ok(profile) = ndb.get_profile_by_pubkey(txn, selected_account.pubkey.bytes()) {
get_profile_url_owned(Some(profile))
} else {
get_profile_url_owned(None)
}
} else {
get_profile_url(None)
}
}
fn display_name_widget(
display_name: DisplayName<'_>,
add_placeholder_space: bool,
) -> impl egui::Widget + '_ {
move |ui: &mut egui::Ui| match display_name {
DisplayName::One(n) => {
let name_response = ui.add(
Label::new(RichText::new(n).text_style(NotedeckTextStyle::Heading3.text_style()))
.selectable(false),
);
if add_placeholder_space {
ui.add_space(16.0);
}
name_response
}
DisplayName::Both {
display_name,
username,
} => {
ui.add(
Label::new(
RichText::new(display_name)
.text_style(NotedeckTextStyle::Heading3.text_style()),
)
.selectable(false),
);
ui.add(
Label::new(
RichText::new(format!("@{}", username))
.size(12.0)
.color(colors::MID_GRAY),
)
.selectable(false),
)
}
}
}
pub fn one_line_display_name_widget(
display_name: DisplayName<'_>,
style: NotedeckTextStyle,
) -> impl egui::Widget + '_ {
let text_style = style.text_style();
move |ui: &mut egui::Ui| match display_name {
DisplayName::One(n) => ui.label(
RichText::new(n)
.text_style(text_style)
.color(colors::GRAY_SECONDARY),
),
DisplayName::Both {
display_name,
username: _,
} => ui.label(
RichText::new(display_name)
.text_style(text_style)
.color(colors::GRAY_SECONDARY),
),
}
}
fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b
where
'b: 'a,
{
move |ui: &mut egui::Ui| {
if let Some(about) = profile.record().profile().and_then(|p| p.about()) {
ui.label(about)
} else {
// need any Response so we dont need an Option
ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())
}
}
}
fn get_display_name_as_string<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str {
let display_name = get_display_name(profile);
match display_name {
DisplayName::One(n) => n,
DisplayName::Both { display_name, .. } => display_name,
}
}
pub fn get_profile_displayname_string<'a>(txn: &'a Transaction, ndb: &Ndb, pk: &Pubkey) -> &'a str {
let profile = ndb.get_profile_by_pubkey(txn, pk.bytes()).ok();
get_display_name_as_string(profile.as_ref())
}
pub fn get_note_users_displayname_string<'a>(
txn: &'a Transaction,
ndb: &Ndb,
id: &NoteId,
) -> &'a str {
let note = ndb.get_note_by_id(txn, id.bytes());
let profile = if let Ok(note) = note {
ndb.get_profile_by_pubkey(txn, note.pubkey()).ok()
} else {
None
};
get_display_name_as_string(profile.as_ref())
}

View File

@@ -0,0 +1,212 @@
use crate::relay_pool_manager::{RelayPoolManager, RelayStatus};
use crate::ui::{Preview, PreviewConfig, View};
use egui::{Align, Button, Frame, Layout, Margin, Rgba, RichText, Rounding, Ui, Vec2};
use crate::app_style::NotedeckTextStyle;
use enostr::RelayPool;
pub struct RelayView<'a> {
manager: RelayPoolManager<'a>,
}
impl View for RelayView<'_> {
fn ui(&mut self, ui: &mut egui::Ui) {
ui.add_space(24.0);
ui.horizontal(|ui| {
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
ui.label(
RichText::new("Relays").text_style(NotedeckTextStyle::Heading2.text_style()),
);
});
// TODO: implement manually adding relays
// ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// if ui.add(add_relay_button()).clicked() {
// // TODO: navigate to 'add relay view'
// };
// });
});
ui.add_space(8.0);
egui::ScrollArea::vertical()
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2])
.show(ui, |ui| {
if let Some(indices) = self.show_relays(ui) {
self.manager.remove_relays(indices);
}
});
}
}
impl<'a> RelayView<'a> {
pub fn new(manager: RelayPoolManager<'a>) -> Self {
RelayView { manager }
}
pub fn panel(&mut self, ui: &mut egui::Ui) {
egui::CentralPanel::default().show(ui.ctx(), |ui| self.ui(ui));
}
/// Show the current relays, and returns the indices of relays the user requested to delete
fn show_relays(&'a self, ui: &mut Ui) -> Option<Vec<usize>> {
let mut indices_to_remove: Option<Vec<usize>> = None;
for (index, relay_info) in self.manager.get_relay_infos().iter().enumerate() {
ui.add_space(8.0);
ui.vertical_centered_justified(|ui| {
relay_frame(ui).show(ui, |ui| {
ui.horizontal(|ui| {
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
Frame::none()
// This frame is needed to add margin because the label will be added to the outer frame first and centered vertically before the connection status is added so the vertical centering isn't accurate.
// TODO: remove this hack and actually center the url & status at the same time
.inner_margin(Margin::symmetric(0.0, 4.0))
.show(ui, |ui| {
egui::ScrollArea::horizontal()
.id_salt(index)
.max_width(
ui.max_rect().width()
- get_right_side_width(relay_info.status),
) // TODO: refactor to dynamically check the size of the 'right to left' portion and set the max width to be the screen width minus padding minus 'right to left' width
.show(ui, |ui| {
ui.label(
RichText::new(relay_info.relay_url)
.text_style(
NotedeckTextStyle::Monospace.text_style(),
)
.color(
ui.style()
.visuals
.noninteractive()
.fg_stroke
.color,
),
);
});
});
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(delete_button(ui.visuals().dark_mode)).clicked() {
indices_to_remove.get_or_insert_with(Vec::new).push(index);
};
show_connection_status(ui, relay_info.status);
});
});
});
});
}
indices_to_remove
}
}
fn get_right_side_width(status: &RelayStatus) -> f32 {
match status {
RelayStatus::Connected => 150.0,
RelayStatus::Connecting => 160.0,
RelayStatus::Disconnected => 175.0,
}
}
#[allow(unused)]
fn add_relay_button() -> egui::Button<'static> {
Button::new("+ Add relay").min_size(Vec2::new(0.0, 32.0))
}
fn delete_button(_dark_mode: bool) -> egui::Button<'static> {
/*
let img_data = if dark_mode {
egui::include_image!("../../assets/icons/delete_icon_4x.png")
} else {
// TODO: use light delete icon
egui::include_image!("../../assets/icons/delete_icon_4x.png")
};
*/
let img_data = egui::include_image!("../../assets/icons/delete_icon_4x.png");
egui::Button::image(egui::Image::new(img_data).max_width(10.0)).frame(false)
}
fn relay_frame(ui: &mut Ui) -> Frame {
Frame::none()
.inner_margin(Margin::same(8.0))
.rounding(ui.style().noninteractive().rounding)
.stroke(ui.style().visuals.noninteractive().bg_stroke)
}
fn show_connection_status(ui: &mut Ui, status: &RelayStatus) {
let fg_color = match status {
RelayStatus::Connected => ui.visuals().selection.bg_fill,
RelayStatus::Connecting => ui.visuals().warn_fg_color,
RelayStatus::Disconnected => ui.visuals().error_fg_color,
};
let bg_color = egui::lerp(Rgba::from(fg_color)..=Rgba::BLACK, 0.8).into();
let label_text = match status {
RelayStatus::Connected => "Connected",
RelayStatus::Connecting => "Connecting...",
RelayStatus::Disconnected => "Not Connected",
};
let frame = Frame::none()
.rounding(Rounding::same(100.0))
.fill(bg_color)
.inner_margin(Margin::symmetric(12.0, 4.0));
frame.show(ui, |ui| {
ui.label(RichText::new(label_text).color(fg_color));
ui.add(get_connection_icon(status));
});
}
fn get_connection_icon(status: &RelayStatus) -> egui::Image<'static> {
let img_data = match status {
RelayStatus::Connected => egui::include_image!("../../assets/icons/connected_icon_4x.png"),
RelayStatus::Connecting => {
egui::include_image!("../../assets/icons/connecting_icon_4x.png")
}
RelayStatus::Disconnected => {
egui::include_image!("../../assets/icons/disconnected_icon_4x.png")
}
};
egui::Image::new(img_data)
}
// PREVIEWS
mod preview {
use super::*;
use crate::test_data::sample_pool;
pub struct RelayViewPreview {
pool: RelayPool,
}
impl RelayViewPreview {
fn new() -> Self {
RelayViewPreview {
pool: sample_pool(),
}
}
}
impl View for RelayViewPreview {
fn ui(&mut self, ui: &mut egui::Ui) {
self.pool.try_recv();
RelayView::new(RelayPoolManager::new(&mut self.pool)).ui(ui);
}
}
impl Preview for RelayView<'_> {
type Prev = RelayViewPreview;
fn preview(_cfg: PreviewConfig) -> Self::Prev {
RelayViewPreview::new()
}
}
}

View File

@@ -0,0 +1,673 @@
use egui::{
vec2, Color32, InnerResponse, Label, Layout, Margin, RichText, ScrollArea, Separator, Stroke,
Widget,
};
use tracing::{error, info};
use crate::{
accounts::{Accounts, AccountsRoute},
app::{get_active_columns_mut, get_decks_mut},
app_style::{self, DECK_ICON_SIZE},
colors,
column::Column,
decks::{DecksAction, DecksCache},
imgcache::ImageCache,
nav::SwitchingAction,
route::Route,
support::Support,
user_account::UserAccount,
Damus,
};
use super::{
anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
configure_deck::deck_icon,
profile::preview::get_account_url,
ProfilePic, View,
};
pub static SIDE_PANEL_WIDTH: f32 = 68.0;
static ICON_WIDTH: f32 = 40.0;
pub struct DesktopSidePanel<'a> {
ndb: &'a nostrdb::Ndb,
img_cache: &'a mut ImageCache,
selected_account: Option<&'a UserAccount>,
decks_cache: &'a DecksCache,
}
impl View for DesktopSidePanel<'_> {
fn ui(&mut self, ui: &mut egui::Ui) {
self.show(ui);
}
}
#[derive(Debug, Eq, PartialEq, Clone, Copy)]
pub enum SidePanelAction {
Panel,
Account,
Settings,
Columns,
ComposeNote,
Search,
ExpandSidePanel,
Support,
NewDeck,
SwitchDeck(usize),
EditDeck(usize),
}
pub struct SidePanelResponse {
pub response: egui::Response,
pub action: SidePanelAction,
}
impl SidePanelResponse {
fn new(action: SidePanelAction, response: egui::Response) -> Self {
SidePanelResponse { action, response }
}
}
impl<'a> DesktopSidePanel<'a> {
pub fn new(
ndb: &'a nostrdb::Ndb,
img_cache: &'a mut ImageCache,
selected_account: Option<&'a UserAccount>,
decks_cache: &'a DecksCache,
) -> Self {
Self {
ndb,
img_cache,
selected_account,
decks_cache,
}
}
pub fn show(&mut self, ui: &mut egui::Ui) -> SidePanelResponse {
let mut frame = egui::Frame::none().inner_margin(Margin::same(8.0));
if !ui.visuals().dark_mode {
frame = frame.fill(colors::ALMOST_WHITE);
}
frame.show(ui, |ui| self.show_inner(ui)).inner
}
fn show_inner(&mut self, ui: &mut egui::Ui) -> SidePanelResponse {
let dark_mode = ui.ctx().style().visuals.dark_mode;
let inner = ui
.vertical(|ui| {
let top_resp = ui
.with_layout(Layout::top_down(egui::Align::Center), |ui| {
// macos needs a bit of space to make room for window
// minimize/close buttons
if cfg!(target_os = "macos") {
ui.add_space(24.0);
}
let expand_resp = ui.add(expand_side_panel_button());
ui.add_space(4.0);
ui.add(milestone_name());
ui.add_space(16.0);
let is_interactive = self
.selected_account
.is_some_and(|s| s.secret_key.is_some());
let compose_resp = ui.add(compose_note_button(is_interactive));
let compose_resp = if is_interactive {
compose_resp
} else {
compose_resp.on_hover_cursor(egui::CursorIcon::NotAllowed)
};
// let search_resp = ui.add(search_button());
let column_resp = ui.add(add_column_button(dark_mode));
ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0));
ui.add_space(8.0);
ui.add(egui::Label::new(
RichText::new("DECKS")
.size(11.0)
.color(ui.visuals().noninteractive().fg_stroke.color),
));
ui.add_space(8.0);
let add_deck_resp = ui.add(add_deck_button());
let decks_inner = ScrollArea::vertical()
.max_height(ui.available_height() - (3.0 * (ICON_WIDTH + 12.0)))
.show(ui, |ui| {
show_decks(ui, self.decks_cache, self.selected_account)
})
.inner;
if expand_resp.clicked() {
Some(InnerResponse::new(
SidePanelAction::ExpandSidePanel,
expand_resp,
))
} else if compose_resp.clicked() {
Some(InnerResponse::new(
SidePanelAction::ComposeNote,
compose_resp,
))
// } else if search_resp.clicked() {
// Some(InnerResponse::new(SidePanelAction::Search, search_resp))
} else if column_resp.clicked() {
Some(InnerResponse::new(SidePanelAction::Columns, column_resp))
} else if add_deck_resp.clicked() {
Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp))
} else if decks_inner.response.secondary_clicked() {
info!("decks inner secondary click");
if let Some(clicked_index) = decks_inner.inner {
Some(InnerResponse::new(
SidePanelAction::EditDeck(clicked_index),
decks_inner.response,
))
} else {
None
}
} else if decks_inner.response.clicked() {
if let Some(clicked_index) = decks_inner.inner {
Some(InnerResponse::new(
SidePanelAction::SwitchDeck(clicked_index),
decks_inner.response,
))
} else {
None
}
} else {
None
}
})
.inner;
ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0));
let (pfp_resp, bottom_resp) = ui
.with_layout(Layout::bottom_up(egui::Align::Center), |ui| {
let pfp_resp = self.pfp_button(ui);
let settings_resp = ui.add(settings_button(dark_mode));
if let Some(new_visuals) = app_style::user_requested_visuals_change(
super::is_oled(),
ui.ctx().style().visuals.dark_mode,
ui,
) {
ui.ctx().set_visuals(new_visuals)
}
let support_resp = ui.add(support_button());
let optional_inner = if pfp_resp.clicked() {
Some(egui::InnerResponse::new(
SidePanelAction::Account,
pfp_resp.clone(),
))
} else if settings_resp.clicked() || settings_resp.hovered() {
Some(egui::InnerResponse::new(
SidePanelAction::Settings,
settings_resp,
))
} else if support_resp.clicked() {
Some(egui::InnerResponse::new(
SidePanelAction::Support,
support_resp,
))
} else {
None
};
(pfp_resp, optional_inner)
})
.inner;
if let Some(bottom_inner) = bottom_resp {
bottom_inner
} else if let Some(top_inner) = top_resp {
top_inner
} else {
egui::InnerResponse::new(SidePanelAction::Panel, pfp_resp)
}
})
.inner;
SidePanelResponse::new(inner.inner, inner.response)
}
fn pfp_button(&mut self, ui: &mut egui::Ui) -> egui::Response {
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
let helper = AnimationHelper::new(ui, "pfp-button", vec2(max_size, max_size));
let min_pfp_size = ICON_WIDTH;
let cur_pfp_size = helper.scale_1d_pos(min_pfp_size);
let txn = nostrdb::Transaction::new(self.ndb).expect("should be able to create txn");
let profile_url = get_account_url(&txn, self.ndb, self.selected_account);
let widget = ProfilePic::new(self.img_cache, profile_url).size(cur_pfp_size);
ui.put(helper.get_animation_rect(), widget);
helper.take_animation_response()
}
pub fn perform_action(
decks_cache: &mut DecksCache,
accounts: &Accounts,
support: &mut Support,
action: SidePanelAction,
) -> Option<SwitchingAction> {
let router = get_active_columns_mut(accounts, decks_cache).get_first_router();
let mut switching_response = None;
match action {
SidePanelAction::Panel => {} // TODO
SidePanelAction::Account => {
if router
.routes()
.iter()
.any(|&r| r == Route::Accounts(AccountsRoute::Accounts))
{
// return if we are already routing to accounts
router.go_back();
} else {
router.route_to(Route::accounts());
}
}
SidePanelAction::Settings => {
if router.routes().iter().any(|&r| r == Route::Relays) {
// return if we are already routing to accounts
router.go_back();
} else {
router.route_to(Route::relays());
}
}
SidePanelAction::Columns => {
if router
.routes()
.iter()
.any(|&r| matches!(r, Route::AddColumn(_)))
{
router.go_back();
} else {
get_active_columns_mut(accounts, decks_cache).new_column_picker();
}
}
SidePanelAction::ComposeNote => {
if router.routes().iter().any(|&r| r == Route::ComposeNote) {
router.go_back();
} else {
router.route_to(Route::ComposeNote);
}
}
SidePanelAction::Search => {
// TODO
info!("Clicked search button");
}
SidePanelAction::ExpandSidePanel => {
// TODO
info!("Clicked expand side panel button");
}
SidePanelAction::Support => {
if router.routes().iter().any(|&r| r == Route::Support) {
router.go_back();
} else {
support.refresh();
router.route_to(Route::Support);
}
}
SidePanelAction::NewDeck => {
if router.routes().iter().any(|&r| r == Route::NewDeck) {
router.go_back();
} else {
router.route_to(Route::NewDeck);
}
}
SidePanelAction::SwitchDeck(index) => {
switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch(
index,
)))
}
SidePanelAction::EditDeck(index) => {
if router.routes().iter().any(|&r| r == Route::EditDeck(index)) {
router.go_back();
} else {
switching_response = Some(crate::nav::SwitchingAction::Decks(
DecksAction::Switch(index),
));
if let Some(edit_deck) = get_decks_mut(accounts, decks_cache)
.decks_mut()
.get_mut(index)
{
edit_deck
.columns_mut()
.get_first_router()
.route_to(Route::EditDeck(index));
} else {
error!("Cannot push EditDeck route to index {}", index);
}
}
}
}
switching_response
}
}
fn settings_button(dark_mode: bool) -> impl Widget {
move |ui: &mut egui::Ui| {
let img_size = 24.0;
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
let img_data = if dark_mode {
egui::include_image!("../../assets/icons/settings_dark_4x.png")
} else {
egui::include_image!("../../assets/icons/settings_light_4x.png")
};
let img = egui::Image::new(img_data).max_width(img_size);
let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size));
let cur_img_size = helper.scale_1d_pos(img_size);
img.paint_at(
ui,
helper
.get_animation_rect()
.shrink((max_size - cur_img_size) / 2.0),
);
helper.take_animation_response()
}
}
fn add_column_button(dark_mode: bool) -> impl Widget {
move |ui: &mut egui::Ui| {
let img_size = 24.0;
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
let img_data = if dark_mode {
egui::include_image!("../../assets/icons/add_column_dark_4x.png")
} else {
egui::include_image!("../../assets/icons/add_column_light_4x.png")
};
let img = egui::Image::new(img_data).max_width(img_size);
let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size));
let cur_img_size = helper.scale_1d_pos(img_size);
img.paint_at(
ui,
helper
.get_animation_rect()
.shrink((max_size - cur_img_size) / 2.0),
);
helper.take_animation_response()
}
}
fn compose_note_button(interactive: bool) -> impl Widget {
move |ui: &mut egui::Ui| -> egui::Response {
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
let min_outer_circle_diameter = 40.0;
let min_plus_sign_size = 14.0; // length of the plus sign
let min_line_width = 2.25; // width of the plus sign
let helper = if interactive {
AnimationHelper::new(ui, "note-compose-button", vec2(max_size, max_size))
} else {
AnimationHelper::no_animation(ui, vec2(max_size, max_size))
};
let painter = ui.painter_at(helper.get_animation_rect());
let use_background_radius = helper.scale_radius(min_outer_circle_diameter);
let use_line_width = helper.scale_1d_pos(min_line_width);
let use_edge_circle_radius = helper.scale_radius(min_line_width);
let fill_color = if interactive {
colors::PINK
} else {
ui.visuals().noninteractive().bg_fill
};
painter.circle_filled(helper.center(), use_background_radius, fill_color);
let min_half_plus_sign_size = min_plus_sign_size / 2.0;
let north_edge = helper.scale_from_center(0.0, min_half_plus_sign_size);
let south_edge = helper.scale_from_center(0.0, -min_half_plus_sign_size);
let west_edge = helper.scale_from_center(-min_half_plus_sign_size, 0.0);
let east_edge = helper.scale_from_center(min_half_plus_sign_size, 0.0);
painter.line_segment(
[north_edge, south_edge],
Stroke::new(use_line_width, Color32::WHITE),
);
painter.line_segment(
[west_edge, east_edge],
Stroke::new(use_line_width, Color32::WHITE),
);
painter.circle_filled(north_edge, use_edge_circle_radius, Color32::WHITE);
painter.circle_filled(south_edge, use_edge_circle_radius, Color32::WHITE);
painter.circle_filled(west_edge, use_edge_circle_radius, Color32::WHITE);
painter.circle_filled(east_edge, use_edge_circle_radius, Color32::WHITE);
helper.take_animation_response()
}
}
#[allow(unused)]
fn search_button() -> impl Widget {
|ui: &mut egui::Ui| -> egui::Response {
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
let min_line_width_circle = 1.5; // width of the magnifying glass
let min_line_width_handle = 1.5;
let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size));
let painter = ui.painter_at(helper.get_animation_rect());
let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle);
let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle);
let min_outer_circle_radius = helper.scale_radius(15.0);
let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius);
let min_handle_length = 7.0;
let cur_handle_length = helper.scale_1d_pos(min_handle_length);
let circle_center = helper.scale_from_center(-2.0, -2.0);
let handle_vec = vec2(
std::f32::consts::FRAC_1_SQRT_2,
std::f32::consts::FRAC_1_SQRT_2,
);
let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0));
let handle_pos_2 =
circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length));
let circle_stroke = Stroke::new(cur_line_width_circle, colors::MID_GRAY);
let handle_stroke = Stroke::new(cur_line_width_handle, colors::MID_GRAY);
painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke);
painter.circle(
circle_center,
min_outer_circle_radius,
ui.style().visuals.widgets.inactive.weak_bg_fill,
circle_stroke,
);
helper.take_animation_response()
}
}
// TODO: convert to responsive button when expanded side panel impl is finished
fn expand_side_panel_button() -> impl Widget {
|ui: &mut egui::Ui| -> egui::Response {
let img_size = 40.0;
let img_data = egui::include_image!("../../assets/damus_rounded_80.png");
let img = egui::Image::new(img_data).max_width(img_size);
ui.add(img)
}
}
fn support_button() -> impl Widget {
|ui: &mut egui::Ui| -> egui::Response {
let img_size = 16.0;
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
let img_data = if ui.visuals().dark_mode {
egui::include_image!("../../assets/icons/help_icon_dark_4x.png")
} else {
egui::include_image!("../../assets/icons/help_icon_inverted_4x.png")
};
let img = egui::Image::new(img_data).max_width(img_size);
let helper = AnimationHelper::new(ui, "help-button", vec2(max_size, max_size));
let cur_img_size = helper.scale_1d_pos(img_size);
img.paint_at(
ui,
helper
.get_animation_rect()
.shrink((max_size - cur_img_size) / 2.0),
);
helper.take_animation_response()
}
}
fn add_deck_button() -> impl Widget {
|ui: &mut egui::Ui| -> egui::Response {
let img_size = 40.0;
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
let img_data = egui::include_image!("../../assets/icons/new_deck_icon_4x_dark.png");
let img = egui::Image::new(img_data).max_width(img_size);
let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size));
let cur_img_size = helper.scale_1d_pos(img_size);
img.paint_at(
ui,
helper
.get_animation_rect()
.shrink((max_size - cur_img_size) / 2.0),
);
helper.take_animation_response()
}
}
fn show_decks<'a>(
ui: &mut egui::Ui,
decks_cache: &'a DecksCache,
selected_account: Option<&'a UserAccount>,
) -> InnerResponse<Option<usize>> {
let show_decks_id = ui.id().with("show-decks");
let account_id = if let Some(acc) = selected_account {
acc.pubkey
} else {
*decks_cache.get_fallback_pubkey()
};
let (cur_decks, account_id) = (
decks_cache.decks(&account_id),
show_decks_id.with(account_id),
);
let active_index = cur_decks.active_index();
let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click());
let mut clicked_index = None;
for (index, deck) in cur_decks.decks().iter().enumerate() {
let highlight = index == active_index;
let deck_icon_resp = ui.add(deck_icon(
account_id.with(index),
Some(deck.icon),
DECK_ICON_SIZE,
40.0,
highlight,
));
if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() {
clicked_index = Some(index);
}
resp = resp.union(deck_icon_resp);
}
InnerResponse::new(clicked_index, resp)
}
fn milestone_name() -> impl Widget {
|ui: &mut egui::Ui| -> egui::Response {
ui.vertical_centered(|ui| {
let font = egui::FontId::new(
crate::app_style::get_font_size(
ui.ctx(),
&crate::app_style::NotedeckTextStyle::Tiny,
),
egui::FontFamily::Name(crate::fonts::NamedFontFamily::Bold.as_str().into()),
);
ui.add(Label::new(
RichText::new("ALPHA")
.color(crate::colors::GRAY_SECONDARY)
.font(font),
).selectable(false)).on_hover_text("Notedeck is an alpha product. Expect bugs and contact us when you run into issues.").on_hover_cursor(egui::CursorIcon::Help)
})
.inner
}
}
mod preview {
use egui_extras::{Size, StripBuilder};
use crate::{
app::get_active_columns_mut,
test_data,
ui::{Preview, PreviewConfig},
};
use super::*;
pub struct DesktopSidePanelPreview {
app: Damus,
}
impl DesktopSidePanelPreview {
fn new() -> Self {
let mut app = test_data::test_app();
get_active_columns_mut(&app.accounts, &mut app.decks_cache)
.add_column(Column::new(vec![Route::accounts()]));
DesktopSidePanelPreview { app }
}
}
impl View for DesktopSidePanelPreview {
fn ui(&mut self, ui: &mut egui::Ui) {
StripBuilder::new(ui)
.size(Size::exact(SIDE_PANEL_WIDTH))
.sizes(Size::remainder(), 0)
.clip(true)
.horizontal(|mut strip| {
strip.cell(|ui| {
let mut panel = DesktopSidePanel::new(
&self.app.ndb,
&mut self.app.img_cache,
self.app.accounts.get_selected_account(),
&self.app.decks_cache,
);
let response = panel.show(ui);
DesktopSidePanel::perform_action(
&mut self.app.decks_cache,
&self.app.accounts,
&mut self.app.support,
response.action,
);
});
});
}
}
impl Preview for DesktopSidePanel<'_> {
type Prev = DesktopSidePanelPreview;
fn preview(_cfg: PreviewConfig) -> Self::Prev {
DesktopSidePanelPreview::new()
}
}
}

View File

@@ -0,0 +1,88 @@
use egui::{vec2, Button, Label, Layout, RichText};
use tracing::error;
use crate::{
app_style::{get_font_size, NotedeckTextStyle},
colors::PINK,
fonts::NamedFontFamily,
support::Support,
};
use super::padding;
pub struct SupportView<'a> {
support: &'a mut Support,
}
impl<'a> SupportView<'a> {
pub fn new(support: &'a mut Support) -> Self {
Self { support }
}
pub fn show(&mut self, ui: &mut egui::Ui) {
padding(8.0, ui, |ui| {
ui.spacing_mut().item_spacing = egui::vec2(0.0, 8.0);
let font = egui::FontId::new(
get_font_size(ui.ctx(), &NotedeckTextStyle::Body),
egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()),
);
ui.add(Label::new(RichText::new("Running into a bug?").font(font)));
ui.label(RichText::new("Step 1").text_style(NotedeckTextStyle::Heading3.text_style()));
padding(8.0, ui, |ui| {
ui.label("Open your default email client to get help from the Damus team");
let size = vec2(120.0, 40.0);
ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| {
let font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
let button_resp = ui.add(open_email_button(font_size, size));
if button_resp.clicked() {
if let Err(e) = open::that(self.support.get_mailto_url()) {
error!(
"Failed to open URL {} because: {}",
self.support.get_mailto_url(),
e
);
};
};
button_resp.on_hover_text_at_pointer(self.support.get_mailto_url());
})
});
ui.add_space(8.0);
if let Some(logs) = self.support.get_most_recent_log() {
ui.label(
RichText::new("Step 2").text_style(NotedeckTextStyle::Heading3.text_style()),
);
let size = vec2(80.0, 40.0);
let copy_button = Button::new(
RichText::new("Copy").size(get_font_size(ui.ctx(), &NotedeckTextStyle::Body)),
)
.fill(PINK)
.min_size(size);
padding(8.0, ui, |ui| {
ui.add(Label::new("Press the button below to copy your most recent logs to your system's clipboard. Then paste it into your email.").wrap());
ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| {
if ui.add(copy_button).clicked() {
ui.output_mut(|w| {
w.copied_text = logs.to_string();
});
}
});
});
} else {
ui.label(
egui::RichText::new("ERROR: Could not find logs on system")
.color(egui::Color32::RED),
);
}
ui.label(format!("Notedeck {}", env!("CARGO_PKG_VERSION")));
ui.label(format!("Commit hash: {}", env!("GIT_COMMIT_HASH")));
});
}
}
fn open_email_button(font_size: f32, size: egui::Vec2) -> impl egui::Widget {
Button::new(RichText::new("Open Email").size(font_size))
.fill(PINK)
.min_size(size)
}

View File

@@ -0,0 +1,132 @@
use crate::{
actionbar::NoteAction,
imgcache::ImageCache,
muted::MuteFun,
notecache::NoteCache,
notes_holder::{NotesHolder, NotesHolderStorage},
thread::Thread,
ui::note::NoteOptions,
unknowns::UnknownIds,
};
use nostrdb::{Ndb, NoteKey, Transaction};
use tracing::error;
use super::timeline::TimelineTabView;
pub struct ThreadView<'a> {
threads: &'a mut NotesHolderStorage<Thread>,
ndb: &'a Ndb,
note_cache: &'a mut NoteCache,
unknown_ids: &'a mut UnknownIds,
img_cache: &'a mut ImageCache,
selected_note_id: &'a [u8; 32],
textmode: bool,
id_source: egui::Id,
}
impl<'a> ThreadView<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
threads: &'a mut NotesHolderStorage<Thread>,
ndb: &'a Ndb,
note_cache: &'a mut NoteCache,
unknown_ids: &'a mut UnknownIds,
img_cache: &'a mut ImageCache,
selected_note_id: &'a [u8; 32],
textmode: bool,
) -> Self {
let id_source = egui::Id::new("threadscroll_threadview");
ThreadView {
threads,
ndb,
note_cache,
unknown_ids,
img_cache,
selected_note_id,
textmode,
id_source,
}
}
pub fn id_source(mut self, id: egui::Id) -> Self {
self.id_source = id;
self
}
pub fn ui(&mut self, ui: &mut egui::Ui, is_muted: &MuteFun) -> Option<NoteAction> {
let txn = Transaction::new(self.ndb).expect("txn");
let selected_note_key = if let Ok(key) = self
.ndb
.get_notekey_by_id(&txn, self.selected_note_id)
.map(NoteKey::new)
{
key
} else {
// TODO: render 404 ?
return None;
};
ui.label(
egui::RichText::new("Threads ALPHA! It's not done. Things will be broken.")
.color(egui::Color32::RED),
);
egui::ScrollArea::vertical()
.id_salt(self.id_source)
.animated(false)
.auto_shrink([false, false])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible)
.show(ui, |ui| {
let note = if let Ok(note) = self.ndb.get_note_by_key(&txn, selected_note_key) {
note
} else {
return None;
};
let root_id = {
let cached_note = self
.note_cache
.cached_note_or_insert(selected_note_key, &note);
cached_note
.reply
.borrow(note.tags())
.root()
.map_or_else(|| self.selected_note_id, |nr| nr.id)
};
let thread = self
.threads
.notes_holder_mutated(self.ndb, self.note_cache, &txn, root_id, is_muted)
.get_ptr();
// TODO(jb55): skip poll if ThreadResult is fresh?
// poll for new notes and insert them into our existing notes
match thread.poll_notes_into_view(&txn, self.ndb, is_muted) {
Ok(action) => {
action.process_action(&txn, self.ndb, self.unknown_ids, self.note_cache)
}
Err(err) => error!("{err}"),
};
// This is threadview. We are not the universe view...
let is_universe = false;
let mut note_options = NoteOptions::new(is_universe);
note_options.set_textmode(self.textmode);
TimelineTabView::new(
thread.view(),
true,
note_options,
&txn,
self.ndb,
self.note_cache,
self.img_cache,
)
.show(ui)
})
.inner
}
}

View File

@@ -0,0 +1,293 @@
use crate::actionbar::NoteAction;
use crate::timeline::TimelineTab;
use crate::{
column::Columns, imgcache::ImageCache, notecache::NoteCache, timeline::TimelineId, ui,
ui::note::NoteOptions,
};
use egui::containers::scroll_area::ScrollBarVisibility;
use egui::{Direction, Layout};
use egui_tabs::TabColor;
use nostrdb::{Ndb, Transaction};
use tracing::{error, warn};
pub struct TimelineView<'a> {
timeline_id: TimelineId,
columns: &'a mut Columns,
ndb: &'a Ndb,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
note_options: NoteOptions,
reverse: bool,
}
impl<'a> TimelineView<'a> {
pub fn new(
timeline_id: TimelineId,
columns: &'a mut Columns,
ndb: &'a Ndb,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
note_options: NoteOptions,
) -> TimelineView<'a> {
let reverse = false;
TimelineView {
ndb,
timeline_id,
columns,
note_cache,
img_cache,
reverse,
note_options,
}
}
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> {
timeline_ui(
ui,
self.ndb,
self.timeline_id,
self.columns,
self.note_cache,
self.img_cache,
self.reverse,
self.note_options,
)
}
pub fn reversed(mut self) -> Self {
self.reverse = true;
self
}
}
#[allow(clippy::too_many_arguments)]
fn timeline_ui(
ui: &mut egui::Ui,
ndb: &Ndb,
timeline_id: TimelineId,
columns: &mut Columns,
note_cache: &mut NoteCache,
img_cache: &mut ImageCache,
reversed: bool,
note_options: NoteOptions,
) -> Option<NoteAction> {
//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;
*/
let scroll_id = {
let timeline = if let Some(timeline) = columns.find_timeline_mut(timeline_id) {
timeline
} else {
error!("tried to render timeline in column, but timeline was missing");
// TODO (jb55): render error when timeline is missing?
// this shouldn't happen...
return None;
};
timeline.selected_view = tabs_ui(ui);
// need this for some reason??
ui.add_space(3.0);
egui::Id::new(("tlscroll", timeline.view_id()))
};
egui::ScrollArea::vertical()
.id_salt(scroll_id)
.animated(false)
.auto_shrink([false, false])
.scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible)
.show(ui, |ui| {
let timeline = if let Some(timeline) = columns.find_timeline_mut(timeline_id) {
timeline
} else {
error!("tried to render timeline in column, but timeline was missing");
// TODO (jb55): render error when timeline is missing?
// this shouldn't happen...
return None;
};
let txn = Transaction::new(ndb).expect("failed to create txn");
TimelineTabView::new(
timeline.current_view(),
reversed,
note_options,
&txn,
ndb,
note_cache,
img_cache,
)
.show(ui)
})
.inner
}
pub fn tabs_ui(ui: &mut egui::Ui) -> i32 {
ui.spacing_mut().item_spacing.y = 0.0;
let tab_res = egui_tabs::Tabs::new(2)
.selected(1)
.hover_bg(TabColor::none())
.selected_fg(TabColor::none())
.selected_bg(TabColor::none())
.hover_bg(TabColor::none())
//.hover_bg(TabColor::custom(egui::Color32::RED))
.height(32.0)
.layout(Layout::centered_and_justified(Direction::TopDown))
.show(ui, |ui, state| {
ui.spacing_mut().item_spacing.y = 0.0;
let ind = state.index();
let txt = if ind == 0 { "Notes" } else { "Notes & Replies" };
let res = ui.add(egui::Label::new(txt).selectable(false));
// underline
if state.is_selected() {
let rect = res.rect;
let underline =
shrink_range_to_width(rect.x_range(), get_label_width(ui, txt) * 1.15);
let underline_y = ui.painter().round_to_pixel(rect.bottom()) - 1.5;
return (underline, underline_y);
}
(egui::Rangef::new(0.0, 0.0), 0.0)
});
//ui.add_space(0.5);
ui::hline(ui);
let sel = tab_res.selected().unwrap_or_default();
let (underline, underline_y) = tab_res.inner()[sel as usize].inner;
let underline_width = underline.span();
let tab_anim_id = ui.id().with("tab_anim");
let tab_anim_size = tab_anim_id.with("size");
let stroke = egui::Stroke {
color: ui.visuals().hyperlink_color,
width: 2.0,
};
let speed = 0.1f32;
// animate underline position
let x = ui
.ctx()
.animate_value_with_time(tab_anim_id, underline.min, speed);
// animate underline width
let w = ui
.ctx()
.animate_value_with_time(tab_anim_size, underline_width, speed);
let underline = egui::Rangef::new(x, x + w);
ui.painter().hline(underline, underline_y, stroke);
sel
}
fn get_label_width(ui: &mut egui::Ui, text: &str) -> f32 {
let font_id = egui::FontId::default();
let galley = ui.fonts(|r| r.layout_no_wrap(text.to_string(), font_id, egui::Color32::WHITE));
galley.rect.width()
}
fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef {
let midpoint = (range.min + range.max) / 2.0;
let half_width = width / 2.0;
let min = midpoint - half_width;
let max = midpoint + half_width;
egui::Rangef::new(min, max)
}
pub struct TimelineTabView<'a> {
tab: &'a TimelineTab,
reversed: bool,
note_options: NoteOptions,
txn: &'a Transaction,
ndb: &'a Ndb,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
}
impl<'a> TimelineTabView<'a> {
pub fn new(
tab: &'a TimelineTab,
reversed: bool,
note_options: NoteOptions,
txn: &'a Transaction,
ndb: &'a Ndb,
note_cache: &'a mut NoteCache,
img_cache: &'a mut ImageCache,
) -> Self {
Self {
tab,
reversed,
txn,
note_options,
ndb,
note_cache,
img_cache,
}
}
pub fn show(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> {
let mut action: Option<NoteAction> = None;
let len = self.tab.notes.len();
self.tab
.list
.clone()
.borrow_mut()
.ui_custom_layout(ui, len, |ui, start_index| {
ui.spacing_mut().item_spacing.y = 0.0;
ui.spacing_mut().item_spacing.x = 4.0;
let ind = if self.reversed {
len - start_index - 1
} else {
start_index
};
let note_key = self.tab.notes[ind].key;
let note = if let Ok(note) = self.ndb.get_note_by_key(self.txn, note_key) {
note
} else {
warn!("failed to query note {:?}", note_key);
return 0;
};
ui::padding(8.0, ui, |ui| {
let resp = ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, &note)
.note_options(self.note_options)
.show(ui);
if let Some(note_action) = resp.action {
action = Some(note_action)
}
if let Some(context) = resp.context_selection {
context.process(ui, &note);
}
});
ui::hline(ui);
//ui.add(egui::Separator::default().spacing(0.0));
1
});
action
}
}

View File

@@ -0,0 +1,94 @@
use crate::fonts::NamedFontFamily;
use egui::{Color32, RichText, Widget};
use nostrdb::ProfileRecord;
pub struct Username<'a> {
profile: Option<&'a ProfileRecord<'a>>,
pk: &'a [u8; 32],
pk_colored: bool,
abbrev: usize,
}
impl<'a> Username<'a> {
pub fn pk_colored(mut self, pk_colored: bool) -> Self {
self.pk_colored = pk_colored;
self
}
pub fn abbreviated(mut self, amount: usize) -> Self {
self.abbrev = amount;
self
}
pub fn new(profile: Option<&'a ProfileRecord>, pk: &'a [u8; 32]) -> Self {
let pk_colored = false;
let abbrev: usize = 1000;
Username {
profile,
pk,
pk_colored,
abbrev,
}
}
}
impl Widget for Username<'_> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let color = if self.pk_colored {
Some(pk_color(self.pk))
} else {
None
};
if let Some(profile) = self.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(), self.abbrev, color);
} else if let Some(name) = prof.name() {
ui_abbreviate_name(ui, name, self.abbrev, color);
}
}
} else {
let mut txt = RichText::new("nostrich").family(NamedFontFamily::Medium.as_family());
if let Some(col) = color {
txt = txt.color(col)
}
ui.label(txt);
}
})
.response
}
}
fn colored_name(name: &str, color: Option<Color32>) -> RichText {
let mut txt = RichText::new(name).family(NamedFontFamily::Medium.as_family());
if let Some(color) = color {
txt = txt.color(color);
}
txt
}
fn ui_abbreviate_name(ui: &mut egui::Ui, name: &str, len: usize, color: Option<Color32>) {
let should_abbrev = name.len() > len;
let name = if should_abbrev {
let closest = crate::abbrev::floor_char_boundary(name, len);
&name[..closest]
} else {
name
};
ui.label(colored_name(name, color));
if should_abbrev {
ui.label(colored_name("..", color));
}
}
fn pk_color(pk: &[u8; 32]) -> Color32 {
Color32::from_rgb(pk[8], pk[10], pk[12])
}