diff --git a/CHANGELOG.md b/CHANGELOG.md index 1244e7955..c8b9db0b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ Added: - Configuration option to enable a topic banner in channels. This can be enabled under `buffer.channel.topic` - Messages typed into the input will persist until sent. Typed messages are saved when switching a pane to another buffer, then are restored when that buffer is returned to. +- Added display of highest access level in front of nicks in chat, mirroring the format in the nick list +- Added ability to toggle Op and Voice from user context menu in channels Fix: diff --git a/data/src/client.rs b/data/src/client.rs index cb3411284..5570d4543 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -540,7 +540,9 @@ impl Client { if victim == self.nickname().as_ref() { self.chanmap.remove(channel); } else if let Some(channel) = self.chanmap.get_mut(channel) { - channel.users.remove(&User::from(Nick::from(victim.as_str()))); + channel + .users + .remove(&User::from(Nick::from(victim.as_str()))); } } Command::Numeric(RPL_WHOREPLY, args) => { @@ -708,7 +710,13 @@ impl Client { self.chanmap.get(channel).map(|channel| &channel.topic) } - fn users<'a>(&'a self, channel: &str) -> &'a [User] { + fn resolve_user_attributes<'a>(&'a self, channel: &str, user: &User) -> Option<&'a User> { + self.chanmap + .get(channel) + .and_then(|channel| channel.users.get(user)) + } + + pub fn users<'a>(&'a self, channel: &str) -> &'a [User] { self.users .get(channel) .map(Vec::as_slice) @@ -854,6 +862,16 @@ impl Map { } } + pub fn resolve_user_attributes<'a>( + &'a self, + server: &Server, + channel: &str, + user: &User, + ) -> Option<&'a User> { + self.client(server) + .and_then(|client| client.resolve_user_attributes(channel, user)) + } + pub fn get_channel_users<'a>(&'a self, server: &Server, channel: &str) -> &'a [User] { self.client(server) .map(|client| client.users(channel)) diff --git a/data/src/command.rs b/data/src/command.rs index b446d4395..13072ddfe 100644 --- a/data/src/command.rs +++ b/data/src/command.rs @@ -17,6 +17,7 @@ pub enum Kind { Part, Topic, Kick, + Mode, Raw, } @@ -35,6 +36,7 @@ impl FromStr for Kind { "part" => Ok(Kind::Part), "topic" => Ok(Kind::Topic), "kick" => Ok(Kind::Kick), + "mode" => Ok(Kind::Mode), "raw" => Ok(Kind::Raw), _ => Err(()), } @@ -53,6 +55,7 @@ pub enum Command { Part(String, Option), Topic(String, Option), Kick(String, String, Option), + Mode(String, Option, Vec), Raw(String, Vec), Unknown(String, Vec), } @@ -107,6 +110,16 @@ pub fn parse(s: &str, buffer: Option<&Buffer>) -> Result { Kind::Kick => validated::<2, 1, true>(args, |[channel, user], [comment]| { Command::Kick(channel, user, comment) }), + Kind::Mode => { + let (channel, rest) = args.split_first().ok_or(Error::MissingCommand)?; + let (mode, users) = rest.split_first().ok_or(Error::MissingCommand)?; + + Ok(Command::Mode( + channel.to_string(), + Some(mode.to_string()), + users.iter().map(|s| s.to_string()).collect(), + )) + } Kind::Raw => { let (cmd, args) = args.split_first().ok_or(Error::MissingCommand)?; @@ -176,6 +189,7 @@ impl TryFrom for proto::Command { Command::Part(chanlist, reason) => proto::Command::PART(chanlist, reason), Command::Topic(channel, topic) => proto::Command::TOPIC(channel, topic), Command::Kick(channel, user, comment) => proto::Command::KICK(channel, user, comment), + Command::Mode(channel, mode, users) => proto::Command::MODE(channel, mode, users), Command::Raw(command, args) => proto::Command::Unknown(command, args), Command::Unknown(command, args) => proto::Command::new(&command, args), }) diff --git a/data/src/history/manager.rs b/data/src/history/manager.rs index 70976b5ef..436b7b3e0 100644 --- a/data/src/history/manager.rs +++ b/data/src/history/manager.rs @@ -10,7 +10,7 @@ use crate::config::buffer::Exclude; use crate::history::{self, History}; use crate::message::{self, Limit}; use crate::time::Posix; -use crate::user::{Nick, NickRef}; +use crate::user::Nick; use crate::{config, input}; use crate::{server, Buffer, Config, Input, Server, User}; @@ -167,8 +167,8 @@ impl Manager { } } - pub fn record_input(&mut self, input: Input, our_nick: NickRef) { - if let Some(message) = input.message(our_nick) { + pub fn record_input(&mut self, input: Input, user: User) { + if let Some(message) = input.message(user) { self.record_message(input.server(), message); } diff --git a/data/src/input.rs b/data/src/input.rs index 4959382d3..9aeea7e39 100644 --- a/data/src/input.rs +++ b/data/src/input.rs @@ -5,7 +5,6 @@ use irc::proto; use irc::proto::format; use crate::time::Posix; -use crate::user::NickRef; use crate::{command, message, Buffer, Command, Message, Server, User}; const INPUT_HISTORY_LENGTH: usize = 100; @@ -56,7 +55,7 @@ impl Input { self.buffer.server() } - pub fn message(&self, our_nick: NickRef) -> Option { + pub fn message(&self, user: User) -> Option { let to_target = |target: String, source| { if proto::is_channel(&target) { Some(message::Target::Channel { @@ -80,10 +79,7 @@ impl Input { received_at: Posix::now(), server_time: Utc::now(), direction: message::Direction::Sent, - target: to_target( - target, - message::Source::User(User::from(our_nick.to_owned())), - )?, + target: to_target(target, message::Source::User(user))?, text, }), Command::Me(target, action) => Some(Message { @@ -91,7 +87,7 @@ impl Input { server_time: Utc::now(), direction: message::Direction::Sent, target: to_target(target, message::Source::Action)?, - text: message::action_text(our_nick, &action), + text: message::action_text(user.nickname(), &action), }), _ => None, } diff --git a/data/src/message.rs b/data/src/message.rs index ab6ee14ee..fdb90ab2c 100644 --- a/data/src/message.rs +++ b/data/src/message.rs @@ -91,10 +91,15 @@ impl Message { && matches!(self.target.source(), Source::User(_) | Source::Action) } - pub fn received(encoded: Encoded, our_nick: Nick, config: &Config) -> Option { + pub fn received( + encoded: Encoded, + our_nick: Nick, + config: &Config, + resolve_attributes: impl Fn(&User, &str) -> Option, + ) -> Option { let server_time = server_time(&encoded); - let text = text(&encoded, &our_nick, config)?; - let target = target(encoded, &our_nick)?; + let text = text(&encoded, &our_nick, config, &resolve_attributes)?; + let target = target(encoded, &our_nick, &resolve_attributes)?; Some(Message { received_at: Posix::now(), @@ -110,7 +115,11 @@ impl Message { } } -fn target(message: Encoded, our_nick: &Nick) -> Option { +fn target( + message: Encoded, + our_nick: &Nick, + resolve_attributes: &dyn Fn(&User, &str) -> Option, +) -> Option { use proto::command::Numeric::*; let user = message.user(); @@ -176,10 +185,13 @@ fn target(message: Encoded, our_nick: &Nick) -> Option { }; match (proto::is_channel(&target), user) { - (true, Some(user)) => Some(Target::Channel { - channel: target, - source: source(user), - }), + (true, Some(user)) => { + let source = source(resolve_attributes(&user, &target).unwrap_or(user)); + Some(Target::Channel { + channel: target, + source, + }) + } (false, Some(user)) => { let target = User::try_from(target.as_str()).ok()?; @@ -202,10 +214,13 @@ fn target(message: Encoded, our_nick: &Nick) -> Option { }; match (proto::is_channel(&target), user) { - (true, Some(user)) => Some(Target::Channel { - channel: target, - source: source(user), - }), + (true, Some(user)) => { + let source = source(resolve_attributes(&user, &target).unwrap_or(user)); + Some(Target::Channel { + channel: target, + source, + }) + } (false, Some(user)) => { let target = User::try_from(target.as_str()).ok()?; @@ -274,19 +289,29 @@ pub fn server_time(message: &Encoded) -> DateTime { .unwrap_or_else(Utc::now) } -fn text(message: &Encoded, our_nick: &Nick, config: &Config) -> Option { +fn text( + message: &Encoded, + our_nick: &Nick, + config: &Config, + resolve_attributes: &dyn Fn(&User, &str) -> Option, +) -> Option { use irc::proto::command::Numeric::*; - let user = message.user(); match &message.command { - Command::TOPIC(_, topic) => { - let user = user?; + Command::TOPIC(target, topic) => { + let raw_user = message.user()?; + let user = resolve_attributes(&raw_user, target).unwrap_or(raw_user); + let topic = topic.as_ref()?; Some(format!(" ∙ {user} changed topic to {topic}")) } - Command::PART(_, text) => { - let user = user?.formatted(config.buffer.server_messages.part.username_format); + Command::PART(target, text) => { + let raw_user = message.user()?; + let user = resolve_attributes(&raw_user, target) + .unwrap_or(raw_user) + .formatted(config.buffer.server_messages.part.username_format); + let text = text .as_ref() .map(|text| format!(" ({text})")) @@ -294,8 +319,9 @@ fn text(message: &Encoded, our_nick: &Nick, config: &Config) -> Option { Some(format!("⟵ {user} has left the channel{text}")) } - Command::JOIN(_, _) => { - let user = user?; + Command::JOIN(target, _) => { + let raw_user = message.user()?; + let user = resolve_attributes(&raw_user, target).unwrap_or(raw_user); (user.nickname() != *our_nick).then(|| { format!( @@ -304,8 +330,10 @@ fn text(message: &Encoded, our_nick: &Nick, config: &Config) -> Option { ) }) } - Command::KICK(_, victim, comment) => { - let user = user?; + Command::KICK(channel, victim, comment) => { + let raw_user = message.user()?; + let user = resolve_attributes(&raw_user, channel).unwrap_or(raw_user); + let comment = comment .as_ref() .map(|comment| format!(" ({comment})")) @@ -319,7 +347,9 @@ fn text(message: &Encoded, our_nick: &Nick, config: &Config) -> Option { Some(format!("⟵ {target} been kicked by {user}{comment}")) } Command::MODE(target, modes, args) if proto::is_channel(target) => { - let user = user?; + let raw_user = message.user()?; + let user = resolve_attributes(&raw_user, target).unwrap_or(raw_user); + let modes = modes .iter() .map(|mode| mode.to_string()) @@ -336,7 +366,7 @@ fn text(message: &Encoded, our_nick: &Nick, config: &Config) -> Option { } Command::PRIVMSG(_, text) => { // Check if a synthetic action message - if let Some(nick) = user.as_ref().map(User::nickname) { + if let Some(nick) = message.user().as_ref().map(User::nickname) { if let Some(action) = parse_action(nick, text) { return Some(action); } diff --git a/data/src/user.rs b/data/src/user.rs index a2cf02001..7650a7c9d 100644 --- a/data/src/user.rs +++ b/data/src/user.rs @@ -155,6 +155,10 @@ impl User { .unwrap_or(AccessLevel::Member) } + pub fn has_access_level(&self, access_level: AccessLevel) -> bool { + self.access_levels.get(&access_level).is_some() + } + pub fn update_access_level(&mut self, operation: mode::Operation, mode: mode::Channel) { if let Ok(level) = AccessLevel::try_from(mode) { match operation { @@ -203,7 +207,7 @@ impl From for User { impl fmt::Display for User { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.nickname()) + write!(f, "{}{}", self.highest_access_level(), self.nickname()) } } @@ -279,7 +283,7 @@ impl<'a> PartialEq for NickRef<'a> { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum AccessLevel { Member, Voice, diff --git a/src/buffer.rs b/src/buffer.rs index 8491176ff..26d2ee3a8 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -1,5 +1,6 @@ pub use data::buffer::Settings; -use data::{buffer, history, Config, User}; +use data::user::Nick; +use data::{buffer, history, Config}; use iced::Command; use self::channel::Channel; @@ -154,7 +155,7 @@ impl Buffer { pub fn insert_user_to_input( &mut self, - user: User, + nick: Nick, history: &mut history::Manager, ) -> Command { if let Some(buffer) = self.data() { @@ -162,11 +163,11 @@ impl Buffer { Buffer::Empty | Buffer::Server(_) => Command::none(), Buffer::Channel(channel) => channel .input_view - .insert_user(user, buffer, history) + .insert_user(nick, buffer, history) .map(|message| Message::Channel(channel::Message::InputView(message))), Buffer::Query(query) => query .input_view - .insert_user(user, buffer, history) + .insert_user(nick, buffer, history) .map(|message| Message::Query(query::Message::InputView(message))), } } else { diff --git a/src/buffer/channel.rs b/src/buffer/channel.rs index 6ad7e301d..7cd203e64 100644 --- a/src/buffer/channel.rs +++ b/src/buffer/channel.rs @@ -57,11 +57,12 @@ pub fn view<'a>( theme::selectable_text::nickname( theme, user.color_seed(&config.buffer.nickname.color), - false, + user.is_away(), ) }, ), - user.clone(), + user, + state.buffer(), ) .map(scroll_view::Message::UserContext); @@ -123,7 +124,7 @@ pub fn view<'a>( .any(|c| c == &state.channel); let users = clients.get_channel_users(&state.server, &state.channel); let channels = clients.get_channels(&state.server); - let nick_list = nick_list::view(users, config).map(Message::UserContext); + let nick_list = nick_list::view(users, &buffer, config).map(Message::UserContext); let show_text_input = match config.buffer.input_visibility { data::buffer::InputVisibility::Focused => is_focused, @@ -134,7 +135,7 @@ pub fn view<'a>( // so produce a zero-height placeholder when topic is None. let topic = topic(state, clients, users, settings, config).unwrap_or_else(|| column![].into()); - let text_input = show_text_input.then(|| { + let text_input = show_text_input.then(move || { input_view::view( &state.input_view, buffer, @@ -263,6 +264,7 @@ fn topic<'a>( topic.time.as_ref(), config.buffer.channel.topic.max_lines, users, + &state.buffer(), config, ) .map(Message::UserContext), @@ -270,7 +272,7 @@ fn topic<'a>( } mod nick_list { - use data::{Config, User}; + use data::{Buffer, Config, User}; use iced::widget::{column, container, scrollable, text, Scrollable}; use iced::Length; use user_context::Message; @@ -279,14 +281,13 @@ mod nick_list { use crate::theme; use crate::widget::Element; - pub fn view<'a>(users: &'a [User], config: &'a Config) -> Element<'a, Message> { + pub fn view<'a>( + users: &'a [User], + buffer: &Buffer, + config: &'a Config, + ) -> Element<'a, Message> { let column = column(users.iter().map(|user| { - let content = text(format!( - "{}{}", - user.highest_access_level(), - user.nickname() - )) - .style(|theme| { + let content = text(user).style(|theme| { theme::text::nickname( theme, user.color_seed(&config.buffer.channel.users.color), @@ -294,7 +295,7 @@ mod nick_list { ) }); - user_context::view(content, user.clone()) + user_context::view(content, user, buffer.clone()) })) .padding(4) .spacing(1); diff --git a/src/buffer/channel/topic.rs b/src/buffer/channel/topic.rs index 1e804c95e..cb3b07f6e 100644 --- a/src/buffer/channel/topic.rs +++ b/src/buffer/channel/topic.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; use data::user::Nick; -use data::{Config, User}; +use data::{Buffer, Config, User}; use iced::widget::{column, container, horizontal_rule, row, scrollable, Scrollable}; use iced::Length; @@ -14,6 +14,7 @@ pub fn view<'a>( time: Option<&'a DateTime>, max_lines: u16, users: &'a [User], + buffer: &Buffer, config: &'a Config, ) -> Element<'a, user_context::Message> { let set_by = who.and_then(|who| { @@ -28,20 +29,25 @@ pub fn view<'a>( false, ) }), - user.clone(), + user, + buffer.clone(), ) } else { - selectable_text(who).style(theme::selectable_text::info).into() + selectable_text(who) + .style(theme::selectable_text::info) + .into() }; Some(row![ selectable_text("set by ").style(theme::selectable_text::transparent), user, - selectable_text(format!(" at {}", time?.to_rfc2822())).style(theme::selectable_text::transparent), + selectable_text(format!(" at {}", time?.to_rfc2822())) + .style(theme::selectable_text::transparent), ]) }); - let content = column![selectable_text(text).style(theme::selectable_text::transparent)].push_maybe(set_by); + let content = column![selectable_text(text).style(theme::selectable_text::transparent)] + .push_maybe(set_by); let scrollable = Scrollable::with_direction_and_style( container(content).width(Length::Fill).padding(padding()), diff --git a/src/buffer/input_view.rs b/src/buffer/input_view.rs index 3a9eafbfd..b477fc075 100644 --- a/src/buffer/input_view.rs +++ b/src/buffer/input_view.rs @@ -1,5 +1,5 @@ use data::input::{Cache, Draft}; -use data::user::User; +use data::user::{Nick, User}; use data::{client, history, Buffer, Input}; use iced::Command; @@ -76,7 +76,18 @@ impl State { } if let Some(nick) = clients.nickname(input.server()) { - history.record_input(input, nick); + let mut user = nick.to_owned().into(); + + // Resolve our attributes if sending this message in a channel + if let Buffer::Channel(server, channel) = input.buffer() { + if let Some(user_with_attributes) = + clients.resolve_user_attributes(server, channel, &user) + { + user = user_with_attributes.clone(); + } + } + + history.record_input(input, user); } (Command::none(), Some(Event::InputSent)) @@ -99,18 +110,18 @@ impl State { pub fn insert_user( &mut self, - user: User, + nick: Nick, buffer: Buffer, history: &mut history::Manager, ) -> Command { let mut text = history.input(&buffer).draft.to_string(); if text.is_empty() { - text = format!("{}: ", user.nickname()); + text = format!("{}: ", nick); } else if text.ends_with(' ') { - text = format!("{}{}", text, user.nickname()); + text = format!("{}{}", text, nick); } else { - text = format!("{} {}", text, user.nickname()); + text = format!("{} {}", text, nick); } history.record_draft(Draft { buffer, text }); diff --git a/src/buffer/query.rs b/src/buffer/query.rs index b1abffb78..b2d8e3ee4 100644 --- a/src/buffer/query.rs +++ b/src/buffer/query.rs @@ -56,7 +56,8 @@ pub fn view<'a>( ) }, ), - user.clone(), + user, + state.buffer(), ) .map(scroll_view::Message::UserContext); diff --git a/src/buffer/user_context.rs b/src/buffer/user_context.rs index 46185be56..278b3be94 100644 --- a/src/buffer/user_context.rs +++ b/src/buffer/user_context.rs @@ -1,4 +1,5 @@ -use data::User; +use data::user::Nick; +use data::{Buffer, User}; use iced::widget::{button, text}; use crate::theme; @@ -8,48 +9,93 @@ use crate::widget::{context_menu, Element}; enum Entry { Whois, Query, + ToggleAccessLevelOp, + ToggleAccessLevelVoice, } impl Entry { - fn list() -> Vec { - vec![Entry::Whois, Entry::Query] + fn list(buffer: &Buffer) -> Vec { + match buffer { + Buffer::Channel(_, _) => vec![ + Entry::Whois, + Entry::Query, + Entry::ToggleAccessLevelOp, + Entry::ToggleAccessLevelVoice, + ], + Buffer::Server(_) | Buffer::Query(_, _) => vec![Entry::Whois], + } } } #[derive(Clone, Debug)] pub enum Message { - Whois(User), - Query(User), - SingleClick(User), + Whois(Nick), + Query(Nick), + SingleClick(Nick), + ToggleAccessLevel(Nick, String), } #[derive(Debug, Clone)] pub enum Event { - SendWhois(User), - OpenQuery(User), - SingleClick(User), + SendWhois(Nick), + OpenQuery(Nick), + SingleClick(Nick), + ToggleAccessLevel(Nick, String), } pub fn update(message: Message) -> Event { match message { - Message::Whois(user) => Event::SendWhois(user), - Message::Query(user) => Event::OpenQuery(user), - Message::SingleClick(user) => Event::SingleClick(user), + Message::Whois(nick) => Event::SendWhois(nick), + Message::Query(nick) => Event::OpenQuery(nick), + Message::SingleClick(nick) => Event::SingleClick(nick), + Message::ToggleAccessLevel(nick, mode) => Event::ToggleAccessLevel(nick, mode), } } -pub fn view<'a>(content: impl Into>, user: User) -> Element<'a, Message> { - let entries = Entry::list(); +pub fn view<'a>( + content: impl Into>, + user: &'a User, + buffer: Buffer, +) -> Element<'a, Message> { + let entries = Entry::list(&buffer); let content = button(content) .padding(0) .style(theme::button::bare) - .on_press(Message::SingleClick(user.clone())); + .on_press(Message::SingleClick(user.nickname().to_owned())); context_menu(content, entries, move |entry, length| { + let nickname = user.nickname().to_owned(); + let (content, message) = match entry { - Entry::Whois => ("Whois", Message::Whois(user.clone())), - Entry::Query => ("Message", Message::Query(user.clone())), + Entry::Whois => ("Whois", Message::Whois(nickname)), + Entry::Query => ("Message", Message::Query(nickname)), + Entry::ToggleAccessLevelOp => { + if user.has_access_level(data::user::AccessLevel::Oper) { + ( + "Take Op (-o)", + Message::ToggleAccessLevel(nickname, "-o".to_owned()), + ) + } else { + ( + "Give Op (+o)", + Message::ToggleAccessLevel(nickname, "+o".to_owned()), + ) + } + } + Entry::ToggleAccessLevelVoice => { + if user.has_access_level(data::user::AccessLevel::Voice) { + ( + "Take Voice (-v)", + Message::ToggleAccessLevel(nickname, "-v".to_owned()), + ) + } else { + ( + "Give Voice (+v)", + Message::ToggleAccessLevel(nickname, "+v".to_owned()), + ) + } + } }; button(text(content).style(theme::text::primary)) diff --git a/src/main.rs b/src/main.rs index 4f465f386..8b8dbdf19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use std::env; use std::time::{Duration, Instant}; use data::config::{self, Config}; -use data::{environment, server}; +use data::{environment, server, User}; use iced::widget::container; use iced::{executor, Application, Command, Length, Subscription}; use screen::{dashboard, help, welcome}; @@ -319,18 +319,31 @@ impl Application for Halloy { messages.into_iter().for_each(|message| { for event in self.clients.receive(&server, message) { + // Resolve a user using client state which stores attributes + let resolve_user_attributes = |user: &User, channel: &str| { + self.clients + .resolve_user_attributes(&server, channel, user) + .cloned() + }; + match event { data::client::Event::Single(encoded, our_nick) => { - if let Some(message) = - data::Message::received(encoded, our_nick, &self.config) - { + if let Some(message) = data::Message::received( + encoded, + our_nick, + &self.config, + resolve_user_attributes, + ) { dashboard.record_message(&server, message); } } data::client::Event::WithTarget(encoded, our_nick, target) => { - if let Some(message) = - data::Message::received(encoded, our_nick, &self.config) - { + if let Some(message) = data::Message::received( + encoded, + our_nick, + &self.config, + resolve_user_attributes, + ) { dashboard .record_message(&server, message.with_target(target)); } @@ -387,9 +400,12 @@ impl Application for Halloy { our_nick, notification, ) => { - if let Some(message) = - data::Message::received(encoded, our_nick, &self.config) - { + if let Some(message) = data::Message::received( + encoded, + our_nick, + &self.config, + resolve_user_attributes, + ) { dashboard.record_message(&server, message); } diff --git a/src/screen/dashboard.rs b/src/screen/dashboard.rs index 761bce7d7..c0e40afda 100644 --- a/src/screen/dashboard.rs +++ b/src/screen/dashboard.rs @@ -110,42 +110,71 @@ impl Dashboard { if let Some(buffer::Event::UserContext(event)) = event { match event { - buffer::user_context::Event::SendWhois(user) => { + buffer::user_context::Event::ToggleAccessLevel(nick, mode) => { + let Some(buffer) = pane.buffer.data() else { + return Command::none(); + }; + + let Some(target) = buffer.target() else { + return Command::none(); + }; + + let command = data::Command::Mode( + target, + Some(mode), + vec![nick.to_string()], + ); + let input = data::Input::command(buffer.clone(), command); + + if let Some(encoded) = input.encoded() { + clients.send(input.buffer(), encoded); + } + } + buffer::user_context::Event::SendWhois(nick) => { if let Some(buffer) = pane.buffer.data() { - let command = - data::Command::Whois(None, user.nickname().to_string()); + let command = data::Command::Whois(None, nick.to_string()); - let input = data::Input::command(buffer, command); + let input = data::Input::command(buffer.clone(), command); if let Some(encoded) = input.encoded() { clients.send(input.buffer(), encoded); } - if let Some(message) = clients - .nickname(input.server()) - .and_then(|nick| input.message(nick)) - { - self.history.record_message(input.server(), message); + if let Some(nick) = clients.nickname(buffer.server()) { + let mut user = nick.to_owned().into(); + + // Resolve our attributes if sending this message in a channel + if let data::Buffer::Channel(server, channel) = &buffer + { + if let Some(user_with_attributes) = clients + .resolve_user_attributes(server, channel, &user) + { + user = user_with_attributes.clone(); + } + } + + if let Some(message) = input.message(user) { + self.history + .record_message(input.server(), message); + } } } } - buffer::user_context::Event::OpenQuery(user) => { + buffer::user_context::Event::OpenQuery(nick) => { if let Some(data) = pane.buffer.data() { - let buffer = data::Buffer::Query( - data.server().clone(), - user.nickname().to_owned(), - ); + let buffer = + data::Buffer::Query(data.server().clone(), nick); return self.open_buffer(buffer, config); } } - buffer::user_context::Event::SingleClick(user) => { + buffer::user_context::Event::SingleClick(nick) => { let Some((_, pane, history)) = self.get_focused_with_history_mut() else { return Command::none(); }; - return pane.buffer.insert_user_to_input(user, history).map( + return pane.buffer.insert_user_to_input(nick, history).map( move |message| { Message::Pane(pane::Message::Buffer(id, message)) },