commit 5b0c8c22fc2059389d12053446fba8da2da346f3
parent a621b0b7b7d1ec17c31a71dfd56c485acf7558cc
Author: William Casarin <jb55@jb55.com>
Date: Thu, 19 Feb 2026 09:15:37 -0800
columns: add welcome bottom sheet for new users
Show a welcome bottom sheet on first launch that slides up over the
demo timeline, explaining what Notedeck and Nostr are. Users see live
content immediately with the welcome overlay on top. "Create Account"
and "I have a Nostr key" buttons dismiss the sheet and route to the
appropriate flow. Persists a welcome_completed flag in settings so it
only shows once.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
7 files changed, 188 insertions(+), 2 deletions(-)
diff --git a/crates/notedeck/src/persist/settings_handler.rs b/crates/notedeck/src/persist/settings_handler.rs
@@ -42,6 +42,8 @@ pub struct Settings {
pub animate_nav_transitions: bool,
pub max_hashtags_per_note: usize,
#[serde(default)]
+ pub welcome_completed: bool,
+ #[serde(default)]
pub tos_accepted: bool,
#[serde(default)]
pub tos_accepted_at: Option<u64>,
@@ -70,6 +72,7 @@ impl Default for Settings {
note_body_font_size: DEFAULT_NOTE_BODY_FONT_SIZE,
animate_nav_transitions: default_animate_nav_transitions(),
max_hashtags_per_note: DEFAULT_MAX_HASHTAGS_PER_NOTE,
+ welcome_completed: false,
tos_accepted: false,
tos_accepted_at: None,
tos_version: default_tos_version(),
@@ -295,6 +298,18 @@ impl SettingsHandler {
.unwrap_or(DEFAULT_MAX_HASHTAGS_PER_NOTE)
}
+ pub fn welcome_completed(&self) -> bool {
+ self.current_settings
+ .as_ref()
+ .map(|s| s.welcome_completed)
+ .unwrap_or(false)
+ }
+
+ pub fn complete_welcome(&mut self) {
+ self.get_settings_mut().welcome_completed = true;
+ self.try_save_settings();
+ }
+
pub fn tos_accepted(&self) -> bool {
self.current_settings
.as_ref()
diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs
@@ -256,7 +256,16 @@ fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Con
warn!("update_damus init: {err}");
}
- if is_compiled_as_mobile() && !app_ctx.settings.tos_accepted() {
+ if !app_ctx.settings.welcome_completed() {
+ let split =
+ egui_nav::Split::PercentFromTop(egui_nav::Percent::new(40).expect("40 <= 100"));
+ if let Some(col) = damus
+ .decks_cache
+ .selected_column_mut(app_ctx.i18n, app_ctx.accounts)
+ {
+ col.sheet_router.route_to(Route::Welcome, split);
+ }
+ } else if is_compiled_as_mobile() && !app_ctx.settings.tos_accepted() {
damus
.columns_mut(app_ctx.i18n, app_ctx.accounts)
.get_selected_router()
@@ -844,6 +853,7 @@ fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool {
Route::Following(_) => false,
Route::FollowedBy(_) => false,
Route::TosAcceptance => false,
+ Route::Welcome => false,
Route::Report(_) => false,
}
}
diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs
@@ -1,5 +1,5 @@
use crate::{
- accounts::{render_accounts_route, AccountsAction, AccountsResponse},
+ accounts::{render_accounts_route, AccountsAction, AccountsResponse, AccountsRoute},
app::{get_active_columns_mut, get_decks_mut},
column::ColumnsAction,
deck_state::DeckState,
@@ -1054,6 +1054,31 @@ fn render_nav_body(
DragResponse::none()
}
+ Route::Welcome => {
+ let resp = ui::welcome::WelcomeView::new(ctx.i18n).show(ui);
+
+ if let Some(welcome_resp) = resp {
+ ctx.settings.complete_welcome();
+ match welcome_resp {
+ ui::welcome::WelcomeResponse::CreateAccount => {
+ app.columns_mut(ctx.i18n, ctx.accounts)
+ .column_mut(col)
+ .router_mut()
+ .route_to(Route::Accounts(AccountsRoute::Onboarding));
+ }
+ ui::welcome::WelcomeResponse::Login => {
+ app.columns_mut(ctx.i18n, ctx.accounts)
+ .column_mut(col)
+ .router_mut()
+ .route_to(Route::Accounts(AccountsRoute::AddAccount));
+ }
+ ui::welcome::WelcomeResponse::Browse => {}
+ }
+ return DragResponse::output(Some(RenderNavAction::Back));
+ }
+
+ DragResponse::none()
+ }
Route::Wallet(wallet_type) => {
let state = match wallet_type {
notedeck::WalletType::Auto => 's: {
diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs
@@ -39,6 +39,7 @@ pub enum Route {
Following(Pubkey),
FollowedBy(Pubkey),
TosAcceptance,
+ Welcome,
Report(ReportTarget),
}
@@ -158,6 +159,9 @@ impl Route {
Route::TosAcceptance => {
writer.write_token("tos");
}
+ Route::Welcome => {
+ writer.write_token("welcome");
+ }
Route::Report(target) => {
writer.write_token("report");
writer.write_token(&target.pubkey.hex());
@@ -310,6 +314,12 @@ impl Route {
},
|p| {
p.parse_all(|p| {
+ p.parse_token("welcome")?;
+ Ok(Route::Welcome)
+ })
+ },
+ |p| {
+ p.parse_all(|p| {
p.parse_token("report")?;
let pubkey = Pubkey::from_hex(p.pull_token()?)
.map_err(|_| ParseError::HexDecodeFailed)?;
@@ -448,6 +458,9 @@ impl Route {
"Terms of Service",
"Column title for TOS acceptance screen"
)),
+ Route::Welcome => {
+ ColumnTitle::formatted(tr!(i18n, "Welcome", "Column title for welcome screen"))
+ }
Route::Report(_) => {
ColumnTitle::formatted(tr!(i18n, "Report", "Column title for report screen"))
}
diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs
@@ -549,6 +549,7 @@ impl<'a> NavTitle<'a> {
Route::Following(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)),
Route::FollowedBy(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)),
Route::TosAcceptance => None,
+ Route::Welcome => None,
Route::Report(_) => None,
}
}
diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs
@@ -23,6 +23,7 @@ pub mod timeline;
pub mod toolbar;
pub mod tos;
pub mod wallet;
+pub mod welcome;
pub mod widgets;
pub use accounts::AccountsView;
diff --git a/crates/notedeck_columns/src/ui/welcome.rs b/crates/notedeck_columns/src/ui/welcome.rs
@@ -0,0 +1,121 @@
+use egui::{vec2, Button, Label, Layout, RichText};
+use notedeck::{tr, Localization, NotedeckTextStyle};
+use notedeck_ui::padding;
+
+pub enum WelcomeResponse {
+ CreateAccount,
+ Login,
+ Browse,
+}
+
+pub struct WelcomeView<'a> {
+ i18n: &'a mut Localization,
+}
+
+impl<'a> WelcomeView<'a> {
+ pub fn new(i18n: &'a mut Localization) -> Self {
+ Self { i18n }
+ }
+
+ pub fn show(&mut self, ui: &mut egui::Ui) -> Option<WelcomeResponse> {
+ let mut response = None;
+
+ padding(16.0, ui, |ui| {
+ ui.spacing_mut().item_spacing = vec2(0.0, 16.0);
+
+ ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
+ ui.add_space(48.0);
+
+ ui.add(Label::new(
+ RichText::new(tr!(
+ self.i18n,
+ "Welcome to Notedeck",
+ "Welcome screen title"
+ ))
+ .text_style(NotedeckTextStyle::Heading2.text_style()),
+ ));
+
+ ui.add_space(8.0);
+
+ let max_width: f32 = 400.0;
+ ui.allocate_ui(vec2(max_width.min(ui.available_width()), 0.0), |ui| {
+ ui.add(
+ Label::new(
+ RichText::new(tr!(
+ self.i18n,
+ "Notedeck is a client for Nostr, an open protocol for decentralized social networking. Unlike traditional platforms, no single company controls your feed, your identity, or your data. Your account is a cryptographic key pair \u{2014} no emails, no passwords, no phone numbers.",
+ "Welcome screen body text explaining what Notedeck and Nostr are"
+ ))
+ .text_style(NotedeckTextStyle::Body.text_style()),
+ )
+ .wrap(),
+ );
+ });
+
+ ui.add_space(24.0);
+
+ let button_size = vec2(200.0, 40.0);
+ let font_size =
+ notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
+
+ if ui
+ .add(
+ Button::new(
+ RichText::new(tr!(
+ self.i18n,
+ "Create Account",
+ "Button to create a new Nostr account"
+ ))
+ .size(font_size),
+ )
+ .fill(notedeck_ui::colors::PINK)
+ .min_size(button_size),
+ )
+ .clicked()
+ {
+ response = Some(WelcomeResponse::CreateAccount);
+ }
+
+ ui.add_space(4.0);
+
+ if ui
+ .add(
+ Button::new(
+ RichText::new(tr!(
+ self.i18n,
+ "I have a Nostr key",
+ "Button for existing Nostr users to log in with their key"
+ ))
+ .size(font_size),
+ )
+ .min_size(button_size),
+ )
+ .clicked()
+ {
+ response = Some(WelcomeResponse::Login);
+ }
+
+ ui.add_space(4.0);
+
+ if ui
+ .add(
+ Button::new(
+ RichText::new(tr!(
+ self.i18n,
+ "Just browsing",
+ "Button to dismiss welcome and browse the app without an account"
+ ))
+ .size(font_size),
+ )
+ .frame(false),
+ )
+ .clicked()
+ {
+ response = Some(WelcomeResponse::Browse);
+ }
+ });
+ });
+
+ response
+ }
+}