diff --git a/Cargo.lock b/Cargo.lock index 2b83679bc7..1940d0d24c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1038,6 +1038,47 @@ dependencies = [ "typenum", ] +[[package]] +name = "cursive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5438eb16bdd8af51b31e74764fef5d0a9260227a5ec82ba75c9d11ce46595839" +dependencies = [ + "ahash 0.8.3", + "cfg-if", + "crossbeam-channel", + "cursive_core", + "lazy_static", + "libc", + "log", + "maplit", + "ncurses", + "signal-hook", + "term_size", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "cursive_core" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4db3b58161228d0dcb45c7968c5e74c3f03ad39e8983e58ad7d57061aa2cd94d" +dependencies = [ + "ahash 0.8.3", + "crossbeam-channel", + "enum-map", + "enumset", + "lazy_static", + "log", + "num", + "owning_ref", + "time 0.3.22", + "unicode-segmentation", + "unicode-width", + "xi-unicode", +] + [[package]] name = "custom_debug" version = "0.5.1" @@ -1253,6 +1294,26 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "enum-map" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9705d8de4776df900a4a0b2384f8b0ab42f775e93b083b42f8ce71bdc32a47e3" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccb14d927583dd5c2eac0f2cf264fc4762aefe1ae14c47a8a20fc1939d3a5fc0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.22", +] + [[package]] name = "enum-ordinalize" version = "3.1.13" @@ -2206,6 +2267,12 @@ dependencies = [ "libc", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "match_cfg" version = "0.1.0" @@ -2349,6 +2416,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ncurses" +version = "5.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5d34d72657dc4b638a1c25d40aae81e4f1c699062f72f467237920752032" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "nextest-workspace-hack" version = "0.1.0" @@ -2396,6 +2474,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af" +dependencies = [ + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.3" @@ -2407,6 +2498,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0d21255c828d6f128a1e41534206671e8c3ea0c62f32291e808dc82cff17d" +dependencies = [ + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -2417,6 +2517,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.15" @@ -2437,6 +2559,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.30.4" @@ -2552,6 +2683,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owning_ref" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff55baddef9e4ad00f88b6c743a2a8062d4c6ade126c2a528644b8e444d52ce" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "owo-colors" version = "3.5.0" @@ -3791,6 +3931,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -4161,6 +4311,7 @@ dependencies = [ "anyhow", "anymap", "base64 0.21.2", + "cursive", "futures", "futures-channel", "hex", @@ -4510,6 +4661,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "term_size" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "termcolor" version = "1.2.0" @@ -4582,6 +4743,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea9e1b3cf1243ae005d9e74085d4d542f3125458f3a81af210d901dcd7411efd" dependencies = [ "itoa", + "libc", + "num_threads", "serde", "time-core", "time-macros", @@ -5857,6 +6020,12 @@ dependencies = [ "tap", ] +[[package]] +name = "xi-unicode" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a67300977d3dc3f8034dae89778f502b6ba20b269527b3223ba59c0cf393bb8a" + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index cc947486b2..2cb10f8051 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -28,3 +28,7 @@ lazy_static = "1.4" [dev-dependencies] # for quickstart-chat example hex = "0.4" +# for cursive-chat example +cursive = "0.20" +futures-channel = "0.3" + diff --git a/crates/sdk/examples/cursive-chat/.gitignore b/crates/sdk/examples/cursive-chat/.gitignore new file mode 100644 index 0000000000..54466f5b09 --- /dev/null +++ b/crates/sdk/examples/cursive-chat/.gitignore @@ -0,0 +1,2 @@ +/target + diff --git a/crates/sdk/examples/cursive-chat/README.md b/crates/sdk/examples/cursive-chat/README.md new file mode 100644 index 0000000000..066bcf05fa --- /dev/null +++ b/crates/sdk/examples/cursive-chat/README.md @@ -0,0 +1,5 @@ +# `cursive-chat` + +A terminal user interface (TUI) client for [the `quickstart-chat` module](/modules/quickstart-chat) using [Cursive](https://github.com/gyscos/cursive). + +This is an extension to [the `quickstart-chat` client](/crates/sdk/examples/quickstart-chat). diff --git a/crates/sdk/examples/cursive-chat/main.rs b/crates/sdk/examples/cursive-chat/main.rs new file mode 100644 index 0000000000..7c0e214f40 --- /dev/null +++ b/crates/sdk/examples/cursive-chat/main.rs @@ -0,0 +1,605 @@ +mod module_bindings; +use module_bindings::*; + +use spacetimedb_sdk::{ + identity::{identity, load_credentials, once_on_connect, save_credentials, Credentials, Identity}, + on_subscription_applied, + reducer::Status, + subscribe, + table::{TableType, TableWithPrimaryKey}, +}; + +use cursive::{ + traits::*, + views::{Dialog, EditView, LinearLayout, PaddedView, ScrollView, TextView}, + Cursive, CursiveRunnable, CursiveRunner, +}; +use futures_channel::mpsc; + +// # Our main function + +fn main() { + // We'll pre-process database events in callbacks, + // then send `UiMessage` events over a channel + // to the `user_input_loop`. + let (ui_send, ui_recv) = mpsc::unbounded(); + + // Each of our callbacks will need a handle on the `UiMessage` channel. + register_callbacks(ui_send); + + // Connecting and subscribing are unchanged relative to the quickstart client. + connect_to_db(); + subscribe_to_tables(); + + // We'll build a Cursive TUI, + let ui = make_ui(); + // then run it manually in our own loop, + // rather than using the Cursive event loop, + // so that we can push events into it. + user_input_loop(ui, ui_recv); +} + +enum UiMessage { + /// A remote user has connected to the server, + /// so add them to the online users view. + UserConnected { + /// Used as a Cursive element name, + /// to identify views which refer to this user, + /// i.e. their online status and their messages. + identity: Identity, + /// Displayed in the online status view. + name: String, + }, + /// A remote user has disconnected from the server, + /// so remove them from the online users view. + UserDisconnected { + /// Used to locate the user's entry in the online status view. + identity: Identity, + }, + /// We have successfully set our own name, + /// so update our past messages and the name in the input bar. + SetOwnName { name: String }, + /// A remote user has set their name, + /// so update their past messages and their online status + /// to use the new name. + SetName { + /// Used to locate the user's entry in the online status view + /// and their past messages. + identity: Identity, + /// Will be placed in the user's online status view + /// and past messages. + new_name: String, + }, + /// Someone sent a new message, + /// so add it to the messages view. + Message { + /// Will be displayed as the sender. + sender_name: String, + /// Used as a Cursive element name to identify the user, + /// so that we can update the sender name if they change their name. + sender_identity: Identity, + /// The text of the message. + text: String, + }, + /// We sent a message that was rejected by the server, + /// so display a pop-up dialog. + MessageRejected { + /// The text of the rejected message, to be included in the dialog. + rejected_message: String, + /// The server error message, to be included in the dialog. + reason: String, + }, + /// We tried to set our name but were rejected by the server, + /// so display a pop-up dialog and reset our name in the input bar. + NameRejected { + /// The current name, to be placed in the input bar. + current_name: String, + /// The rejected name, to be included in the dialog. + rejected_name: String, + /// The server error message, to be included in the dialog. + reason: String, + }, +} + +type UiSend = mpsc::UnboundedSender; +type UiRecv = mpsc::UnboundedReceiver; + +// # Register callbacks + +/// Register all the callbacks our app will use to respond to database events. +fn register_callbacks(send: UiSend) { + // When we receive our `Credentials`, save them to a file. + once_on_connect(on_connected); + + // When a new user joins, print a notification. + User::on_insert(on_user_inserted(send.clone())); + + // When a user's status changes, print a notification. + User::on_update(on_user_updated(send.clone())); + + // When a new message is received, print it. + Message::on_insert(on_message_inserted(send.clone())); + + // When we receive the message backlog, print it in timestamp order. + on_subscription_applied(on_sub_applied(send.clone())); + + // When we fail to set our name, print a warning. + on_set_name(on_name_set(send.clone())); + + // When we fail to send a message, print a warning. + on_send_message(on_message_sent(send.clone())); +} + +// ## Save credentials to a file + +/// Our `on_connect` callback: save our credentials to a file. +fn on_connected(creds: &Credentials) { + if let Err(e) = save_credentials(CREDS_DIR, creds) { + eprintln!("Failed to save credentials: {:?}", e); + } +} + +const CREDS_DIR: &str = ".spacetime_chat"; + +// ## Notify about new users + +/// Our `User::on_insert` callback: if the user is online, print a notification. +fn on_user_inserted(send: UiSend) -> impl FnMut(&User, Option<&ReducerEvent>) + Send + 'static { + move |user, _| { + if user.identity == identity().unwrap() { + send.unbounded_send(UiMessage::SetOwnName { + name: user_name_or_identity(user), + }) + .unwrap(); + } else if user.online { + send.unbounded_send(UiMessage::UserConnected { + identity: user.identity.clone(), + name: user_name_or_identity(user), + }) + .unwrap(); + } + } +} + +fn user_name_or_identity(user: &User) -> String { + user.name + .clone() + .unwrap_or_else(|| identity_leading_hex(&user.identity)) +} + +/// A 16-digit hexadecimal identifier for users who haven't set a name yet. +fn identity_leading_hex(id: &Identity) -> String { + hex::encode(&id.bytes()[0..8]) +} + +// ## Notify about updated users + +/// Our `User::on_update` callback: +/// print a notification about name and status changes. +fn on_user_updated(send: UiSend) -> impl FnMut(&User, &User, Option<&ReducerEvent>) + Send + 'static { + move |old, new, _| { + if new.identity == identity().unwrap() { + if old.name != new.name { + send.unbounded_send(UiMessage::SetOwnName { + name: user_name_or_identity(new), + }) + .unwrap(); + } + } else { + if old.name != new.name { + send.unbounded_send(UiMessage::SetName { + identity: new.identity.clone(), + new_name: user_name_or_identity(new), + }) + .unwrap(); + } + if old.online && !new.online { + send.unbounded_send(UiMessage::UserDisconnected { + identity: new.identity.clone(), + }) + .unwrap(); + } + if !old.online && new.online { + send.unbounded_send(UiMessage::UserConnected { + identity: new.identity.clone(), + name: user_name_or_identity(new), + }) + .unwrap(); + } + } + } +} + +// ## Display incoming messages + +/// Our `Message::on_insert` callback: print new messages. +fn on_message_inserted(send: UiSend) -> impl FnMut(&Message, Option<&ReducerEvent>) + Send + 'static { + move |message, reducer_event| { + if reducer_event.is_some() { + print_message(&send, message); + } + } +} + +fn print_message(send: &UiSend, message: &Message) { + let sender = User::filter_by_identity(message.sender.clone()) + .map(|u| user_name_or_identity(&u)) + .unwrap_or_else(|| "unknown".to_string()); + send.unbounded_send(UiMessage::Message { + sender_name: sender, + sender_identity: message.sender.clone(), + text: message.text.clone(), + }) + .unwrap(); +} + +// ## Print message backlog + +/// Our `on_subscription_applied` callback: +/// sort all past messages and print them in timestamp order. +fn on_sub_applied(send: UiSend) -> impl FnMut() + Send + 'static { + move || { + let mut messages = Message::iter().collect::>(); + messages.sort_by_key(|m| m.sent); + for message in messages { + print_message(&send, &message); + } + } +} + +// ## Warn if set_name failed + +/// Our `on_set_name` callback: print a warning if the reducer failed. +fn on_name_set(send: UiSend) -> impl FnMut(&Identity, &Status, &String) { + move |_sender, status, name| { + if let Status::Failed(err) = status { + send.unbounded_send(UiMessage::NameRejected { + current_name: user_name_or_identity(&User::filter_by_identity(identity().unwrap()).unwrap()), + rejected_name: name.clone(), + reason: err.clone(), + }) + .unwrap(); + } + } +} + +// ## Warn if a message was rejected + +/// Our `on_send_message` callback: print a warning if the reducer failed. +fn on_message_sent(send: UiSend) -> impl FnMut(&Identity, &Status, &String) { + move |_sender, status, text| { + if let Status::Failed(err) = status { + send.unbounded_send(UiMessage::MessageRejected { + rejected_message: text.clone(), + reason: err.clone(), + }) + .unwrap(); + } + } +} + +// # Connect to the database + +/// The URL of the SpacetimeDB instance hosting our chat module. +const SPACETIMEDB_URI: &str = "http://localhost:3000"; + +/// The module name we chose when we published our module. +const DB_NAME: &str = "chat"; + +/// Load credentials from a file and connect to the database. +fn connect_to_db() { + connect( + SPACETIMEDB_URI, + DB_NAME, + load_credentials(CREDS_DIR).expect("Error reading stored credentials"), + ) + .expect("Failed to connect"); +} + +// # Subscribe to queries + +/// Register subscriptions for all rows of both tables. +fn subscribe_to_tables() { + subscribe(&["SELECT * FROM User;", "SELECT * FROM Message;"]).unwrap(); +} + +// # Construct the user interface + +const MESSAGES_VIEW_NAME: &str = "Messages"; +const NEW_MESSAGE_VIEW_NAME: &str = "NewMessage"; +const ONLINE_USERS_VIEW_NAME: &str = "OnlineUsers"; +const SET_NAME_VIEW_NAME: &str = "SetName"; +const MESSAGE_SENDER_VIEW_NAME: &str = "MessageSender"; + +const USERS_WIDTH: usize = 20; + +/// Construct our TUI. +/// +/// Our UI will have 3 parts: +/// - The messages view, which displays incoming and sent messages. +/// - The online users view, which lists all currently-online remote users. +/// - The input bar, which allows the user to set their name and to send messages. +fn make_ui() -> CursiveRunnable { + let mut siv = cursive::default(); + + siv.add_layer( + LinearLayout::horizontal() + .child(PaddedView::lrtb( + 1, + 1, + 1, + 1, + LinearLayout::vertical() + .child(make_messages_view()) + .child(make_input_bar()), + )) + .child(PaddedView::lrtb(0, 1, 1, 1, make_online_users_view())) + .full_screen(), + ); + + siv +} + +/// Construct the messages view, which displays incoming and sent messages. +fn make_messages_view() -> impl View { + ScrollView::new(LinearLayout::vertical().with_name(MESSAGES_VIEW_NAME)) + .scroll_strategy(cursive::view::ScrollStrategy::StickToBottom) + .full_screen() +} + +/// Construct the input bar, which allows the user to set their name and to send messages. +/// +/// The input bar has two inputs: +/// - The set name view, an editable text box which shows the user's name. +/// The user can change their name by typing in it and hitting enter. +/// - The new message view, an editable text box where the user can type a message, +/// then hit enter to send it. +fn make_input_bar() -> impl View { + LinearLayout::horizontal() + .child(make_set_name_view()) + .child(TextView::new(": ")) + .child(make_new_message_view()) +} + +/// Construct the new message view, where the user can type new messages. +fn make_new_message_view() -> impl View { + EditView::new() + .on_submit(ui_send_message) + .with_name(NEW_MESSAGE_VIEW_NAME) + .full_width() +} + +/// The UI callback on the send message view: +/// invoke the `send_message` reducer, then clear the input box. +fn ui_send_message(siv: &mut Cursive, text: &str) { + send_message(text.to_string()); + siv.call_on_name(NEW_MESSAGE_VIEW_NAME, |new_message: &mut EditView| { + new_message.set_content(""); + }); +} + +/// Construct the set name view, which displays the user's name. +/// The user can type into it and press enter to set their name. +fn make_set_name_view() -> impl View { + EditView::new() + .on_submit(ui_set_name) + .with_name(SET_NAME_VIEW_NAME) + .fixed_width(USERS_WIDTH) +} + +/// The UI callback on the set name view: +/// invoke the `set_name` reducer. +/// Leave the new name in the input box. +fn ui_set_name(_siv: &mut Cursive, name: &str) { + set_name(name.to_string()); +} + +/// Construct the online users view, which lists all the online remote users. +fn make_online_users_view() -> impl View { + ScrollView::new(LinearLayout::vertical().with_name(ONLINE_USERS_VIEW_NAME)) + .scroll_strategy(cursive::view::ScrollStrategy::KeepRow) + .full_height() + .fixed_width(USERS_WIDTH) +} + +// # Run our user interface + +/// Run the Cursive TUI. +/// +/// Because we need to push server-driven events into the UI, +/// we can't use Cursive's built-in event loop. +/// Instead, we need our own loop which asks Cursive to process user inputs, +/// then processes server-driven events from the `UiMessage` channel, +/// then re-draws the UI if necessary. +fn user_input_loop(siv: CursiveRunnable, mut recv: UiRecv) { + let mut siv = siv.into_runner(); + siv.refresh(); + + 'per_frame: loop { + siv.step(); + if !siv.is_running() { + break 'per_frame; + } + + // Will be true if any processed `UiMessage` causes any element to be redrawn. + // We'll only re-draw the UI if a change happened. + let mut needs_refresh = false; + + 'process_message: loop { + match recv.try_next() { + // futures-channel returns `Err` to denote "queue is empty," + // so we've processed all messages for this frame. + Err(_) => break 'process_message, + + // futures-channel returns `Ok(None)` to denote "channel is closed," + // so exit the UI loop. + Ok(None) => break 'per_frame, + + // Process the next `UiMessage` and set `needs_refresh`. + Ok(Some(message)) => { + needs_refresh |= process_ui_message(&mut siv, message); + } + } + } + + // If any UI element changed, re-draw the UI. + if needs_refresh { + siv.refresh(); + } + } +} + +/// A full 64-bit hexadecimal identifier, to be used as a Cursive view name. +fn identity_hex(id: &Identity) -> String { + hex::encode(id.bytes()) +} + +/// Update past messages sent by `identity` to change their sender name to `new_name`. +fn rename_message_senders(identity: &Identity, new_name: &str, siv: &mut CursiveRunner) -> bool { + // Like in the main UI loop, we'll track if anything has changed in the UI, + // i.e. if any messages had their sender renamed. + let mut needs_update = false; + + siv.call_on_name(MESSAGES_VIEW_NAME, |messages: &mut LinearLayout| { + // For each message sent by `identity`, + messages.call_on_all( + &cursive::view::Selector::Name(&identity_hex(identity)), + |message: &mut LinearLayout| { + needs_update = true; + // change the sender to the new name. + message.call_on_name(MESSAGE_SENDER_VIEW_NAME, |name: &mut TextView| { + name.set_content(format!("{}: ", new_name)); + }); + }, + ); + }); + + needs_update +} + +/// Update the set name view to display the user's name. +fn set_own_name(siv: &mut CursiveRunner, name: String) { + siv.call_on_name(SET_NAME_VIEW_NAME, |set_name: &mut EditView| { + set_name.set_content(name.clone()); + }); +} + +/// Process a single `UiMessage`. +/// +/// Returns true if the `UiMessage` caused any UI element to be updated. +fn process_ui_message(siv: &mut CursiveRunner, message: UiMessage) -> bool { + match message { + // When a new user connects, add them to the online users view. + UiMessage::UserConnected { identity, name } => { + siv.call_on_name(ONLINE_USERS_VIEW_NAME, |online_users: &mut LinearLayout| { + online_users.add_child( + TextView::new(name) + // Tag their entry in the online users view with their identity, + // so we can find it later in the `UserDisconnected` and `SetName` branches. + .with_name(identity_hex(&identity)), + ); + true + }) + } + + // When a user disconnects, remove them from the online users view. + UiMessage::UserDisconnected { identity } => { + siv.call_on_name(ONLINE_USERS_VIEW_NAME, |online_users: &mut LinearLayout| { + online_users + // Look up their entry in the online users view by their identity. + .find_child_from_name(&identity_hex(&identity)) + .map(|idx| { + online_users.remove_child(idx); + true + }) + .unwrap_or(false) + }) + } + + // When our own name successfully changes, + // update the set name view to show it, + // and update our past messages. + UiMessage::SetOwnName { name } => { + set_own_name(siv, name.clone()); + // Look up our past messages by our identity. + rename_message_senders(&identity().unwrap(), &name, siv); + Some(true) + } + // When someone else updates their name, + // change it in the online users view, + // and update their past messages. + UiMessage::SetName { identity, new_name } => { + siv.call_on_name(ONLINE_USERS_VIEW_NAME, |online_users: &mut LinearLayout| { + // Look up their entry in the online users view by their identity. + online_users.call_on_name(&identity_hex(&identity), |view: &mut TextView| { + view.set_content(new_name.clone()); + }); + }); + // Look up their past messages by their identity. + rename_message_senders(&identity, &new_name, siv); + + Some(true) + } + + // When we receive a new message, add it to the messages view. + UiMessage::Message { + sender_name, + sender_identity, + text, + } => siv.call_on_name(MESSAGES_VIEW_NAME, |messages: &mut LinearLayout| { + messages.add_child( + LinearLayout::horizontal() + .child( + TextView::new(format!("{}: ", sender_name)) + // Tag the sender part with `MESSAGE_SENDER_VIEW_NAME`, + // so that `rename_message_senders` can find it. + .with_name(MESSAGE_SENDER_VIEW_NAME), + ) + .child(TextView::new(text)) + // Tag the message with the sender's identity, + // so that `rename_message_senders` can find it. + .with_name(identity_hex(&sender_identity)), + ); + true + }), + + // When a message we sent is rejected by the server, + // display a dialog with the offending message and the rejection reason. + UiMessage::MessageRejected { + rejected_message, + reason, + } => { + siv.add_layer( + Dialog::around( + LinearLayout::vertical() + .child(TextView::new("Failed to send message.")) + .child(TextView::new(reason)) + .child(TextView::new(rejected_message)), + ) + .dismiss_button("Ok"), + ); + Some(true) + } + + // When our new name is rejected by the server, + // display a dialog with the offending message and the rejection reason. + UiMessage::NameRejected { + current_name, + rejected_name, + reason, + } => { + set_own_name(siv, current_name); + siv.add_layer( + Dialog::around( + LinearLayout::vertical() + .child(TextView::new("Failed to set name.")) + .child(TextView::new(reason)) + .child(TextView::new(rejected_name)), + ) + .dismiss_button("Ok"), + ); + Some(true) + } + } + .unwrap_or(false) +} diff --git a/crates/sdk/examples/cursive-chat/module_bindings/message.rs b/crates/sdk/examples/cursive-chat/module_bindings/message.rs new file mode 100644 index 0000000000..e44493675b --- /dev/null +++ b/crates/sdk/examples/cursive-chat/module_bindings/message.rs @@ -0,0 +1,39 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN RUST INSTEAD. + +#[allow(unused)] +use spacetimedb_sdk::{ + anyhow::{anyhow, Result}, + identity::Identity, + reducer::{Reducer, ReducerCallbackId, Status}, + sats::{de::Deserialize, ser::Serialize}, + spacetimedb_lib, + table::{TableIter, TableType, TableWithPrimaryKey}, +}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub struct Message { + pub sender: Identity, + pub sent: u64, + pub text: String, +} + +impl TableType for Message { + const TABLE_NAME: &'static str = "Message"; + type ReducerEvent = super::ReducerEvent; +} + +impl Message { + #[allow(unused)] + pub fn filter_by_sender(sender: Identity) -> TableIter { + Self::filter(|row| row.sender == sender) + } + #[allow(unused)] + pub fn filter_by_sent(sent: u64) -> TableIter { + Self::filter(|row| row.sent == sent) + } + #[allow(unused)] + pub fn filter_by_text(text: String) -> TableIter { + Self::filter(|row| row.text == text) + } +} diff --git a/crates/sdk/examples/cursive-chat/module_bindings/mod.rs b/crates/sdk/examples/cursive-chat/module_bindings/mod.rs new file mode 100644 index 0000000000..02b769a1df --- /dev/null +++ b/crates/sdk/examples/cursive-chat/module_bindings/mod.rs @@ -0,0 +1,123 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN RUST INSTEAD. + +use spacetimedb_sdk::callbacks::{DbCallbacks, ReducerCallbacks}; +use spacetimedb_sdk::client_api_messages::{Event, TableUpdate}; +use spacetimedb_sdk::client_cache::{ClientCache, RowCallbackReminders}; +use spacetimedb_sdk::global_connection::with_connection_mut; +use spacetimedb_sdk::identity::Credentials; +use spacetimedb_sdk::reducer::AnyReducerEvent; +#[allow(unused)] +use spacetimedb_sdk::{ + anyhow::{anyhow, Result}, + identity::Identity, + reducer::{Reducer, ReducerCallbackId, Status}, + sats::{de::Deserialize, ser::Serialize}, + spacetimedb_lib, + table::{TableIter, TableType, TableWithPrimaryKey}, +}; +use std::sync::Arc; + +pub mod message; +pub mod send_message_reducer; +pub mod set_name_reducer; +pub mod user; + +pub use message::*; +pub use send_message_reducer::*; +pub use set_name_reducer::*; +pub use user::*; + +#[allow(unused)] +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub enum ReducerEvent { + SendMessage(send_message_reducer::SendMessageArgs), + SetName(set_name_reducer::SetNameArgs), +} + +#[allow(unused)] +fn handle_table_update( + table_update: TableUpdate, + client_cache: &mut ClientCache, + callbacks: &mut RowCallbackReminders, +) { + let table_name = &table_update.table_name[..]; + match table_name { + "Message" => client_cache.handle_table_update_no_primary_key::(callbacks, table_update), + "User" => client_cache.handle_table_update_with_primary_key::(callbacks, table_update), + _ => spacetimedb_sdk::log::error!("TableRowOperation on unknown table {:?}", table_name), + } +} + +#[allow(unused)] +fn invoke_row_callbacks( + reminders: &mut RowCallbackReminders, + worker: &mut DbCallbacks, + reducer_event: Option>, + state: &Arc, +) { + reminders.invoke_callbacks::(worker, &reducer_event, state); + reminders.invoke_callbacks::(worker, &reducer_event, state); +} + +#[allow(unused)] +fn handle_resubscribe(new_subs: TableUpdate, client_cache: &mut ClientCache, callbacks: &mut RowCallbackReminders) { + let table_name = &new_subs.table_name[..]; + match table_name { + "Message" => client_cache.handle_resubscribe_for_type::(callbacks, new_subs), + "User" => client_cache.handle_resubscribe_for_type::(callbacks, new_subs), + _ => spacetimedb_sdk::log::error!("TableRowOperation on unknown table {:?}", table_name), + } +} + +#[allow(unused)] +fn handle_event( + event: Event, + reducer_callbacks: &mut ReducerCallbacks, + state: Arc, +) -> Option> { + let Some(function_call) = &event.function_call else { + spacetimedb_sdk::log::warn!("Received Event with None function_call"); return None; +}; + match &function_call.reducer[..] { + "send_message" => reducer_callbacks + .handle_event_of_type::( + event, + state, + ReducerEvent::SendMessage, + ), + "set_name" => reducer_callbacks.handle_event_of_type::( + event, + state, + ReducerEvent::SetName, + ), + unknown => { + spacetimedb_sdk::log::error!("Event on an unknown reducer: {:?}", unknown); + None + } + } +} + +/// Connect to a database named `db_name` accessible over the internet at the URI `spacetimedb_uri`. +/// +/// If `credentials` are supplied, they will be passed to the new connection to +/// identify and authenticate the user. Otherwise, a set of `Credentials` will be +/// generated by the server. +pub fn connect(spacetimedb_uri: IntoUri, db_name: &str, credentials: Option) -> Result<()> +where + IntoUri: TryInto, + >::Error: std::error::Error + Send + Sync + 'static, +{ + with_connection_mut(|connection| { + connection.connect( + spacetimedb_uri, + db_name, + credentials, + handle_table_update, + handle_resubscribe, + invoke_row_callbacks, + handle_event, + )?; + Ok(()) + }) +} diff --git a/crates/sdk/examples/cursive-chat/module_bindings/send_message_reducer.rs b/crates/sdk/examples/cursive-chat/module_bindings/send_message_reducer.rs new file mode 100644 index 0000000000..5cef95354c --- /dev/null +++ b/crates/sdk/examples/cursive-chat/module_bindings/send_message_reducer.rs @@ -0,0 +1,51 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN RUST INSTEAD. + +#[allow(unused)] +use spacetimedb_sdk::{ + anyhow::{anyhow, Result}, + identity::Identity, + reducer::{Reducer, ReducerCallbackId, Status}, + sats::{de::Deserialize, ser::Serialize}, + spacetimedb_lib, + table::{TableIter, TableType, TableWithPrimaryKey}, +}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub struct SendMessageArgs { + pub text: String, +} + +impl Reducer for SendMessageArgs { + const REDUCER_NAME: &'static str = "send_message"; +} + +#[allow(unused)] +pub fn send_message(text: String) { + SendMessageArgs { text }.invoke(); +} + +#[allow(unused)] +pub fn on_send_message( + mut __callback: impl FnMut(&Identity, &Status, &String) + Send + 'static, +) -> ReducerCallbackId { + SendMessageArgs::on_reducer(move |__identity, __status, __args| { + let SendMessageArgs { text } = __args; + __callback(__identity, __status, text); + }) +} + +#[allow(unused)] +pub fn once_on_send_message( + __callback: impl FnOnce(&Identity, &Status, &String) + Send + 'static, +) -> ReducerCallbackId { + SendMessageArgs::once_on_reducer(move |__identity, __status, __args| { + let SendMessageArgs { text } = __args; + __callback(__identity, __status, text); + }) +} + +#[allow(unused)] +pub fn remove_on_send_message(id: ReducerCallbackId) { + SendMessageArgs::remove_on_reducer(id); +} diff --git a/crates/sdk/examples/cursive-chat/module_bindings/set_name_reducer.rs b/crates/sdk/examples/cursive-chat/module_bindings/set_name_reducer.rs new file mode 100644 index 0000000000..785defcbec --- /dev/null +++ b/crates/sdk/examples/cursive-chat/module_bindings/set_name_reducer.rs @@ -0,0 +1,51 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN RUST INSTEAD. + +#[allow(unused)] +use spacetimedb_sdk::{ + anyhow::{anyhow, Result}, + identity::Identity, + reducer::{Reducer, ReducerCallbackId, Status}, + sats::{de::Deserialize, ser::Serialize}, + spacetimedb_lib, + table::{TableIter, TableType, TableWithPrimaryKey}, +}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub struct SetNameArgs { + pub name: String, +} + +impl Reducer for SetNameArgs { + const REDUCER_NAME: &'static str = "set_name"; +} + +#[allow(unused)] +pub fn set_name(name: String) { + SetNameArgs { name }.invoke(); +} + +#[allow(unused)] +pub fn on_set_name( + mut __callback: impl FnMut(&Identity, &Status, &String) + Send + 'static, +) -> ReducerCallbackId { + SetNameArgs::on_reducer(move |__identity, __status, __args| { + let SetNameArgs { name } = __args; + __callback(__identity, __status, name); + }) +} + +#[allow(unused)] +pub fn once_on_set_name( + __callback: impl FnOnce(&Identity, &Status, &String) + Send + 'static, +) -> ReducerCallbackId { + SetNameArgs::once_on_reducer(move |__identity, __status, __args| { + let SetNameArgs { name } = __args; + __callback(__identity, __status, name); + }) +} + +#[allow(unused)] +pub fn remove_on_set_name(id: ReducerCallbackId) { + SetNameArgs::remove_on_reducer(id); +} diff --git a/crates/sdk/examples/cursive-chat/module_bindings/user.rs b/crates/sdk/examples/cursive-chat/module_bindings/user.rs new file mode 100644 index 0000000000..7d472ca97e --- /dev/null +++ b/crates/sdk/examples/cursive-chat/module_bindings/user.rs @@ -0,0 +1,46 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN RUST INSTEAD. + +#[allow(unused)] +use spacetimedb_sdk::{ + anyhow::{anyhow, Result}, + identity::Identity, + reducer::{Reducer, ReducerCallbackId, Status}, + sats::{de::Deserialize, ser::Serialize}, + spacetimedb_lib, + table::{TableIter, TableType, TableWithPrimaryKey}, +}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub struct User { + pub identity: Identity, + pub name: Option, + pub online: bool, +} + +impl TableType for User { + const TABLE_NAME: &'static str = "User"; + type ReducerEvent = super::ReducerEvent; +} + +impl TableWithPrimaryKey for User { + type PrimaryKey = Identity; + fn primary_key(&self) -> &Self::PrimaryKey { + &self.identity + } +} + +impl User { + #[allow(unused)] + pub fn filter_by_identity(identity: Identity) -> Option { + Self::find(|row| row.identity == identity) + } + #[allow(unused)] + pub fn filter_by_name(name: Option) -> TableIter { + Self::filter(|row| row.name == name) + } + #[allow(unused)] + pub fn filter_by_online(online: bool) -> TableIter { + Self::filter(|row| row.online == online) + } +}