From b82376a1c04332463a2d86f3058dc5be4452e55d Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 22 Jun 2026 10:59:17 -0400 Subject: [PATCH] PM-45: client appview session --- .zed/settings.json | 15 + Cargo.lock | 2 + Cargo.toml | 4 +- assets/styling/browse.css | 47 +- .../space_polymodel_actor_getSession.json | 74 ++- .../src/space_polymodel/actor/get_session.rs | 563 +++++++++++++++++- justfile | 14 +- lexicons/actor/getSession.json | 38 +- src/appview/actor.rs | 16 +- src/auth.rs | 150 +++++ src/client.rs | 305 ++++++++++ src/indexing/projection.rs | 6 +- src/main.rs | 252 +++++++- src/session.rs | 124 ++++ src/thing_card.rs | 4 + 15 files changed, 1539 insertions(+), 75 deletions(-) create mode 100644 .zed/settings.json create mode 100644 src/auth.rs create mode 100644 src/client.rs create mode 100644 src/session.rs diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..9f06db4 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,15 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "lsp": { + "rust-analyzer": { + "initialization_options": { + "cargo": { + "allFeatures": true, + }, + }, + }, + }, +} diff --git a/Cargo.lock b/Cargo.lock index 6cd0760..e2bb4c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6189,6 +6189,7 @@ dependencies = [ "dioxus", "dotenvy", "futures", + "http", "hydrant", "jacquard", "jacquard-axum", @@ -6197,6 +6198,7 @@ dependencies = [ "js-sys", "libsqlite3-sys", "polymodel-api", + "reqwest", "rustls", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index c215157..8b56a09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,8 @@ tracing-subscriber = { version = "0.3", optional = true, default-features = fals "fmt", "env-filter", ] } +http = "1.4" +reqwest = { version = "0.12", default-features = false, features = ["json", "charset"] } [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] @@ -99,7 +101,7 @@ js-sys = "0.3" tracing-wasm = "0.2" wasm-bindgen = "=0.2.121" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "console", "Response"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "BroadcastChannel", "console", "Response"] } [dev-dependencies] tokio = { version = "1", features = ["rt", "macros"] } diff --git a/assets/styling/browse.css b/assets/styling/browse.css index 711b0a0..a7a1c18 100644 --- a/assets/styling/browse.css +++ b/assets/styling/browse.css @@ -62,7 +62,37 @@ font-weight: 600; padding: 0.45rem 0; } - +.session-control { + display: inline-flex; + align-items: stretch; + gap: 0; + flex-wrap: nowrap; +} +.session-control input { + width: min(9.5rem, 42vw); + min-height: 2rem; + padding: 0.45rem 0.65rem; + border: 1px solid var(--color-border); + border-right: 0; + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-ui); + font-size: 0.9rem; +} +.session-control input:focus { + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + outline-offset: 1px; + border-color: var(--color-primary); +} +.session-control .button { + min-height: 2rem; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} +.session-control-authenticated { + justify-content: flex-end; + gap: 0.5rem; +} .foundation-hero { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); @@ -109,9 +139,18 @@ color: var(--color-primary); } -.blueprint-axis-x { right: 0.75rem; bottom: 0.75rem; } -.blueprint-axis-y { left: 0.75rem; top: 0.75rem; } -.blueprint-dimension { left: 0.75rem; bottom: 0.75rem; } +.blueprint-axis-x { + right: 0.75rem; + bottom: 0.75rem; +} +.blueprint-axis-y { + left: 0.75rem; + top: 0.75rem; +} +.blueprint-dimension { + left: 0.75rem; + bottom: 0.75rem; +} .browse-controls { display: grid; diff --git a/crates/polymodel-api/lexicons/space_polymodel_actor_getSession.json b/crates/polymodel-api/lexicons/space_polymodel_actor_getSession.json index 2561850..fad4b73 100644 --- a/crates/polymodel-api/lexicons/space_polymodel_actor_getSession.json +++ b/crates/polymodel-api/lexicons/space_polymodel_actor_getSession.json @@ -4,33 +4,63 @@ "defs": { "main": { "type": "query", - "description": "Identity primitive (PM-9). Returns the authenticated viewer's identity plus profile for SSR-first session seeding, or an unauthenticated marker. The profile field reuses the getProfile union shape so the client renders identity + profile from a single first-paint value. PM-26 returns only the unauthenticated marker ({authenticated: false}); the authenticated fields (did/handle/profile) are populated once PM-27 wires OAuth session extraction, without changing this output contract. Takes no parameters: identity is derived from the request session, not an actor argument.", + "description": "Identity primitive (PM-9). Returns either a logged-out marker or the authenticated viewer's full identity/profile for client session seeding. Takes no parameters: identity is derived from the request session, not an actor argument.", "output": { "encoding": "application/json", "schema": { - "type": "object", - "required": [ - "authenticated" - ], - "properties": { - "authenticated": { - "type": "boolean", - "description": "Always present. When false the response carries only this field (no identity/profile); when true, did/handle/profile are populated." - }, - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "profile": { - "type": "ref", - "ref": "space.polymodel.actor.defs#profileViewUnion" - } - } + "type": "ref", + "ref": "space.polymodel.actor.getSession#sessionView" } } + }, + "sessionAuthenticated": { + "type": "object", + "description": "Authenticated viewer identity for client session seeding. If authenticated is true, did, handle, and profile are all present.", + "required": [ + "authenticated", + "did", + "handle", + "profile" + ], + "properties": { + "authenticated": { + "type": "boolean", + "const": true + }, + "did": { + "type": "string", + "format": "did" + }, + "handle": { + "type": "string", + "format": "handle" + }, + "profile": { + "type": "ref", + "ref": "space.polymodel.actor.defs#profileView" + } + } + }, + "sessionUnauthenticated": { + "type": "object", + "description": "Logged-out session marker. No identity fields are present when authenticated is false.", + "required": [ + "authenticated" + ], + "properties": { + "authenticated": { + "type": "boolean", + "const": false + } + } + }, + "sessionView": { + "type": "union", + "refs": [ + "space.polymodel.actor.getSession#sessionUnauthenticated", + "space.polymodel.actor.getSession#sessionAuthenticated" + ], + "closed": true } } } \ No newline at end of file diff --git a/crates/polymodel-api/src/space_polymodel/actor/get_session.rs b/crates/polymodel-api/src/space_polymodel/actor/get_session.rs index 54ab947..5da25b2 100644 --- a/crates/polymodel-api/src/space_polymodel/actor/get_session.rs +++ b/crates/polymodel-api/src/space_polymodel/actor/get_session.rs @@ -10,29 +10,65 @@ use alloc::collections::BTreeMap; #[allow(unused_imports)] use core::marker::PhantomData; -use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; use jacquard_common::deps::smol_str::SmolStr; -use jacquard_common::types::string::Did; +use jacquard_common::types::string::{Did, Handle}; use jacquard_common::types::value::Data; use jacquard_derive::IntoStatic; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; use serde::{Serialize, Deserialize}; -use crate::space_polymodel::actor::ProfileViewUnion; +use crate::space_polymodel::actor::ProfileView; +use crate::space_polymodel::actor::get_session; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct GetSessionOutput { - ///Always present. When false the response carries only this field (no identity/profile); when true, did/handle/profile are populated. + #[serde(flatten)] + pub value: get_session::SessionView, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +/// Authenticated viewer identity for client session seeding. If authenticated is true, did, handle, and profile are all present. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct SessionAuthenticated { pub authenticated: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub did: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub handle: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile: Option>, + pub did: Did, + pub handle: Handle, + pub profile: ProfileView, #[serde(flatten, default, skip_serializing_if = "Option::is_none")] pub extra_data: Option>>, } +/// Logged-out session marker. No identity fields are present when authenticated is false. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct SessionUnauthenticated { + pub authenticated: bool, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(tag = "$type", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub enum SessionView { + #[serde(rename = "space.polymodel.actor.getSession#sessionUnauthenticated")] + SessionUnauthenticated(Box>), + #[serde(rename = "space.polymodel.actor.getSession#sessionAuthenticated")] + SessionAuthenticated(Box>), +} + /** Request marker for the `space.polymodel.actor.getSession` query. This endpoint has no request parameters or input body; send this marker with `jacquard::Client`.*/ @@ -65,4 +101,511 @@ impl jacquard_common::xrpc::XrpcEndpoint for GetSessionRequest { const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; type Request = GetSession; type Response = GetSessionResponse; +} + +impl LexiconSchema for SessionAuthenticated { + fn nsid() -> &'static str { + "space.polymodel.actor.getSession" + } + fn def_name() -> &'static str { + "sessionAuthenticated" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_space_polymodel_actor_getSession() + } + fn validate(&self) -> Result<(), ConstraintError> { + Ok(()) + } +} + +impl LexiconSchema for SessionUnauthenticated { + fn nsid() -> &'static str { + "space.polymodel.actor.getSession" + } + fn def_name() -> &'static str { + "sessionUnauthenticated" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_space_polymodel_actor_getSession() + } + fn validate(&self) -> Result<(), ConstraintError> { + Ok(()) + } +} + +pub mod session_authenticated_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Authenticated; + type Did; + type Handle; + type Profile; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Authenticated = Unset; + type Did = Unset; + type Handle = Unset; + type Profile = Unset; + } + ///State transition - sets the `authenticated` field to Set + pub struct SetAuthenticated(PhantomData St>); + impl sealed::Sealed for SetAuthenticated {} + impl State for SetAuthenticated { + type Authenticated = Set; + type Did = St::Did; + type Handle = St::Handle; + type Profile = St::Profile; + } + ///State transition - sets the `did` field to Set + pub struct SetDid(PhantomData St>); + impl sealed::Sealed for SetDid {} + impl State for SetDid { + type Authenticated = St::Authenticated; + type Did = Set; + type Handle = St::Handle; + type Profile = St::Profile; + } + ///State transition - sets the `handle` field to Set + pub struct SetHandle(PhantomData St>); + impl sealed::Sealed for SetHandle {} + impl State for SetHandle { + type Authenticated = St::Authenticated; + type Did = St::Did; + type Handle = Set; + type Profile = St::Profile; + } + ///State transition - sets the `profile` field to Set + pub struct SetProfile(PhantomData St>); + impl sealed::Sealed for SetProfile {} + impl State for SetProfile { + type Authenticated = St::Authenticated; + type Did = St::Did; + type Handle = St::Handle; + type Profile = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `authenticated` field + pub struct authenticated(()); + ///Marker type for the `did` field + pub struct did(()); + ///Marker type for the `handle` field + pub struct handle(()); + ///Marker type for the `profile` field + pub struct profile(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct SessionAuthenticatedBuilder< + St: session_authenticated_state::State, + S: BosStr = DefaultStr, +> { + _state: PhantomData St>, + _fields: (Option, Option>, Option>, Option>), + _type: PhantomData S>, +} + +impl SessionAuthenticated { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> SessionAuthenticatedBuilder< + session_authenticated_state::Empty, + DefaultStr, + > { + SessionAuthenticatedBuilder::new() + } +} + +impl SessionAuthenticated { + /// Create a new builder for this type + pub fn builder() -> SessionAuthenticatedBuilder< + session_authenticated_state::Empty, + S, + > { + SessionAuthenticatedBuilder::builder() + } +} + +impl SessionAuthenticatedBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + SessionAuthenticatedBuilder { + _state: PhantomData, + _fields: (None, None, None, None), + _type: PhantomData, + } + } +} + +impl SessionAuthenticatedBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + SessionAuthenticatedBuilder { + _state: PhantomData, + _fields: (None, None, None, None), + _type: PhantomData, + } + } +} + +impl SessionAuthenticatedBuilder +where + St: session_authenticated_state::State, + St::Authenticated: session_authenticated_state::IsUnset, +{ + /// Set the `authenticated` field (required) + pub fn authenticated( + mut self, + value: impl Into, + ) -> SessionAuthenticatedBuilder< + session_authenticated_state::SetAuthenticated, + S, + > { + self._fields.0 = Option::Some(value.into()); + SessionAuthenticatedBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SessionAuthenticatedBuilder +where + St: session_authenticated_state::State, + St::Did: session_authenticated_state::IsUnset, +{ + /// Set the `did` field (required) + pub fn did( + mut self, + value: impl Into>, + ) -> SessionAuthenticatedBuilder, S> { + self._fields.1 = Option::Some(value.into()); + SessionAuthenticatedBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SessionAuthenticatedBuilder +where + St: session_authenticated_state::State, + St::Handle: session_authenticated_state::IsUnset, +{ + /// Set the `handle` field (required) + pub fn handle( + mut self, + value: impl Into>, + ) -> SessionAuthenticatedBuilder, S> { + self._fields.2 = Option::Some(value.into()); + SessionAuthenticatedBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SessionAuthenticatedBuilder +where + St: session_authenticated_state::State, + St::Profile: session_authenticated_state::IsUnset, +{ + /// Set the `profile` field (required) + pub fn profile( + mut self, + value: impl Into>, + ) -> SessionAuthenticatedBuilder, S> { + self._fields.3 = Option::Some(value.into()); + SessionAuthenticatedBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SessionAuthenticatedBuilder +where + St: session_authenticated_state::State, + St::Authenticated: session_authenticated_state::IsSet, + St::Did: session_authenticated_state::IsSet, + St::Handle: session_authenticated_state::IsSet, + St::Profile: session_authenticated_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> SessionAuthenticated { + SessionAuthenticated { + authenticated: self._fields.0.unwrap(), + did: self._fields.1.unwrap(), + handle: self._fields.2.unwrap(), + profile: self._fields.3.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> SessionAuthenticated { + SessionAuthenticated { + authenticated: self._fields.0.unwrap(), + did: self._fields.1.unwrap(), + handle: self._fields.2.unwrap(), + profile: self._fields.3.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_space_polymodel_actor_getSession() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("space.polymodel.actor.getSession"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("main"), + LexUserType::XrpcQuery(LexXrpcQuery { + parameters: None, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("sessionAuthenticated"), + LexUserType::Object(LexObject { + description: Some( + CowStr::new_static( + "Authenticated viewer identity for client session seeding. If authenticated is true, did, handle, and profile are all present.", + ), + ), + required: Some( + vec![ + SmolStr::new_static("authenticated"), + SmolStr::new_static("did"), SmolStr::new_static("handle"), + SmolStr::new_static("profile") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("authenticated"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("did"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Did), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("handle"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Handle), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("profile"), + LexObjectProperty::Ref(LexRef { + r#ref: CowStr::new_static( + "space.polymodel.actor.defs#profileView", + ), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("sessionUnauthenticated"), + LexUserType::Object(LexObject { + description: Some( + CowStr::new_static( + "Logged-out session marker. No identity fields are present when authenticated is false.", + ), + ), + required: Some(vec![SmolStr::new_static("authenticated")]), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("authenticated"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("sessionView"), + LexUserType::Union(LexRefUnion { + refs: vec![ + CowStr::new_static("space.polymodel.actor.getSession#sessionUnauthenticated"), + CowStr::new_static("space.polymodel.actor.getSession#sessionAuthenticated") + ], + closed: Some(true), + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} + +pub mod session_unauthenticated_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Authenticated; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Authenticated = Unset; + } + ///State transition - sets the `authenticated` field to Set + pub struct SetAuthenticated(PhantomData St>); + impl sealed::Sealed for SetAuthenticated {} + impl State for SetAuthenticated { + type Authenticated = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `authenticated` field + pub struct authenticated(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct SessionUnauthenticatedBuilder< + St: session_unauthenticated_state::State, + S: BosStr = DefaultStr, +> { + _state: PhantomData St>, + _fields: (Option,), + _type: PhantomData S>, +} + +impl SessionUnauthenticated { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> SessionUnauthenticatedBuilder< + session_unauthenticated_state::Empty, + DefaultStr, + > { + SessionUnauthenticatedBuilder::new() + } +} + +impl SessionUnauthenticated { + /// Create a new builder for this type + pub fn builder() -> SessionUnauthenticatedBuilder< + session_unauthenticated_state::Empty, + S, + > { + SessionUnauthenticatedBuilder::builder() + } +} + +impl SessionUnauthenticatedBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + SessionUnauthenticatedBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl SessionUnauthenticatedBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + SessionUnauthenticatedBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl SessionUnauthenticatedBuilder +where + St: session_unauthenticated_state::State, + St::Authenticated: session_unauthenticated_state::IsUnset, +{ + /// Set the `authenticated` field (required) + pub fn authenticated( + mut self, + value: impl Into, + ) -> SessionUnauthenticatedBuilder< + session_unauthenticated_state::SetAuthenticated, + S, + > { + self._fields.0 = Option::Some(value.into()); + SessionUnauthenticatedBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SessionUnauthenticatedBuilder +where + St: session_unauthenticated_state::State, + St::Authenticated: session_unauthenticated_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> SessionUnauthenticated { + SessionUnauthenticated { + authenticated: self._fields.0.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> SessionUnauthenticated { + SessionUnauthenticated { + authenticated: self._fields.0.unwrap(), + extra_data: Some(extra_data), + } + } } \ No newline at end of file diff --git a/justfile b/justfile index 5425913..3827bec 100644 --- a/justfile +++ b/justfile @@ -2,15 +2,15 @@ set dotenv-load := false -# Format and apply safe automatic fixes. This is the default pre-review cleanup command. +# Format Rust sources. Run clippy/check separately; clippy --fix can corrupt RSX. fix: cargo fmt --all - cargo clippy --workspace --all-targets --fix --allow-dirty --allow-staged --allow-no-vcs -- -D warnings - cargo clippy -p polymodel --all-targets --features server --fix --allow-dirty --allow-staged --allow-no-vcs -- -D warnings - cargo clippy -p polymodel --target wasm32-unknown-unknown --features web --fix --allow-dirty --allow-staged --allow-no-vcs -- -D warnings - +# Run clippy without --fix; automatic clippy rewrites can corrupt Dioxus RSX. +lint: + cargo clippy --workspace --all-targets -- -D warnings + cargo clippy -p polymodel --all-targets --features server -- -D warnings + cargo clippy -p polymodel --target wasm32-unknown-unknown --features web -- -D warnings # Compile checks across the whole workspace plus the app's server/wasm targets. -# Note: the server check uses compile-time `query!` macros that validate against a # migrated SQLite database. Run `just migrate` first (or run `just sqlx-prepare`) # so the macros can resolve. check: @@ -39,7 +39,7 @@ test-server: cargo nextest run -p polymodel --features server # Run all local validation expected before review. -test-all: fix check test test-server +test-all: fix check lint test test-server # Run browser end-to-end tests. e2e: cd e2e && npm test diff --git a/lexicons/actor/getSession.json b/lexicons/actor/getSession.json index 61ce342..49b3ec1 100644 --- a/lexicons/actor/getSession.json +++ b/lexicons/actor/getSession.json @@ -2,21 +2,37 @@ "lexicon": 1, "id": "space.polymodel.actor.getSession", "defs": { + "sessionUnauthenticated": { + "type": "object", + "description": "Logged-out session marker. No identity fields are present when authenticated is false.", + "required": ["authenticated"], + "properties": { "authenticated": { "type": "boolean", "const": false } } + }, + "sessionAuthenticated": { + "type": "object", + "description": "Authenticated viewer identity for client session seeding. If authenticated is true, did, handle, and profile are all present.", + "required": ["authenticated", "did", "handle", "profile"], + "properties": { + "authenticated": { "type": "boolean", "const": true }, + "did": { "type": "string", "format": "did" }, + "handle": { "type": "string", "format": "handle" }, + "profile": { "type": "ref", "ref": "space.polymodel.actor.defs#profileView" } + } + }, + "sessionView": { + "type": "union", + "refs": [ + "space.polymodel.actor.getSession#sessionUnauthenticated", + "space.polymodel.actor.getSession#sessionAuthenticated" + ], + "closed": true + }, "main": { "type": "query", - "description": "Identity primitive (PM-9). Returns the authenticated viewer's identity plus profile for SSR-first session seeding, or an unauthenticated marker. The profile field reuses the getProfile union shape so the client renders identity + profile from a single first-paint value. PM-26 returns only the unauthenticated marker ({authenticated: false}); the authenticated fields (did/handle/profile) are populated once PM-27 wires OAuth session extraction, without changing this output contract. Takes no parameters: identity is derived from the request session, not an actor argument.", + "description": "Identity primitive (PM-9). Returns either a logged-out marker or the authenticated viewer's full identity/profile for client session seeding. Takes no parameters: identity is derived from the request session, not an actor argument.", "output": { "encoding": "application/json", - "schema": { - "type": "object", - "required": ["authenticated"], - "properties": { - "authenticated": { "type": "boolean", "description": "Always present. When false the response carries only this field (no identity/profile); when true, did/handle/profile are populated." }, - "did": { "type": "string", "format": "did" }, - "handle": { "type": "string" }, - "profile": { "type": "ref", "ref": "space.polymodel.actor.defs#profileViewUnion" } - } - } + "schema": { "type": "ref", "ref": "space.polymodel.actor.getSession#sessionView" } } } } diff --git a/src/appview/actor.rs b/src/appview/actor.rs index 3cc8607..02e251d 100644 --- a/src/appview/actor.rs +++ b/src/appview/actor.rs @@ -15,7 +15,7 @@ use polymodel_api::app_bsky::actor::get_profile::GetProfile as BskyGetProfile; use polymodel_api::space_polymodel::actor::{ ProfileViewUnion, get_profile::{GetProfileOutput, GetProfileRequest}, - get_session::{GetSessionOutput, GetSessionRequest}, + get_session::{GetSessionOutput, GetSessionRequest, SessionUnauthenticated, SessionView}, }; use super::error::{AppResult, internal}; @@ -82,14 +82,14 @@ async fn fetch_bsky_profile( pub(super) async fn get_session( State(_state): State>, ) -> AppResult> { - // PM-26 ships the read seam + output contract only. Authenticated state - // (did/handle/profile) is populated once PM-27 wires OAuth session extraction - // — the output shape does not change, only which fields are filled. + // PM-26 ships the unauthenticated arm only. PM-27 will return + // `SessionAuthenticated` once OAuth session extraction and profile writes are wired. + Ok(XrpcResponse(GetSessionOutput { - authenticated: false, - did: None, - handle: None, - profile: None, + value: SessionView::SessionUnauthenticated(Box::new(SessionUnauthenticated { + authenticated: false, + extra_data: None, + })), extra_data: None, })) } diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..3d77ff6 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,150 @@ +#[cfg(test)] +use crate::session::SessionIdentity; + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub const AUTH_BROADCAST_CHANNEL: &str = "polymodel-auth"; +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub const SIGN_OUT_MESSAGE: &str = "sign-out"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Toast { + pub id: u64, + pub message: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ToastState { + next_id: u64, + items: Vec, +} + +impl ToastState { + pub fn push(&mut self, message: impl Into) -> u64 { + let id = self.next_id; + self.next_id += 1; + self.items.push(Toast { + id, + message: message.into(), + }); + id + } + + pub fn dismiss(&mut self, id: u64) { + self.items.retain(|toast| toast.id != id); + } + + pub fn items(&self) -> &[Toast] { + &self.items + } +} + +#[cfg(test)] +pub fn handle_remote_sign_out(session: &mut SessionIdentity, toasts: &mut ToastState) { + *session = SessionIdentity::Anonymous; + toasts.push("Signed out in another tab."); +} + +pub fn login_url(identifier: &str) -> String { + let identifier = identifier.trim(); + if identifier.is_empty() { + "/oauth/login".to_string() + } else { + format!( + "/oauth/start?identifier={}", + encode_query_component(identifier) + ) + } +} + +pub fn logout_url() -> &'static str { + "/oauth/logout" +} + +fn encode_query_component(value: &str) -> String { + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + { + js_sys::encode_uri_component(value) + .as_string() + .unwrap_or_default() + } + + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] + { + value + .bytes() + .flat_map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + vec![byte as char] + } + _ => format!("%{byte:02X}").chars().collect(), + }) + .collect() + } +} + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub fn navigate_to(url: &str) { + if let Some(window) = web_sys::window() { + let _ = window.location().set_href(url); + } +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +pub fn navigate_to(_url: &str) {} + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub fn broadcast_sign_out() { + if let Ok(channel) = web_sys::BroadcastChannel::new(AUTH_BROADCAST_CHANNEL) { + let _ = channel.post_message(&wasm_bindgen::JsValue::from_str(SIGN_OUT_MESSAGE)); + } +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +pub fn broadcast_sign_out() {} + +#[cfg(test)] +mod tests { + use super::*; + use jacquard::types::string::Handle; + use jacquard_common::types::did::Did; + + #[test] + fn login_url_uses_oauth_login_when_identifier_empty() { + assert_eq!(login_url(" "), "/oauth/login"); + } + + #[test] + fn login_url_uses_oauth_start_with_encoded_identifier() { + assert_eq!( + login_url("alice model.example"), + "/oauth/start?identifier=alice%20model.example" + ); + } + + #[test] + fn remote_sign_out_clears_session_and_adds_toast() { + let mut session = SessionIdentity::Authenticated(crate::session::AuthenticatedIdentity { + did: Did::new_owned("did:plc:alice").unwrap(), + handle: Handle::new_owned("alice.test").unwrap(), + display_name: None, + }); + let mut toasts = ToastState::default(); + + handle_remote_sign_out(&mut session, &mut toasts); + + assert_eq!(session, SessionIdentity::Anonymous); + assert_eq!(toasts.items()[0].message, "Signed out in another tab."); + } + + #[test] + fn toast_state_dismisses_by_id() { + let mut toasts = ToastState::default(); + let first = toasts.push("one"); + let _second = toasts.push("two"); + + toasts.dismiss(first); + + assert_eq!(toasts.items().len(), 1); + assert_eq!(toasts.items()[0].message, "two"); + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..d699892 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,305 @@ +//! Same-origin Polymodel appview client for browser UI calls. +//! +//! Polymodel deliberately uses this thin client rather than Jacquard's +//! credential/session `BasicClient` path: appview reads should target this +//! server's `/xrpc/space.polymodel.*` routes and rely on browser-managed +//! cookies once PM-27 wires OAuth sessions. Callers send generated payload +//! request values, for example `client.send(GetSession).await?.into_output()`. +//! Identity resolution is delegated to Jacquard's resolver over the same +//! injected HTTP transport; this keeps Polymodel's appview client thin without +//! adopting a credential/session agent for read calls. + +use std::fmt; +use std::sync::{Arc, RwLock}; + +use jacquard::identity::JacquardResolver; +use jacquard::identity::resolver::{ + DidDocResponse, IdentityError, IdentityResolver, ResolverOptions, +}; +use jacquard_common::BosStr; +use jacquard_common::http_client::HttpClient; +use jacquard_common::types::did::Did; +use jacquard_common::types::string::Handle; +use jacquard_common::xrpc::{CallOptions, XrpcClient, XrpcExt, XrpcRequest, XrpcResponse}; +use jacquard_common::{deps::fluent_uri::Uri, error::XrpcResult}; + +const DEFAULT_NATIVE_BASE_URI: &str = "http://127.0.0.1:8080"; + +#[derive(Clone)] +pub struct PolymodelClient { + http: reqwest::Client, + resolver: JacquardResolver, + base_uri: Arc>>, + opts: Arc>, +} + +impl fmt::Debug for PolymodelClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PolymodelClient") + .field("base_uri", &self.current_base_uri().as_str()) + .finish_non_exhaustive() + } +} + +impl PolymodelClient { + pub fn new() -> Result { + Self::with_base_uri(default_base_uri()?) + } + + pub fn with_base_uri(base_uri: Uri) -> Result { + Self::with_http_and_base_uri(reqwest::Client::new(), base_uri) + } + + pub fn with_http_and_base_uri( + http: reqwest::Client, + base_uri: Uri, + ) -> Result { + let resolver = JacquardResolver::new(http.clone(), ResolverOptions::default()); + Ok(Self { + http, + resolver, + base_uri: Arc::new(RwLock::new(base_uri)), + opts: Arc::new(RwLock::new(CallOptions::default())), + }) + } + + pub fn parse_base_uri(value: &str) -> Result, ClientError> { + Uri::parse(value.to_string()).map_err(|_| ClientError::InvalidBaseUri(value.to_string())) + } + + pub fn current_base_uri(&self) -> Uri { + self.base_uri + .read() + .expect("Polymodel client base URI lock is not poisoned") + .clone() + } +} + +impl Default for PolymodelClient { + fn default() -> Self { + Self::new().expect("default Polymodel appview base URI is valid") + } +} + +impl HttpClient for PolymodelClient { + type Error = reqwest::Error; + + async fn send_http( + &self, + request: http::Request>, + ) -> Result>, Self::Error> { + self.http.send_http(request).await + } +} + +impl XrpcClient for PolymodelClient { + async fn base_uri(&self) -> Uri { + self.current_base_uri() + } + + async fn set_base_uri(&self, uri: Uri) { + *self + .base_uri + .write() + .expect("Polymodel client base URI lock is not poisoned") = uri; + } + + async fn opts(&self) -> CallOptions { + self.opts + .read() + .expect("Polymodel client XRPC options lock is not poisoned") + .clone() + } + + async fn set_opts(&self, opts: CallOptions) { + *self + .opts + .write() + .expect("Polymodel client XRPC options lock is not poisoned") = opts; + } + + #[cfg(not(target_arch = "wasm32"))] + async fn send(&self, request: R) -> XrpcResult> + where + R: XrpcRequest + Send + Sync + serde::Serialize, + ::Response: Send + Sync, + Self: Sync, + { + let base = self.base_uri().await; + let opts = self.opts().await; + let base_ref: Uri<&str> = base.borrow(); + self.xrpc(base_ref).with_options(opts).send(&request).await + } + + #[cfg(target_arch = "wasm32")] + async fn send(&self, request: R) -> XrpcResult> + where + R: XrpcRequest + Send + Sync + serde::Serialize, + ::Response: Send + Sync, + { + let base = self.base_uri().await; + let opts = self.opts().await; + let base_ref: Uri<&str> = base.borrow(); + self.xrpc(base_ref).with_options(opts).send(&request).await + } + + #[cfg(not(target_arch = "wasm32"))] + async fn send_with_opts(&self, request: R, opts: CallOptions) -> XrpcResult> + where + R: XrpcRequest + Send + Sync + serde::Serialize, + ::Response: Send + Sync, + Self: Sync, + { + let base = self.base_uri().await; + let base_ref: Uri<&str> = base.borrow(); + self.xrpc(base_ref).with_options(opts).send(&request).await + } + + #[cfg(target_arch = "wasm32")] + async fn send_with_opts(&self, request: R, opts: CallOptions) -> XrpcResult> + where + R: XrpcRequest + Send + Sync + serde::Serialize, + ::Response: Send + Sync, + { + let base = self.base_uri().await; + let base_ref: Uri<&str> = base.borrow(); + self.xrpc(base_ref).with_options(opts).send(&request).await + } +} + +impl IdentityResolver for PolymodelClient { + fn options(&self) -> &ResolverOptions { + self.resolver.options() + } + + #[cfg(not(target_arch = "wasm32"))] + async fn resolve_handle( + &self, + handle: &Handle, + ) -> Result + where + Self: Sync, + { + self.resolver.resolve_handle(handle).await + } + + #[cfg(target_arch = "wasm32")] + async fn resolve_handle( + &self, + handle: &Handle, + ) -> Result { + self.resolver.resolve_handle(handle).await + } + + #[cfg(not(target_arch = "wasm32"))] + async fn resolve_did_doc( + &self, + did: &Did, + ) -> Result + where + Self: Sync, + { + self.resolver.resolve_did_doc(did).await + } + + #[cfg(target_arch = "wasm32")] + async fn resolve_did_doc( + &self, + did: &Did, + ) -> Result { + self.resolver.resolve_did_doc(did).await + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ClientError { + #[error("invalid Polymodel appview base URI: {0}")] + InvalidBaseUri(String), +} + +fn default_base_uri() -> Result, ClientError> { + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + { + let origin = web_sys::window() + .and_then(|window| window.location().origin().ok()) + .filter(|origin| !origin.is_empty()) + .unwrap_or_else(|| DEFAULT_NATIVE_BASE_URI.to_string()); + PolymodelClient::parse_base_uri(&origin) + } + + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] + { + PolymodelClient::parse_base_uri(DEFAULT_NATIVE_BASE_URI) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use jacquard::ToSmolStr; + use jacquard_common::xrpc::{CallOptions, build_http_request}; + use polymodel_api::space_polymodel::actor::get_session::GetSession; + use polymodel_api::space_polymodel::library::get_feed::GetFeed; + + #[test] + fn stores_base_uri_without_rewriting_it() { + let client = PolymodelClient::with_base_uri( + PolymodelClient::parse_base_uri("https://example.test/app").unwrap(), + ) + .unwrap(); + + assert_eq!( + client.current_base_uri().as_str(), + "https://example.test/app" + ); + } + + #[test] + fn get_feed_payload_builds_same_origin_xrpc_query() { + let base = Uri::parse("https://example.test".to_string()).unwrap(); + let base_ref = Uri::parse(base.as_str()).unwrap(); + let request = GetFeed::new() + .algorithm(Some("recent".to_smolstr())) + .limit(24) + .build(); + let http = build_http_request(&base_ref, &request, &CallOptions::default()).unwrap(); + + assert_eq!(http.method(), http::Method::GET); + assert_eq!( + http.uri().to_string(), + "https://example.test/xrpc/space.polymodel.library.getFeed?algorithm=recent&limit=24" + ); + } + + #[test] + fn get_session_payload_builds_no_param_query() { + let base = Uri::parse("https://example.test".to_string()).unwrap(); + let base_ref = Uri::parse(base.as_str()).unwrap(); + let http = build_http_request(&base_ref, &GetSession, &CallOptions::default()).unwrap(); + + assert_eq!(http.method(), http::Method::GET); + assert_eq!( + http.uri().to_string(), + "https://example.test/xrpc/space.polymodel.actor.getSession" + ); + } + + #[test] + fn polymodel_client_exposes_jacquard_identity_resolver_options() { + let client = PolymodelClient::with_base_uri( + PolymodelClient::parse_base_uri("https://example.test").unwrap(), + ) + .unwrap(); + + let _: &ResolverOptions = client.options(); + } + + #[test] + fn client_error_formats_for_ui() { + let error = PolymodelClient::parse_base_uri("not a uri").unwrap_err(); + assert_eq!( + error.to_string(), + "invalid Polymodel appview base URI: not a uri" + ); + } +} diff --git a/src/indexing/projection.rs b/src/indexing/projection.rs index cc8cd97..490f6b3 100644 --- a/src/indexing/projection.rs +++ b/src/indexing/projection.rs @@ -886,7 +886,7 @@ mod tests { record: Some(json!({ "name": name, "summary": "a thing summary", - "tags": ["cube", "calibration"], + "license": "MIT", "tags": ["cube", "calibration"], "createdAt": "2024-01-15T12:00:00.000Z", "models": models.iter().map(|m| json!({"uri": m, "cid": "c"})).collect::>(), })), @@ -1236,7 +1236,7 @@ mod tests { action: "create".to_string(), record: Some(json!({ "name": "Calibration Cube", - "tags": ["cube"], + "license": "MIT", "tags": ["cube"], "createdAt": "2024-01-01T00:00:00.000Z", "models": [], })), @@ -1253,7 +1253,7 @@ mod tests { update.cid = Some("cid2".to_string()); update.record = Some(json!({ "name": "Friendly Sphere", - "tags": ["sphere"], + "license": "MIT", "tags": ["sphere"], "createdAt": "2024-01-02T00:00:00.000Z", "models": [], })); diff --git a/src/main.rs b/src/main.rs index 55481c8..392fd71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,12 @@ use dioxus::prelude::*; +use jacquard_common::xrpc::XrpcClient; +use polymodel_api::space_polymodel::{actor::get_session::GetSession, library}; +mod auth; +mod client; mod fonts; mod mesh; +mod session; mod thing_card; mod viewer; @@ -14,7 +19,10 @@ mod indexing; #[cfg(feature = "server")] mod appview; -use thing_card::{ThingCard, thing_card_fixtures}; +use auth::{ToastState, broadcast_sign_out, login_url, logout_url, navigate_to}; +use client::PolymodelClient; +use session::SessionIdentity; +use thing_card::ThingCard; use viewer::{ViewerPage, demo_model_for_route, demo_models}; const FAVICON: Asset = asset!("/assets/favicon.jpg"); @@ -73,6 +81,24 @@ enum Route { #[component] fn App() -> Element { + let client = use_context_provider(PolymodelClient::default); + let session = use_context_provider(|| Signal::new(SessionIdentity::Anonymous)); + let toasts = use_context_provider(|| Signal::new(ToastState::default())); + + let _session_seed = use_resource(move || { + let client = client.clone(); + let mut session = session; + async move { + match client.send(GetSession).await { + Ok(response) => match response.into_output() { + Ok(output) => session.set(SessionIdentity::from_get_session(output)), + Err(error) => tracing::warn!(%error, "failed to decode Polymodel session"), + }, + Err(error) => tracing::warn!(%error, "failed to fetch Polymodel session"), + } + } + }); + rsx! { document::Meta { name: "viewport", content: "width=device-width, initial-scale=1" } document::Title { "Polymodel" } @@ -84,13 +110,91 @@ fn App() -> Element { Stylesheet { href: POLYMODEL_CSS } Router:: {} + ToastShelf { toasts } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FeedAlgorithm { + Recent, + Hot, +} + +impl FeedAlgorithm { + fn as_str(self) -> &'static str { + match self { + Self::Recent => "recent", + Self::Hot => "hot", + } + } + + fn label(self) -> &'static str { + match self { + Self::Recent => "Recent", + Self::Hot => "Hot", + } } } +#[derive(Clone, Debug, PartialEq, Eq)] +enum BrowseFeedState { + Loading, + Error(String), + Empty, + Populated(Vec), +} + +fn browse_feed_state( + pending: bool, + result: Option<&Result, String>>, +) -> BrowseFeedState { + if pending && result.is_none() { + return BrowseFeedState::Loading; + } + + match result { + Some(Ok(things)) if things.is_empty() => BrowseFeedState::Empty, + Some(Ok(things)) => BrowseFeedState::Populated(things.clone()), + Some(Err(error)) => BrowseFeedState::Error(error.clone()), + None => BrowseFeedState::Loading, + } +} + +#[allow(clippy::useless_format)] #[component] fn Browse() -> Element { let viewer_model = demo_models()[0].id.to_string(); - let things = thing_card_fixtures(); + let client = use_context::(); + let session = use_context::>(); + let toasts = use_context::>(); + let selected_algorithm = use_signal(|| FeedAlgorithm::Recent); + + let mut feed = use_resource(move || { + let client = client.clone(); + let algorithm = *selected_algorithm.read(); + async move { + let request = library::get_feed::GetFeed::new() + .algorithm(Some(algorithm.as_str().into())) + .limit(24) + .build(); + let response = client + .send(request) + .await + .map_err(|error| format!("Feed request failed: {error}"))?; + let output = response + .into_output() + .map_err(|error| format!("Feed decode failed: {error}"))?; + Ok(output + .value + .items + .into_iter() + .map(|item| item.thing) + .collect::>()) + } + }); + + let feed_result = feed.read(); + let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref()); rsx! { main { class: "browse-page product-shell", @@ -108,6 +212,7 @@ fn Browse() -> Element { to: Route::Viewer { model_id: viewer_model }, "Open viewer" } + SessionControl { session, toasts } } } } @@ -121,16 +226,127 @@ fn Browse() -> Element { aria_label: "Search model projects" } } - div { class: "tab-row", role: "tablist", aria_label: "Feed placeholders", - button { class: "tab is-active", role: "tab", aria_selected: "true", "Recent" } - button { class: "tab", role: "tab", aria_selected: "false", "Hot" } - button { class: "tab", role: "tab", aria_selected: "false", "Following" } + div { class: "tab-row", role: "tablist", aria_label: "Feeds", + FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } + FeedTab { algorithm: FeedAlgorithm::Hot, selected_algorithm } + button { class: "tab", role: "tab", aria_selected: "false", disabled: true, "Following" } } } section { class: "browse-results-grid", aria_label: "Browse results", - for thing in things { - ThingCard { key: "{thing.uri}", thing } + match feed_state { + BrowseFeedState::Loading => rsx! { + for index in 0..6 { + div { key: "skeleton-{index}", class: "state-card loading-state", + span { class: "status-pill", "Loading" } + div { class: "skeleton-line skeleton-wide" } + div { class: "skeleton-line" } + div { class: "skeleton-box" } + } + } + }, + BrowseFeedState::Error(error) => { + let error_message = error.clone(); + rsx! { + div { class: "state-card error-state browse-state-card", + p { "{error_message}" } + button { + class: "button button-secondary", + onclick: move |_| feed.restart(), + "Retry" + } + } + } }, + BrowseFeedState::Empty => rsx! { + div { class: "state-card empty-state browse-state-card", + span { class: "status-pill status-muted", "Empty" } + h2 { "No projects yet" } + p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } + } + }, + BrowseFeedState::Populated(things) => rsx! { + for thing in things { + ThingCard { key: "{thing.uri}", thing } + } + }, + } + } + } + } +} + +#[component] +fn FeedTab(algorithm: FeedAlgorithm, selected_algorithm: Signal) -> Element { + let is_active = *selected_algorithm.read() == algorithm; + let class = if is_active { "tab is-active" } else { "tab" }; + + rsx! { + button { + class, + role: "tab", + aria_selected: "{is_active}", + onclick: move |_| selected_algorithm.set(algorithm), + "{algorithm.label()}" + } + } +} + +#[component] +fn SessionControl(session: Signal, toasts: Signal) -> Element { + let identity = session.read().clone(); + let mut identifier = use_signal(String::new); + + match identity { + SessionIdentity::Anonymous => rsx! { + form { + class: "session-control", + onsubmit: move |_| navigate_to(&login_url(&identifier.read())), + input { + r#type: "text", + placeholder: "handle.test", + aria_label: "ATProto handle", + value: "{identifier}", + oninput: move |event| identifier.set(event.value()) + } + button { class: "button button-primary", r#type: "submit", "Sign in" } + } + }, + SessionIdentity::Authenticated(identity) => { + let label = SessionIdentity::Authenticated(identity).label(); + rsx! { + div { class: "session-control session-control-authenticated", + span { class: "status-pill", "{label}" } + button { + class: "button button-secondary", + onclick: move |_| { + broadcast_sign_out(); + session.set(SessionIdentity::Anonymous); + toasts.write().push("Signing out…"); + navigate_to(logout_url()); + }, + "Sign out" + } + } + } + } + } +} + +#[component] +fn ToastShelf(toasts: Signal) -> Element { + let items = toasts.read().items().to_vec(); + + rsx! { + div { class: "toast-shelf", aria_live: "polite", + for toast in items { + div { key: "toast-{toast.id}", class: "toast blueprint-panel", + span { "{toast.message}" } + button { + class: "toast-dismiss", + aria_label: "Dismiss notification", + onclick: move |_| toasts.write().dismiss(toast.id), + "×" + } } } } @@ -268,7 +484,8 @@ fn FoundationCard(item: FoundationItem) -> Element { #[cfg(test)] mod tests { - use super::foundation_items; + use super::*; + use thing_card::thing_card_fixtures; #[test] fn foundation_items_cover_base_primitives() { @@ -288,4 +505,21 @@ mod tests { assert!(!combined.contains("Jira")); assert!(!combined.contains("Polytoken")); } + + #[test] + fn browse_state_distinguishes_loading_empty_error_and_populated() { + assert_eq!(browse_feed_state(true, None), BrowseFeedState::Loading); + assert_eq!( + browse_feed_state(false, Some(&Ok(vec![]))), + BrowseFeedState::Empty + ); + assert_eq!( + browse_feed_state(false, Some(&Err("boom".to_string()))), + BrowseFeedState::Error("boom".to_string()) + ); + match browse_feed_state(false, Some(&Ok(thing_card_fixtures()))) { + BrowseFeedState::Populated(things) => assert_eq!(things.len(), 3), + state => panic!("expected populated state, got {state:?}"), + } + } } diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..79d7abe --- /dev/null +++ b/src/session.rs @@ -0,0 +1,124 @@ +use jacquard_common::deps::smol_str::{SmolStr, ToSmolStr}; +use jacquard_common::types::string::{Did, Handle}; +use polymodel_api::space_polymodel::actor::ProfileView; +use polymodel_api::space_polymodel::actor::get_session::{GetSessionOutput, SessionView}; +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum SessionIdentity { + #[default] + Anonymous, + Authenticated(AuthenticatedIdentity), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthenticatedIdentity { + pub did: Did, + pub handle: Handle, + pub display_name: Option, +} + +impl SessionIdentity { + pub fn from_get_session(output: GetSessionOutput) -> Self { + match output.value { + SessionView::SessionUnauthenticated(_) => Self::Anonymous, + SessionView::SessionAuthenticated(session) => { + Self::Authenticated(AuthenticatedIdentity { + did: session.did, + handle: session.handle, + display_name: profile_display_name(&session.profile), + }) + } + } + } + + pub fn label(&self) -> SmolStr { + match self { + Self::Anonymous => SmolStr::new_static("Signed out"), + Self::Authenticated(identity) => identity + .display_name + .clone() + .unwrap_or_else(|| identity.handle.as_str().to_smolstr()), + } + } +} + +fn profile_display_name(profile: &ProfileView) -> Option { + profile + .display_name + .as_ref() + .map(|value| value.to_smolstr()) + .filter(|value| !value.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use jacquard_common::types::did::Did; + use polymodel_api::space_polymodel::actor::ProfileView; + use polymodel_api::space_polymodel::actor::get_session::{ + SessionAuthenticated, SessionUnauthenticated, + }; + #[test] + fn unauthenticated_get_session_maps_to_anonymous() { + let output = GetSessionOutput { + value: SessionView::SessionUnauthenticated(Box::new(SessionUnauthenticated { + authenticated: false, + extra_data: None, + })), + extra_data: None, + }; + + assert_eq!( + SessionIdentity::from_get_session(output), + SessionIdentity::Anonymous + ); + } + + #[test] + fn authenticated_get_session_maps_identity_label() { + let output = GetSessionOutput { + value: SessionView::SessionAuthenticated(Box::new(SessionAuthenticated { + did: Did::new_owned("did:plc:alice").unwrap(), + handle: Handle::new_owned("alice.test").unwrap(), + profile: ProfileView { + avatar: None, + default_license: None, + indexed_at: None, + description: None, + did: Did::new_owned("did:plc:alice").unwrap(), + display_name: Some("Alice Maker".into()), + handle: "alice.test".into(), + pronouns: None, + thing_count: None, + extra_data: None, + links: None, + printers: None, + record: jacquard::Data::Null, + total_likes: None, + }, + extra_data: None, + authenticated: true, + })), + extra_data: None, + }; + + let identity = SessionIdentity::from_get_session(output); + assert!(matches!(identity, SessionIdentity::Authenticated(_))); + assert_eq!(identity.label(), "Alice Maker"); + } + #[test] + fn get_session_json_decodes_unauthenticated_union_arm() { + let output: GetSessionOutput = serde_json::from_str(r#"{"$type":"space.polymodel.actor.getSession#sessionUnauthenticated","authenticated":false}"#).unwrap(); + assert_eq!( + SessionIdentity::from_get_session(output), + SessionIdentity::Anonymous + ); + } + #[test] + fn get_session_json_decodes_authenticated_union_arm() { + let output: GetSessionOutput = serde_json::from_str(r#"{"$type":"space.polymodel.actor.getSession#sessionAuthenticated","authenticated":true,"did":"did:plc:alice","handle":"alice.test","profile":{"avatar":null,"description":null,"did":"did:plc:alice","displayName":"Alice Maker","handle":"alice.test","record":null}}"#).unwrap(); + assert_eq!( + SessionIdentity::from_get_session(output).label(), + "Alice Maker" + ); + } +} diff --git a/src/thing_card.rs b/src/thing_card.rs index 6216be3..1ea3f52 100644 --- a/src/thing_card.rs +++ b/src/thing_card.rs @@ -1,10 +1,12 @@ use dioxus::prelude::*; +#[cfg(test)] use jacquard_common::types::{ string::{AtUri, Datetime, Did, Handle}, uri::UriValue, }; use polymodel_api::space_polymodel::library; +#[cfg(test)] const COMPLETE_PREVIEW_SVG: &str = "data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 480\"%3E%3Crect width=\"640\" height=\"480\" fill=\"%23f4efe4\"/%3E%3Cg stroke=\"%232a6f97\" stroke-width=\"2\" fill=\"none\"%3E%3Cpath d=\"M64 96h512v288H64zM160 144h320v192H160zM256 192h128v96H256z\"/%3E%3Cpath d=\"M64 384 160 336M576 384 480 336M160 144 64 96M480 144 576 96\"/%3E%3C/g%3E%3Cg fill=\"%2366767f\" font-family=\"monospace\" font-size=\"24\"%3E%3Ctext x=\"72\" y=\"438\"%3Eparametric enclosure preview%3C/text%3E%3C/g%3E%3C/svg%3E"; #[derive(Clone, Debug, PartialEq)] @@ -13,6 +15,7 @@ pub struct ThingCardMedia { pub alt: String, } +#[cfg(test)] pub fn thing_card_fixtures() -> Vec { vec![ library::ThingViewBasic { @@ -234,6 +237,7 @@ fn first_usable_image( }) } +#[cfg(test)] fn actor( did: &str, handle: &str, -- knotmirror 0.0.1