diff --git a/packages/app/src/components/windows/WindowChat.vue b/packages/app/src/components/windows/WindowChat.vue index fedd234..53e79c9 100644 --- a/packages/app/src/components/windows/WindowChat.vue +++ b/packages/app/src/components/windows/WindowChat.vue @@ -1,14 +1,24 @@ + diff --git a/packages/app/src/stores/irc.ts b/packages/app/src/stores/irc.ts index 592ec0d..7a4634e 100644 --- a/packages/app/src/stores/irc.ts +++ b/packages/app/src/stores/irc.ts @@ -1,6 +1,6 @@ import { defineStore } from "pinia" -import { Message, React, type IrcConnection, type Server, type ServerList } from "core-wasm" -import { computed, ref, shallowRef } from "vue" +import { ChannelMessage, Message, React, type IrcConnection, type Server, type ServerList, OrbitError, IrcChannel } from "core-wasm" +import { computed, reactive, ref, shallowRef } from "vue" import { useUserStore } from "./user" import { useAppStateStore } from "./app-state" @@ -22,7 +22,8 @@ export const useIrcStore = defineStore("irc", () => { const serverHandlers = shallowRef>(new Map()) // Holds references to messages per server. This should be actually per `server:channel` - const serverMessages = shallowRef>(new Map()) + const serverMessages = reactive>>(new Map()) + const serverChannel = ref() let controller: ServerList = {} as ServerList @@ -69,8 +70,21 @@ export const useIrcStore = defineStore("irc", () => { serverHandlers.value.set(state.id, handler) await handler.sign_in_anonymous(user.me.displayName, user.me.accountName, user.me.accountName) + serverChannel.value = await handler.join_channel("#orbit/testing") console.log("Signed in") + // Set initial channel messages + const channelState = (await serverChannel.value.state())! + + const existingServer = serverMessages.get(state.id) ?? new Map() + const existingChannel = existingServer.get(channelState.metadata.name) ?? [] + + existingChannel.push(...channelState.messages) + existingChannel.sort((a, b) => a.metadata.server_time - b.metadata.server_time) + + existingServer.set(channelState.metadata.name, existingChannel) + serverMessages.set(state.id, existingServer) + registerServerEvents(state.id, handler) return { @@ -82,10 +96,15 @@ export const useIrcStore = defineStore("irc", () => { function registerServerEvents(key: number, handler: IrcConnection) { // Runs whenever some dataset on the server object changes handler.on_data((event) => { - if (event instanceof Message) { - const existing = serverMessages.value.get(key) ?? [] - existing.push(event) - serverMessages.value.set(key, existing) + if (event instanceof ChannelMessage) { + const existingServer = serverMessages.get(key) ?? new Map() + const existingChannel = existingServer.get(event.channel) ?? [] + + existingChannel.push(event.message) + existingChannel.sort((a: Message, b: Message) => a.metadata.server_time - b.metadata.server_time) + + existingServer.set(event.channel, existingChannel) + serverMessages.set(key, existingServer) } else if (event instanceof React) { // TODO console.log("Received reaction", event) @@ -104,10 +123,42 @@ export const useIrcStore = defineStore("irc", () => { }) } + // TODO: these should be cached not to create a separate computed value on each call function getServerState(id: number) { return computed(() => serverState.value.get(id)) } + function getChannelMessages(id: number, channel: string) { + return computed(() => serverMessages.get(id)?.get(channel)) + } + + // TODO: will be called automatically by a scroll listener to append new messages as user's nearing the top of the window + async function requestScrollback(id: number, channel: string) { + try { + const oldestId = serverMessages.get(id)?.get(channel)?.at(0)?.metadata.msgid + if (!oldestId) { + return + } + const history = await serverHandlers.value.get(id)?.history_before(channel, oldestId) + + if (!history) { + return + } + + const existingServer = serverMessages.get(id) ?? new Map() + const existingChannel = existingServer.get(history.channel) ?? [] + + existingChannel.push(...history.messages) + existingChannel.sort((a, b) => a.metadata.server_time - b.metadata.server_time) + + existingServer.set(history.channel, existingChannel) + serverMessages.set(id, existingServer) + } catch (e: unknown) { + const error = e as OrbitError + console.error(JSON.parse(error.toString())) + } + } + return { init, serverConnect, @@ -116,5 +167,8 @@ export const useIrcStore = defineStore("irc", () => { serverData: serverState, serverControllers: serverHandlers, getServerState, + getChannelMessages, + requestScrollback, + serverChannel, } }) diff --git a/packages/core/Cargo.lock b/packages/core/Cargo.lock index a1f5c91..10902ec 100644 --- a/packages/core/Cargo.lock +++ b/packages/core/Cargo.lock @@ -128,15 +128,17 @@ dependencies = [ "futures", "gloo-console 0.4.0", "gloo-net 0.7.0", - "indexed_db_futures", + "gloo-timers 0.4.0", "irc-proto", "ordermap", + "rand", "thiserror 2.0.18", "time", "tokio", "tracing", "tsify", "wasm-bindgen", + "web-time", ] [[package]] @@ -354,7 +356,7 @@ dependencies = [ "gloo-net 0.3.1", "gloo-render", "gloo-storage", - "gloo-timers", + "gloo-timers 0.2.6", "gloo-utils 0.1.7", "gloo-worker", ] @@ -510,6 +512,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gloo-utils" version = "0.1.7" @@ -838,6 +852,21 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rustc_version" version = "0.4.1" diff --git a/packages/core/core-shared/Cargo.toml b/packages/core/core-shared/Cargo.toml index ac3cff8..bebd5ed 100644 --- a/packages/core/core-shared/Cargo.toml +++ b/packages/core/core-shared/Cargo.toml @@ -1,4 +1,5 @@ [package] + name = "core-shared" version = "0.1.0" edition = "2024" @@ -8,19 +9,24 @@ anyhow = { version = "1.0.103", features = ["backtrace"] } base64 = "0.22.1" blake3 = "1.8.5" futures = "0.3.32" -gloo-console = { version = "0.4.0", optional = true } -gloo-net = { version = "0.7.0", optional = true } -indexed_db_futures = { version = "0.6.4", features = ["serde"] } irc-proto = "1.1.0" ordermap = "1.2.0" thiserror = "2.0.18" time = { version = "0.3.53", features = ["parsing"] } tracing = "0.1.44" + tsify = { version = "0.5.6", optional = true, features = ["js"] } wasm-bindgen = { version = "0.2.126", optional = true } +web-time = { version = "1.1.0", optional = true } +gloo-console = { version = "0.4.0", optional = true } +gloo-net = { version = "0.7.0", optional = true } +tokio = { version = "1.52.3", features = ["macros", "rt", "time", "sync"], optional = true } +gloo-timers = { version = "0.4.0", features = ["futures"], optional = true } +rand = { version = "0.10.2", default-features = false } [features] -web = ["dep:gloo-net", "dep:wasm-bindgen", "dep:gloo-console", "dep:tsify"] +web = ["dep:gloo-net", "dep:wasm-bindgen", "dep:gloo-console", "dep:tsify", "dep:web-time", "dep:gloo-timers"] +default = ["dep:tokio"] [dev-dependencies] assert_matches = "1.5.0" diff --git a/packages/core/core-shared/src/actor.rs b/packages/core/core-shared/src/actor.rs index ea29a6e..0f86893 100644 --- a/packages/core/core-shared/src/actor.rs +++ b/packages/core/core-shared/src/actor.rs @@ -1,12 +1,19 @@ -use std::fmt; +use std::{fmt, time::Duration}; + +use futures::{FutureExt, future::FusedFuture}; +use rand::{SeedableRng, rngs::SmallRng, seq::IndexedRandom}; +#[cfg(not(feature = "web"))] +use std::time::Instant; +#[cfg(feature = "web")] +use web_time::Instant; #[cfg(feature = "web")] use crate::dbg; use crate::{ SendCommand, state::{ - Channel, ChannelRole, ChannelUser, Message, MessageMetadata, MessageReference, MessageType, - OrbitError, React, Server, ServerEvent, SignedIn, TextMessage, User, + Channel, ChannelRole, ChannelUser, History, Message, MessageMetadata, MessageReference, + MessageType, OrbitError, Server, ServerEvent, SignedIn, Tags, TextMessage, User, }, }; use anyhow::{Context, anyhow}; @@ -19,52 +26,103 @@ use futures::{ }, stream::FusedStream, }; -use irc_proto::{ - BatchSubCommand, CapSubCommand, Command::*, Message as IrcMessage, Response, message::Tag, -}; +use irc_proto::{BatchSubCommand, CapSubCommand, Command::*, Message as IrcMessage, Response}; use ordermap::OrderMap; -use time::{OffsetDateTime, format_description::well_known::Iso8601}; use tracing::{debug, error, warn}; -#[derive(Debug, Default)] -pub struct ResponseChannels(Vec<(CommandKey, oneshot::Sender)>); - #[derive(Debug, Clone, PartialEq, Eq)] pub enum CommandKey { RequestCaps, SignIn, Join(String), Privmsg { target: String, text: String }, + History, + Label(String), } #[derive(Debug)] pub enum CommandResponse { GetState(Box), + GetChannelState(Box>), Capabilities, SignIn(Result), Join(String), Privmsg(Box), + History(History), +} + +const LABEL_CHARSET: &str = "abcdefghijklmnopqrstuvwxyz\ + ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + 1234567890"; + +fn generate_label(rng: &mut SmallRng) -> String { + let char_vec = LABEL_CHARSET + .split("") + .filter(|c| !c.is_empty()) + .collect::>(); + + std::iter::repeat_with(|| char_vec.choose(rng).expect("CHARSET is not empty")) + .take(10) + .copied() + .collect::>() + .join("") +} + +#[derive(Debug)] +pub struct ResponseChannels { + channels: Vec<(CommandKey, Instant, oneshot::Sender)>, + rng: SmallRng, +} + +impl Default for ResponseChannels { + #[tracing::instrument] + fn default() -> Self { + Self { + channels: Vec::new(), + rng: SmallRng::from_seed([0; 32]), + } + } } impl ResponseChannels { + #[tracing::instrument] pub fn register(&mut self, key: CommandKey, os_tx: oneshot::Sender) { - self.0.push((key, os_tx)); + self.channels.push((key, Instant::now(), os_tx)); } #[tracing::instrument] - pub async fn reply( + pub fn register_labeled(&mut self, os_tx: oneshot::Sender) -> String { + let label = generate_label(&mut self.rng); + + self.channels + .push((CommandKey::Label(label.clone()), Instant::now(), os_tx)); + + label + } + + #[tracing::instrument] + pub fn reply( &mut self, key: &CommandKey, response: CommandResponse, - ) -> Result<(), CommandResponse> { - if let Some(idx) = self.0.iter().position(|(rk, _)| rk == key) { - let (_, ch) = self.0.remove(idx); + ) -> Result { + if let Some(idx) = self.channels.iter().position(|(rk, _, _)| rk == key) { + let (_, _, ch) = self.channels.remove(idx); ch.send(response)?; + + Ok(true) } else { + if let CommandKey::Label(label) = key { + warn!("Failed to find response channel for label {label:?}"); + } // warn!("Failed to find response channel"); + Ok(false) } + } - Ok(()) + pub fn check_timeouts(&mut self) { + self.channels + .retain(|(_, creation, _)| creation.elapsed() < Duration::from_secs(1)); } } @@ -77,6 +135,7 @@ pub struct ActorMessage { #[derive(Debug)] pub enum ActorCommand { GetState, + GetChannelState(String), SignIn { nick: String, user: String, @@ -105,6 +164,10 @@ pub enum ActorCommand { AddDisconectHandler { handler: UnboundedSender, }, + RequestHistory { + channel: String, + before_msgid: String, + }, } pub trait IrcConnection: fmt::Debug { @@ -115,29 +178,34 @@ pub trait IrcConnection: fmt::Debug { fn address(&self) -> &str; } -pub struct IrcActor { - cmd_rx: mpsc::UnboundedReceiver, - incoming: C::Incoming, - outgoing: C::Outgoing, - state: Server, - response_channels: ResponseChannels, - event_handlers: Vec>, - error_handlers: Vec>, - disconnect_handlers: Vec>, - - current_batch: Option, - sasl_state: SaslState, +struct RequestedHistory { + channel: String, + label: Option, } #[derive(Debug)] -struct Batch { +struct CurrentBatch { id: String, - typ: BatchSubCommand, + data: BatchData, } -impl Batch { +#[derive(Debug)] +enum BatchData { + History { + label: Option, + channel: String, + messages: Vec, + }, + Multiline { + target: String, + message: Message, + }, + Unhandled, +} + +impl CurrentBatch { fn is_chathistory(&self) -> bool { - matches!(self.typ, BatchSubCommand::CUSTOM(ref c) if c.as_str() == "CHATHISTORY") + matches!(self.data, BatchData::History { .. }) } } @@ -153,6 +221,22 @@ enum SaslState { }, } +pub struct IrcActor { + cmd_rx: mpsc::UnboundedReceiver, + incoming: C::Incoming, + outgoing: C::Outgoing, + state: Server, + response_channels: ResponseChannels, + event_handlers: Vec>, + error_handlers: Vec>, + disconnect_handlers: Vec>, + + current_batches: Vec, + requested_history_batches: Vec<(RequestedHistory, Instant)>, + sasl_state: SaslState, + rng: SmallRng, +} + impl IrcActor { #[tracing::instrument] pub async fn start( @@ -170,11 +254,13 @@ impl IrcActor { outgoing, state: Server::new(id, address), response_channels: ResponseChannels::default(), - event_handlers: Vec::new(), - error_handlers: Vec::new(), - disconnect_handlers: Vec::new(), - current_batch: None, + event_handlers: Default::default(), + error_handlers: Default::default(), + disconnect_handlers: Default::default(), + current_batches: Default::default(), + requested_history_batches: Default::default(), sasl_state: Default::default(), + rng: SmallRng::from_seed([1; 32]), }; let (tx, rx) = oneshot::channel(); @@ -192,6 +278,17 @@ impl IrcActor { #[tracing::instrument(skip(self))] pub async fn run(mut self) { + fn create_timeout() -> impl FusedFuture { + #[cfg(feature = "web")] + let timeout = gloo_timers::future::TimeoutFuture::new(1000).fuse(); + #[cfg(not(feature = "web"))] + let timeout = Box::pin(tokio::time::sleep(Duration::from_secs(1)).fuse()); + + timeout + } + + let mut timeout = create_timeout(); + loop { futures::select! { msg = self.incoming.next() => { @@ -214,6 +311,18 @@ impl IrcActor { cmd = self.cmd_rx.select_next_some() => { self.handle_command(cmd).await.unwrap(); } + _ = timeout => { + self.response_channels.check_timeouts(); + + + assert!( + self.requested_history_batches + .iter() + .all(|(_, creation)| creation.elapsed() < Duration::from_secs(5)) + ); + + timeout = create_timeout(); + } } } } @@ -236,181 +345,443 @@ impl IrcActor { JOIN(ref channel_name, _, _) => { let source = message.source_nickname().unwrap(); - // FIXME: handle other cases + let mut tags = Tags::default(); + if let Some(ref t) = message.tags { + tags = Tags::parse(t); + } + + if self.current_batches.iter().any(|b| b.is_chathistory()) { + assert!(tags.server_time.is_some()); + } + + let state_message = Message { + text: None, + metadata: MessageMetadata { + msgid: tags.msgid_with_fallback(&["JOIN", source]), + server_time: tags.server_time_with_fallback() as f64, + message_type: MessageType::Join, + user: source.to_string(), + }, + }; + + if self + .push_batch(channel_name.clone(), state_message.clone()) + .await + { + return Ok(()); + } + if source == self.state.me.as_ref().unwrap().nickname { let channel = Channel::new(channel_name.clone()); self.state .channels .insert(channel_name.clone(), channel.clone()); - self.response_channels - .reply( - &CommandKey::Join(channel_name.clone()), - CommandResponse::Join(channel_name.clone()), - ) - .await - .map_err(|e| anyhow!("Failed to reply to JOIN command {e:?}"))?; + + if !self.state.capabilities.history.enabled { + self.response_channels + .reply( + &CommandKey::Join(channel_name.clone()), + CommandResponse::Join(channel_name.clone()), + ) + .map_err(|e| anyhow!("Failed to reply to JOIN command {e:?}"))?; + } self.on_event(ServerEvent::Joined(channel)).await?; + + if self.state.capabilities.history.enabled { + let label = if self.state.capabilities.labeled_response.enabled { + Some(generate_label(&mut self.rng)) + } else { + None + }; + + self.requested_history_batches.push(( + RequestedHistory { + channel: channel_name.clone(), + label: label.clone(), + }, + Instant::now(), + )); + self.history_latest(channel_name.clone(), None, 5, label) + .await + .context("Failed to request latest history")?; + } + } else { + let channel = self.channel_mut(channel_name.clone()).await; + + channel.users.push(ChannelUser { + nickname: source.to_string(), + role: ChannelRole::Regular, + }); } + + self.on_event(ServerEvent::Privmsg { + channel: channel_name.to_string(), + message: state_message, + }) + .await?; } - PRIVMSG(ref target, ref text) => { - let mut msgid = None; - let mut server_time = None; - let mut username = None; - let mut relayed_by = None; - let mut reply = None; - if let Some(ref tags) = message.tags { - for Tag(key, value) in tags { - match key.as_str() { - "msgid" => msgid = value.clone(), - "account" => username = value.clone(), - "draft/relaymsg" => relayed_by = value.clone(), - "+draft/reply" | "+reply" => reply = value.clone(), - "time" => { - server_time = value - .as_ref() - .and_then(|v| OffsetDateTime::parse(v, &Iso8601::DEFAULT).ok()) - } - _ => { - warn!("unhandled tag: {key:?}: {value:?}"); - } - } + PART(ref channel_name, ref comment) => { + let mut tags = Tags::default(); + if let Some(ref t) = message.tags { + tags = Tags::parse(t); + } + let source = message.source_nickname().unwrap(); + + let state_message = Message { + text: None, + metadata: MessageMetadata { + msgid: tags.msgid_with_fallback(&["PART", source]), + server_time: tags.server_time_with_fallback() as f64, + message_type: MessageType::Part, + user: source.to_string(), + }, + }; + + if self + .push_batch(channel_name.clone(), state_message.clone()) + .await + { + return Ok(()); + } + + self.on_event(ServerEvent::Privmsg { + channel: channel_name.to_string(), + message: state_message, + }) + .await?; + + let channel = self.channel_mut(channel_name.clone()).await; + + channel.users.retain(|u| u.nickname != source); + } + QUIT(ref comment) => { + let mut tags = Tags::default(); + if let Some(ref t) = message.tags { + tags = Tags::parse(t); + } + let source = message.source_nickname().unwrap(); + + let state_message = Message { + text: None, + metadata: MessageMetadata { + msgid: tags.msgid_with_fallback(&["QUIT", source]), + server_time: tags.server_time_with_fallback() as f64, + message_type: MessageType::Quit, + user: source.to_string(), + }, + }; + + for batch in &mut self.current_batches { + if let BatchData::History { messages, .. } = &mut batch.data { + messages.push(state_message); + return Ok(()); } } - let nickname = message.source_nickname().unwrap(); + self.on_event(ServerEvent::Privmsg { + channel: String::new(), + message: state_message, + }) + .await?; - if let Some(username) = username { - let user = self - .state - .users - .entry(nickname.to_string()) - .or_insert_with(|| User::new(nickname.to_string())); - user.username = Some(username); + self.state.users.remove(source); + for channel in self.state.channels.values_mut() { + channel.users.retain(|u| u.nickname != source); + } + } + PRIVMSG(ref target, ref text) => { + let mut tags = Tags::default(); + if let Some(ref t) = message.tags { + tags = Tags::parse(t); } + assert_eq!( + self.current_batches.iter().last().map(|b| b.id.as_str()), + tags.batch.as_deref(), + ); - let server_time = server_time - .unwrap_or_else(OffsetDateTime::now_utc) - .unix_timestamp(); - let msgid = msgid.unwrap_or_else(|| { - let mut hasher = blake3::Hasher::new(); - hasher.update(&server_time.to_ne_bytes()); - hasher.update(target.as_bytes()); - hasher.update(text.as_bytes()); + if let Some(batch) = self.current_batches.iter_mut().last() + && let BatchData::Multiline { + message, + target: channel, + } = &mut batch.data + && let Some(t) = message.text.as_mut() + { + if t.content.is_empty() { + *channel = target.to_string(); + t.content = text.to_string(); + } else { + t.content = format!("{}\n{}", t.content, text); + } + return Ok(()); + } - hasher.finalize().to_string() - }); + let source = message.source_nickname().unwrap(); - let reply = reply - .and_then(|r| { + let msgid = tags.msgid_with_fallback(&["PRIVMSG", source, target, text]); + + let reply = tags + .reply + .as_ref() + .map(|r| { self.state .channels .get(target) - .and_then(|c| c.messages.get(&r)) + .and_then(|c| c.messages.get(r)) }) - .and_then(|m| { - Some(MessageReference { - text: m.text.clone().map(|t| t.content)?, - username: m.metadata.user.clone(), - }) + .map(|m| MessageReference { + text: m.and_then(|m| m.text.clone().map(|t| t.content)), + username: m.map(|m| m.metadata.user.clone()), }); let state_message = Message { + metadata: MessageMetadata { + msgid: msgid.clone(), + server_time: tags.server_time_with_fallback() as f64, + message_type: MessageType::Privmsg, + user: source.to_string(), + }, text: Some(TextMessage { content: text.clone(), reactions: OrderMap::new(), reply, redacted: false, edited: false, - relayed_by, + relayed_by: tags.relayed_by, }), - metadata: MessageMetadata { - msgid: msgid.clone(), - server_time: server_time as f64, - message_type: MessageType::Privmsg, - user: nickname.to_string(), - }, }; - if nickname == self.state.me.as_ref().unwrap().nickname - && let Err(e) = self - .response_channels - .reply( - &CommandKey::Privmsg { - target: target.clone(), - text: text.clone(), - }, - CommandResponse::Privmsg(Box::new(state_message.clone())), - ) - .await - { - error!("Failed to reply to PRIVMSG command {e:?}"); + if self.push_batch(target.clone(), state_message.clone()).await { + return Ok(()); + } + + if let Some(username) = tags.account.clone() { + let user = self.user_mut(source.to_string()).await; + user.username = Some(username); } - let channel = self - .state - .channels - .entry(target.clone()) - .or_insert_with(|| Channel::new(target.clone())); + let channel = self.channel_mut(target.clone()).await; channel.messages.insert(msgid, state_message.clone()); - if self.current_batch.as_ref().map(|b| b.is_chathistory()) != Some(true) { - self.on_event(ServerEvent::Privmsg { - channel: target.clone(), - message: state_message, - }) - .await?; + if source == self.state.me.as_ref().unwrap().nickname + && let Err(e) = self.response_channels.reply( + &CommandKey::Privmsg { + target: target.clone(), + text: text.clone(), + }, + CommandResponse::Privmsg(Box::new(state_message.clone())), + ) + { + error!("Failed to reply to PRIVMSG command {e:?}"); } + + self.on_event(ServerEvent::Privmsg { + channel: target.clone(), + message: state_message, + }) + .await?; } - BATCH(reference, typ, param) => { + BATCH(ref reference, ref typ, ref param) => { + let mut tags = Tags::default(); + if let Some(ref t) = message.tags { + tags = Tags::parse(t); + } if let Some(id) = reference.strip_prefix('+') { - self.current_batch = Some(Batch { - id: id.to_string(), - typ: typ.clone().unwrap(), - }); - match typ { - Some(BatchSubCommand::CUSTOM(c)) if &c == "METADATA" => (), - _ => warn!(?typ, ?param, "unhandled BATCH type"), + Some(BatchSubCommand::CUSTOM(c)) if c.as_str() == "CHATHISTORY" => { + let idx = self + .requested_history_batches + .iter() + .position(|b| b.0.label == tags.label) + .expect("Chat history was requested"); + let channel = self.requested_history_batches.remove(idx).0.channel; + + self.current_batches.push(CurrentBatch { + id: id.to_string(), + data: BatchData::History { + label: tags.label, + channel, + messages: Vec::new(), + }, + }); + } + Some(BatchSubCommand::CUSTOM(c)) if c.as_str() == "DRAFT/MULTILINE" => { + let source = message.source_nickname().unwrap(); + let target = message.response_target().unwrap(); + let msgid = tags.msgid_with_fallback(&["MULTILINE", source, target]); + + let reply = tags + .reply + .as_ref() + .map(|r| { + self.state + .channels + .get(target) + .and_then(|c| c.messages.get(r)) + }) + .map(|m| MessageReference { + text: m.and_then(|m| m.text.clone().map(|t| t.content)), + username: m.map(|m| m.metadata.user.clone()), + }); + + self.current_batches.push(CurrentBatch { + id: id.to_string(), + data: BatchData::Multiline { + target: String::new(), + message: Message { + metadata: MessageMetadata { + msgid, + message_type: MessageType::Privmsg, + server_time: tags.server_time_with_fallback() as f64, + user: source.to_string(), + }, + text: Some(TextMessage { + content: Default::default(), + reactions: Default::default(), + reply, + redacted: false, + edited: false, + relayed_by: tags.relayed_by, + }), + }, + }, + }); + } + _ => { + self.current_batches.push(CurrentBatch { + id: id.to_string(), + data: BatchData::Unhandled, + }); + warn!(?typ, ?param, "unhandled BATCH type"); + } } } else { assert_eq!( - self.current_batch.as_ref().map(|s| s.id.as_str()), + self.current_batches.iter().last().map(|b| b.id.as_str()), Some(&reference[1..]) ); - self.current_batch = None; - } - } - Raw(ref cmd, ref mut target) if cmd == "TAGMSG" => { - let target = target.remove(0); + if let Some(batch) = self.current_batches.pop() { + match batch.data { + BatchData::History { + label, + channel: channel_name, + messages, + } => { + let channel = self.channel_mut(channel_name.clone()).await; + for message in &messages { + channel + .messages + .insert(message.metadata.msgid.clone(), message.clone()); + } + + let history = History { + channel: channel_name.clone(), + messages, + }; + + let key = if let Some(label) = label { + CommandKey::Label(label) + } else { + CommandKey::History + }; + + self.response_channels + .reply( + &CommandKey::Join(channel_name.clone()), + CommandResponse::Join(channel_name.clone()), + ) + .map_err(|e| { + anyhow!("Failed to reply to JOIN command {e:?}") + })?; + + self.response_channels + .reply(&key, CommandResponse::History(history.clone())) + .unwrap(); + } - let mut react = None; - let mut unreact = None; - let mut reply = None; - if let Some(ref tags) = message.tags { - for Tag(key, value) in tags { - match key.as_str() { - "+draft/reply" | "+reply" => reply = value.clone(), - "+draft/react" => react = value.clone(), - "+draft/unreact" => unreact = value.clone(), - _ => { - warn!("unhandled tag: {key:?}: {value:?}"); + BatchData::Multiline { + target, + message: state_message, + } => { + let source = message.source_nickname().unwrap(); + + if self + .push_batch(target.to_string(), state_message.clone()) + .await + { + return Ok(()); + } + + if let Some(username) = tags.account.clone() { + let user = self.user_mut(source.to_string()).await; + user.username = Some(username); + } + + let channel = self.channel_mut(target.to_string()).await; + channel.messages.insert( + state_message.metadata.msgid.clone(), + state_message.clone(), + ); + + if source == self.state.me.as_ref().unwrap().nickname + && let Err(e) = self.response_channels.reply( + &CommandKey::Privmsg { + target: target.to_string(), + text: state_message + .text + .as_ref() + .unwrap() + .content + .clone(), + }, + CommandResponse::Privmsg(Box::new(state_message.clone())), + ) + { + error!("Failed to reply to PRIVMSG command {e:?}"); + } + + self.on_event(ServerEvent::Privmsg { + channel: target.to_string(), + message: state_message, + }) + .await?; } + BatchData::Unhandled => (), } } } + } + ChannelMODE(ref channel_name, ref mode) => { + let target = message.response_target().unwrap(); + let source = message.source_nickname().unwrap(); - let channel = self - .state - .channels - .entry(target.clone()) - .or_insert_with(|| Channel::new(target.clone())); + if !self.current_batches.iter().any(|b| b.is_chathistory()) { + dbg!(target, source, channel_name, mode); + } + } + TOPIC(ref channel_name, ref text) => { + let target = message.response_target().unwrap(); + let source = message.source_nickname().unwrap(); - let is_unreact = unreact.is_some(); - if let Some(react) = react.or(unreact) - && let Some(reply) = reply + if !self.current_batches.iter().any(|b| b.is_chathistory()) { + dbg!(target, source, channel_name, text); + } + } + Raw(ref cmd, ref mut target) if cmd == "TAGMSG" => { + let target = target.remove(0); + + let mut tags = Tags::default(); + if let Some(ref t) = message.tags { + tags = Tags::parse(t); + } + + let is_unreact = tags.unreact.is_some(); + if let Some(react) = tags.react.or(tags.unreact) + && let Some(reply) = tags.reply { + let channel = self.channel_mut(target.clone()).await; + let nickname = message.source_nickname().unwrap().to_string(); if let Some(message) = channel.messages.get_mut(&reply) { let reactors = message @@ -422,18 +793,18 @@ impl IrcActor { .or_insert_with(Vec::new); if is_unreact { - reactors.push(nickname.clone()); - } else { reactors.retain(|v| *v != nickname); + } else { + reactors.push(nickname.clone()); } // TODO: should it be sent if the message wasn't found? - self.on_event(ServerEvent::React(React { + self.on_event(ServerEvent::React { target_message: reply, user: nickname, text: react, is_unreact, - })) + }) .await?; } } @@ -491,6 +862,25 @@ impl IrcActor { Ok(()) } + pub async fn push_batch(&mut self, target: String, state_message: Message) -> bool { + if let Some(batch) = self.current_batches.iter_mut().find(|b| b.is_chathistory()) + && let BatchData::History { + channel, messages, .. + } = &mut batch.data + { + if channel.is_empty() { + *channel = target.clone(); + } else { + assert_eq!(*channel, target) + } + messages.push(state_message); + + return true; + } + + false + } + #[tracing::instrument(err, skip(self))] pub async fn handle_caps( &mut self, @@ -526,7 +916,6 @@ impl IrcActor { } self.response_channels .reply(&CommandKey::RequestCaps, CommandResponse::Capabilities) - .await .unwrap(); } _ => { @@ -560,7 +949,6 @@ impl IrcActor { &CommandKey::SignIn, CommandResponse::SignIn(Ok(SignedIn::User)), ) - .await .map_err(|e| anyhow!("Failed to reply to sign in command {e:?}"))?; self.cap_end().await.context("Failed to send CAP END")?; @@ -571,7 +959,6 @@ impl IrcActor { &CommandKey::SignIn, CommandResponse::SignIn(Ok(SignedIn::Guest)), ) - .await .map_err(|e| anyhow!("Failed to reply to sign in command {e:?}"))?; } Response::RPL_LOGGEDIN => { @@ -583,7 +970,6 @@ impl IrcActor { &CommandKey::SignIn, CommandResponse::SignIn(Err(OrbitError::SaslFailed(params[1].to_string()))), ) - .await .map_err(|e| anyhow!("Failed to reply to sign in command {e:?}"))?; } Response::ERR_NICKNAMEINUSE => { @@ -592,17 +978,12 @@ impl IrcActor { &CommandKey::SignIn, CommandResponse::SignIn(Err(OrbitError::NickTaken)), ) - .await .map_err(|e| anyhow!("Failed to reply to sign in command {e:?}"))?; } Response::RPL_TOPIC => { let channel_name = params[1].to_string(); let topic = params[2].to_string(); - let channel = self - .state - .channels - .entry(channel_name.clone()) - .or_insert_with(|| Channel::new(channel_name)); + let channel = self.channel_mut(channel_name).await; channel.metadata.topic = Some(topic); @@ -629,7 +1010,7 @@ impl IrcActor { }); } else { channel_users.push(ChannelUser { - role: ChannelRole::None, + role: ChannelRole::Regular, nickname: user.clone(), }); } @@ -640,11 +1021,7 @@ impl IrcActor { .or_insert_with(|| User::new(user)); } - let channel = self - .state - .channels - .entry(channel_name.clone()) - .or_insert_with(|| Channel::new(channel_name)); + let channel = self.channel_mut(channel_name).await; channel.users = channel_users; } @@ -690,6 +1067,13 @@ impl IrcActor { .unwrap() .send(CommandResponse::GetState(Box::new(self.state.clone()))) .unwrap(), + ActorCommand::GetChannelState(channel_name) => cmd + .reply_tx + .unwrap() + .send(CommandResponse::GetChannelState(Box::new( + self.state.channels.get(&channel_name).cloned(), + ))) + .unwrap(), ActorCommand::SignIn { nick, user, @@ -733,6 +1117,33 @@ impl IrcActor { ActorCommand::AddDisconectHandler { handler } => { self.disconnect_handlers.push(handler); } + ActorCommand::RequestHistory { + channel, + before_msgid, + } => { + let label = if self.state.capabilities.labeled_response.enabled { + Some( + self.response_channels + .register_labeled(cmd.reply_tx.unwrap()), + ) + } else { + self.response_channels + .register(CommandKey::History, cmd.reply_tx.unwrap()); + + None + }; + + self.requested_history_batches.push(( + RequestedHistory { + channel: channel.clone(), + label: label.clone(), + }, + Instant::now(), + )); + self.history_before(channel, format!("msgid={before_msgid}"), 5, label) + .await + .context("Failed to send history before")?; + } } Ok(()) @@ -773,6 +1184,7 @@ impl IrcActor { .context("Failed to send CAPS LS")?; self.cap_req(&[ "echo-message", + "labeled-response", "message-tags", "sasl", "draft/message-redaction", @@ -843,6 +1255,20 @@ impl IrcActor { Ok(()) } + + async fn channel_mut(&mut self, name: String) -> &mut Channel { + self.state + .channels + .entry(name.clone()) + .or_insert_with(|| Channel::new(name)) + } + + async fn user_mut(&mut self, nickname: String) -> &mut User { + self.state + .users + .entry(nickname.clone()) + .or_insert_with(|| User::new(nickname)) + } } impl SendCommand for IrcActor { diff --git a/packages/core/core-shared/src/send_command.rs b/packages/core/core-shared/src/send_command.rs index 373a05a..57194b7 100644 --- a/packages/core/core-shared/src/send_command.rs +++ b/packages/core/core-shared/src/send_command.rs @@ -1,8 +1,10 @@ +use core::fmt; + use futures::{ SinkExt, channel::mpsc::{self, UnboundedSender}, }; -use irc_proto::{CapSubCommand, Command::*, Message as IrcMessage}; +use irc_proto::{CapSubCommand, Command::*, Message as IrcMessage, message::Tag}; pub trait SendCommand { type Error: std::error::Error + Send + Sync + 'static; @@ -12,10 +14,11 @@ pub trait SendCommand { fn command( &mut self, command: irc_proto::Command, + label: Option, ) -> impl Future> { async { self.message(IrcMessage { - tags: None, + tags: label.map(|l| vec![Tag(String::from("label"), Some(l))]), prefix: None, command, }) @@ -30,7 +33,7 @@ pub trait SendCommand { server1: String, server2: Option, ) -> impl std::future::Future> { - async { self.command(PONG(server1, server2)).await } + async { self.command(PONG(server1, server2), None).await } } fn cap_ls( @@ -38,7 +41,7 @@ pub trait SendCommand { version: String, ) -> impl std::future::Future> { async { - self.command(CAP(None, CapSubCommand::LS, Some(version), None)) + self.command(CAP(None, CapSubCommand::LS, Some(version), None), None) .await } } @@ -48,24 +51,27 @@ pub trait SendCommand { caps: &[&str], ) -> impl std::future::Future> { async { - self.command(CAP(None, CapSubCommand::REQ, None, Some(caps.join(" ")))) - .await + self.command( + CAP(None, CapSubCommand::REQ, None, Some(caps.join(" "))), + None, + ) + .await } } fn cap_end(&mut self) -> impl std::future::Future> { async { - self.command(CAP(None, CapSubCommand::END, None, None)) + self.command(CAP(None, CapSubCommand::END, None, None), None) .await } } fn nick(&mut self, nick: String) -> impl std::future::Future> { - async { self.command(NICK(nick)).await } + async { self.command(NICK(nick), None).await } } fn sasl(&mut self, req: String) -> impl std::future::Future> { - async { self.command(AUTHENTICATE(req)).await } + async { self.command(AUTHENTICATE(req), None).await } } fn sasl_plain(&mut self) -> impl std::future::Future> { @@ -82,7 +88,7 @@ pub trait SendCommand { mode: String, realname: String, ) -> impl std::future::Future> { - async { self.command(USER(user, mode, realname)).await } + async { self.command(USER(user, mode, realname), None).await } } fn join( @@ -90,7 +96,7 @@ pub trait SendCommand { channel: String, password: Option, ) -> impl std::future::Future> { - async { self.command(JOIN(channel, password, None)).await } + async { self.command(JOIN(channel, password, None), None).await } } fn privmsg( @@ -98,7 +104,7 @@ pub trait SendCommand { target: String, message: String, ) -> impl std::future::Future> { - async { self.command(PRIVMSG(target, message)).await } + async { self.command(PRIVMSG(target, message), None).await } } fn whois( @@ -106,7 +112,58 @@ pub trait SendCommand { server: Option, user: String, ) -> impl std::future::Future> { - async { self.command(WHOIS(server, user)).await } + async { self.command(WHOIS(server, user), None).await } + } + + fn history( + &mut self, + subcommand: ChatHistorySubCommand, + mut args: Vec, + label: Option, + ) -> impl std::future::Future> { + async move { + args.insert(0, subcommand.to_string()); + self.command(Raw("CHATHISTORY".to_string(), args), label) + .await + } + } + + fn history_before( + &mut self, + target: String, + before: String, + limit: i32, + label: Option, + ) -> impl std::future::Future> { + async move { + self.history( + ChatHistorySubCommand::Before, + vec![target, before, limit.to_string()], + label, + ) + .await + } + } + + fn history_latest( + &mut self, + target: String, + since: Option, + limit: i32, + label: Option, + ) -> impl std::future::Future> { + async move { + self.history( + ChatHistorySubCommand::Latest, + vec![ + target, + since.unwrap_or_else(|| String::from("*")), + limit.to_string(), + ], + label, + ) + .await + } } } @@ -118,3 +175,25 @@ impl SendCommand for UnboundedSender { Ok(()) } } + +pub enum ChatHistorySubCommand { + Before, + After, + Latest, + Around, + Between, + Targets, +} + +impl fmt::Display for ChatHistorySubCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Before => write!(f, "BEFORE"), + Self::After => write!(f, "AFTER"), + Self::Latest => write!(f, "LATEST"), + Self::Around => write!(f, "AROUND"), + Self::Between => write!(f, "BETWEEN"), + Self::Targets => write!(f, "TARGETS"), + } + } +} diff --git a/packages/core/core-shared/src/state.rs b/packages/core/core-shared/src/state.rs index 8c67dbc..27aee8c 100644 --- a/packages/core/core-shared/src/state.rs +++ b/packages/core/core-shared/src/state.rs @@ -3,9 +3,12 @@ use std::str::FromStr; #[cfg(feature = "web")] use crate::dbg; +use irc_proto::message::Tag; use ordermap::OrderMap; use thiserror::Error; -use tracing::error; +use time::OffsetDateTime; +use time::format_description::well_known::Iso8601; +use tracing::{error, warn}; #[cfg(feature = "web")] use tsify::Tsify; #[cfg(feature = "web")] @@ -466,7 +469,7 @@ pub enum ChannelRole { Operator, HalfOperator, Voice, - None, + Regular, } impl From for ChannelRole { @@ -547,8 +550,10 @@ impl Eq for MessageMetadata {} #[cfg_attr(feature = "web", derive(Tsify))] #[cfg_attr(feature = "web", wasm_bindgen(getter_with_clone, inspectable))] pub struct MessageReference { - pub username: String, - pub text: String, + /// Unset if message wasn't found or if reply wasn't to a text message + pub text: Option, + /// Unset if message wasn't found + pub username: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -564,17 +569,18 @@ pub enum ServerEvent { channel: String, message: Message, }, - React(React), + React { + target_message: String, + user: String, + text: String, + is_unreact: bool, + }, } #[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "web", derive(Tsify))] -#[cfg_attr(feature = "web", wasm_bindgen(getter_with_clone, inspectable))] -pub struct React { - pub target_message: String, - pub user: String, - pub text: String, - pub is_unreact: bool, +pub struct History { + pub channel: String, + pub messages: Vec, } #[derive(Debug, Clone)] @@ -610,3 +616,80 @@ impl From for OrbitError { Self::Unknown(error.to_string()) } } + +#[derive(Debug, Default)] +pub struct Tags { + pub server_time: Option, + pub msgid: Option, + pub account: Option, + pub relayed_by: Option, + pub batch: Option, + pub bot: Option, + pub label: Option, + pub reply: Option, + pub react: Option, + pub unreact: Option, + pub typing: Option, +} + +impl Tags { + pub fn parse(tags: &Vec) -> Self { + let mut out = Tags::default(); + + for Tag(key, value) in tags { + match key.as_str() { + "time" => { + out.server_time = value + .as_ref() + .and_then(|v| OffsetDateTime::parse(v, &Iso8601::DEFAULT).ok()) + } + "msgid" => out.msgid = value.clone(), + "account" => out.account = value.clone(), + "draft/relaymsg" => out.relayed_by = value.clone(), + "batch" => out.batch = value.clone(), + "bot" => out.bot = value.clone(), + "label" => out.label = value.clone(), + "+draft/reply" | "+reply" => out.reply = value.clone(), + "+draft/react" => out.react = value.clone(), + "+draft/unreact" => out.unreact = value.clone(), + "+typing" => out.typing = value.clone(), + _ => { + warn!("unhandled tag: {key:?}: {value:?}"); + } + } + } + + out + } + + #[cfg(feature = "web")] + pub fn server_time_with_fallback(&self) -> i64 { + self.server_time + .map(|t| t.unix_timestamp()) + .unwrap_or_else(|| { + web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + }) + } + + #[cfg(not(feature = "web"))] + pub fn server_time_with_fallback(&self) -> i64 { + self.server_time + .unwrap_or_else(OffsetDateTime::now_utc) + .unix_timestamp() + } + + pub fn msgid_with_fallback(&self, hash_extras: &[&str]) -> String { + self.msgid.clone().unwrap_or_else(|| { + let mut hasher = blake3::Hasher::new(); + hasher.update(&self.server_time_with_fallback().to_ne_bytes()); + for extra in hash_extras { + hasher.update(extra.as_bytes()); + } + + hasher.finalize().to_string() + }) + } +} diff --git a/packages/core/core-wasm/Cargo.toml b/packages/core/core-wasm/Cargo.toml index 5ed5938..2459cb3 100644 --- a/packages/core/core-wasm/Cargo.toml +++ b/packages/core/core-wasm/Cargo.toml @@ -22,7 +22,7 @@ gloo-console = { version = "0.4.0" } gloo-net = "0.7.0" irc-proto = "1.1.0" -core-shared = { version = "0.1.0", path = "../core-shared", features = ["web"] } +core-shared = { version = "0.1.0", path = "../core-shared", features = ["web"], default-features = false } indexed_db_futures = { version = "0.6.4", features = ["async-upgrade", "serde"] } serde = { version = "1.0.228", features = ["derive"] } anyhow = { version = "1.0.103", features = ["backtrace"] } diff --git a/packages/core/core-wasm/src/lib.rs b/packages/core/core-wasm/src/lib.rs index f79f1c6..64a160d 100644 --- a/packages/core/core-wasm/src/lib.rs +++ b/packages/core/core-wasm/src/lib.rs @@ -5,7 +5,7 @@ use core_shared::{ SendCommand, actor::{self, ActorCommand, ActorMessage, CommandResponse, IrcActor}, state::{ - self, Capabilities, ChannelMetadata, ChannelUser, MessageMetadata, MessageReference, React, + self, Capabilities, ChannelMetadata, ChannelUser, MessageMetadata, MessageReference, ServerMetadata, SignedIn, User, }, }; @@ -137,7 +137,7 @@ impl IrcConnection { .await .context("Failed to send ActorMessage")?; - let resp = rx.await.context("Failed to await ActorMessage")?; + let resp = rx.await.context("Failed to await actor state message")?; let CommandResponse::GetState(server) = resp else { unreachable!("expected state, got: {:?}", resp); }; @@ -253,7 +253,7 @@ impl IrcConnection { .await .context("Failed to send ActorMessage")?; - let resp = rx.await.context("Failed to await ActorMessage")?; + let resp = rx.await.context("Failed to await actor sign in message")?; let CommandResponse::SignIn(result) = resp else { unreachable!("expected sign in, got: {:?}", resp); }; @@ -281,7 +281,7 @@ impl IrcConnection { .await .context("Failed to send ActorMessage")?; - let resp = rx.await.context("Failed to await ActorMessage")?; + let resp = rx.await.context("Failed to await actor sign in message")?; let CommandResponse::SignIn(result) = resp else { unreachable!("expected sign in, got: {:?}", resp); @@ -305,7 +305,7 @@ impl IrcConnection { .await .context("Failed to send ActorMessage")?; - let resp = rx.await.context("Failed to await ActorMessage")?; + let resp = rx.await.context("Failed to await actor join message")?; let CommandResponse::Join(name) = resp else { unreachable!("expected join, got: {:?}", resp); }; @@ -315,6 +315,32 @@ impl IrcConnection { address: self.address.clone(), }) } + + #[wasm_bindgen] + pub async fn history_before( + &mut self, + channel: String, + before_msgid: String, + ) -> Result { + let (tx, rx) = oneshot::channel(); + self.address + .send(ActorMessage { + command: ActorCommand::RequestHistory { + channel, + before_msgid, + }, + reply_tx: Some(tx), + }) + .await + .context("Failed to send ActorMessage")?; + + let resp = rx.await.context("Failed to await actor history message")?; + let CommandResponse::History(history) = resp else { + unreachable!("expected history, got: {:?}", resp); + }; + + Ok(history.into()) + } } #[wasm_bindgen] @@ -325,6 +351,25 @@ pub struct IrcChannel { #[wasm_bindgen] impl IrcChannel { + #[wasm_bindgen] + pub async fn state(&mut self) -> Result, OrbitError> { + let (tx, rx) = oneshot::channel(); + self.address + .send(ActorMessage { + command: ActorCommand::GetChannelState(self.name.clone()), + reply_tx: Some(tx), + }) + .await + .context("Failed to send ActorMessage")?; + + let resp = rx.await.context("Failed to await actor state message")?; + let CommandResponse::GetChannelState(channel) = resp else { + unreachable!("expected state, got: {:?}", resp); + }; + + Ok((*channel).map(Into::into)) + } + #[wasm_bindgen] pub async fn send_message(&mut self, text: String) -> Result { let (tx, rx) = oneshot::channel(); @@ -339,9 +384,9 @@ impl IrcChannel { .await .context("Failed to send ActorMessage")?; - let resp = rx.await.context("Failed to await ActorMessage")?; + let resp = rx.await.context("Failed to await actor message")?; let CommandResponse::Privmsg(message) = resp else { - unreachable!("expected join, got: {:?}", resp); + unreachable!("expected privmsg, got: {:?}", resp); }; Ok((*message).into()) @@ -490,7 +535,17 @@ impl From for ServerEvent { channel, message: message.into(), }), - state::ServerEvent::React(r) => Self::React(r), + state::ServerEvent::React { + target_message, + user, + text, + is_unreact, + } => Self::React(React { + target_message, + user, + text, + is_unreact, + }), } } } @@ -595,3 +650,28 @@ impl From for OrbitError { } } } + +#[derive(Debug, Clone, PartialEq, Eq, Tsify)] +#[wasm_bindgen(getter_with_clone, inspectable)] +pub struct React { + pub target_message: String, + pub user: String, + pub text: String, + pub is_unreact: bool, +} + +#[derive(Debug, Clone, Tsify)] +#[wasm_bindgen(getter_with_clone, inspectable)] +pub struct History { + pub channel: String, + pub messages: Vec, +} + +impl From for History { + fn from(history: state::History) -> Self { + Self { + channel: history.channel, + messages: history.messages.into_iter().map(Into::into).collect(), + } + } +}