Repository: osa1/tiny (language: rust)

Issue:
Channels/nicks need to be case-folded on match
A PRIVMSG received for "#MyChan" and #mychan" must go to the same buffer, as if they were both sent to "#mychan".

This is necessary due to servers' performing this case folding when reading such messages. Note that IRC only does ASCII case-folding, not Unicode case-folding. Unicode case-folding will *not* work. See the discussion here: https://todo.sr.ht/~emersion/soju/52.


Discussion / hints:
Thanks for reporting. I'm not sure that this is a problem. We only create a channel tab based on the server's `JOIN` response. For example, if I send `JOIN #MyChan` to irc.oftc.net this is the `JOIN` response I get from the server:

```
:osa1_!~osa1@... JOIN :#MyChan
```

Note that the channel name is not case folded. We just create a channel with the name used in the server's response.

Here's something I tried: I connect to irc.oftc.net in two clients (both tiny), join #mychan first with one, and then join #MyChan with the other. If I do that than the second client gets this response:

```
:osa1__!~osa1@... :#mychan
```

Note that this one is case-folded.

In all cases I end up connecting to the right channel and both clients were able to communicate in the same channel. I also tried first creating `MyChan` and then joining `mychan`, and that also worked as expected.

So I think we handle this case correctly by waiting for server response before creating a tab. Am I missing anything?
Sorry, I should have provided some more data at the beginning.

Reproduction:
1. Connect tiny, and KiwiIRC to irc.geekshed.net
2. Create a channel, e.g. "#tinytestchan", and join from both clients
3. From KiwiIRC: `/privmsg #TinyTestChan hi`
4. Observe that tiny creates a new buffer named #TinyTestChan where this message is received
5. Write a message in both buffers in tiny
6. Observe that KiwiIRC receives both messages in the same buffer

Conclusions:
- The IRC server considers #tinytestchan and #TinyTestChan to be the same channel.
- The IRC server forwards the casing provided by the client, leading to PRIVMSG's for both #tinytestchan and #TinyTestChan.
- Kiwi normalizes this correctly, while tiny creates new buffers with each containing only part of the channel conversation.

To fix this, tiny must case-fold channel names during any comparisons. Note that unicode folding *must not* be used. I believe this also applies to nicks, but I have not looked into the detals for that (and as I assume it only affects mentions, it's also less important).
Thanks for the example. You're right that this is a bug in tiny. RFC 2812 clearly states in section 1.3 that channel names are case insensitive.

IRC is an ASCII-based protocol so I'd expect unicode characters in channel and nick names to be rejected, but it seems like most servers (e.g. geekshed, OFTC) accept unicode in channel names but not in nicks.. sigh.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 8b4da7f3..0f4ae9b3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -394,6 +394,7 @@ dependencies = [
  "base64",
  "futures",
  "lazy_static",
+ "libtiny_common",
  "libtiny_logger",
  "libtiny_wire",
  "log",
@@ -404,10 +405,15 @@ dependencies = [
  "tokio-tls",
 ]
 
+[[package]]
+name = "libtiny_common"
+version = "0.1.0"
+
 [[package]]
 name = "libtiny_logger"
 version = "0.1.0"
 dependencies = [
+ "libtiny_common",
  "libtiny_ui",
  "log",
  "time",
@@ -420,6 +426,7 @@ dependencies = [
  "bencher",
  "futures",
  "libc",
+ "libtiny_common",
  "libtiny_ui",
  "log",
  "mio",
@@ -437,6 +444,7 @@ dependencies = [
 name = "libtiny_ui"
 version = "0.1.0"
 dependencies = [
+ "libtiny_common",
  "objekt",
  "time",
 ]
@@ -444,6 +452,9 @@ dependencies = [
 [[package]]
 name = "libtiny_wire"
 version = "0.1.0"
+dependencies = [
+ "libtiny_common",
+]
 
 [[package]]
 name = "linked-hash-map"
@@ -1099,6 +1110,7 @@ dependencies = [
  "env_logger",
  "futures",
  "libtiny_client",
+ "libtiny_common",
  "libtiny_logger",
  "libtiny_tui",
  "libtiny_ui",
diff --git a/Cargo.toml b/Cargo.toml
index 496ed26a..905eff16 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,7 @@
 [workspace]
 members = [
     "libtiny_client",
+    "libtiny_common",
     "libtiny_logger",
     "libtiny_tui",
     "libtiny_ui",
diff --git a/libtiny_client/Cargo.toml b/libtiny_client/Cargo.toml
index 89773711..b3a5d87a 100644
--- a/libtiny_client/Cargo.toml
+++ b/libtiny_client/Cargo.toml
@@ -14,6 +14,7 @@ tls-rustls = ["rustls-native-certs", "tokio-rustls"]
 base64 = "0.12"
 futures = { version = "0.3.1", default-features = false, features = ["std", "async-await"] }
 lazy_static = "1.4.0"
+libtiny_common = { path = "../libtiny_common" }
 libtiny_logger = { path = "../libtiny_logger" }
 libtiny_wire = { path = "../libtiny_wire" }
 log = "0.4"
diff --git a/libtiny_client/examples/echo.rs b/libtiny_client/examples/echo.rs
index d4634c2e..b42ceba7 100644
--- a/libtiny_client/examples/echo.rs
+++ b/libtiny_client/examples/echo.rs
@@ -1,6 +1,7 @@
 //! An echo bot that just repeats stuff sent to it (either in a channel or as PRIVMSG).
 
 use libtiny_client::{Client, Event, ServerInfo};
+use libtiny_common::ChanNameRef;
 use libtiny_wire::{Cmd, Msg, MsgTarget, Pfx};
 
 use futures::stream::StreamExt;
@@ -26,7 +27,10 @@ fn main() {
         }
     };
 
-    let chans = &args_vec[1..];
+    let chans = args_vec[1..]
+        .iter()
+        .map(|c| ChanNameRef::new(c).to_owned())
+        .collect::<Vec<_>>();
 
     let server_info = ServerInfo {
         addr: server,
@@ -83,7 +87,7 @@ async fn echo_bot_task(server_info: ServerInfo) {
                                 break;
                             }
                         }
-                        Some((chan, msg.to_owned()))
+                        Some((chan.display().to_owned(), msg.to_owned()))
                     } else {
                         None
                     }
diff --git a/libtiny_client/src/lib.rs b/libtiny_client/src/lib.rs
index 946875f3..f0bb6ee1 100644
--- a/libtiny_client/src/lib.rs
+++ b/libtiny_client/src/lib.rs
@@ -7,6 +7,7 @@ mod state;
 mod stream;
 mod utils;
 
+use libtiny_common::{ChanName, ChanNameRef};
 pub use libtiny_wire as wire;
 
 use pinger::Pinger;
@@ -51,7 +52,7 @@ pub struct ServerInfo {
     pub nicks: Vec<String>,
 
     /// Channels to automatically join
-    pub auto_join: Vec<String>,
+    pub auto_join: Vec<ChanName>,
 
     /// Nickserv password. Sent to NickServ on connecting to the server and nick change, before
     /// join commands.
@@ -100,7 +101,7 @@ pub enum Event {
     /// A wire-protocol error
     WireError(String),
     /// Channel join error message
-    ChannelJoinError { chan: String, msg: String },
+    ChannelJoinError { chan: ChanName, msg: String },
 }
 
 impl From<StreamError> for Event {
@@ -213,14 +214,14 @@ impl Client {
     }
 
     /// Join the given list of channels.
-    pub fn join(&mut self, chans: &[&str]) {
+    pub fn join(&mut self, chans: &[&ChanNameRef]) {
         self.msg_chan
             .try_send(Cmd::Msg(wire::join(&chans)))
             .unwrap()
     }
 
     /// Leave a channel.
-    pub fn part(&mut self, chan: &str) {
+    pub fn part(&mut self, chan: &ChanNameRef) {
         self.state.leave_channel(&mut self.msg_chan, chan)
     }
 
@@ -249,7 +250,7 @@ impl Client {
     }
 
     /// Get all nicks in a channel.
-    pub fn get_chan_nicks(&self, chan: &str) -> Vec<String> {
+    pub fn get_chan_nicks(&self, chan: &ChanNameRef) -> Vec<String> {
         self.state.get_chan_nicks(chan)
     }
 }
diff --git a/libtiny_client/src/state.rs b/libtiny_client/src/state.rs
index dba0cc5a..a3c1f95b 100644
--- a/libtiny_client/src/state.rs
+++ b/libtiny_client/src/state.rs
@@ -2,6 +2,7 @@
 
 use crate::utils;
 use crate::{Cmd, Event, ServerInfo};
+use libtiny_common::{ChanName, ChanNameRef};
 use libtiny_wire as wire;
 use libtiny_wire::{Msg, Pfx};
 
@@ -63,11 +64,11 @@ impl State {
         self.inner.borrow_mut().away_status = msg.map(str::to_owned);
     }
 
-    pub(crate) fn get_chan_nicks(&self, chan: &str) -> Vec<String> {
+    pub(crate) fn get_chan_nicks(&self, chan: &ChanNameRef) -> Vec<String> {
         self.inner.borrow().get_chan_nicks(chan)
     }
 
-    pub(crate) fn leave_channel(&self, msg_chan: &mut Sender<Cmd>, chan: &str) {
+    pub(crate) fn leave_channel(&self, msg_chan: &mut Sender<Cmd>, chan: &ChanNameRef) {
         self.inner.borrow_mut().leave_channel(msg_chan, chan)
     }
 
@@ -123,7 +124,7 @@ struct StateInner {
 #[derive(Debug)]
 struct Chan {
     /// Name of the channel
-    name: String,
+    name: ChanName,
     /// Set of nicknames in channel
     nicks: HashSet<String>,
     /// Channel joined state
@@ -155,7 +156,7 @@ enum JoinState {
 const MAX_JOIN_RETRIES: u8 = 3;
 
 impl Chan {
-    fn new(name: String) -> Chan {
+    fn new(name: ChanName) -> Chan {
         Chan {
             name,
             nicks: HashSet::new(),
@@ -164,7 +165,7 @@ impl Chan {
         }
     }
 
-    fn with_nicks(name: String, nicks: HashSet<String>) -> Chan {
+    fn with_nicks(name: ChanName, nicks: HashSet<String>) -> Chan {
         Chan {
             name,
             nicks,
@@ -338,7 +339,7 @@ impl StateInner {
                     if nick == &self.current_nick {
                         match utils::find_idx(&self.chans, |c| &c.name == chan) {
                             None => {
-                                debug!("Can't find channel state: {}", chan);
+                                debug!("Can't find channel state: {}", chan.display());
                             }
                             Some(chan_idx) => {
                                 self.chans.remove(chan_idx);
@@ -392,6 +393,7 @@ impl StateInner {
             Reply { num: 477, params } => {
                 // Only try to automatically rejoin if nickserv_ident is configured
                 if let (Some(channel), Some(msg_477)) = (params.get(1), params.get(2)) {
+                    let channel = ChanNameRef::new(channel);
                     snd_ev
                         .try_send(Event::Msg(wire::Msg {
                             pfx: pfx.clone(),
@@ -399,7 +401,7 @@ impl StateInner {
                                 ctcp: None,
                                 is_notice: true,
                                 msg: msg_477.clone(),
-                                target: wire::MsgTarget::Chan(channel.clone()),
+                                target: wire::MsgTarget::Chan(channel.to_owned()),
                             },
                         }))
                         .unwrap();
@@ -407,7 +409,7 @@ impl StateInner {
                     if self.nickserv_ident.is_some() {
                         // Helper for creating an event
                         let create_message = |msg: String| Event::ChannelJoinError {
-                            chan: channel.clone(),
+                            chan: channel.to_owned(),
                             msg,
                         };
                         // Find channel in self.chans
@@ -417,7 +419,7 @@ impl StateInner {
                             if let Some(retries) = chan.retry_join() {
                                 let retry_msg = format!(
                                     "Attempting to rejoin {} in 10 seconds... ({}/{})",
-                                    channel,
+                                    channel.display(),
                                     MAX_JOIN_RETRIES - retries,
                                     MAX_JOIN_RETRIES
                                 );
@@ -434,13 +436,14 @@ impl StateInner {
                                     }
                                 }
                                 tokio::task::spawn_local(retry_channel_join(
-                                    channel.clone(),
+                                    channel.to_owned(),
                                     snd_irc_msg,
                                     rcv_abort,
                                 ));
                             } else {
                                 // No more retries
-                                let no_retries_msg = format!("Unable to join {}.", channel);
+                                let no_retries_msg =
+                                    format!("Unable to join {}.", channel.display());
                                 snd_ev.try_send(create_message(no_retries_msg)).unwrap();
                             }
                         } else {
@@ -563,7 +566,7 @@ impl StateInner {
 
             // RPL_ENDOFMOTD: Join channels, set away status
             Reply { num: 376, .. } => {
-                let chans: Vec<&str> = self.chans.iter().map(|c| c.name.as_str()).collect();
+                let chans: Vec<&ChanNameRef> = self.chans.iter().map(|c| c.name.as_ref()).collect();
                 if !chans.is_empty() {
                     snd_irc_msg.try_send(wire::join(&chans)).unwrap();
                 }
@@ -576,7 +579,7 @@ impl StateInner {
 
             // RPL_NAMREPLY: Set users in a channel
             Reply { num: 353, params } => {
-                let chan = &params[2];
+                let chan = ChanNameRef::new(&params[2]);
                 match utils::find_idx(&self.chans, |c| &c.name == chan) {
                     None => self.chans.push(Chan::with_nicks(
                         chan.to_owned(),
@@ -646,8 +649,8 @@ impl StateInner {
         }
     }
 
-    fn get_chan_nicks(&self, chan: &str) -> Vec<String> {
-        match utils::find_idx(&self.chans, |c| c.name == chan) {
+    fn get_chan_nicks(&self, chan: &ChanNameRef) -> Vec<String> {
+        match utils::find_idx(&self.chans, |c| c.name == *chan) {
             None => {
                 error!("Could not find channel index in get_chan_nicks.");
                 vec![]
@@ -667,12 +670,12 @@ impl StateInner {
     }
 
     /// If channel is in Joining state cancel Joining task, otherwise sent part message
-    fn leave_channel(&mut self, msg_chan: &mut Sender<Cmd>, chan: &str) {
-        if let Some(idx) = utils::find_idx(&self.chans, |c| c.name == chan) {
+    fn leave_channel(&mut self, msg_chan: &mut Sender<Cmd>, chan: &ChanNameRef) {
+        if let Some(idx) = utils::find_idx(&self.chans, |c| c.name == *chan) {
             match &mut self.chans[idx].join_state {
                 JoinState::NotJoined => {}
                 JoinState::Joining { stop_task, .. } => {
-                    debug!("Aborting task to retry joining {}", chan);
+                    debug!("Aborting task to retry joining {}", chan.display());
                     let _ = stop_task.try_send(());
                 }
                 JoinState::Joined => msg_chan.try_send(Cmd::Msg(wire::part(chan))).unwrap(),
@@ -691,11 +694,11 @@ impl StateInner {
 }
 
 async fn retry_channel_join(
-    channel: String,
+    channel: ChanName,
     mut snd_irc_msg: Sender<String>,
     rcv_abort: Receiver<()>,
 ) {
-    debug!("Attempting to re-join channel {}", channel);
+    debug!("Attempting to re-join channel {}", channel.display());
 
     use tokio::time::{delay_for, Duration};
 
diff --git a/libtiny_common/Cargo.toml b/libtiny_common/Cargo.toml
new file mode 100644
index 00000000..11d2f428
--- /dev/null
+++ b/libtiny_common/Cargo.toml
@@ -0,0 +1,5 @@
+[package]
+name = "libtiny_common"
+version = "0.1.0"
+authors = ["Ömer Sinan Ağacan <omeragacan@gmail.com>"]
+edition = "2018"
diff --git a/libtiny_common/src/lib.rs b/libtiny_common/src/lib.rs
new file mode 100644
index 00000000..8e505879
--- /dev/null
+++ b/libtiny_common/src/lib.rs
@@ -0,0 +1,143 @@
+//! This crate implements common types used by other libtiny crates.
+
+use std::borrow::Borrow;
+use std::hash::{Hash, Hasher};
+use std::ops::Deref;
+
+/// Channel names according to RFC 2812, section 1.3. Channel names are case insensitive, so this
+/// type defines `Eq`, and `Hash` traits that work in a case-insensitive way. `ChanName::display`
+/// method shows the channel name with the original casing.
+#[derive(Debug, Clone)]
+pub struct ChanName(String);
+
+/// Slice version of `ChanName`
+#[derive(Debug)]
+pub struct ChanNameRef(str);
+
+impl Deref for ChanName {
+    type Target = ChanNameRef;
+
+    fn deref(&self) -> &Self::Target {
+        self.as_ref()
+    }
+}
+
+// https://github.com/rust-lang/rust/blob/10b3595ba6a4c658c9dea105488fc562c815e434/library/std/src/path.rs#L1735
+impl AsRef<ChanNameRef> for ChanName {
+    fn as_ref(&self) -> &ChanNameRef {
+        ChanNameRef::new(self.0.as_ref())
+    }
+}
+
+impl<'a> Borrow<ChanNameRef> for ChanName {
+    fn borrow(&self) -> &ChanNameRef {
+        self.as_ref()
+    }
+}
+
+// Used to normalize channel names. Rules are:
+//
+// - ASCII characters are mapped to their lowercase versions
+// - '[', ']', '\\', '~' are mapped to '{', '}', '|', '^', respectively. See RFC 2812 section 2.2.
+// - Non-ASCII characters are left unchanged.
+fn to_lower(c: char) -> char {
+    match c {
+        '[' => '{',
+        ']' => '}',
+        '\\' => '|',
+        '~' => '^',
+        _ => c.to_ascii_lowercase(),
+    }
+}
+
+impl ChanName {
+    pub fn new(name: String) -> Self {
+        ChanName(name)
+    }
+
+    pub fn display(&self) -> &str {
+        &self.0
+    }
+}
+
+impl ChanNameRef {
+    pub fn new(name: &str) -> &Self {
+        unsafe { &*(name as *const str as *const ChanNameRef) }
+    }
+
+    pub fn display(&self) -> &str {
+        &self.0
+    }
+
+    pub fn normalized(&self) -> String {
+        self.0.chars().map(to_lower).collect()
+    }
+}
+
+impl ToOwned for ChanNameRef {
+    type Owned = ChanName;
+
+    fn to_owned(&self) -> Self::Owned {
+        ChanName(self.0.to_owned())
+    }
+}
+
+impl PartialEq for ChanName {
+    fn eq(&self, other: &Self) -> bool {
+        let self_borrowed: &ChanNameRef = self.borrow();
+        let other_borrowed: &ChanNameRef = other.borrow();
+        self_borrowed.eq(other_borrowed)
+    }
+}
+
+impl Eq for ChanName {}
+
+impl PartialEq<ChanNameRef> for ChanName {
+    fn eq(&self, other: &ChanNameRef) -> bool {
+        let self_borrowed: &ChanNameRef = self.borrow();
+        self_borrowed.eq(other)
+    }
+}
+
+impl Hash for ChanName {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        let self_borrowed: &ChanNameRef = self.borrow();
+        self_borrowed.hash(state)
+    }
+}
+
+impl PartialEq for ChanNameRef {
+    fn eq(&self, other: &Self) -> bool {
+        // https://github.com/rust-lang/rust/blob/b4acb110333392ecdaf890fce080e4b576106aae/library/core/src/slice/mod.rs#L6678-L6684
+
+        // All characters in ASCII have the same encoding length so we can compare byte lenghts.
+        if self.0.as_bytes().len() != other.0.as_bytes().len() {
+            return false;
+        }
+
+        self.0
+            .chars()
+            .map(to_lower)
+            .zip(other.0.chars().map(to_lower))
+            .all(|(a, b)| a == b)
+    }
+}
+
+impl Eq for ChanNameRef {}
+
+impl PartialEq<ChanName> for ChanNameRef {
+    fn eq(&self, other: &ChanName) -> bool {
+        let other_borrowed: &ChanNameRef = other.borrow();
+        self.eq(other_borrowed)
+    }
+}
+
+impl Hash for ChanNameRef {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        // https://github.com/rust-lang/rust/blob/b4acb110333392ecdaf890fce080e4b576106aae/library/core/src/hash/mod.rs#L653-L656
+        self.0.len().hash(state);
+        for c in self.0.chars() {
+            to_lower(c).hash(state);
+        }
+    }
+}
diff --git a/libtiny_logger/Cargo.toml b/libtiny_logger/Cargo.toml
index 53dc444a..fdb8c990 100644
--- a/libtiny_logger/Cargo.toml
+++ b/libtiny_logger/Cargo.toml
@@ -6,6 +6,7 @@ description = "Implements a logging UI for tiny"
 edition = "2018"
 
 [dependencies]
+libtiny_common = { path = "../libtiny_common" }
 libtiny_ui = { path = "../libtiny_ui" }
 log = "0.4"
 time = "0.1"
diff --git a/libtiny_logger/src/lib.rs b/libtiny_logger/src/lib.rs
index e716839c..42aa6ab2 100644
--- a/libtiny_logger/src/lib.rs
+++ b/libtiny_logger/src/lib.rs
@@ -8,6 +8,7 @@ use std::path::PathBuf;
 use std::rc::Rc;
 use time::Tm;
 
+use libtiny_common::{ChanName, ChanNameRef};
 use libtiny_ui::*;
 
 #[macro_use]
@@ -46,8 +47,8 @@ impl UI for Logger {
     fn draw(&self) {}
     delegate!(new_server_tab(serv: &str, alias: Option<String>,));
     delegate!(close_server_tab(serv: &str,));
-    delegate!(new_chan_tab(serv: &str, chan: &str,));
-    delegate!(close_chan_tab(serv: &str, chan: &str,));
+    delegate!(new_chan_tab(serv: &str, chan: &ChanNameRef,));
+    delegate!(close_chan_tab(serv: &str, chan: &ChanNameRef,));
     delegate!(close_user_tab(serv: &str, nick: &str,));
     delegate!(add_client_msg(msg: &str, target: &MsgTarget,));
     delegate!(add_msg(msg: &str, ts: Tm, target: &MsgTarget,));
@@ -71,7 +72,12 @@ impl UI for Logger {
         ts: Tm,
         target: &MsgTarget,
     ));
-    delegate!(set_topic(topic: &str, ts: Tm, serv: &str, chan: &str,));
+    delegate!(set_topic(
+        topic: &str,
+        ts: Tm,
+        serv: &str,
+        chan: &ChanNameRef,
+    ));
     delegate!(set_tab_style(style: TabStyle, target: &MsgTarget,));
 
     // TODO: Maybe just return true?
@@ -93,7 +99,7 @@ struct LoggerInner {
 
 struct ServerLogs {
     fd: File,
-    chans: HashMap<String, File>,
+    chans: HashMap<ChanName, File>,
     users: HashMap<String, File>,
 }
 
@@ -179,23 +185,28 @@ impl LoggerInner {
         self.servers.remove(serv);
     }
 
-    fn new_chan_tab(&mut self, serv: &str, chan: &str) {
+    fn new_chan_tab(&mut self, serv: &str, chan: &ChanNameRef) {
         match self.servers.get_mut(serv) {
             None => {
                 info!("new_chan_tab: can't find server: {}", serv);
             }
             Some(server) => {
                 let mut path = self.log_dir.clone();
-                path.push(&format!("{}_{}.txt", serv, replace_forward_slash(chan)));
+                let chan_name_normalized = chan.normalized();
+                path.push(&format!(
+                    "{}_{}.txt",
+                    serv,
+                    replace_forward_slash(&chan_name_normalized)
+                ));
                 if let Some(mut fd) = try_open_log_file(&path, &*self.report_err) {
                     report_io_err!(self.report_err, print_header(&mut fd));
-                    server.chans.insert(chan.to_string(), fd);
+                    server.chans.insert(ChanName::new(chan_name_normalized), fd);
                 }
             }
         }
     }
 
-    fn close_chan_tab(&mut self, serv: &str, chan: &str) {
+    fn close_chan_tab(&mut self, serv: &str, chan: &ChanNameRef) {
         match self.servers.get_mut(serv) {
             None => {
                 info!("close_chan_tab: can't find server: {}", serv);
@@ -301,7 +312,7 @@ impl LoggerInner {
         });
     }
 
-    fn set_topic(&mut self, topic: &str, ts: Tm, serv: &str, chan: &str) {
+    fn set_topic(&mut self, topic: &str, ts: Tm, serv: &str, chan: &ChanNameRef) {
         let target = MsgTarget::Chan { serv, chan };
         self.apply_to_target(&target, |fd: &mut File, report_err: &dyn Fn(String)| {
             report_io_err!(
@@ -334,11 +345,16 @@ impl LoggerInner {
                         // Create a file for the channel. FIXME Code copied from new_chan_tab:
                         // can't reuse it because of borrowchk issues.
                         let mut path = self.log_dir.clone();
-                        path.push(&format!("{}_{}.txt", serv, replace_forward_slash(chan)));
+                        let chan_name_normalized = chan.normalized();
+                        path.push(&format!(
+                            "{}_{}.txt",
+                            serv,
+                            replace_forward_slash(&chan_name_normalized)
+                        ));
                         if let Some(mut fd) = try_open_log_file(&path, &*self.report_err) {
                             report_io_err!(self.report_err, print_header(&mut fd));
                             f(&mut fd, &*self.report_err);
-                            chans.insert(chan.to_string(), fd);
+                            chans.insert(ChanName::new(chan_name_normalized), fd);
                         }
                     }
                     Some(fd) => {
diff --git a/libtiny_tui/Cargo.toml b/libtiny_tui/Cargo.toml
index 33291e37..16a593e4 100644
--- a/libtiny_tui/Cargo.toml
+++ b/libtiny_tui/Cargo.toml
@@ -14,6 +14,7 @@ desktop-notifications = ["notify-rust"]
 
 [dependencies]
 futures = { version = "0.3.1", default-features = false, features = ["std", "async-await"] }
+libtiny_common = { path = "../libtiny_common" }
 libtiny_ui = { path = "../libtiny_ui" }
 log = "0.4"
 notify-rust = { version = "3", optional = true }
diff --git a/libtiny_tui/examples/chat.rs b/libtiny_tui/examples/chat.rs
index 95ea4731..1285e318 100644
--- a/libtiny_tui/examples/chat.rs
+++ b/libtiny_tui/examples/chat.rs
@@ -8,17 +8,19 @@ use std::io::Read;
 use std::path::PathBuf;
 use tokio::sync::mpsc;
 
+use libtiny_common::ChanNameRef;
 use libtiny_tui::TUI;
 use libtiny_ui::*;
 
 static SERV: &str = "debug";
 static CHAN: &str = "chan";
-static CHAN_TARGET: MsgTarget = MsgTarget::Chan {
-    serv: SERV,
-    chan: CHAN,
-};
 
 fn main() {
+    let chan_target = MsgTarget::Chan {
+        serv: SERV,
+        chan: ChanNameRef::new(CHAN),
+    };
+
     let mut runtime = tokio::runtime::Builder::new()
         .basic_scheduler()
         .enable_all()
@@ -30,8 +32,13 @@ fn main() {
     local.block_on(&mut runtime, async move {
         let (tui, rcv_ev) = TUI::run(PathBuf::from("../tiny/config.yml"));
         tui.new_server_tab("debug", None);
-        tui.new_chan_tab("debug", "chan");
-        tui.set_topic("This is channel topic", time::now(), SERV, CHAN);
+        tui.new_chan_tab("debug", ChanNameRef::new("chan"));
+        tui.set_topic(
+            "This is channel topic",
+            time::now(),
+            SERV,
+            ChanNameRef::new(CHAN),
+        );
         tui.draw();
 
         {
@@ -42,8 +49,8 @@ fn main() {
             for (line_idx, line) in text.lines().enumerate() {
                 let now = time::now();
                 let nick = format!("nick_{}", line_idx);
-                tui.add_nick(&nick, Some(now), &CHAN_TARGET);
-                tui.add_privmsg(&nick, line, now, &CHAN_TARGET, false, false);
+                tui.add_nick(&nick, Some(now), &chan_target);
+                tui.add_privmsg(&nick, line, now, &chan_target, false, false);
             }
         }
 
diff --git a/libtiny_tui/examples/tabs.rs b/libtiny_tui/examples/tabs.rs
index 7ce03b3b..58a26bca 100644
--- a/libtiny_tui/examples/tabs.rs
+++ b/libtiny_tui/examples/tabs.rs
@@ -1,11 +1,13 @@
 // Open a lot of tabs. 10 servers tabs, each one having 3 channels.
 
 use futures::stream::StreamExt;
-use libtiny_tui::TUI;
-use libtiny_ui::*;
 use std::path::PathBuf;
 use tokio::sync::mpsc;
 
+use libtiny_common::ChanNameRef;
+use libtiny_tui::TUI;
+use libtiny_ui::*;
+
 fn main() {
     let mut runtime = tokio::runtime::Builder::new()
         .basic_scheduler()
@@ -22,25 +24,25 @@ fn main() {
             let server = format!("server_{}", serv_idx);
             tui.new_server_tab(&server, None);
 
-            tui.new_chan_tab(&server, "chan_0");
+            tui.new_chan_tab(&server, ChanNameRef::new("chan_0"));
             tui.set_tab_style(
                 TabStyle::NewMsg,
                 &MsgTarget::Chan {
                     serv: &server,
-                    chan: "chan_0",
+                    chan: ChanNameRef::new("chan_0"),
                 },
             );
 
-            tui.new_chan_tab(&server, "chan_1");
+            tui.new_chan_tab(&server, ChanNameRef::new("chan_1"));
             tui.set_tab_style(
                 TabStyle::Highlight,
                 &MsgTarget::Chan {
                     serv: &server,
-                    chan: "chan_1",
+                    chan: ChanNameRef::new("chan_1"),
                 },
             );
 
-            tui.new_chan_tab(&server, "chan_2");
+            tui.new_chan_tab(&server, ChanNameRef::new("chan_2"));
         }
 
         tui.draw();
diff --git a/libtiny_tui/src/lib.rs b/libtiny_tui/src/lib.rs
index 3cbcf00e..9607953a 100644
--- a/libtiny_tui/src/lib.rs
+++ b/libtiny_tui/src/lib.rs
@@ -25,6 +25,7 @@ mod tests;
 
 pub use crate::tab::TabStyle;
 use crate::tui::TUIRet;
+use libtiny_common::ChanNameRef;
 pub use libtiny_ui::*;
 
 use futures::select;
@@ -187,8 +188,8 @@ impl UI for TUI {
     delegate!(draw());
     delegate!(new_server_tab(serv_name: &str, alias: Option<String>,));
     delegate!(close_server_tab(serv_name: &str,));
-    delegate!(new_chan_tab(serv_name: &str, chan: &str,));
-    delegate!(close_chan_tab(serv_name: &str, chan: &str,));
+    delegate!(new_chan_tab(serv_name: &str, chan: &ChanNameRef,));
+    delegate!(close_chan_tab(serv_name: &str, chan: &ChanNameRef,));
     delegate!(close_user_tab(serv_name: &str, nick: &str,));
     delegate!(add_client_msg(msg: &str, target: &MsgTarget,));
     delegate!(add_msg(msg: &str, ts: Tm, target: &MsgTarget,));
@@ -216,7 +217,7 @@ impl UI for TUI {
         topic: &str,
         ts: Tm,
         serv_name: &str,
-        chan_name: &str,
+        chan_name: &ChanNameRef,
     ));
     delegate!(set_tab_style(style: TabStyle, target: &MsgTarget,));
 
diff --git a/libtiny_tui/src/notifier.rs b/libtiny_tui/src/notifier.rs
index 46397214..0f2d49c1 100644
--- a/libtiny_tui/src/notifier.rs
+++ b/libtiny_tui/src/notifier.rs
@@ -41,7 +41,7 @@ impl Notifier {
         match *target {
             MsgTarget::Chan { chan, .. } => {
                 if *self == Notifier::Messages || (*self == Notifier::Mentions && mention) {
-                    notify(&format!("{} in {}", sender, chan), &msg)
+                    notify(&format!("{} in {}", sender, chan.display()), &msg)
                 }
             }
             MsgTarget::User {
diff --git a/libtiny_tui/src/tui.rs b/libtiny_tui/src/tui.rs
index b06205ce..d58e100a 100644
--- a/libtiny_tui/src/tui.rs
+++ b/libtiny_tui/src/tui.rs
@@ -2,6 +2,7 @@
 #![allow(clippy::new_without_default)]
 #![allow(clippy::too_many_arguments)]
 
+use std::borrow::Borrow;
 use std::cmp::Ordering;
 use std::path::PathBuf;
 use std::str::{self, SplitWhitespace};
@@ -16,6 +17,7 @@ use crate::tab::{Tab, TabStyle};
 use crate::widget::WidgetRet;
 use crate::{MsgSource, MsgTarget};
 
+use libtiny_common::ChanNameRef;
 use term_input::{Arrow, Event, Key};
 use termbox_simple::Termbox;
 
@@ -159,7 +161,7 @@ impl TUI {
             MsgSource::Chan { serv, chan } => {
                 self.toggle_ignore(&MsgTarget::Chan {
                     serv: &serv,
-                    chan: &chan,
+                    chan: chan.borrow(),
                 });
             }
             MsgSource::User { serv, nick } => {
@@ -207,7 +209,10 @@ impl TUI {
             // can't use `MsgSource::to_target` here, `Serv` case is different
             let tab_target = match src {
                 MsgSource::Serv { ref serv } => MsgTarget::AllServTabs { serv },
-                MsgSource::Chan { ref serv, ref chan } => MsgTarget::Chan { serv, chan },
+                MsgSource::Chan { ref serv, ref chan } => MsgTarget::Chan {
+                    serv,
+                    chan: chan.borrow(),
+                },
                 MsgSource::User { ref serv, ref nick } => MsgTarget::User { serv, nick },
             };
             self.set_notifier(notifier, &tab_target);
@@ -384,7 +389,7 @@ impl TUI {
     }
 
     /// Returns index of the new tab if a new tab is created.
-    pub(crate) fn new_chan_tab(&mut self, serv: &str, chan: &str) -> Option<usize> {
+    pub(crate) fn new_chan_tab(&mut self, serv: &str, chan: &ChanNameRef) -> Option<usize> {
         match self.find_chan_tab_idx(serv, chan) {
             None => match self.find_last_serv_tab_idx(serv) {
                 None => {
@@ -427,7 +432,7 @@ impl TUI {
         }
     }
 
-    pub(crate) fn close_chan_tab(&mut self, serv: &str, chan: &str) {
+    pub(crate) fn close_chan_tab(&mut self, serv: &str, chan: &ChanNameRef) {
         if let Some(tab_idx) = self.find_chan_tab_idx(serv, chan) {
             self.tabs.remove(tab_idx);
             if self.active_idx == tab_idx {
@@ -972,7 +977,8 @@ impl TUI {
                     }
                 }
                 MsgSource::Chan { ref chan, .. } => {
-                    if chan.contains(string) {
+                    // TODO: Case sensitive matching here is not ideal
+                    if chan.display().contains(string) {
                         next_idx = tab_idx;
                         break;
                     }
@@ -1246,7 +1252,7 @@ impl TUI {
         });
     }
 
-    pub(crate) fn set_topic(&mut self, title: &str, ts: Tm, serv: &str, chan: &str) {
+    pub(crate) fn set_topic(&mut self, title: &str, ts: Tm, serv: &str, chan: &ChanNameRef) {
         let target = MsgTarget::Chan { serv, chan };
         self.apply_to_target(&target, &|tab: &mut Tab, _| {
             tab.widget.show_topic(title, Timestamp::from(ts));
@@ -1371,7 +1377,7 @@ impl TUI {
         None
     }
 
-    fn find_chan_tab_idx(&self, serv_: &str, chan_: &str) -> Option<usize> {
+    fn find_chan_tab_idx(&self, serv_: &str, chan_: &ChanNameRef) -> Option<usize> {
         for (tab_idx, tab) in self.tabs.iter().enumerate() {
             if let MsgSource::Chan { ref serv, ref chan } = tab.src {
                 if serv_ == serv && chan_ == chan {
diff --git a/libtiny_ui/Cargo.toml b/libtiny_ui/Cargo.toml
index fd5614dd..4972608f 100644
--- a/libtiny_ui/Cargo.toml
+++ b/libtiny_ui/Cargo.toml
@@ -6,5 +6,6 @@ description = "Defines the interface for the UI implementations for tiny"
 edition = "2018"
 
 [dependencies]
+libtiny_common = { path = "../libtiny_common" }
 objekt = "0.1"
 time = "0.1"
diff --git a/libtiny_ui/src/lib.rs b/libtiny_ui/src/lib.rs
index fcfe1586..89b71095 100644
--- a/libtiny_ui/src/lib.rs
+++ b/libtiny_ui/src/lib.rs
@@ -1,6 +1,10 @@
 pub use objekt::clone_box;
 use time::Tm;
 
+use std::borrow::Borrow;
+
+use libtiny_common::{ChanName, ChanNameRef};
+
 /// Target of a message to be shown in a UI.
 #[derive(Debug)]
 pub enum MsgTarget<'a> {
@@ -8,7 +12,10 @@ pub enum MsgTarget<'a> {
     Server { serv: &'a str },
 
     /// Show the message in the channel tab.
-    Chan { serv: &'a str, chan: &'a str },
+    Chan {
+        serv: &'a str,
+        chan: &'a ChanNameRef,
+    },
 
     /// Show the message in the privmsg tab.
     User { serv: &'a str, nick: &'a str },
@@ -27,7 +34,7 @@ pub enum MsgSource {
     Serv { serv: String },
 
     /// Message sent in a channel tab.
-    Chan { serv: String, chan: String },
+    Chan { serv: String, chan: ChanName },
 
     /// Message sent in a privmsg tab.
     User { serv: String, nick: String },
@@ -45,7 +52,10 @@ impl MsgSource {
     pub fn to_target(&self) -> MsgTarget {
         match self {
             MsgSource::Serv { serv } => MsgTarget::Server { serv },
-            MsgSource::Chan { serv, chan } => MsgTarget::Chan { serv, chan },
+            MsgSource::Chan { serv, chan } => MsgTarget::Chan {
+                serv,
+                chan: chan.borrow(),
+            },
             MsgSource::User { serv, nick } => MsgTarget::User { serv, nick },
         }
     }
@@ -53,7 +63,7 @@ impl MsgSource {
     pub fn visible_name(&self) -> &str {
         match self {
             MsgSource::Serv { serv, .. } => serv,
-            MsgSource::Chan { chan, .. } => chan,
+            MsgSource::Chan { chan, .. } => chan.display(),
             MsgSource::User { nick, .. } => nick,
         }
     }
@@ -98,10 +108,10 @@ pub trait UI: objekt::Clone {
     fn close_server_tab(&self, serv: &str);
 
     /// Create a channel tab in the given server.
-    fn new_chan_tab(&self, serv: &str, chan: &str);
+    fn new_chan_tab(&self, serv: &str, chan: &ChanNameRef);
 
     /// Close a channel tab.
-    fn close_chan_tab(&self, serv: &str, chan: &str);
+    fn close_chan_tab(&self, serv: &str, chan: &ChanNameRef);
 
     /// Close a user tab.
     fn close_user_tab(&self, serv: &str, nick: &str);
@@ -155,7 +165,7 @@ pub trait UI: objekt::Clone {
     fn rename_nick(&self, old_nick: &str, new_nick: &str, ts: Tm, target: &MsgTarget);
 
     /// Set topic of given tabs.
-    fn set_topic(&self, topic: &str, ts: Tm, serv: &str, chan: &str);
+    fn set_topic(&self, topic: &str, ts: Tm, serv: &str, chan: &ChanNameRef);
 
     /// Set style of the given tabs.
     fn set_tab_style(&self, style: TabStyle, target: &MsgTarget);
@@ -188,12 +198,12 @@ impl<UI1: UI + Clone, UI2: UI + Clone> UI for CombinedUIs<UI1, UI2> {
         self.ui2.close_server_tab(serv);
     }
 
-    fn new_chan_tab(&self, serv: &str, chan: &str) {
+    fn new_chan_tab(&self, serv: &str, chan: &ChanNameRef) {
         self.ui1.new_chan_tab(serv, chan);
         self.ui2.new_chan_tab(serv, chan);
     }
 
-    fn close_chan_tab(&self, serv: &str, chan: &str) {
+    fn close_chan_tab(&self, serv: &str, chan: &ChanNameRef) {
         self.ui1.close_chan_tab(serv, chan);
         self.ui2.close_chan_tab(serv, chan);
     }
@@ -263,7 +273,7 @@ impl<UI1: UI + Clone, UI2: UI + Clone> UI for CombinedUIs<UI1, UI2> {
         self.ui2.rename_nick(old_nick, new_nick, ts, target);
     }
 
-    fn set_topic(&self, topic: &str, ts: Tm, serv: &str, chan: &str) {
+    fn set_topic(&self, topic: &str, ts: Tm, serv: &str, chan: &ChanNameRef) {
         self.ui1.set_topic(topic, ts, serv, chan);
         self.ui2.set_topic(topic, ts, serv, chan);
     }
diff --git a/libtiny_wire/Cargo.toml b/libtiny_wire/Cargo.toml
index d61cd401..d4639be9 100644
--- a/libtiny_wire/Cargo.toml
+++ b/libtiny_wire/Cargo.toml
@@ -4,3 +4,6 @@ version = "0.1.0"
 authors = ["Ömer Sinan Ağacan <omeragacan@gmail.com>"]
 edition = "2018"
 description = "IRC message parsing and generation"
+
+[dependencies]
+libtiny_common = { path = "../libtiny_common" }
diff --git a/libtiny_wire/src/lib.rs b/libtiny_wire/src/lib.rs
index 0e13ce1f..05047f62 100644
--- a/libtiny_wire/src/lib.rs
+++ b/libtiny_wire/src/lib.rs
@@ -4,6 +4,8 @@
 
 use std::str;
 
+use libtiny_common::{ChanName, ChanNameRef};
+
 pub fn pass(pass: &str) -> String {
     format!("PASS {}\r\n", pass)
 }
@@ -32,12 +34,13 @@ pub fn pong(arg: &str) -> String {
     format!("PONG {}\r\n", arg)
 }
 
-pub fn join(chans: &[&str]) -> String {
+pub fn join(chans: &[&ChanNameRef]) -> String {
+    let chans = chans.iter().map(|c| c.display()).collect::<Vec<_>>();
     format!("JOIN {}\r\n", chans.join(","))
 }
 
-pub fn part(channel: &str) -> String {
-    format!("PART {}\r\n", channel)
+pub fn part(chan: &ChanNameRef) -> String {
+    format!("PART {}\r\n", chan.display())
 }
 
 pub fn privmsg(msgtarget: &str, msg: &str) -> String {
@@ -164,7 +167,7 @@ fn parse_pfx(pfx: &str) -> Pfx {
 /// Target of a message
 #[derive(Debug, PartialEq, Eq)]
 pub enum MsgTarget {
-    Chan(String),
+    Chan(ChanName),
     User(String),
 }
 
@@ -212,13 +215,13 @@ pub enum Cmd {
 
     JOIN {
         // TODO: Same as above, this should be a list ...
-        chan: String, // TODO: key field might be useful when joining restricted channels. In practice I've never
-                      // needed it.
+        chan: ChanName, // TODO: key field might be useful when joining restricted channels. In
+                        // practice I've never needed it.
     },
 
     PART {
         // TODO: List of channels
-        chan: String,
+        chan: ChanName,
         msg: Option<String>,
     },
 
@@ -227,7 +230,7 @@ pub enum Cmd {
         /// Channels of the user that just quit. This is not a part of the IRC message, but
         /// something `libtiny_client` fills in for the users. Currently used to update tabs of the
         /// user in TUI.
-        chans: Vec<String>,
+        chans: Vec<ChanName>,
     },
 
     NICK {
@@ -235,7 +238,7 @@ pub enum Cmd {
         /// Channels of the user. Channels of the user that just quit. This is not a part of the
         /// IRC message, but something `libtiny_client` fills in for the users. Currently used to
         /// update tabs of the user in TUI.
-        chans: Vec<String>,
+        chans: Vec<ChanName>,
     },
 
     PING {
@@ -251,7 +254,7 @@ pub enum Cmd {
     },
 
     TOPIC {
-        chan: String,
+        chan: ChanName,
         topic: String,
     },
 
@@ -345,7 +348,7 @@ fn parse_one_message(mut msg: &str) -> Result<Msg, String> {
             let target = params[0];
             let mut msg = params[1];
             let target = if target.starts_with('#') {
-                MsgTarget::Chan(target.to_owned())
+                MsgTarget::Chan(ChanName::new(target.to_owned()))
             } else {
                 MsgTarget::User(target.to_owned())
             };
@@ -383,7 +386,7 @@ fn parse_one_message(mut msg: &str) -> Result<Msg, String> {
         MsgType::Cmd("JOIN") if params.len() == 1 => {
             let chan = params[0];
             Cmd::JOIN {
-                chan: chan.to_owned(),
+                chan: ChanName::new(chan.to_owned()),
             }
         }
         MsgType::Cmd("PART") if params.len() == 1 || params.len() == 2 => {
@@ -393,7 +396,7 @@ fn parse_one_message(mut msg: &str) -> Result<Msg, String> {
                 None
             };
             Cmd::PART {
-                chan: params[0].to_owned(),
+                chan: ChanName::new(params[0].to_owned()),
                 msg: mb_msg,
             }
         }
@@ -422,7 +425,7 @@ fn parse_one_message(mut msg: &str) -> Result<Msg, String> {
             msg: params[0].to_owned(),
         },
         MsgType::Cmd("TOPIC") if params.len() == 2 => Cmd::TOPIC {
-            chan: params[0].to_owned(),
+            chan: ChanName::new(params[0].to_owned()),
             topic: params[1].to_owned(),
         },
         MsgType::Cmd("CAP") if params.len() == 3 => Cmd::CAP {
@@ -644,7 +647,7 @@ mod tests {
                     user: "~tiny@123.123.123.123".to_owned(),
                 }),
                 cmd: Cmd::PART {
-                    chan: "#haskell".to_owned(),
+                    chan: ChanName::new("#haskell".to_owned()),
                     msg: None,
                 },
             }
@@ -663,7 +666,7 @@ mod tests {
                     user: "~tiny@192.168.0.1".to_owned(),
                 }),
                 cmd: Cmd::JOIN {
-                    chan: "#haskell".to_owned(),
+                    chan: ChanName::new("#haskell".to_owned()),
                 },
             }
         );
@@ -687,7 +690,7 @@ mod tests {
                     user: "u@localhost".to_owned(),
                 }),
                 cmd: Cmd::PRIVMSG {
-                    target: MsgTarget::Chan("#ircv3".to_owned()),
+                    target: MsgTarget::Chan(ChanName::new("#ircv3".to_owned())),
                     msg: "writes some specs!".to_owned(),
                     is_notice: false,
                     ctcp: Some(CTCP::Action),
diff --git a/tiny/Cargo.toml b/tiny/Cargo.toml
index 4538eb16..9cf364bf 100644
--- a/tiny/Cargo.toml
+++ b/tiny/Cargo.toml
@@ -20,6 +20,7 @@ dirs = "1.0.2"
 env_logger = { version = "0.7", default-features = false }
 futures = { version = "0.3.1", default-features = false }
 libtiny_client = { path = "../libtiny_client", default-features = false }
+libtiny_common = { path = "../libtiny_common" }
 libtiny_logger = { path = "../libtiny_logger" }
 libtiny_tui = { path = "../libtiny_tui", default-features = false }
 libtiny_ui = { path = "../libtiny_ui" }
diff --git a/tiny/src/cmd.rs b/tiny/src/cmd.rs
index 20190b38..e022c820 100644
--- a/tiny/src/cmd.rs
+++ b/tiny/src/cmd.rs
@@ -4,7 +4,10 @@
 use crate::config;
 use crate::utils;
 use libtiny_client::{Client, ServerInfo};
+use libtiny_common::ChanNameRef;
 use libtiny_ui::{MsgSource, MsgTarget, UI};
+
+use std::borrow::Borrow;
 use std::path::Path;
 
 pub(crate) struct CmdArgs<'a> {
@@ -154,7 +157,7 @@ fn close(args: CmdArgs) {
             client.quit(None);
         }
         MsgSource::Chan { serv, chan } => {
-            ui.close_chan_tab(&serv, &chan);
+            ui.close_chan_tab(&serv, chan.borrow());
             let client_idx = find_client_idx(&clients, &serv).unwrap();
             clients[client_idx].part(&chan);
         }
@@ -260,7 +263,11 @@ fn connect_(
         realname: defaults.realname.clone(),
         pass: pass.map(str::to_owned),
         nicks: defaults.nicks.clone(),
-        auto_join: defaults.join.clone(),
+        auto_join: defaults
+            .join
+            .iter()
+            .map(|c| ChanNameRef::new(c).to_owned())
+            .collect(),
         nickserv_ident: None,
         sasl_auth: None,
     });
@@ -290,7 +297,10 @@ fn join(args: CmdArgs) {
         src,
         ..
     } = args;
-    let words = args.split_whitespace().collect::<Vec<_>>();
+    let words = args
+        .split_whitespace()
+        .map(|c| ChanNameRef::new(c))
+        .collect::<Vec<_>>();
     if words.is_empty() {
         return ui.add_client_err_msg(JOIN_CMD.usage, &MsgTarget::CurrentTab);
     }
diff --git a/tiny/src/conn.rs b/tiny/src/conn.rs
index b4d6d3d8..afbbfedc 100644
--- a/tiny/src/conn.rs
+++ b/tiny/src/conn.rs
@@ -4,10 +4,12 @@
 //! IRC event handling
 
 use futures::stream::StreamExt;
+use tokio::sync::mpsc;
+
 use libtiny_client::Client;
+use libtiny_common::ChanNameRef;
 use libtiny_ui::{MsgTarget, TabStyle, UI};
 use libtiny_wire as wire;
-use tokio::sync::mpsc;
 
 pub(crate) async fn task(
     mut rcv_ev: mpsc::Receiver<libtiny_client::Event>,
@@ -173,7 +175,7 @@ fn handle_irc_msg(ui: &dyn UI, client: &Client, msg: wire::Msg) {
                         ui.set_tab_style(TabStyle::Highlight, &ui_msg_target);
                         let mentions_target = MsgTarget::Server { serv: "mentions" };
                         ui.add_msg(
-                            &format!("{} in {}:{}: {}", sender, serv, chan, msg),
+                            &format!("{} in {}:{}: {}", sender, serv, chan.display(), msg),
                             ts,
                             &mentions_target,
                         );
@@ -406,12 +408,15 @@ fn handle_irc_msg(ui: &dyn UI, client: &Client, msg: wire::Msg) {
                 // one being our nick).
                 let chan = &params[n_params - 2];
                 let topic = &params[n_params - 1];
-                ui.set_topic(topic, time::now(), serv, chan);
+                ui.set_topic(topic, time::now(), serv, ChanNameRef::new(chan));
             }
             // RPL_NAMREPLY: List of users in a channel
             else if n == 353 && n_params > 3 {
                 let chan = &params[2];
-                let chan_target = MsgTarget::Chan { serv, chan };
+                let chan_target = MsgTarget::Chan {
+                    serv,
+                    chan: ChanNameRef::new(chan),
+                };
 
                 for nick in params[3].split_whitespace() {
                     ui.add_nick(wire::drop_nick_prefix(nick), None, &chan_target);
diff --git a/tiny/src/main.rs b/tiny/src/main.rs
index 7e4cf1bd..5a90fabd 100644
--- a/tiny/src/main.rs
+++ b/tiny/src/main.rs
@@ -9,6 +9,7 @@ mod ui;
 mod utils;
 
 use libtiny_client::{Client, ServerInfo};
+use libtiny_common::ChanNameRef;
 use libtiny_logger::{Logger, LoggerInitError};
 use libtiny_tui::{MsgTarget, TUI};
 use libtiny_ui::UI;
@@ -146,7 +147,11 @@ fn run(
                 pass: server.pass,
                 realname: server.realname,
                 nicks: server.nicks,
-                auto_join: server.join,
+                auto_join: server
+                    .join
+                    .iter()
+                    .map(|c| ChanNameRef::new(c).to_owned())
+                    .collect(),
                 nickserv_ident: server.nickserv_ident,
                 sasl_auth: server.sasl_auth.map(|auth| libtiny_client::SASLAuth {
                     username: auth.username,
diff --git a/tiny/src/ui.rs b/tiny/src/ui.rs
index cfcd1156..ea4665fd 100644
--- a/tiny/src/ui.rs
+++ b/tiny/src/ui.rs
@@ -117,7 +117,7 @@ pub(crate) fn send_msg(
 
     // `ui_target`: Where to show the message on ui
     // `msg_target`: Actual PRIVMSG target to send to the server
-    let (ui_target, msg_target) = {
+    let (ui_target, msg_target): (MsgTarget, &str) = {
         match src {
             MsgSource::Serv { .. } => {
                 // we don't split raw messages to 512-bytes long chunks
@@ -125,7 +125,9 @@ pub(crate) fn send_msg(
                 return;
             }
 
-            MsgSource::Chan { ref serv, ref chan } => (MsgTarget::Chan { serv, chan }, chan),
+            MsgSource::Chan { ref serv, ref chan } => {
+                (MsgTarget::Chan { serv, chan }, chan.display())
+            }
 
             MsgSource::User { ref serv, ref nick } => {
                 let msg_target = if nick.eq_ignore_ascii_case("nickserv")


Test patch:
diff --git a/libtiny_tui/src/tests/mod.rs b/libtiny_tui/src/tests/mod.rs
index 0596d5c7..58a83dc9 100644
--- a/libtiny_tui/src/tests/mod.rs
+++ b/libtiny_tui/src/tests/mod.rs
@@ -1,5 +1,6 @@
 use crate::tui::TUI;
 
+use libtiny_common::ChanNameRef;
 use libtiny_ui::*;
 use term_input::{Event, Key};
 use termbox_simple::CellBuf;
@@ -137,7 +138,7 @@ fn close_rightmost_tab() {
 fn small_screen_1() {
     let mut tui = TUI::new_test(21, 3);
     let serv = "irc.server_1.org";
-    let chan = "#chan";
+    let chan = ChanNameRef::new("#chan");
     tui.new_server_tab(serv, None);
     tui.set_nick(serv, "osa1");
     tui.new_chan_tab(serv, chan);
@@ -186,7 +187,7 @@ fn small_screen_1() {
 fn small_screen_2() {
     let mut tui = TUI::new_test(21, 4);
     let serv = "irc.server_1.org";
-    let chan = "#chan";
+    let chan = ChanNameRef::new("#chan");
     tui.new_server_tab(serv, None);
     tui.set_nick(serv, "osa1");
     tui.new_chan_tab(serv, chan);
@@ -223,7 +224,7 @@ fn small_screen_2() {
 fn ctrl_w() {
     let mut tui = TUI::new_test(30, 3);
     let serv = "irc.server_1.org";
-    let chan = "#chan";
+    let chan = ChanNameRef::new("#chan");
     tui.new_server_tab(serv, None);
     tui.set_nick(serv, "osa1");
     tui.new_chan_tab(serv, chan);
@@ -445,7 +446,7 @@ fn test_text_field_wrap() {
 fn test_join_part_overflow() {
     let mut tui = TUI::new_test(21, 4);
     let serv = "irc.server_1.org";
-    let chan = "#chan";
+    let chan = ChanNameRef::new("#chan");
     tui.new_server_tab(serv, None);
     tui.set_nick(serv, "osa1");
     tui.new_chan_tab(serv, chan);