489 lines
22 KiB
Rust
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::<>k::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::<>k::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>(&[]);
|
|
}
|