aper_yew/
lib.rs

1pub use aper_stateroom::{ClientId, IntentEvent, StateMachineContainerProgram, StateProgram};
2use aper_websocket_client::AperWebSocketStateProgramClient;
3use chrono::Duration;
4use gloo_storage::{SessionStorage, Storage};
5use init_tracing::init_tracing;
6use rand::distributions::Alphanumeric;
7use rand::Rng;
8use std::fmt::Debug;
9use std::marker::PhantomData;
10pub use update_interval::UpdateInterval;
11pub use view::{StateProgramViewComponent, StateProgramViewContext};
12use yew::{html, Component, Html, Properties};
13
14mod init_tracing;
15mod update_interval;
16mod view;
17
18const CONNECTION_TOKEN_KEY: &str = "CONNECTION_TOKEN";
19
20/// WebSocket URLs must be absolute, not relative, paths. For ergonomics, we
21/// allow a relative path and expand it.
22fn get_full_ws_url(path: &str) -> String {
23    let location = web_sys::window().unwrap().location();
24    let host = location.host().unwrap();
25    let path_prefix = location.pathname().unwrap();
26    let ws_protocol = match location.protocol().unwrap().as_str() {
27        "http:" => "ws",
28        "https:" => "wss",
29        scheme => panic!("Unknown scheme: {}", scheme),
30    };
31
32    format!("{}://{}{}{}", ws_protocol, &host, &path_prefix, &path)
33}
34
35/// Properties for [StateProgramComponent].
36#[derive(Properties, Clone)]
37pub struct StateProgramComponentProps<V: StateProgramViewComponent> {
38    /// The websocket URL (beginning ws:// or wss://) of the server to connect to.
39    pub websocket_url: String,
40    pub _ph: PhantomData<V>,
41}
42
43impl<V: StateProgramViewComponent> StateProgramComponentProps<V> {
44    pub fn new(websocket_url: &str) -> Self {
45        init_tracing();
46
47        StateProgramComponentProps {
48            websocket_url: get_full_ws_url(websocket_url),
49            _ph: PhantomData,
50        }
51    }
52}
53
54impl<V: StateProgramViewComponent> PartialEq for StateProgramComponentProps<V> {
55    fn eq(&self, other: &Self) -> bool {
56        self.websocket_url == other.websocket_url
57    }
58}
59
60/// Represents a message this component could receive, either from the server or from
61/// an event triggered by the user.
62#[derive(Debug)]
63pub enum Msg<State: StateProgram> {
64    StateTransition(State::T),
65    SetState(State, Duration, u32),
66    Redraw,
67}
68
69struct InnerState<P: StateProgram> {
70    state: P,
71    offset: Duration,
72    client_id: u32,
73}
74
75/// Yew Component which owns a copy of the state as well as a connection to the server,
76/// and keeps its local copy of the state in sync with the server.
77pub struct StateProgramComponent<V: StateProgramViewComponent> {
78    /// Websocket connection to the server.
79    client: Option<AperWebSocketStateProgramClient<V::Program>>,
80    state: Option<InnerState<V::Program>>,
81    _ph: PhantomData<V>,
82}
83
84impl<V: StateProgramViewComponent> StateProgramComponent<V> {
85    /// Initiate a connection to the remote server.
86    fn do_connect(&mut self, context: &yew::Context<Self>) {
87        let link = context.link().clone();
88
89        let token = if let Ok(token) = SessionStorage::get::<String>(CONNECTION_TOKEN_KEY) {
90            token
91        } else {
92            let token: String = rand::thread_rng()
93                .sample_iter(&Alphanumeric)
94                .take(24)
95                .map(char::from)
96                .collect();
97
98            SessionStorage::set(CONNECTION_TOKEN_KEY, &token).expect("Couldn't set session state.");
99            token
100        };
101
102        let url = format!("{}?token={}", context.props().websocket_url, token);
103
104        let client = AperWebSocketStateProgramClient::new(&url, move |state, client_id| {
105            // TODO!
106            let offset = Duration::zero();
107
108            link.send_message(Msg::SetState(state, offset, client_id));
109        })
110        .unwrap();
111        self.client = Some(client);
112    }
113}
114
115impl<V: StateProgramViewComponent> Component for StateProgramComponent<V> {
116    type Message = Msg<V::Program>;
117    type Properties = StateProgramComponentProps<V>;
118
119    /// On creation, we initialize the connection, which starts the process of
120    /// obtaining a copy of the server's current state.
121    fn create(context: &yew::Context<Self>) -> Self {
122        let mut result = Self {
123            client: None,
124            state: None,
125            _ph: PhantomData,
126        };
127
128        result.do_connect(context);
129
130        result
131    }
132
133    fn update(&mut self, _: &yew::Context<Self>, msg: Self::Message) -> bool {
134        match msg {
135            Msg::StateTransition(intent) => {
136                self.client.as_mut().unwrap().push_intent(intent).unwrap();
137                false
138            }
139            Msg::SetState(state, offset, client_id) => {
140                self.state = Some(InnerState {
141                    state,
142                    offset,
143                    client_id,
144                });
145                true
146            }
147            Msg::Redraw => true,
148        }
149    }
150
151    fn view(&self, context: &yew::Context<Self>) -> Html {
152        if let Some(inner_state) = &self.state {
153            let InnerState {
154                state,
155                offset,
156                client_id,
157            } = inner_state;
158
159            let context = StateProgramViewContext {
160                callback: context.link().callback(Msg::StateTransition),
161                redraw: context.link().callback(|_| Msg::Redraw),
162                client_id: *client_id,
163                offset: *offset,
164            };
165
166            V::view(state, context)
167        } else {
168            html! {{"Waiting for initial state."}}
169        }
170    }
171}