diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,12 @@
 # Revision history for glirc2
 
+## 2.3
+
+* Add commands `/znc`
+* Add initial support for ZNC's playback module and `/znc-playback` command
+* Don't consider message seen when in masklist, userlist, or channelinfo windows
+* Add terminal bell on command error
+
 ## 2.2
 
 * Add commands `/ison`, `/userhost`, `/away`, `/notice`, `/ctcp`, `/links`, `/time`, `/stats`
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -182,6 +182,13 @@
 * `/grep` - Filter chat messages using a regular expression
 * `/grepi` - Filter chat messages using a case-insensitive regular expression on the message
 
+ZNC-specific
+
+* `/znc <module> <parameters>` - send command to ZNC module without echoing to all clients
+* `/znc-playback` - ZNC playback module - play everything
+* `/znc-playback <time>` - ZNC playback module - play everything start at the given time today
+* `/znc-playback <date> <time>` - ZNC playback module - play everything start at the given time
+
 Low-level
 
 * `/quote <raw command>` - Send a raw IRC command to the server
diff --git a/glirc.cabal b/glirc.cabal
--- a/glirc.cabal
+++ b/glirc.cabal
@@ -1,5 +1,5 @@
 name:                glirc
-version:             2.2
+version:             2.3
 synopsis:            Console IRC client
 description:         Console IRC client
 license:             ISC
diff --git a/src/Client/ChannelState.hs b/src/Client/ChannelState.hs
--- a/src/Client/ChannelState.hs
+++ b/src/Client/ChannelState.hs
@@ -23,10 +23,14 @@
   , chanUsers
   , chanModes
   , chanLists
-  , chanList
   , chanCreation
   , chanQueuedModeration
 
+  -- * Mask list entries
+  , MaskListEntry(..)
+  , maskListSetter
+  , maskListTime
+
   -- * Topic information
   , TopicProvenance(..)
   , topicAuthor
@@ -64,7 +68,7 @@
         -- ^ user list and sigils
   , _chanModes :: !(Map Char Text)
         -- ^ channel settings and parameters
-  , _chanLists :: !(Map Char (HashMap Text (Text, UTCTime)))
+  , _chanLists :: !(Map Char (HashMap Text MaskListEntry))
         -- ^ mode, mask, setter, set time
   , _chanCreation :: !(Maybe UTCTime) -- ^ creation time of channel
   , _chanQueuedModeration :: ![RawIrcMsg] -- ^ delayed op messages
@@ -77,8 +81,15 @@
   }
   deriving Show
 
+data MaskListEntry = MaskListEntry
+  { _maskListSetter :: {-# UNPACK #-} !Text
+  , _maskListTime   :: {-# UNPACK #-} !UTCTime
+  }
+  deriving Show
+
 makeLenses ''ChannelState
 makeLenses ''TopicProvenance
+makeLenses ''MaskListEntry
 
 -- | Construct an empty 'ChannelState'
 newChannel :: ChannelState
@@ -93,13 +104,6 @@
   , _chanQueuedModeration = []
   }
 
--- | 'Lens' into a mask list for a given mode.
-chanList ::
-  Functor f =>
-  Char {- ^ mode -} ->
-  LensLike' f ChannelState (HashMap Text (Text, UTCTime))
-    {- ^ HashMap mask (setby, setwhen) -}
-chanList mode = chanLists . at mode . non' _Empty
 
 -- | Add a user to the user list
 joinChannel :: Identifier -> ChannelState -> ChannelState
diff --git a/src/Client/Commands.hs b/src/Client/Commands.hs
--- a/src/Client/Commands.hs
+++ b/src/Client/Commands.hs
@@ -68,6 +68,12 @@
 commandContinue :: Monad m => ClientState -> m CommandResult
 commandContinue = return . CommandContinue
 
+commandSuccess :: Monad m => ClientState -> m CommandResult
+commandSuccess = return . CommandContinue . consumeInput
+
+commandFailure :: Monad m => ClientState -> m CommandResult
+commandFailure = return . CommandContinue . set clientBell True
+
 splitWord :: String -> (String, String)
 splitWord str = (w, drop 1 rest)
   where
@@ -110,7 +116,7 @@
             network cs channelId st rest
 
     _ -> case tabCompleteReversed of
-           Nothing         -> commandContinue st
+           Nothing         -> commandFailure st
            Just isReversed -> commandContinue (nickTabCompletion isReversed st)
 
 commands :: HashMap Text Command
@@ -140,6 +146,8 @@
   , ("links"     , NetworkCommand cmdLinks  simpleNetworkTab)
   , ("time"      , NetworkCommand cmdTime   simpleNetworkTab)
   , ("stats"     , NetworkCommand cmdStats  simpleNetworkTab)
+  , ("znc"       , NetworkCommand cmdZnc    simpleNetworkTab)
+  , ("znc-playback", NetworkCommand cmdZncPlayback noNetworkTab)
 
   , ("invite"    , ChannelCommand cmdInvite simpleChannelTab)
   , ("topic"     , ChannelCommand cmdTopic  tabTopic    )
@@ -155,13 +163,13 @@
   ]
 
 noClientTab :: Bool -> ClientCommand
-noClientTab _ st _ = commandContinue st
+noClientTab _ st _ = commandFailure st
 
 noNetworkTab :: Bool -> NetworkCommand
-noNetworkTab _ _ _ st _ = commandContinue st
+noNetworkTab _ _ _ st _ = commandFailure st
 
 noChannelTab :: Bool -> ChannelCommand
-noChannelTab _ _ _ _ st _ = commandContinue st
+noChannelTab _ _ _ _ st _ = commandFailure st
 
 simpleClientTab :: Bool -> ClientCommand
 simpleClientTab isReversed st _ =
@@ -175,18 +183,15 @@
 simpleChannelTab isReversed _ _ _ st _ =
   commandContinue (nickTabCompletion isReversed st)
 
-cmdExit :: ClientState -> String -> IO CommandResult
+cmdExit :: ClientCommand
 cmdExit _ _ = return CommandQuit
 
 -- | When used on a channel that the user is currently
 -- joined to this command will clear the messages but
 -- preserve the window. When used on a window that the
 -- user is not joined to this command will delete the window.
-cmdClear :: ClientState -> String -> IO CommandResult
-cmdClear st _
-  = commandContinue
-  $ windowEffect
-  $ consumeInput st
+cmdClear :: ClientCommand
+cmdClear st _ = commandSuccess (windowEffect st)
   where
     windowEffect
       | isActive  = clearWindow
@@ -207,16 +212,16 @@
                 . csChannels . ix channel) st
 
 
-cmdQuote :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdQuote :: NetworkCommand
 cmdQuote _ cs st rest =
   case parseRawIrcMsg (Text.pack rest) of
-    Nothing  -> commandContinue st
+    Nothing  -> commandFailure st
     Just raw ->
       do sendMsg cs raw
-         commandContinue (consumeInput st)
+         commandSuccess st
 
 -- | Implementation of @/me@
-cmdMe :: NetworkName -> ConnectionState -> Identifier -> ClientState -> String -> IO CommandResult
+cmdMe :: ChannelCommand
 cmdMe network cs channelId st rest =
   do now <- getZonedTime
      let actionTxt = Text.pack ("\^AACTION " ++ rest ++ "\^A")
@@ -227,15 +232,14 @@
                     , _msgBody = IrcBody (Ctcp myNick channelId "ACTION" (Text.pack rest))
                     }
      sendMsg cs (ircPrivmsg channelId actionTxt)
-     commandContinue
-       $ recordChannelMessage network channelId entry
-       $ consumeInput st
+     commandSuccess
+       $ recordChannelMessage network channelId entry st
 
 -- | Implementation of @/ctcp@
-cmdCtcp :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdCtcp :: NetworkCommand
 cmdCtcp network cs st rest =
   case parse of
-    Nothing -> commandContinue st
+    Nothing -> commandFailure st
     Just (target, cmd, args) ->
       do let cmdTxt = Text.toUpper (Text.pack cmd)
              argTxt = Text.pack args
@@ -253,10 +257,10 @@
          return (target, cmd, args)
 
 -- | Implementation of @/notice@
-cmdNotice :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdNotice :: NetworkCommand
 cmdNotice network cs st rest =
   case nextWord rest of
-    Nothing -> commandContinue st
+    Nothing -> commandFailure st
     Just (target, rest1) ->
       do let restTxt = Text.pack rest1
              tgtTxt = Text.pack target
@@ -268,10 +272,10 @@
             network cs st
 
 -- | Implementation of @/msg@
-cmdMsg :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdMsg :: NetworkCommand
 cmdMsg network cs st rest =
   case nextWord rest of
-    Nothing -> commandContinue st
+    Nothing -> commandFailure st
     Just (target, rest1) ->
       do let restTxt = Text.pack rest1
              tgtTxt = Text.pack target
@@ -307,99 +311,140 @@
                           st
                           entries
 
-     commandContinue (consumeInput st')
+     commandSuccess st'
 
-cmdConnect :: ClientState -> String -> IO CommandResult
+cmdConnect :: ClientCommand
 cmdConnect st rest =
   case words rest of
     [networkStr] ->
       do -- abort any existing connection before connecting
          let network = Text.pack networkStr
          st' <- addConnection network =<< abortNetwork network st
-         commandContinue
-           $ changeFocus (NetworkFocus network)
-           $ consumeInput st'
+         commandSuccess
+           $ changeFocus (NetworkFocus network) st'
 
-    _ -> commandContinue st
+    _ -> commandFailure st
 
-cmdFocus :: ClientState -> String -> IO CommandResult
+cmdFocus :: ClientCommand
 cmdFocus st rest =
   case words rest of
     [network] ->
       let focus = NetworkFocus (Text.pack network) in
-      commandContinue
-        $ changeFocus focus
-        $ consumeInput st
+      commandSuccess (changeFocus focus st)
 
     [network,channel] ->
       let focus = ChannelFocus (Text.pack network) (mkId (Text.pack channel)) in
-      commandContinue
-        $ changeFocus focus
-        $ consumeInput st
+      commandSuccess
+        $ changeFocus focus st
 
-    _ -> commandContinue st
+    _ -> commandFailure st
 
-cmdWhois :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdWhois :: NetworkCommand
 cmdWhois _ cs st rest =
   do sendMsg cs (ircWhois (Text.pack <$> words rest))
-     commandContinue (consumeInput st)
+     commandSuccess st
 
-cmdWho :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdWho :: NetworkCommand
 cmdWho _ cs st rest =
   do sendMsg cs (ircWho (Text.pack <$> words rest))
-     commandContinue (consumeInput st)
+     commandSuccess st
 
-cmdWhowas :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdWhowas :: NetworkCommand
 cmdWhowas _ cs st rest =
   do sendMsg cs (ircWhowas (Text.pack <$> words rest))
-     commandContinue (consumeInput st)
+     commandSuccess st
 
-cmdIson :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdIson :: NetworkCommand
 cmdIson _ cs st rest =
   do sendMsg cs (ircIson (Text.pack <$> words rest))
-     commandContinue (consumeInput st)
+     commandSuccess st
 
-cmdUserhost :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdUserhost :: NetworkCommand
 cmdUserhost _ cs st rest =
   do sendMsg cs (ircUserhost (Text.pack <$> words rest))
-     commandContinue (consumeInput st)
+     commandSuccess st
 
-cmdStats :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdStats :: NetworkCommand
 cmdStats _ cs st rest =
   do sendMsg cs (ircStats (Text.pack <$> words rest))
-     commandContinue (consumeInput st)
+     commandSuccess st
 
-cmdAway :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdAway :: NetworkCommand
 cmdAway _ cs st rest =
   do sendMsg cs (ircAway (Text.pack (dropWhile (==' ') rest)))
-     commandContinue (consumeInput st)
+     commandSuccess st
 
-cmdLinks :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdLinks :: NetworkCommand
 cmdLinks _ cs st rest =
   do sendMsg cs (ircLinks (Text.pack <$> words rest))
-     commandContinue (consumeInput st)
+     commandSuccess st
 
-cmdTime :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdTime :: NetworkCommand
 cmdTime _ cs st rest =
   do sendMsg cs (ircTime (Text.pack <$> words rest))
-     commandContinue (consumeInput st)
+     commandSuccess st
 
-cmdMode :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdZnc :: NetworkCommand
+cmdZnc _ cs st rest =
+  do sendMsg cs (ircZnc (Text.words (Text.pack rest)))
+     commandSuccess st
+
+-- TODO: support time ranges
+cmdZncPlayback :: NetworkCommand
+cmdZncPlayback _ cs st rest =
+  case words rest of
+
+    -- request everything
+    [] -> success "0"
+
+    -- current date explicit time
+    [timeStr]
+       | Just tod <- parse timeFormats timeStr ->
+          do now <- getZonedTime
+             successZoned
+               (set (zonedTimeLocalTime . localTimeTimeOfDay) tod now)
+
+    -- explicit date and time
+    [dateStr,timeStr]
+       | Just day  <- parse dateFormats dateStr
+       , Just tod  <- parse timeFormats timeStr ->
+          do tz <- getCurrentTimeZone
+             successZoned ZonedTime
+               { zonedTimeZone = tz
+               , zonedTimeToLocalTime = LocalTime
+                   { localTimeOfDay = tod
+                   , localDay       = day } }
+
+    _ -> commandFailure st
+
+  where
+    timeFormats = ["%T","%R"]
+    dateFormats = ["%F"]
+    parse formats str =
+      asum (map (parseTimeM False defaultTimeLocale ?? str) formats)
+
+    successZoned = success . formatTime defaultTimeLocale "%s"
+
+    success start =
+      do sendMsg cs (ircZnc ["*playback", "play", "*", Text.pack start])
+         commandSuccess st
+
+cmdMode :: NetworkCommand
 cmdMode _ cs st rest = modeCommand (Text.pack <$> words rest) cs st
 
-cmdNick :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
+cmdNick :: NetworkCommand
 cmdNick _ cs st rest =
   case words rest of
     [nick] ->
       do sendMsg cs (ircNick (mkId (Text.pack nick)))
-         commandContinue (consumeInput st)
-    _ -> commandContinue st
+         commandSuccess st
+    _ -> commandFailure st
 
-cmdPart :: NetworkName -> ConnectionState -> Identifier -> ClientState -> String -> IO CommandResult
+cmdPart :: ChannelCommand
 cmdPart _ cs channelId st rest =
   do let msg = dropWhile isSpace rest
      sendMsg cs (ircPart channelId (Text.pack msg))
-     commandContinue (consumeInput st)
+     commandSuccess st
 
 cmdInvite :: NetworkName -> ConnectionState -> Identifier -> ClientState -> String -> IO CommandResult
 cmdInvite _ cs channelId st rest =
@@ -412,14 +457,13 @@
                   else sendModeration channelId [cmd] cs
          commandContinueUpdateCS cs' st
 
-    _ -> commandContinue st
+    _ -> commandFailure st
 
 commandContinueUpdateCS :: ConnectionState -> ClientState -> IO CommandResult
 commandContinueUpdateCS cs st =
   let networkId = view csNetworkId cs in
-  commandContinue
-    $ setStrict (clientConnections . ix networkId) cs
-    $ consumeInput st
+  commandSuccess
+    $ setStrict (clientConnections . ix networkId) cs st
 
 cmdTopic :: NetworkName -> ConnectionState -> Identifier -> ClientState -> String -> IO CommandResult
 cmdTopic _ cs channelId st rest =
@@ -431,7 +475,7 @@
                           ("TOPIC " <> idText channelId <> Text.pack (' ' : topic))
                    | otherwise -> ircTopic channelId (Text.pack topic)
      sendMsg cs cmd
-     commandContinue (consumeInput st)
+     commandSuccess st
 
 tabTopic ::
   Bool {- ^ reversed -} ->
@@ -447,32 +491,26 @@
                     . set Edit.content ("/topic " ++ Text.unpack topic)
         commandContinue (over clientTextBox textBox st)
 
-  | otherwise = commandContinue st
+  | otherwise = commandFailure st
 
 
-cmdUsers :: NetworkName -> ConnectionState -> Identifier -> ClientState -> String -> IO CommandResult
-cmdUsers _ _ _ st _ = commandContinue
-                    $ changeSubfocus FocusUsers
-                    $ consumeInput st
+cmdUsers :: ChannelCommand
+cmdUsers _ _ _ st _ = commandSuccess (changeSubfocus FocusUsers st)
 
-cmdChannelInfo :: NetworkName -> ConnectionState -> Identifier -> ClientState -> String -> IO CommandResult
-cmdChannelInfo _ _ _ st _
-  = commandContinue
-  $ changeSubfocus FocusInfo
-  $ consumeInput st
+cmdChannelInfo :: ChannelCommand
+cmdChannelInfo _ _ _ st _ = commandSuccess (changeSubfocus FocusInfo st)
 
-cmdMasks :: NetworkName -> ConnectionState -> Identifier -> ClientState -> String -> IO CommandResult
+cmdMasks :: ChannelCommand
 cmdMasks _ cs _ st rest =
   case words rest of
     [[mode]] | mode `elem` view (csModeTypes . modesLists) cs ->
-        commandContinue $ changeSubfocus (FocusMasks mode)
-                        $ consumeInput st
-    _ -> commandContinue st
+        commandSuccess (changeSubfocus (FocusMasks mode) st)
+    _ -> commandFailure st
 
 cmdKick :: NetworkName -> ConnectionState -> Identifier -> ClientState -> String -> IO CommandResult
 cmdKick _ cs channelId st rest =
   case nextWord rest of
-    Nothing -> commandContinue st
+    Nothing -> commandFailure st
     Just (who,reason) ->
       do let msg = Text.pack (dropWhile isSpace reason)
              cmd = ircKick channelId (Text.pack who) msg
@@ -483,7 +521,7 @@
 cmdKickBan :: NetworkName -> ConnectionState -> Identifier -> ClientState -> String -> IO CommandResult
 cmdKickBan _ cs channelId st rest =
   case nextWord rest of
-    Nothing -> commandContinue st
+    Nothing -> commandFailure st
     Just (whoStr,reason) ->
       do let msg = Text.pack (dropWhile isSpace reason)
 
@@ -505,7 +543,7 @@
 cmdRemove :: NetworkName -> ConnectionState -> Identifier -> ClientState -> String -> IO CommandResult
 cmdRemove _ cs channelId st rest =
   case nextWord rest of
-    Nothing -> commandContinue st
+    Nothing -> commandFailure st
     Just (who,reason) ->
       do let msg = Text.pack (dropWhile isSpace reason)
              cmd = ircRemove channelId (Text.pack who) msg
@@ -518,25 +556,24 @@
       doJoin channelStr keyStr =
         do let channelId = mkId (Text.pack (takeWhile (/=',') channelStr))
            sendMsg cs (ircJoin (Text.pack channelStr) (Text.pack <$> keyStr))
-           commandContinue
-               $ changeFocus (ChannelFocus network channelId)
-               $ consumeInput st
+           commandSuccess
+               $ changeFocus (ChannelFocus network channelId) st
   in case ws of
        [channel]     -> doJoin channel Nothing
        [channel,key] -> doJoin channel (Just key)
-       _             -> commandContinue st
+       _             -> commandFailure st
 
 
 cmdQuit :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
 cmdQuit _ cs st rest =
   do let msg = Text.pack (dropWhile isSpace rest)
      sendMsg cs (ircQuit msg)
-     commandContinue (consumeInput st)
+     commandSuccess st
 
 cmdDisconnect :: NetworkName -> ConnectionState -> ClientState -> String -> IO CommandResult
 cmdDisconnect network _ st _ =
   do st' <- abortNetwork network st
-     commandContinue (consumeInput st')
+     commandSuccess st'
 
 -- | Reconnect to the currently focused network. It's possible
 -- that we're not currently connected to a network, so
@@ -546,19 +583,17 @@
   | Just network <- views clientFocus focusNetwork st =
 
       do st' <- addConnection network =<< abortNetwork network st
-         commandContinue
-           $ changeFocus (NetworkFocus network)
-           $ consumeInput st'
+         commandSuccess
+           $ changeFocus (NetworkFocus network) st'
 
-  | otherwise = commandContinue st
+  | otherwise = commandFailure st
 
 cmdIgnore :: ClientState -> String -> IO CommandResult
 cmdIgnore st rest =
   case mkId . Text.pack <$> words rest of
-    [] -> commandContinue st
-    xs -> commandContinue
-            $ over clientIgnores updateIgnores
-            $ consumeInput st
+    [] -> commandFailure st
+    xs -> commandSuccess
+            $ over clientIgnores updateIgnores st
       where
         updateIgnores :: HashSet Identifier -> HashSet Identifier
         updateIgnores s = foldl' updateIgnore s xs
@@ -570,14 +605,14 @@
 
     NetworkFocus _ ->
       do sendMsg cs (ircMode (view csNick cs) modes)
-         commandContinue (consumeInput st)
+         commandSuccess st
 
     ChannelFocus _ chan ->
       case modes of
         [] -> success False [[]]
         flags:params ->
           case splitModes (view csModeTypes cs) flags params of
-            Nothing -> commandContinue st
+            Nothing -> commandFailure st
             Just parsedModes ->
               success needOp (unsplitModes <$> chunksOf (view csModeCount cs) parsedModes')
               where
@@ -597,7 +632,7 @@
                       else cs <$ traverse_ (sendMsg cs) cmds
              commandContinueUpdateCS cs' st
 
-    _ -> commandContinue st
+    _ -> commandFailure st
 
 tabMode :: Bool -> NetworkCommand
 tabMode isReversed _ cs st rest =
@@ -666,7 +701,7 @@
   = fromMaybe st
   $ clientTextBox (wordComplete (++": ") isReversed completions) st
   where
-    completions = currentUserList st
+    completions = currentCompletionList st
 
 sendModeration :: Identifier -> [RawIrcMsg] -> ConnectionState -> IO ConnectionState
 sendModeration channel cmds cs
diff --git a/src/Client/Connect.hs b/src/Client/Connect.hs
--- a/src/Client/Connect.hs
+++ b/src/Client/Connect.hs
@@ -103,11 +103,8 @@
            []    -> fail "No private keys found"
            _     -> fail "Too many private keys found"
 
-connect :: ConnectionContext -> ServerSettings -> IO Connection
-connect connectionContext args = do
-  connectionParams <- buildConnectionParams args
-  connectTo connectionContext connectionParams
-
 -- | Create a new 'Connection' which will be closed when the continuation finishes.
 withConnection :: ConnectionContext -> ServerSettings -> (Connection -> IO a) -> IO a
-withConnection cxt settings = bracket (connect cxt settings) connectionClose
+withConnection cxt settings k =
+  do params <- buildConnectionParams settings
+     bracket (connectTo cxt params) connectionClose k
diff --git a/src/Client/ConnectionState.hs b/src/Client/ConnectionState.hs
--- a/src/Client/ConnectionState.hs
+++ b/src/Client/ConnectionState.hs
@@ -121,7 +121,7 @@
 data Transaction
   = NoTransaction
   | NamesTransaction [Text]
-  | BanTransaction [(Text,(Text,UTCTime))]
+  | BanTransaction [(Text,MaskListEntry)]
   | WhoTransaction [UserInfo]
   deriving Show
 
@@ -345,71 +345,43 @@
 
     RPL_BANLIST ->
       case args of
-        _me:_tgt:mask:who:whenTxt:_ | Just when <- parseTimeParam whenTxt ->
-          over csTransaction $ \t ->
-            let !xs = view _BanTransaction t
-            in BanTransaction ((mask,(who,when)):xs)
-        _ -> id
+        _me:_tgt:mask:who:whenTxt:_ -> recordListEntry mask who whenTxt
+        _                           -> id
 
     RPL_ENDOFBANLIST ->
       case args of
-        _me:tgt:_ -> \cs ->
-           set csTransaction NoTransaction
-         $ setStrict (csChannels . ix (mkId tgt) . chanList 'b')
-                     (HashMap.fromList (view (csTransaction . _BanTransaction) cs))
-                     cs
-        _ -> id
+        _me:tgt:_ -> saveList 'b' tgt
+        _         -> id
 
     RPL_QUIETLIST ->
       case args of
-        _me:_tgt:_q:mask:who:whenTxt:_ | Just when <- parseTimeParam whenTxt ->
-          over csTransaction $ \t ->
-            let !xs = view _BanTransaction t
-            in BanTransaction ((mask,(who,when)):xs)
-        _ -> id
+        _me:_tgt:_q:mask:who:whenTxt:_ -> recordListEntry mask who whenTxt
+        _                              -> id
 
     RPL_ENDOFQUIETLIST ->
       case args of
-        _me:tgt:_ -> \cs ->
-           set csTransaction NoTransaction
-         $ setStrict (csChannels . ix (mkId tgt) . chanList 'q')
-                     (HashMap.fromList (view (csTransaction . _BanTransaction) cs))
-                     cs
-        _ -> id
+        _me:tgt:_ -> saveList 'q' tgt
+        _         -> id
 
     RPL_INVEXLIST ->
       case args of
-        _me:_tgt:mask:who:whenTxt:_ | Just when <- parseTimeParam whenTxt ->
-          over csTransaction $ \t ->
-            let !xs = view _BanTransaction t
-            in BanTransaction ((mask,(who,when)):xs)
-        _ -> id
+        _me:_tgt:mask:who:whenTxt:_ -> recordListEntry mask who whenTxt
+        _                           -> id
 
     RPL_ENDOFINVEXLIST ->
       case args of
-        _me:tgt:_ -> \cs ->
-           set csTransaction NoTransaction
-         $ setStrict (csChannels . ix (mkId tgt) . chanList 'I')
-                     (HashMap.fromList (view (csTransaction . _BanTransaction) cs))
-                     cs
-        _ -> id
+        _me:tgt:_ -> saveList 'I' tgt
+        _         -> id
 
     RPL_EXCEPTLIST ->
       case args of
-        _me:_tgt:mask:who:whenTxt:_ | Just when <- parseTimeParam whenTxt ->
-          over csTransaction $ \t ->
-            let !xs = view _BanTransaction t
-            in BanTransaction ((mask,(who,when)):xs)
-        _ -> id
+        _me:_tgt:mask:who:whenTxt:_ -> recordListEntry mask who whenTxt
+        _                           -> id
 
     RPL_ENDOFEXCEPTLIST ->
       case args of
-        _me:tgt:_ -> \cs ->
-             set csTransaction NoTransaction
-           $ setStrict (csChannels . ix (mkId tgt) . chanList 'e')
-                       (HashMap.fromList (view (csTransaction . _BanTransaction) cs))
-                       cs
-        _ -> id
+        _me:tgt:_ -> saveList 'e' tgt
+        _         -> id
 
     RPL_WHOREPLY ->
       case args of
@@ -432,6 +404,41 @@
     _ -> id
 
 
+-- | Add an entry to a mode list transaction
+recordListEntry ::
+  Text {- ^ mask -} ->
+  Text {- ^ set by -} ->
+  Text {- ^ set time -} ->
+  ConnectionState -> ConnectionState
+recordListEntry mask who whenTxt =
+  case parseTimeParam whenTxt of
+    Nothing   -> id
+    Just when ->
+      over csTransaction $ \t ->
+        let !x = MaskListEntry
+                    { _maskListSetter = who
+                    , _maskListTime   = when
+                    }
+            !xs = view _BanTransaction t
+        in BanTransaction ((mask,x):xs)
+
+
+-- | Save a completed ban, quiet, invex, or exempt list into the channel
+-- state.
+saveList ::
+  Char {- ^ mode -} ->
+  Text {- ^ channel -} ->
+  ConnectionState -> ConnectionState
+saveList mode tgt cs
+   = set csTransaction NoTransaction
+   $ setStrict
+        (csChannels . ix (mkId tgt) . chanLists . at mode)
+        (Just $! newList)
+        cs
+  where
+    newList = HashMap.fromList (view (csTransaction . _BanTransaction) cs)
+
+
 -- | These replies are interpreted by the client and should only be shown
 -- in the detailed view.
 squelchReply :: ReplyCode -> Bool
@@ -517,9 +524,12 @@
                      c
 
       | mode `elem` listModes =
-        let entry | polarity = Just (renderUserInfo who, zonedTimeToUTC when)
+        let entry | polarity = Just $! MaskListEntry
+                         { _maskListSetter = renderUserInfo who
+                         , _maskListTime   = zonedTimeToUTC when
+                         }
                   | otherwise = Nothing
-        in setStrict (chanList mode . at arg) entry c
+        in setStrict (chanLists . ix mode . at arg) entry c
 
       | polarity  = set (chanModes . at mode) (Just arg) c
       | otherwise = set (chanModes . at mode) Nothing    c
@@ -541,7 +551,7 @@
     applyOne modes (False, mode, _) = delete mode modes
 
 supportedCaps :: ConnectionState -> [Text]
-supportedCaps cs = sasl ++ ["multi-prefix", "znc.in/server-time-iso"]
+supportedCaps cs = sasl ++ ["multi-prefix", "znc.in/playback", "znc.in/server-time-iso"]
   where
     ss = view csSettings cs
     sasl = ["sasl" | isJust (view ssSaslUsername ss)
diff --git a/src/Client/EventLoop.hs b/src/Client/EventLoop.hs
--- a/src/Client/EventLoop.hs
+++ b/src/Client/EventLoop.hs
@@ -87,10 +87,11 @@
 -- | Apply this function to an initial 'ClientState' to launch the client.
 eventLoop :: ClientState -> IO ()
 eventLoop st0 =
-  do st1 <- clientTick st0
-     let vty = view clientVty st
+  do let st1 = clientTick st0
+         vty = view clientVty st
          (pic, st) = clientPicture st1
 
+     when (view clientBell st0) (beep vty)
      update vty pic
 
      event <- getEvent st
@@ -103,6 +104,9 @@
            NetworkError network time ex   -> doNetworkError network time ex st
            NetworkClose network time      -> doNetworkClose network time st
 
+beep :: Vty -> IO ()
+beep vty = outputByteBuffer (outputIface vty) "\BEL"
+
 -- | Respond to a network connection closing normally.
 doNetworkClose ::
   NetworkId {- ^ finished network -} ->
@@ -301,7 +305,7 @@
 execute :: ClientState -> IO ()
 execute st =
   case clientInput st of
-    []          -> eventLoop st
+    []          -> eventLoop (set clientBell True st)
     '/':command -> do res <- executeCommand Nothing command st
                       case res of
                         CommandQuit -> return ()
@@ -327,7 +331,7 @@
              eventLoop $ recordChannelMessage network channel entry
                        $ consumeInput st
 
-    _ -> eventLoop st
+    _ -> eventLoop (set clientBell True st)
 
 -- | Respond to a timer event.
 doTimerEvent ::
diff --git a/src/Client/Image.hs b/src/Client/Image.hs
--- a/src/Client/Image.hs
+++ b/src/Client/Image.hs
@@ -153,6 +153,7 @@
       [ myNickImage st
       , focusImage st
       , activityImage st
+      , detailImage st
       , scrollImage st
       , latencyImage st
       ]
@@ -168,6 +169,15 @@
       , string (withForeColor defAttr red) "scroll"
       , string defAttr ")"
       ]
+
+detailImage :: ClientState -> Image
+detailImage st
+  | view clientDetailView st = horizCat
+      [ string defAttr "─("
+      , string (withForeColor defAttr red) "detail"
+      , string defAttr ")"
+      ]
+  | otherwise = emptyImage
 
 activityImage :: ClientState -> Image
 activityImage st
diff --git a/src/Client/Image/MaskList.hs b/src/Client/Image/MaskList.hs
--- a/src/Client/Image/MaskList.hs
+++ b/src/Client/Image/MaskList.hs
@@ -17,9 +17,11 @@
 import           Client.ConnectionState
 import           Client.State
 import           Control.Lens
+import           Data.HashMap.Strict (HashMap)
 import qualified Data.HashMap.Strict as HashMap
 import           Data.List
 import           Data.Ord
+import           Data.Text (Text)
 import           Data.Time
 import           Graphics.Vty.Image
 import           Irc.Identifier
@@ -30,8 +32,21 @@
   NetworkName {- ^ Focused network -} ->
   Identifier  {- ^ Focused channel -} ->
   ClientState -> [Image]
-maskListImages mode network channel st = countImage : images
+maskListImages mode network channel st =
+  case mbEntries of
+    Nothing      -> [text' (withForeColor defAttr red) "Mask list not loaded"]
+    Just entries -> maskListImages' entries st
+
   where
+    mbEntries = preview
+                ( clientConnection network
+                . csChannels . ix channel
+                . chanLists . ix mode
+                ) st
+
+maskListImages' :: HashMap Text MaskListEntry -> ClientState -> [Image]
+maskListImages' entries st = countImage : images
+  where
     countImage = text' (withForeColor defAttr green) "Masks (visible/total): " <|>
                  string defAttr (show (length entryList)) <|>
                  char (withForeColor defAttr green) '/' <|>
@@ -39,17 +54,12 @@
 
     matcher = clientMatcher st
 
-    matcher' (x,(y,_)) = matcher x || matcher y
+    matcher' (mask,entry) = matcher mask || matcher (view maskListSetter entry)
 
-    entryList = sortBy (flip (comparing (snd . snd)))
+    entryList = sortBy (flip (comparing (view (_2 . maskListTime))))
               $ filter matcher'
               $ HashMap.toList entries
 
-    entries = view ( clientConnection network
-                   . csChannels . ix channel
-                   . chanList mode
-                   ) st
-
     renderWhen = formatTime defaultTimeLocale " %F %T"
 
     (masks, whoWhens) = unzip entryList
@@ -61,7 +71,7 @@
     images = [ cropLine $ mask <|>
                           text' defAttr who <|>
                           string defAttr (renderWhen when)
-             | (mask, (who, when)) <- zip paddedMaskImages whoWhens ]
+             | (mask, MaskListEntry who when) <- zip paddedMaskImages whoWhens ]
 
     cropLine img
       | imageWidth img > width = cropRight width img
diff --git a/src/Client/State.hs b/src/Client/State.hs
--- a/src/Client/State.hs
+++ b/src/Client/State.hs
@@ -32,12 +32,13 @@
   , clientNetworkMap
   , clientIgnores
   , clientConnection
+  , clientBell
   , initialClientState
 
   -- * Client operations
   , clientMatcher
   , consumeInput
-  , currentUserList
+  , currentCompletionList
   , ircIgnorable
   , clientInput
   , abortNetwork
@@ -151,6 +152,7 @@
   , _clientConfig            :: !Configuration            -- ^ client configuration
   , _clientScroll            :: !Int                      -- ^ buffer scroll lines
   , _clientDetailView        :: !Bool                     -- ^ use detailed rendering mode
+  , _clientBell              :: !Bool                     -- ^ sound a bell next draw
 
   , _clientIgnores           :: !(HashSet Identifier)     -- ^ ignored nicknames
   }
@@ -198,6 +200,7 @@
         , _clientNextConnectionId  = 0
         , _clientNetworkMap        = HashMap.empty
         , _clientIgnores           = HashSet.empty
+        , _clientBell              = False
         }
 
 -- | Forcefully terminate the connection currently associated
@@ -366,10 +369,16 @@
 toWindowLine' :: ClientMessage -> WindowLine
 toWindowLine' = toWindowLine defaultRenderParams
 
-clientTick :: ClientState -> IO ClientState
-clientTick st =
-     return $! over (clientWindows . ix (view clientFocus st)) windowSeen st
+clientTick :: ClientState -> ClientState
+clientTick = set clientBell False . markSeen
 
+
+markSeen :: ClientState -> ClientState
+markSeen st =
+  case view clientSubfocus st of
+    FocusMessages -> overStrict (clientWindows . ix (view clientFocus st)) windowSeen st
+    _             -> st
+
 consumeInput :: ClientState -> ClientState
 consumeInput = over clientTextBox Edit.success
 
@@ -403,12 +412,21 @@
     oldFocus = view clientFocus st
     windows  = view clientWindows st
 
-currentUserList :: ClientState -> [Identifier]
-currentUserList st =
+-- | Returns the current network's channels and current channel's users.
+currentCompletionList :: ClientState -> [Identifier]
+currentCompletionList st =
   case view clientFocus st of
-    ChannelFocus network chan -> channelUserList network chan st
+    NetworkFocus network ->
+         networkChannelList network st
+    ChannelFocus network chan ->
+         networkChannelList network st
+      ++ channelUserList network chan st
     _                         -> []
 
+networkChannelList :: NetworkName -> ClientState -> [Identifier]
+networkChannelList network =
+  views (clientConnection network . csChannels) HashMap.keys
+
 channelUserList :: NetworkName -> Identifier -> ClientState -> [Identifier]
 channelUserList network channel =
   views (clientConnection network . csChannels . ix channel . chanUsers) HashMap.keys
@@ -516,4 +534,3 @@
       | otherwise         = x
 
 applyWindowRenames _ _ st = st
-
diff --git a/src/Irc/Commands.hs b/src/Irc/Commands.hs
--- a/src/Irc/Commands.hs
+++ b/src/Irc/Commands.hs
@@ -36,6 +36,9 @@
   , ircWhois
   , ircWhowas
 
+  -- * ZNC support
+  , ircZnc
+
   -- * SASL support
   , ircAuthenticate
   , plainAuthenticationMode
@@ -227,6 +230,14 @@
 -- | CAP LS command
 ircCapLs :: RawIrcMsg
 ircCapLs = rawIrcMsg "CAP" ["LS"]
+
+-- | ZNC command
+--
+-- /specific to ZNC/
+ircZnc ::
+  [Text] {- ^ parameters -} ->
+  RawIrcMsg
+ircZnc = rawIrcMsg "ZNC"
 
 -- | AUTHENTICATE command
 ircAuthenticate :: Text -> RawIrcMsg
diff --git a/src/Irc/Identifier.hs b/src/Irc/Identifier.hs
--- a/src/Irc/Identifier.hs
+++ b/src/Irc/Identifier.hs
@@ -61,17 +61,6 @@
 ircFoldCase = B.map (B.index casemap . fromIntegral)
 
 casemap :: ByteString
-casemap = B8.pack
-          "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\
-          \\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\
-          \ !\"#$%&'()*+,-./0123456789:;<=>?\
-          \@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_\
-          \`ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^\x7f\
-          \\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\
-          \\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\
-          \\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\
-          \\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\
-          \\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\
-          \\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\
-          \\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\
-          \\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"
+casemap
+  = B8.pack
+  $ ['\x00'..'`'] ++ "ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^" ++ ['\x7f'..'\xff']
diff --git a/src/Irc/Modes.hs b/src/Irc/Modes.hs
--- a/src/Irc/Modes.hs
+++ b/src/Irc/Modes.hs
@@ -35,11 +35,11 @@
 
 -- | Settings that describe how to interpret channel modes
 data ModeTypes = ModeTypes
-  { _modesLists       :: ![Char] -- ^ modes for channel lists (e.g. ban)
-  , _modesAlwaysArg   :: ![Char] -- ^ modes that always have an argument
-  , _modesSetArg      :: ![Char] -- ^ modes that have an argument when set
-  , _modesNeverArg    :: ![Char] -- ^ modes that never have arguments
-  , _modesPrefixModes :: ![(Char,Char)] -- ^ modes requiring a nickname argument (mode,sigil)
+  { _modesLists       :: [Char] -- ^ modes for channel lists (e.g. ban)
+  , _modesAlwaysArg   :: [Char] -- ^ modes that always have an argument
+  , _modesSetArg      :: [Char] -- ^ modes that have an argument when set
+  , _modesNeverArg    :: [Char] -- ^ modes that never have arguments
+  , _modesPrefixModes :: [(Char,Char)] -- ^ modes requiring a nickname argument (mode,sigil)
   }
   deriving Show
 
diff --git a/src/LensUtils.hs b/src/LensUtils.hs
--- a/src/LensUtils.hs
+++ b/src/LensUtils.hs
@@ -12,11 +12,16 @@
   ( Id'
   , overStrict
   , setStrict
+
+  -- * time lenses
+  , zonedTimeLocalTime
+  , localTimeTimeOfDay
   ) where
 
 import           Control.Lens
 import           Control.Applicative
 import           Data.Profunctor.Unsafe
+import           Data.Time
 
 newtype Id' a = Id' { runId' :: a }
 
@@ -40,3 +45,9 @@
 setStrict :: ASetter s t a b -> b -> s -> t
 setStrict l x = set l $! x
 {-# INLINE setStrict #-}
+
+zonedTimeLocalTime :: Lens' ZonedTime LocalTime
+zonedTimeLocalTime f (ZonedTime t z) = f t <&> \t' -> ZonedTime t' z
+
+localTimeTimeOfDay :: Lens' LocalTime TimeOfDay
+localTimeTimeOfDay f (LocalTime d t) = LocalTime d <$> f t
