Placehero: Get initial list of statuses for first exploration (#1087)

![image](https://github.com/user-attachments/assets/b2a0ca3b-b47f-49df-865f-9797bca49caa)

This is an exploration into using the Mastodon API, continuing work on
the hero app.
The actual changes here won't be all that useful, in the long term, but
it's helpful for getting an understanding of how to use the API.

One big, unfortunate surprise is that we really need some HTML parsing
(although only as described [in the
docs](https://docs.joinmastodon.org/spec/activitypub/#sanitization)).
That is something I'm not looking forward to!

One discovery in this PR is that our logging isn't set-up for the first
`build` in Xilem. That makes sense with how logging is owned, but it's
also very unfortunate).

I do intend to land this, to keep progress moving in a piecemeal way,
and potentially to allow collaboration. It might be that hero app PRs
should be largely rubber-stamped. Note that there are "real" changes to
Xilem in this PR (limited to new re-exports)
This commit is contained in:
Daniel McNab 2025-06-19 10:59:06 +01:00 committed by GitHub
parent c895401965
commit 9bafb2dacd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 200 additions and 50 deletions

View File

@ -17,3 +17,8 @@ doc-valid-idents = ["FizzBuzz", "MathML", "RustNL", ".."]
# like Box<dyn Fn(&mut Env, &T)>, which seems overly strict. The new value of 400 is
# a simple guess. It might be worth lowering this, or using the default, in the future.
type-complexity-threshold = 400
disallowed-types = [
# For Placehero
{ path = "megalodon::mastodon::Mastodon", reason = "In case of future changes, we want to keep all our assumptions about using Mastodon centralised", replacement = "placehero::Mastodon" },
]

1
Cargo.lock generated
View File

@ -2892,6 +2892,7 @@ name = "placehero"
version = "0.3.0"
dependencies = [
"megalodon",
"tracing",
"xilem",
]

View File

@ -9,6 +9,7 @@ publish = false
[dependencies]
megalodon = "1.0.0"
tracing.workspace = true
xilem = { version = "0.3.0", path = "../xilem" }
[lints]

View File

@ -11,20 +11,39 @@
use std::sync::Arc;
use megalodon::{Megalodon, entities::Instance};
use megalodon::{
Megalodon,
entities::{Account, Instance, Status},
mastodon,
megalodon::GetAccountStatusesInputOptions,
};
use xilem::{
EventLoopBuilder, WidgetView, WindowOptions, Xilem,
core::{
fork,
one_of::{Either, OneOf},
},
view::{flex, label, prose, task_raw},
EventLoopBuilder, ViewCtx, WidgetView, WindowOptions, Xilem,
core::{NoElement, View, fork, one_of::Either},
palette::css::{LIME, WHITE, YELLOW},
style::{Gradient, Style},
view::{GridExt, GridParams, flex, grid, label, portal, prose, sized_box, split, task_raw},
winit::error::EventLoopError,
};
/// Our shared API client type.
///
/// Megalodon suggests using `dyn Megaldon`, but specifying Mastodon here specifically
/// has the advantage that go-to-definition works.
///
/// We also do not plan to support non-Mastodon servers at the moment.
/// However, keeping this type definition means a greater chance of.
#[expect(
clippy::disallowed_types,
reason = "We want to allow using the type only through this path"
)]
type Mastodon = Arc<mastodon::Mastodon>;
struct Placehero {
megalodon: Arc<dyn Megalodon + Send + Sync + 'static>,
mastodon: Mastodon,
instance: Option<Instance>,
statuses: Vec<Status>,
account: Option<Account>,
}
impl Placehero {
@ -36,54 +55,178 @@ impl Placehero {
prose(instance.title.as_str()),
)))
} else {
OneOf::B(label("Not yet connected (or other unhandled error)"))
Either::B(prose("Not yet connected (or other unhandled error)"))
}
}
fn main_view(&mut self) -> impl WidgetView<Self> + use<> {
if self.statuses.is_empty() {
Either::A(prose("No statuses yet loaded"))
} else {
Either::B(portal(flex(
self.statuses.iter().map(status_view).collect::<Vec<_>>(),
)))
}
}
}
fn app_logic(app_state: &mut Placehero) -> impl WidgetView<Placehero> + use<> {
let megalodon = app_state.megalodon.clone();
fork(
app_state.sidebar(),
task_raw(
move |result| {
let megalodon = megalodon.clone();
async move {
// We choose not to handle the case where the event loop has ended
let instance_result = megalodon.get_instance().await;
// Note that error handling is deferred to the on_event handler
drop(result.message(instance_result));
}
},
|app_state: &mut Placehero, event| match event {
Ok(instance) => app_state.instance = Some(instance.json),
Err(megalodon::error::Error::RequestError(e)) if e.is_connect() => {
todo!()
}
Err(megalodon::error::Error::RequestError(e)) if e.is_status() => {
todo!()
}
Err(e) => {
todo!("handle {e}")
}
},
fn status_view(status: &Status) -> impl WidgetView<Placehero> + use<> {
sized_box(grid(
(
sized_box(label("Avatar"))
.background_gradient(
Gradient::new_linear(
// down-right
const { -45_f64.to_radians() },
)
.with_stops([YELLOW, LIME]),
)
.grid_pos(0, 0),
prose(status.account.display_name.as_str()).grid_pos(1, 0),
prose(status.account.username.as_str()).grid_pos(2, 0),
prose(status.content.as_str()).grid_item(GridParams::new(0, 1, 3, 1)),
prose(status.created_at.to_rfc2822()).grid_pos(0, 2),
prose(status.favourites_count.to_string()).grid_pos(1, 2),
prose(status.replies_count.to_string()).grid_pos(2, 2),
),
3,
3,
))
.expand_width()
.height(300.0)
.border(WHITE, 2.)
}
fn app_logic(app_state: &mut Placehero) -> impl WidgetView<Placehero> + use<> {
let map = app_state
.account
.as_ref()
.map(|it| it.id.clone())
.map(|id| load_statuses(app_state.mastodon.clone(), id));
fork(
split(app_state.sidebar(), app_state.main_view()),
(
load_instance(app_state.mastodon.clone()),
load_account(app_state.mastodon.clone()),
map,
),
)
}
fn load_instance(
mastodon: Mastodon,
) -> impl View<Placehero, (), ViewCtx, Element = NoElement> + use<> {
task_raw(
move |result| {
let mastodon = mastodon.clone();
async move {
// We choose not to handle the case where the event loop has ended
let instance_result = mastodon.get_instance().await;
// Note that error handling is deferred to the on_event handler
drop(result.message(instance_result));
}
},
|app_state: &mut Placehero, event| match event {
Ok(instance) => app_state.instance = Some(instance.json),
Err(megalodon::error::Error::RequestError(e)) if e.is_connect() => {
todo!()
}
Err(megalodon::error::Error::RequestError(e)) if e.is_status() => {
todo!()
}
Err(e) => {
todo!("handle {e}")
}
},
)
}
fn load_account(
mastodon: Mastodon,
) -> impl View<Placehero, (), ViewCtx, Element = NoElement> + use<> {
task_raw(
move |result| {
let mastodon = mastodon.clone();
async move {
// We choose not to handle the case where the event loop has ended
let instance_result = mastodon.lookup_account("raph".to_string()).await;
// Note that error handling is deferred to the on_event handler
drop(result.message(instance_result));
}
},
|app_state: &mut Placehero, event| match event {
Ok(instance) => app_state.account = Some(instance.json),
Err(megalodon::error::Error::RequestError(e)) if e.is_connect() => {
todo!()
}
Err(megalodon::error::Error::RequestError(e)) if e.is_status() => {
todo!()
}
Err(e) => {
todo!("handle {e}")
}
},
)
}
fn load_statuses(
mastodon: Mastodon,
id: String,
) -> impl View<Placehero, (), ViewCtx, Element = NoElement> + use<> {
task_raw(
move |result| {
let mastodon = mastodon.clone();
let id = id.clone();
async move {
// We choose not to handle the case where the event loop has ended
let instance_result = mastodon
.get_account_statuses(
id,
Some(&GetAccountStatusesInputOptions {
exclude_reblogs: Some(true),
exclude_replies: Some(true),
..Default::default()
}),
)
.await;
// Note that error handling is deferred to the on_event handler
drop(result.message(instance_result));
}
},
|app_state: &mut Placehero, event| match event {
Ok(instance) => app_state.statuses = instance.json,
Err(megalodon::error::Error::RequestError(e)) if e.is_connect() => {
todo!()
}
Err(megalodon::error::Error::RequestError(e)) if e.is_status() => {
todo!()
}
Err(e) => {
todo!("handle {e}")
}
},
)
}
/// Execute the app in the given winit event loop.
pub fn run(event_loop: EventLoopBuilder) -> Result<(), EventLoopError> {
let megalodon = megalodon::generator(
megalodon::SNS::Mastodon,
"https://mastodon.online".to_string(),
None,
Some("Placehero".into()),
)
// TODO: Better error handling
.unwrap();
let base_url = "https://mastodon.online".to_string();
// TODO: Determine what user agent we want to send.
// Currently we send "megalodon", as that is the default in the library.
let user_agent = None;
#[expect(
clippy::disallowed_types,
reason = "We are constructing a value of the type, which we will never directly use elsewhere"
)]
let mastodon =
mastodon::Mastodon::new(base_url, None, user_agent).expect("Provided User Agent is valid");
let app_state = Placehero {
megalodon: megalodon.into(),
mastodon: Arc::new(mastodon),
instance: None,
account: None,
statuses: Vec::new(),
};
Xilem::new_simple(

View File

@ -4,12 +4,13 @@
//! Traits used to set custom styles on views.
use masonry::core::Property;
use masonry::properties::types::Gradient;
use masonry::properties::{
use vello::peniko::Color;
pub use masonry::properties::types::{Gradient, GradientShape};
pub use masonry::properties::{
ActiveBackground, Background, BorderColor, BorderWidth, BoxShadow, CornerRadius,
DisabledBackground, HoveredBorderColor, Padding,
};
use vello::peniko::Color;
/// Trait implemented by views to signal that a given property can be set on them.
///

View File

@ -7,9 +7,7 @@ use crate::style::Style;
use masonry::core::{FromDynWidget, Widget, WidgetMut};
use masonry::properties::{Background, BorderColor, BorderWidth, CornerRadius, Padding};
use masonry::widgets::{
GridParams, {self},
};
use masonry::widgets;
use crate::core::{
AppendVec, DynMessage, ElementSplice, MessageResult, Mut, SuperElement, View, ViewElement,
@ -17,6 +15,7 @@ use crate::core::{
};
use crate::{Pod, PropertyTuple as _, ViewCtx, WidgetView};
pub use masonry::widgets::GridParams;
/// A Grid layout divides a window into regions and defines the relationship
/// between inner elements in terms of size and position.
///