diff --git a/CHANGELOG.md b/CHANGELOG.md index 8024db410..4606aa345 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)) - Added support for `http` proxy configuration (see [proxy configuration](https://halloy.squidowl.org/configuration/proxy.html)) 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 diff --git a/data/src/client.rs b/data/src/client.rs index 927cb0cdf..ea62652d3 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: 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: HashMap::new(), } } @@ -658,8 +660,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.get(&isupport::Kind::WHOX).is_some() { + let _ = self.handle.try_send(command!( + "WHO", + channel, + "tcnf", + isupport::WHO_POLL_TOKEN.to_owned() + )); + state.last_who = Some(WhoStatus::Requested( + Instant::now(), + Some(isupport::WHO_POLL_TOKEN), + )); + } 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) { @@ -680,25 +695,36 @@ 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) { - channel.last_who = Some(WhoStatus::Receiving); + 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); + // We requested, don't save to history + return None; } + } + } + } + Command::Numeric(RPL_WHOSPCRPL, args) => { + let target = args.get(2)?; - // 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); - } + if proto::is_channel(target) { + if let Some(channel) = self.chanmap.get_mut(target) { + channel.update_user_away(args.get(3)?, args.get(4)?); - // We requested, don't save to history - if matches!(channel.last_who, Some(WhoStatus::Receiving)) { - return None; + if let Ok(token) = args.get(1)?.parse::() { + 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 + return None; + } + } } } } @@ -708,7 +734,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; @@ -846,6 +872,57 @@ impl Client { self.registration_required_channels.push(channel.clone()); } } + Command::Numeric(RPL_ISUPPORT, args) => { + let args_len = args.len(); + args.iter().enumerate().skip(1).for_each(|(index, arg)| { + let operation = arg.parse::(); + + match operation { + Ok(operation) => { + match operation { + isupport::Operation::Add(parameter) => { + if let Some(kind) = parameter.kind() { + log::info!( + "[{}] adding ISUPPORT parameter: {:?}", + self.server, + parameter + ); + self.isupport.insert(kind, parameter); + } else { + log::debug!( + "[{}] ignoring ISUPPORT parameter: {:?}", + self.server, + parameter + ); + } + } + isupport::Operation::Remove(_) => { + if let Some(kind) = operation.kind() { + log::info!( + "[{}] removing ISUPPORT parameter: {:?}", + self.server, + kind + ); + self.isupport.remove(&kind); + } + } + }; + } + Err(error) => { + if index != args_len - 1 { + log::debug!( + "[{}] unable to parse ISUPPORT parameter: {} ({})", + self.server, + arg, + error + ) + } + } + } + }); + + return None; + } _ => {} } @@ -929,15 +1006,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.get(&isupport::Kind::WHOX).is_some() { + let _ = self.handle.try_send(command!( + "WHO", + channel, + "tcnf", + isupport::WHO_POLL_TOKEN.to_owned() + )); + state.last_who = Some(WhoStatus::Requested( + Instant::now(), + Some(isupport::WHO_POLL_TOKEN), + )); + } else { + let _ = self.handle.try_send(command!("WHO", channel)); + state.last_who = Some(WhoStatus::Requested(Instant::now(), None)); + } log::debug!( "[{}] {channel} - WHO {}", self.server, @@ -1067,6 +1157,12 @@ impl Map { .unwrap_or_default() } + pub fn get_isupport(&self, server: &Server) -> HashMap { + self.client(server) + .map(|client| client.isupport.clone()) + .unwrap_or_default() + } + pub fn get_server_handle(&self, server: &Server) -> Option<&server::Handle> { self.client(server).map(|client| &client.handle) } @@ -1196,6 +1292,26 @@ pub struct Channel { pub names_init: bool, } +impl Channel { + 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 = match away_flag { + 'G' => true, + 'H' => false, + _ => return, + }; + + 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, @@ -1203,10 +1319,10 @@ pub struct Topic { pub time: Option>, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub enum WhoStatus { - Requested(Instant), - Receiving, + Requested(Instant, Option), + Receiving(Option), Done(Instant), } diff --git a/data/src/isupport.rs b/data/src/isupport.rs new file mode 100644 index 000000000..788c864f1 --- /dev/null +++ b/data/src/isupport.rs @@ -0,0 +1,741 @@ +use irc::proto; +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(Clone, Debug, Eq, Hash, PartialEq)] +pub enum Kind { + AWAYLEN, + CHANLIMIT, + CHANNELLEN, + CNOTICE, + CPRIVMSG, + ELIST, + KEYLEN, + KICKLEN, + KNOCK, + NICKLEN, + SAFELIST, + STATUSMSG, + TARGMAX, + TOPICLEN, + USERIP, + WHOX, +} + +#[derive(Debug)] +pub enum Operation { + Add(Parameter), + Remove(String), +} + +impl FromStr for Operation { + type Err = &'static str; + + fn from_str(token: &str) -> Result { + if token.is_empty() { + return Err("empty ISUPPORT token not allowed"); + } + + match token.chars().next() { + Some('-') => Ok(Operation::Remove(token.chars().skip(1).collect())), + _ => { + if let Some((parameter, value)) = token.split_once('=') { + match parameter { + "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(Operation::Add(Parameter::ACCOUNTEXTBAN( + account_based_extended_ban_masks, + ))) + } else { + Err("no valid account-based extended ban masks") + } + } + "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" => { + let mut channel_limits = vec![]; + + value.split(',').for_each(|channel_limit| { + if let Some((prefix, limit)) = channel_limit.split_once(':') { + if limit.is_empty() { + prefix.chars().for_each(|c| { + if proto::CHANNEL_PREFIXES.contains(&c) { + channel_limits.push(ChannelLimit { + prefix: c, + limit: None, + }); + } + }); + } else if let Ok(limit) = limit.parse::() { + prefix.chars().for_each(|c| { + if proto::CHANNEL_PREFIXES.contains(&c) { + channel_limits.push(ChannelLimit { + prefix: c, + limit: Some(limit), + }); + } + }); + } + } + }); + + if !channel_limits.is_empty() { + Ok(Operation::Add(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)| { + if modes.chars().all(|c| c.is_ascii_alphabetic()) { + channel_modes.push(ChannelMode { + letter, + modes: modes.to_string(), + }); + } + }); + + if !channel_modes.is_empty() { + Ok(Operation::Add(Parameter::CHANMODES(channel_modes))) + } else { + Err("no valid channel modes") + } + } + "CHANNELLEN" => Ok(Operation::Add(Parameter::CHANNELLEN( + parse_required_positive_integer(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)?, + ))), + "CLIENTTAGDENY" => { + let mut client_tag_denials = vec![]; + + value + .split(',') + .for_each(|client_tag_denial| { + match client_tag_denial.chars().next() { + Some('*') => { + client_tag_denials.push(ClientOnlyTags::DenyAll) + } + Some('-') => { + client_tag_denials.push(ClientOnlyTags::Allowed( + client_tag_denial.chars().skip(1).collect(), + )) + } + _ => client_tag_denials.push(ClientOnlyTags::Denied( + client_tag_denial.to_string(), + )), + } + }); + + if !client_tag_denials.is_empty() { + Ok(Operation::Add(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(Operation::Add(Parameter::CLIENTVER(major, minor))); + } + } + + Err("value must be a . version number") + } + "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" => { + 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_letters(value)?, + ))), + "ETRACE" => Ok(Operation::Add(Parameter::ETRACE)), + "EXCEPTS" => Ok(Operation::Add(Parameter::EXCEPTS(parse_required_letter( + value, + Some(DEFAULT_BAN_EXCEPTION_CHANNEL_LETTER), + )?))), + "EXTBAN" => { + if let Some((prefix, types)) = value.split_once(',') { + 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 { + Err("invalid extended ban type(s)") + } + } else { + Err("no valid extended ban masks") + } + } + "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(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![]; + + value.split(',').for_each(|modes_limit| { + if let Some((modes, limit)) = modes_limit.split_once(':') { + 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, + }); + } + } + } + }); + + if !modes_limits.is_empty() { + Ok(Operation::Add(Parameter::MAXLIST(modes_limits))) + } else { + Err("no valid modes limits") + } + } + "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![]; + + 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(Operation::Add(Parameter::MSGREFTYPES( + message_reference_types, + ))) + } + "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(')') { + modes.chars().skip(1).zip(prefixes.chars()).for_each( + |(mode, prefix)| { + if proto::CHANNEL_MEMBERSHIP_PREFIXES.contains(&prefix) { + prefix_maps.push(PrefixMap { mode, prefix }) + } + }, + ); + + Ok(Operation::Add(Parameter::PREFIX(prefix_maps))) + } else { + Err("unrecognized PREFIX format") + } + } + "SAFELIST" => Ok(Operation::Add(Parameter::SAFELIST)), + "SECURELIST" => Ok(Operation::Add(Parameter::SECURELIST)), + "SILENCE" => Ok(Operation::Add(Parameter::SILENCE( + parse_optional_positive_integer(value)?, + ))), + "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![]; + + value.split(',').for_each(|command_target_limit| { + if let Some((command, limit)) = command_target_limit.split_once(':') + { + 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), + }); + } + } + } + }); + + if !command_target_limits.is_empty() { + Ok(Operation::Add(Parameter::TARGMAX(command_target_limits))) + } else { + Err("no valid command target limits") + } + } + "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_letters( + value, + )?))), + "WATCH" => Ok(Operation::Add(Parameter::WATCH( + parse_required_positive_integer(value)?, + ))), + "WHOX" => Ok(Operation::Add(Parameter::WHOX)), + _ => Err("unknown ISUPPORT parameter"), + } + } else { + match token { + "ACCEPT" => Err("value required"), + "ACCOUNTEXTBAN" => Err("value(s) required"), + "AWAYLEN" => Ok(Operation::Add(Parameter::AWAYLEN(None))), + "BOT" => Err("value required"), + "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(Operation::Add(Parameter::CHANTYPES(None))), + "CHATHISTORY" => Err("value required"), + "CLIENTTAGDENY" => Err("value(s) required"), + "CLIENTVER" => Err("value required"), + "DEAF" => Ok(Operation::Add(Parameter::DEAF(DEFAULT_DEAF_LETTER))), + "ELIST" => Err("value required"), + "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(Operation::Add(Parameter::FNC)), + "HOSTLEN" => Err("value required"), + "INVEX" => Ok(Operation::Add(Parameter::INVEX( + DEFAULT_INVITE_EXCEPTION_LETTER, + ))), + "KEYLEN" => Err("value required"), + "KICKLEN" => Err("value required"), + "KNOCK" => Ok(Operation::Add(Parameter::KNOCK)), + "LINELEN" => Err("value required"), + "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(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(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(Operation::Add(Parameter::TARGMAX(vec![]))), + "TOPICLEN" => Err("value required"), + "UHNAMES" => Ok(Operation::Add(Parameter::UHNAMES)), + "USERIP" => Ok(Operation::Add(Parameter::USERIP)), + "USERLEN" => Err("value required"), + "UTF8ONLY" => Ok(Operation::Add(Parameter::UTF8ONLY)), + "VLIST" => Err("value required"), + "WATCH" => Err("value required"), + "WHOX" => Ok(Operation::Add(Parameter::WHOX)), + _ => Err("unknown ISUPPORT parameter"), + } + } + } + } + } +} + +impl Operation { + pub fn kind(&self) -> Option { + match self { + 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), + "ELIST" => Some(Kind::ELIST), + "KEYLEN" => Some(Kind::KEYLEN), + "KICKLEN" => Some(Kind::KICKLEN), + "KNOCK" => Some(Kind::KNOCK), + "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), + _ => None, + }, + } + } +} + +// 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(Clone, 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 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), + 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::STATUSMSG(_) => Some(Kind::STATUSMSG), + Parameter::TARGMAX(_) => Some(Kind::TARGMAX), + Parameter::TOPICLEN(_) => Some(Kind::TOPICLEN), + Parameter::USERIP => Some(Kind::USERIP), + Parameter::WHOX => Some(Kind::WHOX), + _ => None, + } + } +} + +#[allow(non_camel_case_types)] +#[derive(Clone, Debug)] +pub enum CaseMap { + ASCII, + RFC1459, + RFC1459_STRICT, + RFC7613, +} + +#[derive(Clone, Debug)] +pub struct ChannelLimit { + pub prefix: char, + pub limit: Option, +} + +#[derive(Clone, Debug)] +pub struct ChannelMode { + pub letter: char, + pub modes: String, +} + +#[derive(Clone, Debug)] +pub enum ClientOnlyTags { + Allowed(String), + Denied(String), + DenyAll, +} + +#[derive(Clone, Debug)] +pub struct CommandTargetLimit { + pub command: String, + pub limit: Option, +} + +#[derive(Clone, Debug)] +pub enum MessageReferenceType { + Timestamp, + MessageID, +} + +#[derive(Clone, Debug)] +pub struct ModesLimit { + pub modes: String, + pub limit: u16, +} + +#[derive(Clone, Debug)] +pub struct PrefixMap { + pub prefix: char, + pub mode: char, +} + +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], +} + +impl WhoToken { + pub fn to_owned(self) -> String { + self.digits.iter().filter(|c| **c != '\0').collect() + } +} + +impl FromStr for WhoToken { + type Err = &'static str; + + fn from_str(token: &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_letters(value: &str) -> Result, &'static str> { + if value.is_empty() { + Ok(None) + } else if value.chars().all(|c| c.is_ascii_alphabetic()) { + Ok(Some(value.to_string())) + } else { + Err("value required to be letter(s) if specified") + } +} + +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_required_letter(value: &str, default_value: Option) -> Result { + if let Some(value) = value.chars().next() { + if value.is_ascii_alphabetic() { + return Ok(value); + } + } else if let Some(default_value) = default_value { + return Ok(default_value); + } + + Err("value required to be a letter") +} + +fn parse_required_letters(value: &str) -> Result { + if !value.is_empty() { + 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") + } +} + +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") + } +} 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; 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..2aa83f26e 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, 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, 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/irc/proto/src/lib.rs b/irc/proto/src/lib.rs index 62f104d24..99c59cb0b 100644 --- a/irc/proto/src/lib.rs +++ b/irc/proto/src/lib.rs @@ -48,13 +48,16 @@ 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('#') - || target.starts_with('&') - || target.starts_with('+') - || target.starts_with('!') + target.starts_with(CHANNEL_PREFIXES) } +// Reference: https://defs.ircdocs.horse/defs/chanmembers +pub const CHANNEL_MEMBERSHIP_PREFIXES: [char; 6] = ['~', '&', '!', '@', '%', '+' ]; + #[macro_export] macro_rules! command { ($c:expr) => ( diff --git a/src/buffer/channel.rs b/src/buffer/channel.rs index da3cdcdfe..58478c28d 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(&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..ca00f826d 100644 --- a/src/buffer/input_view.rs +++ b/src/buffer/input_view.rs @@ -1,4 +1,7 @@ +use std::collections::HashMap; + use data::input::{Cache, Draft}; +use data::isupport; use data::user::{Nick, User}; use data::{client, history, Buffer, Input}; use iced::Command; @@ -22,6 +25,7 @@ pub fn view<'a>( cache: Cache<'a>, users: &'a [User], channels: &'a [String], + isupport: HashMap, buffer_focused: bool, disabled: bool, ) -> Element<'a, Message> { @@ -32,6 +36,7 @@ pub fn view<'a>( cache.history, users, channels, + isupport, buffer_focused, disabled, Message::Input, diff --git a/src/buffer/query.rs b/src/buffer/query.rs index 37fc44b69..e6a97d61a 100644 --- a/src/buffer/query.rs +++ b/src/buffer/query.rs @@ -118,6 +118,7 @@ pub fn view<'a>( input, &[], channels, + clients.get_isupport(&state.server), is_focused, !status.connected() ) diff --git a/src/buffer/server.rs b/src/buffer/server.rs index bc6732d87..956d608f0 100644 --- a/src/buffer/server.rs +++ b/src/buffer/server.rs @@ -75,6 +75,7 @@ pub fn view<'a>( input, &[], channels, + clients.get_isupport(&state.server), is_focused, !status.connected() ) 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.rs b/src/widget/input.rs index 14233df10..9668fb7e1 100644 --- a/src/widget/input.rs +++ b/src/widget/input.rs @@ -1,5 +1,7 @@ +use std::collections::HashMap; + 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 +22,7 @@ pub fn input<'a, Message>( history: &'a [String], users: &'a [User], channels: &'a [String], + isupport: HashMap, buffer_focused: bool, disabled: bool, on_input: impl Fn(input::Draft) -> Message + 'a, @@ -35,6 +38,7 @@ where input, users, channels, + isupport, history, buffer_focused, disabled, @@ -66,6 +70,7 @@ pub struct Input<'a, Message> { input: &'a str, users: &'a [User], channels: &'a [String], + isupport: HashMap, history: &'a [String], buffer_focused: bool, disabled: bool, @@ -96,7 +101,9 @@ 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); Some((self.on_input)(input::Draft { buffer: self.buffer.clone(), @@ -162,7 +169,7 @@ where .clone(); state .completion - .process(&new_input, self.users, self.channels); + .process(&new_input, self.users, self.channels, &self.isupport); return Some((self.on_completion)(input::Draft { buffer: self.buffer.clone(), @@ -182,9 +189,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, + ); new_input }; diff --git a/src/widget/input/completion.rs b/src/widget/input/completion.rs index 79cbebb78..30bc4ea6e 100644 --- a/src/widget/input/completion.rs +++ b/src/widget/input/completion.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; 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; @@ -22,11 +24,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: &HashMap, + ) { let is_command = input.starts_with('/'); if is_command { - self.commands.process(input); + self.commands.process(input, isupport); // Disallow user completions when selecting a command if matches!(self.commands, Commands::Selecting { .. }) { @@ -94,7 +102,7 @@ impl Default for Commands { } impl Commands { - fn process(&mut self, input: &str) { + fn process(&mut self, input: &str, isupport: &HashMap) { let Some((head, rest)) = input.split_once('/') else { *self = Self::Idle; return; @@ -112,18 +120,140 @@ impl Commands { (rest, false) }; + let command_list = COMMAND_LIST + .iter() + .map(|command| { + match command.title { + "AWAY" => { + if let Some(isupport::Parameter::AWAYLEN(Some(max_len))) = + isupport.get(&isupport::Kind::AWAYLEN) + { + return away_command(max_len); + } + } + "JOIN" => { + let channel_len = if let Some(isupport::Parameter::CHANNELLEN(max_len)) = + isupport.get(&isupport::Kind::CHANNELLEN) + { + Some(max_len) + } else { + None + }; + + let channel_limits = + if let Some(isupport::Parameter::CHANLIMIT(channel_limits)) = + isupport.get(&isupport::Kind::CHANLIMIT) + { + Some(channel_limits) + } else { + None + }; + + let key_len = if let Some(isupport::Parameter::KEYLEN(max_len)) = + isupport.get(&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" => { + let channel_membership_prefixes = if let Some( + isupport::Parameter::STATUSMSG(channel_membership_prefixes), + ) = + isupport.get(&isupport::Kind::STATUSMSG) + { + 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, target_limit); + } + } + "NAMES" => { + if let Some(target_limit) = find_target_limit(isupport, command.title) { + return names_command(target_limit); + } + } + "NICK" => { + if let Some(isupport::Parameter::NICKLEN(max_len)) = + isupport.get(&isupport::Kind::NICKLEN) + { + return nick_command(max_len); + } + } + "PART" => { + if let Some(isupport::Parameter::CHANNELLEN(max_len)) = + isupport.get(&isupport::Kind::CHANNELLEN) + { + return part_command(max_len); + } + } + "TOPIC" => { + if let Some(isupport::Parameter::TOPICLEN(max_len)) = + isupport.get(&isupport::Kind::TOPICLEN) + { + return topic_command(max_len); + } + } + "WHO" => { + if isupport.get(&isupport::Kind::WHOX).is_some() { + return WHOX_COMMAND.clone(); + } + } + "WHOIS" => { + if let Some(target_limit) = find_target_limit(isupport, command.title) { + return whois_command(target_limit); + } + } + _ => (), + } + + command.clone() + }) + .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)) = + isupport.get(&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, target_limit)) + } else { + Some(LIST_COMMAND.clone()) + } + } else { + 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 .to_lowercase() .starts_with(&cmd.to_lowercase()) }) - .cloned() .collect(); *self = Self::Selecting { @@ -133,10 +263,9 @@ 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() { *self = Self::Selected { command }; } else { @@ -264,13 +393,44 @@ 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 { + let tooltip_indicator = text("*") + .style(move |theme| { + if index == active_arg { + theme::text::accent(theme) + } else { + theme::text::none(theme) + } + }) + .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(row![text(" "), content]) + } }); container(row(title.into_iter().chain(args))) @@ -285,6 +445,7 @@ impl Command { struct Arg { text: &'static str, optional: bool, + tooltip: Option, } impl fmt::Display for Arg { @@ -385,10 +546,12 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "channels", optional: false, + tooltip: Some(String::from("comma-separated")), }, Arg { text: "keys", optional: true, + tooltip: Some(String::from("comma-separated")), }, ], }, @@ -397,6 +560,7 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { args: vec![Arg { text: "server", optional: true, + tooltip: None, }], }, Command { @@ -404,6 +568,7 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { args: vec![Arg { text: "nickname", optional: false, + tooltip: None, }], }, Command { @@ -411,26 +576,40 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { args: vec![Arg { text: "reason", optional: true, + tooltip: None, }], }, Command { title: "MSG", args: vec![ Arg { - text: "target", + text: "targets", optional: false, + tooltip: Some(String::from( + "comma-separated\n {user}: user directly\n{channel}: all users in channel", + )), }, Arg { text: "text", optional: false, + tooltip: None, }, ], }, Command { title: "WHOIS", args: vec![Arg { - text: "nick", + text: "nicks", optional: false, + tooltip: Some(String::from("comma-separated")), + }], + }, + Command { + title: "AWAY", + args: vec![Arg { + text: "reason", + optional: true, + tooltip: None, }], }, Command { @@ -438,6 +617,7 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { args: vec![Arg { text: "action", optional: false, + tooltip: None, }], }, Command { @@ -446,14 +626,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, }, ], }, @@ -463,10 +646,12 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "channels", optional: false, + tooltip: Some(String::from("comma-separated")), }, Arg { text: "reason", optional: true, + tooltip: None, }, ], }, @@ -476,10 +661,30 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "channel", optional: false, + tooltip: None, }, Arg { text: "topic", optional: true, + tooltip: None, + }, + ], + }, + Command { + title: "WHO", + args: vec![Arg { + text: "target", + optional: false, + tooltip: None, + }], + }, + Command { + title: "NAMES", + args: vec![ + Arg { + text: "channels", + optional: false, + tooltip: Some(String::from("comma-separated")), }, ], }, @@ -489,14 +694,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, }, ], }, @@ -506,12 +714,412 @@ static COMMAND_LIST: Lazy> = Lazy::new(|| { Arg { text: "command", optional: false, + tooltip: None, }, Arg { text: "args", optional: true, + tooltip: None, }, ], }, ] }); + +fn find_target_limit<'a>( + isupport: &'a HashMap, + command: &str, +) -> 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) + } else { + None + } +} + +fn isupport_parameter_to_command(isupport_parameter: &isupport::Parameter) -> Option { + match isupport_parameter { + 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()), + _ => 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![ + Arg { + text: "nickname", + optional: false, + tooltip: None, + }, + Arg { + text: "channel", + optional: false, + tooltip: None, + }, + Arg { + text: "message", + optional: false, + tooltip: None, + }, + ], +}); + +static CPRIVMSG_COMMAND: Lazy = Lazy::new(|| Command { + title: "CPRIVMSG", + args: vec![ + Arg { + text: "nickname", + optional: false, + tooltip: None, + }, + Arg { + text: "channel", + optional: false, + tooltip: None, + }, + Arg { + text: "message", + optional: false, + tooltip: None, + }, + ], +}); + +fn join_command( + channel_len: Option<&u16>, + channel_limits: Option<&Vec>, + key_len: Option<&u16>, +) -> Command { + let mut channels_tooltip = String::from("comma-separated"); + + if let Some(channel_len) = channel_len { + channels_tooltip.push_str(format!("\nmaximum length of each: {}", channel_len).as_str()); + } + + 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( + 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(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: Some(channels_tooltip), + }, + Arg { + text: "keys", + optional: true, + tooltip: Some(keys_tooltip), + }, + ], + } +} + +static KNOCK_COMMAND: Lazy = Lazy::new(|| Command { + title: "KNOCK", + args: vec![ + Arg { + text: "channel", + optional: false, + tooltip: None, + }, + Arg { + text: "message", + optional: true, + tooltip: None, + }, + ], +}); + +static LIST_COMMAND: Lazy = Lazy::new(|| Command { + title: "LIST", + args: vec![Arg { + text: "channels", + optional: true, + tooltip: Some(String::from("comma-separated")), + }], +}); + +fn list_command( + search_extensions: Option<&String>, + target_limit: Option<&isupport::CommandTargetLimit>, +) -> 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", + 'T' => { + "\n T<{#}: topic changed < # min ago\n T>{#}: topic changed > # min ago" + } + 'U' => "\n <{#}: fewer than # users\n >{#}: more than # users", + _ => "", + } + }, + ); + + 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(channels_tooltip), + }], + } + } +} + +fn msg_command( + 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", + ); + + 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: "targets", + optional: false, + tooltip: Some(targets_tooltip), + }, + Arg { + text: "text", + optional: false, + tooltip: None, + }, + ], + } +} + +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", + args: vec![Arg { + text: "nickname", + optional: false, + tooltip: Some(format!("maximum length: {}", max_len)), + }], + } +} + +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", + 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 { + text: "nickname", + optional: false, + tooltip: None, + }], +}); + +static WHOX_COMMAND: Lazy = Lazy::new(|| Command { + title: "WHO", + args: vec![ + 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")), + }, + ], +}); + +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), + }], + } +}