From 6d17fa6e94283c75199fecd3848256219593dc88 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Sun, 21 Apr 2024 15:35:05 -0700 Subject: [PATCH 01/23] Initial ISUPPORT parameter parsing implementation. --- data/src/client.rs | 43 ++- data/src/isupport.rs | 712 +++++++++++++++++++++++++++++++++++++++++++ data/src/lib.rs | 1 + 3 files changed, 755 insertions(+), 1 deletion(-) create mode 100644 data/src/isupport.rs diff --git a/data/src/client.rs b/data/src/client.rs index ce7fed521..e5b19b732 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -9,7 +9,7 @@ use std::time::{Duration, Instant}; use crate::message::server_time; use crate::time::Posix; use crate::user::{Nick, NickRef}; -use crate::{config, dcc, message, mode, Buffer, Server, User}; +use crate::{config, dcc, isupport, message, mode, Buffer, Server, User}; use crate::{file_transfer, server}; const HIGHLIGHT_BLACKOUT_INTERVAL: Duration = Duration::from_secs(5); @@ -88,6 +88,7 @@ pub struct Client { supports_away_notify: bool, highlight_blackout: HighlightBlackout, registration_required_channels: Vec, + isupport_parameters: HashMap, } impl fmt::Debug for Client { @@ -137,6 +138,7 @@ impl Client { supports_away_notify: false, highlight_blackout: HighlightBlackout::Blackout(Instant::now()), registration_required_channels: vec![], + isupport_parameters: HashMap::new(), } } @@ -845,6 +847,45 @@ impl Client { self.registration_required_channels.push(channel.clone()); } } + Command::Numeric(RPL_ISUPPORT, args) => { + args[1..].iter().for_each(|arg| { + if arg != "are supported by this server" { + let isupport_parameter = isupport::Parameter::try_from(arg.clone()); + + match isupport_parameter { + Ok(isupport_parameter) => { + match isupport_parameter { + isupport::Parameter::Negation(key) => { + log::info!( + "[{}] removing ISUPPORT parameter: {}", + self.server, + key + ); + self.isupport_parameters.remove(&key) + } + _ => { + log::info!( + "[{}] adding ISUPPORT parameter: {:?}", + self.server, + isupport_parameter + ); + self.isupport_parameters.insert( + isupport_parameter.key().to_string(), + isupport_parameter, + ) + } + }; + } + Err(error) => log::debug!( + "[{}] unable to parse ISUPPORT parameter: {} ({})", + self.server, + arg, + error + ), + } + } + }); + } _ => {} } diff --git a/data/src/isupport.rs b/data/src/isupport.rs new file mode 100644 index 000000000..16ad7283b --- /dev/null +++ b/data/src/isupport.rs @@ -0,0 +1,712 @@ +// ISUPPORT Parameter References +// - https://defs.ircdocs.horse/defs/isupport.html +// - https://modern.ircdocs.horse/#rplisupport-005 +// - https://ircv3.net/specs/extensions/chathistory +// - https://ircv3.net/specs/extensions/monitor +// - https://ircv3.net/specs/extensions/utf8-only +// - https://ircv3.net/specs/extensions/whox +// - https://github.com/ircv3/ircv3-specifications/pull/464/files +#[allow(non_camel_case_types)] +#[derive(Debug)] +pub enum Parameter { + ACCEPT(u16), + ACCOUNTEXTBAN(Vec), + AWAYLEN(Option), + BOT(char), + CALLERID(char), + CASEMAPPING(CaseMap), + CHANLIMIT(Vec), + CHANMODES(Vec), + CHANNELLEN(u16), + CHANTYPES(Option), + CHATHISTORY(u16), + CLIENTTAGDENY(Vec), + CLIENTVER(u16, u16), + CNOTICE, + CPRIVMSG, + DEAF(char), + ELIST(String), + ESILENCE(Option), + ETRACE, + EXCEPTS(char), + EXTBAN(Option, String), + FNC, + HOSTLEN(u16), + INVEX(char), + KEYLEN(u16), + KICKLEN(u16), + KNOCK, + LINELEN(u16), + MAP, + MAXBANS(u16), + MAXCHANNELS(u16), + MAXLIST(Vec), + MAXPARA(u16), + MAXTARGETS(Option), + METADATA(Option), + MODES(Option), + MONITOR(Option), + MSGREFTYPES(Vec), + NAMESX, + NETWORK(String), + NICKLEN(u16), + OVERRIDE, + PREFIX(Vec), + SAFELIST, + SECURELIST, + SILENCE(Option), + STATUSMSG(String), + TARGMAX(Vec), + TOPICLEN(u16), + UHNAMES, + USERIP, + USERLEN(u16), + UTF8ONLY, + VLIST(String), + WATCH(u16), + WHOX, + Negation(String), +} + +impl TryFrom for Parameter { + type Error = &'static str; + + fn try_from(isupport: String) -> Result { + Self::try_from(isupport.as_str()) + } +} + +impl<'a> TryFrom<&'a str> for Parameter { + type Error = &'static str; + + fn try_from(isupport: &'a str) -> Result { + if isupport.is_empty() { + return Err("empty ISUPPORT parameter not allowed"); + } + + match isupport.chars().nth(0) { + Some('-') => Ok(Parameter::Negation(isupport[1..].to_string())), + _ => { + if let Some((parameter, value)) = isupport.split_once('=') { + match parameter { + "ACCEPT" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::ACCEPT(value)) + } else { + Err("ACCEPT value must be a positive integer") + } + } + "ACCOUNTEXTBAN" => { + let account_based_extended_ban_masks = + value.split(',').map(String::from).collect::>(); + + if !account_based_extended_ban_masks.is_empty() { + Ok(Parameter::ACCOUNTEXTBAN(account_based_extended_ban_masks)) + } else { + Err("no valid account-based extended ban masks") + } + } + "AWAYLEN" => { + if value.is_empty() { + Ok(Parameter::AWAYLEN(None)) + } else if let Ok(value) = value.parse::() { + Ok(Parameter::AWAYLEN(Some(value))) + } else { + Err("AWAYLEN value must be a positive integer if specified") + } + } + "BOT" => { + if let Some(value) = value.chars().nth(0) { + Ok(Parameter::BOT(value)) + } else { + Err("BOT value must be a character") + } + } + "CALLERID" => { + if let Some(value) = value.chars().nth(0) { + Ok(Parameter::CALLERID(value)) + } else { + Ok(Parameter::CALLERID(default_caller_id_letter())) + } + } + "CASEMAPPING" => match value { + "ascii" => Ok(Parameter::CASEMAPPING(CaseMap::ASCII)), + "rfc1459" => Ok(Parameter::CASEMAPPING(CaseMap::RFC1459)), + "rfc1459-strict" => Ok(Parameter::CASEMAPPING(CaseMap::RFC1459_STRICT)), + "rfc7613" => Ok(Parameter::CASEMAPPING(CaseMap::RFC7613)), + _ => Err("unknown casemapping"), + }, + "CHANLIMIT" => { + let mut channel_limits = vec![]; + + value.split(',').for_each(|channel_limit| { + if let Some((prefix, limit)) = channel_limit.split_once(':') { + if limit.is_empty() { + channel_limits.push(ChannelLimit { + prefix: prefix.to_string(), + limit: None, + }); + } else if let Ok(limit) = limit.parse::() { + channel_limits.push(ChannelLimit { + prefix: prefix.to_string(), + limit: Some(limit), + }); + } + } + }); + + if !channel_limits.is_empty() { + Ok(Parameter::CHANLIMIT(channel_limits)) + } else { + Err("no valid channel limits") + } + } + "CHANMODES" => { + let mut channel_modes = vec![]; + + ('A'..='Z') + .zip(value.split(',')) + .for_each(|(letter, modes)| { + channel_modes.push(ChannelMode { + letter, + modes: String::from(modes), + }) + }); + + if !channel_modes.is_empty() { + Ok(Parameter::CHANMODES(channel_modes)) + } else { + Err("no valid channel modes") + } + } + "CHANNELLEN" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::CHANNELLEN(value)) + } else { + Err("CHANNELLEN value must be a positive integer") + } + } + "CHANTYPES" => { + if value.is_empty() { + Ok(Parameter::CHANTYPES(None)) + } else { + Ok(Parameter::CHANTYPES(Some(String::from(value)))) + } + } + "CHATHISTORY" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::CHATHISTORY(value)) + } else { + Err("CHATHISTORY value must be a positive integer") + } + } + "CLIENTTAGDENY" => { + let mut client_tag_denials = vec![]; + + value + .split(',') + .for_each(|client_tag_denial| { + match client_tag_denial.chars().nth(0) { + Some('*') => { + client_tag_denials.push(ClientOnlyTags::DenyAll) + } + Some('-') => { + client_tag_denials.push(ClientOnlyTags::Allowed( + client_tag_denial[1..].to_string(), + )) + } + _ => client_tag_denials.push(ClientOnlyTags::Denied( + client_tag_denial.to_string(), + )), + } + }); + + if !client_tag_denials.is_empty() { + Ok(Parameter::CLIENTTAGDENY(client_tag_denials)) + } else { + Err("no valid client tag denials") + } + } + "CLIENTVER" => { + if let Some((major, minor)) = value.split_once('.') { + if let (Ok(major), Ok(minor)) = + (major.parse::(), minor.parse::()) + { + return Ok(Parameter::CLIENTVER(major, minor)); + } + } + + Err("CLIENTVER value must be a . version number") + } + "CNOTICE" => Ok(Parameter::CNOTICE), + "CPRIVMSG" => Ok(Parameter::CPRIVMSG), + "DEAF" => { + if let Some(value) = value.chars().nth(0) { + Ok(Parameter::DEAF(value)) + } else { + Ok(Parameter::DEAF(default_deaf_letter())) + } + } + "ELIST" => { + if !value.is_empty() { + Ok(Parameter::ELIST(value.to_string())) + } else { + Err("ELIST value required") + } + } + "ESILENCE" => { + if value.is_empty() { + Ok(Parameter::ESILENCE(None)) + } else { + Ok(Parameter::ESILENCE(Some(value.to_string()))) + } + } + "ETRACE" => Ok(Parameter::ETRACE), + "EXCEPTS" => { + if let Some(value) = value.chars().nth(0) { + Ok(Parameter::EXCEPTS(value)) + } else { + Ok(Parameter::EXCEPTS(default_ban_exception_channel_letter())) + } + } + "EXTBAN" => { + if let Some((prefix, types)) = value.split_once(',') { + if prefix.is_empty() { + Ok(Parameter::EXTBAN(None, types.to_string())) + } else { + Ok(Parameter::EXTBAN(prefix.chars().nth(0), types.to_string())) + } + } else { + Err("no valid extended ban masks") + } + } + "FNC" => Ok(Parameter::FNC), + "HOSTLEN" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::HOSTLEN(value)) + } else { + Err("HOSTLEN value must be a positive integer") + } + } + "INVEX" => { + if let Some(value) = value.chars().nth(0) { + Ok(Parameter::INVEX(value)) + } else { + Ok(Parameter::INVEX(default_invite_exception_letter())) + } + } + "KEYLEN" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::KEYLEN(value)) + } else { + Err("KEYLEN value must be a positive integer") + } + } + "KICKLEN" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::KICKLEN(value)) + } else { + Err("KICKLEN value must be a positive integer") + } + } + "KNOCK" => Ok(Parameter::KNOCK), + "LINELEN" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::LINELEN(value)) + } else { + Err("LINELEN value must be a positive integer") + } + } + "MAP" => Ok(Parameter::MAP), + "MAXBANS" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::MAXBANS(value)) + } else { + Err("MAXBANS value must be a positive integer") + } + } + "MAXCHANNELS" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::MAXCHANNELS(value)) + } else { + Err("MAXCHANNELS value must be a positive integer") + } + } + "MAXLIST" => { + let mut modes_limits = vec![]; + + value.split(',').for_each(|modes_limit| { + if let Some((modes, limit)) = modes_limit.split_once(':') { + if let Ok(limit) = limit.parse::() { + modes_limits.push(ModesLimit { + modes: modes.to_string(), + limit, + }); + } + } + }); + + if !modes_limits.is_empty() { + Ok(Parameter::MAXLIST(modes_limits)) + } else { + Err("no valid modes limits") + } + } + "MAXPARA" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::MAXPARA(value)) + } else { + Err("MAXPARA value must be a positive integer") + } + } + "MAXTARGETS" => { + if value.is_empty() { + Ok(Parameter::MAXTARGETS(None)) + } else if let Ok(value) = value.parse::() { + Ok(Parameter::MAXTARGETS(Some(value))) + } else { + Err("MAXTARGETS value must be a positive integer if specified") + } + } + "METADATA" => { + if value.is_empty() { + Ok(Parameter::METADATA(None)) + } else if let Ok(value) = value.parse::() { + Ok(Parameter::METADATA(Some(value))) + } else { + Err("METADATA value must be a positive integer if specified") + } + } + "MODES" => { + if value.is_empty() { + Ok(Parameter::MODES(None)) + } else if let Ok(value) = value.parse::() { + Ok(Parameter::MODES(Some(value))) + } else { + Err("MODES value must be a positive integer if specified") + } + } + "MONITOR" => { + if value.is_empty() { + Ok(Parameter::MONITOR(None)) + } else if let Ok(value) = value.parse::() { + Ok(Parameter::MONITOR(Some(value))) + } else { + Err("MONITOR value must be a positive integer if specified") + } + } + "MSGREFTYPES" => { + let mut message_reference_types = vec![]; + + value.split(',').for_each(|message_reference_type| { + match message_reference_type { + "msgid" => message_reference_types + .insert(0, MessageReferenceType::MessageID), + "timestamp" => message_reference_types + .insert(0, MessageReferenceType::Timestamp), + _ => (), + } + }); + + Ok(Parameter::MSGREFTYPES(message_reference_types)) + } + "NAMESX" => Ok(Parameter::NAMESX), + "NETWORK" => Ok(Parameter::NETWORK(value.to_string())), + "NICKLEN" | "MAXNICKLEN" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::NICKLEN(value)) + } else { + Err("NICKLEN value must be a positive integer") + } + } + "OVERRIDE" => Ok(Parameter::OVERRIDE), + "PREFIX" => { + let mut prefix_maps = vec![]; + + if let Some((modes, prefixes)) = value.split_once(')') { + let modes = &modes[1..]; + + modes + .chars() + .zip(prefixes.chars()) + .for_each(|(mode, prefix)| { + prefix_maps.push(PrefixMap { mode, prefix }) + }); + + Ok(Parameter::PREFIX(prefix_maps)) + } else { + Err("unrecognized PREFIX format") + } + } + "SAFELIST" => Ok(Parameter::SAFELIST), + "SECURELIST" => Ok(Parameter::SECURELIST), + "SILENCE" => { + if value.is_empty() { + Ok(Parameter::SILENCE(None)) + } else if let Ok(value) = value.parse::() { + Ok(Parameter::SILENCE(Some(value))) + } else { + Err("SILENCE value must be a positive integer if specified") + } + } + "STATUSMSG" => Ok(Parameter::STATUSMSG(value.to_string())), + "TARGMAX" => { + let mut command_target_limits = vec![]; + + value.split(',').for_each(|command_target_limit| { + if let Some((command, limit)) = command_target_limit.split_once(':') + { + if limit.is_empty() { + command_target_limits.push(CommandTargetLimit { + command: command.to_string(), + limit: None, + }); + } else if let Ok(limit) = limit.parse::() { + command_target_limits.push(CommandTargetLimit { + command: command.to_string(), + limit: Some(limit), + }); + } + } + }); + + if !command_target_limits.is_empty() { + Ok(Parameter::TARGMAX(command_target_limits)) + } else { + Err("no valid command target limits") + } + } + "TOPICLEN" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::TOPICLEN(value)) + } else { + Err("TOPICLEN value must be a positive integer") + } + } + "UHNAMES" => Ok(Parameter::UHNAMES), + "USERIP" => Ok(Parameter::USERIP), + "USERLEN" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::USERLEN(value)) + } else { + Err("USERLEN value must be a positive integer") + } + } + "UTF8ONLY" => Ok(Parameter::UTF8ONLY), + "VLIST" => { + if !value.is_empty() { + Ok(Parameter::VLIST(value.to_string())) + } else { + Err("VLIST value required") + } + } + "WATCH" => { + if let Ok(value) = value.parse::() { + Ok(Parameter::WATCH(value)) + } else { + Err("WATCH value must be a positive integer") + } + } + "WHOX" => Ok(Parameter::WHOX), + _ => Err("unknown ISUPPORT parameter"), + } + } else { + match isupport { + "ACCEPT" => Err("ACCEPT value required"), + "ACCOUNTEXTBAN" => Err("ACCOUNTEXTBAN value(s) required"), + "AWAYLEN" => Ok(Parameter::AWAYLEN(None)), + "BOT" => Err("BOT value required"), + "CALLERID" => Ok(Parameter::CALLERID(default_caller_id_letter())), + "CASEMAPPING" => Err("CASEMAPPING value required"), + "CHANLIMIT" => Err("CHANLIMIT value(s) required"), + "CHANMODES" => Err("CHANMODES value(s) required"), + "CHANNELLEN" => Err("CHANNELLEN value required"), + "CHANTYPES" => Ok(Parameter::CHANTYPES(None)), + "CHATHISTORY" => Err("CHATHISTORY value required"), + "CLIENTTAGDENY" => Err("CLIENTTAGDENY value(s) required"), + "CLIENTVER" => Err("CLIENTVER value required"), + "DEAF" => Ok(Parameter::DEAF(default_deaf_letter())), + "ELIST" => Err("ELIST value required"), + "ESILENCE" => Ok(Parameter::ESILENCE(None)), + "ETRACE" => Ok(Parameter::ETRACE), + "EXCEPTS" => Ok(Parameter::EXCEPTS(default_ban_exception_channel_letter())), + "EXTBAN" => Err("EXTBAN value required"), + "FNC" => Ok(Parameter::FNC), + "HOSTLEN" => Err("HOSTLEN value required"), + "INVEX" => Ok(Parameter::INVEX(default_invite_exception_letter())), + "KEYLEN" => Err("KEYLEN value required"), + "KICKLEN" => Err("KICKLEN value required"), + "KNOCK" => Ok(Parameter::KNOCK), + "LINELEN" => Err("LINELEN value required"), + "MAP" => Ok(Parameter::MAP), + "MAXBANS" => Err("MAXBANS value required"), + "MAXCHANNELS" => Err("MAXCHANNELS value required"), + "MAXLIST" => Err("MAXLIST value(s) required"), + "MAXPARA" => Err("MAXPARA value required"), + "MAXTARGETS" => Ok(Parameter::MAXTARGETS(None)), + "METADATA" => Ok(Parameter::METADATA(None)), + "MODES" => Ok(Parameter::MODES(None)), + "MONITOR" => Ok(Parameter::MONITOR(None)), + "MSGREFTYPES" => Ok(Parameter::MSGREFTYPES(vec![])), + "NAMESX" => Ok(Parameter::NAMESX), + "NETWORK" => Err("NETWORK value required"), + "NICKLEN" | "MAXNICKLEN" => Err("NICKLEN value required"), + "OVERRIDE" => Ok(Parameter::OVERRIDE), + "PREFIX" => Ok(Parameter::PREFIX(vec![])), + "SAFELIST" => Ok(Parameter::SAFELIST), + "SECURELIST" => Ok(Parameter::SECURELIST), + "SILENCE" => Ok(Parameter::SILENCE(None)), + "STATUSMSG" => Err("STATUSMSG value required"), + "TARGMAX" => Ok(Parameter::TARGMAX(vec![])), + "TOPICLEN" => Err("TOPICLEN value required"), + "UHNAMES" => Ok(Parameter::UHNAMES), + "USERIP" => Ok(Parameter::USERIP), + "USERLEN" => Err("USERLEN value required"), + "UTF8ONLY" => Ok(Parameter::UTF8ONLY), + "VLIST" => Err("VLIST value required"), + "WATCH" => Err("WATCH value required"), + "WHOX" => Ok(Parameter::WHOX), + _ => Err("unknown ISUPPORT parameter"), + } + } + } + } + } +} + +impl Parameter { + pub fn key(&self) -> &str { + match self { + Parameter::ACCEPT(_) => "ACCEPT", + Parameter::ACCOUNTEXTBAN(_) => "ACCOUNTEXTBAN", + Parameter::AWAYLEN(_) => "AWAYLEN", + Parameter::BOT(_) => "BOT", + Parameter::CALLERID(_) => "CALLERID", + Parameter::CASEMAPPING(_) => "CASEMAPPING", + Parameter::CHANLIMIT(_) => "CHANLIMIT", + Parameter::CHANMODES(_) => "CHANMODES", + Parameter::CHANNELLEN(_) => "CHANNELLEN", + Parameter::CHANTYPES(_) => "CHANTYPES", + Parameter::CHATHISTORY(_) => "CHATHISTORY", + Parameter::CLIENTTAGDENY(_) => "CLIENTTAGDENY", + Parameter::CLIENTVER(_, _) => "CLIENTVER", + Parameter::CNOTICE => "CNOTICE", + Parameter::CPRIVMSG => "CPRIVMSG", + Parameter::DEAF(_) => "DEAF", + Parameter::ELIST(_) => "ELIST", + Parameter::ESILENCE(_) => "ESILENCE", + Parameter::ETRACE => "ETRACE", + Parameter::EXCEPTS(_) => "EXCEPTS", + Parameter::EXTBAN(_, _) => "EXTBAN", + Parameter::FNC => "FNC", + Parameter::HOSTLEN(_) => "HOSTLEN", + Parameter::INVEX(_) => "INVEX", + Parameter::KEYLEN(_) => "KEYLEN", + Parameter::KICKLEN(_) => "KICKLEN", + Parameter::KNOCK => "KNOCK", + Parameter::LINELEN(_) => "LINELEN", + Parameter::MAP => "MAP", + Parameter::MAXBANS(_) => "MAXBANS", + Parameter::MAXCHANNELS(_) => "MAXCHANNELS", + Parameter::MAXLIST(_) => "MAXLIST", + Parameter::MAXPARA(_) => "MAXPARA", + Parameter::MAXTARGETS(_) => "MAXTARGETS", + Parameter::METADATA(_) => "METADATA", + Parameter::MODES(_) => "MODES", + Parameter::MONITOR(_) => "MONITOR", + Parameter::MSGREFTYPES(_) => "MSGREFTYPES", + Parameter::NAMESX => "NAMESX", + Parameter::NETWORK(_) => "NETWORK", + Parameter::NICKLEN(_) => "NICKLEN", + Parameter::OVERRIDE => "OVERRIDE", + Parameter::PREFIX(_) => "PREFIX", + Parameter::SAFELIST => "SAFELIST", + Parameter::SECURELIST => "SECURELIST", + Parameter::SILENCE(_) => "SILENCE", + Parameter::STATUSMSG(_) => "STATUSMSG", + Parameter::TARGMAX(_) => "TARGMAX", + Parameter::TOPICLEN(_) => "TOPICLEN", + Parameter::UHNAMES => "UHNAMES", + Parameter::USERIP => "USERIP", + Parameter::USERLEN(_) => "USERLEN", + Parameter::UTF8ONLY => "UTF8ONLY", + Parameter::VLIST(_) => "VLIST", + Parameter::WATCH(_) => "WATCH", + Parameter::WHOX => "WHOX", + Parameter::Negation(key) => key.as_ref(), + } + } +} + +#[allow(non_camel_case_types)] +#[derive(Debug)] +pub enum CaseMap { + ASCII, + RFC1459, + RFC1459_STRICT, + RFC7613, +} + +#[allow(dead_code)] +#[derive(Debug)] +pub struct ChannelLimit { + prefix: String, + limit: Option, +} + +#[allow(dead_code)] +#[derive(Debug)] +pub struct ChannelMode { + letter: char, + modes: String, +} + +#[derive(Debug)] +pub enum ClientOnlyTags { + Allowed(String), + Denied(String), + DenyAll, +} + +#[allow(dead_code)] +#[derive(Debug)] +pub struct CommandTargetLimit { + command: String, + limit: Option, +} + +#[derive(Debug)] +pub enum MessageReferenceType { + Timestamp, + MessageID, +} + +#[allow(dead_code)] +#[derive(Debug)] +pub struct ModesLimit { + modes: String, + limit: u16, +} + +#[allow(dead_code)] +#[derive(Debug)] +pub struct PrefixMap { + prefix: char, + mode: char, +} + +pub fn default_ban_exception_channel_letter() -> char { + 'e' +} + +pub fn default_caller_id_letter() -> char { + 'g' +} + +pub fn default_deaf_letter() -> char { + 'D' +} + +pub fn default_invite_exception_letter() -> char { + 'I' +} diff --git a/data/src/lib.rs b/data/src/lib.rs index a7abbe599..52f490ea4 100644 --- a/data/src/lib.rs +++ b/data/src/lib.rs @@ -26,6 +26,7 @@ pub mod environment; pub mod file_transfer; pub mod history; pub mod input; +pub mod isupport; pub mod log; pub mod message; pub mod mode; From eac949ca5c75afaa36286bd6851e441ac95d28b8 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Mon, 22 Apr 2024 20:01:39 -0700 Subject: [PATCH 02/23] Preliminary support for CNOTICE, CPRIVMSG, KNOCK, SAFELIST, USERIP, and WHOX. --- data/src/client.rs | 8 ++ data/src/message.rs | 6 +- data/src/message/broadcast.rs | 9 +- irc/proto/src/command.rs | 34 ++++++-- src/buffer/channel.rs | 9 +- src/buffer/input_view.rs | 3 + src/buffer/query.rs | 1 + src/buffer/server.rs | 1 + src/widget/input.rs | 30 +++++-- src/widget/input/completion.rs | 148 +++++++++++++++++++++++++++++++-- 10 files changed, 220 insertions(+), 29 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index e5b19b732..d2e0d7e78 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -885,6 +885,8 @@ impl Client { } } }); + + return None; } _ => {} } @@ -1101,6 +1103,12 @@ impl Map { .unwrap_or_default() } + pub fn get_isupport_parameters<'a>(&'a self, server: &Server) -> Vec<&'a isupport::Parameter> { + self.client(server) + .map(|client| client.isupport_parameters.values().collect::>()) + .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/message.rs b/data/src/message.rs index 0f2317783..4d71ce009 100644 --- a/data/src/message.rs +++ b/data/src/message.rs @@ -284,7 +284,7 @@ fn target( | Command::CONNECT(_, _, _) | Command::ADMIN(_) | Command::INFO - | Command::WHO(_) + | Command::WHO(_, _, _) | Command::WHOIS(_, _) | Command::WHOWAS(_, _) | Command::KILL(_, _) @@ -299,6 +299,10 @@ fn target( | Command::CAP(_, _, _, _) | Command::AUTHENTICATE(_) | Command::BATCH(_, _) + | Command::CNOTICE(_, _, _) + | Command::CPRIVMSG(_, _, _) + | Command::KNOCK(_, _) + | Command::USERIP(_) | Command::HELP(_) | Command::MODE(_, _, _) | Command::Numeric(_, _) diff --git a/data/src/message/broadcast.rs b/data/src/message/broadcast.rs index 7bbf7dc84..8a8d6c35e 100644 --- a/data/src/message/broadcast.rs +++ b/data/src/message/broadcast.rs @@ -179,7 +179,14 @@ pub fn nickname( format!(" ∙ {old_nick} is now known as {new_nick}") }; - expand(channels, queries, false, Cause::Server(None), text, sent_time) + expand( + channels, + queries, + false, + Cause::Server(None), + text, + sent_time, + ) } pub fn invite( diff --git a/irc/proto/src/command.rs b/irc/proto/src/command.rs index 3640b56ea..55e038f33 100644 --- a/irc/proto/src/command.rs +++ b/irc/proto/src/command.rs @@ -69,8 +69,8 @@ pub enum Command { NOTICE(String, String), /* User-Based Queries */ - /// - WHO(String), + /// [%[,]] + WHO(String, Option, Option), /// [] WHOIS(Option, String), /// [] @@ -96,6 +96,14 @@ pub enum Command { /* IRC extensions */ BATCH(String, Vec), + /// : + CNOTICE(String, String, String), + /// : + CPRIVMSG(String, String, String), + /// [] + KNOCK(String, Option), + /// + USERIP(String), Numeric(Numeric, Vec), Unknown(String, Vec), @@ -164,7 +172,7 @@ impl Command { "MODE" if len > 0 => MODE(req!(), opt!(), params.collect()), "PRIVMSG" if len > 1 => PRIVMSG(req!(), req!()), "NOTICE" if len > 1 => NOTICE(req!(), req!()), - "WHO" if len > 0 => WHO(req!()), + "WHO" if len > 0 => WHO(req!(), opt!(), opt!()), "WHOIS" => { let a = req!(); match opt!() { @@ -182,6 +190,10 @@ impl Command { "USERHOST" => USERHOST(params.collect()), "WALLOPS" if len > 0 => WALLOPS(req!()), "BATCH" if len > 0 => BATCH(req!(), params.collect()), + "CNOTICE" if len > 2 => CNOTICE(req!(), req!(), req!()), + "CPRIVMSG" if len > 2 => CPRIVMSG(req!(), req!(), req!()), + "KNOCK" if len > 0 => KNOCK(req!(), opt!()), + "USERIP" if len > 0 => USERIP(req!()), _ => Self::Unknown(tag, params.collect()), } } @@ -217,7 +229,9 @@ impl Command { Command::MODE(a, b, c) => std::iter::once(a).chain(b).chain(c).collect(), Command::PRIVMSG(a, b) => vec![a, b], Command::NOTICE(a, b) => vec![a, b], - Command::WHO(a) => vec![a], + Command::WHO(a, b, c) => std::iter::once(a) + .chain(b.map(|b| c.map_or_else(|| format!("%{}", b), |c| format!("%{},{}", b, c)))) + .collect(), Command::WHOIS(a, b) => a.into_iter().chain(Some(b)).collect(), Command::WHOWAS(a, b) => std::iter::once(a).chain(b).collect(), Command::KILL(a, b) => vec![a, b], @@ -229,6 +243,10 @@ impl Command { Command::USERHOST(params) => params, Command::WALLOPS(a) => vec![a], Command::BATCH(a, rest) => std::iter::once(a).chain(rest).collect(), + Command::CNOTICE(a, b, c) => vec![a, b, format!(":{}", c)], + Command::CPRIVMSG(a, b, c) => vec![a, b, format!(":{}", c)], + Command::KNOCK(a, b) => std::iter::once(a).chain(b).collect(), + Command::USERIP(a) => vec![a], Command::Numeric(_, params) => params, Command::Unknown(_, params) => params, } @@ -267,7 +285,7 @@ impl Command { MODE(_, _, _) => "MODE".to_string(), PRIVMSG(_, _) => "PRIVMSG".to_string(), NOTICE(_, _) => "NOTICE".to_string(), - WHO(_) => "WHO".to_string(), + WHO(_, _, _) => "WHO".to_string(), WHOIS(_, _) => "WHOIS".to_string(), WHOWAS(_, _) => "WHOWAS".to_string(), KILL(_, _) => "KILL".to_string(), @@ -279,6 +297,10 @@ impl Command { USERHOST(_) => "USERHOST".to_string(), WALLOPS(_) => "WALLOPS".to_string(), BATCH(_, _) => "BATCH".to_string(), + CNOTICE(_, _, _) => "CNOTICE".to_string(), + CPRIVMSG(_, _, _) => "CPRIVMSG".to_string(), + KNOCK(_, _) => "KNOCK".to_string(), + USERIP(_) => "USERIP".to_string(), Numeric(numeric, _) => format!("{:03}", *numeric as u16), Unknown(tag, _) => tag.clone(), } @@ -347,6 +369,7 @@ pub enum Numeric { RPL_ENDOFEXCEPTLIST = 349, RPL_VERSION = 351, RPL_NAMREPLY = 353, + RPL_WHOSPCRPL = 354, RPL_ENDOFNAMES = 366, RPL_LINKS = 364, RPL_ENDOFLINKS = 365, @@ -487,6 +510,7 @@ impl TryFrom for Numeric { 349 => RPL_ENDOFEXCEPTLIST, 351 => RPL_VERSION, 353 => RPL_NAMREPLY, + 354 => RPL_WHOSPCRPL, 366 => RPL_ENDOFNAMES, 364 => RPL_LINKS, 365 => RPL_ENDOFLINKS, diff --git a/src/buffer/channel.rs b/src/buffer/channel.rs index da3cdcdfe..004881ec9 100644 --- a/src/buffer/channel.rs +++ b/src/buffer/channel.rs @@ -149,6 +149,7 @@ pub fn view<'a>( input, users, channels, + clients.get_isupport_parameters(&state.server), is_focused, !is_connected_to_channel, ) @@ -308,13 +309,7 @@ mod nick_list { ) }); - user_context::view( - content, - user, - Some(user), - buffer.clone(), - our_user, - ) + user_context::view(content, user, Some(user), buffer.clone(), our_user) })) .padding(4) .spacing(1); diff --git a/src/buffer/input_view.rs b/src/buffer/input_view.rs index b477fc075..7e1d8cd0a 100644 --- a/src/buffer/input_view.rs +++ b/src/buffer/input_view.rs @@ -1,4 +1,5 @@ use data::input::{Cache, Draft}; +use data::isupport; use data::user::{Nick, User}; use data::{client, history, Buffer, Input}; use iced::Command; @@ -22,6 +23,7 @@ pub fn view<'a>( cache: Cache<'a>, users: &'a [User], channels: &'a [String], + isupport_parameters: Vec<&'a isupport::Parameter>, buffer_focused: bool, disabled: bool, ) -> Element<'a, Message> { @@ -32,6 +34,7 @@ pub fn view<'a>( cache.history, users, channels, + isupport_parameters, buffer_focused, disabled, Message::Input, diff --git a/src/buffer/query.rs b/src/buffer/query.rs index 37fc44b69..93ba4305f 100644 --- a/src/buffer/query.rs +++ b/src/buffer/query.rs @@ -118,6 +118,7 @@ pub fn view<'a>( input, &[], channels, + clients.get_isupport_parameters(&state.server), is_focused, !status.connected() ) diff --git a/src/buffer/server.rs b/src/buffer/server.rs index bc6732d87..39aded9f4 100644 --- a/src/buffer/server.rs +++ b/src/buffer/server.rs @@ -75,6 +75,7 @@ pub fn view<'a>( input, &[], channels, + clients.get_isupport_parameters(&state.server), is_focused, !status.connected() ) diff --git a/src/widget/input.rs b/src/widget/input.rs index 14233df10..3e0e1037a 100644 --- a/src/widget/input.rs +++ b/src/widget/input.rs @@ -1,5 +1,5 @@ use data::user::User; -use data::{input, Buffer, Command}; +use data::{input, isupport, Buffer, Command}; use iced::advanced::widget::{self, Operation}; pub use iced::widget::text_input::{focus, move_cursor_to_end}; use iced::widget::{component, container, row, text, text_input, Component}; @@ -20,6 +20,7 @@ pub fn input<'a, Message>( history: &'a [String], users: &'a [User], channels: &'a [String], + isupport_parameters: Vec<&'a isupport::Parameter>, buffer_focused: bool, disabled: bool, on_input: impl Fn(input::Draft) -> Message + 'a, @@ -35,6 +36,7 @@ where input, users, channels, + isupport_parameters, history, buffer_focused, disabled, @@ -66,6 +68,7 @@ pub struct Input<'a, Message> { input: &'a str, users: &'a [User], channels: &'a [String], + isupport_parameters: Vec<&'a isupport::Parameter>, history: &'a [String], buffer_focused: bool, disabled: bool, @@ -96,7 +99,12 @@ where // Reset selected history state.selected_history = None; - state.completion.process(&input, self.users, self.channels); + state.completion.process( + &input, + self.users, + self.channels, + &self.isupport_parameters, + ); Some((self.on_input)(input::Draft { buffer: self.buffer.clone(), @@ -160,9 +168,12 @@ where .get(state.selected_history.unwrap()) .unwrap() .clone(); - state - .completion - .process(&new_input, self.users, self.channels); + state.completion.process( + &new_input, + self.users, + self.channels, + &self.isupport_parameters, + ); return Some((self.on_completion)(input::Draft { buffer: self.buffer.clone(), @@ -182,9 +193,12 @@ where } else { *index -= 1; let new_input = self.history.get(*index).unwrap().clone(); - state - .completion - .process(&new_input, self.users, self.channels); + state.completion.process( + &new_input, + self.users, + self.channels, + &self.isupport_parameters, + ); new_input }; diff --git a/src/widget/input/completion.rs b/src/widget/input/completion.rs index 79cbebb78..43515adfc 100644 --- a/src/widget/input/completion.rs +++ b/src/widget/input/completion.rs @@ -1,5 +1,6 @@ use std::fmt; +use data::isupport; use data::user::User; use iced::widget::{column, container, row, text}; use iced::Length; @@ -22,11 +23,17 @@ impl Completion { } /// Process input and update the completion state - pub fn process(&mut self, input: &str, users: &[User], channels: &[String]) { + pub fn process( + &mut self, + input: &str, + users: &[User], + channels: &[String], + isupport_parameters: &[&isupport::Parameter], + ) { let is_command = input.starts_with('/'); if is_command { - self.commands.process(input); + self.commands.process(input, isupport_parameters); // Disallow user completions when selecting a command if matches!(self.commands, Commands::Selecting { .. }) { @@ -94,7 +101,7 @@ impl Default for Commands { } impl Commands { - fn process(&mut self, input: &str) { + fn process(&mut self, input: &str, isupport_parameters: &[&isupport::Parameter]) { let Some((head, rest)) = input.split_once('/') else { *self = Self::Idle; return; @@ -112,11 +119,30 @@ impl Commands { (rest, false) }; + let command_list = + COMMAND_LIST + .iter() + .map(|command| { + if command.title == "WHO" + && isupport_parameters.iter().any(|isupport_parameter| { + matches!(isupport_parameter, isupport::Parameter::WHOX) + }) + { + &WHOX_COMMAND + } else { + command + } + }) + .chain(isupport_parameters.iter().filter_map(|isupport_parameter| { + isupport_parameter_to_command(isupport_parameter) + })) + .collect::>(); + match self { // Command not fully typed, show filtered entries _ if !has_space => { - let filtered = COMMAND_LIST - .iter() + let filtered = command_list + .into_iter() .filter(|command| { command .title @@ -133,8 +159,8 @@ impl Commands { } // Command fully typed, transition to showing known entry Self::Idle | Self::Selecting { .. } => { - if let Some(command) = COMMAND_LIST - .iter() + if let Some(command) = command_list + .into_iter() .find(|command| command.title.to_lowercase() == cmd.to_lowercase()) .cloned() { @@ -483,6 +509,13 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { }, ], }, + Command { + title: "WHO", + args: vec![Arg { + text: "target", + optional: false, + }], + }, Command { title: "KICK", args: vec![ @@ -515,3 +548,104 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { }, ] }); + +fn isupport_parameter_to_command(isupport_parameter: &isupport::Parameter) -> Option<&Command> { + match isupport_parameter { + isupport::Parameter::KNOCK => Some(&KNOCK_COMMAND), + isupport::Parameter::USERIP => Some(&USERIP_COMMAND), + isupport::Parameter::CNOTICE => Some(&CNOTICE_COMMAND), + isupport::Parameter::CPRIVMSG => Some(&CPRIVMSG_COMMAND), + isupport::Parameter::SAFELIST => Some(&LIST_COMMAND), + _ => None, + } +} + +static CNOTICE_COMMAND: Lazy = Lazy::new(|| Command { + title: "CNOTICE", + args: vec![ + Arg { + text: "nickname", + optional: false, + }, + Arg { + text: "channel", + optional: false, + }, + Arg { + text: "message", + optional: false, + }, + ], +}); + +static CPRIVMSG_COMMAND: Lazy = Lazy::new(|| Command { + title: "CPRIVMSG", + args: vec![ + Arg { + text: "nickname", + optional: false, + }, + Arg { + text: "channel", + optional: false, + }, + Arg { + text: "message", + optional: false, + }, + ], +}); + +static KNOCK_COMMAND: Lazy = Lazy::new(|| Command { + title: "KNOCK", + args: vec![ + Arg { + text: "channel", + optional: false, + }, + Arg { + text: "message", + optional: true, + }, + ], +}); + +static LIST_COMMAND: Lazy = Lazy::new(|| Command { + title: "LIST", + args: vec![ + Arg { + text: "channels", + optional: true, + }, + Arg { + text: "server", + optional: true, + }, + ], +}); + +static USERIP_COMMAND: Lazy = Lazy::new(|| Command { + title: "USERIP", + args: vec![Arg { + text: "nickname", + optional: false, + }], +}); + +static WHOX_COMMAND: Lazy = Lazy::new(|| Command { + title: "WHO", + args: vec![ + Arg { + text: "target", + optional: false, + }, + Arg { + text: "fields", + optional: true, + }, + Arg { + text: "token", + optional: true, + }, + ], +}); From afd06870e2924b7307cbb78856aea1fd90fb6338 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Mon, 22 Apr 2024 22:04:40 -0700 Subject: [PATCH 03/23] Utilize `WHOX` for `WHO` polling. --- data/src/client.rs | 76 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index d2e0d7e78..a946cc288 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -659,8 +659,21 @@ impl Client { if let Some(state) = self.chanmap.get_mut(channel) { // Sends WHO to get away state on users. - let _ = self.handle.try_send(command!("WHO", channel)); - state.last_who = Some(WhoStatus::Requested(Instant::now())); + if self.isupport_parameters.get("WHOX").is_some() { + let _ = self.handle.try_send(command!( + "WHO", + channel, + "tcnf", + format!("{}", who_polling_tag()) + )); + state.last_who = Some(WhoStatus::Requested( + Instant::now(), + Some(who_polling_tag()), + )); + } else { + let _ = self.handle.try_send(command!("WHO", channel)); + state.last_who = Some(WhoStatus::Requested(Instant::now(), None)); + } log::debug!("[{}] {channel} - WHO requested", self.server); } } else if let Some(channel) = self.chanmap.get_mut(channel) { @@ -681,7 +694,7 @@ impl Client { if proto::is_channel(target) { if let Some(channel) = self.chanmap.get_mut(target) { - if matches!(channel.last_who, Some(WhoStatus::Requested(_)) | None) { + if matches!(channel.last_who, Some(WhoStatus::Requested(_, None)) | None) { channel.last_who = Some(WhoStatus::Receiving); log::debug!("[{}] {target} - WHO receiving...", self.server); } @@ -704,6 +717,38 @@ impl Client { } } } + Command::Numeric(RPL_WHOSPCRPL, args) => { + let target = args.get(2)?; + + if proto::is_channel(target) { + if let Some(channel) = self.chanmap.get_mut(target) { + let tag = args.get(1)?.parse::().ok(); + + if let Some(WhoStatus::Requested(_, request_tag)) = channel.last_who { + if request_tag == tag { + channel.last_who = Some(WhoStatus::Receiving); + log::debug!("[{}] {target} - WHO receiving...", self.server); + } + } + + // We requested, don't save to history + if matches!(channel.last_who, Some(WhoStatus::Receiving)) { + // H = Here, G = gone (away) + let flags = args.get(4)?.chars().collect::>(); + let away = *(flags.first()?) == 'G'; + + let lookup = User::from(Nick::from(args[3].clone())); + + if let Some(mut user) = channel.users.take(&lookup) { + user.update_away(away); + channel.users.insert(user); + } + + return None; + } + } + } + } Command::Numeric(RPL_ENDOFWHO, args) => { let target = args.get(1)?; @@ -971,15 +1016,28 @@ impl Client { (now.duration_since(last) >= self.config.who_poll_interval) .then_some(Request::Poll) } - Some(WhoStatus::Requested(requested)) => (now.duration_since(requested) + Some(WhoStatus::Requested(requested, _)) => (now.duration_since(requested) >= self.config.who_retry_interval) .then_some(Request::Retry), _ => None, }; if let Some(request) = request { - let _ = self.handle.try_send(command!("WHO", channel)); - state.last_who = Some(WhoStatus::Requested(Instant::now())); + if self.isupport_parameters.get("WHOX").is_some() { + let _ = self.handle.try_send(command!( + "WHO", + channel, + "tcnf", + format!("{}", who_polling_tag()) + )); + state.last_who = Some(WhoStatus::Requested( + Instant::now(), + Some(who_polling_tag()), + )); + } else { + let _ = self.handle.try_send(command!("WHO", channel)); + state.last_who = Some(WhoStatus::Requested(Instant::now(), None)); + } log::debug!( "[{}] {channel} - WHO {}", self.server, @@ -1247,11 +1305,15 @@ pub struct Topic { #[derive(Debug, Clone, Copy)] pub enum WhoStatus { - Requested(Instant), + Requested(Instant, Option), Receiving, Done(Instant), } +fn who_polling_tag() -> u16 { + 9 +} + /// Group channels together into as few JOIN messages as possible fn group_joins<'a>( channels: &'a [String], From 8a86a46d2ba7375f5e0b5c6f62f1c773845b8112 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Mon, 22 Apr 2024 23:02:19 -0700 Subject: [PATCH 04/23] Changelog update. --- CHANGELOG.md | 5 +++-- README.md | 2 ++ book/src/README.md | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3280c29..75e3987da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,9 @@ Added: - Reload configuration file from within the application (Ctrl + r (macOS: + r)) - Allow configuration of internal messages in buffer (see [buffer configuration](https://halloy.squidowl.org/configuration/buffer.html#bufferinternal_messages-section)) - User information added to context menu -- Support for IRCv3 CAP NEW and CAP DEL subcommands -- Enable support for IRCv3 `multi-prefix` +- Support for IRCv3 `CAP NEW` and `CAP DEL` subcommands +- Enable support for IRCv3 `multi-prefix`, `WHOX`, and `UTF8ONLY` +- Dynamic commands and tooltips added to command auto-completion via `ISUPPORT` - Added support for `socks5` proxy configuration (see [proxy configuration](https://halloy.squidowl.org/configuration/proxy.html)) Changed: diff --git a/README.md b/README.md index 4c8d1a2fe..25ca5da2d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Join **#halloy** on libera.chat if you have questions or looking for help. * [sasl-3.1](https://ircv3.net/specs/extensions/sasl-3.1) * [cap-notify](https://ircv3.net/specs/extensions/capability-negotiation.html#cap-notify) * [multi-prefix](https://ircv3.net/specs/extensions/multi-prefix) + * [`WHOX`](https://ircv3.net/specs/extensions/whox) + * [`UTF8ONLY`](https://ircv3.net/specs/extensions/utf8-only) * SASL support * DCC Send * Keyboard shortcuts diff --git a/book/src/README.md b/book/src/README.md index 6c2844c40..c55b8b698 100644 --- a/book/src/README.md +++ b/book/src/README.md @@ -15,6 +15,8 @@ * [sasl-3.1](https://ircv3.net/specs/extensions/sasl-3.1) * [cap-notify](https://ircv3.net/specs/extensions/capability-negotiation.html#cap-notify) * [multi-prefix](https://ircv3.net/specs/extensions/multi-prefix) + * [`WHOX`](https://ircv3.net/specs/extensions/whox) + * [`UTF8ONLY`](https://ircv3.net/specs/extensions/utf8-only) * SASL support * DCC Send * Keyboard shortcuts From d336e14e591eed52cd2451df88c424ab392ecbc8 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Tue, 23 Apr 2024 01:53:30 -0700 Subject: [PATCH 05/23] Naming fix. --- data/src/client.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index a946cc288..868898105 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -664,11 +664,11 @@ impl Client { "WHO", channel, "tcnf", - format!("{}", who_polling_tag()) + format!("{}", who_polling_token()) )); state.last_who = Some(WhoStatus::Requested( Instant::now(), - Some(who_polling_tag()), + Some(who_polling_token()), )); } else { let _ = self.handle.try_send(command!("WHO", channel)); @@ -1028,11 +1028,11 @@ impl Client { "WHO", channel, "tcnf", - format!("{}", who_polling_tag()) + format!("{}", who_polling_token()) )); state.last_who = Some(WhoStatus::Requested( Instant::now(), - Some(who_polling_tag()), + Some(who_polling_token()), )); } else { let _ = self.handle.try_send(command!("WHO", channel)); @@ -1310,7 +1310,7 @@ pub enum WhoStatus { Done(Instant), } -fn who_polling_tag() -> u16 { +fn who_polling_token() -> u16 { 9 } From 1fd0ef8f0e93fb4810683426b7c255599ddcaf11 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Tue, 23 Apr 2024 12:16:14 -0700 Subject: [PATCH 06/23] Parameterize & clean up ISUPPORT module. --- data/src/isupport.rs | 346 ++++++++++++++++--------------------------- 1 file changed, 124 insertions(+), 222 deletions(-) diff --git a/data/src/isupport.rs b/data/src/isupport.rs index 16ad7283b..c322ac62f 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -84,18 +84,12 @@ impl<'a> TryFrom<&'a str> for Parameter { return Err("empty ISUPPORT parameter not allowed"); } - match isupport.chars().nth(0) { + match isupport.chars().next() { Some('-') => Ok(Parameter::Negation(isupport[1..].to_string())), _ => { if let Some((parameter, value)) = isupport.split_once('=') { match parameter { - "ACCEPT" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::ACCEPT(value)) - } else { - Err("ACCEPT value must be a positive integer") - } - } + "ACCEPT" => Ok(Parameter::ACCEPT(parse_required_positive_integer(value)?)), "ACCOUNTEXTBAN" => { let account_based_extended_ban_masks = value.split(',').map(String::from).collect::>(); @@ -107,28 +101,13 @@ impl<'a> TryFrom<&'a str> for Parameter { } } "AWAYLEN" => { - if value.is_empty() { - Ok(Parameter::AWAYLEN(None)) - } else if let Ok(value) = value.parse::() { - Ok(Parameter::AWAYLEN(Some(value))) - } else { - Err("AWAYLEN value must be a positive integer if specified") - } - } - "BOT" => { - if let Some(value) = value.chars().nth(0) { - Ok(Parameter::BOT(value)) - } else { - Err("BOT value must be a character") - } - } - "CALLERID" => { - if let Some(value) = value.chars().nth(0) { - Ok(Parameter::CALLERID(value)) - } else { - Ok(Parameter::CALLERID(default_caller_id_letter())) - } + Ok(Parameter::AWAYLEN(parse_optional_positive_integer(value)?)) } + "BOT" => Ok(Parameter::BOT(parse_required_letter(value, None)?)), + "CALLERID" => Ok(Parameter::CALLERID(parse_required_letter( + value, + Some(default_caller_id_letter()), + )?)), "CASEMAPPING" => match value { "ascii" => Ok(Parameter::CASEMAPPING(CaseMap::ASCII)), "rfc1459" => Ok(Parameter::CASEMAPPING(CaseMap::RFC1459)), @@ -179,34 +158,20 @@ impl<'a> TryFrom<&'a str> for Parameter { Err("no valid channel modes") } } - "CHANNELLEN" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::CHANNELLEN(value)) - } else { - Err("CHANNELLEN value must be a positive integer") - } - } - "CHANTYPES" => { - if value.is_empty() { - Ok(Parameter::CHANTYPES(None)) - } else { - Ok(Parameter::CHANTYPES(Some(String::from(value)))) - } - } - "CHATHISTORY" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::CHATHISTORY(value)) - } else { - Err("CHATHISTORY value must be a positive integer") - } - } + "CHANNELLEN" => Ok(Parameter::CHANNELLEN(parse_required_positive_integer( + value, + )?)), + "CHANTYPES" => Ok(Parameter::CHANTYPES(parse_optional_string(value))), + "CHATHISTORY" => Ok(Parameter::CHATHISTORY( + parse_required_positive_integer(value)?, + )), "CLIENTTAGDENY" => { let mut client_tag_denials = vec![]; value .split(',') .for_each(|client_tag_denial| { - match client_tag_denial.chars().nth(0) { + match client_tag_denial.chars().next() { Some('*') => { client_tag_denials.push(ClientOnlyTags::DenyAll) } @@ -236,45 +201,27 @@ impl<'a> TryFrom<&'a str> for Parameter { } } - Err("CLIENTVER value must be a . version number") + Err("value must be a . version number") } "CNOTICE" => Ok(Parameter::CNOTICE), "CPRIVMSG" => Ok(Parameter::CPRIVMSG), - "DEAF" => { - if let Some(value) = value.chars().nth(0) { - Ok(Parameter::DEAF(value)) - } else { - Ok(Parameter::DEAF(default_deaf_letter())) - } - } - "ELIST" => { - if !value.is_empty() { - Ok(Parameter::ELIST(value.to_string())) - } else { - Err("ELIST value required") - } - } - "ESILENCE" => { - if value.is_empty() { - Ok(Parameter::ESILENCE(None)) - } else { - Ok(Parameter::ESILENCE(Some(value.to_string()))) - } - } + "DEAF" => Ok(Parameter::DEAF(parse_required_letter( + value, + Some(default_deaf_letter()), + )?)), + "ELIST" => Ok(Parameter::ELIST(parse_required_string(value)?)), + "ESILENCE" => Ok(Parameter::ESILENCE(parse_optional_string(value))), "ETRACE" => Ok(Parameter::ETRACE), - "EXCEPTS" => { - if let Some(value) = value.chars().nth(0) { - Ok(Parameter::EXCEPTS(value)) - } else { - Ok(Parameter::EXCEPTS(default_ban_exception_channel_letter())) - } - } + "EXCEPTS" => Ok(Parameter::EXCEPTS(parse_required_letter( + value, + Some(default_ban_exception_channel_letter()), + )?)), "EXTBAN" => { if let Some((prefix, types)) = value.split_once(',') { if prefix.is_empty() { Ok(Parameter::EXTBAN(None, types.to_string())) } else { - Ok(Parameter::EXTBAN(prefix.chars().nth(0), types.to_string())) + Ok(Parameter::EXTBAN(prefix.chars().next(), types.to_string())) } } else { Err("no valid extended ban masks") @@ -282,56 +229,27 @@ impl<'a> TryFrom<&'a str> for Parameter { } "FNC" => Ok(Parameter::FNC), "HOSTLEN" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::HOSTLEN(value)) - } else { - Err("HOSTLEN value must be a positive integer") - } - } - "INVEX" => { - if let Some(value) = value.chars().nth(0) { - Ok(Parameter::INVEX(value)) - } else { - Ok(Parameter::INVEX(default_invite_exception_letter())) - } - } - "KEYLEN" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::KEYLEN(value)) - } else { - Err("KEYLEN value must be a positive integer") - } + Ok(Parameter::HOSTLEN(parse_required_positive_integer(value)?)) } + "INVEX" => Ok(Parameter::INVEX(parse_required_letter( + value, + Some(default_invite_exception_letter()), + )?)), + "KEYLEN" => Ok(Parameter::KEYLEN(parse_required_positive_integer(value)?)), "KICKLEN" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::KICKLEN(value)) - } else { - Err("KICKLEN value must be a positive integer") - } + Ok(Parameter::KICKLEN(parse_required_positive_integer(value)?)) } "KNOCK" => Ok(Parameter::KNOCK), "LINELEN" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::LINELEN(value)) - } else { - Err("LINELEN value must be a positive integer") - } + Ok(Parameter::LINELEN(parse_required_positive_integer(value)?)) } "MAP" => Ok(Parameter::MAP), "MAXBANS" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::MAXBANS(value)) - } else { - Err("MAXBANS value must be a positive integer") - } - } - "MAXCHANNELS" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::MAXCHANNELS(value)) - } else { - Err("MAXCHANNELS value must be a positive integer") - } + Ok(Parameter::MAXBANS(parse_required_positive_integer(value)?)) } + "MAXCHANNELS" => Ok(Parameter::MAXCHANNELS( + parse_required_positive_integer(value)?, + )), "MAXLIST" => { let mut modes_limits = vec![]; @@ -353,47 +271,17 @@ impl<'a> TryFrom<&'a str> for Parameter { } } "MAXPARA" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::MAXPARA(value)) - } else { - Err("MAXPARA value must be a positive integer") - } - } - "MAXTARGETS" => { - if value.is_empty() { - Ok(Parameter::MAXTARGETS(None)) - } else if let Ok(value) = value.parse::() { - Ok(Parameter::MAXTARGETS(Some(value))) - } else { - Err("MAXTARGETS value must be a positive integer if specified") - } + Ok(Parameter::MAXPARA(parse_required_positive_integer(value)?)) } + "MAXTARGETS" => Ok(Parameter::MAXTARGETS(parse_optional_positive_integer( + value, + )?)), "METADATA" => { - if value.is_empty() { - Ok(Parameter::METADATA(None)) - } else if let Ok(value) = value.parse::() { - Ok(Parameter::METADATA(Some(value))) - } else { - Err("METADATA value must be a positive integer if specified") - } - } - "MODES" => { - if value.is_empty() { - Ok(Parameter::MODES(None)) - } else if let Ok(value) = value.parse::() { - Ok(Parameter::MODES(Some(value))) - } else { - Err("MODES value must be a positive integer if specified") - } + Ok(Parameter::METADATA(parse_optional_positive_integer(value)?)) } + "MODES" => Ok(Parameter::MODES(parse_optional_positive_integer(value)?)), "MONITOR" => { - if value.is_empty() { - Ok(Parameter::MONITOR(None)) - } else if let Ok(value) = value.parse::() { - Ok(Parameter::MONITOR(Some(value))) - } else { - Err("MONITOR value must be a positive integer if specified") - } + Ok(Parameter::MONITOR(parse_optional_positive_integer(value)?)) } "MSGREFTYPES" => { let mut message_reference_types = vec![]; @@ -413,11 +301,7 @@ impl<'a> TryFrom<&'a str> for Parameter { "NAMESX" => Ok(Parameter::NAMESX), "NETWORK" => Ok(Parameter::NETWORK(value.to_string())), "NICKLEN" | "MAXNICKLEN" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::NICKLEN(value)) - } else { - Err("NICKLEN value must be a positive integer") - } + Ok(Parameter::NICKLEN(parse_required_positive_integer(value)?)) } "OVERRIDE" => Ok(Parameter::OVERRIDE), "PREFIX" => { @@ -441,13 +325,7 @@ impl<'a> TryFrom<&'a str> for Parameter { "SAFELIST" => Ok(Parameter::SAFELIST), "SECURELIST" => Ok(Parameter::SECURELIST), "SILENCE" => { - if value.is_empty() { - Ok(Parameter::SILENCE(None)) - } else if let Ok(value) = value.parse::() { - Ok(Parameter::SILENCE(Some(value))) - } else { - Err("SILENCE value must be a positive integer if specified") - } + Ok(Parameter::SILENCE(parse_optional_positive_integer(value)?)) } "STATUSMSG" => Ok(Parameter::STATUSMSG(value.to_string())), "TARGMAX" => { @@ -477,94 +355,74 @@ impl<'a> TryFrom<&'a str> for Parameter { } } "TOPICLEN" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::TOPICLEN(value)) - } else { - Err("TOPICLEN value must be a positive integer") - } + Ok(Parameter::TOPICLEN(parse_required_positive_integer(value)?)) } "UHNAMES" => Ok(Parameter::UHNAMES), "USERIP" => Ok(Parameter::USERIP), "USERLEN" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::USERLEN(value)) - } else { - Err("USERLEN value must be a positive integer") - } + Ok(Parameter::USERLEN(parse_required_positive_integer(value)?)) } "UTF8ONLY" => Ok(Parameter::UTF8ONLY), - "VLIST" => { - if !value.is_empty() { - Ok(Parameter::VLIST(value.to_string())) - } else { - Err("VLIST value required") - } - } - "WATCH" => { - if let Ok(value) = value.parse::() { - Ok(Parameter::WATCH(value)) - } else { - Err("WATCH value must be a positive integer") - } - } + "VLIST" => Ok(Parameter::VLIST(parse_required_string(value)?)), + "WATCH" => Ok(Parameter::WATCH(parse_required_positive_integer(value)?)), "WHOX" => Ok(Parameter::WHOX), _ => Err("unknown ISUPPORT parameter"), } } else { match isupport { - "ACCEPT" => Err("ACCEPT value required"), - "ACCOUNTEXTBAN" => Err("ACCOUNTEXTBAN value(s) required"), + "ACCEPT" => Err("value required"), + "ACCOUNTEXTBAN" => Err("value(s) required"), "AWAYLEN" => Ok(Parameter::AWAYLEN(None)), - "BOT" => Err("BOT value required"), + "BOT" => Err("value required"), "CALLERID" => Ok(Parameter::CALLERID(default_caller_id_letter())), - "CASEMAPPING" => Err("CASEMAPPING value required"), - "CHANLIMIT" => Err("CHANLIMIT value(s) required"), - "CHANMODES" => Err("CHANMODES value(s) required"), - "CHANNELLEN" => Err("CHANNELLEN value required"), + "CASEMAPPING" => Err("value required"), + "CHANLIMIT" => Err("value(s) required"), + "CHANMODES" => Err("value(s) required"), + "CHANNELLEN" => Err("value required"), "CHANTYPES" => Ok(Parameter::CHANTYPES(None)), - "CHATHISTORY" => Err("CHATHISTORY value required"), - "CLIENTTAGDENY" => Err("CLIENTTAGDENY value(s) required"), - "CLIENTVER" => Err("CLIENTVER value required"), + "CHATHISTORY" => Err("value required"), + "CLIENTTAGDENY" => Err("value(s) required"), + "CLIENTVER" => Err("value required"), "DEAF" => Ok(Parameter::DEAF(default_deaf_letter())), - "ELIST" => Err("ELIST value required"), + "ELIST" => Err("value required"), "ESILENCE" => Ok(Parameter::ESILENCE(None)), "ETRACE" => Ok(Parameter::ETRACE), "EXCEPTS" => Ok(Parameter::EXCEPTS(default_ban_exception_channel_letter())), - "EXTBAN" => Err("EXTBAN value required"), + "EXTBAN" => Err("value required"), "FNC" => Ok(Parameter::FNC), - "HOSTLEN" => Err("HOSTLEN value required"), + "HOSTLEN" => Err("value required"), "INVEX" => Ok(Parameter::INVEX(default_invite_exception_letter())), - "KEYLEN" => Err("KEYLEN value required"), - "KICKLEN" => Err("KICKLEN value required"), + "KEYLEN" => Err("value required"), + "KICKLEN" => Err("value required"), "KNOCK" => Ok(Parameter::KNOCK), - "LINELEN" => Err("LINELEN value required"), + "LINELEN" => Err("value required"), "MAP" => Ok(Parameter::MAP), - "MAXBANS" => Err("MAXBANS value required"), - "MAXCHANNELS" => Err("MAXCHANNELS value required"), - "MAXLIST" => Err("MAXLIST value(s) required"), - "MAXPARA" => Err("MAXPARA value required"), + "MAXBANS" => Err("value required"), + "MAXCHANNELS" => Err("value required"), + "MAXLIST" => Err("value(s) required"), + "MAXPARA" => Err("value required"), "MAXTARGETS" => Ok(Parameter::MAXTARGETS(None)), "METADATA" => Ok(Parameter::METADATA(None)), "MODES" => Ok(Parameter::MODES(None)), "MONITOR" => Ok(Parameter::MONITOR(None)), "MSGREFTYPES" => Ok(Parameter::MSGREFTYPES(vec![])), "NAMESX" => Ok(Parameter::NAMESX), - "NETWORK" => Err("NETWORK value required"), - "NICKLEN" | "MAXNICKLEN" => Err("NICKLEN value required"), + "NETWORK" => Err("value required"), + "NICKLEN" | "MAXNICKLEN" => Err("value required"), "OVERRIDE" => Ok(Parameter::OVERRIDE), "PREFIX" => Ok(Parameter::PREFIX(vec![])), "SAFELIST" => Ok(Parameter::SAFELIST), "SECURELIST" => Ok(Parameter::SECURELIST), "SILENCE" => Ok(Parameter::SILENCE(None)), - "STATUSMSG" => Err("STATUSMSG value required"), + "STATUSMSG" => Err("value required"), "TARGMAX" => Ok(Parameter::TARGMAX(vec![])), - "TOPICLEN" => Err("TOPICLEN value required"), + "TOPICLEN" => Err("value required"), "UHNAMES" => Ok(Parameter::UHNAMES), "USERIP" => Ok(Parameter::USERIP), - "USERLEN" => Err("USERLEN value required"), + "USERLEN" => Err("value required"), "UTF8ONLY" => Ok(Parameter::UTF8ONLY), - "VLIST" => Err("VLIST value required"), - "WATCH" => Err("WATCH value required"), + "VLIST" => Err("value required"), + "WATCH" => Err("value required"), "WHOX" => Ok(Parameter::WHOX), _ => Err("unknown ISUPPORT parameter"), } @@ -710,3 +568,47 @@ pub fn default_deaf_letter() -> char { pub fn default_invite_exception_letter() -> char { 'I' } + +fn parse_optional_positive_integer(value: &str) -> Result, &'static str> { + if value.is_empty() { + Ok(None) + } else if let Ok(value) = value.parse::() { + Ok(Some(value)) + } else { + Err("optional value must be a positive integer if specified") + } +} + +fn parse_optional_string(value: &str) -> Option { + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +fn parse_required_letter(value: &str, default_value: Option) -> Result { + if let Some(value) = value.chars().next() { + Ok(value) + } else if let Some(default_value) = default_value { + Ok(default_value) + } else { + Err("value required to be a letter") + } +} + +fn parse_required_positive_integer(value: &str) -> Result { + if let Ok(value) = value.parse::() { + Ok(value) + } else { + Err("value required to be a positive integer") + } +} + +fn parse_required_string(value: &str) -> Result { + if !value.is_empty() { + Ok(value.to_string()) + } else { + Err("value required") + } +} From f9c1bc54fa2aa10f505c721314307d7485207e7e Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Tue, 23 Apr 2024 12:39:59 -0700 Subject: [PATCH 07/23] Clarify name. --- data/src/isupport.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/data/src/isupport.rs b/data/src/isupport.rs index c322ac62f..2eb9ade8d 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -209,7 +209,7 @@ impl<'a> TryFrom<&'a str> for Parameter { value, Some(default_deaf_letter()), )?)), - "ELIST" => Ok(Parameter::ELIST(parse_required_string(value)?)), + "ELIST" => Ok(Parameter::ELIST(parse_required_non_empty_string(value)?)), "ESILENCE" => Ok(Parameter::ESILENCE(parse_optional_string(value))), "ETRACE" => Ok(Parameter::ETRACE), "EXCEPTS" => Ok(Parameter::EXCEPTS(parse_required_letter( @@ -363,7 +363,7 @@ impl<'a> TryFrom<&'a str> for Parameter { Ok(Parameter::USERLEN(parse_required_positive_integer(value)?)) } "UTF8ONLY" => Ok(Parameter::UTF8ONLY), - "VLIST" => Ok(Parameter::VLIST(parse_required_string(value)?)), + "VLIST" => Ok(Parameter::VLIST(parse_required_non_empty_string(value)?)), "WATCH" => Ok(Parameter::WATCH(parse_required_positive_integer(value)?)), "WHOX" => Ok(Parameter::WHOX), _ => Err("unknown ISUPPORT parameter"), @@ -597,18 +597,18 @@ fn parse_required_letter(value: &str, default_value: Option) -> Result Result { - if let Ok(value) = value.parse::() { - Ok(value) +fn parse_required_non_empty_string(value: &str) -> Result { + if !value.is_empty() { + Ok(value.to_string()) } else { - Err("value required to be a positive integer") + Err("value required") } } -fn parse_required_string(value: &str) -> Result { - if !value.is_empty() { - Ok(value.to_string()) +fn parse_required_positive_integer(value: &str) -> Result { + if let Ok(value) = value.parse::() { + Ok(value) } else { - Err("value required") + Err("value required to be a positive integer") } } From 8309039926e0e2dc3cbad2efbdf74b2b968786b0 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Tue, 23 Apr 2024 18:00:26 -0700 Subject: [PATCH 08/23] Review fixes. --- data/src/client.rs | 158 +++++------ data/src/isupport.rs | 548 ++++++++++++++++++++++----------------- irc/proto/src/command.rs | 4 +- 3 files changed, 393 insertions(+), 317 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index 868898105..3f7181e10 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -664,11 +664,11 @@ impl Client { "WHO", channel, "tcnf", - format!("{}", who_polling_token()) + isupport::WHO_POLL_TOKEN.to_owned() )); state.last_who = Some(WhoStatus::Requested( Instant::now(), - Some(who_polling_token()), + Some(isupport::WHO_POLL_TOKEN), )); } else { let _ = self.handle.try_send(command!("WHO", channel)); @@ -695,23 +695,14 @@ impl Client { if proto::is_channel(target) { if let Some(channel) = self.chanmap.get_mut(target) { if matches!(channel.last_who, Some(WhoStatus::Requested(_, None)) | None) { - channel.last_who = Some(WhoStatus::Receiving); + channel.last_who = Some(WhoStatus::Receiving(None)); log::debug!("[{}] {target} - WHO receiving...", self.server); } - // H = Here, G = gone (away) - let flags = args.get(6)?.chars().collect::>(); - let away = *(flags.first()?) == 'G'; - - let lookup = User::from(Nick::from(args[5].clone())); - - if let Some(mut user) = channel.users.take(&lookup) { - user.update_away(away); - channel.users.insert(user); - } + channel.update_user_away(User::from(Nick::from(args[5].clone())), args.get(6)?.chars().collect::>()); // We requested, don't save to history - if matches!(channel.last_who, Some(WhoStatus::Receiving)) { + if matches!(channel.last_who, Some(WhoStatus::Receiving(None))) { return None; } } @@ -722,29 +713,33 @@ impl Client { if proto::is_channel(target) { if let Some(channel) = self.chanmap.get_mut(target) { - let tag = args.get(1)?.parse::().ok(); - - if let Some(WhoStatus::Requested(_, request_tag)) = channel.last_who { - if request_tag == tag { - channel.last_who = Some(WhoStatus::Receiving); - log::debug!("[{}] {target} - WHO receiving...", self.server); + if let Ok(token) = isupport::WhoToken::try_from(args.get(1)?.clone()) { + if let Some(WhoStatus::Requested(_, Some(request_token))) = + channel.last_who + { + if request_token == token { + channel.last_who = + Some(WhoStatus::Receiving(Some(request_token))); + log::debug!("[{}] {target} - WHO receiving...", self.server); + } } - } - // We requested, don't save to history - if matches!(channel.last_who, Some(WhoStatus::Receiving)) { - // H = Here, G = gone (away) - let flags = args.get(4)?.chars().collect::>(); - let away = *(flags.first()?) == 'G'; + // We requested, don't save to history + if let Some(WhoStatus::Receiving(Some(request_token))) = + channel.last_who + { + if request_token == token { + let flags = args.get(4)?.chars().collect::>(); - let lookup = User::from(Nick::from(args[3].clone())); + if flags.iter().all(|c| matches!(c, 'H' | 'G' | '&' | '@' | '%' | '+')) { + if let Ok(user) = User::try_from(args.get(3)?.clone()) { + channel.update_user_away(user, flags); + } + } - if let Some(mut user) = channel.users.take(&lookup) { - user.update_away(away); - channel.users.insert(user); + return None; + } } - - return None; } } } @@ -754,7 +749,7 @@ impl Client { if proto::is_channel(target) { if let Some(channel) = self.chanmap.get_mut(target) { - if matches!(channel.last_who, Some(WhoStatus::Receiving)) { + if matches!(channel.last_who, Some(WhoStatus::Receiving(_))) { channel.last_who = Some(WhoStatus::Done(Instant::now())); log::debug!("[{}] {target} - WHO done", self.server); return None; @@ -893,40 +888,43 @@ impl Client { } } Command::Numeric(RPL_ISUPPORT, args) => { - args[1..].iter().for_each(|arg| { - if arg != "are supported by this server" { - let isupport_parameter = isupport::Parameter::try_from(arg.clone()); - - match isupport_parameter { - Ok(isupport_parameter) => { - match isupport_parameter { - isupport::Parameter::Negation(key) => { - log::info!( - "[{}] removing ISUPPORT parameter: {}", - self.server, - key - ); - self.isupport_parameters.remove(&key) - } - _ => { - log::info!( - "[{}] adding ISUPPORT parameter: {:?}", - self.server, - isupport_parameter - ); - self.isupport_parameters.insert( - isupport_parameter.key().to_string(), - isupport_parameter, - ) - } - }; + let args_len = args.len(); + args.iter().enumerate().skip(1).for_each(|(index, arg)| { + let isupport_operation = arg.parse::(); + + match isupport_operation { + Ok(isupport_operation) => { + match isupport_operation { + isupport::Operation::Add(isupport_parameter) => { + log::info!( + "[{}] adding ISUPPORT parameter: {:?}", + self.server, + isupport_parameter + ); + self.isupport_parameters.insert( + isupport_parameter.key().to_string(), + isupport_parameter, + ) + } + isupport::Operation::Remove(key) => { + log::info!( + "[{}] removing ISUPPORT parameter: {}", + self.server, + key + ); + self.isupport_parameters.remove(&key) + } + }; + } + Err(error) => { + if index != args_len - 1 { + log::debug!( + "[{}] unable to parse ISUPPORT parameter: {} ({})", + self.server, + arg, + error + ) } - Err(error) => log::debug!( - "[{}] unable to parse ISUPPORT parameter: {} ({})", - self.server, - arg, - error - ), } } }); @@ -1028,11 +1026,11 @@ impl Client { "WHO", channel, "tcnf", - format!("{}", who_polling_token()) + isupport::WHO_POLL_TOKEN.to_owned() )); state.last_who = Some(WhoStatus::Requested( Instant::now(), - Some(who_polling_token()), + Some(isupport::WHO_POLL_TOKEN), )); } else { let _ = self.handle.try_send(command!("WHO", channel)); @@ -1296,6 +1294,20 @@ pub struct Channel { pub names_init: bool, } +impl Channel { + pub fn update_user_away(&mut self, user: User, flags: Vec) { + if let Some(away_flag) = flags.first() { + // H = Here, G = gone (away) + let away = *away_flag == 'G'; + + if let Some(mut user) = self.users.take(&user) { + user.update_away(away); + self.users.insert(user); + } + } + } +} + #[derive(Default, Debug, Clone)] pub struct Topic { pub text: Option, @@ -1303,17 +1315,13 @@ pub struct Topic { pub time: Option>, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub enum WhoStatus { - Requested(Instant, Option), - Receiving, + Requested(Instant, Option), + Receiving(Option), Done(Instant), } -fn who_polling_token() -> u16 { - 9 -} - /// Group channels together into as few JOIN messages as possible fn group_joins<'a>( channels: &'a [String], diff --git a/data/src/isupport.rs b/data/src/isupport.rs index 2eb9ade8d..ad41c915e 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -1,118 +1,59 @@ -// ISUPPORT Parameter References -// - https://defs.ircdocs.horse/defs/isupport.html -// - https://modern.ircdocs.horse/#rplisupport-005 -// - https://ircv3.net/specs/extensions/chathistory -// - https://ircv3.net/specs/extensions/monitor -// - https://ircv3.net/specs/extensions/utf8-only -// - https://ircv3.net/specs/extensions/whox -// - https://github.com/ircv3/ircv3-specifications/pull/464/files -#[allow(non_camel_case_types)] -#[derive(Debug)] -pub enum Parameter { - ACCEPT(u16), - ACCOUNTEXTBAN(Vec), - AWAYLEN(Option), - BOT(char), - CALLERID(char), - CASEMAPPING(CaseMap), - CHANLIMIT(Vec), - CHANMODES(Vec), - CHANNELLEN(u16), - CHANTYPES(Option), - CHATHISTORY(u16), - CLIENTTAGDENY(Vec), - CLIENTVER(u16, u16), - CNOTICE, - CPRIVMSG, - DEAF(char), - ELIST(String), - ESILENCE(Option), - ETRACE, - EXCEPTS(char), - EXTBAN(Option, String), - FNC, - HOSTLEN(u16), - INVEX(char), - KEYLEN(u16), - KICKLEN(u16), - KNOCK, - LINELEN(u16), - MAP, - MAXBANS(u16), - MAXCHANNELS(u16), - MAXLIST(Vec), - MAXPARA(u16), - MAXTARGETS(Option), - METADATA(Option), - MODES(Option), - MONITOR(Option), - MSGREFTYPES(Vec), - NAMESX, - NETWORK(String), - NICKLEN(u16), - OVERRIDE, - PREFIX(Vec), - SAFELIST, - SECURELIST, - SILENCE(Option), - STATUSMSG(String), - TARGMAX(Vec), - TOPICLEN(u16), - UHNAMES, - USERIP, - USERLEN(u16), - UTF8ONLY, - VLIST(String), - WATCH(u16), - WHOX, - Negation(String), -} - -impl TryFrom for Parameter { - type Error = &'static str; +use std::str::FromStr; - fn try_from(isupport: String) -> Result { - Self::try_from(isupport.as_str()) - } +#[derive(Debug)] +pub enum Operation { + Add(Parameter), + Remove(String), } -impl<'a> TryFrom<&'a str> for Parameter { - type Error = &'static str; +impl FromStr for Operation { + type Err = &'static str; - fn try_from(isupport: &'a str) -> Result { - if isupport.is_empty() { - return Err("empty ISUPPORT parameter not allowed"); + fn from_str(token: &str) -> Result { + if token.is_empty() { + return Err("empty ISUPPORT token not allowed"); } - match isupport.chars().next() { - Some('-') => Ok(Parameter::Negation(isupport[1..].to_string())), + match token.chars().next() { + Some('-') => Ok(Operation::Remove(token.chars().skip(1).collect())), _ => { - if let Some((parameter, value)) = isupport.split_once('=') { + if let Some((parameter, value)) = token.split_once('=') { match parameter { - "ACCEPT" => Ok(Parameter::ACCEPT(parse_required_positive_integer(value)?)), + "ACCEPT" => Ok(Operation::Add(Parameter::ACCEPT( + parse_required_positive_integer(value)?, + ))), "ACCOUNTEXTBAN" => { let account_based_extended_ban_masks = value.split(',').map(String::from).collect::>(); if !account_based_extended_ban_masks.is_empty() { - Ok(Parameter::ACCOUNTEXTBAN(account_based_extended_ban_masks)) + Ok(Operation::Add(Parameter::ACCOUNTEXTBAN( + account_based_extended_ban_masks, + ))) } else { Err("no valid account-based extended ban masks") } } - "AWAYLEN" => { - Ok(Parameter::AWAYLEN(parse_optional_positive_integer(value)?)) - } - "BOT" => Ok(Parameter::BOT(parse_required_letter(value, None)?)), - "CALLERID" => Ok(Parameter::CALLERID(parse_required_letter( - value, - Some(default_caller_id_letter()), - )?)), - "CASEMAPPING" => match value { - "ascii" => Ok(Parameter::CASEMAPPING(CaseMap::ASCII)), - "rfc1459" => Ok(Parameter::CASEMAPPING(CaseMap::RFC1459)), - "rfc1459-strict" => Ok(Parameter::CASEMAPPING(CaseMap::RFC1459_STRICT)), - "rfc7613" => Ok(Parameter::CASEMAPPING(CaseMap::RFC7613)), + "AWAYLEN" => Ok(Operation::Add(Parameter::AWAYLEN( + parse_optional_positive_integer(value)?, + ))), + "BOT" => Ok(Operation::Add(Parameter::BOT(parse_required_letter( + value, None, + )?))), + "CALLERID" => Ok(Operation::Add(Parameter::CALLERID( + parse_required_letter(value, Some(DEFAULT_CALLER_ID_LETTER))?, + ))), + "CASEMAPPING" => match value.to_lowercase().as_ref() { + "ascii" => Ok(Operation::Add(Parameter::CASEMAPPING(CaseMap::ASCII))), + "rfc1459" => { + Ok(Operation::Add(Parameter::CASEMAPPING(CaseMap::RFC1459))) + } + "rfc1459-strict" => Ok(Operation::Add(Parameter::CASEMAPPING( + CaseMap::RFC1459_STRICT, + ))), + "rfc7613" => { + Ok(Operation::Add(Parameter::CASEMAPPING(CaseMap::RFC7613))) + } _ => Err("unknown casemapping"), }, "CHANLIMIT" => { @@ -121,21 +62,25 @@ impl<'a> TryFrom<&'a str> for Parameter { value.split(',').for_each(|channel_limit| { if let Some((prefix, limit)) = channel_limit.split_once(':') { if limit.is_empty() { - channel_limits.push(ChannelLimit { - prefix: prefix.to_string(), - limit: None, + prefix.chars().for_each(|c| { + channel_limits.push(ChannelLimit { + prefix: c, + limit: None, + }) }); } else if let Ok(limit) = limit.parse::() { - channel_limits.push(ChannelLimit { - prefix: prefix.to_string(), - limit: Some(limit), + prefix.chars().for_each(|c| { + channel_limits.push(ChannelLimit { + prefix: c, + limit: Some(limit), + }) }); } } }); if !channel_limits.is_empty() { - Ok(Parameter::CHANLIMIT(channel_limits)) + Ok(Operation::Add(Parameter::CHANLIMIT(channel_limits))) } else { Err("no valid channel limits") } @@ -153,18 +98,20 @@ impl<'a> TryFrom<&'a str> for Parameter { }); if !channel_modes.is_empty() { - Ok(Parameter::CHANMODES(channel_modes)) + Ok(Operation::Add(Parameter::CHANMODES(channel_modes))) } else { Err("no valid channel modes") } } - "CHANNELLEN" => Ok(Parameter::CHANNELLEN(parse_required_positive_integer( - value, - )?)), - "CHANTYPES" => Ok(Parameter::CHANTYPES(parse_optional_string(value))), - "CHATHISTORY" => Ok(Parameter::CHATHISTORY( + "CHANNELLEN" => Ok(Operation::Add(Parameter::CHANNELLEN( + parse_required_positive_integer(value)?, + ))), + "CHANTYPES" => Ok(Operation::Add(Parameter::CHANTYPES( + parse_optional_string(value), + ))), + "CHATHISTORY" => Ok(Operation::Add(Parameter::CHATHISTORY( parse_required_positive_integer(value)?, - )), + ))), "CLIENTTAGDENY" => { let mut client_tag_denials = vec![]; @@ -177,7 +124,7 @@ impl<'a> TryFrom<&'a str> for Parameter { } Some('-') => { client_tag_denials.push(ClientOnlyTags::Allowed( - client_tag_denial[1..].to_string(), + client_tag_denial.chars().skip(1).collect(), )) } _ => client_tag_denials.push(ClientOnlyTags::Denied( @@ -187,7 +134,7 @@ impl<'a> TryFrom<&'a str> for Parameter { }); if !client_tag_denials.is_empty() { - Ok(Parameter::CLIENTTAGDENY(client_tag_denials)) + Ok(Operation::Add(Parameter::CLIENTTAGDENY(client_tag_denials))) } else { Err("no valid client tag denials") } @@ -197,59 +144,68 @@ impl<'a> TryFrom<&'a str> for Parameter { if let (Ok(major), Ok(minor)) = (major.parse::(), minor.parse::()) { - return Ok(Parameter::CLIENTVER(major, minor)); + return Ok(Operation::Add(Parameter::CLIENTVER(major, minor))); } } Err("value must be a . version number") } - "CNOTICE" => Ok(Parameter::CNOTICE), - "CPRIVMSG" => Ok(Parameter::CPRIVMSG), - "DEAF" => Ok(Parameter::DEAF(parse_required_letter( + "CNOTICE" => Ok(Operation::Add(Parameter::CNOTICE)), + "CPRIVMSG" => Ok(Operation::Add(Parameter::CPRIVMSG)), + "DEAF" => Ok(Operation::Add(Parameter::DEAF(parse_required_letter( value, - Some(default_deaf_letter()), - )?)), - "ELIST" => Ok(Parameter::ELIST(parse_required_non_empty_string(value)?)), - "ESILENCE" => Ok(Parameter::ESILENCE(parse_optional_string(value))), - "ETRACE" => Ok(Parameter::ETRACE), - "EXCEPTS" => Ok(Parameter::EXCEPTS(parse_required_letter( + Some(DEFAULT_DEAF_LETTER), + )?))), + "ELIST" => Ok(Operation::Add(Parameter::ELIST( + parse_required_non_empty_string(value)?, + ))), + "ESILENCE" => Ok(Operation::Add(Parameter::ESILENCE( + parse_optional_string(value), + ))), + "ETRACE" => Ok(Operation::Add(Parameter::ETRACE)), + "EXCEPTS" => Ok(Operation::Add(Parameter::EXCEPTS(parse_required_letter( value, - Some(default_ban_exception_channel_letter()), - )?)), + Some(DEFAULT_BAN_EXCEPTION_CHANNEL_LETTER), + )?))), "EXTBAN" => { if let Some((prefix, types)) = value.split_once(',') { if prefix.is_empty() { - Ok(Parameter::EXTBAN(None, types.to_string())) + Ok(Operation::Add(Parameter::EXTBAN(None, types.to_string()))) } else { - Ok(Parameter::EXTBAN(prefix.chars().next(), types.to_string())) + Ok(Operation::Add(Parameter::EXTBAN( + prefix.chars().next(), + types.to_string(), + ))) } } else { Err("no valid extended ban masks") } } - "FNC" => Ok(Parameter::FNC), - "HOSTLEN" => { - Ok(Parameter::HOSTLEN(parse_required_positive_integer(value)?)) - } - "INVEX" => Ok(Parameter::INVEX(parse_required_letter( + "FNC" => Ok(Operation::Add(Parameter::FNC)), + "HOSTLEN" => Ok(Operation::Add(Parameter::HOSTLEN( + parse_required_positive_integer(value)?, + ))), + "INVEX" => Ok(Operation::Add(Parameter::INVEX(parse_required_letter( value, - Some(default_invite_exception_letter()), - )?)), - "KEYLEN" => Ok(Parameter::KEYLEN(parse_required_positive_integer(value)?)), - "KICKLEN" => { - Ok(Parameter::KICKLEN(parse_required_positive_integer(value)?)) - } - "KNOCK" => Ok(Parameter::KNOCK), - "LINELEN" => { - Ok(Parameter::LINELEN(parse_required_positive_integer(value)?)) - } - "MAP" => Ok(Parameter::MAP), - "MAXBANS" => { - Ok(Parameter::MAXBANS(parse_required_positive_integer(value)?)) - } - "MAXCHANNELS" => Ok(Parameter::MAXCHANNELS( + Some(DEFAULT_INVITE_EXCEPTION_LETTER), + )?))), + "KEYLEN" => Ok(Operation::Add(Parameter::KEYLEN( + parse_required_positive_integer(value)?, + ))), + "KICKLEN" => Ok(Operation::Add(Parameter::KICKLEN( + parse_required_positive_integer(value)?, + ))), + "KNOCK" => Ok(Operation::Add(Parameter::KNOCK)), + "LINELEN" => Ok(Operation::Add(Parameter::LINELEN( + parse_required_positive_integer(value)?, + ))), + "MAP" => Ok(Operation::Add(Parameter::MAP)), + "MAXBANS" => Ok(Operation::Add(Parameter::MAXBANS( + parse_required_positive_integer(value)?, + ))), + "MAXCHANNELS" => Ok(Operation::Add(Parameter::MAXCHANNELS( parse_required_positive_integer(value)?, - )), + ))), "MAXLIST" => { let mut modes_limits = vec![]; @@ -265,24 +221,26 @@ impl<'a> TryFrom<&'a str> for Parameter { }); if !modes_limits.is_empty() { - Ok(Parameter::MAXLIST(modes_limits)) + Ok(Operation::Add(Parameter::MAXLIST(modes_limits))) } else { Err("no valid modes limits") } } - "MAXPARA" => { - Ok(Parameter::MAXPARA(parse_required_positive_integer(value)?)) - } - "MAXTARGETS" => Ok(Parameter::MAXTARGETS(parse_optional_positive_integer( - value, - )?)), - "METADATA" => { - Ok(Parameter::METADATA(parse_optional_positive_integer(value)?)) - } - "MODES" => Ok(Parameter::MODES(parse_optional_positive_integer(value)?)), - "MONITOR" => { - Ok(Parameter::MONITOR(parse_optional_positive_integer(value)?)) - } + "MAXPARA" => Ok(Operation::Add(Parameter::MAXPARA( + parse_required_positive_integer(value)?, + ))), + "MAXTARGETS" => Ok(Operation::Add(Parameter::MAXTARGETS( + parse_optional_positive_integer(value)?, + ))), + "METADATA" => Ok(Operation::Add(Parameter::METADATA( + parse_optional_positive_integer(value)?, + ))), + "MODES" => Ok(Operation::Add(Parameter::MODES( + parse_optional_positive_integer(value)?, + ))), + "MONITOR" => Ok(Operation::Add(Parameter::MONITOR( + parse_optional_positive_integer(value)?, + ))), "MSGREFTYPES" => { let mut message_reference_types = vec![]; @@ -296,38 +254,35 @@ impl<'a> TryFrom<&'a str> for Parameter { } }); - Ok(Parameter::MSGREFTYPES(message_reference_types)) - } - "NAMESX" => Ok(Parameter::NAMESX), - "NETWORK" => Ok(Parameter::NETWORK(value.to_string())), - "NICKLEN" | "MAXNICKLEN" => { - Ok(Parameter::NICKLEN(parse_required_positive_integer(value)?)) + Ok(Operation::Add(Parameter::MSGREFTYPES( + message_reference_types, + ))) } - "OVERRIDE" => Ok(Parameter::OVERRIDE), + "NAMESX" => Ok(Operation::Add(Parameter::NAMESX)), + "NETWORK" => Ok(Operation::Add(Parameter::NETWORK(value.to_string()))), + "NICKLEN" | "MAXNICKLEN" => Ok(Operation::Add(Parameter::NICKLEN( + parse_required_positive_integer(value)?, + ))), + "OVERRIDE" => Ok(Operation::Add(Parameter::OVERRIDE)), "PREFIX" => { let mut prefix_maps = vec![]; if let Some((modes, prefixes)) = value.split_once(')') { - let modes = &modes[1..]; - - modes - .chars() - .zip(prefixes.chars()) - .for_each(|(mode, prefix)| { - prefix_maps.push(PrefixMap { mode, prefix }) - }); + modes.chars().skip(1).zip(prefixes.chars()).for_each( + |(mode, prefix)| prefix_maps.push(PrefixMap { mode, prefix }), + ); - Ok(Parameter::PREFIX(prefix_maps)) + Ok(Operation::Add(Parameter::PREFIX(prefix_maps))) } else { Err("unrecognized PREFIX format") } } - "SAFELIST" => Ok(Parameter::SAFELIST), - "SECURELIST" => Ok(Parameter::SECURELIST), - "SILENCE" => { - Ok(Parameter::SILENCE(parse_optional_positive_integer(value)?)) - } - "STATUSMSG" => Ok(Parameter::STATUSMSG(value.to_string())), + "SAFELIST" => Ok(Operation::Add(Parameter::SAFELIST)), + "SECURELIST" => Ok(Operation::Add(Parameter::SECURELIST)), + "SILENCE" => Ok(Operation::Add(Parameter::SILENCE( + parse_optional_positive_integer(value)?, + ))), + "STATUSMSG" => Ok(Operation::Add(Parameter::STATUSMSG(value.to_string()))), "TARGMAX" => { let mut command_target_limits = vec![]; @@ -349,81 +304,91 @@ impl<'a> TryFrom<&'a str> for Parameter { }); if !command_target_limits.is_empty() { - Ok(Parameter::TARGMAX(command_target_limits)) + Ok(Operation::Add(Parameter::TARGMAX(command_target_limits))) } else { Err("no valid command target limits") } } - "TOPICLEN" => { - Ok(Parameter::TOPICLEN(parse_required_positive_integer(value)?)) - } - "UHNAMES" => Ok(Parameter::UHNAMES), - "USERIP" => Ok(Parameter::USERIP), - "USERLEN" => { - Ok(Parameter::USERLEN(parse_required_positive_integer(value)?)) - } - "UTF8ONLY" => Ok(Parameter::UTF8ONLY), - "VLIST" => Ok(Parameter::VLIST(parse_required_non_empty_string(value)?)), - "WATCH" => Ok(Parameter::WATCH(parse_required_positive_integer(value)?)), - "WHOX" => Ok(Parameter::WHOX), + "TOPICLEN" => Ok(Operation::Add(Parameter::TOPICLEN( + parse_required_positive_integer(value)?, + ))), + "UHNAMES" => Ok(Operation::Add(Parameter::UHNAMES)), + "USERIP" => Ok(Operation::Add(Parameter::USERIP)), + "USERLEN" => Ok(Operation::Add(Parameter::USERLEN( + parse_required_positive_integer(value)?, + ))), + "UTF8ONLY" => Ok(Operation::Add(Parameter::UTF8ONLY)), + "VLIST" => Ok(Operation::Add(Parameter::VLIST( + parse_required_non_empty_string(value)?, + ))), + "WATCH" => Ok(Operation::Add(Parameter::WATCH( + parse_required_positive_integer(value)?, + ))), + "WHOX" => Ok(Operation::Add(Parameter::WHOX)), _ => Err("unknown ISUPPORT parameter"), } } else { - match isupport { + match token { "ACCEPT" => Err("value required"), "ACCOUNTEXTBAN" => Err("value(s) required"), - "AWAYLEN" => Ok(Parameter::AWAYLEN(None)), + "AWAYLEN" => Ok(Operation::Add(Parameter::AWAYLEN(None))), "BOT" => Err("value required"), - "CALLERID" => Ok(Parameter::CALLERID(default_caller_id_letter())), + "CALLERID" => Ok(Operation::Add(Parameter::CALLERID( + DEFAULT_CALLER_ID_LETTER, + ))), "CASEMAPPING" => Err("value required"), "CHANLIMIT" => Err("value(s) required"), "CHANMODES" => Err("value(s) required"), "CHANNELLEN" => Err("value required"), - "CHANTYPES" => Ok(Parameter::CHANTYPES(None)), + "CHANTYPES" => Ok(Operation::Add(Parameter::CHANTYPES(None))), "CHATHISTORY" => Err("value required"), "CLIENTTAGDENY" => Err("value(s) required"), "CLIENTVER" => Err("value required"), - "DEAF" => Ok(Parameter::DEAF(default_deaf_letter())), + "DEAF" => Ok(Operation::Add(Parameter::DEAF(DEFAULT_DEAF_LETTER))), "ELIST" => Err("value required"), - "ESILENCE" => Ok(Parameter::ESILENCE(None)), - "ETRACE" => Ok(Parameter::ETRACE), - "EXCEPTS" => Ok(Parameter::EXCEPTS(default_ban_exception_channel_letter())), + "ESILENCE" => Ok(Operation::Add(Parameter::ESILENCE(None))), + "ETRACE" => Ok(Operation::Add(Parameter::ETRACE)), + "EXCEPTS" => Ok(Operation::Add(Parameter::EXCEPTS( + DEFAULT_BAN_EXCEPTION_CHANNEL_LETTER, + ))), "EXTBAN" => Err("value required"), - "FNC" => Ok(Parameter::FNC), + "FNC" => Ok(Operation::Add(Parameter::FNC)), "HOSTLEN" => Err("value required"), - "INVEX" => Ok(Parameter::INVEX(default_invite_exception_letter())), + "INVEX" => Ok(Operation::Add(Parameter::INVEX( + DEFAULT_INVITE_EXCEPTION_LETTER, + ))), "KEYLEN" => Err("value required"), "KICKLEN" => Err("value required"), - "KNOCK" => Ok(Parameter::KNOCK), + "KNOCK" => Ok(Operation::Add(Parameter::KNOCK)), "LINELEN" => Err("value required"), - "MAP" => Ok(Parameter::MAP), + "MAP" => Ok(Operation::Add(Parameter::MAP)), "MAXBANS" => Err("value required"), "MAXCHANNELS" => Err("value required"), "MAXLIST" => Err("value(s) required"), "MAXPARA" => Err("value required"), - "MAXTARGETS" => Ok(Parameter::MAXTARGETS(None)), - "METADATA" => Ok(Parameter::METADATA(None)), - "MODES" => Ok(Parameter::MODES(None)), - "MONITOR" => Ok(Parameter::MONITOR(None)), - "MSGREFTYPES" => Ok(Parameter::MSGREFTYPES(vec![])), - "NAMESX" => Ok(Parameter::NAMESX), + "MAXTARGETS" => Ok(Operation::Add(Parameter::MAXTARGETS(None))), + "METADATA" => Ok(Operation::Add(Parameter::METADATA(None))), + "MODES" => Ok(Operation::Add(Parameter::MODES(None))), + "MONITOR" => Ok(Operation::Add(Parameter::MONITOR(None))), + "MSGREFTYPES" => Ok(Operation::Add(Parameter::MSGREFTYPES(vec![]))), + "NAMESX" => Ok(Operation::Add(Parameter::NAMESX)), "NETWORK" => Err("value required"), "NICKLEN" | "MAXNICKLEN" => Err("value required"), - "OVERRIDE" => Ok(Parameter::OVERRIDE), - "PREFIX" => Ok(Parameter::PREFIX(vec![])), - "SAFELIST" => Ok(Parameter::SAFELIST), - "SECURELIST" => Ok(Parameter::SECURELIST), - "SILENCE" => Ok(Parameter::SILENCE(None)), + "OVERRIDE" => Ok(Operation::Add(Parameter::OVERRIDE)), + "PREFIX" => Ok(Operation::Add(Parameter::PREFIX(vec![]))), + "SAFELIST" => Ok(Operation::Add(Parameter::SAFELIST)), + "SECURELIST" => Ok(Operation::Add(Parameter::SECURELIST)), + "SILENCE" => Ok(Operation::Add(Parameter::SILENCE(None))), "STATUSMSG" => Err("value required"), - "TARGMAX" => Ok(Parameter::TARGMAX(vec![])), + "TARGMAX" => Ok(Operation::Add(Parameter::TARGMAX(vec![]))), "TOPICLEN" => Err("value required"), - "UHNAMES" => Ok(Parameter::UHNAMES), - "USERIP" => Ok(Parameter::USERIP), + "UHNAMES" => Ok(Operation::Add(Parameter::UHNAMES)), + "USERIP" => Ok(Operation::Add(Parameter::USERIP)), "USERLEN" => Err("value required"), - "UTF8ONLY" => Ok(Parameter::UTF8ONLY), + "UTF8ONLY" => Ok(Operation::Add(Parameter::UTF8ONLY)), "VLIST" => Err("value required"), "WATCH" => Err("value required"), - "WHOX" => Ok(Parameter::WHOX), + "WHOX" => Ok(Operation::Add(Parameter::WHOX)), _ => Err("unknown ISUPPORT parameter"), } } @@ -432,6 +397,75 @@ impl<'a> TryFrom<&'a str> for Parameter { } } +// ISUPPORT Parameter References +// - https://defs.ircdocs.horse/defs/isupport.html +// - https://modern.ircdocs.horse/#rplisupport-005 +// - https://ircv3.net/specs/extensions/chathistory +// - https://ircv3.net/specs/extensions/monitor +// - https://ircv3.net/specs/extensions/utf8-only +// - https://ircv3.net/specs/extensions/whox +// - https://github.com/ircv3/ircv3-specifications/pull/464/files +#[allow(non_camel_case_types)] +#[derive(Debug)] +pub enum Parameter { + ACCEPT(u16), + ACCOUNTEXTBAN(Vec), + AWAYLEN(Option), + BOT(char), + CALLERID(char), + CASEMAPPING(CaseMap), + CHANLIMIT(Vec), + CHANMODES(Vec), + CHANNELLEN(u16), + CHANTYPES(Option), + CHATHISTORY(u16), + CLIENTTAGDENY(Vec), + CLIENTVER(u16, u16), + CNOTICE, + CPRIVMSG, + DEAF(char), + ELIST(String), + ESILENCE(Option), + ETRACE, + EXCEPTS(char), + EXTBAN(Option, String), + FNC, + HOSTLEN(u16), + INVEX(char), + KEYLEN(u16), + KICKLEN(u16), + KNOCK, + LINELEN(u16), + MAP, + MAXBANS(u16), + MAXCHANNELS(u16), + MAXLIST(Vec), + MAXPARA(u16), + MAXTARGETS(Option), + METADATA(Option), + MODES(Option), + MONITOR(Option), + MSGREFTYPES(Vec), + NAMESX, + NETWORK(String), + NICKLEN(u16), + OVERRIDE, + PREFIX(Vec), + SAFELIST, + SECURELIST, + SILENCE(Option), + STATUSMSG(String), + TARGMAX(Vec), + TOPICLEN(u16), + UHNAMES, + USERIP, + USERLEN(u16), + UTF8ONLY, + VLIST(String), + WATCH(u16), + WHOX, +} + impl Parameter { pub fn key(&self) -> &str { match self { @@ -491,7 +525,6 @@ impl Parameter { Parameter::VLIST(_) => "VLIST", Parameter::WATCH(_) => "WATCH", Parameter::WHOX => "WHOX", - Parameter::Negation(key) => key.as_ref(), } } } @@ -508,7 +541,7 @@ pub enum CaseMap { #[allow(dead_code)] #[derive(Debug)] pub struct ChannelLimit { - prefix: String, + prefix: char, limit: Option, } @@ -553,22 +586,55 @@ pub struct PrefixMap { mode: char, } -pub fn default_ban_exception_channel_letter() -> char { - 'e' +const DEFAULT_BAN_EXCEPTION_CHANNEL_LETTER: char = 'e'; + +const DEFAULT_CALLER_ID_LETTER: char = 'g'; + +const DEFAULT_DEAF_LETTER: char = 'D'; + +const DEFAULT_INVITE_EXCEPTION_LETTER: char = 'I'; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WhoToken { + digits: [char; 3], } -pub fn default_caller_id_letter() -> char { - 'g' +impl WhoToken { + pub fn to_owned(self) -> String { + self.digits.iter().filter(|c| **c != '\0').collect() + } } -pub fn default_deaf_letter() -> char { - 'D' +impl TryFrom for WhoToken { + type Error = &'static str; + + fn try_from(token: String) -> Result { + Self::try_from(token.as_str()) + } } -pub fn default_invite_exception_letter() -> char { - 'I' +impl<'a> TryFrom<&'a str> for WhoToken { + type Error = &'static str; + + fn try_from(token: &'a str) -> Result { + if (1usize..=3usize).contains(&token.chars().count()) + && token.chars().all(|c| c.is_ascii_digit()) + { + let mut digits = ['\0', '\0', '\0']; + + token.chars().enumerate().for_each(|(i, c)| digits[i] = c); + + Ok(WhoToken { digits }) + } else { + Err("WHO token must be 1-3 ASCII digits") + } + } } +pub const WHO_POLL_TOKEN: WhoToken = WhoToken { + digits: ['9', '\0', '\0'], +}; + fn parse_optional_positive_integer(value: &str) -> Result, &'static str> { if value.is_empty() { Ok(None) @@ -589,12 +655,14 @@ fn parse_optional_string(value: &str) -> Option { fn parse_required_letter(value: &str, default_value: Option) -> Result { if let Some(value) = value.chars().next() { - Ok(value) + if value.is_ascii_alphabetic() { + return Ok(value); + } } else if let Some(default_value) = default_value { - Ok(default_value) - } else { - Err("value required to be a letter") + return Ok(default_value); } + + Err("value required to be a letter") } fn parse_required_non_empty_string(value: &str) -> Result { diff --git a/irc/proto/src/command.rs b/irc/proto/src/command.rs index 55e038f33..2aa83f26e 100644 --- a/irc/proto/src/command.rs +++ b/irc/proto/src/command.rs @@ -243,8 +243,8 @@ impl Command { Command::USERHOST(params) => params, Command::WALLOPS(a) => vec![a], Command::BATCH(a, rest) => std::iter::once(a).chain(rest).collect(), - Command::CNOTICE(a, b, c) => vec![a, b, format!(":{}", c)], - Command::CPRIVMSG(a, b, c) => vec![a, b, format!(":{}", c)], + Command::CNOTICE(a, b, c) => vec![a, b, c], + Command::CPRIVMSG(a, b, c) => vec![a, b, c], Command::KNOCK(a, b) => std::iter::once(a).chain(b).collect(), Command::USERIP(a) => vec![a], Command::Numeric(_, params) => params, From 387e782f78b169c9d745bce911cccaec9755ce77 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Tue, 23 Apr 2024 19:33:40 -0700 Subject: [PATCH 09/23] `String` to `isupport::Kind` conversion for `HashMap` key. --- data/src/client.rs | 42 +++++++++++--------- data/src/isupport.rs | 95 ++++++++++++++++++-------------------------- 2 files changed, 61 insertions(+), 76 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index 3f7181e10..8f9c98b2a 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -88,7 +88,7 @@ pub struct Client { supports_away_notify: bool, highlight_blackout: HighlightBlackout, registration_required_channels: Vec, - isupport_parameters: HashMap, + isupport_parameters: HashMap, } impl fmt::Debug for Client { @@ -659,7 +659,7 @@ impl Client { if let Some(state) = self.chanmap.get_mut(channel) { // Sends WHO to get away state on users. - if self.isupport_parameters.get("WHOX").is_some() { + if self.isupport_parameters.get(&isupport::Kind::WHOX).is_some() { let _ = self.handle.try_send(command!( "WHO", channel, @@ -896,23 +896,27 @@ impl Client { Ok(isupport_operation) => { match isupport_operation { isupport::Operation::Add(isupport_parameter) => { - log::info!( - "[{}] adding ISUPPORT parameter: {:?}", - self.server, - isupport_parameter - ); - self.isupport_parameters.insert( - isupport_parameter.key().to_string(), - isupport_parameter, - ) + if let Some(kind) = isupport_parameter.kind() { + log::info!( + "[{}] adding ISUPPORT parameter: {:?}", + self.server, + isupport_parameter + ); + self.isupport_parameters.insert( + kind, + isupport_parameter, + ); + } } - isupport::Operation::Remove(key) => { - log::info!( - "[{}] removing ISUPPORT parameter: {}", - self.server, - key - ); - self.isupport_parameters.remove(&key) + isupport::Operation::Remove(_) => { + if let Some(kind) = isupport_operation.kind() { + log::info!( + "[{}] removing ISUPPORT parameter: {:?}", + self.server, + kind + ); + self.isupport_parameters.remove(&kind); + } } }; } @@ -1021,7 +1025,7 @@ impl Client { }; if let Some(request) = request { - if self.isupport_parameters.get("WHOX").is_some() { + if self.isupport_parameters.get(&isupport::Kind::WHOX).is_some() { let _ = self.handle.try_send(command!( "WHO", channel, diff --git a/data/src/isupport.rs b/data/src/isupport.rs index ad41c915e..6202ddf6e 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -1,5 +1,18 @@ use std::str::FromStr; +// Utilized ISUPPORT parameters should have an associated Kind enum variant +// returned by Operation::kind() and Parameter::kind() +#[allow(non_camel_case_types)] +#[derive(Debug, Eq, Hash, PartialEq)] +pub enum Kind { + CNOTICE, + CPRIVMSG, + KNOCK, + SAFELIST, + USERIP, + WHOX, +} + #[derive(Debug)] pub enum Operation { Add(Parameter), @@ -397,6 +410,23 @@ impl FromStr for Operation { } } +impl Operation { + pub fn kind(&self) -> Option { + match self { + Operation::Add(parameter) => parameter.kind(), + Operation::Remove(parameter) => match parameter.as_ref() { + "CNOTICE" => Some(Kind::CNOTICE), + "CPRIVMSG" => Some(Kind::CPRIVMSG), + "KNOCK" => Some(Kind::KNOCK), + "SAFELIST" => Some(Kind::SAFELIST), + "USERIP" => Some(Kind::USERIP), + "WHOX" => Some(Kind::WHOX), + _ => None, + } + } + } +} + // ISUPPORT Parameter References // - https://defs.ircdocs.horse/defs/isupport.html // - https://modern.ircdocs.horse/#rplisupport-005 @@ -467,64 +497,15 @@ pub enum Parameter { } impl Parameter { - pub fn key(&self) -> &str { + pub fn kind(&self) -> Option { match self { - Parameter::ACCEPT(_) => "ACCEPT", - Parameter::ACCOUNTEXTBAN(_) => "ACCOUNTEXTBAN", - Parameter::AWAYLEN(_) => "AWAYLEN", - Parameter::BOT(_) => "BOT", - Parameter::CALLERID(_) => "CALLERID", - Parameter::CASEMAPPING(_) => "CASEMAPPING", - Parameter::CHANLIMIT(_) => "CHANLIMIT", - Parameter::CHANMODES(_) => "CHANMODES", - Parameter::CHANNELLEN(_) => "CHANNELLEN", - Parameter::CHANTYPES(_) => "CHANTYPES", - Parameter::CHATHISTORY(_) => "CHATHISTORY", - Parameter::CLIENTTAGDENY(_) => "CLIENTTAGDENY", - Parameter::CLIENTVER(_, _) => "CLIENTVER", - Parameter::CNOTICE => "CNOTICE", - Parameter::CPRIVMSG => "CPRIVMSG", - Parameter::DEAF(_) => "DEAF", - Parameter::ELIST(_) => "ELIST", - Parameter::ESILENCE(_) => "ESILENCE", - Parameter::ETRACE => "ETRACE", - Parameter::EXCEPTS(_) => "EXCEPTS", - Parameter::EXTBAN(_, _) => "EXTBAN", - Parameter::FNC => "FNC", - Parameter::HOSTLEN(_) => "HOSTLEN", - Parameter::INVEX(_) => "INVEX", - Parameter::KEYLEN(_) => "KEYLEN", - Parameter::KICKLEN(_) => "KICKLEN", - Parameter::KNOCK => "KNOCK", - Parameter::LINELEN(_) => "LINELEN", - Parameter::MAP => "MAP", - Parameter::MAXBANS(_) => "MAXBANS", - Parameter::MAXCHANNELS(_) => "MAXCHANNELS", - Parameter::MAXLIST(_) => "MAXLIST", - Parameter::MAXPARA(_) => "MAXPARA", - Parameter::MAXTARGETS(_) => "MAXTARGETS", - Parameter::METADATA(_) => "METADATA", - Parameter::MODES(_) => "MODES", - Parameter::MONITOR(_) => "MONITOR", - Parameter::MSGREFTYPES(_) => "MSGREFTYPES", - Parameter::NAMESX => "NAMESX", - Parameter::NETWORK(_) => "NETWORK", - Parameter::NICKLEN(_) => "NICKLEN", - Parameter::OVERRIDE => "OVERRIDE", - Parameter::PREFIX(_) => "PREFIX", - Parameter::SAFELIST => "SAFELIST", - Parameter::SECURELIST => "SECURELIST", - Parameter::SILENCE(_) => "SILENCE", - Parameter::STATUSMSG(_) => "STATUSMSG", - Parameter::TARGMAX(_) => "TARGMAX", - Parameter::TOPICLEN(_) => "TOPICLEN", - Parameter::UHNAMES => "UHNAMES", - Parameter::USERIP => "USERIP", - Parameter::USERLEN(_) => "USERLEN", - Parameter::UTF8ONLY => "UTF8ONLY", - Parameter::VLIST(_) => "VLIST", - Parameter::WATCH(_) => "WATCH", - Parameter::WHOX => "WHOX", + Parameter::CNOTICE => Some(Kind::CNOTICE), + Parameter::CPRIVMSG => Some(Kind::CPRIVMSG), + Parameter::KNOCK => Some(Kind::KNOCK), + Parameter::SAFELIST => Some(Kind::SAFELIST), + Parameter::USERIP => Some(Kind::USERIP), + Parameter::WHOX => Some(Kind::WHOX), + _ => None, } } } From 128bb4aa6e6fc95b4ea299217a027cc69336aae7 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Tue, 23 Apr 2024 19:58:32 -0700 Subject: [PATCH 10/23] Neglected `rustfmt`. --- data/src/client.rs | 27 +++++++++++++++++++-------- data/src/isupport.rs | 2 +- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index 8f9c98b2a..0131b117c 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -659,7 +659,11 @@ impl Client { if let Some(state) = self.chanmap.get_mut(channel) { // Sends WHO to get away state on users. - if self.isupport_parameters.get(&isupport::Kind::WHOX).is_some() { + if self + .isupport_parameters + .get(&isupport::Kind::WHOX) + .is_some() + { let _ = self.handle.try_send(command!( "WHO", channel, @@ -699,7 +703,10 @@ impl Client { log::debug!("[{}] {target} - WHO receiving...", self.server); } - channel.update_user_away(User::from(Nick::from(args[5].clone())), args.get(6)?.chars().collect::>()); + channel.update_user_away( + User::from(Nick::from(args[5].clone())), + args.get(6)?.chars().collect::>(), + ); // We requested, don't save to history if matches!(channel.last_who, Some(WhoStatus::Receiving(None))) { @@ -731,7 +738,10 @@ impl Client { if request_token == token { let flags = args.get(4)?.chars().collect::>(); - if flags.iter().all(|c| matches!(c, 'H' | 'G' | '&' | '@' | '%' | '+')) { + if flags + .iter() + .all(|c| matches!(c, 'H' | 'G' | '&' | '@' | '%' | '+')) + { if let Ok(user) = User::try_from(args.get(3)?.clone()) { channel.update_user_away(user, flags); } @@ -902,10 +912,7 @@ impl Client { self.server, isupport_parameter ); - self.isupport_parameters.insert( - kind, - isupport_parameter, - ); + self.isupport_parameters.insert(kind, isupport_parameter); } } isupport::Operation::Remove(_) => { @@ -1025,7 +1032,11 @@ impl Client { }; if let Some(request) = request { - if self.isupport_parameters.get(&isupport::Kind::WHOX).is_some() { + if self + .isupport_parameters + .get(&isupport::Kind::WHOX) + .is_some() + { let _ = self.handle.try_send(command!( "WHO", channel, diff --git a/data/src/isupport.rs b/data/src/isupport.rs index 6202ddf6e..f54247a81 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -422,7 +422,7 @@ impl Operation { "USERIP" => Some(Kind::USERIP), "WHOX" => Some(Kind::WHOX), _ => None, - } + }, } } } From 15a964f14d88027d605b2831a1130446e300206e Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Wed, 24 Apr 2024 01:56:33 -0700 Subject: [PATCH 11/23] Proof of concept for command list tooltips. --- data/src/isupport.rs | 23 +++--- src/theme/text.rs | 6 ++ src/widget/input/completion.rs | 146 +++++++++++++++++++++++++++------ 3 files changed, 139 insertions(+), 36 deletions(-) diff --git a/data/src/isupport.rs b/data/src/isupport.rs index f54247a81..e5b0802f6 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -3,11 +3,12 @@ use std::str::FromStr; // Utilized ISUPPORT parameters should have an associated Kind enum variant // returned by Operation::kind() and Parameter::kind() #[allow(non_camel_case_types)] -#[derive(Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum Kind { CNOTICE, CPRIVMSG, KNOCK, + NICKLEN, SAFELIST, USERIP, WHOX, @@ -418,6 +419,7 @@ impl Operation { "CNOTICE" => Some(Kind::CNOTICE), "CPRIVMSG" => Some(Kind::CPRIVMSG), "KNOCK" => Some(Kind::KNOCK), + "NICKLEN" => Some(Kind::NICKLEN), "SAFELIST" => Some(Kind::SAFELIST), "USERIP" => Some(Kind::USERIP), "WHOX" => Some(Kind::WHOX), @@ -436,7 +438,7 @@ impl Operation { // - https://ircv3.net/specs/extensions/whox // - https://github.com/ircv3/ircv3-specifications/pull/464/files #[allow(non_camel_case_types)] -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum Parameter { ACCEPT(u16), ACCOUNTEXTBAN(Vec), @@ -502,6 +504,7 @@ impl Parameter { Parameter::CNOTICE => Some(Kind::CNOTICE), Parameter::CPRIVMSG => Some(Kind::CPRIVMSG), Parameter::KNOCK => Some(Kind::KNOCK), + Parameter::NICKLEN(_) => Some(Kind::NICKLEN), Parameter::SAFELIST => Some(Kind::SAFELIST), Parameter::USERIP => Some(Kind::USERIP), Parameter::WHOX => Some(Kind::WHOX), @@ -511,7 +514,7 @@ impl Parameter { } #[allow(non_camel_case_types)] -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum CaseMap { ASCII, RFC1459, @@ -520,20 +523,20 @@ pub enum CaseMap { } #[allow(dead_code)] -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ChannelLimit { prefix: char, limit: Option, } #[allow(dead_code)] -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ChannelMode { letter: char, modes: String, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum ClientOnlyTags { Allowed(String), Denied(String), @@ -541,27 +544,27 @@ pub enum ClientOnlyTags { } #[allow(dead_code)] -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct CommandTargetLimit { command: String, limit: Option, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum MessageReferenceType { Timestamp, MessageID, } #[allow(dead_code)] -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ModesLimit { modes: String, limit: u16, } #[allow(dead_code)] -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct PrefixMap { prefix: char, mode: char, diff --git a/src/theme/text.rs b/src/theme/text.rs index 710ee0167..998fb3a03 100644 --- a/src/theme/text.rs +++ b/src/theme/text.rs @@ -61,6 +61,12 @@ pub fn transparent(theme: &Theme) -> Style { } } +pub fn transparent_accent(theme: &Theme) -> Style { + Style { + color: Some(theme.colors().accent.low_alpha), + } +} + pub fn nickname(theme: &Theme, seed: Option, transparent: bool) -> Style { let dark_theme = theme.colors().is_dark_theme(); diff --git a/src/widget/input/completion.rs b/src/widget/input/completion.rs index 43515adfc..f5b7496ab 100644 --- a/src/widget/input/completion.rs +++ b/src/widget/input/completion.rs @@ -2,7 +2,7 @@ use std::fmt; use data::isupport; use data::user::User; -use iced::widget::{column, container, row, text}; +use iced::widget::{column, container, row, text, tooltip}; use iced::Length; use once_cell::sync::Lazy; @@ -119,24 +119,29 @@ impl Commands { (rest, false) }; - let command_list = - COMMAND_LIST - .iter() - .map(|command| { - if command.title == "WHO" - && isupport_parameters.iter().any(|isupport_parameter| { - matches!(isupport_parameter, isupport::Parameter::WHOX) - }) + let command_list = COMMAND_LIST + .iter() + .map(|command| { + if command.title == "WHO" + && find_isupport_parameter(isupport_parameters, isupport::Kind::WHOX).is_some() + { + return WHOX_COMMAND.clone(); + } else if command.title == "NICK" { + if let Some(isupport::Parameter::NICKLEN(nick_len)) = + find_isupport_parameter(isupport_parameters, isupport::Kind::NICKLEN) { - &WHOX_COMMAND - } else { - command + return nick_command(nick_len); } - }) - .chain(isupport_parameters.iter().filter_map(|isupport_parameter| { + } + + command.clone() + }) + .chain( + isupport_parameters.iter().filter_map(|isupport_parameter| { isupport_parameter_to_command(isupport_parameter) - })) - .collect::>(); + }), + ) + .collect::>(); match self { // Command not fully typed, show filtered entries @@ -149,7 +154,6 @@ impl Commands { .to_lowercase() .starts_with(&cmd.to_lowercase()) }) - .cloned() .collect(); *self = Self::Selecting { @@ -162,7 +166,6 @@ impl Commands { if let Some(command) = command_list .into_iter() .find(|command| command.title.to_lowercase() == cmd.to_lowercase()) - .cloned() { *self = Self::Selected { command }; } else { @@ -290,13 +293,31 @@ impl Command { let title = Some(Element::from(text(self.title))); let args = self.args.iter().enumerate().map(|(index, arg)| { - Element::from(text(format!(" {arg}")).style(move |theme| { + let content = text(format!(" {arg}")).style(move |theme| { if index == active_arg { theme::text::accent(theme) } else { theme::text::none(theme) } - })) + }); + + if let Some(arg_tooltip) = &arg.tooltip { + Element::from(tooltip( + content, + container(text(arg_tooltip.clone()).style(move |theme| { + if index == active_arg { + theme::text::transparent_accent(theme) + } else { + theme::text::transparent(theme) + } + })) + .style(theme::container::context) + .padding(8), + tooltip::Position::Top, + )) + } else { + Element::from(content) + } }); container(row(title.into_iter().chain(args))) @@ -311,6 +332,7 @@ impl Command { struct Arg { text: &'static str, optional: bool, + tooltip: Option, } impl fmt::Display for Arg { @@ -411,10 +433,12 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "channels", optional: false, + tooltip: None, }, Arg { text: "keys", optional: true, + tooltip: None, }, ], }, @@ -423,6 +447,7 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { args: vec![Arg { text: "server", optional: true, + tooltip: None, }], }, Command { @@ -430,6 +455,7 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { args: vec![Arg { text: "nickname", optional: false, + tooltip: None, }], }, Command { @@ -437,6 +463,7 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { args: vec![Arg { text: "reason", optional: true, + tooltip: None, }], }, Command { @@ -445,10 +472,12 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "target", optional: false, + tooltip: None, }, Arg { text: "text", optional: false, + tooltip: None, }, ], }, @@ -457,6 +486,7 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { args: vec![Arg { text: "nick", optional: false, + tooltip: None, }], }, Command { @@ -464,6 +494,7 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { args: vec![Arg { text: "action", optional: false, + tooltip: None, }], }, Command { @@ -472,14 +503,17 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "channel", optional: false, + tooltip: None, }, Arg { text: "mode", optional: false, + tooltip: None, }, Arg { text: "user", optional: true, + tooltip: None, }, ], }, @@ -489,10 +523,12 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "channels", optional: false, + tooltip: None, }, Arg { text: "reason", optional: true, + tooltip: None, }, ], }, @@ -502,10 +538,12 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "channel", optional: false, + tooltip: None, }, Arg { text: "topic", optional: true, + tooltip: None, }, ], }, @@ -514,6 +552,7 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { args: vec![Arg { text: "target", optional: false, + tooltip: None, }], }, Command { @@ -522,14 +561,17 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "channel", optional: false, + tooltip: None, }, Arg { text: "user", optional: false, + tooltip: None, }, Arg { text: "comment", optional: true, + tooltip: None, }, ], }, @@ -539,23 +581,36 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "command", optional: false, + tooltip: None, }, Arg { text: "args", optional: true, + tooltip: None, }, ], }, ] }); -fn isupport_parameter_to_command(isupport_parameter: &isupport::Parameter) -> Option<&Command> { +fn find_isupport_parameter( + isupport_parameters: &[&isupport::Parameter], + kind: isupport::Kind, +) -> Option { + isupport_parameters + .iter() + .find(|isupport_parameter| isupport_parameter.kind() == Some(kind.clone())) + .cloned() + .cloned() +} + +fn isupport_parameter_to_command(isupport_parameter: &isupport::Parameter) -> Option { match isupport_parameter { - isupport::Parameter::KNOCK => Some(&KNOCK_COMMAND), - isupport::Parameter::USERIP => Some(&USERIP_COMMAND), - isupport::Parameter::CNOTICE => Some(&CNOTICE_COMMAND), - isupport::Parameter::CPRIVMSG => Some(&CPRIVMSG_COMMAND), - isupport::Parameter::SAFELIST => Some(&LIST_COMMAND), + isupport::Parameter::KNOCK => Some(KNOCK_COMMAND.clone()), + isupport::Parameter::USERIP => Some(USERIP_COMMAND.clone()), + isupport::Parameter::CNOTICE => Some(CNOTICE_COMMAND.clone()), + isupport::Parameter::CPRIVMSG => Some(CPRIVMSG_COMMAND.clone()), + isupport::Parameter::SAFELIST => Some(LIST_COMMAND.clone()), _ => None, } } @@ -566,14 +621,17 @@ static CNOTICE_COMMAND: Lazy = Lazy::new(|| Command { Arg { text: "nickname", optional: false, + tooltip: None, }, Arg { text: "channel", optional: false, + tooltip: None, }, Arg { text: "message", optional: false, + tooltip: None, }, ], }); @@ -584,14 +642,17 @@ static CPRIVMSG_COMMAND: Lazy = Lazy::new(|| Command { Arg { text: "nickname", optional: false, + tooltip: None, }, Arg { text: "channel", optional: false, + tooltip: None, }, Arg { text: "message", optional: false, + tooltip: None, }, ], }); @@ -602,10 +663,12 @@ static KNOCK_COMMAND: Lazy = Lazy::new(|| Command { Arg { text: "channel", optional: false, + tooltip: None, }, Arg { text: "message", optional: true, + tooltip: None, }, ], }); @@ -616,19 +679,33 @@ static LIST_COMMAND: Lazy = Lazy::new(|| Command { Arg { text: "channels", optional: true, + tooltip: None, }, Arg { text: "server", optional: true, + tooltip: None, }, ], }); +fn nick_command(max_len: u16) -> Command { + Command { + title: "NICK", + args: vec![Arg { + text: "nickname", + optional: false, + tooltip: Some(format!("maximum length: {}", max_len)), + }], + } +} + static USERIP_COMMAND: Lazy = Lazy::new(|| Command { title: "USERIP", args: vec![Arg { text: "nickname", optional: false, + tooltip: None, }], }); @@ -638,14 +715,31 @@ static WHOX_COMMAND: Lazy = Lazy::new(|| Command { Arg { text: "target", optional: false, + tooltip: None, }, Arg { text: "fields", optional: true, + tooltip: Some(String::from( + "t: token\n\ + c: channel\n\ + u: username\n\ + i: IP address\n\ + h: hostname\n\ + s: server name\n\ + n: nickname\n\ + f: WHO flags\n\ + d: hop count\n\ + l: idle seconds\n\ + a: account name\n\ + o: channel op level\n\ + r: realname", + )), }, Arg { text: "token", optional: true, + tooltip: Some(String::from("1-3 digits")), }, ], }); From 5ac50811451eec395feb976e52e67b30db9ea31a Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Wed, 24 Apr 2024 02:36:25 -0700 Subject: [PATCH 12/23] Utilize `FromStr` for `isupport::WhoToken`. --- data/src/client.rs | 2 +- data/src/isupport.rs | 14 +++----------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index 0131b117c..7f7da110d 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -720,7 +720,7 @@ impl Client { if proto::is_channel(target) { if let Some(channel) = self.chanmap.get_mut(target) { - if let Ok(token) = isupport::WhoToken::try_from(args.get(1)?.clone()) { + if let Ok(token) = args.get(1)?.parse::() { if let Some(WhoStatus::Requested(_, Some(request_token))) = channel.last_who { diff --git a/data/src/isupport.rs b/data/src/isupport.rs index e5b0802f6..a0ae288f4 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -589,18 +589,10 @@ impl WhoToken { } } -impl TryFrom for WhoToken { - type Error = &'static str; - - fn try_from(token: String) -> Result { - Self::try_from(token.as_str()) - } -} - -impl<'a> TryFrom<&'a str> for WhoToken { - type Error = &'static str; +impl FromStr for WhoToken { + type Err = &'static str; - fn try_from(token: &'a str) -> Result { + fn from_str(token: &str) -> Result { if (1usize..=3usize).contains(&token.chars().count()) && token.chars().all(|c| c.is_ascii_digit()) { From e47a8446d3dfb1b5116650b32794d53ef5f868f9 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Wed, 24 Apr 2024 14:20:58 -0700 Subject: [PATCH 13/23] Missed review fix. --- data/src/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/src/client.rs b/data/src/client.rs index 7f7da110d..e1c730ec7 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -704,7 +704,7 @@ impl Client { } channel.update_user_away( - User::from(Nick::from(args[5].clone())), + User::from(Nick::from(args.get(5)?.clone())), args.get(6)?.chars().collect::>(), ); From 0b70bb98471b4d434e68b1bcf9e45086a7c6a3d8 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Wed, 24 Apr 2024 18:49:35 -0700 Subject: [PATCH 14/23] =?UTF-8?q?`ELIST`-style=20`LIST`=20command=20comple?= =?UTF-8?q?tion=20support.=20Initial=20batch=20of=20tooltips=20for=20comma?= =?UTF-8?q?nd=20list.=20Potential=20tooltip=20indicator=20added=20to=20com?= =?UTF-8?q?mand=20list.=20Other=20fixes=20suggested=20by=20reviews.=20More?= =?UTF-8?q?=20stringent=20parsing=20for=20`ISUPPORT`=20parameters.=20Less?= =?UTF-8?q?=20stringent=20parsing=20for=20`WHOX`=20poll=20returns=20(upon?= =?UTF-8?q?=20further=20review,=20flags=20is=20unconstrained=20in=20what?= =?UTF-8?q?=20it=20may=20contain=20=E2=86=92=20only=20testing=20for=20an?= =?UTF-8?q?=20away=20flag=20as=20the=20first=20character).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/src/client.rs | 45 +++---- data/src/isupport.rs | 176 ++++++++++++++++-------- irc/proto/src/lib.rs | 9 +- src/buffer/channel.rs | 2 +- src/buffer/input_view.rs | 4 +- src/buffer/query.rs | 2 +- src/buffer/server.rs | 2 +- src/widget/input.rs | 26 ++-- src/widget/input/completion.rs | 239 ++++++++++++++++++++++++++------- 9 files changed, 354 insertions(+), 151 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index e1c730ec7..c549b196b 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -88,7 +88,7 @@ pub struct Client { supports_away_notify: bool, highlight_blackout: HighlightBlackout, registration_required_channels: Vec, - isupport_parameters: HashMap, + isupport: HashMap, } impl fmt::Debug for Client { @@ -138,7 +138,7 @@ impl Client { supports_away_notify: false, highlight_blackout: HighlightBlackout::Blackout(Instant::now()), registration_required_channels: vec![], - isupport_parameters: HashMap::new(), + isupport: HashMap::new(), } } @@ -659,11 +659,7 @@ impl Client { if let Some(state) = self.chanmap.get_mut(channel) { // Sends WHO to get away state on users. - if self - .isupport_parameters - .get(&isupport::Kind::WHOX) - .is_some() - { + if self.isupport.get(&isupport::Kind::WHOX).is_some() { let _ = self.handle.try_send(command!( "WHO", channel, @@ -738,10 +734,7 @@ impl Client { if request_token == token { let flags = args.get(4)?.chars().collect::>(); - if flags - .iter() - .all(|c| matches!(c, 'H' | 'G' | '&' | '@' | '%' | '+')) - { + if flags.first().map_or(false, |c| matches!(c, 'H' | 'G')) { if let Ok(user) = User::try_from(args.get(3)?.clone()) { channel.update_user_away(user, flags); } @@ -900,29 +893,29 @@ impl Client { Command::Numeric(RPL_ISUPPORT, args) => { let args_len = args.len(); args.iter().enumerate().skip(1).for_each(|(index, arg)| { - let isupport_operation = arg.parse::(); + let operation = arg.parse::(); - match isupport_operation { - Ok(isupport_operation) => { - match isupport_operation { - isupport::Operation::Add(isupport_parameter) => { - if let Some(kind) = isupport_parameter.kind() { + match operation { + Ok(operation) => { + match operation { + isupport::Operation::Add(parameter) => { + if let Some(kind) = parameter.kind() { log::info!( "[{}] adding ISUPPORT parameter: {:?}", self.server, - isupport_parameter + parameter ); - self.isupport_parameters.insert(kind, isupport_parameter); + self.isupport.insert(kind, parameter); } } isupport::Operation::Remove(_) => { - if let Some(kind) = isupport_operation.kind() { + if let Some(kind) = operation.kind() { log::info!( "[{}] removing ISUPPORT parameter: {:?}", self.server, kind ); - self.isupport_parameters.remove(&kind); + self.isupport.remove(&kind); } } }; @@ -1032,11 +1025,7 @@ impl Client { }; if let Some(request) = request { - if self - .isupport_parameters - .get(&isupport::Kind::WHOX) - .is_some() - { + if self.isupport.get(&isupport::Kind::WHOX).is_some() { let _ = self.handle.try_send(command!( "WHO", channel, @@ -1174,9 +1163,9 @@ impl Map { .unwrap_or_default() } - pub fn get_isupport_parameters<'a>(&'a self, server: &Server) -> Vec<&'a isupport::Parameter> { + pub fn get_isupport<'a>(&'a self, server: &Server) -> Vec<&'a isupport::Parameter> { self.client(server) - .map(|client| client.isupport_parameters.values().collect::>()) + .map(|client| client.isupport.values().collect::>()) .unwrap_or_default() } diff --git a/data/src/isupport.rs b/data/src/isupport.rs index a0ae288f4..d265cf944 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -1,3 +1,4 @@ +use irc::proto; use std::str::FromStr; // Utilized ISUPPORT parameters should have an associated Kind enum variant @@ -5,11 +6,17 @@ use std::str::FromStr; #[allow(non_camel_case_types)] #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum Kind { + AWAYLEN, + CHANNELLEN, CNOTICE, CPRIVMSG, + ELIST, + KEYLEN, + KICKLEN, KNOCK, NICKLEN, SAFELIST, + TOPICLEN, USERIP, WHOX, } @@ -77,17 +84,21 @@ impl FromStr for Operation { if let Some((prefix, limit)) = channel_limit.split_once(':') { if limit.is_empty() { prefix.chars().for_each(|c| { - channel_limits.push(ChannelLimit { - prefix: c, - limit: None, - }) + if proto::CHANNEL_PREFIXES.contains(&c) { + channel_limits.push(ChannelLimit { + prefix: c, + limit: None, + }); + } }); } else if let Ok(limit) = limit.parse::() { prefix.chars().for_each(|c| { - channel_limits.push(ChannelLimit { - prefix: c, - limit: Some(limit), - }) + if proto::CHANNEL_PREFIXES.contains(&c) { + channel_limits.push(ChannelLimit { + prefix: c, + limit: Some(limit), + }); + } }); } } @@ -105,10 +116,12 @@ impl FromStr for Operation { ('A'..='Z') .zip(value.split(',')) .for_each(|(letter, modes)| { - channel_modes.push(ChannelMode { - letter, - modes: String::from(modes), - }) + if modes.chars().all(|c| c.is_ascii_alphabetic()) { + channel_modes.push(ChannelMode { + letter, + modes: modes.to_string(), + }); + } }); if !channel_modes.is_empty() { @@ -120,9 +133,17 @@ impl FromStr for Operation { "CHANNELLEN" => Ok(Operation::Add(Parameter::CHANNELLEN( parse_required_positive_integer(value)?, ))), - "CHANTYPES" => Ok(Operation::Add(Parameter::CHANTYPES( - parse_optional_string(value), - ))), + "CHANTYPES" => { + if value.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 { + Err("value must only contain channel types if specified") + } + } "CHATHISTORY" => Ok(Operation::Add(Parameter::CHATHISTORY( parse_required_positive_integer(value)?, ))), @@ -170,11 +191,21 @@ impl FromStr for Operation { value, Some(DEFAULT_DEAF_LETTER), )?))), - "ELIST" => Ok(Operation::Add(Parameter::ELIST( - parse_required_non_empty_string(value)?, - ))), + "ELIST" => { + if !value.is_empty() { + let value = value.to_uppercase(); + + if value.chars().all(|c| "CMNTU".contains(c)) { + Ok(Operation::Add(Parameter::ELIST(value.to_string()))) + } else { + Err("value required to only contain valid search extensions") + } + } else { + Err("value required") + } + } "ESILENCE" => Ok(Operation::Add(Parameter::ESILENCE( - parse_optional_string(value), + parse_optional_letters(value)?, ))), "ETRACE" => Ok(Operation::Add(Parameter::ETRACE)), "EXCEPTS" => Ok(Operation::Add(Parameter::EXCEPTS(parse_required_letter( @@ -183,13 +214,22 @@ impl FromStr for Operation { )?))), "EXTBAN" => { if let Some((prefix, types)) = value.split_once(',') { - if prefix.is_empty() { - Ok(Operation::Add(Parameter::EXTBAN(None, types.to_string()))) + if types.chars().all(|c| c.is_ascii_alphabetic()) { + if prefix.is_empty() { + Ok(Operation::Add(Parameter::EXTBAN( + None, + types.to_string(), + ))) + } else if prefix.chars().all(|c| c.is_ascii()) { + Ok(Operation::Add(Parameter::EXTBAN( + prefix.chars().next(), + types.to_string(), + ))) + } else { + Err("invalid extended ban prefix(es)") + } } else { - Ok(Operation::Add(Parameter::EXTBAN( - prefix.chars().next(), - types.to_string(), - ))) + Err("invalid extended ban type(s)") } } else { Err("no valid extended ban masks") @@ -225,11 +265,15 @@ impl FromStr for Operation { value.split(',').for_each(|modes_limit| { if let Some((modes, limit)) = modes_limit.split_once(':') { - if let Ok(limit) = limit.parse::() { - modes_limits.push(ModesLimit { - modes: modes.to_string(), - limit, - }); + if !modes.is_empty() + && modes.chars().all(|c| c.is_ascii_alphabetic()) + { + if let Ok(limit) = limit.parse::() { + modes_limits.push(ModesLimit { + modes: modes.to_string(), + limit, + }); + } } } }); @@ -283,7 +327,11 @@ impl FromStr for Operation { if let Some((modes, prefixes)) = value.split_once(')') { modes.chars().skip(1).zip(prefixes.chars()).for_each( - |(mode, prefix)| prefix_maps.push(PrefixMap { mode, prefix }), + |(mode, prefix)| { + if proto::CHANNEL_MEMBERSHIP_PREFIXES.contains(&prefix) { + prefix_maps.push(PrefixMap { mode, prefix }) + } + }, ); Ok(Operation::Add(Parameter::PREFIX(prefix_maps))) @@ -303,16 +351,20 @@ impl FromStr for Operation { value.split(',').for_each(|command_target_limit| { if let Some((command, limit)) = command_target_limit.split_once(':') { - if limit.is_empty() { - command_target_limits.push(CommandTargetLimit { - command: command.to_string(), - limit: None, - }); - } else if let Ok(limit) = limit.parse::() { - command_target_limits.push(CommandTargetLimit { - command: command.to_string(), - limit: Some(limit), - }); + if !command.is_empty() + && command.chars().all(|c| c.is_ascii_alphabetic()) + { + if limit.is_empty() { + command_target_limits.push(CommandTargetLimit { + command: command.to_uppercase().to_string(), + limit: None, + }); + } else if let Ok(limit) = limit.parse::() { + command_target_limits.push(CommandTargetLimit { + command: command.to_uppercase().to_string(), + limit: Some(limit), + }); + } } } }); @@ -332,9 +384,9 @@ impl FromStr for Operation { parse_required_positive_integer(value)?, ))), "UTF8ONLY" => Ok(Operation::Add(Parameter::UTF8ONLY)), - "VLIST" => Ok(Operation::Add(Parameter::VLIST( - parse_required_non_empty_string(value)?, - ))), + "VLIST" => Ok(Operation::Add(Parameter::VLIST(parse_required_letters( + value, + )?))), "WATCH" => Ok(Operation::Add(Parameter::WATCH( parse_required_positive_integer(value)?, ))), @@ -416,11 +468,17 @@ impl Operation { match self { Operation::Add(parameter) => parameter.kind(), Operation::Remove(parameter) => match parameter.as_ref() { + "AWAYLEN" => Some(Kind::AWAYLEN), + "CHANNELLEN" => Some(Kind::CHANNELLEN), "CNOTICE" => Some(Kind::CNOTICE), "CPRIVMSG" => Some(Kind::CPRIVMSG), + "ELIST" => Some(Kind::ELIST), + "KEYLEN" => Some(Kind::KEYLEN), + "KICKLEN" => Some(Kind::KICKLEN), "KNOCK" => Some(Kind::KNOCK), "NICKLEN" => Some(Kind::NICKLEN), "SAFELIST" => Some(Kind::SAFELIST), + "TOPICLEN" => Some(Kind::TOPICLEN), "USERIP" => Some(Kind::USERIP), "WHOX" => Some(Kind::WHOX), _ => None, @@ -501,11 +559,17 @@ pub enum Parameter { impl Parameter { pub fn kind(&self) -> Option { match self { + Parameter::AWAYLEN(_) => Some(Kind::AWAYLEN), + Parameter::CHANNELLEN(_) => Some(Kind::CHANNELLEN), Parameter::CNOTICE => Some(Kind::CNOTICE), Parameter::CPRIVMSG => Some(Kind::CPRIVMSG), + Parameter::ELIST(_) => Some(Kind::ELIST), + Parameter::KEYLEN(_) => Some(Kind::KEYLEN), + Parameter::KICKLEN(_) => Some(Kind::KICKLEN), Parameter::KNOCK => Some(Kind::KNOCK), Parameter::NICKLEN(_) => Some(Kind::NICKLEN), Parameter::SAFELIST => Some(Kind::SAFELIST), + Parameter::TOPICLEN(_) => Some(Kind::TOPICLEN), Parameter::USERIP => Some(Kind::USERIP), Parameter::WHOX => Some(Kind::WHOX), _ => None, @@ -611,21 +675,23 @@ pub const WHO_POLL_TOKEN: WhoToken = WhoToken { digits: ['9', '\0', '\0'], }; -fn parse_optional_positive_integer(value: &str) -> Result, &'static str> { +fn parse_optional_letters(value: &str) -> Result, &'static str> { if value.is_empty() { Ok(None) - } else if let Ok(value) = value.parse::() { - Ok(Some(value)) + } else if value.chars().all(|c| c.is_ascii_alphabetic()) { + Ok(Some(value.to_string())) } else { - Err("optional value must be a positive integer if specified") + Err("value required to be letter(s) if specified") } } -fn parse_optional_string(value: &str) -> Option { +fn parse_optional_positive_integer(value: &str) -> Result, &'static str> { if value.is_empty() { - None + Ok(None) + } else if let Ok(value) = value.parse::() { + Ok(Some(value)) } else { - Some(value.to_string()) + Err("optional value must be a positive integer if specified") } } @@ -641,9 +707,13 @@ fn parse_required_letter(value: &str, default_value: Option) -> Result Result { +fn parse_required_letters(value: &str) -> Result { if !value.is_empty() { - Ok(value.to_string()) + if value.chars().all(|c| c.is_ascii_alphabetic()) { + Ok(value.to_string()) + } else { + Err("value required to be letter(s)") + } } else { Err("value required") } diff --git a/irc/proto/src/lib.rs b/irc/proto/src/lib.rs index 62f104d24..83c53ba02 100644 --- a/irc/proto/src/lib.rs +++ b/irc/proto/src/lib.rs @@ -48,13 +48,14 @@ pub fn command(command: &str, parameters: Vec) -> Message { } } +pub const CHANNEL_PREFIXES: [char; 4] = ['#', '&', '+', '!']; + pub fn is_channel(target: &str) -> bool { - target.starts_with('#') - || target.starts_with('&') - || target.starts_with('+') - || target.starts_with('!') + target.starts_with(CHANNEL_PREFIXES) } +pub const CHANNEL_MEMBERSHIP_PREFIXES: [char; 5] = ['~', '&', '@', '%', '+' ]; + #[macro_export] macro_rules! command { ($c:expr) => ( diff --git a/src/buffer/channel.rs b/src/buffer/channel.rs index 004881ec9..58478c28d 100644 --- a/src/buffer/channel.rs +++ b/src/buffer/channel.rs @@ -149,7 +149,7 @@ pub fn view<'a>( input, users, channels, - clients.get_isupport_parameters(&state.server), + clients.get_isupport(&state.server), is_focused, !is_connected_to_channel, ) diff --git a/src/buffer/input_view.rs b/src/buffer/input_view.rs index 7e1d8cd0a..f17bd2158 100644 --- a/src/buffer/input_view.rs +++ b/src/buffer/input_view.rs @@ -23,7 +23,7 @@ pub fn view<'a>( cache: Cache<'a>, users: &'a [User], channels: &'a [String], - isupport_parameters: Vec<&'a isupport::Parameter>, + isupport: Vec<&'a isupport::Parameter>, buffer_focused: bool, disabled: bool, ) -> Element<'a, Message> { @@ -34,7 +34,7 @@ pub fn view<'a>( cache.history, users, channels, - isupport_parameters, + isupport, buffer_focused, disabled, Message::Input, diff --git a/src/buffer/query.rs b/src/buffer/query.rs index 93ba4305f..e6a97d61a 100644 --- a/src/buffer/query.rs +++ b/src/buffer/query.rs @@ -118,7 +118,7 @@ pub fn view<'a>( input, &[], channels, - clients.get_isupport_parameters(&state.server), + clients.get_isupport(&state.server), is_focused, !status.connected() ) diff --git a/src/buffer/server.rs b/src/buffer/server.rs index 39aded9f4..956d608f0 100644 --- a/src/buffer/server.rs +++ b/src/buffer/server.rs @@ -75,7 +75,7 @@ pub fn view<'a>( input, &[], channels, - clients.get_isupport_parameters(&state.server), + clients.get_isupport(&state.server), is_focused, !status.connected() ) diff --git a/src/widget/input.rs b/src/widget/input.rs index 3e0e1037a..e70669993 100644 --- a/src/widget/input.rs +++ b/src/widget/input.rs @@ -20,7 +20,7 @@ pub fn input<'a, Message>( history: &'a [String], users: &'a [User], channels: &'a [String], - isupport_parameters: Vec<&'a isupport::Parameter>, + isupport: Vec<&'a isupport::Parameter>, buffer_focused: bool, disabled: bool, on_input: impl Fn(input::Draft) -> Message + 'a, @@ -36,7 +36,7 @@ where input, users, channels, - isupport_parameters, + isupport, history, buffer_focused, disabled, @@ -68,7 +68,7 @@ pub struct Input<'a, Message> { input: &'a str, users: &'a [User], channels: &'a [String], - isupport_parameters: Vec<&'a isupport::Parameter>, + isupport: Vec<&'a isupport::Parameter>, history: &'a [String], buffer_focused: bool, disabled: bool, @@ -99,12 +99,9 @@ where // Reset selected history state.selected_history = None; - state.completion.process( - &input, - self.users, - self.channels, - &self.isupport_parameters, - ); + state + .completion + .process(&input, self.users, self.channels, &self.isupport); Some((self.on_input)(input::Draft { buffer: self.buffer.clone(), @@ -168,12 +165,9 @@ where .get(state.selected_history.unwrap()) .unwrap() .clone(); - state.completion.process( - &new_input, - self.users, - self.channels, - &self.isupport_parameters, - ); + state + .completion + .process(&new_input, self.users, self.channels, &self.isupport); return Some((self.on_completion)(input::Draft { buffer: self.buffer.clone(), @@ -197,7 +191,7 @@ where &new_input, self.users, self.channels, - &self.isupport_parameters, + &self.isupport, ); new_input }; diff --git a/src/widget/input/completion.rs b/src/widget/input/completion.rs index f5b7496ab..f6ae0a2fd 100644 --- a/src/widget/input/completion.rs +++ b/src/widget/input/completion.rs @@ -28,12 +28,12 @@ impl Completion { input: &str, users: &[User], channels: &[String], - isupport_parameters: &[&isupport::Parameter], + isupport: &[&isupport::Parameter], ) { let is_command = input.starts_with('/'); if is_command { - self.commands.process(input, isupport_parameters); + self.commands.process(input, isupport); // Disallow user completions when selecting a command if matches!(self.commands, Commands::Selecting { .. }) { @@ -101,7 +101,7 @@ impl Default for Commands { } impl Commands { - fn process(&mut self, input: &str, isupport_parameters: &[&isupport::Parameter]) { + fn process(&mut self, input: &str, isupport: &[&isupport::Parameter]) { let Some((head, rest)) = input.split_once('/') else { *self = Self::Idle; return; @@ -122,25 +122,60 @@ impl Commands { let command_list = COMMAND_LIST .iter() .map(|command| { - if command.title == "WHO" - && find_isupport_parameter(isupport_parameters, isupport::Kind::WHOX).is_some() - { - return WHOX_COMMAND.clone(); - } else if command.title == "NICK" { - if let Some(isupport::Parameter::NICKLEN(nick_len)) = - find_isupport_parameter(isupport_parameters, isupport::Kind::NICKLEN) - { - return nick_command(nick_len); + match command.title { + "AWAY" => { + if let Some(isupport::Parameter::AWAYLEN(Some(max_len))) = + find_isupport_parameter(isupport, isupport::Kind::AWAYLEN) + { + return away_command(max_len); + } + } + "JOIN" => { + let channel_len = + find_isupport_parameter(isupport, isupport::Kind::CHANNELLEN); + let key_len = find_isupport_parameter(isupport, isupport::Kind::KEYLEN); + + if channel_len.is_some() || key_len.is_some() { + return join_command(channel_len, key_len); + } } + "NICK" => { + if let Some(isupport::Parameter::NICKLEN(max_len)) = + find_isupport_parameter(isupport, isupport::Kind::NICKLEN) + { + return nick_command(max_len); + } + } + "TOPIC" => { + if let Some(isupport::Parameter::TOPICLEN(max_len)) = + find_isupport_parameter(isupport, isupport::Kind::TOPICLEN) + { + return topic_command(max_len); + } + } + "WHO" => { + if find_isupport_parameter(isupport, isupport::Kind::WHOX).is_some() { + return WHOX_COMMAND.clone(); + } + } + _ => (), } command.clone() }) - .chain( - isupport_parameters.iter().filter_map(|isupport_parameter| { + .chain(isupport.iter().filter_map(|isupport_parameter| { + if matches!(isupport_parameter, isupport::Parameter::SAFELIST) { + if let Some(isupport::Parameter::ELIST(search_extensions)) = + find_isupport_parameter(isupport, isupport::Kind::ELIST) + { + Some(list_command(&search_extensions)) + } else { + Some(LIST_COMMAND.clone()) + } + } else { isupport_parameter_to_command(isupport_parameter) - }), - ) + } + })) .collect::>(); match self { @@ -293,7 +328,7 @@ impl Command { let title = Some(Element::from(text(self.title))); let args = self.args.iter().enumerate().map(|(index, arg)| { - let content = text(format!(" {arg}")).style(move |theme| { + let content = text(format!("{arg}")).style(move |theme| { if index == active_arg { theme::text::accent(theme) } else { @@ -302,21 +337,34 @@ impl Command { }); if let Some(arg_tooltip) = &arg.tooltip { - Element::from(tooltip( - content, - container(text(arg_tooltip.clone()).style(move |theme| { + let tooltip_indicator = text("*") + .style(move |theme| { if index == active_arg { - theme::text::transparent_accent(theme) + theme::text::accent(theme) } else { - theme::text::transparent(theme) + theme::text::none(theme) } - })) - .style(theme::container::context) - .padding(8), - tooltip::Position::Top, - )) + }) + .size(8); + + Element::from(row![ + text(" "), + tooltip( + row![content, tooltip_indicator].align_items(iced::Alignment::Start), + container(text(arg_tooltip.clone()).style(move |theme| { + if index == active_arg { + theme::text::transparent_accent(theme) + } else { + theme::text::transparent(theme) + } + })) + .style(theme::container::context) + .padding(8), + tooltip::Position::Top, + ) + ]) } else { - Element::from(content) + Element::from(row![text(" "), content]) } }); @@ -433,12 +481,12 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "channels", optional: false, - tooltip: None, + tooltip: Some(String::from("comma-separated")), }, Arg { text: "keys", optional: true, - tooltip: None, + tooltip: Some(String::from("comma-separated")), }, ], }, @@ -489,6 +537,14 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { tooltip: None, }], }, + Command { + title: "AWAY", + args: vec![Arg { + text: "reason", + optional: true, + tooltip: None, + }], + }, Command { title: "ME", args: vec![Arg { @@ -594,10 +650,10 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { }); fn find_isupport_parameter( - isupport_parameters: &[&isupport::Parameter], + isupport: &[&isupport::Parameter], kind: isupport::Kind, ) -> Option { - isupport_parameters + isupport .iter() .find(|isupport_parameter| isupport_parameter.kind() == Some(kind.clone())) .cloned() @@ -610,11 +666,21 @@ fn isupport_parameter_to_command(isupport_parameter: &isupport::Parameter) -> Op isupport::Parameter::USERIP => Some(USERIP_COMMAND.clone()), isupport::Parameter::CNOTICE => Some(CNOTICE_COMMAND.clone()), isupport::Parameter::CPRIVMSG => Some(CPRIVMSG_COMMAND.clone()), - isupport::Parameter::SAFELIST => Some(LIST_COMMAND.clone()), _ => None, } } +fn away_command(max_len: u16) -> Command { + Command { + title: "AWAY", + args: vec![Arg { + text: "reason", + optional: true, + tooltip: Some(format!("maximum length: {}", max_len)), + }], + } +} + static CNOTICE_COMMAND: Lazy = Lazy::new(|| Command { title: "CNOTICE", args: vec![ @@ -675,20 +741,85 @@ static KNOCK_COMMAND: Lazy = Lazy::new(|| Command { static LIST_COMMAND: Lazy = Lazy::new(|| Command { title: "LIST", - args: vec![ - Arg { - text: "channels", - optional: true, - tooltip: None, - }, - Arg { - text: "server", - optional: true, - tooltip: None, - }, - ], + args: vec![Arg { + text: "channels", + optional: true, + tooltip: Some(String::from("comma-separated")), + }], }); +fn list_command(search_extensions: &str) -> Command { + let elistconds_tooltip = search_extensions.chars().fold( + String::from("comma-separated"), + |tooltip, search_extension| { + tooltip + + match search_extension { + 'C' => "\n C<{#}: created < # min ago\n C>{#}: created > # min ago", + 'M' => "\n {mask}: matches mask", + 'N' => "\n!{mask}: does not match mask", + 'T' => { + "\n T<{#}: topic changed < # min ago\n T>{#}: topic changed > # min ago" + } + 'U' => "\n U<{#}: fewer than # users\n U>{#}: more than # users", + _ => "", + } + }, + ); + + Command { + title: "LIST", + args: vec![ + Arg { + text: "channels", + optional: true, + tooltip: Some(String::from("comma-separated")), + }, + Arg { + text: "elistconds", + optional: true, + tooltip: Some(elistconds_tooltip), + }, + ], + } +} + +fn join_command( + channel_len: Option, + key_len: Option, +) -> Command { + Command { + title: "JOIN", + args: vec![ + Arg { + text: "channels", + optional: false, + tooltip: if let Some(isupport::Parameter::CHANNELLEN(channel_len)) = channel_len { + Some(format!( + "comma-separated\n\ + maximum length of each: {}", + channel_len + )) + } else { + Some(String::from("comma-separated")) + }, + }, + Arg { + text: "keys", + optional: true, + tooltip: if let Some(isupport::Parameter::KEYLEN(key_len)) = key_len { + Some(format!( + "comma-separated\n\ + maximum length of each: {}", + key_len + )) + } else { + Some(String::from("comma-separated")) + }, + }, + ], + } +} + fn nick_command(max_len: u16) -> Command { Command { title: "NICK", @@ -700,6 +831,24 @@ fn nick_command(max_len: u16) -> Command { } } +fn topic_command(max_len: u16) -> Command { + Command { + title: "TOPIC", + args: vec![ + Arg { + text: "channel", + optional: false, + tooltip: None, + }, + Arg { + text: "topic", + optional: true, + tooltip: Some(format!("maximum length: {}", max_len)), + }, + ], + } +} + static USERIP_COMMAND: Lazy = Lazy::new(|| Command { title: "USERIP", args: vec![Arg { From e10dc5d64fc5787415083aa369b9d42a9e76aa07 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Wed, 24 Apr 2024 19:10:48 -0700 Subject: [PATCH 15/23] One more tooltip. --- src/widget/input/completion.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widget/input/completion.rs b/src/widget/input/completion.rs index f6ae0a2fd..7f5d7bf7c 100644 --- a/src/widget/input/completion.rs +++ b/src/widget/input/completion.rs @@ -579,7 +579,7 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "channels", optional: false, - tooltip: None, + tooltip: Some(String::from("comma-separated")), }, Arg { text: "reason", From 996c257f33ef7267fdf3a2b34b4f0bf95777d12a Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Thu, 25 Apr 2024 03:09:40 -0700 Subject: [PATCH 16/23] Further consolidate `RPL_WHOREPLY` and `RPL_WHOSPCRPL` handling. --- data/src/client.rs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index c549b196b..3f21cc382 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -699,10 +699,7 @@ impl Client { log::debug!("[{}] {target} - WHO receiving...", self.server); } - channel.update_user_away( - User::from(Nick::from(args.get(5)?.clone())), - args.get(6)?.chars().collect::>(), - ); + channel.update_user_away(args.get(5)?, args.get(6)?); // We requested, don't save to history if matches!(channel.last_who, Some(WhoStatus::Receiving(None))) { @@ -732,13 +729,7 @@ impl Client { channel.last_who { if request_token == token { - let flags = args.get(4)?.chars().collect::>(); - - if flags.first().map_or(false, |c| matches!(c, 'H' | 'G')) { - if let Ok(user) = User::try_from(args.get(3)?.clone()) { - channel.update_user_away(user, flags); - } - } + channel.update_user_away(args.get(3)?, args.get(4)?); return None; } @@ -1299,10 +1290,12 @@ pub struct Channel { } impl Channel { - pub fn update_user_away(&mut self, user: User, flags: Vec) { - if let Some(away_flag) = flags.first() { + pub fn update_user_away(&mut self, user: &str, flags: &str) { + let user = User::from(Nick::from(user)); + + if let Some(away_flag) = flags.chars().next() { // H = Here, G = gone (away) - let away = *away_flag == 'G'; + let away = away_flag == 'G'; if let Some(mut user) = self.users.take(&user) { user.update_away(away); From f436a95dc8fb7134788f038b1b0cdb4bccdfa107 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Thu, 25 Apr 2024 11:34:37 -0700 Subject: [PATCH 17/23] Slightly more stringent parsing of `RPL_WHOREPLY` and `RPL_WHOSPCRPL`. --- data/src/client.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/data/src/client.rs b/data/src/client.rs index 3f21cc382..7020ba305 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -1295,7 +1295,11 @@ impl Channel { if let Some(away_flag) = flags.chars().next() { // H = Here, G = gone (away) - let away = away_flag == 'G'; + let away = match away_flag { + 'G' => true, + 'H' => false, + _ => return, + }; if let Some(mut user) = self.users.take(&user) { user.update_away(away); From c0bca1f7c94dea45ce899e8caa35253664f95654 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Thu, 25 Apr 2024 14:29:24 -0700 Subject: [PATCH 18/23] `STATUSMSG` utilization for `MSG` target tooltip and `PART` channels tooltip. --- data/src/isupport.rs | 14 +++- irc/proto/src/lib.rs | 4 +- src/widget/input/completion.rs | 125 ++++++++++++++++++++++++++------- 3 files changed, 114 insertions(+), 29 deletions(-) diff --git a/data/src/isupport.rs b/data/src/isupport.rs index d265cf944..8d9e99d14 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -16,6 +16,7 @@ pub enum Kind { KNOCK, NICKLEN, SAFELIST, + STATUSMSG, TOPICLEN, USERIP, WHOX, @@ -344,7 +345,16 @@ impl FromStr for Operation { "SILENCE" => Ok(Operation::Add(Parameter::SILENCE( parse_optional_positive_integer(value)?, ))), - "STATUSMSG" => Ok(Operation::Add(Parameter::STATUSMSG(value.to_string()))), + "STATUSMSG" => { + if value + .chars() + .all(|c| proto::CHANNEL_MEMBERSHIP_PREFIXES.contains(&c)) + { + Ok(Operation::Add(Parameter::STATUSMSG(value.to_string()))) + } else { + Err("unknown channel membership prefix(es)") + } + } "TARGMAX" => { let mut command_target_limits = vec![]; @@ -478,6 +488,7 @@ impl Operation { "KNOCK" => Some(Kind::KNOCK), "NICKLEN" => Some(Kind::NICKLEN), "SAFELIST" => Some(Kind::SAFELIST), + "STATUSMSG" => Some(Kind::STATUSMSG), "TOPICLEN" => Some(Kind::TOPICLEN), "USERIP" => Some(Kind::USERIP), "WHOX" => Some(Kind::WHOX), @@ -569,6 +580,7 @@ impl Parameter { Parameter::KNOCK => Some(Kind::KNOCK), Parameter::NICKLEN(_) => Some(Kind::NICKLEN), Parameter::SAFELIST => Some(Kind::SAFELIST), + Parameter::STATUSMSG(_) => Some(Kind::STATUSMSG), Parameter::TOPICLEN(_) => Some(Kind::TOPICLEN), Parameter::USERIP => Some(Kind::USERIP), Parameter::WHOX => Some(Kind::WHOX), diff --git a/irc/proto/src/lib.rs b/irc/proto/src/lib.rs index 83c53ba02..99c59cb0b 100644 --- a/irc/proto/src/lib.rs +++ b/irc/proto/src/lib.rs @@ -48,13 +48,15 @@ pub fn command(command: &str, parameters: Vec) -> Message { } } +// Reference: https://defs.ircdocs.horse/defs/chantypes pub const CHANNEL_PREFIXES: [char; 4] = ['#', '&', '+', '!']; pub fn is_channel(target: &str) -> bool { target.starts_with(CHANNEL_PREFIXES) } -pub const CHANNEL_MEMBERSHIP_PREFIXES: [char; 5] = ['~', '&', '@', '%', '+' ]; +// Reference: https://defs.ircdocs.horse/defs/chanmembers +pub const CHANNEL_MEMBERSHIP_PREFIXES: [char; 6] = ['~', '&', '!', '@', '%', '+' ]; #[macro_export] macro_rules! command { diff --git a/src/widget/input/completion.rs b/src/widget/input/completion.rs index 7f5d7bf7c..fded1fc52 100644 --- a/src/widget/input/completion.rs +++ b/src/widget/input/completion.rs @@ -139,6 +139,13 @@ impl Commands { return join_command(channel_len, key_len); } } + "MSG" => { + if let Some(isupport::Parameter::STATUSMSG(channel_membership_prefixes)) = + find_isupport_parameter(isupport, isupport::Kind::STATUSMSG) + { + return msg_command(&channel_membership_prefixes); + } + } "NICK" => { if let Some(isupport::Parameter::NICKLEN(max_len)) = find_isupport_parameter(isupport, isupport::Kind::NICKLEN) @@ -146,6 +153,13 @@ impl Commands { return nick_command(max_len); } } + "PART" => { + if let Some(isupport::Parameter::CHANNELLEN(max_len)) = + find_isupport_parameter(isupport, isupport::Kind::CHANNELLEN) + { + return part_command(max_len); + } + } "TOPIC" => { if let Some(isupport::Parameter::TOPICLEN(max_len)) = find_isupport_parameter(isupport, isupport::Kind::TOPICLEN) @@ -520,7 +534,9 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "target", optional: false, - tooltip: None, + tooltip: Some(String::from( + " {user}: user directly\n{channel}: all users in channel", + )), }, Arg { text: "text", @@ -723,6 +739,43 @@ static CPRIVMSG_COMMAND: Lazy = Lazy::new(|| Command { ], }); +fn join_command( + channel_len: Option, + key_len: Option, +) -> Command { + Command { + title: "JOIN", + args: vec![ + Arg { + text: "channels", + optional: false, + tooltip: if let Some(isupport::Parameter::CHANNELLEN(channel_len)) = channel_len { + Some(format!( + "comma-separated\n\ + maximum length of each: {}", + channel_len + )) + } else { + Some(String::from("comma-separated")) + }, + }, + Arg { + text: "keys", + optional: true, + tooltip: if let Some(isupport::Parameter::KEYLEN(key_len)) = key_len { + Some(format!( + "comma-separated\n\ + maximum length of each: {}", + key_len + )) + } else { + Some(String::from("comma-separated")) + }, + }, + ], + } +} + static KNOCK_COMMAND: Lazy = Lazy::new(|| Command { title: "KNOCK", args: vec![ @@ -783,38 +836,35 @@ fn list_command(search_extensions: &str) -> Command { } } -fn join_command( - channel_len: Option, - key_len: Option, -) -> Command { +fn msg_command(channel_membership_prefixes: &str) -> Command { + let target_tooltip = channel_membership_prefixes.chars().fold( + String::from(" {user}: user directly\n {channel}: all users in channel"), + |tooltip, channel_membership_prefix| { + tooltip + + match channel_membership_prefix { + '~' => "\n~{channel}: all founders in channel", + '&' => "\n&{channel}: all protected users in channel", + '!' => "\n!{channel}: all protected users in channel", + '@' => "\n@{channel}: all operators in channel", + '%' => "\n%{channel}: all half-operators in channel", + '+' => "\n+{channel}: all voiced users in channel", + _ => "", + } + }, + ); + Command { - title: "JOIN", + title: "MSG", args: vec![ Arg { - text: "channels", + text: "target", optional: false, - tooltip: if let Some(isupport::Parameter::CHANNELLEN(channel_len)) = channel_len { - Some(format!( - "comma-separated\n\ - maximum length of each: {}", - channel_len - )) - } else { - Some(String::from("comma-separated")) - }, + tooltip: Some(target_tooltip), }, Arg { - text: "keys", - optional: true, - tooltip: if let Some(isupport::Parameter::KEYLEN(key_len)) = key_len { - Some(format!( - "comma-separated\n\ - maximum length of each: {}", - key_len - )) - } else { - Some(String::from("comma-separated")) - }, + text: "text", + optional: false, + tooltip: None, }, ], } @@ -831,6 +881,27 @@ fn nick_command(max_len: u16) -> Command { } } +fn part_command(max_len: u16) -> Command { + Command { + title: "PART", + args: vec![ + Arg { + text: "channels", + optional: false, + tooltip: Some(format!( + "comma-separated\nmaximum length of each: {}", + max_len + )), + }, + Arg { + text: "reason", + optional: true, + tooltip: None, + }, + ], + } +} + fn topic_command(max_len: u16) -> Command { Command { title: "TOPIC", From f059cee4406a20f4550d856c591cf615654feccf Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Thu, 25 Apr 2024 17:28:16 -0700 Subject: [PATCH 19/23] `CHANLIMIT` utilization for `JOIN` channels tooltip. --- data/src/client.rs | 6 ++++ data/src/isupport.rs | 28 ++++++++--------- src/widget/input/completion.rs | 57 ++++++++++++++++++++++------------ 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index 7020ba305..762819d84 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -897,6 +897,12 @@ impl Client { parameter ); self.isupport.insert(kind, parameter); + } else { + log::debug!( + "[{}] ignoring ISUPPORT parameter: {:?}", + self.server, + parameter + ); } } isupport::Operation::Remove(_) => { diff --git a/data/src/isupport.rs b/data/src/isupport.rs index 8d9e99d14..c38fc484d 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -7,6 +7,7 @@ use std::str::FromStr; #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum Kind { AWAYLEN, + CHANLIMIT, CHANNELLEN, CNOTICE, CPRIVMSG, @@ -479,6 +480,7 @@ impl Operation { Operation::Add(parameter) => parameter.kind(), Operation::Remove(parameter) => match parameter.as_ref() { "AWAYLEN" => Some(Kind::AWAYLEN), + "CHANLIMIT" => Some(Kind::CHANLIMIT), "CHANNELLEN" => Some(Kind::CHANNELLEN), "CNOTICE" => Some(Kind::CNOTICE), "CPRIVMSG" => Some(Kind::CPRIVMSG), @@ -571,6 +573,7 @@ impl Parameter { pub fn kind(&self) -> Option { match self { Parameter::AWAYLEN(_) => Some(Kind::AWAYLEN), + Parameter::CHANLIMIT(_) => Some(Kind::CHANLIMIT), Parameter::CHANNELLEN(_) => Some(Kind::CHANNELLEN), Parameter::CNOTICE => Some(Kind::CNOTICE), Parameter::CPRIVMSG => Some(Kind::CPRIVMSG), @@ -598,18 +601,16 @@ pub enum CaseMap { RFC7613, } -#[allow(dead_code)] #[derive(Clone, Debug)] pub struct ChannelLimit { - prefix: char, - limit: Option, + pub prefix: char, + pub limit: Option, } -#[allow(dead_code)] #[derive(Clone, Debug)] pub struct ChannelMode { - letter: char, - modes: String, + pub letter: char, + pub modes: String, } #[derive(Clone, Debug)] @@ -619,11 +620,10 @@ pub enum ClientOnlyTags { DenyAll, } -#[allow(dead_code)] #[derive(Clone, Debug)] pub struct CommandTargetLimit { - command: String, - limit: Option, + pub command: String, + pub limit: Option, } #[derive(Clone, Debug)] @@ -632,18 +632,16 @@ pub enum MessageReferenceType { MessageID, } -#[allow(dead_code)] #[derive(Clone, Debug)] pub struct ModesLimit { - modes: String, - limit: u16, + pub modes: String, + pub limit: u16, } -#[allow(dead_code)] #[derive(Clone, Debug)] pub struct PrefixMap { - prefix: char, - mode: char, + pub prefix: char, + pub mode: char, } const DEFAULT_BAN_EXCEPTION_CHANNEL_LETTER: char = 'e'; diff --git a/src/widget/input/completion.rs b/src/widget/input/completion.rs index fded1fc52..26b277ff9 100644 --- a/src/widget/input/completion.rs +++ b/src/widget/input/completion.rs @@ -133,10 +133,12 @@ impl Commands { "JOIN" => { let channel_len = find_isupport_parameter(isupport, isupport::Kind::CHANNELLEN); + let channel_limits = + find_isupport_parameter(isupport, isupport::Kind::CHANLIMIT); let key_len = find_isupport_parameter(isupport, isupport::Kind::KEYLEN); - if channel_len.is_some() || key_len.is_some() { - return join_command(channel_len, key_len); + if channel_len.is_some() || channel_limits.is_some() || key_len.is_some() { + return join_command(channel_len, channel_limits, key_len); } } "MSG" => { @@ -741,36 +743,51 @@ static CPRIVMSG_COMMAND: Lazy = Lazy::new(|| Command { fn join_command( channel_len: Option, + channel_limits: Option, key_len: Option, ) -> Command { + let mut channels_tooltip = String::from("comma-separated"); + + if let Some(isupport::Parameter::CHANNELLEN(channel_len)) = channel_len { + channels_tooltip.push_str(format!("\nmaximum length of each: {}", channel_len).as_str()); + } + + if let Some(isupport::Parameter::CHANLIMIT(channel_limits)) = channel_limits { + channel_limits.iter().for_each(|channel_limit| { + if let Some(limit) = channel_limit.limit { + channels_tooltip.push_str( + format!( + "\nup to {limit} {} channels per client", + channel_limit.prefix + ) + .as_str(), + ); + } else { + channels_tooltip.push_str( + format!("\nunlimited {} channels per client", channel_limit.prefix).as_str(), + ); + } + }); + } + + let mut keys_tooltip = String::from("comma-separated"); + + if let Some(isupport::Parameter::KEYLEN(key_len)) = key_len { + keys_tooltip.push_str(format!("\nmaximum length of each: {}", key_len).as_str()); + } + Command { title: "JOIN", args: vec![ Arg { text: "channels", optional: false, - tooltip: if let Some(isupport::Parameter::CHANNELLEN(channel_len)) = channel_len { - Some(format!( - "comma-separated\n\ - maximum length of each: {}", - channel_len - )) - } else { - Some(String::from("comma-separated")) - }, + tooltip: Some(channels_tooltip), }, Arg { text: "keys", optional: true, - tooltip: if let Some(isupport::Parameter::KEYLEN(key_len)) = key_len { - Some(format!( - "comma-separated\n\ - maximum length of each: {}", - key_len - )) - } else { - Some(String::from("comma-separated")) - }, + tooltip: Some(keys_tooltip), }, ], } From fb749fff519cdada08415b4884fae5d463d1f69d Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Thu, 25 Apr 2024 21:50:53 -0700 Subject: [PATCH 20/23] Utilization of `TARGMAX` in command list tooltips. --- data/src/isupport.rs | 3 + src/widget/input/completion.rs | 298 +++++++++++++++++++++++++-------- 2 files changed, 231 insertions(+), 70 deletions(-) diff --git a/data/src/isupport.rs b/data/src/isupport.rs index c38fc484d..788c864f1 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -18,6 +18,7 @@ pub enum Kind { NICKLEN, SAFELIST, STATUSMSG, + TARGMAX, TOPICLEN, USERIP, WHOX, @@ -491,6 +492,7 @@ impl Operation { "NICKLEN" => Some(Kind::NICKLEN), "SAFELIST" => Some(Kind::SAFELIST), "STATUSMSG" => Some(Kind::STATUSMSG), + "TARGMAX" => Some(Kind::TARGMAX), "TOPICLEN" => Some(Kind::TOPICLEN), "USERIP" => Some(Kind::USERIP), "WHOX" => Some(Kind::WHOX), @@ -584,6 +586,7 @@ impl Parameter { Parameter::NICKLEN(_) => Some(Kind::NICKLEN), Parameter::SAFELIST => Some(Kind::SAFELIST), Parameter::STATUSMSG(_) => Some(Kind::STATUSMSG), + Parameter::TARGMAX(_) => Some(Kind::TARGMAX), Parameter::TOPICLEN(_) => Some(Kind::TOPICLEN), Parameter::USERIP => Some(Kind::USERIP), Parameter::WHOX => Some(Kind::WHOX), diff --git a/src/widget/input/completion.rs b/src/widget/input/completion.rs index 26b277ff9..6c66d77ff 100644 --- a/src/widget/input/completion.rs +++ b/src/widget/input/completion.rs @@ -131,21 +131,58 @@ impl Commands { } } "JOIN" => { - let channel_len = - find_isupport_parameter(isupport, isupport::Kind::CHANNELLEN); + let channel_len = if let Some(isupport::Parameter::CHANNELLEN(max_len)) = + find_isupport_parameter(isupport, isupport::Kind::CHANNELLEN) + { + Some(max_len) + } else { + None + }; + let channel_limits = - find_isupport_parameter(isupport, isupport::Kind::CHANLIMIT); - let key_len = find_isupport_parameter(isupport, isupport::Kind::KEYLEN); + if let Some(isupport::Parameter::CHANLIMIT(channel_limits)) = + find_isupport_parameter(isupport, isupport::Kind::CHANLIMIT) + { + Some(channel_limits) + } else { + None + }; + + let key_len = if let Some(isupport::Parameter::KEYLEN(max_len)) = + find_isupport_parameter(isupport, isupport::Kind::KEYLEN) + { + Some(max_len) + } else { + None + }; if channel_len.is_some() || channel_limits.is_some() || key_len.is_some() { return join_command(channel_len, channel_limits, key_len); } } "MSG" => { - if let Some(isupport::Parameter::STATUSMSG(channel_membership_prefixes)) = + let channel_membership_prefixes = if let Some( + isupport::Parameter::STATUSMSG(channel_membership_prefixes), + ) = find_isupport_parameter(isupport, isupport::Kind::STATUSMSG) { - return msg_command(&channel_membership_prefixes); + Some(channel_membership_prefixes) + } else { + None + }; + + let target_limit = find_target_limit(isupport, "PRIVMSG"); + + if channel_membership_prefixes.is_some() || target_limit.is_some() { + return msg_command( + channel_membership_prefixes.as_deref(), + target_limit, + ); + } + } + "NAMES" => { + if let Some(target_limit) = find_target_limit(isupport, command.title) { + return names_command(target_limit); } } "NICK" => { @@ -174,6 +211,11 @@ impl Commands { return WHOX_COMMAND.clone(); } } + "WHOIS" => { + if let Some(target_limit) = find_target_limit(isupport, command.title) { + return whois_command(target_limit); + } + } _ => (), } @@ -181,10 +223,19 @@ impl Commands { }) .chain(isupport.iter().filter_map(|isupport_parameter| { if matches!(isupport_parameter, isupport::Parameter::SAFELIST) { - if let Some(isupport::Parameter::ELIST(search_extensions)) = - find_isupport_parameter(isupport, isupport::Kind::ELIST) - { - Some(list_command(&search_extensions)) + let search_extensions = + if let Some(isupport::Parameter::ELIST(search_extensions)) = + find_isupport_parameter(isupport, isupport::Kind::ELIST) + { + Some(search_extensions) + } else { + None + }; + + let target_limit = find_target_limit(isupport, "LIST"); + + if search_extensions.is_some() || target_limit.is_some() { + Some(list_command(search_extensions.as_deref(), target_limit)) } else { Some(LIST_COMMAND.clone()) } @@ -534,10 +585,10 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { title: "MSG", args: vec![ Arg { - text: "target", + text: "targets", optional: false, tooltip: Some(String::from( - " {user}: user directly\n{channel}: all users in channel", + "comma-separated\n {user}: user directly\n{channel}: all users in channel", )), }, Arg { @@ -550,9 +601,9 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Command { title: "WHOIS", args: vec![Arg { - text: "nick", + text: "nicks", optional: false, - tooltip: None, + tooltip: Some(String::from("comma-separated")), }], }, Command { @@ -629,6 +680,16 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { tooltip: None, }], }, + Command { + title: "NAMES", + args: vec![ + Arg { + text: "channels", + optional: false, + tooltip: Some(String::from("comma-separated")), + }, + ], + }, Command { title: "KICK", args: vec![ @@ -678,6 +739,23 @@ fn find_isupport_parameter( .cloned() } +fn find_target_limit( + isupport: &[&isupport::Parameter], + command: &str, +) -> Option { + if let Some(isupport::Parameter::TARGMAX(target_limits)) = isupport + .iter() + .find(|isupport_parameter| isupport_parameter.kind() == Some(isupport::Kind::TARGMAX)) + { + target_limits + .iter() + .find(|target_limit| target_limit.command == command) + .cloned() + } else { + None + } +} + fn isupport_parameter_to_command(isupport_parameter: &isupport::Parameter) -> Option { match isupport_parameter { isupport::Parameter::KNOCK => Some(KNOCK_COMMAND.clone()), @@ -742,17 +820,17 @@ static CPRIVMSG_COMMAND: Lazy = Lazy::new(|| Command { }); fn join_command( - channel_len: Option, - channel_limits: Option, - key_len: Option, + channel_len: Option, + channel_limits: Option>, + key_len: Option, ) -> Command { let mut channels_tooltip = String::from("comma-separated"); - if let Some(isupport::Parameter::CHANNELLEN(channel_len)) = channel_len { + if let Some(channel_len) = channel_len { channels_tooltip.push_str(format!("\nmaximum length of each: {}", channel_len).as_str()); } - if let Some(isupport::Parameter::CHANLIMIT(channel_limits)) = channel_limits { + if let Some(channel_limits) = channel_limits { channel_limits.iter().for_each(|channel_limit| { if let Some(limit) = channel_limit.limit { channels_tooltip.push_str( @@ -767,13 +845,13 @@ fn join_command( format!("\nunlimited {} channels per client", channel_limit.prefix).as_str(), ); } - }); + }) } let mut keys_tooltip = String::from("comma-separated"); - if let Some(isupport::Parameter::KEYLEN(key_len)) = key_len { - keys_tooltip.push_str(format!("\nmaximum length of each: {}", key_len).as_str()); + if let Some(key_len) = key_len { + keys_tooltip.push_str(format!("\nmaximum length of each: {}", key_len).as_str()) } Command { @@ -818,12 +896,26 @@ static LIST_COMMAND: Lazy = Lazy::new(|| Command { }], }); -fn list_command(search_extensions: &str) -> Command { - let elistconds_tooltip = search_extensions.chars().fold( - String::from("comma-separated"), - |tooltip, search_extension| { - tooltip - + match search_extension { +fn list_command( + search_extensions: Option<&str>, + target_limit: Option, +) -> Command { + let mut channels_tooltip = String::from("comma-separated"); + + if let Some(target_limit) = target_limit { + if let Some(limit) = target_limit.limit { + channels_tooltip.push_str(format!("\nup to {} channel", limit).as_str()); + if limit > 1 { + channels_tooltip.push('s') + } + } + } + + if let Some(search_extensions) = search_extensions { + let elistconds_tooltip = search_extensions.chars().fold( + String::from("comma-separated"), + |tooltip, search_extension| { + tooltip + match search_extension { 'C' => "\n C<{#}: created < # min ago\n C>{#}: created > # min ago", 'M' => "\n {mask}: matches mask", 'N' => "\n!{mask}: does not match mask", @@ -833,50 +925,76 @@ fn list_command(search_extensions: &str) -> Command { 'U' => "\n U<{#}: fewer than # users\n U>{#}: more than # users", _ => "", } - }, - ); + }, + ); - Command { - title: "LIST", - args: vec![ - Arg { + Command { + title: "LIST", + args: vec![ + Arg { + text: "channels", + optional: true, + tooltip: Some(channels_tooltip), + }, + Arg { + text: "elistconds", + optional: true, + tooltip: Some(elistconds_tooltip), + }, + ], + } + } else { + Command { + title: "LIST", + args: vec![Arg { text: "channels", optional: true, - tooltip: Some(String::from("comma-separated")), - }, - Arg { - text: "elistconds", - optional: true, - tooltip: Some(elistconds_tooltip), - }, - ], + tooltip: Some(channels_tooltip), + }], + } } } -fn msg_command(channel_membership_prefixes: &str) -> Command { - let target_tooltip = channel_membership_prefixes.chars().fold( - String::from(" {user}: user directly\n {channel}: all users in channel"), - |tooltip, channel_membership_prefix| { - tooltip - + match channel_membership_prefix { - '~' => "\n~{channel}: all founders in channel", - '&' => "\n&{channel}: all protected users in channel", - '!' => "\n!{channel}: all protected users in channel", - '@' => "\n@{channel}: all operators in channel", - '%' => "\n%{channel}: all half-operators in channel", - '+' => "\n+{channel}: all voiced users in channel", - _ => "", - } - }, +fn msg_command( + channel_membership_prefixes: Option<&str>, + target_limit: Option, +) -> 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"), + _ => (), + }, + ); + } + + if let Some(target_limit) = target_limit { + if let Some(limit) = target_limit.limit { + targets_tooltip.push_str(format!("\nup to {} target", limit).as_str()); + if limit > 1 { + targets_tooltip.push('s') + } + } + } + Command { title: "MSG", args: vec![ Arg { - text: "target", + text: "targets", optional: false, - tooltip: Some(target_tooltip), + tooltip: Some(targets_tooltip), }, Arg { text: "text", @@ -887,6 +1005,26 @@ fn msg_command(channel_membership_prefixes: &str) -> Command { } } +fn names_command(target_limit: isupport::CommandTargetLimit) -> Command { + let mut channels_tooltip = String::from("comma-separated"); + + if let Some(limit) = target_limit.limit { + channels_tooltip.push_str(format!("\nup to {} channel", limit).as_str()); + if limit > 1 { + channels_tooltip.push('s') + } + } + + Command { + title: "NAMES", + args: vec![Arg { + text: "channels", + optional: false, + tooltip: Some(channels_tooltip), + }], + } +} + fn nick_command(max_len: u16) -> Command { Command { title: "NICK", @@ -960,17 +1098,17 @@ static WHOX_COMMAND: Lazy = Lazy::new(|| Command { tooltip: Some(String::from( "t: token\n\ c: channel\n\ - u: username\n\ - i: IP address\n\ - h: hostname\n\ - s: server name\n\ - n: nickname\n\ - f: WHO flags\n\ - d: hop count\n\ - l: idle seconds\n\ - a: account name\n\ - o: channel op level\n\ - r: realname", + u: username\n\ + i: IP address\n\ + h: hostname\n\ + s: server name\n\ + n: nickname\n\ + f: WHO flags\n\ + d: hop count\n\ + l: idle seconds\n\ + a: account name\n\ + o: channel op level\n\ + r: realname", )), }, Arg { @@ -980,3 +1118,23 @@ static WHOX_COMMAND: Lazy = Lazy::new(|| Command { }, ], }); + +fn whois_command(target_limit: isupport::CommandTargetLimit) -> Command { + let mut nicks_tooltip = String::from("comma-separated"); + + if let Some(limit) = target_limit.limit { + nicks_tooltip.push_str(format!("\nup to {} nick", limit).as_str()); + if limit > 1 { + nicks_tooltip.push('s') + } + } + + Command { + title: "WHOIS", + args: vec![Arg { + text: "nicks", + optional: false, + tooltip: Some(nicks_tooltip), + }], + } +} From 3ffc9b7303ea30021d56206dd5dbec1f1c76c8c6 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Thu, 25 Apr 2024 22:44:26 -0700 Subject: [PATCH 21/23] Restructuring to improve code clarity (`get` should also be faster than `find`). --- data/src/client.rs | 4 +- src/buffer/input_view.rs | 4 +- src/widget/input.rs | 6 ++- src/widget/input/completion.rs | 83 ++++++++++++++-------------------- 4 files changed, 43 insertions(+), 54 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index 762819d84..c788fb106 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -1160,9 +1160,9 @@ impl Map { .unwrap_or_default() } - pub fn get_isupport<'a>(&'a self, server: &Server) -> Vec<&'a isupport::Parameter> { + pub fn get_isupport(&self, server: &Server) -> HashMap { self.client(server) - .map(|client| client.isupport.values().collect::>()) + .map(|client| client.isupport.clone()) .unwrap_or_default() } diff --git a/src/buffer/input_view.rs b/src/buffer/input_view.rs index f17bd2158..ca00f826d 100644 --- a/src/buffer/input_view.rs +++ b/src/buffer/input_view.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use data::input::{Cache, Draft}; use data::isupport; use data::user::{Nick, User}; @@ -23,7 +25,7 @@ pub fn view<'a>( cache: Cache<'a>, users: &'a [User], channels: &'a [String], - isupport: Vec<&'a isupport::Parameter>, + isupport: HashMap, buffer_focused: bool, disabled: bool, ) -> Element<'a, Message> { diff --git a/src/widget/input.rs b/src/widget/input.rs index e70669993..9668fb7e1 100644 --- a/src/widget/input.rs +++ b/src/widget/input.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use data::user::User; use data::{input, isupport, Buffer, Command}; use iced::advanced::widget::{self, Operation}; @@ -20,7 +22,7 @@ pub fn input<'a, Message>( history: &'a [String], users: &'a [User], channels: &'a [String], - isupport: Vec<&'a isupport::Parameter>, + isupport: HashMap, buffer_focused: bool, disabled: bool, on_input: impl Fn(input::Draft) -> Message + 'a, @@ -68,7 +70,7 @@ pub struct Input<'a, Message> { input: &'a str, users: &'a [User], channels: &'a [String], - isupport: Vec<&'a isupport::Parameter>, + isupport: HashMap, history: &'a [String], buffer_focused: bool, disabled: bool, diff --git a/src/widget/input/completion.rs b/src/widget/input/completion.rs index 6c66d77ff..e82ddf3a2 100644 --- a/src/widget/input/completion.rs +++ b/src/widget/input/completion.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fmt; use data::isupport; @@ -28,7 +29,7 @@ impl Completion { input: &str, users: &[User], channels: &[String], - isupport: &[&isupport::Parameter], + isupport: &HashMap, ) { let is_command = input.starts_with('/'); @@ -101,7 +102,7 @@ impl Default for Commands { } impl Commands { - fn process(&mut self, input: &str, isupport: &[&isupport::Parameter]) { + fn process(&mut self, input: &str, isupport: &HashMap) { let Some((head, rest)) = input.split_once('/') else { *self = Self::Idle; return; @@ -125,14 +126,14 @@ impl Commands { match command.title { "AWAY" => { if let Some(isupport::Parameter::AWAYLEN(Some(max_len))) = - find_isupport_parameter(isupport, isupport::Kind::AWAYLEN) + isupport.get(&isupport::Kind::AWAYLEN) { return away_command(max_len); } } "JOIN" => { let channel_len = if let Some(isupport::Parameter::CHANNELLEN(max_len)) = - find_isupport_parameter(isupport, isupport::Kind::CHANNELLEN) + isupport.get(&isupport::Kind::CHANNELLEN) { Some(max_len) } else { @@ -141,7 +142,7 @@ impl Commands { let channel_limits = if let Some(isupport::Parameter::CHANLIMIT(channel_limits)) = - find_isupport_parameter(isupport, isupport::Kind::CHANLIMIT) + isupport.get(&isupport::Kind::CHANLIMIT) { Some(channel_limits) } else { @@ -149,7 +150,7 @@ impl Commands { }; let key_len = if let Some(isupport::Parameter::KEYLEN(max_len)) = - find_isupport_parameter(isupport, isupport::Kind::KEYLEN) + isupport.get(&isupport::Kind::KEYLEN) { Some(max_len) } else { @@ -164,7 +165,7 @@ impl Commands { let channel_membership_prefixes = if let Some( isupport::Parameter::STATUSMSG(channel_membership_prefixes), ) = - find_isupport_parameter(isupport, isupport::Kind::STATUSMSG) + isupport.get(&isupport::Kind::STATUSMSG) { Some(channel_membership_prefixes) } else { @@ -174,10 +175,7 @@ impl Commands { let target_limit = find_target_limit(isupport, "PRIVMSG"); if channel_membership_prefixes.is_some() || target_limit.is_some() { - return msg_command( - channel_membership_prefixes.as_deref(), - target_limit, - ); + return msg_command(channel_membership_prefixes, target_limit); } } "NAMES" => { @@ -187,27 +185,27 @@ impl Commands { } "NICK" => { if let Some(isupport::Parameter::NICKLEN(max_len)) = - find_isupport_parameter(isupport, isupport::Kind::NICKLEN) + isupport.get(&isupport::Kind::NICKLEN) { return nick_command(max_len); } } "PART" => { if let Some(isupport::Parameter::CHANNELLEN(max_len)) = - find_isupport_parameter(isupport, isupport::Kind::CHANNELLEN) + isupport.get(&isupport::Kind::CHANNELLEN) { return part_command(max_len); } } "TOPIC" => { if let Some(isupport::Parameter::TOPICLEN(max_len)) = - find_isupport_parameter(isupport, isupport::Kind::TOPICLEN) + isupport.get(&isupport::Kind::TOPICLEN) { return topic_command(max_len); } } "WHO" => { - if find_isupport_parameter(isupport, isupport::Kind::WHOX).is_some() { + if isupport.get(&isupport::Kind::WHOX).is_some() { return WHOX_COMMAND.clone(); } } @@ -221,11 +219,11 @@ impl Commands { command.clone() }) - .chain(isupport.iter().filter_map(|isupport_parameter| { + .chain(isupport.iter().filter_map(|(_, isupport_parameter)| { if matches!(isupport_parameter, isupport::Parameter::SAFELIST) { let search_extensions = if let Some(isupport::Parameter::ELIST(search_extensions)) = - find_isupport_parameter(isupport, isupport::Kind::ELIST) + isupport.get(&isupport::Kind::ELIST) { Some(search_extensions) } else { @@ -235,7 +233,7 @@ impl Commands { let target_limit = find_target_limit(isupport, "LIST"); if search_extensions.is_some() || target_limit.is_some() { - Some(list_command(search_extensions.as_deref(), target_limit)) + Some(list_command(search_extensions, target_limit)) } else { Some(LIST_COMMAND.clone()) } @@ -728,29 +726,16 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { ] }); -fn find_isupport_parameter( - isupport: &[&isupport::Parameter], - kind: isupport::Kind, -) -> Option { - isupport - .iter() - .find(|isupport_parameter| isupport_parameter.kind() == Some(kind.clone())) - .cloned() - .cloned() -} - -fn find_target_limit( - isupport: &[&isupport::Parameter], +fn find_target_limit<'a>( + isupport: &'a HashMap, command: &str, -) -> Option { - if let Some(isupport::Parameter::TARGMAX(target_limits)) = isupport - .iter() - .find(|isupport_parameter| isupport_parameter.kind() == Some(isupport::Kind::TARGMAX)) +) -> Option<&'a isupport::CommandTargetLimit> { + if let Some(isupport::Parameter::TARGMAX(target_limits)) = + isupport.get(&isupport::Kind::TARGMAX) { target_limits .iter() .find(|target_limit| target_limit.command == command) - .cloned() } else { None } @@ -766,7 +751,7 @@ fn isupport_parameter_to_command(isupport_parameter: &isupport::Parameter) -> Op } } -fn away_command(max_len: u16) -> Command { +fn away_command(max_len: &u16) -> Command { Command { title: "AWAY", args: vec![Arg { @@ -820,9 +805,9 @@ static CPRIVMSG_COMMAND: Lazy = Lazy::new(|| Command { }); fn join_command( - channel_len: Option, - channel_limits: Option>, - key_len: Option, + channel_len: Option<&u16>, + channel_limits: Option<&Vec>, + key_len: Option<&u16>, ) -> Command { let mut channels_tooltip = String::from("comma-separated"); @@ -897,8 +882,8 @@ static LIST_COMMAND: Lazy = Lazy::new(|| Command { }); fn list_command( - search_extensions: Option<&str>, - target_limit: Option, + search_extensions: Option<&String>, + target_limit: Option<&isupport::CommandTargetLimit>, ) -> Command { let mut channels_tooltip = String::from("comma-separated"); @@ -956,8 +941,8 @@ fn list_command( } fn msg_command( - channel_membership_prefixes: Option<&str>, - target_limit: Option, + channel_membership_prefixes: Option<&String>, + target_limit: Option<&isupport::CommandTargetLimit>, ) -> Command { let mut targets_tooltip = String::from( "comma-separated\n {user}: user directly\n {channel}: all users in channel", @@ -1005,7 +990,7 @@ fn msg_command( } } -fn names_command(target_limit: isupport::CommandTargetLimit) -> Command { +fn names_command(target_limit: &isupport::CommandTargetLimit) -> Command { let mut channels_tooltip = String::from("comma-separated"); if let Some(limit) = target_limit.limit { @@ -1025,7 +1010,7 @@ fn names_command(target_limit: isupport::CommandTargetLimit) -> Command { } } -fn nick_command(max_len: u16) -> Command { +fn nick_command(max_len: &u16) -> Command { Command { title: "NICK", args: vec![Arg { @@ -1036,7 +1021,7 @@ fn nick_command(max_len: u16) -> Command { } } -fn part_command(max_len: u16) -> Command { +fn part_command(max_len: &u16) -> Command { Command { title: "PART", args: vec![ @@ -1057,7 +1042,7 @@ fn part_command(max_len: u16) -> Command { } } -fn topic_command(max_len: u16) -> Command { +fn topic_command(max_len: &u16) -> Command { Command { title: "TOPIC", args: vec![ @@ -1119,7 +1104,7 @@ static WHOX_COMMAND: Lazy = Lazy::new(|| Command { ], }); -fn whois_command(target_limit: isupport::CommandTargetLimit) -> Command { +fn whois_command(target_limit: &isupport::CommandTargetLimit) -> Command { let mut nicks_tooltip = String::from("comma-separated"); if let Some(limit) = target_limit.limit { From 634cdacdfa4230ce833255cea662567600182e80 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Sun, 28 Apr 2024 03:23:47 -0700 Subject: [PATCH 22/23] Minor fix in command description in `ELIST` tooltip. --- src/widget/input/completion.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widget/input/completion.rs b/src/widget/input/completion.rs index e82ddf3a2..30bc4ea6e 100644 --- a/src/widget/input/completion.rs +++ b/src/widget/input/completion.rs @@ -907,7 +907,7 @@ fn list_command( 'T' => { "\n T<{#}: topic changed < # min ago\n T>{#}: topic changed > # min ago" } - 'U' => "\n U<{#}: fewer than # users\n U>{#}: more than # users", + 'U' => "\n <{#}: fewer than # users\n >{#}: more than # users", _ => "", } }, From abf2f8cfa5e7c5880cb2f21b919e565b85d77694 Mon Sep 17 00:00:00 2001 From: Cory Forsstrom Date: Tue, 30 Apr 2024 13:55:52 -0700 Subject: [PATCH 23/23] Cleanup control flow for discarding WHO message from history --- data/src/client.rs | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/data/src/client.rs b/data/src/client.rs index c788fb106..90887307f 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -694,15 +694,12 @@ impl Client { if proto::is_channel(target) { if let Some(channel) = self.chanmap.get_mut(target) { + channel.update_user_away(args.get(5)?, args.get(6)?); + if matches!(channel.last_who, Some(WhoStatus::Requested(_, None)) | None) { channel.last_who = Some(WhoStatus::Receiving(None)); log::debug!("[{}] {target} - WHO receiving...", self.server); - } - - channel.update_user_away(args.get(5)?, args.get(6)?); - - // We requested, don't save to history - if matches!(channel.last_who, Some(WhoStatus::Receiving(None))) { + // We requested, don't save to history return None; } } @@ -713,6 +710,8 @@ impl Client { if proto::is_channel(target) { if let Some(channel) = self.chanmap.get_mut(target) { + channel.update_user_away(args.get(3)?, args.get(4)?); + if let Ok(token) = args.get(1)?.parse::() { if let Some(WhoStatus::Requested(_, Some(request_token))) = channel.last_who @@ -721,16 +720,7 @@ impl Client { channel.last_who = Some(WhoStatus::Receiving(Some(request_token))); log::debug!("[{}] {target} - WHO receiving...", self.server); - } - } - - // We requested, don't save to history - if let Some(WhoStatus::Receiving(Some(request_token))) = - channel.last_who - { - if request_token == token { - channel.update_user_away(args.get(3)?, args.get(4)?); - + // We requested, don't save to history return None; } }