From 7e69520c251cf9064880322b2345743baafe4861 Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Fri, 18 Oct 2024 22:33:27 +0000 Subject: [PATCH 1/5] add CHANTYPES support --- data/src/client.rs | 52 +++++++++++++++++++++++++------------ data/src/history.rs | 4 +-- data/src/history/manager.rs | 3 ++- data/src/input.rs | 4 +-- data/src/isupport.rs | 13 +++++----- data/src/message.rs | 14 ++++++---- irc/proto/src/lib.rs | 22 ++++++++++------ src/buffer/input_view.rs | 3 ++- src/main.rs | 6 +++++ src/screen/dashboard.rs | 3 ++- 10 files changed, 82 insertions(+), 42 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index 979a7a2ff..23c364ba2 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -188,6 +188,17 @@ impl Client { } } + fn start_reroute(&self, command: &Command) -> bool { + use Command::*; + + if let MODE(target, _, _) = command { + !self.is_channel(target) + } else { + matches!(command, WHO(..) | WHOIS(..) | WHOWAS(..)) + } + } + + fn send(&mut self, buffer: &buffer::Upstream, mut message: message::Encoded) { if self.supports_labels { use proto::Tag; @@ -204,7 +215,7 @@ impl Client { }]; } - self.reroute_responses_to = start_reroute(&message.command).then(|| buffer.clone()); + self.reroute_responses_to = self.start_reroute(&message.command).then(|| buffer.clone()); if let Err(e) = self.handle.try_send(message.into()) { log::warn!("Error sending message: {e}"); @@ -896,7 +907,7 @@ impl Client { Command::Numeric(RPL_WHOREPLY, args) => { let target = args.get(1)?; - if proto::is_channel(target) { + if self.is_channel(target) { if let Some(channel) = self.chanmap.get_mut(target) { channel.update_user_away(args.get(5)?, args.get(6)?); @@ -915,7 +926,7 @@ impl Client { Command::Numeric(RPL_WHOSPCRPL, args) => { let target = args.get(2)?; - if proto::is_channel(target) { + if self.is_channel(target) { if let Some(channel) = self.chanmap.get_mut(target) { channel.update_user_away(args.get(3)?, args.get(4)?); @@ -947,7 +958,7 @@ impl Client { Command::Numeric(RPL_ENDOFWHO, args) => { let target = args.get(1)?; - if proto::is_channel(target) { + if self.is_channel(target) { if let Some(channel) = self.chanmap.get_mut(target) { if matches!(channel.last_who, Some(WhoStatus::Receiving(_))) { channel.last_who = Some(WhoStatus::Done(Instant::now())); @@ -995,7 +1006,7 @@ impl Client { } } Command::MODE(target, Some(modes), Some(args)) => { - if proto::is_channel(target) { + if self.is_channel(target) { let modes = mode::parse::(modes, args); if let Some(channel) = self.chanmap.get_mut(target) { @@ -1053,7 +1064,7 @@ impl Client { Command::Numeric(RPL_ENDOFNAMES, args) => { let target = args.get(1)?; - if proto::is_channel(target) { + if self.is_channel(target) { if let Some(channel) = self.chanmap.get_mut(target) { if !channel.names_init { channel.names_init = true; @@ -1395,6 +1406,19 @@ impl Client { } } } + + pub fn chantypes(&self) -> &[char] { + self.isupport.get(&isupport::Kind::CHANTYPES).and_then(|chantypes| { + let isupport::Parameter::CHANTYPES(types) = chantypes else { + unreachable!("Corruption in isupport table.") + }; + types.as_deref() + }).unwrap_or(proto::DEFAULT_CHANNEL_PREFIXES) + } + + pub fn is_channel(&self, target: &str) -> bool { + proto::is_channel(target, self.chantypes()) + } } #[derive(Debug)] @@ -1549,6 +1573,12 @@ impl Map { .unwrap_or_default() } + pub fn get_chantypes<'a>(&'a self, server: &Server) -> &'a [char] { + self.client(server) + .map(|client| client.chantypes()) + .unwrap_or_default() + } + pub fn get_server_handle(&self, server: &Server) -> Option<&server::Handle> { self.client(server).map(|client| &client.handle) } @@ -1637,16 +1667,6 @@ fn remove_tag(key: &str, tags: &mut Vec) -> Option { .value } -fn start_reroute(command: &Command) -> bool { - use Command::*; - - if let MODE(target, _, _) = command { - !proto::is_channel(target) - } else { - matches!(command, WHO(..) | WHOIS(..) | WHOWAS(..)) - } -} - fn stop_reroute(command: &Command) -> bool { use command::Numeric::*; diff --git a/data/src/history.rs b/data/src/history.rs index 98d6737ad..c074814f6 100644 --- a/data/src/history.rs +++ b/data/src/history.rs @@ -36,8 +36,8 @@ pub enum Kind { } impl Kind { - pub fn from_target(server: Server, target: String) -> Self { - if proto::is_channel(&target) { + pub fn from_target(server: Server, target: String, chantypes: &[char]) -> Self { + if proto::is_channel(&target, chantypes) { Self::Channel(server, target) } else { Self::Query(server, Nick::from(target)) diff --git a/data/src/history/manager.rs b/data/src/history/manager.rs index 8f7699d9f..46f51dbc7 100644 --- a/data/src/history/manager.rs +++ b/data/src/history/manager.rs @@ -184,10 +184,11 @@ impl Manager { input: Input, user: User, channel_users: &[User], + chantypes: &[char], ) -> Vec> { let mut tasks = vec![]; - if let Some(messages) = input.messages(user, channel_users) { + if let Some(messages) = input.messages(user, channel_users, chantypes) { for message in messages { tasks.extend(self.record_message(input.server(), message)); } diff --git a/data/src/input.rs b/data/src/input.rs index f4b344c35..5f8152274 100644 --- a/data/src/input.rs +++ b/data/src/input.rs @@ -63,9 +63,9 @@ impl Input { self.buffer.server() } - pub fn messages(&self, user: User, channel_users: &[User]) -> Option> { + pub fn messages(&self, user: User, channel_users: &[User], chantypes: &[char]) -> Option> { let to_target = |target: &str, source| { - if let Some((prefix, channel)) = proto::parse_channel_from_target(target) { + if let Some((prefix, channel)) = proto::parse_channel_from_target(target, chantypes) { Some(message::Target::Channel { channel, source, diff --git a/data/src/isupport.rs b/data/src/isupport.rs index ac4c94474..4037b4757 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -9,6 +9,7 @@ pub enum Kind { AWAYLEN, CHANLIMIT, CHANNELLEN, + CHANTYPES, CNOTICE, CPRIVMSG, ELIST, @@ -139,12 +140,11 @@ impl FromStr for Operation { parse_required_positive_integer(value)?, ))), "CHANTYPES" => { - if value.is_empty() { + let chars = value.chars().collect::>(); + if chars.is_empty() { Ok(Operation::Add(Parameter::CHANTYPES(None))) - } else if value.chars().all(|c| proto::CHANNEL_PREFIXES.contains(&c)) { - Ok(Operation::Add(Parameter::CHANTYPES(Some( - value.to_string(), - )))) + } else if chars.iter().all(|c| proto::CHANNEL_PREFIXES.contains(c)) { + Ok(Operation::Add(Parameter::CHANTYPES(Some(chars)))) } else { Err("value must only contain channel types if specified") } @@ -485,6 +485,7 @@ impl Operation { "AWAYLEN" => Some(Kind::AWAYLEN), "CHANLIMIT" => Some(Kind::CHANLIMIT), "CHANNELLEN" => Some(Kind::CHANNELLEN), + "CHANTYPES" => Some(Kind::CHANTYPES), "CNOTICE" => Some(Kind::CNOTICE), "CPRIVMSG" => Some(Kind::CPRIVMSG), "ELIST" => Some(Kind::ELIST), @@ -526,7 +527,7 @@ pub enum Parameter { CHANLIMIT(Vec), CHANMODES(Vec), CHANNELLEN(u16), - CHANTYPES(Option), + CHANTYPES(Option>), CHATHISTORY(u16), CLIENTTAGDENY(Vec), CLIENTVER(u16, u16), diff --git a/data/src/message.rs b/data/src/message.rs index 9542b989b..787bbcb6a 100644 --- a/data/src/message.rs +++ b/data/src/message.rs @@ -188,6 +188,7 @@ impl Message { config: &'a Config, resolve_attributes: impl Fn(&User, &str) -> Option, channel_users: impl Fn(&str) -> &'a [User], + chantypes: &[char] ) -> Option { let server_time = server_time(&encoded); let id = message_id(&encoded); @@ -197,8 +198,9 @@ impl Message { config, &resolve_attributes, &channel_users, + chantypes, )?; - let target = target(encoded, &our_nick, &resolve_attributes)?; + let target = target(encoded, &our_nick, &resolve_attributes, chantypes)?; let received_at = Posix::now(); let hash = Hash::new(&received_at, &content); @@ -610,6 +612,7 @@ fn target( message: Encoded, our_nick: &Nick, resolve_attributes: &dyn Fn(&User, &str) -> Option, + chantypes: &[char], ) -> Option { use proto::command::Numeric::*; @@ -617,7 +620,7 @@ fn target( match message.0.command { // Channel - Command::MODE(target, ..) if proto::is_channel(&target) => Some(Target::Channel { + Command::MODE(target, ..) if proto::is_channel(&target, chantypes) => Some(Target::Channel { channel: target, source: source::Source::Server(None), prefix: None, @@ -681,7 +684,7 @@ fn target( } }; - match (proto::parse_channel_from_target(&target), user) { + match (proto::parse_channel_from_target(&target, chantypes), user) { (Some((prefix, channel)), Some(user)) => { let source = source(resolve_attributes(&user, &channel).unwrap_or(user)); Some(Target::Channel { @@ -715,7 +718,7 @@ fn target( } }; - match (proto::parse_channel_from_target(&target), user) { + match (proto::parse_channel_from_target(&target, chantypes), user) { (Some((prefix, channel)), Some(user)) => { let source = source(resolve_attributes(&user, &channel).unwrap_or(user)); Some(Target::Channel { @@ -833,6 +836,7 @@ fn content<'a>( config: &Config, resolve_attributes: &dyn Fn(&User, &str) -> Option, channel_users: &dyn Fn(&str) -> &'a [User], + chantypes: &[char], ) -> Option { use irc::proto::command::Numeric::*; @@ -902,7 +906,7 @@ fn content<'a>( &[], )) } - Command::MODE(target, modes, args) if proto::is_channel(target) => { + Command::MODE(target, modes, args) if proto::is_channel(target, chantypes) => { let raw_user = message.user()?; let with_access_levels = config.buffer.nickname.show_access_levels; let user = resolve_attributes(&raw_user, target) diff --git a/irc/proto/src/lib.rs b/irc/proto/src/lib.rs index a951f09e1..d4f82884d 100644 --- a/irc/proto/src/lib.rs +++ b/irc/proto/src/lib.rs @@ -49,32 +49,38 @@ pub fn command(command: &str, parameters: Vec) -> Message { } /// Reference: https://defs.ircdocs.horse/defs/chantypes -pub const CHANNEL_PREFIXES: [char; 4] = ['#', '&', '+', '!']; +pub const CHANNEL_PREFIXES: &[char] = &['#', '&', '+', '!']; + +/// Reference: https://defs.ircdocs.horse/defs/chantypes +/// +/// Channel types which should be used if the CHANTYPES ISUPPORT is not returned +pub const DEFAULT_CHANNEL_PREFIXES: &[char] = &['#', '&']; + /// https://modern.ircdocs.horse/#channels /// /// Channel names are strings (beginning with specified prefix characters). Apart from the requirement of /// the first character being a valid channel type prefix character; the only restriction on a channel name /// is that it may not contain any spaces (' ', 0x20), a control G / BELL ('^G', 0x07), or a comma (',', 0x2C) /// (which is used as a list item separator by the protocol). -pub const CHANNEL_BLACKLIST_CHARS: [char; 3] = [',', '\u{07}', ',']; +pub const CHANNEL_BLACKLIST_CHARS: &[char] = &[',', '\u{07}', ',']; -pub fn is_channel(target: &str) -> bool { - target.starts_with(CHANNEL_PREFIXES) && !target.contains(CHANNEL_BLACKLIST_CHARS) +pub fn is_channel(target: &str, chantypes: &[char]) -> bool { + target.starts_with(chantypes) && !target.contains(CHANNEL_BLACKLIST_CHARS) } // Reference: https://defs.ircdocs.horse/defs/chanmembers -pub const CHANNEL_MEMBERSHIP_PREFIXES: [char; 6] = ['~', '&', '!', '@', '%', '+']; +pub const CHANNEL_MEMBERSHIP_PREFIXES: &[char] = &['~', '&', '!', '@', '%', '+']; -pub fn parse_channel_from_target(target: &str) -> Option<(Option, String)> { +pub fn parse_channel_from_target(target: &str, chantypes: &[char]) -> Option<(Option, String)> { if target.starts_with(CHANNEL_MEMBERSHIP_PREFIXES) { let channel = target.strip_prefix(CHANNEL_MEMBERSHIP_PREFIXES)?; - if is_channel(channel) { + if is_channel(channel, chantypes) { return Some((target.chars().next(), channel.to_string())); } } - if is_channel(target) { + if is_channel(target, chantypes) { Some((None, target.to_string())) } else { None diff --git a/src/buffer/input_view.rs b/src/buffer/input_view.rs index bfca0cd71..f76cc36e8 100644 --- a/src/buffer/input_view.rs +++ b/src/buffer/input_view.rs @@ -184,6 +184,7 @@ impl State { if let Some(nick) = clients.nickname(buffer.server()) { let mut user = nick.to_owned().into(); let mut channel_users = &[][..]; + let chantypes = clients.get_chantypes(buffer.server()); // Resolve our attributes if sending this message in a channel if let buffer::Upstream::Channel(server, channel) = &buffer { @@ -198,7 +199,7 @@ impl State { history_task = Task::batch( history - .record_input(input, user, channel_users) + .record_input(input, user, channel_users, chantypes) .into_iter() .map(Task::future), ); diff --git a/src/main.rs b/src/main.rs index 25ff72dd3..c85e67772 100644 --- a/src/main.rs +++ b/src/main.rs @@ -503,6 +503,8 @@ impl Halloy { self.clients.get_channel_users(&server, channel) }; + let chantypes = self.clients.get_chantypes(&server); + match event { data::client::Event::Single(encoded, our_nick) => { if let Some(message) = data::Message::received( @@ -511,6 +513,7 @@ impl Halloy { &self.config, resolve_user_attributes, channel_users, + chantypes, ) { commands.push( dashboard @@ -526,6 +529,7 @@ impl Halloy { &self.config, resolve_user_attributes, channel_users, + chantypes, ) { commands.push( dashboard @@ -642,6 +646,7 @@ impl Halloy { &self.config, resolve_user_attributes, channel_users, + chantypes, ) { commands.push( dashboard @@ -735,6 +740,7 @@ impl Halloy { history::Kind::from_target( server.clone(), target, + chantypes, ), read_marker, ) diff --git a/src/screen/dashboard.rs b/src/screen/dashboard.rs index b8d89ad17..e92c38c39 100644 --- a/src/screen/dashboard.rs +++ b/src/screen/dashboard.rs @@ -207,6 +207,7 @@ impl Dashboard { if let Some(nick) = clients.nickname(buffer.server()) { let mut user = nick.to_owned().into(); let mut channel_users = &[][..]; + let chantypes = clients.get_chantypes(buffer.server()); // Resolve our attributes if sending this message in a channel if let buffer::Upstream::Channel(server, channel) = @@ -225,7 +226,7 @@ impl Dashboard { } if let Some(messages) = - input.messages(user, channel_users) + input.messages(user, channel_users, chantypes) { let mut tasks = vec![task]; From 618d6c3b3e6f1ec95a6cb12927d064223eac26f2 Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Sat, 26 Oct 2024 19:19:05 +0000 Subject: [PATCH 2/5] add STATUSMSG support --- data/src/buffer.rs | 2 +- data/src/client.rs | 15 +++++++++++++ data/src/history/manager.rs | 5 +++-- data/src/input.rs | 4 ++-- data/src/isupport.rs | 10 ++++----- data/src/message.rs | 28 ++++++++++++----------- data/src/message/broadcast.rs | 2 +- irc/proto/src/lib.rs | 31 ++++++++++++++++--------- src/buffer/channel.rs | 2 +- src/buffer/input_view.rs | 3 ++- src/buffer/input_view/completion.rs | 35 +++++++++++++---------------- src/main.rs | 4 ++++ src/screen/dashboard.rs | 3 ++- 13 files changed, 87 insertions(+), 57 deletions(-) diff --git a/data/src/buffer.rs b/data/src/buffer.rs index 68313b681..dbf513b8a 100644 --- a/data/src/buffer.rs +++ b/data/src/buffer.rs @@ -75,7 +75,7 @@ impl Upstream { Self::Channel(_, channel) => message::Target::Channel { channel, source: message::Source::Server(source), - prefix: None, + prefix: Default::default(), }, Self::Query(_, nick) => message::Target::Query { nick, diff --git a/data/src/client.rs b/data/src/client.rs index 23c364ba2..22480fada 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -1416,6 +1416,15 @@ impl Client { }).unwrap_or(proto::DEFAULT_CHANNEL_PREFIXES) } + pub fn statusmsg(&self) -> &[char] { + self.isupport.get(&isupport::Kind::STATUSMSG).map(|statusmsg| { + let isupport::Parameter::STATUSMSG(prefixes) = statusmsg else { + unreachable!("Corruption in isupport table.") + }; + prefixes.as_ref() + }).unwrap_or(&[]) + } + pub fn is_channel(&self, target: &str) -> bool { proto::is_channel(target, self.chantypes()) } @@ -1579,6 +1588,12 @@ impl Map { .unwrap_or_default() } + pub fn get_statusmsg<'a>(&'a self, server: &Server) -> &'a [char] { + self.client(server) + .map(|client| client.statusmsg()) + .unwrap_or_default() + } + pub fn get_server_handle(&self, server: &Server) -> Option<&server::Handle> { self.client(server).map(|client| &client.handle) } diff --git a/data/src/history/manager.rs b/data/src/history/manager.rs index 46f51dbc7..78daddec6 100644 --- a/data/src/history/manager.rs +++ b/data/src/history/manager.rs @@ -185,10 +185,11 @@ impl Manager { user: User, channel_users: &[User], chantypes: &[char], + statusmsg: &[char], ) -> Vec> { let mut tasks = vec![]; - if let Some(messages) = input.messages(user, channel_users, chantypes) { + if let Some(messages) = input.messages(user, channel_users, chantypes, statusmsg) { for message in messages { tasks.extend(self.record_message(input.server(), message)); } @@ -600,7 +601,7 @@ impl Data { buffer_config .status_message_prefix .brackets - .format(prefix) + .format(prefix.iter().collect::()) .chars() .count() + 1 diff --git a/data/src/input.rs b/data/src/input.rs index 5f8152274..bc58bcad1 100644 --- a/data/src/input.rs +++ b/data/src/input.rs @@ -63,9 +63,9 @@ impl Input { self.buffer.server() } - pub fn messages(&self, user: User, channel_users: &[User], chantypes: &[char]) -> Option> { + pub fn messages(&self, user: User, channel_users: &[User], chantypes: &[char], statusmsg: &[char]) -> Option> { let to_target = |target: &str, source| { - if let Some((prefix, channel)) = proto::parse_channel_from_target(target, chantypes) { + if let Some((prefix, channel)) = proto::parse_channel_from_target(target, chantypes, statusmsg) { Some(message::Target::Channel { channel, source, diff --git a/data/src/isupport.rs b/data/src/isupport.rs index 4037b4757..53377471d 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -350,11 +350,11 @@ impl FromStr for Operation { parse_optional_positive_integer(value)?, ))), "STATUSMSG" => { - if value - .chars() - .all(|c| proto::CHANNEL_MEMBERSHIP_PREFIXES.contains(&c)) + let chars = value.chars().collect::>(); + if chars.iter() + .all(|c| proto::CHANNEL_MEMBERSHIP_PREFIXES.contains(c)) { - Ok(Operation::Add(Parameter::STATUSMSG(value.to_string()))) + Ok(Operation::Add(Parameter::STATUSMSG(chars))) } else { Err("unknown channel membership prefix(es)") } @@ -564,7 +564,7 @@ pub enum Parameter { SAFELIST, SECURELIST, SILENCE(Option), - STATUSMSG(String), + STATUSMSG(Vec), TARGMAX(Vec), TOPICLEN(u16), UHNAMES, diff --git a/data/src/message.rs b/data/src/message.rs index 787bbcb6a..9b81e9508 100644 --- a/data/src/message.rs +++ b/data/src/message.rs @@ -111,7 +111,7 @@ pub enum Target { Channel { channel: Channel, source: Source, - prefix: Option, + prefix: Vec, }, Query { nick: Nick, @@ -126,10 +126,10 @@ pub enum Target { } impl Target { - pub fn prefix(&self) -> Option<&char> { + pub fn prefix(&self) -> Option<&[char]> { match self { Target::Server { .. } => None, - Target::Channel { prefix, .. } => prefix.as_ref(), + Target::Channel { prefix, .. } => Some(prefix), Target::Query { .. } => None, Target::Logs => None, Target::Highlights { .. } => None, @@ -188,7 +188,8 @@ impl Message { config: &'a Config, resolve_attributes: impl Fn(&User, &str) -> Option, channel_users: impl Fn(&str) -> &'a [User], - chantypes: &[char] + chantypes: &[char], + statusmsg: &[char], ) -> Option { let server_time = server_time(&encoded); let id = message_id(&encoded); @@ -200,7 +201,7 @@ impl Message { &channel_users, chantypes, )?; - let target = target(encoded, &our_nick, &resolve_attributes, chantypes)?; + let target = target(encoded, &our_nick, &resolve_attributes, chantypes, statusmsg)?; let received_at = Posix::now(); let hash = Hash::new(&received_at, &content); @@ -613,6 +614,7 @@ fn target( our_nick: &Nick, resolve_attributes: &dyn Fn(&User, &str) -> Option, chantypes: &[char], + statusmsg: &[char], ) -> Option { use proto::command::Numeric::*; @@ -623,12 +625,12 @@ fn target( Command::MODE(target, ..) if proto::is_channel(&target, chantypes) => Some(Target::Channel { channel: target, source: source::Source::Server(None), - prefix: None, + prefix: Default::default(), }), Command::TOPIC(channel, _) | Command::KICK(channel, _, _) => Some(Target::Channel { channel, source: source::Source::Server(None), - prefix: None, + prefix: Default::default(), }), Command::PART(channel, _) => Some(Target::Channel { channel, @@ -636,7 +638,7 @@ fn target( source::server::Kind::Part, Some(user?.nickname().to_owned()), ))), - prefix: None, + prefix: Default::default(), }), Command::JOIN(channel, _) => Some(Target::Channel { channel, @@ -644,7 +646,7 @@ fn target( source::server::Kind::Join, Some(user?.nickname().to_owned()), ))), - prefix: None, + prefix: Default::default(), }), Command::Numeric(RPL_TOPIC | RPL_TOPICWHOTIME, params) => { let channel = params.get(1)?.clone(); @@ -654,7 +656,7 @@ fn target( source::server::Kind::ReplyTopic, None, ))), - prefix: None, + prefix: Default::default(), }) } Command::Numeric(RPL_CHANNELMODEIS, params) => { @@ -662,7 +664,7 @@ fn target( Some(Target::Channel { channel, source: source::Source::Server(None), - prefix: None, + prefix: Default::default(), }) } Command::Numeric(RPL_AWAY, params) => { @@ -684,7 +686,7 @@ fn target( } }; - match (proto::parse_channel_from_target(&target, chantypes), user) { + match (proto::parse_channel_from_target(&target, chantypes, statusmsg), user) { (Some((prefix, channel)), Some(user)) => { let source = source(resolve_attributes(&user, &channel).unwrap_or(user)); Some(Target::Channel { @@ -718,7 +720,7 @@ fn target( } }; - match (proto::parse_channel_from_target(&target, chantypes), user) { + match (proto::parse_channel_from_target(&target, chantypes, statusmsg), user) { (Some((prefix, channel)), Some(user)) => { let source = source(resolve_attributes(&user, &channel).unwrap_or(user)); Some(Target::Channel { diff --git a/data/src/message/broadcast.rs b/data/src/message/broadcast.rs index b0269c8d0..82e31f4be 100644 --- a/data/src/message/broadcast.rs +++ b/data/src/message/broadcast.rs @@ -47,7 +47,7 @@ fn expand( Target::Channel { channel, source: source.clone(), - prefix: None, + prefix: Default::default(), }, content.clone(), ) diff --git a/irc/proto/src/lib.rs b/irc/proto/src/lib.rs index d4f82884d..d2d02f09b 100644 --- a/irc/proto/src/lib.rs +++ b/irc/proto/src/lib.rs @@ -71,17 +71,26 @@ pub fn is_channel(target: &str, chantypes: &[char]) -> bool { // Reference: https://defs.ircdocs.horse/defs/chanmembers pub const CHANNEL_MEMBERSHIP_PREFIXES: &[char] = &['~', '&', '!', '@', '%', '+']; -pub fn parse_channel_from_target(target: &str, chantypes: &[char]) -> Option<(Option, String)> { - if target.starts_with(CHANNEL_MEMBERSHIP_PREFIXES) { - let channel = target.strip_prefix(CHANNEL_MEMBERSHIP_PREFIXES)?; - - if is_channel(channel, chantypes) { - return Some((target.chars().next(), channel.to_string())); - } - } - - if is_channel(target, chantypes) { - Some((None, target.to_string())) +/// https://modern.ircdocs.horse/#channels +/// +/// Given a target, split it into a channel name (beginning with a character in `chantypes`) and a +/// possible list of prefixes (given in `statusmsg_prefixes`). If these two lists overlap, the +/// behaviour is unspecified. +pub fn parse_channel_from_target( + target: &str, + chantypes: &[char], + statusmsg_prefixes: &[char], +) -> Option<(Vec, String)> { + // We parse the target by finding the first character in chantypes, and returing (even if that + // character is in statusmsg_prefixes) + // If the characters before the first chantypes character are all valid prefixes, then we have + // a valid channel name with those prefixes. let chan_index = target.find(chantypes)?; + let chan_index = target.find(chantypes)?; + + // will not panic, since `find` always returns a valid codepoint index + let (prefix, chan) = target.split_at(chan_index); + if prefix.chars().all(|ref c| statusmsg_prefixes.contains(c)) { + Some((prefix.chars().collect(), chan.to_owned())) } else { None } diff --git a/src/buffer/channel.rs b/src/buffer/channel.rs index ed79162bc..f0de61929 100644 --- a/src/buffer/channel.rs +++ b/src/buffer/channel.rs @@ -72,7 +72,7 @@ pub fn view<'a>( |prefix| { let text = selectable_text(format!( "{} ", - config.buffer.status_message_prefix.brackets.format(prefix) + config.buffer.status_message_prefix.brackets.format(String::from_iter(prefix)) )) .style(theme::selectable_text::tertiary); diff --git a/src/buffer/input_view.rs b/src/buffer/input_view.rs index f76cc36e8..29c50f057 100644 --- a/src/buffer/input_view.rs +++ b/src/buffer/input_view.rs @@ -185,6 +185,7 @@ impl State { let mut user = nick.to_owned().into(); let mut channel_users = &[][..]; let chantypes = clients.get_chantypes(buffer.server()); + let statusmsg = clients.get_statusmsg(buffer.server()); // Resolve our attributes if sending this message in a channel if let buffer::Upstream::Channel(server, channel) = &buffer { @@ -199,7 +200,7 @@ impl State { history_task = Task::batch( history - .record_input(input, user, channel_users, chantypes) + .record_input(input, user, channel_users, chantypes, statusmsg) .into_iter() .map(Task::future), ); diff --git a/src/buffer/input_view/completion.rs b/src/buffer/input_view/completion.rs index 50c92489a..d0f8e217b 100644 --- a/src/buffer/input_view/completion.rs +++ b/src/buffer/input_view/completion.rs @@ -185,19 +185,20 @@ impl Commands { } } "MSG" => { - let channel_membership_prefixes = if let Some( + let channel_membership_prefixes = + if let Some( isupport::Parameter::STATUSMSG(channel_membership_prefixes), ) = isupport.get(&isupport::Kind::STATUSMSG) { - Some(channel_membership_prefixes) + channel_membership_prefixes.clone() } else { - None + vec![] }; let target_limit = find_target_limit(isupport, "PRIVMSG"); - if channel_membership_prefixes.is_some() || target_limit.is_some() { + if !channel_membership_prefixes.is_empty() || target_limit.is_some() { return msg_command(channel_membership_prefixes, target_limit); } } @@ -1181,27 +1182,23 @@ static MONITOR_STATUS_COMMAND: Lazy = Lazy::new(|| Command { }); fn msg_command( - channel_membership_prefixes: Option<&String>, + channel_membership_prefixes: Vec, target_limit: Option<&isupport::CommandTargetLimit>, ) -> Command { let mut targets_tooltip = String::from( "comma-separated\n {user}: user directly\n {channel}: all users in channel", ); - if let Some(channel_membership_prefixes) = channel_membership_prefixes { - channel_membership_prefixes - .chars() - .for_each( - |channel_membership_prefix| match channel_membership_prefix { - '~' => targets_tooltip.push_str("\n~{channel}: all founders in channel"), - '&' => targets_tooltip.push_str("\n&{channel}: all protected users in channel"), - '!' => targets_tooltip.push_str("\n!{channel}: all protected users in channel"), - '@' => targets_tooltip.push_str("\n@{channel}: all operators in channel"), - '%' => targets_tooltip.push_str("\n%{channel}: all half-operators in channel"), - '+' => targets_tooltip.push_str("\n+{channel}: all voiced users in channel"), - _ => (), - }, - ); + for channel_membership_prefix in channel_membership_prefixes { + match channel_membership_prefix { + '~' => targets_tooltip.push_str("\n~{channel}: all founders in channel"), + '&' => targets_tooltip.push_str("\n&{channel}: all protected users in channel"), + '!' => targets_tooltip.push_str("\n!{channel}: all protected users in channel"), + '@' => targets_tooltip.push_str("\n@{channel}: all operators in channel"), + '%' => targets_tooltip.push_str("\n%{channel}: all half-operators in channel"), + '+' => targets_tooltip.push_str("\n+{channel}: all voiced users in channel"), + _ => (), + } } if let Some(target_limit) = target_limit { diff --git a/src/main.rs b/src/main.rs index c85e67772..259878b43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -504,6 +504,7 @@ impl Halloy { }; let chantypes = self.clients.get_chantypes(&server); + let statusmsg = self.clients.get_statusmsg(&server); match event { data::client::Event::Single(encoded, our_nick) => { @@ -514,6 +515,7 @@ impl Halloy { resolve_user_attributes, channel_users, chantypes, + statusmsg, ) { commands.push( dashboard @@ -530,6 +532,7 @@ impl Halloy { resolve_user_attributes, channel_users, chantypes, + statusmsg, ) { commands.push( dashboard @@ -647,6 +650,7 @@ impl Halloy { resolve_user_attributes, channel_users, chantypes, + statusmsg, ) { commands.push( dashboard diff --git a/src/screen/dashboard.rs b/src/screen/dashboard.rs index e92c38c39..dd0174446 100644 --- a/src/screen/dashboard.rs +++ b/src/screen/dashboard.rs @@ -208,6 +208,7 @@ impl Dashboard { let mut user = nick.to_owned().into(); let mut channel_users = &[][..]; let chantypes = clients.get_chantypes(buffer.server()); + let statusmsg = clients.get_statusmsg(buffer.server()); // Resolve our attributes if sending this message in a channel if let buffer::Upstream::Channel(server, channel) = @@ -226,7 +227,7 @@ impl Dashboard { } if let Some(messages) = - input.messages(user, channel_users, chantypes) + input.messages(user, channel_users, chantypes, statusmsg) { let mut tasks = vec![task]; From 920831ab0f35aea567a520e49e8aeb371e06f419 Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Fri, 18 Oct 2024 20:55:26 +0000 Subject: [PATCH 3/5] add tests --- irc/proto/src/lib.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/irc/proto/src/lib.rs b/irc/proto/src/lib.rs index d2d02f09b..ae9602c57 100644 --- a/irc/proto/src/lib.rs +++ b/irc/proto/src/lib.rs @@ -105,3 +105,47 @@ macro_rules! command { $crate::command($c, vec![$($p.into(),)*]) ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_channel_correct() { + let chantypes = DEFAULT_CHANNEL_PREFIXES; + assert!(is_channel("#foo", chantypes)); + assert!(is_channel("&foo", chantypes)); + assert!(!is_channel("foo", chantypes)); + } + + #[test] + fn empty_chantypes() { + assert!(!is_channel("#foo", &[])); + assert!(!is_channel("&foo", &[])); + } + + #[test] + fn parse_channel() { + let chantypes = DEFAULT_CHANNEL_PREFIXES; + let prefixes = CHANNEL_MEMBERSHIP_PREFIXES; + assert_eq!( + parse_channel_from_target("#foo", chantypes, prefixes), + Some((vec![], "#foo".to_owned())) + ); + assert_eq!( + parse_channel_from_target("+%#foo", chantypes, prefixes), + Some((vec!['+', '%'], "#foo".to_owned())) + ); + assert_eq!( + parse_channel_from_target("&+%foo", chantypes, prefixes), + Some((vec![], "&+%foo".to_owned())) + ); + } + + #[test] + fn invalid_channels() { + let chantypes = DEFAULT_CHANNEL_PREFIXES; + let prefixes = CHANNEL_MEMBERSHIP_PREFIXES; + assert!(parse_channel_from_target("+%foo", chantypes, prefixes).is_none()); + } +} From da976f947da36c1508b93d672555f79317f8b87b Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Fri, 18 Oct 2024 22:35:15 +0000 Subject: [PATCH 4/5] change sorting of channels in the sidebar --- data/src/client.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/data/src/client.rs b/data/src/client.rs index 22480fada..2734e4fa9 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use futures::channel::mpsc; use irc::proto::{self, command, Command}; use itertools::{Either, Itertools}; +use std::cmp::Ordering; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::time::{Duration, Instant}; @@ -1287,8 +1288,27 @@ impl Client { } } + // TODO allow configuring the "sorting method" + // this function sorts channels together which have similar names when the chantype prefix + // (sometimes multipled) is removed + // e.g. '#chat', '##chat-offtopic' and '&chat-local' all get sorted together instead of in + // wildly different places. + fn compare_channels(&self, a: &str, b: &str) -> Ordering { + let (Some(a_chantype), Some(b_chantype)) = (a.chars().nth(0), b.chars().nth(0)) else { + return a.cmp(b); + }; + + if [a_chantype, b_chantype].iter().all(|c| self.chantypes().contains(c)) { + let ord = a.trim_start_matches(a_chantype).cmp(b.trim_start_matches(b_chantype)); + if ord != Ordering::Equal { + return ord; + } + } + a.cmp(b) + } + fn sync(&mut self) { - self.channels = self.chanmap.keys().cloned().collect(); + self.channels = self.chanmap.keys().cloned().sorted_by(|a, b| self.compare_channels(a, b)).collect(); self.users = self .chanmap .iter() From bd31605c001d64d2fa5a3d05968c0ac1e51f0957 Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Thu, 24 Oct 2024 14:00:44 +0000 Subject: [PATCH 5/5] address review comments --- data/src/buffer.rs | 2 +- data/src/history/manager.rs | 4 +-- data/src/input.rs | 4 +-- data/src/isupport.rs | 55 +++++++++++++---------------------- data/src/message.rs | 26 ++++++++--------- data/src/message/broadcast.rs | 2 +- irc/proto/src/lib.rs | 18 +++++------- src/buffer/channel.rs | 14 ++++----- 8 files changed, 55 insertions(+), 70 deletions(-) diff --git a/data/src/buffer.rs b/data/src/buffer.rs index dbf513b8a..14752135b 100644 --- a/data/src/buffer.rs +++ b/data/src/buffer.rs @@ -75,7 +75,7 @@ impl Upstream { Self::Channel(_, channel) => message::Target::Channel { channel, source: message::Source::Server(source), - prefix: Default::default(), + prefixes: Default::default(), }, Self::Query(_, nick) => message::Target::Query { nick, diff --git a/data/src/history/manager.rs b/data/src/history/manager.rs index 78daddec6..219ac690f 100644 --- a/data/src/history/manager.rs +++ b/data/src/history/manager.rs @@ -597,11 +597,11 @@ impl Data { filtered .iter() .filter_map(|message| { - message.target.prefix().map(|prefix| { + message.target.prefixes().map(|prefixes| { buffer_config .status_message_prefix .brackets - .format(prefix.iter().collect::()) + .format(prefixes.iter().collect::()) .chars() .count() + 1 diff --git a/data/src/input.rs b/data/src/input.rs index bc58bcad1..0420c275a 100644 --- a/data/src/input.rs +++ b/data/src/input.rs @@ -65,11 +65,11 @@ impl Input { pub fn messages(&self, user: User, channel_users: &[User], chantypes: &[char], statusmsg: &[char]) -> Option> { let to_target = |target: &str, source| { - if let Some((prefix, channel)) = proto::parse_channel_from_target(target, chantypes, statusmsg) { + if let Some((prefixes, channel)) = proto::parse_channel_from_target(target, chantypes, statusmsg) { Some(message::Target::Channel { channel, source, - prefix, + prefixes, }) } else if let Ok(user) = User::try_from(target) { Some(message::Target::Query { diff --git a/data/src/isupport.rs b/data/src/isupport.rs index 53377471d..946f1eec3 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -1,4 +1,3 @@ -use irc::proto; use std::str::FromStr; // Utilized ISUPPORT parameters should have an associated Kind enum variant @@ -89,23 +88,21 @@ impl FromStr for Operation { value.split(',').for_each(|channel_limit| { if let Some((prefix, limit)) = channel_limit.split_once(':') { if limit.is_empty() { - prefix.chars().for_each(|c| { - if proto::CHANNEL_PREFIXES.contains(&c) { - channel_limits.push(ChannelLimit { - prefix: c, - limit: None, - }); - } - }); + for c in prefix.chars() { + // TODO validate after STATUSMSG received + channel_limits.push(ChannelLimit { + prefix: c, + limit: None, + }); + } } else if let Ok(limit) = limit.parse::() { - prefix.chars().for_each(|c| { - if proto::CHANNEL_PREFIXES.contains(&c) { - channel_limits.push(ChannelLimit { - prefix: c, - limit: Some(limit), - }); - } - }); + for c in prefix.chars() { + // TODO validate after STATUSMSG received + channel_limits.push(ChannelLimit { + prefix: c, + limit: Some(limit), + }); + } } } }); @@ -143,10 +140,9 @@ impl FromStr for Operation { let chars = value.chars().collect::>(); if chars.is_empty() { Ok(Operation::Add(Parameter::CHANTYPES(None))) - } else if chars.iter().all(|c| proto::CHANNEL_PREFIXES.contains(c)) { - Ok(Operation::Add(Parameter::CHANTYPES(Some(chars)))) } else { - Err("value must only contain channel types if specified") + // TODO validate after STATUSMSG is received + Ok(Operation::Add(Parameter::CHANTYPES(Some(chars)))) } } "CHATHISTORY" => Ok(Operation::Add(Parameter::CHATHISTORY( @@ -331,13 +327,9 @@ impl FromStr for Operation { let mut prefix_maps = vec![]; if let Some((modes, prefixes)) = value.split_once(')') { - modes.chars().skip(1).zip(prefixes.chars()).for_each( - |(mode, prefix)| { - if proto::CHANNEL_MEMBERSHIP_PREFIXES.contains(&prefix) { - prefix_maps.push(PrefixMap { mode, prefix }) - } - }, - ); + for (mode, prefix) in modes.chars().skip(1).zip(prefixes.chars()) { + prefix_maps.push(PrefixMap { mode, prefix }) + } Ok(Operation::Add(Parameter::PREFIX(prefix_maps))) } else { @@ -351,13 +343,8 @@ impl FromStr for Operation { ))), "STATUSMSG" => { let chars = value.chars().collect::>(); - if chars.iter() - .all(|c| proto::CHANNEL_MEMBERSHIP_PREFIXES.contains(c)) - { - Ok(Operation::Add(Parameter::STATUSMSG(chars))) - } else { - Err("unknown channel membership prefix(es)") - } + // TODO validate that STATUSMSG ⊂ PREFIX after ISUPPORT ends + Ok(Operation::Add(Parameter::STATUSMSG(chars))) } "TARGMAX" => { let mut command_target_limits = vec![]; diff --git a/data/src/message.rs b/data/src/message.rs index 9b81e9508..c3678e579 100644 --- a/data/src/message.rs +++ b/data/src/message.rs @@ -111,7 +111,7 @@ pub enum Target { Channel { channel: Channel, source: Source, - prefix: Vec, + prefixes: Vec, }, Query { nick: Nick, @@ -126,10 +126,10 @@ pub enum Target { } impl Target { - pub fn prefix(&self) -> Option<&[char]> { + pub fn prefixes(&self) -> Option<&[char]> { match self { Target::Server { .. } => None, - Target::Channel { prefix, .. } => Some(prefix), + Target::Channel { prefixes, .. } => Some(prefixes), Target::Query { .. } => None, Target::Logs => None, Target::Highlights { .. } => None, @@ -625,12 +625,12 @@ fn target( Command::MODE(target, ..) if proto::is_channel(&target, chantypes) => Some(Target::Channel { channel: target, source: source::Source::Server(None), - prefix: Default::default(), + prefixes: Default::default(), }), Command::TOPIC(channel, _) | Command::KICK(channel, _, _) => Some(Target::Channel { channel, source: source::Source::Server(None), - prefix: Default::default(), + prefixes: Default::default(), }), Command::PART(channel, _) => Some(Target::Channel { channel, @@ -638,7 +638,7 @@ fn target( source::server::Kind::Part, Some(user?.nickname().to_owned()), ))), - prefix: Default::default(), + prefixes: Default::default(), }), Command::JOIN(channel, _) => Some(Target::Channel { channel, @@ -646,7 +646,7 @@ fn target( source::server::Kind::Join, Some(user?.nickname().to_owned()), ))), - prefix: Default::default(), + prefixes: Default::default(), }), Command::Numeric(RPL_TOPIC | RPL_TOPICWHOTIME, params) => { let channel = params.get(1)?.clone(); @@ -656,7 +656,7 @@ fn target( source::server::Kind::ReplyTopic, None, ))), - prefix: Default::default(), + prefixes: Default::default(), }) } Command::Numeric(RPL_CHANNELMODEIS, params) => { @@ -664,7 +664,7 @@ fn target( Some(Target::Channel { channel, source: source::Source::Server(None), - prefix: Default::default(), + prefixes: Default::default(), }) } Command::Numeric(RPL_AWAY, params) => { @@ -687,12 +687,12 @@ fn target( }; match (proto::parse_channel_from_target(&target, chantypes, statusmsg), user) { - (Some((prefix, channel)), Some(user)) => { + (Some((prefixes, channel)), Some(user)) => { let source = source(resolve_attributes(&user, &channel).unwrap_or(user)); Some(Target::Channel { channel, source, - prefix, + prefixes, }) } (None, Some(user)) => { @@ -721,12 +721,12 @@ fn target( }; match (proto::parse_channel_from_target(&target, chantypes, statusmsg), user) { - (Some((prefix, channel)), Some(user)) => { + (Some((prefixes, channel)), Some(user)) => { let source = source(resolve_attributes(&user, &channel).unwrap_or(user)); Some(Target::Channel { channel, source, - prefix, + prefixes, }) } (None, Some(user)) => { diff --git a/data/src/message/broadcast.rs b/data/src/message/broadcast.rs index 82e31f4be..29671c28e 100644 --- a/data/src/message/broadcast.rs +++ b/data/src/message/broadcast.rs @@ -47,7 +47,7 @@ fn expand( Target::Channel { channel, source: source.clone(), - prefix: Default::default(), + prefixes: Default::default(), }, content.clone(), ) diff --git a/irc/proto/src/lib.rs b/irc/proto/src/lib.rs index ae9602c57..1bd31b29e 100644 --- a/irc/proto/src/lib.rs +++ b/irc/proto/src/lib.rs @@ -47,10 +47,6 @@ pub fn command(command: &str, parameters: Vec) -> Message { command: Command::new(command, parameters), } } - -/// Reference: https://defs.ircdocs.horse/defs/chantypes -pub const CHANNEL_PREFIXES: &[char] = &['#', '&', '+', '!']; - /// Reference: https://defs.ircdocs.horse/defs/chantypes /// /// Channel types which should be used if the CHANTYPES ISUPPORT is not returned @@ -67,10 +63,6 @@ pub const CHANNEL_BLACKLIST_CHARS: &[char] = &[',', '\u{07}', ',']; pub fn is_channel(target: &str, chantypes: &[char]) -> bool { target.starts_with(chantypes) && !target.contains(CHANNEL_BLACKLIST_CHARS) } - -// Reference: https://defs.ircdocs.horse/defs/chanmembers -pub const CHANNEL_MEMBERSHIP_PREFIXES: &[char] = &['~', '&', '!', '@', '%', '+']; - /// https://modern.ircdocs.horse/#channels /// /// Given a target, split it into a channel name (beginning with a character in `chantypes`) and a @@ -84,10 +76,12 @@ pub fn parse_channel_from_target( // We parse the target by finding the first character in chantypes, and returing (even if that // character is in statusmsg_prefixes) // If the characters before the first chantypes character are all valid prefixes, then we have - // a valid channel name with those prefixes. let chan_index = target.find(chantypes)?; + // a valid channel name with those prefixes. let chan_index = target.find(chantypes)?; - // will not panic, since `find` always returns a valid codepoint index + // This will not panic, since `find` always returns a valid codepoint index. + // We call `find` -> `split_at` because it is an _inclusive_ split, which includes the match. + // We need to return this since the channel target includes its chantype. let (prefix, chan) = target.split_at(chan_index); if prefix.chars().all(|ref c| statusmsg_prefixes.contains(c)) { Some((prefix.chars().collect(), chan.to_owned())) @@ -110,6 +104,10 @@ macro_rules! command { mod tests { use super::*; + + // Reference: https://defs.ircdocs.horse/defs/chanmembers + const CHANNEL_MEMBERSHIP_PREFIXES: &[char] = &['~', '&', '!', '@', '%', '+']; + #[test] fn is_channel_correct() { let chantypes = DEFAULT_CHANNEL_PREFIXES; diff --git a/src/buffer/channel.rs b/src/buffer/channel.rs index f0de61929..6dbed4806 100644 --- a/src/buffer/channel.rs +++ b/src/buffer/channel.rs @@ -61,7 +61,7 @@ pub fn view<'a>( selectable_text(timestamp).style(theme::selectable_text::timestamp) }); - let prefix = message.target.prefix().map_or( + let prefixes = message.target.prefixes().map_or( max_nick_width.and_then(|_| { max_prefix_width.map(|width| { selectable_text("") @@ -69,10 +69,10 @@ pub fn view<'a>( .horizontal_alignment(alignment::Horizontal::Right) }) }), - |prefix| { + |prefixes| { let text = selectable_text(format!( "{} ", - config.buffer.status_message_prefix.brackets.format(String::from_iter(prefix)) + config.buffer.status_message_prefix.brackets.format(String::from_iter(prefixes)) )) .style(theme::selectable_text::tertiary); @@ -145,7 +145,7 @@ pub fn view<'a>( let timestamp_nickname_row = row![] .push_maybe(timestamp) - .push_maybe(prefix) + .push_maybe(prefixes) .push(nick) .push(space); @@ -193,7 +193,7 @@ pub fn view<'a>( container( row![] .push_maybe(timestamp) - .push_maybe(prefix) + .push_maybe(prefixes) .push(marker) .push(space) .push(message), @@ -216,7 +216,7 @@ pub fn view<'a>( container( row![] .push_maybe(timestamp) - .push_maybe(prefix) + .push_maybe(prefixes) .push(marker) .push(space) .push(message), @@ -243,7 +243,7 @@ pub fn view<'a>( container( row![] .push_maybe(timestamp) - .push_maybe(prefix) + .push_maybe(prefixes) .push(marker) .push(space) .push(message),