Files

489 lines
22 KiB
Rust

//! Dev screenshot harness: renders app pages to PNG so layout changes can
//! be reviewed without a live session or a real account.
//!
//! Run headless under broadway with isolated XDG dirs:
//! ```sh
//! gtk4-broadwayd :9 &
//! GDK_BACKEND=broadway BROADWAY_DISPLAY=:9 \
//! XDG_CONFIG_HOME=/tmp/shot/cfg XDG_DATA_HOME=/tmp/shot/data XDG_CACHE_HOME=/tmp/shot/cache \
//! cargo run -p sunstone-gtk --example ui_shot -- <target> <wait_secs> <out.png> [--online]
//! ```
//! Targets: `main:<page-id>` (whole shell, anon account; `--online` adds
//! public relays) or `notifications` / `streams` / `videos` (single page
//! with injected fake data, offline).
use std::sync::Arc;
use relm4::adw;
use relm4::adw::prelude::*;
use relm4::gtk::{self, glib};
use relm4::prelude::*;
use sunstone_core::nostr_sdk::prelude::*;
use sunstone_core::notifications::{Notification, NotificationKind};
use sunstone_core::settings::Settings;
use sunstone_core::streams::{Stream, StreamStatus};
use sunstone_core::video::Video;
use sunstone_core::calendar::CalEvent;
use sunstone_core::feeds::DisplayNote;
use sunstone_core::Core;
use sunstone_gtk::main_view::{MainMsg, MainView};
use sunstone_gtk::pages::calendar::{CalView, CalendarMsg, CalendarPage};
use sunstone_gtk::pages::feed::{FeedMsg, FeedPage};
use sunstone_gtk::pages::notifications::{NotificationsMsg, NotificationsPage};
use sunstone_gtk::pages::streams::{StreamsMsg, StreamsPage};
use sunstone_gtk::pages::videos::{VideosMsg, VideosPage};
const PAGE_IDS: &[&str] = &[
"feed", "notifications", "watch", "calendar", "messages", "bookmarks", "drafts", "settings",
];
fn fake_event(kind: Kind, content: &str) -> Event {
EventBuilder::new(kind, content)
.sign_with_keys(&Keys::generate())
.unwrap()
}
fn avatar(seed: &str) -> Option<String> {
Some(format!("https://picsum.photos/seed/{seed}/128/128"))
}
fn fake_notifications() -> Vec<Notification> {
let mk = |kind, name: &str, summary: &str, preview: &str, age: u64| {
let mut event = fake_event(Kind::TextNote, preview);
event.created_at = Timestamp::now() - age;
Notification {
actor: event.pubkey,
actor_name: name.into(),
avatar_url: avatar(name),
summary: summary.into(),
preview: preview.into(),
kind,
thread: event.id,
event,
}
};
vec![
mk(NotificationKind::Reply, "Alice", "replied to you", "Totally agree — the outbox model is the only sane way to do this.", 60 * 4),
mk(NotificationKind::Zap, "Bob", "zapped you ⚡", "shipped NIP-17 DMs in sunstone tonight, gift wraps and all", 60 * 25),
mk(NotificationKind::Reaction, "Carol", "reacted 🤙", "the relay list IS the social graph, fight me", 60 * 47),
mk(NotificationKind::Mention, "Dave", "mentioned you", "cc @you — wasn't this exactly the relay bug you hit last week?", 3600 * 2),
mk(NotificationKind::Repost, "Erin", "reposted your note", "pure black themes or nothing", 3600 * 5),
mk(NotificationKind::Reaction, "Frank", "reacted ❤", "shipped NIP-17 DMs in sunstone tonight, gift wraps and all", 3600 * 9),
mk(NotificationKind::Zap, "Grace", "zapped you ⚡", "the relay list IS the social graph, fight me", 86_400),
mk(NotificationKind::Reply, "Heidi", "replied to you", "GStreamer with playbin3 has been solid for me on trixie fwiw.", 86_400 * 2),
]
}
fn fake_streams() -> Vec<Stream> {
let mk = |seed: &str, title: &str, host: &str, status, viewers| {
let event = fake_event(Kind::LiveEvent, "");
Stream {
coordinate: format!("30311:{}:{seed}", event.pubkey.to_hex()),
title: title.into(),
summary: None,
image: Some(format!("https://picsum.photos/seed/{seed}/640/360")),
streaming_url: None,
recording_url: None,
status,
viewers,
host: event.pubkey,
host_name: host.into(),
host_avatar: avatar(host),
starts: None,
event,
}
};
vec![
mk("s1", "NoGood Radio — 24/7 underground", "NoGood", StreamStatus::Live, Some(231)),
mk("s2", "Building a nostr client live, part 12", "Alice", StreamStatus::Live, Some(48)),
mk("s3", "Friday night music show", "Bob", StreamStatus::Live, Some(12)),
mk("s4", "Bitcoin dev call (planned)", "Carol", StreamStatus::Planned, None),
mk("s5", "Last week's show — recording", "Dave", StreamStatus::Ended, None),
mk("s6", "Lo-fi & chill", "Erin", StreamStatus::Live, Some(97)),
]
}
fn fake_videos() -> Vec<Video> {
let mk = |seed: &str, title: &str, author: &str, secs| {
let mut event = fake_event(Kind::Custom(21), "");
event.created_at = Timestamp::now() - 3600 * 7;
Video {
title: title.into(),
author_name: author.into(),
avatar_url: avatar(author),
url: "https://example.com/v.mp4".into(),
mime: Some("video/mp4".into()),
thumb: Some(format!("https://picsum.photos/seed/{seed}/640/360")),
duration_secs: Some(secs),
short: false,
event,
}
};
vec![
mk("v1", "Sovereign computing in 12 minutes", "Alice", 731),
mk("v2", "Relay operators roundtable", "Bob", 3640),
mk("v3", "How gift wraps actually work (NIP-59)", "Carol", 922),
mk("v4", "Desk tour: the offgrid setup", "Dave", 488),
mk("v5", "Live from the conference floor", "Erin", 1290),
mk("v6", "GTK4 theming deep dive", "Frank", 2210),
]
}
fn fake_cal_events() -> Vec<CalEvent> {
let mk = |title: &str, loc: &str, desc: &str, host: &str, days: i64, hour: i64| {
let event = EventBuilder::new(Kind::Custom(31923), desc)
.sign_with_keys(&Keys::generate())
.unwrap();
let start = Timestamp::now().as_secs() as i64 + days * 86400 + hour * 3600;
CalEvent {
coordinate: format!("31923:{}:{title}", event.pubkey.to_hex()),
all_day: false,
title: title.into(),
start_ts: Timestamp::from(start as u64),
end_ts: Some(Timestamp::from((start + 5400) as u64)),
location: Some(loc.into()),
organizer: event.pubkey,
organizer_name: host.into(),
event,
}
};
vec![
mk("Nostrasia 2026 — Day 1 Keynote", "Tokyo, Japan", "Opening keynote on the state of nostr, the outbox model, and what's next for decentralized social. Doors at 9.", "Nostrasia", 2, 9),
mk("Sats'n'Facts unconference", "Chiang Mai, Thailand", "Hardware, signal, and freedom tech hacking week. Bring a soldering iron.", "SnF", 5, 10),
mk("Bitcoin Park nostr meetup", "Nashville, TN", "Monthly relay operators + client devs hang. Lightning drinks on the house.", "BTC Park", 7, 18),
mk("Live coding: building a nostr client", "Online", "Pair-programming a NIP-52 calendar view in GTK4, live. Q&A after.", "Alice", 9, 14),
]
}
fn shoot_and_quit(app: &adw::Application, window: &adw::ApplicationWindow, wait: u32, out: String) {
let app = app.clone();
let window = window.clone();
glib::timeout_add_seconds_local(wait, move || {
let paintable = gtk::WidgetPaintable::new(Some(&window));
let snapshot = gtk::Snapshot::new();
paintable.snapshot(&snapshot, window.width() as f64, window.height() as f64);
match snapshot.to_node() {
Some(node) => {
let renderer = window.renderer().expect("window renderer");
let texture = renderer.render_texture(&node, None);
texture.save_to_png(&out).expect("save png");
println!("saved {out}");
}
None => eprintln!("nothing rendered"),
}
app.quit();
glib::ControlFlow::Break
});
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let target = args.get(1).cloned().unwrap_or_else(|| "main:feed".into());
let wait: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(6);
let out = args
.get(3)
.cloned()
.unwrap_or_else(|| "/tmp/sunstone-shot.png".into());
let online = args.iter().any(|a| a == "--online");
gstreamer::init().expect("gst");
let app = adw::Application::builder()
.application_id("dev.sunstone.Shot")
.build();
app.connect_activate(move |app| {
adw::StyleManager::default().set_color_scheme(adw::ColorScheme::ForceDark);
sunstone_gtk::apply_css();
let settings = Settings {
relays: if online {
vec![
"wss://relay.damus.io".into(),
"wss://nos.lol".into(),
"wss://relay.primal.net".into(),
]
} else {
Vec::new()
},
..Default::default()
};
let core = Arc::new(Core::new(Keys::generate(), settings).expect("core"));
let window = adw::ApplicationWindow::builder()
.application(app)
.default_width(1280)
.default_height(840)
.build();
let content: gtk::Widget = match target.split(':').next().unwrap_or("main") {
"notifications" => {
let ctrl = NotificationsPage::builder().launch(core.clone()).detach();
let sender = ctrl.sender().clone();
glib::timeout_add_seconds_local_once(2, move || {
let _ = sender.send(NotificationsMsg::Loaded {
items: fake_notifications(),
append: false,
fresh: true,
});
});
let widget = ctrl.widget().clone().upcast();
std::mem::forget(ctrl);
widget
}
"streams" => {
let ctrl = StreamsPage::builder().launch(core.clone()).detach();
let sender = ctrl.sender().clone();
glib::timeout_add_seconds_local_once(2, move || {
let _ = sender.send(StreamsMsg::Loaded {
generation: 0,
streams: fake_streams(),
fresh: true,
});
});
let widget = ctrl.widget().clone().upcast();
std::mem::forget(ctrl);
widget
}
"composer" => {
use sunstone_gtk::components::composer::{ComposeMode, Composer, ComposerMsg};
let ctrl = Composer::builder()
.launch((core.clone(), ComposeMode::New, None))
.detach();
let sender = ctrl.sender().clone();
// Inject fake follows so the @-mention list has content.
glib::timeout_add_seconds_local_once(1, move || {
let people = [
"fiatjaf", "jack", "gigi", "hodlbod", "pablof7z", "vitorpamplona",
"calle", "jb55", "miljan", "alice",
];
let candidates = people
.iter()
.map(|name| (Keys::generate().public_key(), name.to_string()))
.collect();
let _ = sender.send(ComposerMsg::MentionLoaded(candidates));
});
// The dialog surface isn't captured in the window snapshot, so
// reparent its content (the toolbar + composer body) into the
// window directly.
let dialog = ctrl.widget().clone();
let body = dialog.child().expect("composer dialog child");
dialog.set_child(None::<&gtk::Widget>);
std::mem::forget(ctrl);
body
}
"calendar" => {
let ctrl = CalendarPage::builder().launch(core.clone()).detach();
let sender = ctrl.sender().clone();
glib::timeout_add_seconds_local_once(1, move || {
let _ = sender.send(CalendarMsg::SetView(CalView::Discover));
let _ = sender.send(CalendarMsg::Loaded {
generation: 0,
events: fake_cal_events(),
fresh: true,
});
});
let widget = ctrl.widget().clone().upcast();
std::mem::forget(ctrl);
widget
}
"zap" => {
let dialog = sunstone_gtk::components::zap::present_zap_picker(
&window,
"Luna Vega",
500,
|_, _| {},
);
let body = dialog.child().expect("zap dialog child");
dialog.set_child(None::<&gtk::Widget>);
body
}
"profile" => {
let ctrl = FeedPage::builder().launch(core.clone()).detach();
let sender = ctrl.sender().clone();
glib::timeout_add_seconds_local_once(1, move || {
let pk = Keys::generate().public_key();
let meta = Metadata::from_json(
r#"{"name":"lunavega","display_name":"Luna Vega","nip05":"luna@vega.lol","picture":"https://picsum.photos/seed/luna/200/200","about":"Relay operator, lightning tinkerer, full-time cat herder. Building sovereign tools at the edge of the network. ⚡"}"#,
)
.unwrap();
let note = |text: &str| DisplayNote {
event: fake_event(Kind::TextNote, text),
author_name: "Luna Vega".into(),
avatar_url: avatar("luna"),
text: text.into(),
mentions: vec![],
image_urls: vec![],
video_urls: vec![],
title: None,
highlight: false,
poll: None,
quoted_ids: vec![],
quoted_addrs: vec![],
emojis: vec![],
};
let _ = sender.send(FeedMsg::OpenProfile(pk));
// Inject after the real (offline, empty) loaders finish
// so the fake data isn't overwritten.
let sender = sender.clone();
glib::timeout_add_seconds_local_once(2, move || {
let _ = sender.send(FeedMsg::ProfileLoaded {
pubkey: pk,
profile: Box::new(sunstone_core::feeds::AuthorProfile {
pubkey: pk,
meta: Some(meta),
notes: vec![
note("just published a new NIP draft for encrypted calendar events — feedback welcome 🗓"),
note("zaps are the only engagement metric that can't be gamed"),
],
relays: vec![
"wss://relay.damus.io".into(),
"wss://nos.lol".into(),
],
following: Vec::new(),
followed_by_me: false,
}),
fresh: true,
});
});
});
let widget = ctrl.widget().clone().upcast();
std::mem::forget(ctrl);
widget
}
"settings-emoji" => {
let ctrl = sunstone_gtk::pages::settings::SettingsPage::builder()
.launch(core.clone())
.detach();
let widget = ctrl.widget().clone();
// The stack now sits inside the sub-nav split Box.
// Click the Emoji row in the sub-nav (after realization,
// so nothing re-selects the first row over it).
let widget2 = widget.clone();
glib::timeout_add_seconds_local_once(1, move || {
if let Some(split) = widget2.content() {
if let Some(list) = split
.first_child()
.and_then(|c| c.downcast::<gtk::ListBox>().ok())
{
let row = std::env::var("SETTINGS_ROW")
.ok()
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(5);
list.select_row(list.row_at_index(row).as_ref());
}
}
});
std::mem::forget(ctrl);
widget.upcast()
}
"onboarding" => {
let ctrl = sunstone_gtk::onboarding::Onboarding::builder().launch(()).detach();
let widget = ctrl.widget().clone().upcast();
std::mem::forget(ctrl);
widget
}
"markdown" => {
let sample = "# Sovereign Computing\n\nThe **outbox model** is the only sane way to build a \
_nostr_ client. Here's why it matters:\n\n\
- relays are where you publish\n- followers read from *your* write relays\n\
- no central index\n\n## Code\n\nUse `EventBuilder::reaction` for NIP-25.\n\n\
```\nlet e = EventBuilder::text_note(\"gm\");\n```\n\n\
Read more at [nostrhub](https://nostrhub.io/).\n\n---\n\nThat's the gist.";
let rendered = sunstone_gtk::components::markdown::render(sample);
let clamp = adw::Clamp::builder()
.maximum_size(720)
.margin_top(20)
.margin_start(16)
.margin_end(16)
.child(&rendered.widget)
.build();
gtk::ScrolledWindow::builder().child(&clamp).build().upcast()
}
"feed" => {
let ctrl = FeedPage::builder().launch(core.clone()).detach();
let sender = ctrl.sender().clone();
glib::timeout_add_seconds_local_once(2, move || {
let note = |text: &str| DisplayNote {
event: fake_event(Kind::TextNote, text),
author_name: "Alice".into(),
avatar_url: avatar("Alice"),
text: text.into(),
mentions: vec![],
image_urls: vec![],
video_urls: vec![],
title: None,
highlight: false,
poll: None,
quoted_ids: vec![],
quoted_addrs: vec![],
emojis: vec![],
};
// A poll note (kind 1068) to show the interactive render.
let poll_event = EventBuilder::new(Kind::Custom(1068), "Best nostr client?")
.tags([
Tag::parse(["option", "a", "Sunstone"]).unwrap(),
Tag::parse(["option", "b", "Jumble"]).unwrap(),
Tag::parse(["option", "c", "Wisp"]).unwrap(),
])
.sign_with_keys(&Keys::generate())
.unwrap();
let mut poll_note = note("");
poll_note.text = "Best nostr client?".into();
poll_note.poll = sunstone_core::polls::parse_poll(&poll_event);
poll_note.event = poll_event;
let mut image_note = note("sunset over the relay farm 🌅");
image_note.image_urls = vec!["https://picsum.photos/seed/sun/1200/700".into()];
let _ = sender.send(FeedMsg::Loaded {
generation: 1,
notes: vec![
note("custom emoji reactions just landed — hit the smiley to pick one 🤙"),
note("great explainer on the outbox model https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
note("the NIPs repo is the source of truth https://github.com/nostr-protocol/nips"),
image_note,
poll_note,
note("the outbox model is the only sane way to build a nostr client"),
],
append: false,
fresh: true,
});
});
let widget = ctrl.widget().clone().upcast();
std::mem::forget(ctrl);
widget
}
"videos" => {
let ctrl = VideosPage::builder().launch(core.clone()).detach();
let sender = ctrl.sender().clone();
glib::timeout_add_seconds_local_once(2, move || {
let _ = sender.send(VideosMsg::Loaded {
generation: 0,
videos: fake_videos(),
append: false,
fresh: true,
});
});
let widget = ctrl.widget().clone().upcast();
std::mem::forget(ctrl);
widget
}
_ => {
let page = target.split(':').nth(1).unwrap_or("feed");
let index = PAGE_IDS.iter().position(|p| *p == page).unwrap_or(0);
let ctrl = MainView::builder().launch(core.clone()).detach();
let _ = ctrl.sender().send(MainMsg::SelectPage(index));
// Demo the nav badges (no live data offline).
let s = ctrl.sender().clone();
glib::timeout_add_seconds_local_once(1, move || {
let _ = s.send(MainMsg::StreamsLive(true));
let _ = s.send(MainMsg::NotifArrived(12));
});
let widget = ctrl.widget().clone().upcast();
std::mem::forget(ctrl);
widget
}
};
window.set_content(Some(&content));
window.present();
shoot_and_quit(app, &window, wait, out.clone());
});
app.run_with_args::<&str>(&[]);
}