diff --git a/Cargo.toml b/Cargo.toml index 6fbe11318..fd4ae9e69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ members = [ "examples/node_refs", "examples/npm_and_rest", "examples/pub_sub", + "examples/store", "examples/textarea", "examples/timer", "examples/todomvc", diff --git a/examples/store/Cargo.toml b/examples/store/Cargo.toml new file mode 100644 index 000000000..26dde178f --- /dev/null +++ b/examples/store/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "store" +version = "0.1.0" +authors = ["MichaƂ Kawalec "] +edition = "2018" + +[dependencies] +wasm-bindgen = { version = "^0.2", features = ["serde-serialize"] } +wasm-bindgen-futures = "0.4" +yew = { path = "../../yew" } +yewtil = { path = "../../yewtil" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["raw_value"] } + +[dependencies.web-sys] +version = "0.3" +features = [ + "Window", + "Navigator", + "MediaDevices", + "MediaStreamConstraints" +] diff --git a/examples/store/src/agents/media_manager.rs b/examples/store/src/agents/media_manager.rs new file mode 100644 index 000000000..7b738ea17 --- /dev/null +++ b/examples/store/src/agents/media_manager.rs @@ -0,0 +1,109 @@ +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::{spawn_local, JsFuture}; +use web_sys::{console, window, MediaDevices, MediaStreamConstraints}; +use yew::agent::AgentLink; +use yewtil::store::{Store, StoreWrapper}; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub enum Request { + GetStream, + GetDevices, +} + +#[derive(Debug)] +pub enum Action { + SetStream(JsValue), + SetStreamError(JsValue), + SetDevices(Vec), +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct DeviceId(pub String); + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct InputDeviceInfo { + device_id: DeviceId, + group_id: String, + kind: String, + label: String, +} + +pub struct MediaManager { + pub known_devices: Vec, + pub media_stream: Option, + pub media_devices: MediaDevices, + pub set_stream_error: Option, +} + +impl Store for MediaManager { + type Action = Action; + type Input = Request; + + fn new() -> Self { + let window = window().unwrap(); + let navigator = window.navigator(); + let media_devices = navigator.media_devices().unwrap(); + + MediaManager { + known_devices: Vec::new(), + media_stream: None, + media_devices, + set_stream_error: None, + } + } + + fn handle_input(&self, link: AgentLink>, msg: Self::Input) { + match msg { + Request::GetStream => { + console::log_1(&"Continuing handling getstream".into()); + let mut media_constraints = MediaStreamConstraints::new(); + media_constraints + .audio(&JsValue::TRUE) + .video(&JsValue::TRUE); + + let media_promise = MediaDevices::get_user_media_with_constraints( + &self.media_devices, + &media_constraints, + ) + .unwrap(); + + spawn_local(async move { + match JsFuture::from(media_promise).await { + Ok(media) => link.send_message(Action::SetStream(media)), + Err(e) => link.send_message(Action::SetStreamError(e)), + } + }); + } + Request::GetDevices => { + let devices_promise = MediaDevices::enumerate_devices(&self.media_devices).unwrap(); + + spawn_local(async move { + let devices = JsFuture::from(devices_promise) + .await + .unwrap() + .into_serde::>() + .unwrap(); + + link.send_message(Action::SetDevices(devices)); + }); + } + } + } + + fn reduce(&mut self, msg: Self::Action) { + match msg { + Action::SetStream(stream) => { + self.media_stream = Some(stream); + } + Action::SetStreamError(error) => { + self.set_stream_error = Some(error); + } + Action::SetDevices(devices) => { + self.known_devices = devices; + } + } + } +} diff --git a/examples/store/src/agents/mod.rs b/examples/store/src/agents/mod.rs new file mode 100644 index 000000000..0a40870f5 --- /dev/null +++ b/examples/store/src/agents/mod.rs @@ -0,0 +1 @@ +pub mod media_manager; diff --git a/examples/store/src/lib.rs b/examples/store/src/lib.rs new file mode 100644 index 000000000..1329e8678 --- /dev/null +++ b/examples/store/src/lib.rs @@ -0,0 +1,69 @@ +mod agents; + +use yew::prelude::*; +//use yew::agent::{Bridgeable, Dispatcher, Dispatched, ReadOnly, StoreWrapper}; +use web_sys::console; +use yewtil::store::{Bridgeable, ReadOnly, StoreWrapper}; + +use crate::agents::media_manager::{MediaManager, Request}; + +pub struct App { + link: ComponentLink, + media_manager: Box>>, +} + +pub enum Msg { + GetStream, + GetDevices, + MediaManagerMsg(ReadOnly), +} + +impl Component for App { + type Message = Msg; + type Properties = (); + + fn create(_: Self::Properties, link: ComponentLink) -> Self { + let callback = link.callback(Msg::MediaManagerMsg); + let media_manager = MediaManager::bridge(callback); + Self { + link, + media_manager, + } + } + + fn update(&mut self, msg: Self::Message) -> ShouldRender { + match msg { + Msg::GetStream => { + self.media_manager.send(Request::GetStream); + console::log_1(&"after send".into()); + } + Msg::GetDevices => self.media_manager.send(Request::GetDevices), + Msg::MediaManagerMsg(state) => { + if let Some(stream) = &state.borrow().media_stream { + console::log_2(&"We have a stream".into(), &stream); + } + + console::log_1(&"Received update".into()); + } + } + false + } + + fn change(&mut self, _props: Self::Properties) -> ShouldRender { + false + } + + fn view(&self) -> Html { + html! { +
+ + + +
+ } + } +} diff --git a/examples/store/src/main.rs b/examples/store/src/main.rs new file mode 100644 index 000000000..3d8c297de --- /dev/null +++ b/examples/store/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + yew::start_app::(); +} diff --git a/yewtil/Cargo.toml b/yewtil/Cargo.toml index 8231283e1..0f143fce5 100644 --- a/yewtil/Cargo.toml +++ b/yewtil/Cargo.toml @@ -14,7 +14,7 @@ all = ["stable", "experimental"] # Broad features ## All features MUST be stable or experimental -stable = ["neq", "pure", "history", "mrc_irc", "effect", "future"] +stable = ["neq", "pure", "history", "mrc_irc", "effect", "future", "store"] experimental = ["dsl", "lrc", "with_callback", "fetch"] # Some pointers are stable, some experimental. @@ -30,6 +30,7 @@ dsl = [] effect = [] fetch = ["serde", "serde_json", "neq", "future", "web-sys"] future = ["wasm-bindgen-futures", "wasm-bindgen", "futures"] +store = [] # Ptr features lrc = [] diff --git a/yewtil/src/lib.rs b/yewtil/src/lib.rs index cf5b26795..f2bd0b8d9 100644 --- a/yewtil/src/lib.rs +++ b/yewtil/src/lib.rs @@ -9,6 +9,7 @@ //! * "mrc_irc" - Ergonomic Rc pointers. //! * "lrc" - Linked-list Rc pointer. //! * "history" - History tracker +//! * "store" - Global state with easy binding // //! * "dsl" - Use functions instead of Yew's `html!` macro. //#[cfg(feature = "dsl")] @@ -48,3 +49,6 @@ pub use effect::{effect, Effect}; #[cfg(feature = "future")] pub mod future; + +#[cfg(feature = "store")] +pub mod store; diff --git a/yewtil/src/store.rs b/yewtil/src/store.rs new file mode 100644 index 000000000..543222b41 --- /dev/null +++ b/yewtil/src/store.rs @@ -0,0 +1,152 @@ +use std::cell::RefCell; +use std::collections::HashSet; +use std::ops::Deref; +use std::rc::Rc; +use yew::agent::{Agent, AgentLink, Context, Discoverer, Dispatcher, HandlerId}; +use yew::prelude::*; + +/// A functional state wrapper, enforcing a unidirectional +/// data flow and consistent state to the observers. +/// +/// `handle_input` receives incoming messages from components, +/// `reduce` applies changes to the state +/// +/// Once created with a first bridge, a Store will never be destroyed +/// for the lifetime of the application. +pub trait Store: Sized + 'static { + /// Messages instructing the store to do somethin + type Input; + /// State updates to be consumed by `reduce` + type Action; + + /// Create a new Store + fn new() -> Self; + + /// Receives messages from components and other agents. Use the `link` + /// to send actions to itself in order to notify `reduce` once your + /// operation completes. This is the place to do side effects, like + /// talking to the server, or asking the user for input. + /// + /// Note that you can look at the state of your Store, but you + /// cannot modify it here. If you want to modify it, send a Message + /// to the reducer + fn handle_input(&self, link: AgentLink>, msg: Self::Input); + + /// A pure function, with no side effects. Receives a message, + /// and applies it to the state as it sees fit. + fn reduce(&mut self, msg: Self::Action); +} + +/// Hides the full context Agent from a Store and does +/// the boring data wrangling logic +#[derive(Debug)] +pub struct StoreWrapper { + /// Currently subscibed components and agents + pub handlers: HashSet, + /// Link to itself so Store::handle_input can send actions to reducer + pub link: AgentLink, + + /// The actual Store + pub state: Shared, + + /// A circular dispatcher to itself so the store is not removed + pub self_dispatcher: Dispatcher, +} + +type Shared = Rc>; + +/// A wrapper ensuring state observers can only +/// borrow the state immutably +#[derive(Debug)] +pub struct ReadOnly { + state: Shared, +} + +impl ReadOnly { + /// Allow only immutable borrows to the underlying data + pub fn borrow<'a>(&'a self) -> impl Deref + 'a { + self.state.borrow() + } +} + +/// This is a wrapper, intended to be used as an opaque +/// machinery allowing the Store to do it's things. +impl Agent for StoreWrapper { + type Reach = Context; + type Message = S::Action; + type Input = S::Input; + type Output = ReadOnly; + + fn create(link: AgentLink) -> Self { + let state = Rc::new(RefCell::new(S::new())); + let handlers = HashSet::new(); + + // Link to self to never go out of scope + let self_dispatcher = Self::dispatcher(); + + StoreWrapper { + handlers, + state, + link, + self_dispatcher, + } + } + + fn update(&mut self, msg: Self::Message) { + { + self.state.borrow_mut().reduce(msg); + } + + for handler in self.handlers.iter() { + self.link.respond( + *handler, + ReadOnly { + state: self.state.clone(), + }, + ); + } + } + + fn handle_input(&mut self, msg: Self::Input, _id: HandlerId) { + self.state.borrow().handle_input(self.link.clone(), msg); + } + + fn connected(&mut self, id: HandlerId) { + self.handlers.insert(id); + } + + fn disconnected(&mut self, id: HandlerId) { + self.handlers.remove(&id); + } +} + +// This instance is quite unfortunate, as the Rust compiler +// does not support mutually exclusive trait bounds (https://github.com/rust-lang/rust/issues/51774), +// we have to create a new trait with the same function as in the original one. + +/// Allows us to communicate with a store +pub trait Bridgeable: Sized + 'static { + /// A wrapper for the store we want to bridge to, + /// which serves as a communication intermediary + type Wrapper: Agent; + + /// Creates a messaging bridge between a worker and the component. + fn bridge( + callback: Callback<::Output>, + ) -> Box>; +} + +/// Implementation of bridge creation +impl Bridgeable for T +where + T: Store, +{ + /// The hiding wrapper + type Wrapper = StoreWrapper; + + fn bridge( + callback: Callback<::Output>, + ) -> Box> { + ::Reach::spawn_or_join(Some(callback)) + } +}