diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,113 @@
 
+50200.11.0
+==========
+
+New features:
+ * Matterhorn now opens post links itself rather than invoking the
+   configured URL open command. This means that if a user obtains a link
+   to a post in the web client and pastes it into a message, Matterhorn
+   will detect it, and if the user chooses to open that link, Matterhorn
+   will switch to the post's channel and show the post. This change
+   comes a new theme attribute, `permalink`, that is used to style post
+   permalinks. Post links with no labels appear as `<post link>`; post
+   links with labels are rendered with their labels. Post links also
+   appear in the URL list accessed with `C-o`, and they are opened by
+   the message selection mode `o` binding.
+ * Message timestamp visibility can now be controlled with the
+   configuration setting `showMessageTimestamps` (default: `True`) and
+   can be toggled at runtime with the new `/toggle-message-timestamps`
+   command.
+ * The channel list can now be displayed on either the left side of
+   the screen or the right. This is controlled with a new configuration
+   setting, `channelListOrientation`, set to either `left` (the default
+   and previous behavior) or `right`.
+ * Matterhorn now supports strikethrough syntax in messages. This change
+   adds a new theme attribute, `markdownStrikethrough`, that
+   is used to style strikethrough text. This change also adds
+   support for `strikethrough` as a valid style specifier in theme
+   customization files. Bear in mind that your terminal might
+   not support strikethrough; for details, see the FAQ entry on
+   strikethrough support.
+ * Matterhorn now has improved handling for channel topics (headers)
+   with multiple lines of text. Previously Matterhorn displayed all
+   lines in a multi-line channel topic but replaced newlines with
+   spaces. That resulted in broken layouts for multi-line topics where
+   layout was important. Now it renders the topics as originally
+   intended with user control over how multi-line topics are displayed
+   in order to let the user control how the topic affects screen real
+   estate use.
+   * Added a new key event, `toggle-expanded-channel-topics`, to toggle
+     whether channel topics show only their first line or all lines.
+   * Added a new configuration setting, `showExpandedChannelTopics`, to
+     set this behavior on startup (default: `True`).
+   * Added a new command, `/toggle-expanded-topics`, and a new default
+     keybinding, `F3`, to control this behavior at runtime.
+ * Added a `/topic` command that opens an interactive topic editor when
+   run with no arguments. The editor provides a live Markdown preview
+   of the channel topic. As part of the UI for this feature, new theme
+   attributes were added: `button` and `button.focused` for rendering
+   unfocused and focused buttons, respectively.
+ * Command autocompletion now queries the server for available commands
+   and includes those in the command list. Server commands are also
+   indicated as being provided by the server to help distinguish them
+   from Matterhorn-specific commands.
+
+Other changes:
+ * Channel selection mode (`C-g`) now displays entries more consistently
+   with the normal channel sidebar, including:
+   * Channel mention counts and visual styles
+   * The previous channel sigil (`<`) and first-unread channel sigil
+     (`~`)
+   * Channel mute status (`(m)`)
+   * Unread status and visual style
+ * When composing replies, Mattehorn now displays the threat root
+   message of the thread being replied to, rather than the message
+   the user selected for reply (#670). This is intended to be less
+   surprising than the old behavior since showing the root post in the
+   preview is consistent with the display of the post in the message
+   list once it is posted, where the root post is shown above the reply.
+ * Matterhorn no longer considers external program output to constitute
+   an error, instead only reporting external program errors on non-zero
+   exit status (#665).
+ * Matterhorn now decorates all hyperlinks with angle brackets. This
+   change is intended to get around issues caused when a hyperlink label
+   has a style or color that prevents the normal hyperlink color from
+   being used, thus concealing the fact that it is a link. This makes
+   all links visible regardless of link label content. In particular,
+   links labeled `foo` now get rendered as `<foo>`. Links with no labels
+   also get decorated this way.
+ * The total unread channel count is now included in the window title
+   bar updates.
+ * The total unread channel count is now included in the channel list
+   header.
+ * Long lines in code blocks with no syntax highlighting are now wrapped
+   to improve readability.
+ * Command autocompletion now sorts command completions to prefer prefix
+   matches first.
+ * Command autocomplete now makes only one request for the server
+   command list per completion attempt.
+ * There is now a new `/shortcuts` command to stand in for
+   server-provided version which did nothing in Matterhorn.
+ * Message selection mode now remains active when:
+   * Opening message URLs (`C-s`/`o`),
+   * Exiting the message view window (`C-s`/`v`), and
+   * Exiting the emoji reaction list window (`C-s`/`a`).
+ * The channel list now includes an unread indicator in the DM channel
+   section header when any DM channels are unread.
+ * The visual style for channel selection match text now includes the
+   underline style.
+ * All unknown websocket events are now logged rather than triggering
+   messages to ask the user to report issues.
+
+Bug fixes:
+ * The URL list (`C-o`) now has a fixed header when the channel in
+   question is a DM channel.
+ * The URL list no longer treats URLs with the same target but different
+   labels as duplicates.
+ * The message editor's multi-line toggle UI hint now looks up the
+   active keybinding rather than hard-coding it.
+ * The channel sidebar's unread logic was improved (#655).
+
 50200.10.3
 ==========
 
@@ -128,7 +237,7 @@
    followed by a space. While this is often desirable behavior, it
    also meant that if the very next input character was a punctuation
    character such as a period, the final text would be `@user .` Based
-   on the observation that usally the intention is to end up with
+   on the observation that usually the intention is to end up with
    `@user.`, the editor now automatically replaces the trailing space
    when such a punctuation character is entered. This behavior is
    configurable with the `smartediting` configuration setting which
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -24,8 +24,9 @@
 
 When you run Matterhorn you'll be prompted for your server URL and
 credentials. To connect, just paste your web client's Mattermost URL
-into the Server URL box and enter your credentials. See the next section
-on the details for providing each kind of supported credentials.
+into the Server URL box and enter your credentials. See the [Matterhorn
+User Guide](docs/UserGuide.md) on the details for providing each kind of
+supported credentials.
 
 Note: Version `ABBCC.X.Y` matches Mattermost server version `A.BB.CC`.
 For example, if your Mattermost server version is `3.6.0` then you
diff --git a/client-scripts/cowsay b/client-scripts/cowsay
new file mode 100644
--- /dev/null
+++ b/client-scripts/cowsay
@@ -0,0 +1,7 @@
+#!/bin/bash -e
+
+# We want the fences so that the cowsay block gets rendered
+# in Markdown as a code block. Otherwise, we just call cowsay.
+echo '~~~~~~~~~~'
+cowsay
+echo '~~~~~~~~~~'
diff --git a/client-scripts/figlet b/client-scripts/figlet
new file mode 100644
--- /dev/null
+++ b/client-scripts/figlet
@@ -0,0 +1,7 @@
+#!/bin/bash -e
+
+# We want the fences so that the cowsay block gets rendered
+# in Markdown as a code block. Otherwise, we just call cowsay.
+echo '~~~~~~~~~~'
+figlet
+echo '~~~~~~~~~~'
diff --git a/client-scripts/rot13 b/client-scripts/rot13
new file mode 100644
--- /dev/null
+++ b/client-scripts/rot13
@@ -0,0 +1,4 @@
+#!/bin/bash -e
+
+# This should be a reasonably portable ROT-13-izer across Unixes
+tr '[A-Za-z]' '[N-ZA-Mn-za-m]'
diff --git a/client-scripts/talky b/client-scripts/talky
new file mode 100644
--- /dev/null
+++ b/client-scripts/talky
@@ -0,0 +1,38 @@
+#!/bin/bash -e
+
+BASE_URL="https://talky.io"
+
+base64enc() {
+    if [[ -x $(which shasum) ]] ; then
+        retval=$(echo $1 | shasum | sed 's/ .*//g')
+    elif [[ -x $(which openssl) ]] ; then
+        retval=$(echo $1 | openssl enc -base64 | sed 's/\//x/g' | sed 's/+/x/g' | sed 's/=/x/g')
+    elif [[ -x $(which base64) ]] ; then
+        retval=$(echo $1 | base64 | sed 's/\//x/g' | sed 's/+/x/g' | sed 's/=/x/g')
+    else
+        echo "Unable to base64 encode, install base64?"
+        exit
+    fi
+    echo $retval
+}
+
+getroom() {
+if [[ -z "$1" ]]; then
+    if [[ ! -a /dev/urandom ]] ; then
+        echo "No urandom! Can not make random room name."
+        exit
+    elif [[ ! -x $(which dd) ]] ; then
+        echo "No `dd` utility, can not read random file."
+        exit
+    else
+        ROOM_RAND=$(dd if=/dev/urandom bs=1 count=32 2>/dev/null)
+        ROOM_NAME=$( base64enc ${ROOM_RAND} )
+    fi
+else
+    ROOM_NAME=$1
+fi
+}
+
+getroom
+
+echo "${BASE_URL}/${ROOM_NAME}"
diff --git a/docs/commands.md b/docs/commands.md
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -11,7 +11,7 @@
 | `/focus <~channel>` | Focus on a channel or user |
 | `/focus` | Select from available channels |
 | `/group-msg <@user [@user ...]>` | Create a group chat |
-| `/help` | Show this help screen |
+| `/help` | Show the main help screen |
 | `/help <topic>` | Show help about a particular topic |
 | `/hide` | Hide the current DM or group channel from the channel list |
 | `/join` | Find a channel to join |
@@ -23,7 +23,6 @@
 | `/log-start <path>` | Begin logging debug information to the specified path |
 | `/log-status` | Show current debug logging status |
 | `/log-stop` | Stop logging |
-| `/me <message>` | Send an emote message |
 | `/members` | Show the current channel's members |
 | `/message-preview` | Toggle preview of the current message |
 | `/msg` | Search for a user to enter a private chat |
@@ -31,7 +30,6 @@
 | `/msg <@user> <message or command>` | Go to a user's channel and send the specified message or command |
 | `/notify-prefs` | Edit the current channel's notification preferences |
 | `/pinned-posts` | Open a window of this channel's pinned posts |
-| `/purpose <purpose>` | Set the current channel's purpose |
 | `/quit` | Exit Matterhorn |
 | `/reconnect` | Force a reconnection attempt to the server |
 | `/remove-user <@user>` | Remove a user from the current channel |
@@ -39,10 +37,13 @@
 | `/search <terms>` | Search for posts with given terms |
 | `/sh` | List the available shell scripts |
 | `/sh <script> <message>` | Run a prewritten shell script |
-| `/shrug <message>` | Send a message followed by a shrug emoticon |
+| `/shortcuts` | Show keyboard shortcuts |
 | `/theme` | List the available themes |
 | `/theme <theme>` | Set the color theme |
 | `/toggle-channel-list` | Toggle channel list visibility |
+| `/toggle-expanded-topics` | Toggle expanded channel topics |
+| `/toggle-message-timestamps` | Toggle message timestamps |
+| `/topic` | Set the current channel's topic (header) interactively |
 | `/topic <topic>` | Set the current channel's topic (header) |
 | `/user` | Show users to initiate a private DM chat channel |
 | `/username-attribute <@user>` | Display the attribute used to color the specified username |
diff --git a/docs/keybindings.md b/docs/keybindings.md
--- a/docs/keybindings.md
+++ b/docs/keybindings.md
@@ -23,6 +23,7 @@
 | `C-r` | `reply-recent` | Reply to the most recent message |
 | `M-p` | `toggle-message-preview` | Toggle message preview |
 | `F2` | `toggle-channel-list-visibility` | Toggle channel list visibility |
+| `F3` | `toggle-expanded-channel-topics` | Toggle display of expanded channel topics |
 | `M-k` | `invoke-editor` | Invoke `$EDITOR` to edit the current message |
 | `C-g` | `enter-fast-select` | Enter fast channel selection mode |
 | `C-q` | `quit` | Quit |
@@ -45,6 +46,25 @@
 | `Esc`, `C-c` | `cancel` | Cancel autocomplete, message reply, or edit, in that order |
 | `M-8` | `show-flagged-posts` | View currently flagged posts |
 
+# Text Editing
+| Keybinding | Event Name | Description |
+| ---------- | ---------- | ----------- |
+| `C-t` | `editor-transpose-chars` | Transpose the final two characters |
+| `C-a` | `editor-beginning-of-line` | Go to the start of the current line |
+| `C-e` | `editor-end-of-line` | Go to the end of the current line |
+| `C-d` | `editor-delete-char` | Delete the character at the cursor |
+| `C-u` | `editor-kill-to-beginning-of-line` | Delete from the cursor to the start of the current line |
+| `C-k` | `editor-kill-to-end-of-line` | Kill the line to the right of the current position and copy it |
+| `C-f` | `editor-next-char` | Move one character to the right |
+| `C-b` | `editor-prev-char` | Move one character to the left |
+| `M-f` | `editor-next-word` | Move one word to the right |
+| `M-b` | `editor-prev-word` | Move one word to the left |
+| `C-w`, `M-Backspace` | `editor-delete-prev-word` | Delete the word to the left of the cursor |
+| `M-d` | `editor-delete-next-word` | Delete the word to the right of the cursor |
+| `Home` | `editor-home` | Move the cursor to the beginning of the input |
+| `End` | `editor-end` | Move the cursor to the end of the input |
+| `C-y` | `editor-yank` | Paste the current buffer contents at the cursor |
+
 # Channel Select Mode
 | Keybinding | Event Name | Description |
 | ---------- | ---------- | ----------- |
@@ -55,15 +75,6 @@
 | `Down` | `focus-next-channel-alternate` | Select next match (alternate binding) |
 | `Up` | `focus-prev-channel-alternate` | Select previous match (alternate binding) |
 
-# URL Select Mode
-| Keybinding | Event Name | Description |
-| ---------- | ---------- | ----------- |
-| `Enter` | (non-customizable key) | Open the selected URL, if any |
-| `Esc`, `C-c` | `cancel` | Cancel URL selection |
-| `k`, `Up` | `select-up` | Move cursor up |
-| `j`, `Down` | `select-down` | Move cursor down |
-| `q` | (non-customizable key) | Cancel URL selection |
-
 # Message Select Mode
 | Keybinding | Event Name | Description |
 | ---------- | ---------- | ----------- |
@@ -86,33 +97,24 @@
 | `Enter` | `fetch-for-gap` | Fetch messages for the selected gap |
 | `a` | `react-to-message` | Post a reaction to the selected message |
 
-# Text Editing
+# User Listings
 | Keybinding | Event Name | Description |
 | ---------- | ---------- | ----------- |
-| `C-t` | `editor-transpose-chars` | Transpose the final two characters |
-| `C-a` | `editor-beginning-of-line` | Go to the start of the current line |
-| `C-e` | `editor-end-of-line` | Go to the end of the current line |
-| `C-d` | `editor-delete-char` | Delete the character at the cursor |
-| `C-u` | `editor-kill-to-beginning-of-line` | Delete from the cursor to the start of the current line |
-| `C-k` | `editor-kill-to-end-of-line` | Kill the line to the right of the current position and copy it |
-| `C-f` | `editor-next-char` | Move one character to the right |
-| `C-b` | `editor-prev-char` | Move one character to the left |
-| `M-f` | `editor-next-word` | Move one word to the right |
-| `M-b` | `editor-prev-word` | Move one word to the left |
-| `C-w`, `M-Backspace` | `editor-delete-prev-word` | Delete the word to the left of the cursor |
-| `M-d` | `editor-delete-next-word` | Delete the word to the right of the cursor |
-| `Home` | `editor-home` | Move the cursor to the beginning of the input |
-| `End` | `editor-end` | Move the cursor to the end of the input |
-| `C-y` | `editor-yank` | Paste the current buffer contents at the cursor |
+| `Esc`, `C-c` | `cancel` | Close the user search list |
+| `C-p`, `Up` | `search-select-up` | Select the previous user |
+| `C-n`, `Down` | `search-select-down` | Select the next user |
+| `PgDown` | `page-down` | Page down in the user list |
+| `PgUp` | `page-up` | Page up in the user list |
+| `Enter` | `activate-list-item` | Interact with the selected user |
 
-# Flagged Messages
+# URL Select Mode
 | Keybinding | Event Name | Description |
 | ---------- | ---------- | ----------- |
-| `Esc`, `C-c` | `cancel` | Exit post browsing |
-| `k`, `Up` | `select-up` | Select the previous message |
-| `j`, `Down` | `select-down` | Select the next message |
-| `f` | `flag-message` | Toggle the selected message flag |
-| `Enter` | `activate-list-item` | Jump to and select current message |
+| `Enter` | (non-customizable key) | Open the selected URL, if any |
+| `Esc`, `C-c` | `cancel` | Cancel URL selection |
+| `k`, `Up` | `select-up` | Move cursor up |
+| `j`, `Down` | `select-down` | Move cursor down |
+| `q` | (non-customizable key) | Cancel URL selection |
 
 # Theme List Window
 | Keybinding | Event Name | Description |
@@ -124,16 +126,6 @@
 | `PgUp` | `page-up` | Page up in the theme list |
 | `Enter` | `activate-list-item` | Switch to the selected color theme |
 
-# User Listings
-| Keybinding | Event Name | Description |
-| ---------- | ---------- | ----------- |
-| `Esc`, `C-c` | `cancel` | Close the user search list |
-| `C-p`, `Up` | `search-select-up` | Select the previous user |
-| `C-n`, `Down` | `search-select-down` | Select the next user |
-| `PgDown` | `page-down` | Page down in the user list |
-| `PgUp` | `page-up` | Page up in the user list |
-| `Enter` | `activate-list-item` | Interact with the selected user |
-
 # Channel Search Window
 | Keybinding | Event Name | Description |
 | ---------- | ---------- | ----------- |
@@ -144,16 +136,6 @@
 | `PgUp` | `page-up` | Page up in the channel list |
 | `Enter` | `activate-list-item` | Join the selected channel |
 
-# Reaction Emoji Search Window
-| Keybinding | Event Name | Description |
-| ---------- | ---------- | ----------- |
-| `Esc`, `C-c` | `cancel` | Close the emoji search window |
-| `C-p`, `Up` | `search-select-up` | Select the previous emoji |
-| `C-n`, `Down` | `search-select-down` | Select the next emoji |
-| `PgDown` | `page-down` | Page down in the emoji list |
-| `PgUp` | `page-up` | Page up in the emoji list |
-| `Enter` | `activate-list-item` | Post the selected emoji reaction |
-
 # Message Viewer: Common
 | Keybinding | Event Name | Description |
 | ---------- | ---------- | ----------- |
@@ -200,4 +182,23 @@
 | ---------- | ---------- | ----------- |
 | `Esc`, `C-c` | `cancel` | Cancel attachment file browse |
 | `o` | `open-attachment` | Open the selected file using the URL open command |
+
+# Flagged Messages
+| Keybinding | Event Name | Description |
+| ---------- | ---------- | ----------- |
+| `Esc`, `C-c` | `cancel` | Exit post browsing |
+| `k`, `Up` | `select-up` | Select the previous message |
+| `j`, `Down` | `select-down` | Select the next message |
+| `f` | `flag-message` | Toggle the selected message flag |
+| `Enter` | `activate-list-item` | Jump to and select current message |
+
+# Reaction Emoji Search Window
+| Keybinding | Event Name | Description |
+| ---------- | ---------- | ----------- |
+| `Esc`, `C-c` | `cancel` | Close the emoji search window |
+| `C-p`, `Up` | `search-select-up` | Select the previous emoji |
+| `C-n`, `Down` | `search-select-down` | Select the next emoji |
+| `PgDown` | `page-down` | Page down in the emoji list |
+| `PgUp` | `page-up` | Page up in the emoji list |
+| `Enter` | `activate-list-item` | Post the selected emoji reaction |
 
diff --git a/matterhorn.cabal b/matterhorn.cabal
--- a/matterhorn.cabal
+++ b/matterhorn.cabal
@@ -1,5 +1,5 @@
 name:                matterhorn
-version:             50200.10.3
+version:             50200.11.0
 synopsis:            Terminal client for the Mattermost chat system
 description:         This is a terminal client for the Mattermost chat
                      system. Please see the README for a list of
@@ -25,9 +25,10 @@
                      docs/commands.md
                      docs/AUTHORS.txt
                      docs/PRACTICES.md
-                     scripts/cowsay
-                     scripts/figlet
-                     scripts/rot13
+                     client-scripts/cowsay
+                     client-scripts/figlet
+                     client-scripts/rot13
+                     client-scripts/talky
                      notification-scripts/notify
                      emoji/LICENSE.txt
                      emoji/emoji.json
@@ -37,115 +38,119 @@
   type: git
   location: https://github.com/matterhorn-chat/matterhorn.git
 
-executable matterhorn
+library
   hs-source-dirs:      src
-  main-is:             Main.hs
-  other-modules:       Config
-                       Config.Schema
-                       Command
-                       Connection
-                       App
-                       Clipboard
-                       Constants
-                       State.Async
-                       State.Attachments
-                       State.Autocomplete
-                       State.Channels
-                       State.ChannelSelect
-                       State.Common
-                       State.Editing
-                       State.Flagging
-                       State.Help
-                       State.Logging
-                       State.Messages
-                       State.MessageSelect
-                       State.NotifyPrefs
-                       State.PostListOverlay
-                       State.ThemeListOverlay
-                       State.ReactionEmojiListOverlay
-                       State.Reactions
-                       State.Setup
-                       State.Setup.Threads
-                       State.Setup.Threads.Logging
-                       State.UrlSelect
-                       State.ListOverlay
-                       State.UserListOverlay
-                       State.ChannelListOverlay
-                       State.Users
-                       Zipper
-                       Themes
-                       Draw
-                       Emoji
-                       Draw.RichText
-                       Draw.ManageAttachments
-                       Draw.Autocomplete
-                       Draw.Main
-                       Draw.Messages
-                       Draw.ChannelList
-                       Draw.ChannelListOverlay
-                       Draw.ReactionEmojiListOverlay
-                       Draw.ShowHelp
-                       Draw.LeaveChannelConfirm
-                       Draw.DeleteChannelConfirm
-                       Draw.PostListOverlay
-                       Draw.ThemeListOverlay
-                       Draw.UserListOverlay
-                       Draw.ListOverlay
-                       Draw.URLList
-                       Draw.Util
-                       Draw.TabbedWindow
-                       Draw.NotifyPrefs
-                       InputHistory
-                       IOUtil
-                       Events
-                       Events.ManageAttachments
-                       Events.Keybindings
-                       Events.ShowHelp
-                       Events.MessageSelect
-                       Events.Main
-                       Events.ChannelSelect
-                       Events.PostListOverlay
-                       Events.ThemeListOverlay
-                       Events.UrlSelect
-                       Events.UserListOverlay
-                       Events.ChannelListOverlay
-                       Events.ReactionEmojiListOverlay
-                       Events.LeaveChannelConfirm
-                       Events.DeleteChannelConfirm
-                       Events.TabbedWindow
-                       Events.EditNotifyPrefs
-                       Windows.ViewMessage
-                       HelpTopics
-                       KeyMap
-                       LastRunState
-                       Scripts
-                       TimeUtils
-                       Prelude.MH
-                       Types
-                       Types.Channels
-                       Types.Common
-                       Types.DirectionalSeq
-                       Types.KeyEvents
-                       Types.Messages
-                       Types.RichText
-                       Types.Users
-                       Types.Posts
-                       FilePaths
-                       TeamSelect
-                       Login
-                       Options
-                       Util
+  exposed-modules:     Matterhorn.App
+                       Matterhorn.Clipboard
+                       Matterhorn.Command
+                       Matterhorn.Config
+                       Matterhorn.Config.Schema
+                       Matterhorn.Connection
+                       Matterhorn.Constants
+                       Matterhorn.Draw
+                       Matterhorn.Draw.Autocomplete
+                       Matterhorn.Draw.Buttons
+                       Matterhorn.Draw.ChannelList
+                       Matterhorn.Draw.ChannelListOverlay
+                       Matterhorn.Draw.ChannelTopicWindow
+                       Matterhorn.Draw.DeleteChannelConfirm
+                       Matterhorn.Draw.LeaveChannelConfirm
+                       Matterhorn.Draw.ListOverlay
+                       Matterhorn.Draw.Main
+                       Matterhorn.Draw.ManageAttachments
+                       Matterhorn.Draw.Messages
+                       Matterhorn.Draw.NotifyPrefs
+                       Matterhorn.Draw.PostListOverlay
+                       Matterhorn.Draw.ReactionEmojiListOverlay
+                       Matterhorn.Draw.RichText
+                       Matterhorn.Draw.ShowHelp
+                       Matterhorn.Draw.TabbedWindow
+                       Matterhorn.Draw.ThemeListOverlay
+                       Matterhorn.Draw.URLList
+                       Matterhorn.Draw.UserListOverlay
+                       Matterhorn.Draw.Util
+                       Matterhorn.Emoji
+                       Matterhorn.Events
+                       Matterhorn.Events.ChannelListOverlay
+                       Matterhorn.Events.ChannelSelect
+                       Matterhorn.Events.ChannelTopicWindow
+                       Matterhorn.Events.DeleteChannelConfirm
+                       Matterhorn.Events.EditNotifyPrefs
+                       Matterhorn.Events.Keybindings
+                       Matterhorn.Events.LeaveChannelConfirm
+                       Matterhorn.Events.Main
+                       Matterhorn.Events.ManageAttachments
+                       Matterhorn.Events.MessageSelect
+                       Matterhorn.Events.PostListOverlay
+                       Matterhorn.Events.ReactionEmojiListOverlay
+                       Matterhorn.Events.ShowHelp
+                       Matterhorn.Events.TabbedWindow
+                       Matterhorn.Events.ThemeListOverlay
+                       Matterhorn.Events.UrlSelect
+                       Matterhorn.Events.UserListOverlay
+                       Matterhorn.FilePaths
+                       Matterhorn.HelpTopics
+                       Matterhorn.IOUtil
+                       Matterhorn.InputHistory
+                       Matterhorn.KeyMap
+                       Matterhorn.LastRunState
+                       Matterhorn.Login
+                       Matterhorn.Options
+                       Matterhorn.Prelude
+                       Matterhorn.Scripts
+                       Matterhorn.State.Async
+                       Matterhorn.State.Attachments
+                       Matterhorn.State.Autocomplete
+                       Matterhorn.State.ChannelListOverlay
+                       Matterhorn.State.ChannelSelect
+                       Matterhorn.State.Channels
+                       Matterhorn.State.ChannelTopicWindow
+                       Matterhorn.State.Common
+                       Matterhorn.State.Editing
+                       Matterhorn.State.Flagging
+                       Matterhorn.State.Help
+                       Matterhorn.State.Links
+                       Matterhorn.State.ListOverlay
+                       Matterhorn.State.Logging
+                       Matterhorn.State.MessageSelect
+                       Matterhorn.State.Messages
+                       Matterhorn.State.NotifyPrefs
+                       Matterhorn.State.PostListOverlay
+                       Matterhorn.State.ReactionEmojiListOverlay
+                       Matterhorn.State.Reactions
+                       Matterhorn.State.Setup
+                       Matterhorn.State.Setup.Threads
+                       Matterhorn.State.Setup.Threads.Logging
+                       Matterhorn.State.ThemeListOverlay
+                       Matterhorn.State.UrlSelect
+                       Matterhorn.State.UserListOverlay
+                       Matterhorn.State.Users
+                       Matterhorn.TeamSelect
+                       Matterhorn.Themes
+                       Matterhorn.TimeUtils
+                       Matterhorn.Types
+                       Matterhorn.Types.Channels
+                       Matterhorn.Types.Common
+                       Matterhorn.Types.DirectionalSeq
+                       Matterhorn.Types.KeyEvents
+                       Matterhorn.Types.Messages
+                       Matterhorn.Types.Posts
+                       Matterhorn.Types.RichText
+                       Matterhorn.Types.Users
+                       Matterhorn.Util
+                       Matterhorn.Windows.ViewMessage
+                       Matterhorn.Zipper
                        Paths_matterhorn
   default-extensions:  OverloadedStrings,
                        ScopedTypeVariables,
                        NoImplicitPrelude
 
-  ghc-options:         -Wall -threaded -with-rtsopts=-I0 -Wcompat
+  ghc-options:         -Wall -Wcompat
   if impl(ghc >= 8.2)
     ghc-options: -fhide-source-paths
 
   build-depends:       base                 >=4.8      && <5
-                     , mattermost-api       == 50200.8.0
+                     , mattermost-api       == 50200.9.0
                      , base-compat          >= 0.9     && < 0.12
                      , unordered-containers >= 0.2     && < 0.3
                      , containers           >= 0.5.7   && < 0.7
@@ -159,9 +164,9 @@
                      , config-ini           >= 0.2.2.0 && < 0.3
                      , process              >= 1.4     && < 1.7
                      , microlens-platform   >= 0.3     && < 0.5
-                     , brick                >= 0.55    && < 0.56
+                     , brick                >= 0.57    && < 0.58
                      , brick-skylighting    >= 0.2     && < 0.4
-                     , vty                  >= 5.30    && < 5.31
+                     , vty                  >= 5.31    && < 5.32
                      , word-wrap            >= 0.4.0   && < 0.5
                      , transformers         >= 0.4     && < 0.6
                      , text-zipper          >= 0.10    && < 0.11
@@ -191,19 +196,24 @@
                      , network-uri          >= 2.6     && < 2.7
   default-language:    Haskell2010
 
+executable matterhorn
+  hs-source-dirs:      programs
+  main-is:             Main.hs
+
+  ghc-options:         -Wall -threaded -with-rtsopts=-I0 -Wcompat
+  if impl(ghc >= 8.2)
+    ghc-options: -fhide-source-paths
+
+  build-depends:       base                 >=4.8      && <5
+                     , matterhorn
+                     , text
+  default-language:    Haskell2010
+
 test-suite test_messages
   type:               exitcode-stdio-1.0
   main-is:            test_messages.hs
   other-modules:      Cheapskate_QCA
                     , Message_QCA
-                    , TimeUtils
-                    , Types.Messages
-                    , Types.Posts
-                    , Types.Common
-                    , Types.DirectionalSeq
-                    , Types.RichText
-                    , Constants
-                    , Prelude.MH
   default-language:   Haskell2010
   default-extensions: OverloadedStrings
                     , ScopedTypeVariables
@@ -211,42 +221,19 @@
   if impl(ghc >= 8.2)
     ghc-options: -fhide-source-paths
 
-  hs-source-dirs:     src, test
+  hs-source-dirs:     test
   build-depends:      base                 >=4.7      && <5
-                    , base-compat          >= 0.9     && < 0.12
-                    , brick                >= 0.55    && < 0.56
-                    , bytestring           >= 0.10    && < 0.11
-                    , cheapskate           >= 0.1     && < 0.2
+                    , Unique               >= 0.4     && < 0.5
                     , checkers             >= 0.4     && < 0.6
-                    , config-ini           >= 0.2.2.0 && < 0.3
-                    , connection           >= 0.2     && < 0.4
-                    , containers           >= 0.5.7   && < 0.7
-                    , directory            >= 1.3     && < 1.4
-                    , filepath             >= 1.4     && < 1.5
-                    , hashable             >= 1.2     && < 1.4
-                    , Hclip                >= 3.0     && < 3.1
-                    , mattermost-api       == 50200.8.0
-                    , mattermost-api-qc    == 50200.8.0
-                    , microlens-platform   >= 0.3     && < 0.5
-                    , mtl                  >= 2.2     && < 2.3
-                    , process              >= 1.4     && < 1.7
-                    , quickcheck-text      >= 0.1     && < 0.2
-                    , stm                  >= 2.4     && < 2.6
-                    , strict               >= 0.3     && < 0.4
-                    , string-conversions   >= 0.4     && < 0.5
                     , tasty                >= 0.11    && < 1.3
                     , tasty-hunit          >= 0.9     && < 0.12
                     , tasty-quickcheck     >= 0.8     && < 0.12
-                    , text                 >= 1.2     && < 1.3
-                    , text-zipper          >= 0.10    && < 0.11
-                    , time                 >= 1.6     && < 2.0
-                    , timezone-olson       >= 0.2     && < 0.3
-                    , timezone-series      >= 0.1.6.1 && < 0.2
-                    , transformers         >= 0.4     && < 0.6
-                    , Unique               >= 0.4     && < 0.5
-                    , unordered-containers >= 0.2     && < 0.3
-                    , vector               <  0.13
-                    , vty                  >= 5.30    && < 5.31
-                    , xdg-basedir          >= 0.2     && < 0.3
-                    , semigroups           >= 0.18    && < 0.20
-                    , uuid                 >= 1.3     && < 1.4
+                    , bytestring
+                    , cheapskate
+                    , containers
+                    , matterhorn
+                    , mattermost-api
+                    , mattermost-api-qc
+                    , text
+                    , time
+                    , uuid
diff --git a/programs/Main.hs b/programs/Main.hs
new file mode 100644
--- /dev/null
+++ b/programs/Main.hs
@@ -0,0 +1,60 @@
+module Main where
+
+import Prelude ()
+import qualified Data.Text.IO as T
+
+import System.Exit ( exitFailure, exitSuccess )
+
+import Matterhorn.Prelude
+import Matterhorn.Config
+import Matterhorn.Options
+import Matterhorn.App
+import Matterhorn.Events.Keybindings ( ensureKeybindingConsistency )
+import Matterhorn.KeyMap ( keybindingModeMap )
+import Matterhorn.Draw.ShowHelp ( keybindingMarkdownTable, keybindingTextTable
+                                , commandMarkdownTable, commandTextTable )
+
+
+main :: IO ()
+main = do
+    opts <- grabOptions
+
+    configResult <- if optIgnoreConfig opts
+                    then return $ Right defaultConfig
+                    else fmap snd <$> findConfig (optConfLocation opts)
+
+    config <- case configResult of
+        Left err -> do
+            putStrLn $ "Error loading config: " <> err
+            exitFailure
+        Right c -> return c
+
+    let keyConfig = configUserKeys config
+        format = optPrintFormat opts
+
+    printedCommands <- case optPrintCommands opts of
+        False -> return False
+        True -> do
+            case format of
+                Markdown -> T.putStrLn commandMarkdownTable
+                Plain -> T.putStrLn commandTextTable
+            return True
+
+    printedKeybindings <- case optPrintKeybindings opts of
+        False -> return False
+        True -> do
+            case format of
+                Markdown -> T.putStrLn $ keybindingMarkdownTable keyConfig
+                Plain -> T.putStrLn $ keybindingTextTable keyConfig
+            return True
+
+    when (printedKeybindings || printedCommands) exitSuccess
+
+    case ensureKeybindingConsistency keyConfig keybindingModeMap of
+        Right () -> return ()
+        Left err -> do
+            putStrLn $ "Configuration error: " <> err
+            exitFailure
+
+    finalSt <- runMatterhorn opts config
+    closeMatterhorn finalSt
diff --git a/scripts/cowsay b/scripts/cowsay
deleted file mode 100644
--- a/scripts/cowsay
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash -e
-
-# We want the fences so that the cowsay block gets rendered
-# in Markdown as a code block. Otherwise, we just call cowsay.
-echo '~~~~~~~~~~'
-cowsay
-echo '~~~~~~~~~~'
diff --git a/scripts/figlet b/scripts/figlet
deleted file mode 100644
--- a/scripts/figlet
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash -e
-
-# We want the fences so that the cowsay block gets rendered
-# in Markdown as a code block. Otherwise, we just call cowsay.
-echo '~~~~~~~~~~'
-figlet
-echo '~~~~~~~~~~'
diff --git a/scripts/rot13 b/scripts/rot13
deleted file mode 100644
--- a/scripts/rot13
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/bash -e
-
-# This should be a reasonably portable ROT-13-izer across Unixes
-tr '[A-Za-z]' '[N-ZA-Mn-za-m]'
diff --git a/src/App.hs b/src/App.hs
deleted file mode 100644
--- a/src/App.hs
+++ /dev/null
@@ -1,114 +0,0 @@
-module App
-  ( runMatterhorn
-  , closeMatterhorn
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Control.Monad.Trans.Except ( runExceptT )
-import qualified Graphics.Vty as Vty
-import           Text.Aspell ( stopAspell )
-import           GHC.Conc (getNumProcessors, setNumCapabilities)
-import           System.Posix.IO ( stdInput )
-
-import           Network.Mattermost
-
-import           Config
-import           Draw
-import qualified Events
-import           IOUtil
-import           InputHistory
-import           LastRunState
-import           Options hiding ( ShowHelp )
-import           State.Setup
-import           State.Setup.Threads.Logging ( shutdownLogManager )
-import           Types
-
-
-app :: App ChatState MHEvent Name
-app = App
-  { appDraw         = draw
-  , appChooseCursor = \s cs -> case appMode s of
-      Main                          -> showFirstCursor s cs
-      ChannelSelect                 -> showFirstCursor s cs
-      UserListOverlay               -> showFirstCursor s cs
-      ReactionEmojiListOverlay      -> showFirstCursor s cs
-      ChannelListOverlay            -> showFirstCursor s cs
-      ManageAttachmentsBrowseFiles  -> showFirstCursor s cs
-      ThemeListOverlay              -> showFirstCursor s cs
-      LeaveChannelConfirm           -> Nothing
-      DeleteChannelConfirm          -> Nothing
-      MessageSelect                 -> Nothing
-      MessageSelectDeleteConfirm    -> Nothing
-      PostListOverlay _             -> Nothing
-      ManageAttachments             -> Nothing
-      ViewMessage                   -> Nothing
-      ShowHelp _ _                  -> Nothing
-      UrlSelect                     -> Nothing
-      EditNotifyPrefs               -> Nothing
-  , appHandleEvent  = Events.onEvent
-  , appStartEvent   = return
-  , appAttrMap      = (^.csResources.crTheme)
-  }
-
-applicationMaxCPUs :: Int
-applicationMaxCPUs = 2
-
-setupCpuUsage :: Config -> IO ()
-setupCpuUsage config = do
-    actualNumCpus <- getNumProcessors
-
-    let requestedCPUs = case configCpuUsagePolicy config of
-            SingleCPU -> 1
-            MultipleCPUs -> min applicationMaxCPUs actualNumCpus
-
-    setNumCapabilities requestedCPUs
-
-runMatterhorn :: Options -> Config -> IO ChatState
-runMatterhorn opts config = do
-    setupCpuUsage config
-
-    let mkVty = do
-          mEraseChar <- Vty.getTtyEraseChar stdInput
-          let addEraseChar cfg = case mEraseChar of
-                  Nothing -> cfg
-                  Just ch -> cfg { Vty.inputMap = (Nothing, [ch], Vty.EvKey Vty.KBS []) : Vty.inputMap cfg }
-
-          vty <- Vty.mkVty $ addEraseChar Vty.defaultConfig
-          let output = Vty.outputIface vty
-          Vty.setMode output Vty.BracketedPaste True
-          Vty.setMode output Vty.Hyperlink $ configHyperlinkingMode config
-          return vty
-
-    (st, vty) <- setupState mkVty (optLogLocation opts) config
-    finalSt <- customMain vty mkVty (Just $ st^.csResources.crEventQueue) app st
-
-    case finalSt^.csEditState.cedSpellChecker of
-        Nothing -> return ()
-        Just (s, _) -> stopAspell s
-
-    return finalSt
-
--- | Cleanup resources and save data for restoring on program restart.
-closeMatterhorn :: ChatState -> IO ()
-closeMatterhorn finalSt = do
-  logIfError (mmCloseSession $ getResourceSession $ finalSt^.csResources)
-      "Error in closing session"
-
-  logIfError (writeHistory (finalSt^.csEditState.cedInputHistory))
-      "Error in writing history"
-
-  logIfError (writeLastRunState finalSt)
-      "Error in writing last run state"
-
-  shutdownLogManager $ finalSt^.csResources.crLogManager
-
-  where
-    logIfError action msg = do
-      done <- runExceptT $ convertIOException $ action
-      case done of
-        Left err -> putStrLn $ msg <> ": " <> err
-        Right _  -> return ()
diff --git a/src/Clipboard.hs b/src/Clipboard.hs
deleted file mode 100644
--- a/src/Clipboard.hs
+++ /dev/null
@@ -1,31 +0,0 @@
-module Clipboard
-  ( copyToClipboard
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Control.Exception ( try )
-import qualified Data.Text as T
-import           System.Hclip ( setClipboard, ClipboardException(..) )
-
-import           Types
-
-
-copyToClipboard :: Text -> MH ()
-copyToClipboard txt = do
-  result <- liftIO (try (setClipboard (T.unpack txt)))
-  case result of
-    Left e -> do
-      let errMsg = case e of
-            UnsupportedOS _ ->
-              "Matterhorn does not support yanking on this operating system."
-            NoTextualData ->
-              "Textual data is required to set the clipboard."
-            MissingCommands cmds ->
-              "Could not set clipboard due to missing one of the " <>
-              "required program(s): " <> (T.pack $ show cmds)
-      mhError $ ClipboardError errMsg
-    Right () ->
-      return ()
diff --git a/src/Command.hs b/src/Command.hs
deleted file mode 100644
--- a/src/Command.hs
+++ /dev/null
@@ -1,335 +0,0 @@
-{-# LANGUAGE GADTs #-}
-{-# LANGUAGE OverloadedStrings #-}
-module Command
-  ( commandList
-  , dispatchCommand
-  , printArgSpec
-  , toggleMessagePreview
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Main ( invalidateCache )
-import qualified Control.Exception as Exn
-import qualified Data.Char as Char
-import qualified Data.Text as T
-import           Lens.Micro.Platform ( (%=) )
-
-import qualified Network.Mattermost.Endpoints as MM
-import qualified Network.Mattermost.Exceptions as MM
-import qualified Network.Mattermost.Types as MM
-
-import           Connection ( connectWebsockets )
-import           Constants ( userSigil, normalChannelSigil )
-import           HelpTopics
-import           Scripts
-import           State.Help
-import           State.Editing
-import           State.Channels
-import           State.ChannelSelect
-import           State.Logging
-import           State.PostListOverlay
-import           State.UserListOverlay
-import           State.ChannelListOverlay
-import           State.ThemeListOverlay
-import           State.NotifyPrefs
-import           State.Common ( postInfoMessage )
-import           State.Users
-import           Themes ( attrForUsername )
-import           Types
-
-
--- | This function skips any initial whitespace and returns the first
--- 'token' (i.e. any sequence of non-whitespace characters) as well as
--- the trailing rest of the string, after any whitespace. This is used
--- for tokenizing the first bits of command input while leaving the
--- subsequent chunks unchanged, preserving newlines and other
--- important formatting.
-unwordHead :: Text -> Maybe (Text, Text)
-unwordHead t =
-  let t' = T.dropWhile Char.isSpace t
-      (w, rs)  = T.break Char.isSpace t'
-  in if T.null w
-       then Nothing
-       else Just (w, T.dropWhile Char.isSpace rs)
-
-printArgSpec :: CmdArgs a -> Text
-printArgSpec NoArg = ""
-printArgSpec (LineArg ts) = "<" <> ts <> ">"
-printArgSpec (TokenArg t NoArg) = "<" <> t <> ">"
-printArgSpec (UserArg rs) = "<" <> userSigil <> "user>" <> addSpace (printArgSpec rs)
-printArgSpec (ChannelArg rs) = "<" <> normalChannelSigil <> "channel>" <> addSpace (printArgSpec rs)
-printArgSpec (TokenArg t rs) = "<" <> t <> ">" <> addSpace (printArgSpec rs)
-
-addSpace :: Text -> Text
-addSpace "" = ""
-addSpace t = " " <> t
-
-matchArgs :: CmdArgs a -> Text -> Either Text a
-matchArgs NoArg t = case unwordHead t of
-  Nothing -> return ()
-  Just (a, as)
-    | not (T.all Char.isSpace as) -> Left ("Unexpected arguments '" <> t <> "'")
-    | otherwise -> Left ("Unexpected argument '" <> a <> "'")
-matchArgs (LineArg _) t = return t
-matchArgs spec@(UserArg rs) t = case unwordHead t of
-  Nothing -> case rs of
-    NoArg -> Left ("Missing argument: " <> printArgSpec spec)
-    _     -> Left ("Missing arguments: " <> printArgSpec spec)
-  Just (a, as) -> (,) <$> pure a <*> matchArgs rs as
-matchArgs spec@(ChannelArg rs) t = case unwordHead t of
-  Nothing -> case rs of
-    NoArg -> Left ("Missing argument: " <> printArgSpec spec)
-    _     -> Left ("Missing arguments: " <> printArgSpec spec)
-  Just (a, as) -> (,) <$> pure a <*> matchArgs rs as
-matchArgs spec@(TokenArg _ rs) t = case unwordHead t of
-  Nothing -> case rs of
-    NoArg -> Left ("Missing argument: " <> printArgSpec spec)
-    _     -> Left ("Missing arguments: " <> printArgSpec spec)
-  Just (a, as) -> (,) <$> pure a <*> matchArgs rs as
-
-commandList :: [Cmd]
-commandList =
-  [ Cmd "quit" "Exit Matterhorn" NoArg $ \ () -> requestQuit
-
-  , Cmd "right" "Focus on the next channel" NoArg $ \ () ->
-      nextChannel
-
-  , Cmd "left" "Focus on the previous channel" NoArg $ \ () ->
-      prevChannel
-
-  , Cmd "create-channel" "Create a new public channel"
-    (LineArg "channel name") $ \ name ->
-      createOrdinaryChannel True name
-
-  , Cmd "create-private-channel" "Create a new private channel"
-    (LineArg "channel name") $ \ name ->
-      createOrdinaryChannel False name
-
-  , Cmd "delete-channel" "Delete the current channel"
-    NoArg $ \ () ->
-      beginCurrentChannelDeleteConfirm
-
-  , Cmd "hide" "Hide the current DM or group channel from the channel list"
-    NoArg $ \ () -> do
-      hideDMChannel =<< use csCurrentChannelId
-
-  , Cmd "reconnect" "Force a reconnection attempt to the server"
-    NoArg $ \ () ->
-      connectWebsockets
-
-  , Cmd "members" "Show the current channel's members"
-    NoArg $ \ () ->
-      enterChannelMembersUserList
-
-  , Cmd "leave" "Leave a normal channel or hide a DM channel" NoArg $ \ () ->
-      startLeaveCurrentChannel
-
-  , Cmd "join" "Find a channel to join" NoArg $ \ () ->
-      enterChannelListOverlayMode
-
-  , Cmd "join" "Join the specified channel" (ChannelArg NoArg) $ \(n, ()) ->
-      joinChannelByName n
-
-  , Cmd "theme" "List the available themes" NoArg $ \ () ->
-      enterThemeListMode
-
-  , Cmd "theme" "Set the color theme"
-    (TokenArg "theme" NoArg) $ \ (themeName, ()) ->
-      setTheme themeName
-
-  , Cmd "topic" "Set the current channel's topic (header)"
-    (LineArg "topic") $ \ p ->
-      if not (T.null p) then setChannelTopic p else return ()
-
-  , Cmd "purpose" "Set the current channel's purpose"
-    (LineArg "purpose") $ \ p ->
-      if not (T.null p) then setChannelPurpose p else return ()
-
-  , Cmd "add-user" "Search for a user to add to the current channel"
-    NoArg $ \ () ->
-        enterChannelInviteUserList
-
-  , Cmd "msg" "Search for a user to enter a private chat"
-    NoArg $ \ () ->
-        enterDMSearchUserList
-
-  , Cmd "msg" "Chat with the specified user"
-    (UserArg NoArg) $ \ (name, ()) ->
-        changeChannelByName name
-
-  , Cmd "username-attribute" "Display the attribute used to color the specified username"
-    (UserArg NoArg) $ \ (name, ()) ->
-        displayUsernameAttribute name
-
-  , Cmd "msg" "Go to a user's channel and send the specified message or command"
-    (UserArg $ LineArg "message or command") $ \ (name, msg) -> do
-        withFetchedUserMaybe (UserFetchByUsername name) $ \foundUser -> do
-            case foundUser of
-                Just user -> createOrFocusDMChannel user $ Just $ \cId ->
-                    handleInputSubmission cId msg
-                Nothing -> mhError $ NoSuchUser name
-
-  , Cmd "log-start" "Begin logging debug information to the specified path"
-    (TokenArg "path" NoArg) $ \ (path, ()) ->
-        startLogging $ T.unpack path
-
-  , Cmd "log-snapshot" "Dump the current debug log buffer to the specified path"
-    (TokenArg "path" NoArg) $ \ (path, ()) ->
-        logSnapshot $ T.unpack path
-
-  , Cmd "log-stop" "Stop logging"
-    NoArg $ \ () ->
-        stopLogging
-
-  , Cmd "log-mark" "Add a custom marker message to the Matterhorn debug log"
-    (LineArg "message") $ \ markMsg ->
-        mhLog LogUserMark markMsg
-
-  , Cmd "log-status" "Show current debug logging status"
-    NoArg $ \ () ->
-        getLogDestination
-
-  , Cmd "add-user" "Add a user to the current channel"
-    (UserArg NoArg) $ \ (uname, ()) ->
-        addUserByNameToCurrentChannel uname
-
-  , Cmd "remove-user" "Remove a user from the current channel"
-    (UserArg NoArg) $ \ (uname, ()) ->
-        removeUserFromCurrentChannel uname
-
-  , Cmd "user" "Show users to initiate a private DM chat channel"
-    -- n.b. this is identical to "msg", but is provided as an
-    -- alternative mental model for useability.
-    NoArg $ \ () ->
-        enterDMSearchUserList
-
-  , Cmd "message-preview" "Toggle preview of the current message" NoArg $ \_ ->
-        toggleMessagePreview
-
-  , Cmd "toggle-channel-list" "Toggle channel list visibility" NoArg $ \_ ->
-        toggleChannelListVisibility
-
-  , Cmd "focus" "Focus on a channel or user"
-    (ChannelArg NoArg) $ \ (name, ()) ->
-        changeChannelByName name
-
-  , Cmd "focus" "Select from available channels" NoArg $ \ () ->
-        beginChannelSelect
-
-  , Cmd "help" "Show this help screen" NoArg $ \ _ ->
-        showHelpScreen mainHelpTopic
-
-  , Cmd "help" "Show help about a particular topic"
-      (TokenArg "topic" NoArg) $ \ (topicName, ()) ->
-          case lookupHelpTopic topicName of
-              Nothing -> mhError $ NoSuchHelpTopic topicName
-              Just topic -> showHelpScreen topic
-
-  , Cmd "sh" "List the available shell scripts" NoArg $ \ () ->
-      listScripts
-
-  , Cmd "group-msg" "Create a group chat"
-    (LineArg (userSigil <> "user [" <> userSigil <> "user ...]")) createGroupChannel
-
-  , Cmd "sh" "Run a prewritten shell script"
-    (TokenArg "script" (LineArg "message")) $ \ (script, text) -> do
-        cId <- use csCurrentChannelId
-        findAndRunScript cId script text
-
-  , Cmd "me" "Send an emote message"
-    (LineArg "message") $
-    \msg -> execMMCommand "me" msg
-
-  , Cmd "shrug" "Send a message followed by a shrug emoticon"
-    (LineArg "message") $
-    \msg -> execMMCommand "shrug" msg
-
-  , Cmd "flags" "Open a window of your flagged posts" NoArg $ \ () ->
-      enterFlaggedPostListMode
-
-  , Cmd "pinned-posts" "Open a window of this channel's pinned posts" NoArg $ \ () ->
-      enterPinnedPostListMode
-
-  , Cmd "search" "Search for posts with given terms" (LineArg "terms") $
-      enterSearchResultPostListMode
-
-  , Cmd "notify-prefs" "Edit the current channel's notification preferences" NoArg $ \_ ->
-          enterEditNotifyPrefsMode
-
-  ]
-
-displayUsernameAttribute :: Text -> MH ()
-displayUsernameAttribute name = do
-    let an = attrForUsername trimmed
-        trimmed = trimUserSigil name
-    postInfoMessage $ "The attribute used for " <> userSigil <> trimmed <>
-                      " is " <> (attrNameToConfig an)
-
-execMMCommand :: Text -> Text -> MH ()
-execMMCommand name rest = do
-  cId      <- use csCurrentChannelId
-  session  <- getSession
-  em       <- use (csEditState.cedEditMode)
-  tId      <- gets myTeamId
-  let mc = MM.MinCommand
-             { MM.minComChannelId = cId
-             , MM.minComCommand   = "/" <> name <> " " <> rest
-             , MM.minComParentId  = case em of
-                 Replying _ p -> Just $ MM.getId p
-                 Editing p _  -> MM.postRootId p
-                 _            -> Nothing
-             , MM.minComRootId  = case em of
-                 Replying _ p -> MM.postRootId p <|> (Just $ MM.postId p)
-                 Editing p _  -> MM.postRootId p
-                 _            -> Nothing
-             , MM.minComTeamId = tId
-             }
-      runCmd = liftIO $ do
-        void $ MM.mmExecuteCommand mc session
-      handleHTTP (MM.HTTPResponseException err) =
-        return (Just (T.pack err))
-        -- XXX: this might be a bit brittle in the future, because it
-        -- assumes the shape of an error message. We might want to
-        -- think about a better way of discovering this error and
-        -- reporting it accordingly?
-      handleCmdErr (MM.MattermostServerError err) =
-        let (_, msg) = T.breakOn ": " err in
-          return (Just (T.drop 2 msg))
-      handleMMErr (MM.MattermostError
-                     { MM.mattermostErrorMessage = msg }) =
-        return (Just msg)
-  errMsg <- liftIO $ (runCmd >> return Nothing) `Exn.catch` handleHTTP
-                                                `Exn.catch` handleCmdErr
-                                                `Exn.catch` handleMMErr
-  case errMsg of
-    Nothing -> return ()
-    Just err ->
-      mhError $ GenericError ("Error running command: " <> err)
-
-dispatchCommand :: Text -> MH ()
-dispatchCommand cmd =
-  case unwordHead cmd of
-    Just (x, xs)
-      | matchingCmds <- [ c
-                        | c@(Cmd name _ _ _) <- commandList
-                        , name == x
-                        ] -> go [] matchingCmds
-      where go [] [] = do
-              execMMCommand x xs
-            go errs [] = do
-              let msg = ("error running command /" <> x <> ":\n" <>
-                         mconcat [ "    " <> e | e <- errs ])
-              mhError $ GenericError msg
-            go errs (Cmd _ _ spec exe : cs) =
-              case matchArgs spec xs of
-                Left e -> go (e:errs) cs
-                Right args -> exe args
-    _ -> return ()
-
-toggleMessagePreview :: MH ()
-toggleMessagePreview = do
-    mh invalidateCache
-    csShowMessagePreview %= not
diff --git a/src/Command.hs-boot b/src/Command.hs-boot
deleted file mode 100644
--- a/src/Command.hs-boot
+++ /dev/null
@@ -1,9 +0,0 @@
-module Command where
-
-import Data.Text ( Text )
-
-import Types ( MH, Cmd, CmdArgs )
-
-commandList :: [Cmd]
-printArgSpec :: CmdArgs a -> Text
-dispatchCommand :: Text -> MH ()
diff --git a/src/Config.hs b/src/Config.hs
deleted file mode 100644
--- a/src/Config.hs
+++ /dev/null
@@ -1,351 +0,0 @@
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TupleSections #-}
-{-# LANGUAGE MultiWayIf #-}
-module Config
-  ( Config(..)
-  , PasswordSource(..)
-  , findConfig
-  , defaultConfig
-  , configConnectionType
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Control.Monad.Trans.Except
-import           Control.Monad.Trans.Class ( lift )
-import           Config.Schema
-import           Data.Char ( isDigit, isAlpha )
-import           Data.List ( isPrefixOf )
-import           Data.List.Split ( splitOn )
-import qualified Data.Map.Strict as M
-import qualified Data.Text as T
-import qualified Data.Text.IO as T
-import           System.Directory ( makeAbsolute, getHomeDirectory )
-import           System.Environment ( getExecutablePath )
-import           System.FilePath ( (</>), takeDirectory, splitPath, joinPath )
-import           System.Process ( readProcess )
-import           Network.Mattermost.Types (ConnectionType(..))
-import           Network.URI ( isIPv4address, isIPv6address )
-
-import           FilePaths
-import           IOUtil
-import           Types
-import           Types.KeyEvents
-
-
-defaultPort :: Int
-defaultPort = 443
-
-bundledSyntaxPlaceholderName :: String
-bundledSyntaxPlaceholderName = "BUNDLED_SYNTAX"
-
-userSyntaxPlaceholderName :: String
-userSyntaxPlaceholderName = "USER_SYNTAX"
-
-defaultSkylightingPaths :: IO [FilePath]
-defaultSkylightingPaths = do
-    xdg <- xdgSyntaxDir
-    adjacent <- getBundledSyntaxPath
-    return [xdg, adjacent]
-
-getBundledSyntaxPath :: IO FilePath
-getBundledSyntaxPath = do
-    selfPath <- getExecutablePath
-    let distDir = "dist-newstyle/"
-        pathBits = splitPath selfPath
-
-    return $ if distDir `elem` pathBits
-             then
-                 -- We're in development, so use the development
-                 -- executable path to locate the XML path in the
-                 -- development tree.
-                 (joinPath $ takeWhile (/= distDir) pathBits) </> syntaxDirName
-             else
-                 -- In this case we assume the binary is being run from
-                 -- a release, in which case the syntax directory is a
-                 -- sibling of the executable path.
-                 takeDirectory selfPath </> syntaxDirName
-
-fromIni :: IniParser Config
-fromIni = do
-  conf <- section "mattermost" $ do
-    configUser           <- fieldMbOf "user" stringField
-    configHost           <- fieldMbOf "host" hostField
-    configTeam           <- fieldMbOf "team" stringField
-    configPort           <- fieldDefOf "port" number (configPort defaultConfig)
-    configUrlPath        <- fieldMbOf "urlPath" stringField
-    configChannelListWidth <- fieldDefOf "channelListWidth" number
-                              (configChannelListWidth defaultConfig)
-    configCpuUsagePolicy <- fieldDefOf "cpuUsagePolicy" cpuUsagePolicy
-                            (configCpuUsagePolicy defaultConfig)
-    configLogMaxBufferSize <- fieldDefOf "logMaxBufferSize" number
-                              (configLogMaxBufferSize defaultConfig)
-    configTimeFormat     <- fieldMbOf "timeFormat" stringField
-    configDateFormat     <- fieldMbOf "dateFormat" stringField
-    configTheme          <- fieldMbOf "theme" stringField
-    configThemeCustomizationFile <- fieldMbOf "themeCustomizationFile" stringField
-    configAspellDictionary <- fieldMbOf "aspellDictionary" stringField
-    configURLOpenCommand <- fieldMbOf "urlOpenCommand" stringField
-    configURLOpenCommandInteractive <- fieldFlagDef "urlOpenCommandIsInteractive" False
-    configSmartBacktick  <- fieldFlagDef "smartbacktick"
-      (configSmartBacktick defaultConfig)
-    configSmartEditing <- fieldFlagDef "smartediting"
-      (configSmartEditing defaultConfig)
-    configShowOlderEdits <- fieldFlagDef "showOlderEdits"
-      (configShowOlderEdits defaultConfig)
-    configShowBackground <- fieldDefOf "showBackgroundActivity" backgroundField
-      (configShowBackground defaultConfig)
-    configShowMessagePreview <- fieldFlagDef "showMessagePreview"
-      (configShowMessagePreview defaultConfig)
-    configShowChannelList <- fieldFlagDef "showChannelList"
-      (configShowChannelList defaultConfig)
-    configShowTypingIndicator <- fieldFlagDef "showTypingIndicator"
-      (configShowTypingIndicator defaultConfig)
-    configEnableAspell <- fieldFlagDef "enableAspell"
-      (configEnableAspell defaultConfig)
-    configSyntaxDirs <- fieldDefOf "syntaxDirectories" syntaxDirsField []
-    configActivityNotifyCommand <- fieldMb "activityNotifyCommand"
-    configActivityBell <- fieldFlagDef "activityBell"
-      (configActivityBell defaultConfig)
-    configHyperlinkingMode <- fieldFlagDef "hyperlinkURLs"
-      (configHyperlinkingMode defaultConfig)
-    configMessageSelectAfterURLOpen <- fieldFlagDef "messageSelectAfterURLOpen"
-      (configMessageSelectAfterURLOpen defaultConfig)
-    configPass <- (Just . PasswordCommand <$> field "passcmd") <!>
-                  (Just . PasswordString  <$> field "pass") <!>
-                  pure Nothing
-    configToken <- (Just . TokenCommand  <$> field "tokencmd") <!>
-                  pure Nothing
-    configUnsafeUseHTTP <-
-      fieldFlagDef "unsafeUseUnauthenticatedConnection" False
-    configValidateServerCertificate <-
-      fieldFlagDef "validateServerCertificate" True
-    configDirectChannelExpirationDays <- fieldDefOf "directChannelExpirationDays" number
-      (configDirectChannelExpirationDays defaultConfig)
-    configDefaultAttachmentPath <- fieldMbOf "defaultAttachmentPath" filePathField
-
-    let configAbsPath = Nothing
-        configUserKeys = mempty
-    return Config { .. }
-  keys <- sectionMb "keybindings" $ do
-      fmap (M.fromList . catMaybes) $ forM allEvents $ \ ev -> do
-          kb <- fieldMbOf (keyEventName ev) parseBindingList
-          case kb of
-              Nothing      -> return Nothing
-              Just binding -> return (Just (ev, binding))
-  return conf { configUserKeys = fromMaybe mempty keys }
-
-syntaxDirsField :: Text -> Either String [FilePath]
-syntaxDirsField = listWithSeparator ":" string
-
-validHostnameFragmentChar :: Char -> Bool
-validHostnameFragmentChar c = isAlpha c || isDigit c || c == '-'
-
-isHostnameFragment :: String -> Bool
-isHostnameFragment "" = False
-isHostnameFragment s = all validHostnameFragmentChar s
-
-isHostname :: String -> Bool
-isHostname "" = False
-isHostname s =
-    let parts@(h:_) = splitOn "." s
-    in all isHostnameFragment parts && not ("-" `isPrefixOf` h)
-
-hostField :: Text -> Either String Text
-hostField t =
-    let s = T.unpack t
-        valid = or [ isIPv4address s
-                   , isIPv6address s
-                   , isHostname s
-                   ]
-    in if valid
-       then Right t
-       else Left "Invalid 'host' value, must be a hostname or IPv4/IPv6 address"
-
-expandTilde :: FilePath -> FilePath -> FilePath
-expandTilde homeDir p =
-    let parts = splitPath p
-        f part | part == "~/" = homeDir <> "/"
-               | otherwise    = part
-    in joinPath $ f <$> parts
-
-backgroundField :: Text -> Either String BackgroundInfo
-backgroundField t =
-    case t of
-        "Disabled" -> Right Disabled
-        "Active" -> Right Active
-        "ActiveCount" -> Right ActiveCount
-        _ -> Left ("Invalid value " <> show t
-                  <> "; must be one of: Disabled, Active, ActiveCount")
-
-cpuUsagePolicy :: Text -> Either String CPUUsagePolicy
-cpuUsagePolicy t =
-    case T.toLower t of
-        "single" -> return SingleCPU
-        "multiple" -> return MultipleCPUs
-        _ -> Left $ "Invalid CPU usage policy value: " <> show t
-
-stringField :: Text -> Either e Text
-stringField t =
-    case isQuoted t of
-        True -> Right $ parseQuotedString t
-        False -> Right t
-
-filePathField :: Text -> Either e FilePath
-filePathField t = let path = T.unpack t in Right path
-
-parseQuotedString :: Text -> Text
-parseQuotedString t =
-    let body = T.drop 1 $ T.init t
-        unescapeQuotes s | T.null s = s
-                         | "\\\"" `T.isPrefixOf` s = "\"" <> unescapeQuotes (T.drop 2 s)
-                         | otherwise = (T.singleton $ T.head s) <> unescapeQuotes (T.drop 1 s)
-    in unescapeQuotes body
-
-isQuoted :: Text -> Bool
-isQuoted t =
-    let quote = "\""
-    in (quote `T.isPrefixOf` t) &&
-       (quote `T.isSuffixOf` t)
-
-defaultConfig :: Config
-defaultConfig =
-    Config { configAbsPath                     = Nothing
-           , configUser                        = Nothing
-           , configHost                        = Nothing
-           , configTeam                        = Nothing
-           , configPort                        = defaultPort
-           , configUrlPath                     = Nothing
-           , configPass                        = Nothing
-           , configToken                       = Nothing
-           , configTimeFormat                  = Nothing
-           , configDateFormat                  = Nothing
-           , configTheme                       = Nothing
-           , configThemeCustomizationFile      = Nothing
-           , configSmartBacktick               = True
-           , configSmartEditing                = True
-           , configURLOpenCommand              = Nothing
-           , configURLOpenCommandInteractive   = False
-           , configActivityNotifyCommand       = Nothing
-           , configActivityBell                = False
-           , configShowBackground              = Disabled
-           , configShowMessagePreview          = False
-           , configShowChannelList             = True
-           , configEnableAspell                = False
-           , configAspellDictionary            = Nothing
-           , configUnsafeUseHTTP               = False
-           , configValidateServerCertificate   = True
-           , configChannelListWidth            = 20
-           , configLogMaxBufferSize            = 200
-           , configShowOlderEdits              = True
-           , configUserKeys                    = mempty
-           , configShowTypingIndicator         = False
-           , configHyperlinkingMode            = True
-           , configMessageSelectAfterURLOpen   = False
-           , configSyntaxDirs                  = []
-           , configDirectChannelExpirationDays = 7
-           , configCpuUsagePolicy              = MultipleCPUs
-           , configDefaultAttachmentPath       = Nothing
-           }
-
-findConfig :: Maybe FilePath -> IO (Either String ([String], Config))
-findConfig Nothing = runExceptT $ do
-    cfg <- lift $ locateConfig configFileName
-    (warns, config) <-
-      case cfg of
-        Nothing -> return ([], defaultConfig)
-        Just path -> getConfig path
-    config' <- fixupPaths config
-    return (warns, config')
-findConfig (Just path) =
-    runExceptT $ do (warns, config) <- getConfig path
-                    config' <- fixupPaths config
-                    return (warns, config')
-
--- | Fix path references in the configuration:
---
--- * Rewrite the syntax directory path list with 'fixupSyntaxDirs'
--- * Expand "~" encountered in any setting that contains a path value
-fixupPaths :: Config -> ExceptT String IO Config
-fixupPaths initial = do
-    new <- fixupSyntaxDirs initial
-    homeDir <- liftIO getHomeDirectory
-    let fixP = expandTilde homeDir
-        fixPText = T.pack . expandTilde homeDir . T.unpack
-    return $ new { configThemeCustomizationFile = fixPText <$> configThemeCustomizationFile new
-                 , configSyntaxDirs             = fixP <$> configSyntaxDirs new
-                 , configURLOpenCommand         = fixPText <$> configURLOpenCommand new
-                 , configActivityNotifyCommand  = fixPText <$> configActivityNotifyCommand new
-                 , configDefaultAttachmentPath  = fixP <$> configDefaultAttachmentPath new
-                 }
-
--- | If the configuration has no syntax directories specified (the
--- default if the user did not provide the setting), fill in the
--- list with the defaults. Otherwise replace any bundled directory
--- placeholders in the config's syntax path list.
-fixupSyntaxDirs :: Config -> ExceptT String IO Config
-fixupSyntaxDirs c =
-    if configSyntaxDirs c == []
-    then do
-        dirs <- liftIO defaultSkylightingPaths
-        return $ c { configSyntaxDirs = dirs }
-    else do
-        newDirs <- forM (configSyntaxDirs c) $ \dir ->
-            if | dir == bundledSyntaxPlaceholderName -> liftIO getBundledSyntaxPath
-               | dir == userSyntaxPlaceholderName    -> liftIO xdgSyntaxDir
-               | otherwise                           -> return dir
-
-        return $ c { configSyntaxDirs = newDirs }
-
-getConfig :: FilePath -> ExceptT String IO ([String], Config)
-getConfig fp = do
-    absPath <- convertIOException $ makeAbsolute fp
-    t <- (convertIOException $ T.readFile absPath) `catchE`
-         (\e -> throwE $ "Could not read " <> show absPath <> ": " <> e)
-
-    -- HACK ALERT FIXME:
-    --
-    -- The config parser library we use, config-ini (as of 0.2.4.0)
-    -- cannot handle configuration files without trailing newlines.
-    -- Since that's not a really good reason for this function to raise
-    -- an exception (and is fixable on the fly), we have the following
-    -- check. This check is admittedly not a great thing to have to do,
-    -- and we should definitely get rid of it when config-ini fixes this
-    -- issue.
-    let t' = if "\n" `T.isSuffixOf` t then t else t <> "\n"
-
-    case parseIniFile t' fromIni of
-        Left err -> do
-            throwE $ "Unable to parse " ++ absPath ++ ":" ++ fatalString err
-        Right (warns, conf) -> do
-            actualPass <- case configPass conf of
-                Just (PasswordCommand cmdString) -> do
-                    let (cmd:rest) = T.unpack <$> T.words cmdString
-                    output <- convertIOException (readProcess cmd rest "") `catchE`
-                              (\e -> throwE $ "Could not execute password command: " <> e)
-                    return $ Just $ T.pack (takeWhile (/= '\n') output)
-                Just (PasswordString pass) -> return $ Just pass
-                Nothing -> return Nothing
-
-            actualToken <- case configToken conf of
-                Just (TokenCommand cmdString) -> do
-                    let (cmd:rest) = T.unpack <$> T.words cmdString
-                    output <- convertIOException (readProcess cmd rest "") `catchE`
-                              (\e -> throwE $ "Could not execute token command: " <> e)
-                    return $ Just $ T.pack (takeWhile (/= '\n') output)
-                Just (TokenString _) -> error $ "BUG: getConfig: token in the Config was already a TokenString"
-                Nothing -> return Nothing
-
-            let conf' = conf
-                  { configPass = PasswordString <$> actualPass
-                  , configToken = TokenString <$> actualToken
-                  , configAbsPath = Just absPath
-                  }
-            return (map warningString warns, conf')
-
-configConnectionType :: Config -> ConnectionType
-configConnectionType config
-  | configUnsafeUseHTTP config = ConnectHTTP
-  | otherwise = ConnectHTTPS (configValidateServerCertificate config)
diff --git a/src/Config/Schema.hs b/src/Config/Schema.hs
deleted file mode 100644
--- a/src/Config/Schema.hs
+++ /dev/null
@@ -1,203 +0,0 @@
-{- |
-
-This module provides an INI schema validator that is able to track unused
-sections and fields in order to report warning messages to the user.
-
-
- -}
-module Config.Schema
-  ( IniParser
-  , parseIniFile
-  , (<!>)
-
-  , Fatal(..)
-  , fatalString
-
-  , Warning(..)
-  , warningString
-
-  , section
-  , sectionMb
-  , fieldMbOf
-  , fieldMb
-  , field
-  , fieldDefOf
-  , fieldFlagDef
-
-  -- * Re-exports
-  , number
-  , string
-  , listWithSeparator
-  ) where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Data.Map (Map)
-import qualified Data.Map as Map
-import           Data.List.NonEmpty (NonEmpty)
-import qualified Data.List.NonEmpty as NonEmpty
-import qualified Data.Text as T
-import           Control.Monad
-import           Data.Ini.Config (flag, number, listWithSeparator, string)
-import           Data.Ini.Config.Raw
-
-
-------------------------------------------------------------------------
-
-newtype Parser e t a = Parser { unParser :: e -> Map NormalizedText (NonEmpty t) -> Either Fatal (Map NormalizedText (NonEmpty t), [Warning], a) }
-
-instance Functor (Parser e t) where
-  fmap = liftM
-
-instance Applicative (Parser e t) where
-  pure x = Parser $ \_ s -> Right (s, [], x)
-  (<*>) = ap
-
-instance Monad (Parser e t) where
-  m >>= f = Parser $ \e s0 ->
-              do (s1, ws1, x1) <- unParser m e s0
-                 (s2, ws2, x2) <- unParser (f x1) e s1
-                 Right (s2, ws1++ws2, x2)
-
-
-
-(<!>) :: Parser e t a -> Parser e t a -> Parser e t a
-p <!> q = Parser $ \e s ->
-  case unParser p e s of
-    Right r -> Right r
-    Left {} -> unParser q e s
-
-getenv :: Parser e t e
-getenv = Parser $ \e s -> Right (s, [], e)
-
-request :: Text -> Parser e t (Maybe t)
-request name = Parser $ \_ s ->
-  let name' = normalize name in
-  Right $!
-  case Map.lookup name' s of
-    Nothing                 -> (s , [], Nothing)
-    Just (x NonEmpty.:| xs) -> (s', [], Just x)
-      where
-        s' = case NonEmpty.nonEmpty xs of
-               Nothing -> Map.delete name' s
-               Just ne -> Map.insert name' ne s
-
-fatal :: Fatal -> Parser e t a
-fatal e = Parser $ \_ _ -> Left e
-
-warnings :: [Warning] -> IniParser ()
-warnings ws = Parser $ \_ s -> Right (s, ws, ())
-
-------------------------------------------------------------------------
-
-type IniParser = Parser RawIni IniSection
-
-type SectionParser = Parser IniSection IniValue
-
-data Fatal
-  = NoSection Text
-  | MissingField IniSection Text
-  | BadField IniSection IniValue String
-  | ParseError String
-  deriving Show
-
-data Warning
-  = UnusedSection IniSection
-  | UnusedField IniSection IniValue
-  deriving Show
-
-------------------------------------------------------------------------
-
-fatalString :: Fatal -> String
-fatalString (NoSection name) = "No top-level section named " ++ show name
-fatalString (MissingField sec name) = "Missing field " ++ show name ++ " in section " ++ show (isName sec)
-fatalString (BadField sec val err) =
-  "Line " ++ show (vLineNo val) ++
-  " in section " ++ show (isName sec) ++
-  ": " ++ err
-fatalString (ParseError err) = err
-
-warningString :: Warning -> String
-warningString (UnusedSection sec) = "Unused section " ++ show (isName sec)
-warningString (UnusedField sec val) =
-  "Line " ++ show (vLineNo val) ++
-  " in section " ++ show (isName sec) ++
-  ": unused field"
-
-------------------------------------------------------------------------
-
-parseIniFile :: Text -> IniParser a -> Either Fatal ([Warning], a)
-parseIniFile text parser =
-  case parseRawIni text of
-    Left e -> Left (ParseError e)
-    Right ini ->
-      let entries = Map.fromListWith (<>)
-                       [ (k, pure v) | (k,v) <- toList (fromRawIni ini) ]
-      in
-      case unParser parser ini entries of
-        Left e -> Left e
-        Right (entries', ws, x) -> Right (ws ++ unused, x)
-          where
-            unused = [ UnusedSection e | e <- concatMap toList entries' ]
-
-------------------------------------------------------------------------
-
-section :: Text -> SectionParser a -> IniParser a
-section name parser =
-  do mb <- sectionMb name parser
-     case mb of
-       Nothing -> fatal (NoSection name)
-       Just x  -> pure x
-
-sectionMb :: Text -> SectionParser a -> IniParser (Maybe a)
-sectionMb name parser =
-  do mb <- request name
-     case mb of
-       Nothing -> pure Nothing
-       Just sec ->
-         let entries = Map.fromListWith (<>)
-                       [ (k, pure v) | (k,v) <- toList (isVals sec) ]
-         in
-         case unParser parser sec entries of
-           Left e -> fatal e
-           Right (entries', ws, result) ->
-             do warnings (ws ++ [ UnusedField sec v | v <- concatMap toList entries' ])
-                pure (Just result)
-
-------------------------------------------------------------------------
-
-field :: Text -> SectionParser Text
-field name =
-  do mb <- fieldMb name
-     case mb of
-       Just x -> pure x
-       Nothing ->
-         do s <- getenv
-            fatal (MissingField s name)
-
-fieldMbOf :: Text -> (Text -> Either String a) -> SectionParser (Maybe a)
-fieldMbOf name validate =
-  do mb <- request name
-     case mb of
-       Nothing -> pure Nothing
-       Just val ->
-         case validate (getVal val) of
-           Left e ->
-             do sec <- getenv
-                fatal (BadField sec val e)
-           Right x -> pure (Just x)
-
-fieldMb :: Text -> SectionParser (Maybe Text)
-fieldMb name = fmap getVal <$> request name
-
-fieldDefOf :: Text -> (Text -> Either String a) -> a -> SectionParser a
-fieldDefOf name validate def = fromMaybe def <$> fieldMbOf name validate
-
-fieldFlagDef :: Text -> Bool -> SectionParser Bool
-fieldFlagDef name def = fieldDefOf name flag def
-
-------------------------------------------------------------------------
-
-getVal :: IniValue -> Text
-getVal = T.strip . vValue
diff --git a/src/Connection.hs b/src/Connection.hs
deleted file mode 100644
--- a/src/Connection.hs
+++ /dev/null
@@ -1,104 +0,0 @@
-module Connection where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Control.Concurrent ( forkIO, threadDelay, killThread )
-import qualified Control.Concurrent.STM as STM
-import           Control.Exception ( SomeException, catch, AsyncException(..), throwIO )
-import qualified Data.HashMap.Strict as HM
-import           Data.Int (Int64)
-import           Data.Semigroup ( Max(..) )
-import qualified Data.Text as T
-import           Data.Time ( UTCTime(..), secondsToDiffTime, getCurrentTime
-                           , diffUTCTime )
-import           Data.Time.Calendar ( Day(..) )
-import           Lens.Micro.Platform ( (.=) )
-
-import           Network.Mattermost.Types ( ChannelId )
-import qualified Network.Mattermost.WebSocket as WS
-
-import           Constants
-import           Types
-
-
-connectWebsockets :: MH ()
-connectWebsockets = do
-  logger <- mhGetIOLogger
-
-  -- If we have an old websocket thread, kill it.
-  mOldTid <- use (csResources.crWebsocketThreadId)
-  case mOldTid of
-      Nothing -> return ()
-      Just oldTid -> liftIO $ do
-          logger LogWebsocket "Terminating previous websocket thread"
-          killThread oldTid
-
-  st <- use id
-  session <- getSession
-
-  tid <- liftIO $ do
-    let shunt (Left msg) = writeBChan (st^.csResources.crEventQueue) (WebsocketParseError msg)
-        shunt (Right (Right e)) = writeBChan (st^.csResources.crEventQueue) (WSEvent e)
-        shunt (Right (Left e)) = writeBChan (st^.csResources.crEventQueue) (WSActionResponse e)
-        runWS = WS.mmWithWebSocket session shunt $ \ws -> do
-                  writeBChan (st^.csResources.crEventQueue) WebsocketConnect
-                  processWebsocketActions st ws 1 HM.empty
-    logger LogWebsocket "Starting new websocket thread"
-    forkIO $ runWS `catch` ignoreThreadKilled
-                   `catch` handleTimeout logger 1 st
-                   `catch` handleError logger 5 st
-
-  csResources.crWebsocketThreadId .= Just tid
-
-ignoreThreadKilled :: AsyncException -> IO ()
-ignoreThreadKilled ThreadKilled = return ()
-ignoreThreadKilled e = throwIO e
-
--- | Take websocket actions from the websocket action channel in the
--- ChatState and send them to the server over the websocket.
---
--- Takes and propagates the action sequence number which is incremented
--- for each successful send.
---
--- Keeps and propagates a map of channel id to last user_typing
--- notification send time so that the new user_typing actions are
--- throttled to be send only once in two seconds.
-processWebsocketActions :: ChatState -> WS.MMWebSocket -> Int64 -> HashMap ChannelId (Max UTCTime) -> IO ()
-processWebsocketActions st ws s userTypingLastNotifTimeMap = do
-  action <- STM.atomically $ STM.readTChan (st^.csResources.crWebsocketActionChan)
-  if (shouldSendAction action)
-    then do
-      WS.mmSendWSAction (st^.csResources.crConn) ws $ convert action
-      now <- getCurrentTime
-      processWebsocketActions st ws (s + 1) $ userTypingLastNotifTimeMap' action now
-    else do
-      processWebsocketActions st ws s userTypingLastNotifTimeMap
-  where
-    convert (UserTyping _ cId pId) = WS.UserTyping s cId pId
-
-    shouldSendAction (UserTyping ts cId _) =
-      diffUTCTime ts (userTypingLastNotifTime cId) >= (userTypingExpiryInterval / 2 - 0.5)
-
-    userTypingLastNotifTime cId = getMax $ HM.lookupDefault (Max zeroTime) cId userTypingLastNotifTimeMap
-
-    zeroTime = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime 0)
-
-    userTypingLastNotifTimeMap' (UserTyping _ cId _) now =
-      HM.insertWith (<>) cId (Max now) userTypingLastNotifTimeMap
-
-handleTimeout :: (LogCategory -> Text -> IO ()) -> Int -> ChatState -> WS.MMWebSocketTimeoutException -> IO ()
-handleTimeout logger seconds st e = do
-    logger LogWebsocket $ T.pack $ "Websocket timeout exception: " <> show e
-    reconnectAfter seconds st
-
-handleError :: (LogCategory -> Text -> IO ()) -> Int -> ChatState -> SomeException -> IO ()
-handleError logger seconds st e = do
-    logger LogWebsocket $ T.pack $ "Websocket error: " <> show e
-    reconnectAfter seconds st
-
-reconnectAfter :: Int -> ChatState -> IO ()
-reconnectAfter seconds st = do
-  writeBChan (st^.csResources.crEventQueue) WebsocketDisconnect
-  threadDelay (seconds * 1000 * 1000)
-  writeBChan (st^.csResources.crEventQueue) RefreshWebsocketEvent
diff --git a/src/Constants.hs b/src/Constants.hs
deleted file mode 100644
--- a/src/Constants.hs
+++ /dev/null
@@ -1,35 +0,0 @@
-module Constants
-  ( pageAmount
-  , userTypingExpiryInterval
-  , numScrollbackPosts
-  , previewMaxHeight
-  , normalChannelSigil
-  , userSigil
-  )
-where
-
-import Prelude ()
-import Prelude.MH
-
-
--- | The number of rows to consider a "page" when scrolling
-pageAmount :: Int
-pageAmount = 15
-
--- | The expiry interval in seconds for user typing notifications.
-userTypingExpiryInterval :: NominalDiffTime
-userTypingExpiryInterval = 5
-
-numScrollbackPosts :: Int
-numScrollbackPosts = 100
-
--- | The maximum height of the message preview, in lines.
-previewMaxHeight :: Int
-previewMaxHeight = 5
-
--- Sigils
-normalChannelSigil :: Text
-normalChannelSigil = "~"
-
-userSigil :: Text
-userSigil = "@"
diff --git a/src/Draw.hs b/src/Draw.hs
deleted file mode 100644
--- a/src/Draw.hs
+++ /dev/null
@@ -1,46 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-{-# LANGUAGE PackageImports #-}
-module Draw (draw) where
-
-import Prelude ()
-import Prelude.MH
-
-import Brick
-
-import Lens.Micro.Platform ( _2, singular, _Just )
-
-import Draw.DeleteChannelConfirm
-import Draw.LeaveChannelConfirm
-import Draw.Main
-import Draw.ThemeListOverlay
-import Draw.PostListOverlay
-import Draw.ShowHelp
-import Draw.UserListOverlay
-import Draw.ChannelListOverlay
-import Draw.ReactionEmojiListOverlay
-import Draw.TabbedWindow
-import Draw.ManageAttachments
-import Draw.NotifyPrefs
-import Types
-
-
-draw :: ChatState -> [Widget Name]
-draw st =
-    case appMode st of
-        Main                       -> drawMain True st
-        UrlSelect                  -> drawMain True st
-        ShowHelp topic _           -> drawShowHelp topic st
-        ChannelSelect              -> drawMain True st
-        LeaveChannelConfirm        -> drawLeaveChannelConfirm st
-        MessageSelect              -> drawMain True st
-        MessageSelectDeleteConfirm -> drawMain True st
-        DeleteChannelConfirm       -> drawDeleteChannelConfirm st
-        ThemeListOverlay           -> drawThemeListOverlay st
-        PostListOverlay contents   -> drawPostListOverlay contents st
-        UserListOverlay            -> drawUserListOverlay st
-        ChannelListOverlay         -> drawChannelListOverlay st
-        ReactionEmojiListOverlay   -> drawReactionEmojiListOverlay st
-        ViewMessage                -> drawTabbedWindow (st^.csViewedMessage.singular _Just._2) st : drawMain False st
-        ManageAttachments          -> drawManageAttachments st
-        ManageAttachmentsBrowseFiles -> drawManageAttachments st
-        EditNotifyPrefs            -> drawNotifyPrefs st : drawMain False st
diff --git a/src/Draw/Autocomplete.hs b/src/Draw/Autocomplete.hs
deleted file mode 100644
--- a/src/Draw/Autocomplete.hs
+++ /dev/null
@@ -1,188 +0,0 @@
-module Draw.Autocomplete
-  ( autocompleteLayer
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Widgets.Border
-import           Brick.Widgets.List ( renderList, listElementsL, listSelectedFocusedAttr
-                                    , listSelectedElement
-                                    )
-import qualified Data.Text as T
-
-import           Network.Mattermost.Types ( User(..), Channel(..) )
-
-import           Constants ( normalChannelSigil )
-import           Draw.Util
-import           Themes
-import           Types
-import           Types.Common ( sanitizeUserText )
-
-
-autocompleteLayer :: ChatState -> Widget Name
-autocompleteLayer st =
-    case st^.csEditState.cedAutocomplete of
-        Nothing ->
-            emptyWidget
-        Just ac ->
-            renderAutocompleteBox st ac
-
-userNotInChannelMarker :: T.Text
-userNotInChannelMarker = "*"
-
-elementTypeLabel :: AutocompletionType -> Text
-elementTypeLabel ACUsers = "Users"
-elementTypeLabel ACChannels = "Channels"
-elementTypeLabel ACCodeBlockLanguage = "Languages"
-elementTypeLabel ACEmoji = "Emoji"
-elementTypeLabel ACCommands = "Commands"
-
-renderAutocompleteBox :: ChatState -> AutocompleteState -> Widget Name
-renderAutocompleteBox st ac =
-    let matchList = _acCompletionList ac
-        maxListHeight = 5
-        visibleHeight = min maxListHeight numResults
-        numResults = length elements
-        elements = matchList^.listElementsL
-        label = withDefAttr clientMessageAttr $
-                txt $ elementTypeLabel (ac^.acType) <> ": " <> (T.pack $ show numResults) <>
-                     " match" <> (if numResults == 1 then "" else "es") <>
-                     " (Tab/Shift-Tab to select)"
-
-        selElem = snd <$> listSelectedElement matchList
-        curChan = st^.csCurrentChannel
-        footer = case renderAutocompleteFooterFor curChan =<< selElem of
-            Just w -> hBorderWithLabel w
-            _ -> hBorder
-        curUser = myUsername st
-
-    in if numResults == 0
-       then emptyWidget
-       else Widget Greedy Greedy $ do
-           ctx <- getContext
-           let rowOffset = ctx^.availHeightL - 3 - editorOffset - visibleHeight
-               editorOffset = if st^.csEditState.cedEphemeral.eesMultiline
-                              then multilineHeightLimit
-                              else 0
-           render $ translateBy (Location (0, rowOffset)) $
-                    vBox [ hBorderWithLabel label
-                         , vLimit visibleHeight $
-                           renderList (renderAutocompleteAlternative curUser) True matchList
-                         , footer
-                         ]
-
-renderAutocompleteFooterFor :: ClientChannel -> AutocompleteAlternative -> Maybe (Widget Name)
-renderAutocompleteFooterFor _ (SpecialMention MentionChannel) = Nothing
-renderAutocompleteFooterFor _ (SpecialMention MentionAll) = Nothing
-renderAutocompleteFooterFor ch (UserCompletion _ False) =
-    Just $ hBox [ txt $ "("
-                , withDefAttr clientEmphAttr (txt userNotInChannelMarker)
-                , txt ": not a member of "
-                , withDefAttr channelNameAttr (txt $ normalChannelSigil <> ch^.ccInfo.cdName)
-                , txt ")"
-                ]
-renderAutocompleteFooterFor _ (ChannelCompletion False ch) =
-    Just $ hBox [ txt $ "("
-                , withDefAttr clientEmphAttr (txt userNotInChannelMarker)
-                , txt ": you are not a member of "
-                , withDefAttr channelNameAttr (txt $ normalChannelSigil <> sanitizeUserText (channelName ch))
-                , txt ")"
-                ]
-renderAutocompleteFooterFor _ _ = Nothing
-
-renderAutocompleteAlternative :: Text -> Bool -> AutocompleteAlternative -> Widget Name
-renderAutocompleteAlternative _ sel (EmojiCompletion e) =
-    padRight Max $ renderEmojiCompletion sel e
-renderAutocompleteAlternative _ sel (SpecialMention m) =
-    padRight Max $ renderSpecialMention m sel
-renderAutocompleteAlternative curUser sel (UserCompletion u inChan) =
-    padRight Max $ renderUserCompletion curUser u inChan sel
-renderAutocompleteAlternative _ sel (ChannelCompletion inChan c) =
-    padRight Max $ renderChannelCompletion c inChan sel
-renderAutocompleteAlternative _ _ (SyntaxCompletion t) =
-    padRight Max $ txt t
-renderAutocompleteAlternative _ _ (CommandCompletion n args desc) =
-    padRight Max $ renderCommandCompletion n args desc
-
-renderSpecialMention :: SpecialMention -> Bool -> Widget Name
-renderSpecialMention m sel =
-    let usernameWidth = 18
-        padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')
-        maybeForce = if sel
-                     then forceAttr listSelectedFocusedAttr
-                     else id
-        t = autocompleteAlternativeReplacement $ SpecialMention m
-        desc = case m of
-            MentionChannel -> "Notifies all users in this channel"
-            MentionAll     -> "Mentions all users in this channel"
-    in maybeForce $
-       hBox [ txt "  "
-            , padTo usernameWidth $ withDefAttr clientEmphAttr $ txt t
-            , txt desc
-            ]
-
-renderEmojiCompletion :: Bool -> T.Text -> Widget Name
-renderEmojiCompletion sel e =
-    let maybeForce = if sel
-                     then forceAttr listSelectedFocusedAttr
-                     else id
-    in maybeForce $
-       padLeft (Pad 2) $
-       withDefAttr emojiAttr $
-       txt $
-       autocompleteAlternativeReplacement $ EmojiCompletion e
-
-renderUserCompletion :: Text -> User -> Bool -> Bool -> Widget Name
-renderUserCompletion curUser u inChan selected =
-    let usernameWidth = 18
-        fullNameWidth = 25
-        padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')
-        username = userUsername u
-        fullName = (sanitizeUserText $ userFirstName u) <> " " <>
-                   (sanitizeUserText $ userLastName u)
-        nickname = sanitizeUserText $ userNickname u
-        maybeForce = if selected
-                     then forceAttr listSelectedFocusedAttr
-                     else id
-        memberDisplay = if inChan
-                        then txt "  "
-                        else withDefAttr clientEmphAttr $
-                             txt $ userNotInChannelMarker <> " "
-    in maybeForce $
-       hBox [ memberDisplay
-            , padTo usernameWidth $ colorUsername curUser username ("@" <> username)
-            , padTo fullNameWidth $ txt fullName
-            , txt nickname
-            ]
-
-renderChannelCompletion :: Channel -> Bool -> Bool -> Widget Name
-renderChannelCompletion c inChan selected =
-    let urlNameWidth = 30
-        displayNameWidth = 30
-        padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')
-        maybeForce = if selected
-                     then forceAttr listSelectedFocusedAttr
-                     else id
-        memberDisplay = if inChan
-                        then txt "  "
-                        else withDefAttr clientEmphAttr $
-                             txt $ userNotInChannelMarker <> " "
-    in maybeForce $
-       hBox [ memberDisplay
-            , padTo urlNameWidth $
-              withDefAttr channelNameAttr $
-              txt $ normalChannelSigil <> (sanitizeUserText $ channelName c)
-            , padTo displayNameWidth $
-              withDefAttr channelNameAttr $
-              txt $ sanitizeUserText $ channelDisplayName c
-            , txt $ sanitizeUserText $ channelPurpose c
-            ]
-
-renderCommandCompletion :: Text -> Text -> Text -> Widget Name
-renderCommandCompletion name args desc =
-    withDefAttr clientMessageAttr
-        (txt $ "/" <> name <> if T.null args then "" else " " <> args) <+>
-    (txt $ " - " <> desc)
diff --git a/src/Draw/ChannelList.hs b/src/Draw/ChannelList.hs
deleted file mode 100644
--- a/src/Draw/ChannelList.hs
+++ /dev/null
@@ -1,208 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-{-# LANGUAGE RankNTypes #-}
-
--- | This module provides the Drawing functionality for the
--- ChannelList sidebar.  The sidebar is divided vertically into groups
--- and each group is rendered separately.
---
--- There are actually two UI modes handled by this code:
---
---   * Normal display of the channels, with various markers to
---     indicate the current channel, channels with unread messages,
---     user state (for Direct Message channels), etc.
---
---   * ChannelSelect display where the user is typing match characters
---     into a prompt at the ChannelList sidebar is showing only those
---     channels matching the entered text (and highlighting the
---     matching portion).
-
-module Draw.ChannelList (renderChannelList) where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Widgets.Border
-import           Brick.Widgets.Center (hCenter)
-import qualified Data.Text as T
-import           Lens.Micro.Platform (non)
-
-import qualified Network.Mattermost.Types as MM
-
-import           Constants ( userSigil )
-import           Draw.Util
-import           State.Channels
-import           Themes
-import           Types
-import           Types.Common ( sanitizeUserText )
-import qualified Zipper as Z
-
--- | Internal record describing each channel entry and its associated
--- attributes.  This is the object passed to the rendering function so
--- that it can determine how to render each channel.
-data ChannelListEntryData =
-    ChannelListEntryData { entrySigil       :: Text
-                         , entryLabel       :: Text
-                         , entryHasUnread   :: Bool
-                         , entryMentions    :: Int
-                         , entryIsRecent    :: Bool
-                         , entryIsReturn    :: Bool
-                         , entryIsCurrent   :: Bool
-                         , entryIsMuted     :: Bool
-                         , entryUserStatus  :: Maybe UserStatus
-                         }
-
-renderChannelList :: ChatState -> Widget Name
-renderChannelList st =
-    viewport ChannelList Vertical body
-    where
-        teamHeader = hCenter $
-                     withDefAttr clientEmphAttr $
-                     txt $ "Team: " <> teamNameStr
-        myUsername_ = myUsername st
-        me = userById (myUserId st) st
-        statusSigil = maybe ' ' userSigilFromInfo me
-        selfHeader = hCenter $
-                     colorUsername myUsername_ myUsername_ (T.singleton statusSigil <> " " <> userSigil <> myUsername_)
-        teamNameStr = sanitizeUserText $ MM.teamDisplayName $ st^.csMyTeam
-        body = case appMode st of
-            ChannelSelect ->
-                let zipper = st^.csChannelSelectState.channelSelectMatches
-                    matches = if Z.isEmpty zipper
-                              then [hCenter $ txt "No matches"]
-                              else (renderChannelListGroup st (renderChannelSelectListEntry (Z.focus zipper)) <$>
-                                   Z.toList zipper)
-                in vBox $
-                   teamHeader :
-                   selfHeader :
-                   matches
-            _ ->
-                cached ChannelSidebar $
-                vBox $
-                teamHeader :
-                selfHeader :
-                (renderChannelListGroup st (\s e -> renderChannelListEntry myUsername_ $ mkChannelEntryData s e) <$>
-                    Z.toList (st^.csFocus))
-
-renderChannelListGroupHeading :: ChannelListGroup -> Widget Name
-renderChannelListGroupHeading g =
-    let (anyUnread, label) = case g of
-            ChannelGroupPublicChannels u -> (u, "Public Channels")
-            ChannelGroupPrivateChannels u -> (u, "Private Channels")
-            ChannelGroupDirectMessages u -> (u, "Direct Messages")
-        addUnread = if anyUnread
-                    then (<+> (withDefAttr unreadGroupMarkerAttr $ txt "*"))
-                    else id
-        labelWidget = addUnread $ withDefAttr channelListHeaderAttr $ txt label
-    in hBorderWithLabel labelWidget
-
-renderChannelListGroup :: ChatState
-                       -> (ChatState -> e -> Widget Name)
-                       -> (ChannelListGroup, [e])
-                       -> Widget Name
-renderChannelListGroup st renderEntry (group, es) =
-    let heading = renderChannelListGroupHeading group
-        entryWidgets = renderEntry st <$> es
-    in if null entryWidgets
-       then emptyWidget
-       else vBox (heading : entryWidgets)
-
-mkChannelEntryData :: ChatState
-                   -> ChannelListEntry
-                   -> ChannelListEntryData
-mkChannelEntryData st e =
-    ChannelListEntryData sigilWithSpace name unread mentions recent ret current muted status
-    where
-        cId = channelListEntryChannelId e
-        Just chan = findChannelById cId (st^.csChannels)
-        unread = hasUnread' chan
-        recent = isRecentChannel st cId
-        ret = isReturnChannel st cId
-        current = isCurrentChannel st cId
-        muted = isMuted chan
-        (name, normalSigil, addSpace, status) = case e of
-            CLChannel _ ->
-                (chan^.ccInfo.cdDisplayName, Nothing, False, Nothing)
-            CLGroupDM _ ->
-                (chan^.ccInfo.cdDisplayName, Just " ", True, Nothing)
-            CLUserDM _ uId ->
-                let Just u = userById uId st
-                    uname = if useNickname st
-                            then u^.uiNickName.non (u^.uiName)
-                            else u^.uiName
-                in (uname, Just $ T.singleton $ userSigilFromInfo u, True, Just $ u^.uiStatus)
-        sigilWithSpace = sigil <> if addSpace then " " else ""
-        prevEditSigil = "»"
-        sigil = if current
-                then fromMaybe "" normalSigil
-                else case chan^.ccEditState.eesInputHistoryPosition of
-                    Just _ -> prevEditSigil
-                    Nothing ->
-                        case chan^.ccEditState.eesLastInput of
-                            ("", _) -> fromMaybe "" normalSigil
-                            _       -> prevEditSigil
-        mentions = chan^.ccInfo.cdMentionCount
-
--- | Render an individual Channel List entry (in Normal mode) with
--- appropriate visual decorations.
-renderChannelListEntry :: Text -> ChannelListEntryData -> Widget Name
-renderChannelListEntry myUName entry = body
-    where
-    body = decorate $ decorateEntry entry $ decorateMentions $ padRight Max $
-           entryWidget $ entrySigil entry <> entryLabel entry
-    decorate = if | entryIsCurrent entry ->
-                      visible . forceAttr currentChannelNameAttr
-                  | entryMentions entry > 0 && not (entryIsMuted entry) ->
-                      forceAttr mentionsChannelAttr
-                  | entryHasUnread entry ->
-                      forceAttr unreadChannelAttr
-                  | otherwise -> id
-    entryWidget = case entryUserStatus entry of
-                    Just Offline -> withDefAttr clientMessageAttr . txt
-                    Just _       -> colorUsername myUName (entryLabel entry)
-                    Nothing      -> txt
-    decorateMentions
-      | entryMentions entry > 9 =
-        (<+> str "(9+)")
-      | entryMentions entry > 0 =
-        (<+> str ("(" <> show (entryMentions entry) <> ")"))
-      | entryIsMuted entry =
-        (<+> str "(m)")
-      | otherwise = id
-
--- | Render an individual entry when in Channel Select mode,
--- highlighting the matching portion, or completely suppressing the
--- entry if it doesn't match.
-renderChannelSelectListEntry :: Maybe ChannelSelectMatch -> ChatState -> ChannelSelectMatch -> Widget Name
-renderChannelSelectListEntry curMatch st match =
-    let ChannelSelectMatch preMatch inMatch postMatch _ entry = match
-        maybeSelect = if (Just entry) == (matchEntry <$> curMatch)
-                      then visible . withDefAttr currentChannelNameAttr
-                      else id
-        entryData = mkChannelEntryData st entry
-    in maybeSelect $
-       decorateEntry entryData $
-       padRight Max $
-         hBox [ txt $ entrySigil entryData <> preMatch
-              , forceAttr channelSelectMatchAttr $ txt inMatch
-              , txt postMatch
-              ]
-
--- If this channel is the return channel, add a decoration to denote
--- that.
---
--- Otherwise, if this channel is the most recently viewed channel (prior
--- to the currently viewed channel), add a decoration to denote that.
-decorateEntry :: ChannelListEntryData -> Widget n -> Widget n
-decorateEntry entry =
-    if entryIsReturn entry
-    then (<+> (withDefAttr recentMarkerAttr $ str returnChannelSigil))
-    else if entryIsRecent entry
-         then (<+> (withDefAttr recentMarkerAttr $ str recentChannelSigil))
-         else id
-
-recentChannelSigil :: String
-recentChannelSigil = "<"
-
-returnChannelSigil :: String
-returnChannelSigil = "~"
diff --git a/src/Draw/ChannelListOverlay.hs b/src/Draw/ChannelListOverlay.hs
deleted file mode 100644
--- a/src/Draw/ChannelListOverlay.hs
+++ /dev/null
@@ -1,54 +0,0 @@
-module Draw.ChannelListOverlay
-  ( drawChannelListOverlay
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import qualified Data.Text as T
-import           Text.Wrap ( defaultWrapSettings, preserveIndentation )
-
-import           Network.Mattermost.Types
-import           Network.Mattermost.Lenses
-
-import           Draw.Main
-import           Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )
-import           Types
-import           Types.Common ( sanitizeUserText )
-import           Themes
-
-
-drawChannelListOverlay :: ChatState -> [Widget Name]
-drawChannelListOverlay st =
-    let overlay = drawListOverlay (st^.csChannelListOverlay) channelSearchScopeHeader
-                                  channelSearchScopeNoResults channelSearchScopePrompt
-                                  renderChannel
-                                  Nothing
-                                  OverlayCenter
-                                  80
-    in joinBorders overlay : drawMain False st
-
-channelSearchScopePrompt :: ChannelSearchScope -> Widget Name
-channelSearchScopePrompt scope =
-    txt $ case scope of
-        AllChannels -> "Search channels:"
-
-channelSearchScopeNoResults :: ChannelSearchScope -> Widget Name
-channelSearchScopeNoResults scope =
-    txt $ case scope of
-        AllChannels -> "No matching channels found."
-
-channelSearchScopeHeader :: ChannelSearchScope -> Widget Name
-channelSearchScopeHeader scope =
-    withDefAttr clientEmphAttr $ txt $ case scope of
-        AllChannels -> "Join a Channel"
-
-renderChannel :: Bool -> Channel -> Widget Name
-renderChannel _ chan =
-    let baseStr = (sanitizeUserText $ chan^.channelNameL) <> " (" <>
-                  (sanitizeUserText $ chan^.channelDisplayNameL) <> ")"
-        s = "  " <> (T.strip $ sanitizeUserText $ chan^.channelPurposeL)
-    in (vLimit 1 $ padRight Max $ withDefAttr clientEmphAttr $ txt baseStr) <=>
-       (vLimit 1 $ txtWrapWith (defaultWrapSettings { preserveIndentation = True }) s)
diff --git a/src/Draw/DeleteChannelConfirm.hs b/src/Draw/DeleteChannelConfirm.hs
deleted file mode 100644
--- a/src/Draw/DeleteChannelConfirm.hs
+++ /dev/null
@@ -1,33 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-module Draw.DeleteChannelConfirm
-    ( drawDeleteChannelConfirm
-    )
-where
-
-import Prelude ()
-import Prelude.MH
-
-import Brick
-import Brick.Widgets.Border
-import Brick.Widgets.Center
-
-import Draw.Main
-import Themes
-import Types
-
-
-drawDeleteChannelConfirm :: ChatState -> [Widget Name]
-drawDeleteChannelConfirm st =
-    confirmBox st : (drawMain False st)
-
-confirmBox :: ChatState -> Widget Name
-confirmBox st =
-    let cName = st^.csCurrentChannel.ccInfo.cdName
-    in centerLayer $ hLimit 50 $ vLimit 15 $
-       withDefAttr dialogAttr $
-       borderWithLabel (txt "Confirm Delete Channel") $
-       vBox [ padBottom (Pad 1) $ hCenter $ txt "Are you sure you want to delete this channel?"
-            , padBottom (Pad 1) $ hCenter $ withDefAttr dialogEmphAttr $ txt cName
-            , hCenter $ txt "Press " <+> (withDefAttr dialogEmphAttr $ txt "Y") <+> txt " to delete the channel"
-            , hCenter $ txt "or any other key to cancel."
-            ]
diff --git a/src/Draw/LeaveChannelConfirm.hs b/src/Draw/LeaveChannelConfirm.hs
deleted file mode 100644
--- a/src/Draw/LeaveChannelConfirm.hs
+++ /dev/null
@@ -1,33 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-module Draw.LeaveChannelConfirm
-    ( drawLeaveChannelConfirm
-    )
-where
-
-import Prelude ()
-import Prelude.MH
-
-import Brick
-import Brick.Widgets.Border
-import Brick.Widgets.Center
-
-import Draw.Main
-import Themes
-import Types
-
-
-drawLeaveChannelConfirm :: ChatState -> [Widget Name]
-drawLeaveChannelConfirm st =
-    confirmBox st : (drawMain False st)
-
-confirmBox :: ChatState -> Widget Name
-confirmBox st =
-    let cName = st^.csCurrentChannel.ccInfo.cdName
-    in centerLayer $ hLimit 50 $ vLimit 15 $
-       withDefAttr dialogAttr $
-       borderWithLabel (txt "Confirm Leave Channel") $
-       vBox [ padBottom (Pad 1) $ hCenter $ txt "Are you sure you want to leave this channel?"
-            , padBottom (Pad 1) $ hCenter $ withDefAttr dialogEmphAttr $ txt cName
-            , hCenter $ txt "Press " <+> (withDefAttr dialogEmphAttr $ txt "Y") <+> txt " to leave the channel"
-            , hCenter $ txt "or any other key to cancel."
-            ]
diff --git a/src/Draw/ListOverlay.hs b/src/Draw/ListOverlay.hs
deleted file mode 100644
--- a/src/Draw/ListOverlay.hs
+++ /dev/null
@@ -1,134 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RecordWildCards #-}
-
-module Draw.ListOverlay
-  ( drawListOverlay
-  , OverlayPosition(..)
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Widgets.Border
-import           Brick.Widgets.Center
-import           Brick.Widgets.Edit
-import qualified Brick.Widgets.List as L
-import           Control.Monad.Trans.Reader ( withReaderT )
-import qualified Data.Foldable as F
-import qualified Data.Text as T
-import           Graphics.Vty ( imageWidth, translateX)
-import           Lens.Micro.Platform ( (%~), (.~), to )
-
-import           Themes
-import           Types
-
-
-hLimitWithPadding :: Int -> Widget n -> Widget n
-hLimitWithPadding pad contents = Widget
-  { hSize  = Fixed
-  , vSize  = (vSize contents)
-  , render =
-      withReaderT (& availWidthL  %~ (\ n -> n - (2 * pad))) $ render $ cropToContext contents
-  }
-
-data OverlayPosition =
-    OverlayCenter
-    | OverlayUpperRight
-    deriving (Eq, Show)
-
--- | Draw a ListOverlayState. This draws a bordered box with the
--- overlay's search input and results list inside the box. The provided
--- functions determine how to render the overlay in various states.
-drawListOverlay :: ListOverlayState a b
-                -- ^ The overlay state
-                -> (b -> Widget Name)
-                -- ^ The function to build the window title from the
-                -- current search scope
-                -> (b -> Widget Name)
-                -- ^ The function to generate a message for the search
-                -- scope indicating that no results were found
-                -> (b -> Widget Name)
-                -- ^ The function to generate the editor prompt for the
-                -- search scope
-                -> (Bool -> a -> Widget Name)
-                -- ^ The function to render an item from the overlay's
-                -- list
-                -> Maybe (Widget Name)
-                -- ^ The footer widget to render underneath the search
-                -- results
-                -> OverlayPosition
-                -- ^ How to position the overlay layer
-                -> Int
-                -- ^ The maximum window width in columns
-                -> Widget Name
-drawListOverlay st scopeHeader scopeNoResults scopePrompt renderItem footer layerPos maxWinWidth =
-  positionLayer $ hLimitWithPadding 10 $ vLimit 25 $
-  hLimit maxWinWidth $
-  borderWithLabel title body
-  where
-      title = withDefAttr clientEmphAttr $
-              hBox [ scopeHeader scope
-                   , case st^.listOverlayRecordCount of
-                         Nothing -> emptyWidget
-                         Just c -> txt " (" <+> str (show c) <+> txt ")"
-                   ]
-      positionLayer = case layerPos of
-          OverlayCenter -> centerLayer
-          OverlayUpperRight -> upperRightLayer
-      body = vBox [ (padRight (Pad 1) promptMsg) <+>
-                    renderEditor (txt . T.unlines) True (st^.listOverlaySearchInput)
-                  , cursorPositionBorder
-                  , showResults
-                  , fromMaybe emptyWidget footer
-                  ]
-      plural 1 = ""
-      plural _ = "s"
-      cursorPositionBorder =
-          if st^.listOverlaySearching
-          then hBorderWithLabel $ txt "[Searching...]"
-          else case st^.listOverlaySearchResults.L.listSelectedL of
-              Nothing -> hBorder
-              Just _ ->
-                  let showingFirst = "Showing first " <> show numSearchResults <>
-                                     " result" <> plural numSearchResults
-                      showingAll = "Showing all " <> show numSearchResults <>
-                                   " result" <> plural numSearchResults
-                      showing = "Showing " <> show numSearchResults <>
-                                " result" <> plural numSearchResults
-                      msg = case getEditContents (st^.listOverlaySearchInput) of
-                          [""] ->
-                              case st^.listOverlayRecordCount of
-                                  Nothing -> showing
-                                  Just total -> if numSearchResults < total
-                                                then showingFirst
-                                                else showingAll
-                          _ -> showing
-                  in hBorderWithLabel $ str $ "[" <> msg <> "]"
-
-      scope = st^.listOverlaySearchScope
-      promptMsg = scopePrompt scope
-
-      showMessage = center . withDefAttr clientEmphAttr
-
-      showResults
-        | numSearchResults == 0 = showMessage $ scopeNoResults scope
-        | otherwise = renderedUserList
-
-      renderedUserList = L.renderList renderItem True (st^.listOverlaySearchResults)
-      numSearchResults = F.length $ st^.listOverlaySearchResults.L.listElementsL
-
-upperRightLayer :: Widget a -> Widget a
-upperRightLayer w =
-    Widget (hSize w) (vSize w) $ do
-        result <- render w
-        c <- getContext
-        let rWidth = result^.imageL.to imageWidth
-            leftPaddingAmount = max 0 $ c^.availWidthL - rWidth
-            paddedImage = translateX leftPaddingAmount $ result^.imageL
-            off = Location (leftPaddingAmount, 0)
-        if leftPaddingAmount == 0 then
-            return result else
-            return $ addResultOffset off
-                   $ result & imageL .~ paddedImage
diff --git a/src/Draw/Main.hs b/src/Draw/Main.hs
deleted file mode 100644
--- a/src/Draw/Main.hs
+++ /dev/null
@@ -1,655 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-module Draw.Main (drawMain) where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Widgets.Border
-import           Brick.Widgets.Border.Style
-import           Brick.Widgets.Center ( hCenter )
-import           Brick.Widgets.List ( listElements )
-import           Brick.Widgets.Edit ( editContentsL, renderEditor, getEditContents )
-import           Control.Arrow ( (>>>) )
-import           Data.Char ( isSpace, isPunctuation )
-import qualified Data.Map as M
-import qualified Data.Sequence as Seq
-import qualified Data.Set as S
-import qualified Data.Text as T
-import           Data.Text.Zipper ( cursorPosition, insertChar, getText, gotoEOL )
-import           Data.Time.Calendar ( fromGregorian )
-import           Data.Time.Clock ( UTCTime(..) )
-import qualified Graphics.Vty as Vty
-import           Lens.Micro.Platform ( (.~), (^?!), to, view, folding )
-
-import           Network.Mattermost.Types ( ChannelId, Type(Direct, Private, Group)
-                                          , ServerTime(..), UserId
-                                          )
-
-
-import           Constants
-import           Draw.ChannelList ( renderChannelList )
-import           Draw.Messages
-import           Draw.Autocomplete
-import           Draw.URLList
-import           Draw.Util
-import           Draw.RichText
-import           Events.Keybindings
-import           Events.MessageSelect
-import           State.MessageSelect
-import           Themes
-import           TimeUtils ( justAfter, justBefore )
-import           Types
-import           Types.RichText ( parseMarkdown )
-import           Types.KeyEvents
-
-
-previewFromInput :: Maybe MessageType -> UserId -> Text -> Maybe Message
-previewFromInput _ _ s | s == T.singleton cursorSentinel = Nothing
-previewFromInput overrideTy uId s =
-    -- If it starts with a slash but not /me, this has no preview
-    -- representation
-    let isCommand = "/" `T.isPrefixOf` s
-        isEmoteCmd = "/me " `T.isPrefixOf` s
-        content = if isEmoteCmd
-                  then T.stripStart $ T.drop 3 s
-                  else s
-        msgTy = fromMaybe (if isEmoteCmd then CP Emote else CP NormalPost) overrideTy
-    in if isCommand && not isEmoteCmd
-       then Nothing
-       else Just $ Message { _mText          = parseMarkdown content
-                           , _mMarkdownSource = content
-                           , _mUser          = UserI False uId
-                           , _mDate          = ServerTime $ UTCTime (fromGregorian 1970 1 1) 0
-                           -- The date is not used for preview
-                           -- rendering, but we need to provide one.
-                           -- Ideally we'd just use today's date, but
-                           -- the rendering function is pure so we
-                           -- can't.
-                           , _mType          = msgTy
-                           , _mPending       = False
-                           , _mDeleted       = False
-                           , _mAttachments   = mempty
-                           , _mInReplyToMsg  = NotAReply
-                           , _mMessageId     = Nothing
-                           , _mReactions     = mempty
-                           , _mOriginalPost  = Nothing
-                           , _mFlagged       = False
-                           , _mPinned        = False
-                           , _mChannelId     = Nothing
-                           }
-
--- | Tokens in spell check highlighting.
-data Token =
-    Ignore Text
-    -- ^ This bit of text is to be ignored for the purposes of
-    -- spell-checking.
-    | Check Text
-    -- ^ This bit of text should be checked against the spell checker's
-    -- misspelling list.
-    deriving (Show)
-
-drawEditorContents :: ChatState -> HighlightSet -> [Text] -> Widget Name
-drawEditorContents st hs =
-    let noHighlight = txt . T.unlines
-    in case st^.csEditState.cedSpellChecker of
-        Nothing -> noHighlight
-        Just _ ->
-            case S.null (st^.csEditState.cedMisspellings) of
-                True -> noHighlight
-                False -> doHighlightMisspellings
-                           hs
-                           (st^.csEditState.cedMisspellings)
-
--- | This function takes a set of misspellings from the spell
--- checker, the editor lines, and builds a rendering of the text with
--- misspellings highlighted.
---
--- This function processes each line of text from the editor as follows:
---
--- * Tokenize the line based on our rules for what constitutes
---   whitespace. We do this because we need to check "words" in the
---   user's input against the list of misspellings returned by the spell
---   checker. But to do this we need to ignore the same things that
---   Aspell ignores, and it ignores whitespace and lots of puncutation.
---   We also do this because once we have identified the misspellings
---   present in the input, we need to reconstruct the user's input and
---   that means preserving whitespace so that the input looks as it was
---   originally typed.
---
--- * Once we have a list of tokens -- the whitespace tokens to be
---   preserved but ignored and the tokens to be checked -- we check
---   each non-whitespace token for presence in the list of misspellings
---   reported by the checker.
---
--- * Having indicated which tokens correspond to misspelled words, we
---   then need to coallesce adjacent tokens that are of the same
---   "misspelling status", i.e., two neighboring tokens (of whitespace
---   or check type) need to be coallesced if they both correspond to
---   text that is a misspelling or if they both are NOT a misspelling.
---   We do this so that the final Brick widget is optimal in that it
---   uses a minimal number of box cells to display substrings that have
---   the same attribute.
---
--- * Finally we build a widget out of these coallesced tokens and apply
---   the misspellingAttr attribute to the misspelled tokens.
---
--- Note that since we have to come to our own conclusion about which
--- words are worth checking in the checker's output, sometimes our
--- algorithm will differ from aspell in what is considered "part of a
--- word" and what isn't. In particular, Aspell is smart about sometimes
--- noticing that "'" is an apostrophe and at other times that it is
--- a single quote as part of a quoted string. As a result there will
--- be cases where Markdown formatting characters interact poorly
--- with Aspell's checking to result in misspellings that are *not*
--- highlighted.
---
--- One way to deal with this would be to *not* parse the user's input
--- as done here, complete with all its Markdown metacharacters, but to
--- instead 1) parse the input as Markdown, 2) traverse the Markdown AST
--- and extract the words from the relevant subtrees, and 3) spell-check
--- those words. The reason we don't do it that way in the first place is
--- because 1) the user's input might not be valid markdown and 2) even
--- if we did that, we'd still have to do this tokenization operation to
--- annotate misspellings and reconstruct the user's raw input.
-doHighlightMisspellings :: HighlightSet -> S.Set Text -> [Text] -> Widget Name
-doHighlightMisspellings hSet misspellings contents =
-    -- Traverse the input, gathering non-whitespace into tokens and
-    -- checking if they appear in the misspelling collection
-    let whitelist = S.union (hUserSet hSet) (hChannelSet hSet)
-
-        handleLine t | t == "" = txt " "
-        handleLine t =
-            -- For annotated tokens, coallesce tokens of the same type
-            -- and add attributes for misspellings.
-            let mkW (Left tok) =
-                    let s = getTokenText tok
-                    in if T.null s
-                       then emptyWidget
-                       else withDefAttr misspellingAttr $ txt $ getTokenText tok
-                mkW (Right tok) =
-                    let s = getTokenText tok
-                    in if T.null s
-                       then emptyWidget
-                       else txt s
-
-                go :: Either Token Token -> [Either Token Token] -> [Either Token Token]
-                go lst [] = [lst]
-                go lst (tok:toks) =
-                    case (lst, tok) of
-                        (Left a, Left b)   -> go (Left $ combineTokens a b) toks
-                        (Right a, Right b) -> go (Right $ combineTokens a b) toks
-                        _                  -> lst : go tok toks
-
-            in hBox $ mkW <$> (go (Right $ Ignore "") $ annotatedTokens t)
-
-        combineTokens (Ignore a) (Ignore b) = Ignore $ a <> b
-        combineTokens (Check a) (Check b) = Check $ a <> b
-        combineTokens (Ignore a) (Check b) = Check $ a <> b
-        combineTokens (Check a) (Ignore b) = Check $ a <> b
-
-        getTokenText (Ignore a) = a
-        getTokenText (Check a) = a
-
-        annotatedTokens t =
-            -- For every token, check on whether it is a misspelling.
-            -- The result is Either Token Token where the Left is a
-            -- misspelling and the Right is not.
-            checkMisspelling <$> tokenize t (Ignore "")
-
-        checkMisspelling t@(Ignore _) = Right t
-        checkMisspelling t@(Check s) =
-            if s `S.member` whitelist
-            then Right t
-            else if s `S.member` misspellings
-                 then Left t
-                 else Right t
-
-        ignoreChar c = isSpace c || isPunctuation c || c == '`' || c == '/' ||
-                       T.singleton c == userSigil || T.singleton c == normalChannelSigil
-
-        tokenize t curTok
-            | T.null t = [curTok]
-            | ignoreChar $ T.head t =
-                case curTok of
-                    Ignore s -> tokenize (T.tail t) (Ignore $ s <> (T.singleton $ T.head t))
-                    Check s -> Check s : tokenize (T.tail t) (Ignore $ T.singleton $ T.head t)
-            | otherwise =
-                case curTok of
-                    Ignore s -> Ignore s : tokenize (T.tail t) (Check $ T.singleton $ T.head t)
-                    Check s -> tokenize (T.tail t) (Check $ s <> (T.singleton $ T.head t))
-
-    in vBox $ handleLine <$> contents
-
-renderUserCommandBox :: ChatState -> HighlightSet -> Widget Name
-renderUserCommandBox st hs =
-    let prompt = txt $ case st^.csEditState.cedEditMode of
-            Replying _ _ -> "reply> "
-            Editing _ _  ->  "edit> "
-            NewPost      ->      "> "
-        inputBox = renderEditor (drawEditorContents st hs) True (st^.csEditState.cedEditor)
-        curContents = getEditContents $ st^.csEditState.cedEditor
-        multilineContent = length curContents > 1
-        multilineHints =
-            hBox [ borderElem bsHorizontal
-                 , str $ "[" <> (show $ (+1) $ fst $ cursorPosition $
-                                        st^.csEditState.cedEditor.editContentsL) <>
-                         "/" <> (show $ length curContents) <> "]"
-                 , hBorderWithLabel $ withDefAttr clientEmphAttr $
-                   txt $ "In multi-line mode. Press " <>
-                         (ppBinding (getFirstDefaultBinding ToggleMultiLineEvent)) <>
-                         " to finish."
-                 ]
-
-        replyDisplay = case st^.csEditState.cedEditMode of
-            Replying msg _ ->
-                let msgWithoutParent = msg & mInReplyToMsg .~ NotAReply
-                in hBox [ replyArrow
-                        , addEllipsis $ renderMessage MessageData
-                          { mdMessage           = msgWithoutParent
-                          , mdUserName          = msgWithoutParent^.mUser.to (nameForUserRef st)
-                          , mdParentMessage     = Nothing
-                          , mdParentUserName    = Nothing
-                          , mdHighlightSet      = hs
-                          , mdEditThreshold     = Nothing
-                          , mdShowOlderEdits    = False
-                          , mdRenderReplyParent = True
-                          , mdIndentBlocks      = False
-                          , mdThreadState       = NoThread
-                          , mdShowReactions     = True
-                          , mdMessageWidthLimit = Nothing
-                          , mdMyUsername        = myUsername st
-                          }
-                        ]
-            _ -> emptyWidget
-
-        commandBox = case st^.csEditState.cedEphemeral.eesMultiline of
-            False ->
-                let linesStr = "line" <> if numLines == 1 then "" else "s"
-                    numLines = length curContents
-                in vLimit 1 $ hBox $
-                   prompt : if multilineContent
-                            then [ withDefAttr clientEmphAttr $
-                                   str $ "[" <> show numLines <> " " <> linesStr <>
-                                         "; Enter: send, M-e: edit, Backspace: cancel] "
-                                 , txt $ head curContents
-                                 , showCursor MessageInput (Location (0,0)) $ str " "
-                                 ]
-                            else [inputBox]
-            True -> vLimit multilineHeightLimit inputBox <=> multilineHints
-    in replyDisplay <=> commandBox
-
-renderChannelHeader :: ChatState -> HighlightSet -> ClientChannel -> Widget Name
-renderChannelHeader st hs chan =
-    let chnType = chan^.ccInfo.cdType
-        topicStr = chan^.ccInfo.cdHeader
-        userHeader u = let s = T.intercalate " " $ filter (not . T.null) parts
-                           parts = [ userSigil <> u^.uiName
-                                   , if (all T.null names)
-                                     then mempty
-                                     else "is"
-                                   ] <> names <> [
-                                     if T.null (u^.uiEmail)
-                                     then mempty
-                                     else "(" <> u^.uiEmail <> ")"
-                                   ]
-                           names = [ u^.uiFirstName
-                                   , nick
-                                   , u^.uiLastName
-                                   ]
-                           quote n = "\"" <> n <> "\""
-                           nick = maybe "" quote $ u^.uiNickName
-                       in s
-        maybeTopic = if T.null topicStr
-                     then ""
-                     else " - " <> topicStr
-        channelNameString = case chnType of
-            Direct ->
-                case chan^.ccInfo.cdDMUserId >>= flip userById st of
-                    Nothing -> mkChannelName (chan^.ccInfo)
-                    Just u -> userHeader u
-            Private ->
-                channelNamePair <> " (Private)"
-            Group ->
-                channelNamePair <> " (Private group)"
-            _ ->
-                channelNamePair
-        newlineToSpace '\n' = ' '
-        newlineToSpace c = c
-        channelNamePair = mkChannelName (chan^.ccInfo) <> " - " <> (chan^.ccInfo.cdDisplayName)
-
-    in renderText' (myUsername st)
-         hs
-         (T.map newlineToSpace (channelNameString <> maybeTopic))
-
-renderCurrentChannelDisplay :: ChatState -> HighlightSet -> Widget Name
-renderCurrentChannelDisplay st hs = header <=> messages
-    where
-    header = withDefAttr channelHeaderAttr $
-             padRight Max $
-             renderChannelHeader st hs chan
-    messages = padTop Max $ padRight (Pad 1) body
-
-    body = chatText
-
-    chatText = case appMode st of
-        MessageSelect ->
-            renderMessagesWithSelect (st^.csMessageSelect) channelMessages
-        MessageSelectDeleteConfirm ->
-            renderMessagesWithSelect (st^.csMessageSelect) channelMessages
-        _ ->
-            cached (ChannelMessages cId) $
-            renderLastMessages st hs editCutoff $
-            retrogradeMsgsWithThreadStates $
-            reverseMessages channelMessages
-
-    renderMessagesWithSelect (MessageSelectState selMsgId) msgs =
-        -- In this case, we want to fill the message list with messages
-        -- but use the post ID as a cursor. To do this efficiently we
-        -- only want to render enough messages to fill the screen.
-        --
-        -- If the message area is H rows high, this actually renders at
-        -- most 2H rows' worth of messages and then does the appropriate
-        -- cropping. This way we can simplify the math needed to figure
-        -- out how to crop while bounding the number of messages we
-        -- render around the cursor.
-        --
-        -- First, we sanity-check the application state because under
-        -- some conditions, the selected message might be gone (e.g.
-        -- deleted).
-        let (s, (before, after)) = splitDirSeqOn (\(m, _) -> m^.mMessageId == selMsgId) msgsWithStates
-            msgsWithStates = chronologicalMsgsWithThreadStates msgs
-        in case s of
-             Nothing ->
-                 renderLastMessages st hs editCutoff before
-             Just m ->
-                 unsafeRenderMessageSelection (m, (before, after)) (renderSingleMessage st hs Nothing)
-
-    cutoff = getNewMessageCutoff cId st
-    editCutoff = getEditedMessageCutoff cId st
-    channelMessages =
-        insertTransitions (getMessageListing cId st)
-                          cutoff
-                          (getDateFormat st)
-                          (st ^. timeZone)
-
-    cId = st^.csCurrentChannelId
-    chan = st^.csCurrentChannel
-
-getMessageListing :: ChannelId -> ChatState -> Messages
-getMessageListing cId st =
-    st ^?! csChannels.folding (findChannelById cId) . ccContents . cdMessages . to (filterMessages isShown)
-    where isShown m
-            | st^.csResources.crUserPreferences.userPrefShowJoinLeave = True
-            | otherwise = not $ isJoinLeave m
-
-insertTransitions :: Messages -> Maybe NewMessageIndicator -> Text -> TimeZoneSeries -> Messages
-insertTransitions ms cutoff = insertDateMarkers $ foldr addMessage ms newMessagesT
-    where anyNondeletedNewMessages t =
-              isJust $ findLatestUserMessage (not . view mDeleted) (messagesAfter t ms)
-          newMessagesT = case cutoff of
-              Nothing -> []
-              Just Hide -> []
-              Just (NewPostsAfterServerTime t)
-                  | anyNondeletedNewMessages t -> [newMessagesMsg $ justAfter t]
-                  | otherwise -> []
-              Just (NewPostsStartingAt t)
-                  | anyNondeletedNewMessages (justBefore t) -> [newMessagesMsg $ justBefore t]
-                  | otherwise -> []
-          newMessagesMsg d = newMessageOfType (T.pack "New Messages")
-                             (C NewMessagesTransition) d
-
-renderChannelSelectPrompt :: ChatState -> Widget Name
-renderChannelSelectPrompt st =
-    let e = st^.csChannelSelectState.channelSelectInput
-    in withDefAttr channelSelectPromptAttr $
-       (txt "Switch to channel [use ^ and $ to anchor]: ") <+>
-       (renderEditor (txt . T.concat) True e)
-
-drawMain :: Bool -> ChatState -> [Widget Name]
-drawMain useColor st =
-    let maybeColor = if useColor then id else forceAttr "invalid"
-    in maybeColor <$>
-           [ connectionLayer st
-           , autocompleteLayer st
-           , joinBorders $ mainInterface st
-           ]
-
-connectionLayer :: ChatState -> Widget Name
-connectionLayer st =
-    case st^.csConnectionStatus of
-        Connected -> emptyWidget
-        Disconnected ->
-            Widget Fixed Fixed $ do
-                ctx <- getContext
-                let aw = ctx^.availWidthL
-                    w = length msg + 2
-                    msg = "NOT CONNECTED"
-                render $ translateBy (Location (max 0 (aw - w), 0)) $
-                         withDefAttr errorMessageAttr $
-                         border $ str msg
-
-messageSelectBottomBar :: ChatState -> Widget Name
-messageSelectBottomBar st =
-    let optionStr = if null usableOptions
-                    then "(no actions available for this message)"
-                    else T.intercalate " " usableOptions
-        usableOptions = catMaybes $ mkOption <$> options
-        mkOption (f, k, desc) = if f postMsg
-                                then Just $ k <> ":" <> desc
-                                else Nothing
-        numURLs = Seq.length $ msgURLs postMsg
-        s = if numURLs == 1 then "" else "s"
-        hasURLs = numURLs > 0
-        openUrlsMsg = "open " <> (T.pack $ show numURLs) <> " URL" <> s
-        hasVerb = isJust (findVerbatimChunk (postMsg^.mText))
-        -- make sure these keybinding pieces are up-to-date!
-        ev e =
-          let keyconf = st^.csResources.crConfiguration.to configUserKeys
-              KeyHandlerMap keymap = messageSelectKeybindings keyconf
-          in T.intercalate ","
-               [ ppBinding (eventToBinding k)
-               | KH { khKey     = k
-                    , khHandler = h
-                    } <- M.elems keymap
-               , kehEventTrigger h == ByEvent e
-               ]
-        options = [ ( not . isGap
-                    , ev YankWholeMessageEvent
-                    , "yank-all"
-                    )
-                  , ( \m -> isFlaggable m && not (m^.mFlagged)
-                    , ev FlagMessageEvent
-                    , "flag"
-                    )
-                  , ( \m -> isFlaggable m && m^.mFlagged
-                    , ev FlagMessageEvent
-                    , "unflag"
-                    )
-                  , ( \m -> isPinnable m && not (m^.mPinned)
-                    , ev PinMessageEvent
-                    , "pin"
-                    )
-                  , ( \m -> isPinnable m && m^.mPinned
-                    , ev PinMessageEvent
-                    , "unpin"
-                    )
-                  , ( isReplyable
-                    , ev ReplyMessageEvent
-                    , "reply"
-                    )
-                  , ( not . isGap
-                    , ev ViewMessageEvent
-                    , "view"
-                    )
-                  , ( isGap
-                    , ev FillGapEvent
-                    , "load messages"
-                    )
-                  , ( \m -> isMine st m && isEditable m
-                    , ev EditMessageEvent
-                    , "edit"
-                    )
-                  , ( \m -> isMine st m && isDeletable m
-                    , ev DeleteMessageEvent
-                    , "delete"
-                    )
-                  , ( const hasURLs
-                    , ev OpenMessageURLEvent
-                    , openUrlsMsg
-                    )
-                  , ( const hasVerb
-                    , ev YankMessageEvent
-                    , "yank-code"
-                    )
-                  , ( isReactable
-                    , ev ReactToMessageEvent
-                    , "react"
-                    )
-                  ]
-        Just postMsg = getSelectedMessage st
-
-    in hBox [ borderElem bsHorizontal
-            , txt "["
-            , withDefAttr messageSelectStatusAttr $
-              txt $ "Message select: " <> optionStr
-            , txt "]"
-            , hBorder
-            ]
-
-maybePreviewViewport :: Widget Name -> Widget Name
-maybePreviewViewport w =
-    Widget Greedy Fixed $ do
-        result <- render w
-        case (Vty.imageHeight $ result^.imageL) > previewMaxHeight of
-            False -> return result
-            True ->
-                render $ vLimit previewMaxHeight $ viewport MessagePreviewViewport Vertical $
-                         (Widget Fixed Fixed $ return result)
-
-inputPreview :: ChatState -> HighlightSet -> Widget Name
-inputPreview st hs | not $ st^.csShowMessagePreview = emptyWidget
-                   | otherwise = thePreview
-    where
-    uId = myUserId st
-    -- Insert a cursor sentinel into the input text just before
-    -- rendering the preview. We use the inserted sentinel (which is
-    -- not rendered) to get brick to ensure that the line the cursor is
-    -- on is visible in the preview viewport. We put the sentinel at
-    -- the *end* of the line because it will still influence markdown
-    -- parsing and can create undesirable/confusing churn in the
-    -- rendering while the cursor moves around. If the cursor is at the
-    -- end of whatever line the user is editing, that is very unlikely
-    -- to be a problem.
-    curContents = getText $ (gotoEOL >>> insertChar cursorSentinel) $
-                  st^.csEditState.cedEditor.editContentsL
-    curStr = T.intercalate "\n" curContents
-    overrideTy = case st^.csEditState.cedEditMode of
-        Editing _ ty -> Just ty
-        _ -> Nothing
-    previewMsg = previewFromInput overrideTy uId curStr
-    thePreview = let noPreview = str "(No preview)"
-                     msgPreview = case previewMsg of
-                       Nothing -> noPreview
-                       Just pm -> if T.null curStr
-                                  then noPreview
-                                  else prview pm $ getParentMessage st pm
-                     prview m p = renderMessage MessageData
-                                  { mdMessage           = m
-                                  , mdUserName          = m^.mUser.to (nameForUserRef st)
-                                  , mdParentMessage     = p
-                                  , mdParentUserName    = p >>= (^.mUser.to (nameForUserRef st))
-                                  , mdHighlightSet      = hs
-                                  , mdEditThreshold     = Nothing
-                                  , mdShowOlderEdits    = False
-                                  , mdRenderReplyParent = True
-                                  , mdIndentBlocks      = True
-                                  , mdThreadState       = NoThread
-                                  , mdShowReactions     = True
-                                  , mdMessageWidthLimit = Nothing
-                                  , mdMyUsername        = myUsername st
-                                  }
-                 in (maybePreviewViewport msgPreview) <=>
-                    hBorderWithLabel (withDefAttr clientEmphAttr $ str "[Preview ↑]")
-
-userInputArea :: ChatState -> HighlightSet -> Widget Name
-userInputArea st hs =
-    case appMode st of
-        ChannelSelect -> renderChannelSelectPrompt st
-        UrlSelect     -> hCenter $ hBox [ txt "Press "
-                                        , withDefAttr clientEmphAttr $ txt "Enter"
-                                        , txt " to open the selected URL or "
-                                        , withDefAttr clientEmphAttr $ txt "Escape"
-                                        , txt " to cancel."
-                                        ]
-        MessageSelectDeleteConfirm -> renderDeleteConfirm
-        _             -> renderUserCommandBox st hs
-
-renderDeleteConfirm :: Widget Name
-renderDeleteConfirm =
-    hCenter $ txt "Are you sure you want to delete the selected message? (y/n)"
-
-mainInterface :: ChatState -> Widget Name
-mainInterface st =
-    vBox [ if st^.csShowChannelList || appMode st == ChannelSelect
-           then hBox [hLimit channelListWidth (renderChannelList st), vBorder, mainDisplay]
-           else mainDisplay
-         , bottomBorder
-         , inputPreview st hs
-         , userInputArea st hs
-         ]
-    where
-    hs = getHighlightSet st
-    channelListWidth = configChannelListWidth $ st^.csResources.crConfiguration
-    mainDisplay = case appMode st of
-        UrlSelect -> renderUrlList st
-        _         -> maybeSubdue $ renderCurrentChannelDisplay st hs
-
-    bottomBorder = case appMode st of
-        MessageSelect -> messageSelectBottomBar st
-        _ -> maybeSubdue $ hBox
-             [ showAttachmentCount
-             , hBorder
-             , showTypingUsers
-             , showBusy
-             ]
-
-    showAttachmentCount =
-        let count = length $ listElements $ st^.csEditState.cedAttachmentList
-        in if count == 0
-           then emptyWidget
-           else hBox [ borderElem bsHorizontal
-                     , withDefAttr clientMessageAttr $
-                       txt $ "(" <> (T.pack $ show count) <> " attachment" <>
-                             (if count == 1 then "" else "s") <> "; "
-                     , withDefAttr clientEmphAttr $
-                       txt $ ppBinding (getFirstDefaultBinding ShowAttachmentListEvent)
-                     , txt " to manage)"
-                     ]
-
-    showTypingUsers =
-        let format = renderText' (myUsername st) hs
-        in case allTypingUsers (st^.csCurrentChannel.ccInfo.cdTypingUsers) of
-            [] -> emptyWidget
-            [uId] | Just un <- usernameForUserId uId st ->
-               format $ "[" <> userSigil <> un <> " is typing]"
-            [uId1, uId2] | Just un1 <- usernameForUserId uId1 st
-                         , Just un2 <- usernameForUserId uId2 st ->
-               format $ "[" <> userSigil <> un1 <> " and " <> userSigil <> un2 <> " are typing]"
-            _ -> format "[several people are typing]"
-
-    showBusy = case st^.csWorkerIsBusy of
-                 Just (Just n) -> hLimit 2 hBorder <+> txt (T.pack $ "*" <> show n)
-                 Just Nothing -> hLimit 2 hBorder <+> txt "*"
-                 Nothing -> emptyWidget
-
-    maybeSubdue = if appMode st == ChannelSelect
-                  then forceAttr ""
-                  else id
-
-replyArrow :: Widget a
-replyArrow =
-    Widget Fixed Fixed $ do
-        ctx <- getContext
-        let bs = ctx^.ctxBorderStyleL
-        render $ str [' ', bsCornerTL bs, '▸']
diff --git a/src/Draw/ManageAttachments.hs b/src/Draw/ManageAttachments.hs
deleted file mode 100644
--- a/src/Draw/ManageAttachments.hs
+++ /dev/null
@@ -1,65 +0,0 @@
-module Draw.ManageAttachments
-  ( drawManageAttachments
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Widgets.Border
-import           Brick.Widgets.Center
-import qualified Brick.Widgets.FileBrowser as FB
-import           Brick.Widgets.List
-import           Data.Maybe ( fromJust )
-
-import           Types
-import           Types.KeyEvents
-import           Events.Keybindings ( getFirstDefaultBinding )
-import           Themes
-import           Draw.Main ( drawMain )
-
-
-drawManageAttachments :: ChatState -> [Widget Name]
-drawManageAttachments st =
-    topLayer : drawMain False st
-    where
-        topLayer = case appMode st of
-            ManageAttachments -> drawAttachmentList st
-            ManageAttachmentsBrowseFiles -> drawFileBrowser st
-            _ -> error "BUG: drawManageAttachments called in invalid mode"
-
-drawAttachmentList :: ChatState -> Widget Name
-drawAttachmentList st =
-    let addBinding = ppBinding $ getFirstDefaultBinding AttachmentListAddEvent
-        delBinding = ppBinding $ getFirstDefaultBinding AttachmentListDeleteEvent
-        escBinding = ppBinding $ getFirstDefaultBinding CancelEvent
-        openBinding = ppBinding $ getFirstDefaultBinding AttachmentOpenEvent
-    in centerLayer $
-       hLimit 60 $
-       vLimit 15 $
-       joinBorders $
-       borderWithLabel (withDefAttr clientEmphAttr $ txt "Attachments") $
-       vBox [ renderList renderAttachmentItem True (st^.csEditState.cedAttachmentList)
-            , hBorder
-            , hCenter $ withDefAttr clientMessageAttr $
-                        txt $ addBinding <> ":add " <>
-                              delBinding <> ":delete " <>
-                              openBinding <> ":open " <>
-                              escBinding <> ":close"
-            ]
-
-renderAttachmentItem :: Bool -> AttachmentData -> Widget Name
-renderAttachmentItem _ d =
-    padRight Max $ str $ FB.fileInfoSanitizedFilename $ attachmentDataFileInfo d
-
-drawFileBrowser :: ChatState -> Widget Name
-drawFileBrowser st =
-    centerLayer $
-    hLimit 60 $
-    vLimit 20 $
-    borderWithLabel (withDefAttr clientEmphAttr $ txt "Attach File") $
-    -- invariant: cedFileBrowser is not Nothing if appMode is
-    -- ManageAttachmentsBrowseFiles, and that is the only way to reach
-    -- this code, ergo the fromJust.
-    FB.renderFileBrowser True $ fromJust (st^.csEditState.cedFileBrowser)
diff --git a/src/Draw/Messages.hs b/src/Draw/Messages.hs
deleted file mode 100644
--- a/src/Draw/Messages.hs
+++ /dev/null
@@ -1,204 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-module Draw.Messages
-  ( nameForUserRef
-  , renderSingleMessage
-  , unsafeRenderMessageSelection
-  , renderLastMessages
-  )
-where
-
-import           Brick
-import           Brick.Widgets.Border
-import           Control.Monad.Trans.Reader ( withReaderT )
-import qualified Data.Foldable as F
-import qualified Graphics.Vty as Vty
-import           Lens.Micro.Platform ( (.~), to )
-import           Network.Mattermost.Types ( ServerTime(..), userUsername )
-import           Prelude ()
-import           Prelude.MH
-
-import           Draw.Util
-import           Draw.RichText
-import           Themes
-import           Types
-import           Types.DirectionalSeq
-
-maxMessageHeight :: Int
-maxMessageHeight = 200
-
--- | nameForUserRef converts the UserRef into a printable name, based
--- on the current known user data.
-nameForUserRef :: ChatState -> UserRef -> Maybe Text
-nameForUserRef st uref =
-    case uref of
-        NoUser -> Nothing
-        UserOverride _ t -> Just t
-        UserI _ uId -> displayNameForUserId uId st
-
--- | renderSingleMessage is the main message drawing function.
---
--- The `ind` argument specifies an "indicator boundary".  Showing
--- various indicators (e.g. "edited") is not typically done for
--- messages that are older than this boundary value.
-renderSingleMessage :: ChatState
-                    -> HighlightSet
-                    -> Maybe ServerTime
-                    -> Message
-                    -> ThreadState
-                    -> Widget Name
-renderSingleMessage st hs ind m threadState =
-  renderChatMessage st hs ind threadState (withBrackets . renderTime st . withServerTime) m
-
-renderChatMessage :: ChatState
-                  -> HighlightSet
-                  -> Maybe ServerTime
-                  -> ThreadState
-                  -> (ServerTime -> Widget Name)
-                  -> Message
-                  -> Widget Name
-renderChatMessage st hs ind threadState renderTimeFunc msg =
-    let showOlderEdits = configShowOlderEdits $ st^.csResources.crConfiguration
-        parent = case msg^.mInReplyToMsg of
-          NotAReply -> Nothing
-          InReplyTo pId -> getMessageForPostId st pId
-        m = renderMessage MessageData
-              { mdMessage           = msg
-              , mdUserName          = msg^.mUser.to (nameForUserRef st)
-              , mdParentMessage     = parent
-              , mdParentUserName    = parent >>= (^.mUser.to (nameForUserRef st))
-              , mdEditThreshold     = ind
-              , mdHighlightSet      = hs
-              , mdShowOlderEdits    = showOlderEdits
-              , mdRenderReplyParent = True
-              , mdIndentBlocks      = True
-              , mdThreadState       = threadState
-              , mdShowReactions     = True
-              , mdMessageWidthLimit = Nothing
-              , mdMyUsername        = userUsername $ myUser st
-              }
-        fullMsg =
-          case msg^.mUser of
-            NoUser
-              | isGap msg -> withDefAttr gapMessageAttr m
-              | otherwise ->
-                case msg^.mType of
-                    C DateTransition ->
-                        withDefAttr dateTransitionAttr (hBorderWithLabel m)
-                    C NewMessagesTransition ->
-                        withDefAttr newMessageTransitionAttr (hBorderWithLabel m)
-                    C Error ->
-                        withDefAttr errorMessageAttr m
-                    _ ->
-                        withDefAttr clientMessageAttr m
-            _ | isJoinLeave msg -> withDefAttr clientMessageAttr m
-              | otherwise -> m
-        maybeRenderTime w =
-            let maybePadTime = if threadState == InThreadShowParent
-                               then (txt " " <=>) else id
-            in hBox [maybePadTime $ renderTimeFunc (msg^.mDate), txt " ", w]
-        maybeRenderTimeWith f = if isTransition msg then id else f
-    in maybeRenderTimeWith maybeRenderTime fullMsg
-
--- | Render a selected message with focus, including the messages
--- before and the messages after it. The foldable parameters exist
--- because (depending on the situation) we might use either of the
--- message list types for the 'before' and 'after' (i.e. the
--- chronological or retrograde message sequences).
-unsafeRenderMessageSelection :: (Foldable f, Foldable g)
-                             => ((Message, ThreadState), (f (Message, ThreadState), g (Message, ThreadState)))
-                             -> (Message -> ThreadState -> Widget Name)
-                             -> Widget Name
-unsafeRenderMessageSelection ((curMsg, curThreadState), (before, after)) doMsgRender =
-  Widget Greedy Greedy $ do
-    ctx <- getContext
-    curMsgResult <- withReaderT relaxHeight $ render $
-                    forceAttr messageSelectAttr $
-                    padRight Max $ doMsgRender curMsg curThreadState
-
-    let targetHeight = ctx^.availHeightL
-        upperHeight = targetHeight `div` 2
-        lowerHeight = targetHeight - upperHeight
-
-        lowerRender img (m, tState) = render1HLimit doMsgRender Vty.vertJoin targetHeight img tState m
-        upperRender img (m, tState) = render1HLimit doMsgRender (flip Vty.vertJoin) targetHeight img tState m
-
-    lowerHalf <- foldM lowerRender Vty.emptyImage after
-    upperHalf <- foldM upperRender Vty.emptyImage before
-
-    let curHeight = Vty.imageHeight $ curMsgResult^.imageL
-        uncropped = upperHalf Vty.<-> curMsgResult^.imageL Vty.<-> lowerHalf
-        img = if | Vty.imageHeight lowerHalf < (lowerHeight - curHeight) ->
-                     Vty.cropTop targetHeight uncropped
-                 | Vty.imageHeight upperHalf < upperHeight ->
-                     Vty.cropBottom targetHeight uncropped
-                 | otherwise ->
-                     Vty.cropTop upperHeight upperHalf Vty.<-> curMsgResult^.imageL Vty.<->
-                        (if curHeight < lowerHeight
-                          then Vty.cropBottom (lowerHeight - curHeight) lowerHalf
-                          else Vty.cropBottom lowerHeight lowerHalf)
-    return $ emptyResult & imageL .~ img
-
-renderLastMessages :: ChatState
-                   -> HighlightSet
-                   -> Maybe ServerTime
-                   -> DirectionalSeq Retrograde (Message, ThreadState)
-                   -> Widget Name
-renderLastMessages st hs editCutoff msgs =
-    Widget Greedy Greedy $ do
-        ctx <- getContext
-        let targetHeight = ctx^.availHeightL
-            doMsgRender = renderSingleMessage st hs editCutoff
-
-            newMessagesTransitions = filterMessages (isNewMessagesTransition . fst) msgs
-            newMessageTransition = fst <$> (listToMaybe $ F.toList newMessagesTransitions)
-
-            isBelow m transition = m^.mDate > transition^.mDate
-
-            go :: Vty.Image -> DirectionalSeq Retrograde (Message, ThreadState) -> RenderM Name Vty.Image
-            go img ms | messagesLength ms == 0 = return img
-            go img ms = do
-                let Just (m, threadState) = messagesHead ms
-                    newMessagesAbove = maybe False (isBelow m) newMessageTransition
-                newImg <- render1HLimit doMsgRender (flip Vty.vertJoin) targetHeight img threadState m
-                -- If the new message fills the window, check whether
-                -- there is still a "New Messages" transition that is
-                -- not displayed. If there is, then we need to replace
-                -- the top line of the new image with a "New Messages"
-                -- indicator.
-                if Vty.imageHeight newImg >= targetHeight && newMessagesAbove
-                then do
-                    transitionResult <- render $ withDefAttr newMessageTransitionAttr $
-                                                 hBorderWithLabel (txt "New Messages ↑")
-                    let newImg2 = Vty.vertJoin (transitionResult^.imageL)
-                                               (Vty.cropTop (targetHeight - 1) newImg)
-                    return newImg2
-                else go newImg $ messagesDrop 1 ms
-
-        img <- go Vty.emptyImage msgs
-        return $ emptyResult & imageL .~ (Vty.cropTop targetHeight img)
-
-relaxHeight :: Context -> Context
-relaxHeight c = c & availHeightL .~ (max maxMessageHeight (c^.availHeightL))
-
-render1HLimit :: (Message -> ThreadState -> Widget Name)
-              -> (Vty.Image -> Vty.Image -> Vty.Image)
-              -> Int
-              -> Vty.Image
-              -> ThreadState
-              -> Message
-              -> RenderM Name Vty.Image
-render1HLimit doMsgRender fjoin lim img threadState msg
-  | Vty.imageHeight img >= lim = return img
-  | otherwise = fjoin img <$> render1 doMsgRender threadState msg
-
-render1 :: (Message -> ThreadState -> Widget Name)
-        -> ThreadState
-        -> Message
-        -> RenderM Name Vty.Image
-render1 doMsgRender threadState msg = case msg^.mDeleted of
-    True -> return Vty.emptyImage
-    False -> do
-        r <- withReaderT relaxHeight $
-             render $ padRight Max $
-             doMsgRender msg threadState
-        return $ r^.imageL
diff --git a/src/Draw/NotifyPrefs.hs b/src/Draw/NotifyPrefs.hs
deleted file mode 100644
--- a/src/Draw/NotifyPrefs.hs
+++ /dev/null
@@ -1,35 +0,0 @@
-module Draw.NotifyPrefs
-  ( drawNotifyPrefs
-  )
-where
-
-import Prelude ()
-import Prelude.MH
-
-import Brick
-import Brick.Widgets.Border
-import Brick.Widgets.Center
-import Brick.Forms (renderForm)
-import Data.List (intersperse)
-
-import Draw.Util (renderKeybindingHelp)
-import Types
-import Types.KeyEvents
-import Themes
-
-drawNotifyPrefs :: ChatState -> Widget Name
-drawNotifyPrefs st =
-    let Just form = st^.csNotifyPrefs
-        label = forceAttr clientEmphAttr $ str "Notification Preferences"
-        formKeys = withDefAttr clientEmphAttr <$> txt <$> ["Tab", "BackTab"]
-        bindings = vBox $ hCenter <$> [ renderKeybindingHelp "Save" [FormSubmitEvent] <+> txt "  " <+>
-                                        renderKeybindingHelp "Cancel" [CancelEvent]
-                                      , hBox ((intersperse (txt "/") formKeys) <> [txt (":Cycle form fields")])
-                                      , hBox [withDefAttr clientEmphAttr $ txt "Space", txt ":Toggle form field"]
-                                      ]
-    in centerLayer $
-       vLimit 25 $
-       hLimit 39 $
-       joinBorders $
-       borderWithLabel label $
-       (padAll 1 $ renderForm form) <=> hBorder <=> bindings
diff --git a/src/Draw/PostListOverlay.hs b/src/Draw/PostListOverlay.hs
deleted file mode 100644
--- a/src/Draw/PostListOverlay.hs
+++ /dev/null
@@ -1,117 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE TupleSections #-}
-
-module Draw.PostListOverlay where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Widgets.Border
-import           Brick.Widgets.Center
-import           Control.Monad.Trans.Reader ( withReaderT )
-import qualified Data.Text as T
-import           Lens.Micro.Platform ( (^?), (%~), to )
-
-import           Network.Mattermost.Lenses
-import           Network.Mattermost.Types
-
-import           Constants ( userSigil )
-import           Draw.Main
-import           Draw.Messages
-import           Draw.Util
-import           Themes
-import           Types
-
-
-hLimitWithPadding :: Int -> Widget n -> Widget n
-hLimitWithPadding pad contents = Widget
-  { hSize  = Fixed
-  , vSize  = (vSize contents)
-  , render =
-      withReaderT (& availWidthL  %~ (\ n -> n - (2 * pad))) $ render $ cropToContext contents
-  }
-
-drawPostListOverlay :: PostListContents -> ChatState -> [Widget Name]
-drawPostListOverlay contents st =
-  (joinBorders $ drawPostsBox contents st) : (drawMain False st)
-
--- | Draw a PostListOverlay as a floating overlay on top of whatever
--- is rendered beneath it
-drawPostsBox :: PostListContents -> ChatState -> Widget Name
-drawPostsBox contents st =
-  centerLayer $ hLimitWithPadding 10 $ borderWithLabel contentHeader $
-    padRight (Pad 1) messageListContents
-  where -- The 'window title' of the overlay
-        hs = getHighlightSet st
-        contentHeader = withAttr channelListHeaderAttr $ txt $ case contents of
-          PostListFlagged -> "Flagged posts"
-          PostListPinned cId ->
-              let cName = case findChannelById cId (st^.csChannels) of
-                      Nothing -> "<UNKNOWN>"
-                      Just cc ->
-                          case cc^.ccInfo.cdType of
-                              Direct ->
-                                  case cc^.ccInfo.cdDMUserId >>= flip userById st of
-                                      Nothing -> mkChannelName (cc^.ccInfo)
-                                      Just u -> userSigil <> u^.uiName
-                              _ -> mkChannelName (cc^.ccInfo)
-              in "Posts pinned in " <> cName
-          PostListSearch terms searching -> "Search results" <> if searching
-            then ": " <> terms
-            else " (" <> (T.pack . show . length) messages <> "): " <> terms
-
-        messages = insertDateMarkers
-                     (filterMessages knownChannel $ st^.csPostListOverlay.postListPosts)
-                     (getDateFormat st)
-                     (st^.timeZone)
-
-        knownChannel msg =
-            case msg^.mChannelId of
-                Just cId | Nothing <- st^?csChannels.channelByIdL(cId) -> False
-                _ -> True
-
-        -- The overall contents, with a sensible default even if there
-        -- are no messages
-        messageListContents
-          | null messages =
-            padTopBottom 1 $
-            hCenter $
-            withDefAttr clientEmphAttr $
-            str $ case contents of
-              PostListFlagged -> "You have no flagged messages."
-              PostListPinned _ -> "This channel has no pinned messages."
-              PostListSearch _ searching ->
-                if searching
-                  then "Searching ..."
-                  else "No search results found"
-          | otherwise = vBox renderedMessageList
-
-        -- The render-message function we're using
-        renderMessageForOverlay msg tState =
-          let renderedMsg = renderSingleMessage st hs Nothing msg tState
-          in case msg^.mOriginalPost of
-            -- We should factor out some of the channel name logic at
-            -- some point, but we can do that later
-            Just post
-              | Just chan <- st^?csChannels.channelByIdL(post^.postChannelIdL) ->
-                 case chan^.ccInfo.cdType of
-                  Direct
-                    | Just u <- flip userById st =<< chan^.ccInfo.cdDMUserId ->
-                        (forceAttr channelNameAttr (txt (userSigil <> u^.uiName)) <=>
-                          (str "  " <+> renderedMsg))
-                  _ -> (forceAttr channelNameAttr (txt (chan^.ccInfo.to mkChannelName)) <=>
-                         (str "  " <+> renderedMsg))
-            _ | CP _ <- msg^.mType -> str "[BUG: unknown channel]"
-              | otherwise -> renderedMsg
-
-        -- The full message list, rendered with the current selection
-        renderedMessageList =
-          let (s, (before, after)) = splitDirSeqOn matchesMessage messagesWithStates
-              matchesMessage (m, _) = m^.mMessageId == (MessagePostId <$> st^.csPostListOverlay.postListSelected)
-              messagesWithStates = (, InThreadShowParent) <$> messages
-          in case s of
-            Nothing ->
-                map (uncurry renderMessageForOverlay) (reverse (toList messagesWithStates))
-            Just curMsg ->
-              [unsafeRenderMessageSelection (curMsg, (after, before)) renderMessageForOverlay]
diff --git a/src/Draw/ReactionEmojiListOverlay.hs b/src/Draw/ReactionEmojiListOverlay.hs
deleted file mode 100644
--- a/src/Draw/ReactionEmojiListOverlay.hs
+++ /dev/null
@@ -1,40 +0,0 @@
-module Draw.ReactionEmojiListOverlay
-  ( drawReactionEmojiListOverlay
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Widgets.List ( listSelectedFocusedAttr )
-import qualified Data.Text as T
-
-import           Draw.Main
-import           Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )
-import           Types
-import           Themes
-
-
-drawReactionEmojiListOverlay :: ChatState -> [Widget Name]
-drawReactionEmojiListOverlay st =
-    let overlay = drawListOverlay (st^.csReactionEmojiListOverlay)
-                                  (const $ txt "Search Emoji")
-                                  (const $ txt "No matching emoji found.")
-                                  (const $ txt "Search emoji:")
-                                  renderEmoji
-                                  Nothing
-                                  OverlayCenter
-                                  80
-    in joinBorders overlay : drawMain False st
-
-renderEmoji :: Bool -> (Bool, T.Text) -> Widget Name
-renderEmoji sel (mine, e) =
-    let maybeForce = if sel
-                     then forceAttr listSelectedFocusedAttr
-                     else id
-    in maybeForce $
-       padRight Max $
-       hBox [ if mine then txt " * " else txt "   "
-            , withDefAttr emojiAttr $ txt $ ":" <> e <> ":"
-            ]
diff --git a/src/Draw/RichText.hs b/src/Draw/RichText.hs
deleted file mode 100644
--- a/src/Draw/RichText.hs
+++ /dev/null
@@ -1,525 +0,0 @@
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE MultiWayIf #-}
-{-# LANGUAGE ViewPatterns #-}
-{-# LANGUAGE ParallelListComp #-}
-{-# LANGUAGE RecordWildCards #-}
-module Draw.RichText
-  ( MessageData(..)
-  , renderRichText
-  , renderMessage
-  , renderText
-  , renderText'
-  , renderElementSeq
-  , cursorSentinel
-  , addEllipsis
-  , findVerbatimChunk
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick ( (<+>), Widget, hLimit, imageL
-                       , raw, render, Size(..), Widget(..)
-                       )
-import qualified Brick as B
-import qualified Brick.Widgets.Border as B
-import qualified Brick.Widgets.Skylighting as BS
-import qualified Data.Foldable as F
-import qualified Data.Map.Strict as Map
-import qualified Data.Sequence as Seq
-import           Data.Sequence ( ViewL(..)
-                               , ViewR(..)
-                               , (<|)
-                               , (|>)
-                               , viewl
-                               , viewr)
-import qualified Data.Sequence as S
-import qualified Data.Set as Set
-import qualified Data.Text as T
-import qualified Graphics.Vty as V
-import qualified Skylighting.Core as Sky
-
-import           Network.Mattermost.Lenses ( postEditAtL, postCreateAtL )
-import           Network.Mattermost.Types ( ServerTime(..) )
-
-import           Constants ( normalChannelSigil, userSigil )
-import           Themes
-import           Types ( Name, HighlightSet(..) )
-import           Types.Messages
-import           Types.Posts
-import           Types.RichText
-
-
-emptyHSet :: HighlightSet
-emptyHSet = HighlightSet Set.empty Set.empty mempty
-
-omittedUsernameType :: MessageType -> Bool
-omittedUsernameType = \case
-  CP Join -> True
-  CP Leave -> True
-  CP TopicChange -> True
-  _ -> False
-
--- Add the edit sentinel to the end of the last block in the sequence.
--- If the last block is a paragraph, append it to that paragraph.
--- Otherwise, append a new block so it appears beneath the last
--- block-level element.
-addEditSentinel :: ElementData -> Seq RichTextBlock -> Seq RichTextBlock
-addEditSentinel d bs =
-    case viewr bs of
-        EmptyR -> bs
-        (rest :> b) -> rest <> appendEditSentinel d b
-
-appendEditSentinel :: ElementData -> RichTextBlock -> Seq RichTextBlock
-appendEditSentinel sentinel b =
-    let s = Para (S.singleton m)
-        m = Element Normal sentinel
-    in case b of
-        Para is -> S.singleton $ Para (is |> Element Normal ESpace |> m)
-        _ -> S.fromList [b, s]
-
--- | A bundled structure that includes all the information necessary
--- to render a given message
-data MessageData =
-    MessageData { mdEditThreshold :: Maybe ServerTime
-                -- ^ If specified, any messages edited before this point
-                -- in time are not indicated as edited.
-                , mdShowOlderEdits :: Bool
-                -- ^ Indicates whether "edited" markers should be shown
-                -- for old messages (i.e., ignore the mdEditThreshold
-                -- value).
-                , mdShowReactions :: Bool
-                -- ^ Whether to render reactions.
-                , mdMessage :: Message
-                -- ^ The message to render.
-                , mdUserName :: Maybe Text
-                -- ^ The username of the message's author, if any. This
-                -- is passed here rather than obtaining from the message
-                -- because we need to do lookups in the ChatState to
-                -- compute this, and we don't pass the ChatState into
-                -- renderMessage.
-                , mdParentMessage :: Maybe Message
-                -- ^ The parent message of this message, if any.
-                , mdParentUserName :: Maybe Text
-                -- ^ The author of the parent message, if any.
-                , mdThreadState :: ThreadState
-                -- ^ The thread state of this message.
-                , mdRenderReplyParent :: Bool
-                -- ^ Whether to render the parent message.
-                , mdHighlightSet :: HighlightSet
-                -- ^ The highlight set to use to highlight usernames,
-                -- channel names, etc.
-                , mdIndentBlocks :: Bool
-                -- ^ Whether to indent the message underneath the
-                -- author's name (True) or just display it to the right
-                -- of the author's name (False).
-                , mdMessageWidthLimit :: Maybe Int
-                -- ^ A width override to use to wrap non-code blocks. If
-                -- unspecified, all blocks in the message will be
-                -- wrapped and truncated at the width specified by the
-                -- rendering context. If specified, all non-code blocks
-                -- will be wrapped at this width and code blocks will be
-                -- rendered using the context's width.
-                , mdMyUsername :: Text
-                -- ^ The username of the user running Matterhorn.
-                }
-
--- | renderMessage performs markdown rendering of the specified message.
-renderMessage :: MessageData -> Widget Name
-renderMessage md@MessageData { mdMessage = msg, .. } =
-    let msgUsr = case mdUserName of
-          Just u -> if omittedUsernameType (msg^.mType) then Nothing else Just u
-          Nothing -> Nothing
-        botElem = if isBotMessage msg then B.txt "[BOT]" else B.emptyWidget
-        nameElems = case msgUsr of
-          Just un
-            | isEmote msg ->
-                [ B.withDefAttr pinnedMessageIndicatorAttr $ B.txt $ if msg^.mPinned then "[PIN]" else ""
-                , B.txt $ (if msg^.mFlagged then "[!] " else "") <> "*"
-                , colorUsername mdMyUsername un un
-                , botElem
-                , B.txt " "
-                ]
-            | otherwise ->
-                [ B.withDefAttr pinnedMessageIndicatorAttr $ B.txt $ if msg^.mPinned then "[PIN] " else ""
-                , colorUsername mdMyUsername un un
-                , botElem
-                , B.txt $ (if msg^.mFlagged then "[!]" else "") <> ": "
-                ]
-          Nothing -> []
-
-        -- Use the editing threshold to determine whether to append an
-        -- editing indication to this message.
-        maybeAugment bs = case msg^.mOriginalPost of
-            Nothing -> bs
-            Just p ->
-                if p^.postEditAtL > p^.postCreateAtL
-                then case mdEditThreshold of
-                    Just cutoff | p^.postEditAtL >= cutoff ->
-                        addEditSentinel (EEditSentinel True) bs
-                    _ -> if mdShowOlderEdits
-                         then addEditSentinel (EEditSentinel False) bs
-                         else bs
-                else bs
-
-        augmentedText = maybeAugment $ msg^.mText
-        msgWidget =
-            vBox $ (layout mdHighlightSet mdMessageWidthLimit nameElems augmentedText . viewl) augmentedText :
-                   catMaybes [msgAtch, msgReac]
-        replyIndent = B.Widget B.Fixed B.Fixed $ do
-            ctx <- B.getContext
-            -- NB: The amount subtracted here must be the total padding
-            -- added below (pad 1 + vBorder)
-            w <- B.render $ B.hLimit (ctx^.B.availWidthL - 2) msgWidget
-            B.render $ B.vLimit (V.imageHeight $ w^.B.imageL) $
-                B.padRight (B.Pad 1) B.vBorder B.<+> (B.Widget B.Fixed B.Fixed $ return w)
-        msgAtch = if Seq.null (msg^.mAttachments)
-          then Nothing
-          else Just $ B.withDefAttr clientMessageAttr $ vBox
-                 [ B.txt ("  [attached: `" <> a^.attachmentName <> "`]")
-                 | a <- toList (msg^.mAttachments)
-                 ]
-        msgReac = if Map.null (msg^.mReactions) || (not mdShowReactions)
-          then Nothing
-          else let renderR e us =
-                       let n = Set.size us
-                       in if | n == 1    -> " [" <> e <> "]"
-                             | n > 1     -> " [" <> e <> " " <> T.pack (show n) <> "]"
-                             | otherwise -> ""
-                   reactionMsg = Map.foldMapWithKey renderR (msg^.mReactions)
-               in Just $ B.withDefAttr emojiAttr $ B.txt ("   " <> reactionMsg)
-        withParent p =
-            case mdThreadState of
-                NoThread -> msgWidget
-                InThreadShowParent -> p B.<=> replyIndent
-                InThread -> replyIndent
-    in if not mdRenderReplyParent
-       then msgWidget
-       else case msg^.mInReplyToMsg of
-          NotAReply -> msgWidget
-          InReplyTo _ ->
-              case mdParentMessage of
-                  Nothing -> withParent (B.str "[loading...]")
-                  Just pm ->
-                      let parentMsg = renderMessage md
-                            { mdShowOlderEdits    = False
-                            , mdMessage           = pm
-                            , mdUserName          = mdParentUserName
-                            , mdParentMessage     = Nothing
-                            , mdRenderReplyParent = False
-                            , mdIndentBlocks      = False
-                            }
-                      in withParent (addEllipsis $ B.forceAttr replyParentAttr parentMsg)
-
-    where
-        layout :: HighlightSet -> Maybe Int -> [Widget Name] -> Seq RichTextBlock
-               -> ViewL RichTextBlock -> Widget Name
-        layout hs w nameElems bs xs | length xs > 1     = multiLnLayout hs w nameElems bs
-        layout hs w nameElems bs (Blockquote {} :< _) = multiLnLayout hs w nameElems bs
-        layout hs w nameElems bs (CodeBlock {} :< _)  = multiLnLayout hs w nameElems bs
-        layout hs w nameElems bs (HTMLBlock {} :< _)  = multiLnLayout hs w nameElems bs
-        layout hs w nameElems bs (List {} :< _)       = multiLnLayout hs w nameElems bs
-        layout hs w nameElems bs (Para inlns :< _)
-            | F.any breakCheck inlns                    = multiLnLayout hs w nameElems bs
-        layout hs w nameElems bs _                      = nameNextToMessage hs w nameElems bs
-
-        multiLnLayout hs w nameElems bs =
-            if mdIndentBlocks
-               then vBox [ hBox nameElems
-                         , hBox [B.txt "  ", renderRichText mdMyUsername hs ((subtract 2) <$> w) bs]
-                         ]
-               else nameNextToMessage hs w nameElems bs
-
-        nameNextToMessage hs w nameElems bs =
-            Widget Fixed Fixed $ do
-                nameResult <- render $ hBox nameElems
-                let newW = subtract (V.imageWidth (nameResult^.imageL)) <$> w
-                render $ hBox [raw (nameResult^.imageL), renderRichText mdMyUsername hs newW bs]
-
-        breakCheck e = eData e `elem` [ELineBreak, ESoftBreak]
-
-addEllipsis :: Widget a -> Widget a
-addEllipsis w = B.Widget (B.hSize w) (B.vSize w) $ do
-    ctx <- B.getContext
-    let aw = ctx^.B.availWidthL
-    result <- B.render w
-    let withEllipsis = (B.hLimit (aw - 3) $ B.vLimit 1 $ (B.Widget B.Fixed B.Fixed $ return result)) <+>
-                       B.str "..."
-    if (V.imageHeight (result^.B.imageL) > 1) || (V.imageWidth (result^.B.imageL) == aw) then
-        B.render withEllipsis else
-        return result
-
--- Cursor sentinel for tracking the user's cursor position in previews.
-cursorSentinel :: Char
-cursorSentinel = '‸'
-
--- Render markdown with username highlighting
-renderRichText :: Text -> HighlightSet -> Maybe Int -> Seq RichTextBlock -> Widget a
-renderRichText curUser hSet w =
-  B.vBox . toList . fmap (blockToWidget curUser hSet w) . addBlankLines
-
--- Add blank lines only between adjacent elements of the same type, to
--- save space
-addBlankLines :: Seq RichTextBlock -> Seq RichTextBlock
-addBlankLines = go' . viewl
-  where go' EmptyL = S.empty
-        go' (x :< xs) = go x (viewl xs)
-        go a@Para {} (b@Para {} :< rs) =
-             a <| blank <| go b (viewl rs)
-        go a@Header {} (b@Header {} :< rs) =
-             a <| blank <| go b (viewl rs)
-        go a@Blockquote {} (b@Blockquote {} :< rs) =
-             a <| blank <| go b (viewl rs)
-        go a@List {} (b@List {} :< rs) =
-             a <| blank <| go b (viewl rs)
-        go a@CodeBlock {} (b@CodeBlock {} :< rs) =
-             a <| blank <| go b (viewl rs)
-        go a@HTMLBlock {} (b@HTMLBlock {} :< rs) =
-             a <| blank <| go b (viewl rs)
-        go x (y :< rs) = x <| go y (viewl rs)
-        go x (EmptyL) = S.singleton x
-        blank = Para (S.singleton (Element Normal ESpace))
-
--- Render text to markdown without username highlighting
-renderText :: Text -> Widget a
-renderText txt = renderText' "" emptyHSet txt
-
-renderText' :: Text -> HighlightSet -> Text -> Widget a
-renderText' curUser hSet t = renderRichText curUser hSet Nothing rtBs
-  where rtBs = parseMarkdown t
-
-vBox :: F.Foldable f => f (Widget a) -> Widget a
-vBox = B.vBox . toList
-
-hBox :: F.Foldable f => f (Widget a) -> Widget a
-hBox = B.hBox . toList
-
-header :: Int -> Widget a
-header n = B.txt (T.replicate n "#")
-
-maybeHLimit :: Maybe Int -> Widget a -> Widget a
-maybeHLimit Nothing w = w
-maybeHLimit (Just i) w = hLimit i w
-
-blockToWidget :: Text -> HighlightSet -> Maybe Int -> RichTextBlock -> Widget a
-blockToWidget curUser hSet w (Para is) =
-    toElementChunk curUser w is hSet
-blockToWidget curUser hSet w (Header n is) =
-    B.withDefAttr clientHeaderAttr $
-        hBox [B.padRight (B.Pad 1) $ header n, toElementChunk curUser (subtract 1 <$> w) is hSet]
-blockToWidget curUser hSet w (Blockquote is) =
-    maybeHLimit w $
-    addQuoting (vBox $ fmap (blockToWidget curUser hSet w) is)
-blockToWidget curUser hSet w (List _ l bs) =
-    maybeHLimit w $
-    blocksToList curUser l w bs hSet
-blockToWidget _ hSet _ (CodeBlock ci tx) =
-    let f = maybe rawCodeBlockToWidget (codeBlockToWidget (hSyntaxMap hSet)) mSyntax
-        mSyntax = do
-            lang <- codeBlockLanguage ci
-            Sky.lookupSyntax lang (hSyntaxMap hSet)
-    in f tx
-blockToWidget _ _ w (HTMLBlock t) =
-    maybeHLimit w $
-    textWithCursor t
-blockToWidget _ _ w (HRule) =
-    maybeHLimit w $
-    B.vLimit 1 (B.fill '*')
-
-quoteChar :: Char
-quoteChar = '>'
-
-addQuoting :: B.Widget n -> B.Widget n
-addQuoting w =
-    B.Widget B.Fixed (B.vSize w) $ do
-        ctx <- B.getContext
-        childResult <- B.render $ B.hLimit (ctx^.B.availWidthL - 2) w
-
-        let quoteBorder = B.raw $ V.charFill (ctx^.B.attrL) quoteChar 1 height
-            height = V.imageHeight $ childResult^.B.imageL
-
-        B.render $ B.hBox [ B.padRight (B.Pad 1) quoteBorder
-                          , B.Widget B.Fixed B.Fixed $ return childResult
-                          ]
-
-codeBlockToWidget :: Sky.SyntaxMap -> Sky.Syntax -> Text -> Widget a
-codeBlockToWidget syntaxMap syntax tx =
-    let result = Sky.tokenize cfg syntax tx
-        cfg = Sky.TokenizerConfig syntaxMap False
-    in case result of
-        Left _ -> rawCodeBlockToWidget tx
-        Right tokLines ->
-            let padding = B.padLeftRight 1 (B.vLimit (length tokLines) B.vBorder)
-            in (B.txt $ "[" <> Sky.sName syntax <> "]") B.<=>
-               (padding <+> BS.renderRawSource textWithCursor tokLines)
-
-rawCodeBlockToWidget :: Text -> Widget a
-rawCodeBlockToWidget tx =
-    B.withDefAttr codeAttr $
-        let padding = B.padLeftRight 1 (B.vLimit (length theLines) B.vBorder)
-            theLines = expandEmpty <$> T.lines tx
-            expandEmpty "" = " "
-            expandEmpty s  = s
-        in padding <+> (B.vBox $ textWithCursor <$> theLines)
-
-toElementChunk :: Text -> Maybe Int -> Seq Element -> HighlightSet -> Widget a
-toElementChunk curUser w es hSet = B.Widget B.Fixed B.Fixed $ do
-  ctx <- B.getContext
-  let width = fromMaybe (ctx^.B.availWidthL) w
-      ws    = fmap (renderElementSeq curUser) (wrapLine width hSet es)
-  B.render (vBox (fmap hBox ws))
-
-blocksToList :: Text -> ListType -> Maybe Int -> Seq (Seq RichTextBlock) -> HighlightSet -> Widget a
-blocksToList curUser lt w bs hSet = vBox
-  [ B.txt i <+> (vBox (fmap (blockToWidget curUser hSet w) b))
-  | b <- F.toList bs
-  | i <- is ]
-  where is = case lt of
-          Bullet _ -> repeat ("• ")
-          Numbered Period s ->
-            [ T.pack (show (n :: Int)) <> ". " | n <- [s..] ]
-          Numbered Paren s ->
-            [ T.pack (show (n :: Int)) <> ") " | n <- [s..] ]
-
-data SplitState = SplitState
-  { splitChunks  :: Seq (Seq Element)
-  , splitCurrCol :: Int
-  }
-
-wrapLine :: Int -> HighlightSet -> Seq Element -> Seq (Seq Element)
-wrapLine maxCols hSet = splitChunks . go (SplitState (S.singleton S.empty) 0)
-  where go st (viewl-> e :< es) = go st' es
-          where
-              HighlightSet { hUserSet = uSet, hChannelSet = cSet } = hSet
-
-              -- Right before we check the width of the token, we see
-              -- if the token is a user or channel reference. If so, we
-              -- check if it is valid. If it is valid, we leave it in
-              -- place; otherwise we translate it into an ordinary text
-              -- element so that it does not render highlighted as a
-              -- valid user or channel reference.
-              newElement = e { eData = newEData }
-              newEData = case eData e of
-                  EUser u ->
-                      if u `Set.member` uSet
-                      then EUser u
-                      else EText $ userSigil <> u
-                  EChannel c ->
-                      if c `Set.member` cSet
-                      then EChannel c
-                      else EText $ normalChannelSigil <> c
-                  d -> d
-
-              addHyperlink url el = setElementStyle (Hyperlink url (eStyle el)) el
-
-              st' =
-                  case newEData of
-                      EHyperlink url (Just labelEs) ->
-                          go st $ addHyperlink url <$> labelEs
-                      EImage url (Just labelEs) ->
-                          go st $ addHyperlink url <$> labelEs
-                      _ ->
-                          if | newEData == ESoftBreak || newEData == ELineBreak ->
-                                 st { splitChunks = splitChunks st |> S.empty
-                                    , splitCurrCol = 0
-                                    }
-                             | available >= eWidth ->
-                                 st { splitChunks  = addElement newElement (splitChunks st)
-                                    , splitCurrCol = splitCurrCol st + eWidth
-                                    }
-                             | newEData == ESpace ->
-                                 st { splitChunks = splitChunks st |> S.empty
-                                    , splitCurrCol = 0
-                                    }
-                             | otherwise ->
-                                 st { splitChunks  = splitChunks st |> S.singleton newElement
-                                    , splitCurrCol = eWidth
-                                    }
-              available = maxCols - splitCurrCol st
-              eWidth = elementWidth newElement
-              addElement x (viewr-> ls :> l) = ( ls |> (l |> x))
-              addElement _ _ = error "[unreachable]"
-        go st _                 = st
-
-renderElementSeq :: Text -> Seq Element -> Seq (Widget a)
-renderElementSeq curUser es = renderElement curUser <$> es
-
-renderElement :: Text -> Element -> Widget a
-renderElement curUser e = addStyle sty widget
-    where
-        sty = eStyle e
-        dat = eData e
-        addStyle s = case s of
-                Normal                  -> id
-                Emph                    -> B.withDefAttr clientEmphAttr
-                Strong                  -> B.withDefAttr clientStrongAttr
-                Code                    -> B.withDefAttr codeAttr
-                Hyperlink (URL url) innerSty ->
-                    B.hyperlink url . B.withDefAttr urlAttr .  addStyle innerSty
-        rawText = B.txt . removeCursor
-        widget = case dat of
-            -- Cursor sentinels get parsed as individual text nodes by
-            -- Cheapskate, meaning that we get a text node with exactly
-            -- one character in it. That's why we compare accordingly
-            -- above. In addition, since that character has no width, we
-            -- have to replace it with something that has a size (such
-            -- as a space) to get Brick's visibility logic to scroll the
-            -- viewport to get it into view.
-            EText t                      -> if t == T.singleton (cursorSentinel)
-                                            then B.visible $ B.txt " "
-                                            else rawText t
-
-            ESpace                       -> B.txt " "
-            ERawHtml t                   -> rawText t
-            EEditSentinel recent         -> let attr = if recent
-                                                       then editedRecentlyMarkingAttr
-                                                       else editedMarkingAttr
-                                            in B.withDefAttr attr $ B.txt editMarking
-            EUser u                      -> colorUsername curUser u $ userSigil <> u
-            EChannel c                   -> B.withDefAttr channelNameAttr $
-                                            B.txt $ normalChannelSigil <> c
-            EHyperlink (URL url) Nothing -> rawText url
-            EImage (URL url) Nothing     -> rawText url
-            EEmoji em                    -> B.withDefAttr emojiAttr $
-                                            B.txt $ ":" <> em <> ":"
-
-            -- Hyperlink and image nodes with labels should not appear
-            -- at this point because line-wrapping should break them up
-            -- into normal text node sequences with hyperlink styles
-            -- attached.
-            EHyperlink {}                -> rawText "(Report renderElement bug #1)"
-            EImage {}                    -> rawText "(Report renderElement bug #2)"
-
-            -- Line breaks should never need to get rendered since the
-            -- line-wrapping algorithm removes them.
-            ESoftBreak                   -> B.emptyWidget
-            ELineBreak                   -> B.emptyWidget
-
-textWithCursor :: Text -> Widget a
-textWithCursor t
-    | T.any (== cursorSentinel) t = B.visible $ B.txt $ removeCursor t
-    | otherwise = B.txt t
-
-removeCursor :: Text -> Text
-removeCursor = T.filter (/= cursorSentinel)
-
-editMarking :: Text
-editMarking = "(edited)"
-
-elementWidth :: Element -> Int
-elementWidth e =
-    case eData e of
-        EText t                      -> B.textWidth t
-        ERawHtml t                   -> B.textWidth t
-        EUser t                      -> T.length userSigil + B.textWidth t
-        EChannel t                   -> T.length normalChannelSigil + B.textWidth t
-        EEditSentinel _              -> B.textWidth editMarking
-        EImage (URL url) Nothing     -> B.textWidth url
-        EImage _ (Just is)           -> sum $ elementWidth <$> is
-        EHyperlink (URL url) Nothing -> B.textWidth url
-        EHyperlink _ (Just is)       -> sum $ elementWidth <$> is
-        EEmoji t                     -> B.textWidth t + 2
-        ESpace                       -> 1
-        ELineBreak                   -> 0
-        ESoftBreak                   -> 0
diff --git a/src/Draw/ShowHelp.hs b/src/Draw/ShowHelp.hs
deleted file mode 100644
--- a/src/Draw/ShowHelp.hs
+++ /dev/null
@@ -1,567 +0,0 @@
-module Draw.ShowHelp
-  ( drawShowHelp
-  , keybindingMarkdownTable
-  , keybindingTextTable
-  , commandTextTable
-  , commandMarkdownTable
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Themes ( themeDescriptions )
-import           Brick.Widgets.Border
-import           Brick.Widgets.Center ( hCenter )
-import           Brick.Widgets.List ( listSelectedFocusedAttr )
-import qualified Data.Map as M
-import qualified Data.Text as T
-import qualified Graphics.Vty as Vty
-import           Lens.Micro.Platform ( singular, _Just, _2 )
-
-import           Network.Mattermost.Version ( mmApiVersion )
-
-import           Command
-import           Events
-import           Events.ChannelSelect
-import           Events.Keybindings
-import           Events.Main
-import           Events.MessageSelect
-import           Events.ThemeListOverlay
-import           Events.PostListOverlay
-import           Events.ShowHelp
-import           Events.UrlSelect
-import           Events.UserListOverlay
-import           Events.ChannelListOverlay
-import           Events.ReactionEmojiListOverlay
-import           Events.ManageAttachments
-import           Events.TabbedWindow
-import           Windows.ViewMessage
-import           HelpTopics ( helpTopics )
-import           Draw.RichText ( renderText )
-import           Options ( mhVersion )
-import           State.Editing ( editingKeyHandlers )
-import           Themes
-import           Types
-import           Types.KeyEvents ( BindingState(..), Binding(..)
-                                 , ppBinding, nonCharKeys, eventToBinding )
-
-
-drawShowHelp :: HelpTopic -> ChatState -> [Widget Name]
-drawShowHelp topic st =
-    [helpBox (helpTopicViewportName topic) $ helpTopicDraw topic st]
-
-helpTopicDraw :: HelpTopic -> ChatState -> Widget Name
-helpTopicDraw topic st =
-    overrideAttr codeAttr helpEmphAttr $
-    hCenter $
-    hLimit helpContentWidth $
-    case helpTopicScreen topic of
-        MainHelp -> mainHelp (configUserKeys (st^.csResources.crConfiguration))
-        ScriptHelp -> scriptHelp
-        ThemeHelp -> themeHelp
-        SyntaxHighlightHelp -> syntaxHighlightHelp (configSyntaxDirs $ st^.csResources.crConfiguration)
-        KeybindingHelp -> keybindingHelp (configUserKeys (st^.csResources.crConfiguration))
-
-mainHelp :: KeyConfig -> Widget Name
-mainHelp kc = summary
-  where
-    summary = vBox entries
-    entries = [ heading $ T.pack mhVersion
-              , headingNoPad $ T.pack mmApiVersion
-              , heading "Help Topics"
-              , drawHelpTopics
-              , heading "Commands"
-              , padTop (Pad 1) mkCommandHelpText
-              , heading "Keybindings"
-              ] <>
-              (mkKeybindingHelp kc <$> keybindSections)
-
-    mkCommandHelpText :: Widget Name
-    mkCommandHelpText =
-      let commandNameWidth = 2 + (maximum $ T.length <$> fst <$> commandHelpInfo)
-      in vBox [ (emph $ txt $ padTo commandNameWidth info) <+> renderText desc
-              | (info, desc) <- commandHelpInfo
-              ]
-
-commandHelpInfo :: [(T.Text, T.Text)]
-commandHelpInfo = pairs
-    where
-        pairs = [ (info, desc)
-                | Cmd cmd desc args _ <- cs
-                , let argSpec = printArgSpec args
-                      spc = if T.null argSpec then "" else " "
-                      info = T.cons '/' cmd <> spc <> argSpec
-                ]
-        cs = sortWith commandName commandList
-
-commandTextTable :: T.Text
-commandTextTable =
-    let commandNameWidth = 4 + (maximum $ T.length <$> fst <$> commandHelpInfo)
-    in T.intercalate "\n" $
-       [ padTo commandNameWidth info <> desc
-       | (info, desc) <- commandHelpInfo
-       ]
-
-commandMarkdownTable :: T.Text
-commandMarkdownTable =
-    T.intercalate "\n" $
-    [ "# Commands"
-    , ""
-    , "| Command | Description |"
-    , "| ------- | ----------- |"
-    ] <>
-    [ "| `" <> info <> "` | " <> desc <> " |"
-    | (info, desc) <- commandHelpInfo
-    ]
-
-drawHelpTopics :: Widget Name
-drawHelpTopics =
-    let allHelpTopics = drawTopic <$> helpTopics
-        topicNameWidth = 4 + (maximum $ T.length <$> helpTopicName <$> helpTopics)
-        drawTopic t = (emph $ txt (padTo topicNameWidth $ helpTopicName t)) <+>
-                      txt (helpTopicDescription t)
-    in vBox $ (padBottom (Pad 1) $
-               para "Learn more about these topics with `/help <topic>`:")
-            : allHelpTopics
-
-helpContentWidth :: Int
-helpContentWidth = 72
-
-scriptHelp :: Widget Name
-scriptHelp = heading "Using Scripts" <=> vBox scriptHelpText
-  where scriptHelpText = map paraL
-          [ [ "Matterhorn has a special feature that allows you to use "
-             , "prewritten shell scripts to preprocess messages. "
-             , "For example, this can allow you to run various filters over "
-             , "your written text, do certain kinds of automated formatting, "
-             , "or just automatically cowsay-ify a message." ]
-           , [ "These scripts can be any kind of executable file, "
-             , "as long as the file lives in "
-             , "*~/.config/matterhorn/scripts* (unless you've explicitly "
-             , "moved your XDG configuration directory elsewhere). "
-             , "Those executables are given no arguments "
-             , "on the command line and are passed your typed message on "
-             , "*stdin*; whatever they produce on *stdout* is sent "
-             , "as a message. If the script exits successfully, then everything "
-             , "that appeared on *stderr* is discarded; if it instead exits with "
-             , "a failing exit code, your message is *not* sent, and you are "
-             , "presented with whatever was printed on stderr as a "
-             , "local error message." ]
-           , [ "To run a script, simply type" ]
-           , [ "> *> /sh [script-name] [my-message]*" ]
-           , [ "And the script named *[script-name]* will be invoked with "
-             , "the text of *[my-message]*. If the script does not exist, "
-             , "or if it exists but is not marked as executable, you'll be "
-             , "presented with an appropriate error message." ]
-           , [ "For example, if you want to use a basic script to "
-             , "automatically ROT13 your message, you can write a shell "
-             , "script using the standard Unix *tr* utility, like this:" ]
-           , [ "> *#!/bin/bash -e*"
-             , "> *tr '[A-Za-z]' '[N-ZA-Mn-za-m]'*" ]
-           , [ "Move this script to *~/.config/matterhorn/scripts/rot13* "
-             , "and be sure it's executable with" ]
-           , [ "> *$ chmod u+x ~/.config/matterhorn/scripts/rot13*" ]
-           , [ "after which you can send ROT13 messages with the "
-             , "Matterhorn command " ]
-           , [ "> *> /sh rot13 Hello, world!*" ]
-           ]
-
-keybindingMarkdownTable :: KeyConfig -> Text
-keybindingMarkdownTable kc = title <> keybindSectionStrings
-    where title = "# Keybindings\n"
-          keybindSectionStrings = T.concat $ sectionText <$> keybindSections
-          sectionText = mkKeybindEventSectionHelp kc keybindEventHelpMarkdown T.unlines mkHeading
-          mkHeading n =
-              "\n# " <> n <>
-              "\n| Keybinding | Event Name | Description |" <>
-              "\n| ---------- | ---------- | ----------- |"
-
-keybindingTextTable :: KeyConfig -> Text
-keybindingTextTable kc = title <> keybindSectionStrings
-    where title = "Keybindings\n===========\n"
-          keybindSectionStrings = T.concat $ sectionText <$> keybindSections
-          sectionText = mkKeybindEventSectionHelp kc (keybindEventHelpText keybindingWidth eventNameWidth) T.unlines mkHeading
-          keybindingWidth = 15
-          eventNameWidth = 30
-          mkHeading n =
-              "\n" <> n <>
-              "\n" <> (T.replicate (T.length n) "=")
-
-keybindingHelp :: KeyConfig -> Widget Name
-keybindingHelp kc = vBox $
-  [ heading "Configurable Keybindings"
-  , padBottom (Pad 1) $ vBox keybindingHelpText
-  ] ++ keybindSectionWidgets
-    ++
-  [ headingNoPad "Keybinding Syntax"
-  , vBox validKeys
-  ]
-  where keybindSectionWidgets = sectionWidget <$> keybindSections
-        sectionWidget = mkKeybindEventSectionHelp kc keybindEventHelpWidget vBox headingNoPad
-
-        keybindingHelpText = map paraL
-          [ [ "Many of the keybindings used in Matterhorn can be "
-            , "modified from within Matterhorn's **config.ini** file. "
-            , "To do this, include a section called **[KEYBINDINGS]** "
-            , "in your config file and use the event names listed below as "
-            , "keys and the desired key sequence as values. "
-            , "See the end of this page for documentation on the valid "
-            , "syntax for key sequences."
-            ]
-          , [ "For example, by default, the keybinding to move to the next "
-            , "channel in the public channel list is **"
-            , nextChanBinding
-            , "**, and the corresponding "
-            , "previous channel binding is **"
-            , prevChanBinding
-            , "**. You might want to remap these "
-            , "to other keys: say, **C-j** and **C-k**. We can do this with the following "
-            , "configuration snippet:"
-            ]
-          , [ "```ini\n"
-            , "[KEYBINDINGS]\n"
-            , "focus-next-channel = C-j\n"
-            , "focus-prev-channel = C-k\n"
-            , "```"
-            ]
-          , [ "You can remap a command to more than one key sequence, in which "
-            , "case any one of the key sequences provided can be used to invoke "
-            , "the relevant command. To do this, provide the desired bindings as "
-            , "a comma-separated list. Additionally, some key combinations are "
-            , "used in multiple modes (such as URL select or help viewing) and "
-            , "therefore share the same name, such as **cancel** or **scroll-up**."
-            ]
-          , [ "Additionally, some keys simply cannot be remapped, mostly in the "
-            , "case of editing keybindings. If you feel that a particular key "
-            , "event should be rebindable and isn't, then please feel free to "
-            , "let us know by posting an issue in the Matterhorn issue tracker."
-            ]
-          , [ "It is also possible to entirely unbind a key event by setting its "
-            , "key to **unbound**, thus avoiding conflicts between default bindings "
-            , "and new ones:"
-            ]
-          , [ "```ini\n"
-            , "[KEYBINDINGS]\n"
-            , "focus-next-channel = unbound\n"
-            , "```"
-            ]
-          , [ "The rebindable key events, along with their **current** "
-            , "values, are as follows:"
-            ]
-           ]
-        nextChanBinding = ppBinding (getFirstDefaultBinding NextChannelEvent)
-        prevChanBinding = ppBinding (getFirstDefaultBinding PrevChannelEvent)
-        validKeys = map paraL
-          [ [ "The syntax used for key sequences consists of zero or more "
-            , "single-character modifier characters followed by a keystroke, "
-            , "all separated by dashes. The available modifier keys are "
-            , "**S** for Shift, **C** for Ctrl, **A** for Alt, and **M** for "
-            , "Meta. So, for example, **"
-            , ppBinding (Binding [] (Vty.KFun 2))
-            , "** is the F2 key pressed with no "
-            , "modifier keys; **"
-            , ppBinding (Binding [Vty.MCtrl] (Vty.KChar 'x'))
-            , "** is Ctrl and X pressed together, "
-            , "and **"
-            , ppBinding (Binding [Vty.MShift, Vty.MCtrl] (Vty.KChar 'x'))
-            , "** is Shift, Ctrl, and X all pressed together. "
-            , "Although Matterhorn will pretty-print all key combinations "
-            , "with specific capitalization, the parser is **not** case-sensitive "
-            , "and will ignore any capitalization."
-            ]
-          , [ "Your terminal emulator might not recognize some particular "
-            , "keypress combinations, or it might reserve certain combinations of "
-            , "keys for some terminal-specific operation. Matterhorn does not have a "
-            , "reliable way of testing this, so it is up to you to avoid setting "
-            , "keybindings that your terminal emulator does not deliver to applications."
-            ]
-          , [ "Letter keys, number keys, and function keys are specified with "
-            , "their obvious name, such as **x** for the X key, **8** for the 8 "
-            , "key, and **f5** for the F5 key. Other valid keys include: "
-            , T.intercalate ", " [ "**" <> key <> "**" | key <- nonCharKeys ]
-            , "."
-            ]
-          ]
-
-emph :: Widget a -> Widget a
-emph = withDefAttr helpEmphAttr
-
-para :: Text -> Widget a
-para t = padTop (Pad 1) $ renderText t
-
-paraL :: [Text] -> Widget a
-paraL = para . mconcat
-
-heading :: Text -> Widget a
-heading = padTop (Pad 1) . headingNoPad
-
-headingNoPad :: Text -> Widget a
-headingNoPad t = hCenter $ emph $ renderText t
-
-syntaxHighlightHelp :: [FilePath] -> Widget a
-syntaxHighlightHelp dirs = vBox
-  [ heading "Syntax Highlighting"
-
-  , para $ "Matterhorn supports syntax highlighting in Markdown code blocks when the " <>
-           "name of the code block language follows the block opening sytnax:"
-  , para $ "```<language>"
-  , para $ "The possible values of `language` are determined by the available syntax " <>
-           "definitions. The available definitions are loaded from the following " <>
-           "directories according to the configuration setting `syntaxDirectories`. " <>
-           "If the setting is omitted, it defaults to the following sequence of directories:"
-  , para $ T.pack $ intercalate "\n" $ (\d -> "`" <> d <> "`") <$> dirs
-  , para $ "Syntax definitions are in the Kate XML format. Files with an " <>
-           "`xml` extension are loaded from each directory, with directories earlier " <>
-           "in the list taking precedence over later directories when more than one " <>
-           "directory provides a definition file for the same syntax."
-  , para $ "To place custom definitions in a directory, place a Kate " <>
-           "XML syntax definition in the directory and ensure that a copy of " <>
-           "`language.dtd` is also present. The file `language.dtd` can be found in " <>
-           "the `syntax/` directory of your Matterhorn distribution."
-  ]
-
-themeHelp :: Widget a
-themeHelp = vBox
-  [ heading "Using Themes"
-  , para "Matterhorn provides these built-in color themes:"
-  , padTop (Pad 1) $ vBox $ hCenter <$> emph <$>
-                            txt <$> internalThemeName <$> internalThemes
-  , para $
-        "These themes can be selected with the */theme* command. To automatically " <>
-        "select a theme at startup, set the *theme* configuration file option to one " <>
-        "of the themes listed above."
-
-  , heading "Customizing the Theme"
-  , para $
-        "Theme customization is also supported. To customize the selected theme, " <>
-        "create a theme customization file and set the `themeCustomizationFile` " <>
-        "configuration option to the path to the customization file. If the path " <>
-        "to the file is relative, Matterhorn will look for it in the same directory " <>
-        "as the Matterhorn configuration file."
-
-  , para $
-        "Theme customization files are INI-style files that can customize any " <>
-        "foreground color, background color, or style of any aspect of the " <>
-        "Matterhorn user interface. Here is an example:"
-
-  , para $
-        "```\n" <>
-        "[default]\n" <>
-        "default.fg = blue\n" <>
-        "default.bg = black\n" <>
-        "\n" <>
-        "[other]\n" <>
-        attrNameToConfig codeAttr <> ".fg = magenta\n" <>
-        attrNameToConfig codeAttr <> ".style = bold\n" <>
-        attrNameToConfig clientEmphAttr <> ".fg = cyan\n" <>
-        attrNameToConfig clientEmphAttr <> ".style = [bold, underline]\n" <>
-        attrNameToConfig listSelectedFocusedAttr <> ".fg = brightGreen\n" <>
-        "```"
-
-  , para $
-        "In the example above, the theme's default foreground and background colors " <>
-        "are both customized to *blue* and *black*, respectively. The *default* section " <>
-        "contains only customizations for the *default* attribute. All other customizations " <>
-        "go in the *other* section. We can also set the style for attributes; we can either " <>
-        "set just one style (as with the bold setting above) or multiple styles at once " <>
-        "(as in the bold/underline example).\n"
-
-  , para $
-        "Available colors are:\n" <>
-        " * black\n" <>
-        " * red\n" <>
-        " * green\n" <>
-        " * yellow\n" <>
-        " * blue\n" <>
-        " * magenta\n" <>
-        " * cyan\n" <>
-        " * white\n" <>
-        " * brightBlack\n" <>
-        " * brightRed\n" <>
-        " * brightGreen\n" <>
-        " * brightYellow\n" <>
-        " * brightBlue\n" <>
-        " * brightMagenta\n" <>
-        " * brightCyan\n" <>
-        " * brightWhite"
-
-  , para $
-        "Available styles are:\n" <>
-        " * standout\n" <>
-        " * underline\n" <>
-        " * italic\n" <>
-        " * reverseVideo\n" <>
-        " * blink\n" <>
-        " * dim\n" <>
-        " * bold\n"
-
-  , para $
-        "It is also possible to specify RGB values using HTML syntax: `#RRGGBB`. " <>
-        "Bear in mind that such colors are clamped to the nearest 256-color palette " <>
-        "entry, so it is not possible to get the exact color specified.\n\n" <>
-        "In addition, a special value of *default* is possible for either color " <>
-        "setting of an attribute. This value indicates that the attribute should " <>
-        "use the terminal emulator's default foreground or background color of " <>
-        "choice rather than a specific ANSI color."
-
-  , heading "Username Highlighting"
-  , para $
-        "Username colors are chosen by hashing each username and then using the hash " <>
-        "to choose a color from a list of predefined username colors. If you would like " <>
-        "to change the color in a given entry of this list, we provide the " <>
-        "\"username.N\" attributes, where N is the index in the username color list."
-
-  , heading "Theme Attributes"
-  , para $
-        "This section lists all possible theme attributes for use in customization " <>
-        "files along with a description of how each one is used in Matterhorn. Each " <>
-        "option listed can be set in the *other* section of the customization file. " <>
-        "Each provides three customization settings:"
-
-  , para $
-        " * *<option>.fg = <color>*\n" <>
-        " * *<option>.bg = <color>*\n" <>
-        " * *<option>.style = <style>* or *<option>.style = [<style>, ...]*\n"
-
-  , let names = sort $
-                 (\(n, msg) -> (n, attrNameToConfig n, msg)) <$>
-                 (M.toList $ themeDescriptions themeDocs)
-        mkEntry (n, opt, msg) =
-            padTop (Pad 1) $
-            vBox [ hBox [ withDefAttr clientEmphAttr $ txt opt
-                        , padLeft Max $ forceAttr n $ txt "(demo)"
-                        ]
-                 , txt msg
-                 ]
-    in vBox $ mkEntry <$> names
-  ]
-
-keybindSections :: [(Text, [KeyEventHandler])]
-keybindSections =
-    [ ("Global Keybindings", globalKeyHandlers)
-    , ("Help Page", helpKeyHandlers)
-    , ("Main Interface", mainKeyHandlers)
-    , ("Channel Select Mode", channelSelectKeyHandlers)
-    , ("URL Select Mode", urlSelectKeyHandlers)
-    , ("Message Select Mode", messageSelectKeyHandlers)
-    , ("Text Editing", editingKeyHandlers (csEditState.cedEditor))
-    , ("Flagged Messages", postListOverlayKeyHandlers)
-    , ("Theme List Window", themeListOverlayKeyHandlers)
-    , ("User Listings", userListOverlayKeyHandlers)
-    , ("Channel Search Window", channelListOverlayKeyHandlers)
-    , ("Reaction Emoji Search Window", reactionEmojiListOverlayKeyHandlers)
-    , ("Message Viewer: Common", tabbedWindowKeyHandlers (csViewedMessage.singular _Just._2))
-    , ("Message Viewer: Message tab", viewMessageKeyHandlers)
-    , ("Message Viewer: Reactions tab", viewMessageReactionsKeyHandlers)
-    , ("Attachment List", attachmentListKeyHandlers)
-    , ("Attachment File Browser", attachmentBrowseKeyHandlers)
-    ]
-
-helpBox :: Name -> Widget Name -> Widget Name
-helpBox n helpText =
-    withDefAttr helpAttr $
-    borderWithLabel (emph $ txt "Matterhorn Help") $
-    viewport HelpViewport Vertical $
-    cached n helpText
-
-kbColumnWidth :: Int
-kbColumnWidth = 14
-
-kbDescColumnWidth :: Int
-kbDescColumnWidth = 60
-
-mkKeybindingHelp :: KeyConfig -> (Text, [KeyEventHandler]) -> Widget Name
-mkKeybindingHelp kc (sectionName, kbs) =
-    (heading sectionName) <=>
-    (padTop (Pad 1) $ hCenter $ vBox $ snd <$> sortWith fst results)
-    where
-        results = mkKeybindHelp kc <$> kbs
-
-mkKeybindHelp :: KeyConfig -> KeyEventHandler -> (Text, Widget Name)
-mkKeybindHelp kc h =
-    let unbound = ["(unbound)"]
-        label = case kehEventTrigger h of
-            Static k -> ppBinding $ eventToBinding k
-            ByEvent ev ->
-                let bindings = case M.lookup ev kc of
-                        Nothing ->
-                            let bs = defaultBindings ev
-                            in if not $ null bs
-                               then ppBinding <$> defaultBindings ev
-                               else unbound
-                        Just Unbound -> unbound
-                        Just (BindingList bs) | not (null bs) -> ppBinding <$> bs
-                                              | otherwise -> unbound
-                in T.intercalate ", " bindings
-
-        rendering = (emph $ txt $ padTo kbColumnWidth $
-                      label) <+> txt " " <+>
-                    (hLimit kbDescColumnWidth $ padRight Max $ renderText $
-                     ehDescription $ kehHandler h)
-    in (label, rendering)
-
-mkKeybindEventSectionHelp :: KeyConfig
-                          -> ((Either Text Text, Text, [Text]) -> a)
-                          -> ([a] -> a)
-                          -> (Text -> a)
-                          -> (Text, [KeyEventHandler])
-                          -> a
-mkKeybindEventSectionHelp kc mkKeybindHelpFunc vertCat mkHeading (sectionName, kbs) =
-  vertCat $ (mkHeading sectionName) :
-            (mkKeybindHelpFunc <$> (mkKeybindEventHelp kc <$> kbs))
-
-keybindEventHelpWidget :: (Either Text Text, Text, [Text]) -> Widget Name
-keybindEventHelpWidget (evName, desc, evs) =
-    let evText = T.intercalate ", " evs
-        label = case evName of
-            Left s -> txt $ "; " <> s
-            Right s -> emph $ txt s
-    in padBottom (Pad 1) $
-       vBox [ txtWrap ("; " <> desc)
-            , label <+> txt (" = " <> evText)
-            ]
-
-keybindEventHelpMarkdown :: (Either Text Text, Text, [Text]) -> Text
-keybindEventHelpMarkdown (evName, desc, evs) =
-    let quote s = "`" <> s <> "`"
-        name = case evName of
-            Left s -> s
-            Right s -> quote s
-    in "| " <> (T.intercalate ", " $ quote <$> evs) <>
-       " | " <> name <>
-       " | " <> desc <>
-       " |"
-
-keybindEventHelpText :: Int -> Int -> (Either Text Text, Text, [Text]) -> Text
-keybindEventHelpText width eventNameWidth (evName, desc, evs) =
-    let name = case evName of
-            Left s -> s
-            Right s -> s
-    in padTo width (T.intercalate ", " evs) <> " " <>
-       padTo eventNameWidth name <> " " <>
-       desc
-
-mkKeybindEventHelp :: KeyConfig -> KeyEventHandler -> (Either Text Text, Text, [Text])
-mkKeybindEventHelp kc h =
-  let trig = kehEventTrigger h
-      unbound = ["(unbound)"]
-      (label, evText) = case trig of
-          Static key -> (Left "(non-customizable key)", [ppBinding $ eventToBinding key])
-          ByEvent ev -> case M.lookup ev kc of
-              Nothing ->
-                  let name = keyEventName ev
-                  in if not (null (defaultBindings ev))
-                     then (Right name, ppBinding <$> defaultBindings ev)
-                     else (Left name, unbound)
-              Just Unbound -> (Right $ keyEventName ev, unbound)
-              Just (BindingList bs) -> (Right $ keyEventName ev,
-                                        if not (null bs)
-                                        then ppBinding <$> bs
-                                        else unbound
-                                        )
-  in (label, ehDescription $ kehHandler h, evText)
-
-padTo :: Int -> Text -> Text
-padTo n s = s <> T.replicate (n - T.length s) " "
diff --git a/src/Draw/TabbedWindow.hs b/src/Draw/TabbedWindow.hs
deleted file mode 100644
--- a/src/Draw/TabbedWindow.hs
+++ /dev/null
@@ -1,77 +0,0 @@
-module Draw.TabbedWindow
-  ( drawTabbedWindow
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Widgets.Border
-import           Brick.Widgets.Center
-import           Data.List ( intersperse )
-import qualified Graphics.Vty as Vty
-
-import           Draw.Util ( renderKeybindingHelp )
-import           Types
-import           Themes
-import           Types.KeyEvents
-
--- | Render a tabbed window.
-drawTabbedWindow :: (Eq a, Show a)
-                 => TabbedWindow a
-                 -> ChatState
-                 -> Widget Name
-drawTabbedWindow w cs =
-    let cur = getCurrentTabbedWindowEntry w
-        tabBody = tweRender cur (twValue w) cs
-        title = forceAttr clientEmphAttr $ twtTitle (twTemplate w) (tweValue cur)
-    in centerLayer $
-       vLimit (twWindowHeight w) $
-       hLimit (twWindowWidth w) $
-       joinBorders $
-       borderWithLabel title $
-       (tabBar w <=> tabBody <=> hBorder <=> hCenter keybindingHelp)
-
--- | Keybinding help to show at the bottom of a tabbed window.
-keybindingHelp :: Widget Name
-keybindingHelp =
-    let pairs = [ ("Switch tabs", [SelectNextTabEvent, SelectPreviousTabEvent])
-                , ("Scroll", [ScrollUpEvent, ScrollDownEvent, ScrollLeftEvent, ScrollRightEvent, PageLeftEvent, PageRightEvent])
-                ]
-    in hBox $ intersperse (txt "  ") $ (uncurry renderKeybindingHelp) <$> pairs
-
--- | The scrollable tab bar to show at the top of a tabbed window.
-tabBar :: (Eq a, Show a)
-       => TabbedWindow a
-       -> Widget Name
-tabBar w =
-    let cur = getCurrentTabbedWindowEntry w
-        entries = twtEntries (twTemplate w)
-        renderEntry e =
-            let useAttr = if isCurrent
-                          then withDefAttr tabSelectedAttr
-                          else withDefAttr tabUnselectedAttr
-                isCurrent = tweValue e == tweValue cur
-                makeVisible = if isCurrent then visible else id
-                decorateTab v = Widget Fixed Fixed $ do
-                    result <- render v
-                    let width = Vty.imageWidth (result^.imageL)
-                    if isCurrent
-                       then
-                           render $ padBottom (Pad 1) $ raw $ result^.imageL
-                       else
-                           render $ vBox [raw $ result^.imageL, hLimit width hBorder]
-            in makeVisible $
-               decorateTab $
-               useAttr $
-               padLeftRight 2 $
-               txt $
-               tweTitle e (tweValue e) isCurrent
-        contents = Widget Fixed Fixed $ do
-            ctx <- getContext
-            let width = ctx^.availWidthL
-            render $ hBox $ (intersperse divider $ renderEntry <$> entries) <>
-                            [divider, padTop (Pad 1) $ hLimit width hBorder]
-        divider = vLimit 1 vBorder <=> joinableBorder (Edges True False False False)
-    in vLimit 2 $ viewport TabbedWindowTabBar Horizontal contents
diff --git a/src/Draw/ThemeListOverlay.hs b/src/Draw/ThemeListOverlay.hs
deleted file mode 100644
--- a/src/Draw/ThemeListOverlay.hs
+++ /dev/null
@@ -1,53 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RecordWildCards #-}
-
-module Draw.ThemeListOverlay
-  ( drawThemeListOverlay
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import qualified Brick.Widgets.List as L
-import           Brick.Widgets.Border ( hBorder )
-import           Brick.Widgets.Center ( hCenter )
-
-import           Draw.Main
-import           Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )
-import           Themes
-import           Types
-import           Types.KeyEvents ( ppBinding )
-import           Events.Keybindings
-
-
-drawThemeListOverlay :: ChatState -> [Widget Name]
-drawThemeListOverlay st =
-    let overlay = drawListOverlay (st^.csThemeListOverlay)
-                                  (const $ txt "Themes")
-                                  (const $ txt "No matching themes found.")
-                                  (const $ txt "Search built-in themes:")
-                                  renderInternalTheme
-                                  (Just footer)
-                                  OverlayUpperRight
-                                  50
-        footer = hBorder <=>
-                 (hCenter $ hBox [ enter
-                                 , txt $ ":choose theme  "
-                                 , close
-                                 , txt ":close"
-                                 ])
-        enter = emph $ txt $ ppBinding (getFirstDefaultBinding ActivateListItemEvent)
-        close = emph $ txt $ ppBinding (getFirstDefaultBinding CancelEvent)
-        emph = withDefAttr clientEmphAttr
-    in joinBorders overlay : drawMain True st
-
-renderInternalTheme :: Bool -> InternalTheme -> Widget Name
-renderInternalTheme foc it =
-    (if foc then forceAttr L.listSelectedFocusedAttr else id) $
-    (padRight Max $
-     withDefAttr clientEmphAttr $
-     txt $ internalThemeName it) <=>
-    (vLimit 2 $
-     (padLeft (Pad 2) $ txtWrap $ internalThemeDesc it) <=> fill ' ')
diff --git a/src/Draw/URLList.hs b/src/Draw/URLList.hs
deleted file mode 100644
--- a/src/Draw/URLList.hs
+++ /dev/null
@@ -1,57 +0,0 @@
-module Draw.URLList
-  ( renderUrlList
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Widgets.List ( renderList )
-import qualified Data.Foldable as F
-import           Lens.Micro.Platform ( to )
-
-import           Network.Mattermost.Types ( ServerTime(..) )
-
-import           Draw.Messages
-import           Draw.Util
-import           Draw.RichText
-import           Themes
-import           Types
-import           Types.RichText ( unURL )
-
-
-renderUrlList :: ChatState -> Widget Name
-renderUrlList st =
-    header <=> urlDisplay
-    where
-        header = withDefAttr channelHeaderAttr $ vLimit 1 $
-                 (txt $ "URLs: " <> (st^.csCurrentChannel.ccInfo.cdName)) <+>
-                 fill ' '
-
-        urlDisplay = if F.length urls == 0
-                     then str "No URLs found in this channel."
-                     else renderList renderItem True urls
-
-        urls = st^.csUrlList
-
-        me = myUsername st
-
-        renderItem sel link =
-          let time = link^.linkTime
-          in attr sel $ vLimit 2 $
-            (vLimit 1 $
-             hBox [ let u = maybe "<server>" id (link^.linkUser.to (nameForUserRef st))
-                    in colorUsername me u u
-                  , case link^.linkLabel of
-                      Nothing -> emptyWidget
-                      Just label -> txt ": " <+> hBox (F.toList $ renderElementSeq me label)
-                  , fill ' '
-                  , renderDate st $ withServerTime time
-                  , str " "
-                  , renderTime st $ withServerTime time
-                  ] ) <=>
-            (vLimit 1 (renderText $ unURL $ link^.linkURL))
-
-        attr True = forceAttr urlListSelectedAttr
-        attr False = id
diff --git a/src/Draw/UserListOverlay.hs b/src/Draw/UserListOverlay.hs
deleted file mode 100644
--- a/src/Draw/UserListOverlay.hs
+++ /dev/null
@@ -1,78 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RecordWildCards #-}
-
-module Draw.UserListOverlay
-  ( drawUserListOverlay
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import qualified Brick.Widgets.List as L
-import qualified Data.Text as T
-import qualified Graphics.Vty as V
-
-import           Draw.Main
-import           Draw.Util ( userSigilFromInfo )
-import           Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )
-import           Themes
-import           Types
-
-
-drawUserListOverlay :: ChatState -> [Widget Name]
-drawUserListOverlay st =
-    let overlay = drawListOverlay (st^.csUserListOverlay) userSearchScopeHeader
-                                  userSearchScopeNoResults userSearchScopePrompt
-                                  (renderUser (myUsername st))
-                                  Nothing
-                                  OverlayCenter
-                                  80
-    in joinBorders overlay : drawMain False st
-
-userSearchScopePrompt :: UserSearchScope -> Widget Name
-userSearchScopePrompt scope =
-    txt $ case scope of
-        ChannelMembers _ _    -> "Search channel members:"
-        ChannelNonMembers _ _ -> "Search users:"
-        AllUsers Nothing      -> "Search users:"
-        AllUsers (Just _)     -> "Search team members:"
-
-userSearchScopeNoResults :: UserSearchScope -> Widget Name
-userSearchScopeNoResults scope =
-    txt $ case scope of
-        ChannelMembers _ _    -> "No users in channel."
-        ChannelNonMembers _ _ -> "All users in your team are already in this channel."
-        AllUsers _            -> "No users found."
-
-userSearchScopeHeader :: UserSearchScope -> Widget Name
-userSearchScopeHeader scope =
-    txt $ case scope of
-        ChannelMembers {}    -> "Channel Members"
-        ChannelNonMembers {} -> "Invite Users to Channel"
-        AllUsers Nothing     -> "Users On This Server"
-        AllUsers (Just _)    -> "Users In My Team"
-
-renderUser :: Text -> Bool -> UserInfo -> Widget Name
-renderUser myUName foc ui =
-    (if foc then forceAttr L.listSelectedFocusedAttr else id) $
-    vLimit 2 $
-    padRight Max $
-    hBox $ (padRight (Pad 1) $ colorUsername myUName (ui^.uiName) (T.singleton $ userSigilFromInfo ui))
-           : (hLimit usernameWidth $ padRight Max $ colorUsername myUName (ui^.uiName) (ui^.uiName))
-           : extras
-    where
-        sanitize = T.strip . T.replace "\t" " "
-        usernameWidth = 20
-        extras = padRight (Pad 1) <$> catMaybes [mFullname, mNickname, mEmail]
-        mFullname = if (not (T.null (ui^.uiFirstName)) || not (T.null (ui^.uiLastName)))
-                    then Just $ txt $ (sanitize $ ui^.uiFirstName) <> " " <> (sanitize $ ui^.uiLastName)
-                    else Nothing
-        mNickname = case ui^.uiNickName of
-                      Just n | n /= (ui^.uiName) -> Just $ txt $ "(" <> n <> ")"
-                      _ -> Nothing
-        mEmail = if (T.null $ ui^.uiEmail)
-                 then Nothing
-                 else Just $ modifyDefAttr (`V.withURL` ("mailto:" <> ui^.uiEmail)) $
-                             withDefAttr urlAttr (txt ("<" <> ui^.uiEmail <> ">"))
diff --git a/src/Draw/Util.hs b/src/Draw/Util.hs
deleted file mode 100644
--- a/src/Draw/Util.hs
+++ /dev/null
@@ -1,100 +0,0 @@
-module Draw.Util
-  ( withBrackets
-  , renderTime
-  , renderDate
-  , renderKeybindingHelp
-  , insertDateMarkers
-  , getDateFormat
-  , mkChannelName
-  , userSigilFromInfo
-  , multilineHeightLimit
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Data.List ( intersperse )
-import qualified Data.Set as Set
-import qualified Data.Text as T
-import           Lens.Micro.Platform ( to )
-import           Network.Mattermost.Types
-
-import           Constants ( userSigil, normalChannelSigil )
-import           Themes
-import           TimeUtils
-import           Types
-import           Types.KeyEvents
-import           Events.Keybindings ( getFirstDefaultBinding )
-
-
-defaultTimeFormat :: Text
-defaultTimeFormat = "%R"
-
-defaultDateFormat :: Text
-defaultDateFormat = "%Y-%m-%d"
-
-multilineHeightLimit :: Int
-multilineHeightLimit = 5
-
-getTimeFormat :: ChatState -> Text
-getTimeFormat st =
-    maybe defaultTimeFormat id (st^.csResources.crConfiguration.to configTimeFormat)
-
-getDateFormat :: ChatState -> Text
-getDateFormat st =
-    maybe defaultDateFormat id (st^.csResources.crConfiguration.to configDateFormat)
-
-renderTime :: ChatState -> UTCTime -> Widget Name
-renderTime st = renderUTCTime (getTimeFormat st) (st^.timeZone)
-
-renderDate :: ChatState -> UTCTime -> Widget Name
-renderDate st = renderUTCTime (getDateFormat st) (st^.timeZone)
-
-renderUTCTime :: Text -> TimeZoneSeries -> UTCTime -> Widget a
-renderUTCTime fmt tz t =
-    if T.null fmt
-    then emptyWidget
-    else withDefAttr timeAttr (txt $ localTimeText fmt $ asLocalTime tz t)
-
-renderKeybindingHelp :: Text -> [KeyEvent] -> Widget Name
-renderKeybindingHelp label evs =
-  let ppEv ev = withDefAttr clientEmphAttr $ txt (ppBinding (getFirstDefaultBinding ev))
-  in hBox $ (intersperse (txt "/") $ ppEv <$> evs) <> [txt (":" <> label)]
-
--- | Generates a local matterhorn-only client message that creates a
--- date marker.  The server date is converted to a local time (via
--- timezone), and midnight of that timezone used to generate date
--- markers.  Note that the actual time of the server and this client
--- are still not synchronized, but no manipulations here actually use
--- the client time.
-insertDateMarkers :: Messages -> Text -> TimeZoneSeries -> Messages
-insertDateMarkers ms datefmt tz = foldr (addMessage . dateMsg) ms dateRange
-    where dateRange = foldr checkDateChange Set.empty ms
-          checkDateChange m = let msgDay = startOfDay (Just tz) (withServerTime (m^.mDate))
-                              in if m^.mDeleted then id else Set.insert msgDay
-          dateMsg d = let t = localTimeText datefmt $ asLocalTime tz d
-                      in newMessageOfType t (C DateTransition) (ServerTime d)
-
-
-withBrackets :: Widget a -> Widget a
-withBrackets w = hBox [str "[", w, str "]"]
-
-userSigilFromInfo :: UserInfo -> Char
-userSigilFromInfo u = case u^.uiStatus of
-    Offline      -> ' '
-    Online       -> '+'
-    Away         -> '-'
-    DoNotDisturb -> '×'
-    Other _      -> '?'
-
-mkChannelName :: ChannelInfo -> Text
-mkChannelName c = T.append sigil (c^.cdName)
-    where
-        sigil = case c^.cdType of
-            Private   -> mempty
-            Ordinary  -> normalChannelSigil
-            Group     -> mempty
-            Direct    -> userSigil
-            Unknown _ -> mempty
diff --git a/src/Emoji.hs b/src/Emoji.hs
deleted file mode 100644
--- a/src/Emoji.hs
+++ /dev/null
@@ -1,108 +0,0 @@
-{-# LANGUAGE ScopedTypeVariables #-}
-module Emoji
-  ( EmojiCollection
-  , loadEmoji
-  , emptyEmojiCollection
-  , getMatchingEmoji
-  , matchesEmoji
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Control.Exception as E
-import           Control.Monad.Except
-import qualified Data.Aeson as A
-import qualified Data.ByteString.Lazy as BSL
-import qualified Data.Foldable as F
-import qualified Data.Text as T
-import qualified Data.Sequence as Seq
-
-import           Network.Mattermost.Types ( Session )
-import qualified Network.Mattermost.Endpoints as MM
-
-
-newtype EmojiData = EmojiData (Seq.Seq T.Text)
-
--- | The collection of all emoji names we loaded from a JSON disk file.
--- You might rightly ask: why don't we use a Trie here, for efficient
--- lookups? The answer is that we need infix lookups; prefix matches are
--- not enough. In practice it seems not to matter that much; despite the
--- O(n) search we get good enough performance that we aren't worried
--- about this. If at some point this becomes an issue, other data
--- structures with good infix lookup performance should be identified
--- (full-text search, perhaps?).
-newtype EmojiCollection = EmojiCollection [T.Text]
-
-instance A.FromJSON EmojiData where
-    parseJSON = A.withArray "EmojiData" $ \v -> do
-        aliasVecs <- forM v $ \val ->
-            flip (A.withObject "EmojiData Entry") val $ \obj -> do
-                as <- obj A..: "aliases"
-                forM as $ A.withText "Alias list element" return
-
-        return $ EmojiData $ mconcat $ F.toList aliasVecs
-
-emptyEmojiCollection :: EmojiCollection
-emptyEmojiCollection = EmojiCollection mempty
-
--- | Load an EmojiCollection from a JSON disk file.
-loadEmoji :: FilePath -> IO (Either String EmojiCollection)
-loadEmoji path = runExceptT $ do
-    result <- lift $ E.try $ BSL.readFile path
-    case result of
-        Left (e::E.SomeException) -> throwError $ show e
-        Right bs -> do
-            EmojiData es <- ExceptT $ return $ A.eitherDecode bs
-            return $ EmojiCollection $ T.toLower <$> F.toList es
-
--- | Look up matching emoji in the collection using the provided search
--- string. This does a case-insensitive infix match. The search string
--- may be provided with or without leading and trailing colons.
-lookupEmoji :: EmojiCollection -> T.Text -> [T.Text]
-lookupEmoji (EmojiCollection es) search =
-    filter (matchesEmoji search) es
-
--- | Match a search string against an emoji.
-matchesEmoji :: T.Text
-             -- ^ The search string (will be converted to lowercase and
-             -- colons will be removed)
-             -> T.Text
-             -- ^ The emoji string (assumed to be lowercase and without
-             -- leading/trailing colons)
-             -> Bool
-matchesEmoji searchString e =
-    sanitizeEmojiSearch searchString `T.isInfixOf` e
-
-sanitizeEmojiSearch :: T.Text -> T.Text
-sanitizeEmojiSearch = stripColons . T.toLower . T.strip
-
--- | Perform an emoji search against both the local EmojiCollection as
--- well as the server's custom emoji. Return the results, sorted. If the
--- empty string is specified, all local and all custom emoji will be
--- included in the returned list.
-getMatchingEmoji :: Session -> EmojiCollection -> T.Text -> IO [T.Text]
-getMatchingEmoji session em rawSearchString = do
-    let localAlts = lookupEmoji em rawSearchString
-        sanitized = sanitizeEmojiSearch rawSearchString
-    customResult <- E.try $ case T.null sanitized of
-        True -> MM.mmGetListOfCustomEmoji Nothing Nothing session
-        False -> MM.mmSearchCustomEmoji sanitized session
-
-    let custom = case customResult of
-            Left (_::E.SomeException) -> []
-            Right result -> result
-
-    return $ sort $ (MM.emojiName <$> custom) <> localAlts
-
-stripColons :: T.Text -> T.Text
-stripColons t =
-    stripHeadColon $ stripTailColon t
-    where
-        stripHeadColon v = if ":" `T.isPrefixOf` v
-                           then T.tail v
-                           else v
-        stripTailColon v = if ":" `T.isSuffixOf` v
-                           then T.init v
-                           else v
diff --git a/src/Events.hs b/src/Events.hs
deleted file mode 100644
--- a/src/Events.hs
+++ /dev/null
@@ -1,383 +0,0 @@
-module Events
-  ( onEvent
-  , globalKeybindings
-  , globalKeyHandlers
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import qualified Data.Sequence as Seq
-import qualified Data.Set as Set
-import qualified Data.Text as T
-import qualified Graphics.Vty as Vty
-import           Lens.Micro.Platform ( (.=), preuse, _2, singular, _Just )
-
-import qualified Network.Mattermost.Endpoints as MM
-import           Network.Mattermost.Exceptions ( mattermostErrorMessage )
-import           Network.Mattermost.Lenses
-import           Network.Mattermost.Types
-import           Network.Mattermost.WebSocket
-
-import           Connection
-import           Constants ( userSigil, normalChannelSigil )
-import           HelpTopics
-import           State.Channels
-import           State.Common
-import           State.Flagging
-import           State.Help
-import           State.Messages
-import           State.Reactions
-import           State.Users
-import           Types
-import           Types.Common
-
-import           Events.ChannelSelect
-import           Events.DeleteChannelConfirm
-import           Events.Keybindings
-import           Events.LeaveChannelConfirm
-import           Events.Main
-import           Events.MessageSelect
-import           Events.ThemeListOverlay
-import           Events.PostListOverlay
-import           Events.ShowHelp
-import           Events.UrlSelect
-import           Events.UserListOverlay
-import           Events.ChannelListOverlay
-import           Events.ReactionEmojiListOverlay
-import           Events.TabbedWindow
-import           Events.ManageAttachments
-import           Events.EditNotifyPrefs
-
-
-onEvent :: ChatState -> BrickEvent Name MHEvent -> EventM Name (Next ChatState)
-onEvent st ev = runMHEvent st $ do
-    onBrickEvent ev
-    doPendingUserFetches
-    doPendingUserStatusFetches
-
-onBrickEvent :: BrickEvent Name MHEvent -> MH ()
-onBrickEvent (AppEvent e) =
-    onAppEvent e
-onBrickEvent (VtyEvent (Vty.EvKey (Vty.KChar 'l') [Vty.MCtrl])) = do
-    vty <- mh getVtyHandle
-    liftIO $ Vty.refresh vty
-onBrickEvent (VtyEvent e) =
-    onVtyEvent e
-onBrickEvent _ =
-    return ()
-
-onAppEvent :: MHEvent -> MH ()
-onAppEvent RefreshWebsocketEvent =
-    connectWebsockets
-onAppEvent WebsocketDisconnect = do
-    csConnectionStatus .= Disconnected
-    disconnectChannels
-onAppEvent WebsocketConnect = do
-    csConnectionStatus .= Connected
-    refreshChannelsAndUsers
-    refreshClientConfig
-    fetchVisibleIfNeeded
-onAppEvent (RateLimitExceeded winSz) =
-    mhError $ GenericError $ T.pack $
-        let s = if winSz == 1 then "" else "s"
-        in "The server's API request rate limit was exceeded; Matterhorn will " <>
-           "retry the failed request in " <> show winSz <> " second" <> s <>
-           ". Please contact your Mattermost administrator " <>
-           "about API rate limiting issues."
-onAppEvent RateLimitSettingsMissing =
-    mhError $ GenericError $
-        "A request was rate-limited but could not be retried due to rate " <>
-        "limit settings missing"
-onAppEvent RequestDropped =
-    mhError $ GenericError $
-        "An API request was retried and dropped due to a rate limit. Matterhorn " <>
-        "may now be inconsistent with the server. Please contact your " <>
-        "Mattermost administrator about API rate limiting issues."
-onAppEvent BGIdle =
-    csWorkerIsBusy .= Nothing
-onAppEvent (BGBusy n) =
-    csWorkerIsBusy .= Just n
-onAppEvent (WSEvent we) =
-    handleWSEvent we
-onAppEvent (WSActionResponse r) =
-    handleWSActionResponse r
-onAppEvent (RespEvent f) = f
-onAppEvent (WebsocketParseError e) = do
-    let msg = "A websocket message could not be parsed:\n  " <>
-              T.pack e <>
-              "\nPlease report this error at https://github.com/matterhorn-chat/matterhorn/issues"
-    mhError $ GenericError msg
-onAppEvent (IEvent e) = do
-    handleIEvent e
-
-handleIEvent :: InternalEvent -> MH ()
-handleIEvent (DisplayError e) =
-    postErrorMessage' $ formatError e
-handleIEvent (LoggingStarted path) =
-    postInfoMessage $ "Logging to " <> T.pack path
-handleIEvent (LogDestination dest) =
-    case dest of
-        Nothing ->
-            postInfoMessage "Logging is currently disabled. Enable it with /log-start."
-        Just path ->
-            postInfoMessage $ T.pack $ "Logging to " <> path
-handleIEvent (LogSnapshotSucceeded path) =
-    postInfoMessage $ "Log snapshot written to " <> T.pack path
-handleIEvent (LoggingStopped path) =
-    postInfoMessage $ "Stopped logging to " <> T.pack path
-handleIEvent (LogStartFailed path err) =
-    postErrorMessage' $ "Could not start logging to " <> T.pack path <>
-                        ", error: " <> T.pack err
-handleIEvent (LogSnapshotFailed path err) =
-    postErrorMessage' $ "Could not write log snapshot to " <> T.pack path <>
-                        ", error: " <> T.pack err
-
-formatError :: MHError -> T.Text
-formatError (GenericError msg) =
-    msg
-formatError (NoSuchChannel chan) =
-    T.pack $ "No such channel: " <> show chan
-formatError (NoSuchUser user) =
-    T.pack $ "No such user: " <> show user
-formatError (AmbiguousName name) =
-    (T.pack $ "The input " <> show name <> " matches both channels ") <>
-    "and users. Try using '" <> userSigil <> "' or '" <>
-    normalChannelSigil <> "' to disambiguate."
-formatError (ServerError e) =
-    mattermostErrorMessage e
-formatError (ClipboardError msg) =
-    msg
-formatError (ConfigOptionMissing opt) =
-    T.pack $ "Config option " <> show opt <> " missing"
-formatError (ProgramExecutionFailed progName logPath) =
-    T.pack $ "An error occurred when running " <> show progName <>
-             "; see " <> show logPath <> " for details."
-formatError (NoSuchScript name) =
-    "No script named " <> name <> " was found"
-formatError (NoSuchHelpTopic topic) =
-    let knownTopics = ("  - " <>) <$> helpTopicName <$> helpTopics
-    in "Unknown help topic: `" <> topic <> "`. " <>
-       (T.unlines $ "Available topics are:" : knownTopics)
-formatError (AsyncErrEvent e) =
-    "An unexpected error has occurred! The exception encountered was:\n  " <>
-    T.pack (show e) <>
-    "\nPlease report this error at https://github.com/matterhorn-chat/matterhorn/issues"
-
-onVtyEvent :: Vty.Event -> MH ()
-onVtyEvent e = do
-    case e of
-        (Vty.EvResize _ _) ->
-            -- On resize, invalidate the entire rendering cache since
-            -- many things depend on the window size.
-            --
-            -- Note: we fall through after this because it is sometimes
-            -- important for modes to have their own additional logic
-            -- to run when a resize occurs, so we don't want to stop
-            -- processing here.
-            mh invalidateCache
-        _ -> return ()
-
-    void $ handleKeyboardEvent globalKeybindings handleGlobalEvent e
-
-handleGlobalEvent :: Vty.Event -> MH ()
-handleGlobalEvent e = do
-    mode <- gets appMode
-    case mode of
-        Main                       -> onEventMain e
-        ShowHelp _ _               -> void $ onEventShowHelp e
-        ChannelSelect              -> void $ onEventChannelSelect e
-        UrlSelect                  -> void $ onEventUrlSelect e
-        LeaveChannelConfirm        -> onEventLeaveChannelConfirm e
-        MessageSelect              -> onEventMessageSelect e
-        MessageSelectDeleteConfirm -> onEventMessageSelectDeleteConfirm e
-        DeleteChannelConfirm       -> onEventDeleteChannelConfirm e
-        ThemeListOverlay           -> onEventThemeListOverlay e
-        PostListOverlay _          -> onEventPostListOverlay e
-        UserListOverlay            -> onEventUserListOverlay e
-        ChannelListOverlay         -> onEventChannelListOverlay e
-        ReactionEmojiListOverlay   -> onEventReactionEmojiListOverlay e
-        ViewMessage                -> void $ handleTabbedWindowEvent (csViewedMessage.singular _Just._2) e
-        ManageAttachments          -> onEventManageAttachments e
-        ManageAttachmentsBrowseFiles -> onEventManageAttachments e
-        EditNotifyPrefs            -> void $ onEventEditNotifyPrefs e
-
-globalKeybindings :: KeyConfig -> KeyHandlerMap
-globalKeybindings = mkKeybindings globalKeyHandlers
-
-globalKeyHandlers :: [KeyEventHandler]
-globalKeyHandlers =
-    [ mkKb ShowHelpEvent
-        "Show this help screen"
-        (showHelpScreen mainHelpTopic)
-    ]
-
-handleWSActionResponse :: WebsocketActionResponse -> MH ()
-handleWSActionResponse r =
-    case warStatus r of
-        WebsocketActionStatusOK -> return ()
-
-handleWSEvent :: WebsocketEvent -> MH ()
-handleWSEvent we = do
-    myId <- gets myUserId
-    myTId <- gets myTeamId
-    case weEvent we of
-        WMPosted
-            | Just p <- wepPost (weData we) ->
-                when (wepTeamId (weData we) == Just myTId ||
-                      wepTeamId (weData we) == Nothing) $ do
-                    let wasMentioned = maybe False (Set.member myId) $ wepMentions (weData we)
-                    addNewPostedMessage $ RecentPost p wasMentioned
-                    cId <- use csCurrentChannelId
-                    when (postChannelId p /= cId) $
-                        showChannelInSidebar (p^.postChannelIdL) False
-            | otherwise -> return ()
-
-        WMPostEdited
-            | Just p <- wepPost (weData we) -> do
-                editMessage p
-                cId <- use csCurrentChannelId
-                when (postChannelId p == cId) (updateViewed False)
-                when (postChannelId p /= cId) $
-                    showChannelInSidebar (p^.postChannelIdL) False
-            | otherwise -> return ()
-
-        WMPostDeleted
-            | Just p <- wepPost (weData we) -> do
-                deleteMessage p
-                cId <- use csCurrentChannelId
-                when (postChannelId p == cId) (updateViewed False)
-                when (postChannelId p /= cId) $
-                    showChannelInSidebar (p^.postChannelIdL) False
-            | otherwise -> return ()
-
-        WMStatusChange
-            | Just status <- wepStatus (weData we)
-            , Just uId <- wepUserId (weData we) ->
-                setUserStatus uId status
-            | otherwise -> return ()
-
-        WMUserAdded
-            | Just cId <- webChannelId (weBroadcast we) ->
-                when (wepUserId (weData we) == Just myId &&
-                      wepTeamId (weData we) == Just myTId) $
-                    handleChannelInvite cId
-            | otherwise -> return ()
-
-        WMNewUser
-            | Just uId <- wepUserId $ weData we ->
-                handleNewUsers (Seq.singleton uId) (return ())
-            | otherwise -> return ()
-
-        WMUserRemoved
-            | Just cId <- wepChannelId (weData we) ->
-                when (webUserId (weBroadcast we) == Just myId) $
-                    removeChannelFromState cId
-            | otherwise -> return ()
-
-        WMTyping
-            | Just uId <- wepUserId $ weData we
-            , Just cId <- webChannelId (weBroadcast we) -> handleTypingUser uId cId
-            | otherwise -> return ()
-
-        WMChannelDeleted
-            | Just cId <- wepChannelId (weData we) ->
-                when (webTeamId (weBroadcast we) == Just myTId) $
-                    removeChannelFromState cId
-            | otherwise -> return ()
-
-        WMDirectAdded
-            | Just cId <- webChannelId (weBroadcast we) -> handleChannelInvite cId
-            | otherwise -> return ()
-
-        -- An 'ephemeral message' is just Mattermost's version of our
-        -- 'client message'. This can be a little bit wacky, e.g.
-        -- if the user types '/shortcuts' in the browser, we'll get
-        -- an ephemeral message even in MatterHorn with the browser
-        -- shortcuts, but it's probably a good idea to handle these
-        -- messages anyway.
-        WMEphemeralMessage
-            | Just p <- wepPost $ weData we -> postInfoMessage (sanitizeUserText $ p^.postMessageL)
-            | otherwise -> return ()
-
-        WMPreferenceChanged
-            | Just prefs <- wepPreferences (weData we) ->
-                mapM_ applyPreferenceChange prefs
-            | otherwise -> return ()
-
-        WMPreferenceDeleted
-            | Just pref <- wepPreferences (weData we)
-            , Just fps <- mapM preferenceToFlaggedPost pref ->
-              forM_ fps $ \f ->
-                  updateMessageFlag (flaggedPostId f) False
-            | otherwise -> return ()
-
-        WMReactionAdded
-            | Just r <- wepReaction (weData we)
-            , Just cId <- webChannelId (weBroadcast we) -> addReactions cId [r]
-            | otherwise -> return ()
-
-        WMReactionRemoved
-            | Just r <- wepReaction (weData we)
-            , Just cId <- webChannelId (weBroadcast we) -> removeReaction r cId
-            | otherwise -> return ()
-
-        WMChannelViewed
-            | Just cId <- wepChannelId $ weData we -> refreshChannelById cId
-            | otherwise -> return ()
-
-        WMChannelUpdated
-            | Just cId <- webChannelId $ weBroadcast we -> do
-                mChan <- preuse (csChannel(cId))
-                when (isJust mChan) $ do
-                    refreshChannelById cId
-                    updateSidebar
-            | otherwise -> return ()
-
-        WMGroupAdded
-            | Just cId <- webChannelId (weBroadcast we) -> handleChannelInvite cId
-            | otherwise -> return ()
-
-        WMChannelMemberUpdated
-            | Just channelMember <- wepChannelMember $ weData we ->
-                  when (channelMemberUserId channelMember == myId) $
-                      updateChannelNotifyProps
-                      (channelMemberChannelId channelMember)
-                      (channelMemberNotifyProps channelMember)
-            | otherwise -> return ()
-
-        -- We are pretty sure we should do something about these:
-        WMAddedToTeam -> return ()
-
-        -- We aren't sure whether there is anything we should do about
-        -- these yet:
-        WMUpdateTeam -> return ()
-        WMTeamDeleted -> return ()
-        WMUserUpdated -> return ()
-        WMLeaveTeam -> return ()
-
-        -- We deliberately ignore these events:
-        WMChannelCreated -> return ()
-        WMEmojiAdded -> return ()
-        WMWebRTC -> return ()
-        WMHello -> return ()
-        WMAuthenticationChallenge -> return ()
-        WMUserRoleUpdated -> return ()
-        WMPluginStatusesChanged -> return ()
-        WMPluginEnabled -> return ()
-        WMPluginDisabled -> return ()
-        WMUnknownEvent {} -> return ()
-
--- | Refresh client-accessible server configuration information. This
--- is usually triggered when a reconnect event for the WebSocket to the
--- server occurs.
-refreshClientConfig :: MH ()
-refreshClientConfig = do
-    session <- getSession
-    doAsyncWith Preempt $ do
-        cfg <- MM.mmGetClientConfiguration (Just "old") session
-        return $ Just $ do
-            csClientConfig .= Just cfg
-            updateSidebar
diff --git a/src/Events/ChannelListOverlay.hs b/src/Events/ChannelListOverlay.hs
deleted file mode 100644
--- a/src/Events/ChannelListOverlay.hs
+++ /dev/null
@@ -1,35 +0,0 @@
-module Events.ChannelListOverlay
-  ( onEventChannelListOverlay
-  , channelListOverlayKeybindings
-  , channelListOverlayKeyHandlers
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Graphics.Vty as Vty
-
-import           Events.Keybindings
-import           State.ChannelListOverlay
-import           State.ListOverlay
-import           Types
-
-
-onEventChannelListOverlay :: Vty.Event -> MH ()
-onEventChannelListOverlay =
-    void . onEventListOverlay csChannelListOverlay channelListOverlayKeybindings
-
--- | The keybindings we want to use while viewing a channel list overlay
-channelListOverlayKeybindings :: KeyConfig -> KeyHandlerMap
-channelListOverlayKeybindings = mkKeybindings channelListOverlayKeyHandlers
-
-channelListOverlayKeyHandlers :: [KeyEventHandler]
-channelListOverlayKeyHandlers =
-    [ mkKb CancelEvent "Close the channel search list" (exitListOverlay csChannelListOverlay)
-    , mkKb SearchSelectUpEvent "Select the previous channel" channelListSelectUp
-    , mkKb SearchSelectDownEvent "Select the next channel" channelListSelectDown
-    , mkKb PageDownEvent "Page down in the channel list" channelListPageDown
-    , mkKb PageUpEvent "Page up in the channel list" channelListPageUp
-    , mkKb ActivateListItemEvent "Join the selected channel" (listOverlayActivateCurrent csChannelListOverlay)
-    ]
diff --git a/src/Events/ChannelSelect.hs b/src/Events/ChannelSelect.hs
deleted file mode 100644
--- a/src/Events/ChannelSelect.hs
+++ /dev/null
@@ -1,45 +0,0 @@
-module Events.ChannelSelect where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Widgets.Edit ( handleEditorEvent )
-import qualified Graphics.Vty as Vty
-
-import           Events.Keybindings
-import           State.Channels
-import           State.ChannelSelect
-import           State.Editing ( editingKeybindings )
-import           Types
-import qualified Zipper as Z
-
-
-onEventChannelSelect :: Vty.Event -> MH Bool
-onEventChannelSelect =
-  handleKeyboardEvent channelSelectKeybindings $ \e -> do
-      handled <- handleKeyboardEvent (editingKeybindings (csChannelSelectState.channelSelectInput)) (const $ return ()) e
-      when (not handled) $
-          mhHandleEventLensed (csChannelSelectState.channelSelectInput) handleEditorEvent e
-
-      updateChannelSelectMatches
-
-channelSelectKeybindings :: KeyConfig -> KeyHandlerMap
-channelSelectKeybindings = mkKeybindings channelSelectKeyHandlers
-
-channelSelectKeyHandlers :: [KeyEventHandler]
-channelSelectKeyHandlers =
-    [ staticKb "Switch to selected channel"
-         (Vty.EvKey Vty.KEnter []) $ do
-             matches <- use (csChannelSelectState.channelSelectMatches)
-             case Z.focus matches of
-                 Nothing -> return ()
-                 Just match -> do
-                     setMode Main
-                     setFocus $ channelListEntryChannelId $ matchEntry match
-
-    , mkKb CancelEvent "Cancel channel selection" $ setMode Main
-    , mkKb NextChannelEvent "Select next match" channelSelectNext
-    , mkKb PrevChannelEvent "Select previous match" channelSelectPrevious
-    , mkKb NextChannelEventAlternate "Select next match (alternate binding)" channelSelectNext
-    , mkKb PrevChannelEventAlternate "Select previous match (alternate binding)" channelSelectPrevious
-    ]
diff --git a/src/Events/DeleteChannelConfirm.hs b/src/Events/DeleteChannelConfirm.hs
deleted file mode 100644
--- a/src/Events/DeleteChannelConfirm.hs
+++ /dev/null
@@ -1,20 +0,0 @@
-module Events.DeleteChannelConfirm where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Graphics.Vty as Vty
-
-import           Types
-import           State.Channels
-
-
-onEventDeleteChannelConfirm :: Vty.Event -> MH ()
-onEventDeleteChannelConfirm (Vty.EvKey k []) = do
-    case k of
-        Vty.KChar c | c `elem` ("yY"::String) ->
-            deleteCurrentChannel
-        _ -> return ()
-    setMode Main
-onEventDeleteChannelConfirm _ = do
-    setMode Main
diff --git a/src/Events/EditNotifyPrefs.hs b/src/Events/EditNotifyPrefs.hs
deleted file mode 100644
--- a/src/Events/EditNotifyPrefs.hs
+++ /dev/null
@@ -1,48 +0,0 @@
-module Events.EditNotifyPrefs
-    ( onEventEditNotifyPrefs
-    , editNotifyPrefsKeybindings
-    , editNotifyPrefsKeyHandlers
-    )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Forms (handleFormEvent, formState)
-import           Data.Maybe (fromJust)
-import qualified Graphics.Vty as V
-import qualified Network.Mattermost.Endpoints as MM
-
-import           Lens.Micro.Platform (_Just, (.=), singular)
-
-import           Types
-import           Types.KeyEvents
-import           Events.Keybindings
-import           State.NotifyPrefs
-import           State.Async
-
-onEventEditNotifyPrefs :: V.Event -> MH Bool
-onEventEditNotifyPrefs =
-    let fallback e = do
-            form <- use (csNotifyPrefs.singular _Just)
-            updatedForm <- mh $ handleFormEvent (VtyEvent e) form
-            csNotifyPrefs .= Just updatedForm
-    in handleKeyboardEvent editNotifyPrefsKeybindings fallback
-
-editNotifyPrefsKeybindings :: KeyConfig -> KeyHandlerMap
-editNotifyPrefsKeybindings = mkKeybindings editNotifyPrefsKeyHandlers
-
-editNotifyPrefsKeyHandlers :: [KeyEventHandler]
-editNotifyPrefsKeyHandlers =
-    [ mkKb CancelEvent "Close channel notification preferences" exitEditNotifyPrefsMode
-    , mkKb FormSubmitEvent "Save channel notification preferences" $ do
-        st <- use id
-        let form = fromJust $ st^.csNotifyPrefs
-            cId = st^.csCurrentChannelId
-
-        doAsyncChannelMM Preempt cId
-          (\s _ -> MM.mmUpdateChannelNotifications cId (myUserId st) (formState form) s)
-          (\_ _ -> Nothing)
-        exitEditNotifyPrefsMode
-    ]
diff --git a/src/Events/Keybindings.hs b/src/Events/Keybindings.hs
deleted file mode 100644
--- a/src/Events/Keybindings.hs
+++ /dev/null
@@ -1,322 +0,0 @@
-module Events.Keybindings
-  ( defaultBindings
-  , lookupKeybinding
-  , getFirstDefaultBinding
-
-  , mkKb
-  , staticKb
-  , mkKeybindings
-
-  , handleKeyboardEvent
-
-  , EventHandler(..)
-  , KeyHandler(..)
-  , KeyEventHandler(..)
-  , KeyEventTrigger(..)
-  , KeyHandlerMap(..)
-
-  -- Re-exports:
-  , KeyEvent (..)
-  , KeyConfig
-  , allEvents
-  , parseBinding
-  , keyEventName
-  , keyEventFromName
-
-  , ensureKeybindingConsistency
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Data.Text as T
-import qualified Data.Map.Strict as M
-import qualified Graphics.Vty as Vty
-
-import           Types
-import           Types.KeyEvents
-
-
--- * Keybindings
-
--- | An 'EventHandler' represents a event handler.
-data EventHandler =
-    EH { ehDescription :: Text
-       -- ^ The description of this handler's behavior.
-       , ehAction :: MH ()
-       -- ^ The action to take when this handler is invoked.
-       }
-
--- | A trigger for a key event.
-data KeyEventTrigger =
-    Static Vty.Event
-    -- ^ The key event is always triggered by a specific key.
-    | ByEvent KeyEvent
-    -- ^ The key event is always triggered by an abstract key event (and
-    -- thus configured to be bound to specific key(s) in the KeyConfig).
-    deriving (Show, Eq, Ord)
-
--- | A handler for an abstract key event.
-data KeyEventHandler =
-    KEH { kehHandler :: EventHandler
-        -- ^ The handler to invoke.
-        , kehEventTrigger :: KeyEventTrigger
-        -- ^ The trigger for the handler.
-        }
-
--- | A handler for a specific key.
-data KeyHandler =
-    KH { khHandler :: KeyEventHandler
-       -- ^ The handler to invoke.
-       , khKey :: Vty.Event
-       -- ^ The specific key that should trigger this handler.
-       }
-
-newtype KeyHandlerMap = KeyHandlerMap (M.Map Vty.Event KeyHandler)
-
--- | Find a keybinding that matches a Vty Event
-lookupKeybinding :: Vty.Event -> KeyHandlerMap -> Maybe KeyHandler
-lookupKeybinding e (KeyHandlerMap m) = M.lookup e m
-
--- | Handle a keyboard event by looking it up in a map of bindings and
--- invoking the matching binding's handler. If no match can be found,
--- invoke a fallback action instead. Return True if the key event was
--- handled with a matching binding; False if not (the fallback case).
-handleKeyboardEvent :: (KeyConfig -> KeyHandlerMap)
-                    -- ^ The function to build a key handler map from a
-                    -- key configuration.
-                    -> (Vty.Event -> MH ())
-                    -- ^ The fallback action to invoke if no matching
-                    -- binding can be found.
-                    -> Vty.Event
-                    -- ^ The event to handle.
-                    -> MH Bool
-handleKeyboardEvent mkKeyMap fallthrough e = do
-  conf <- use (csResources.crConfiguration)
-  let keyMap = mkKeyMap (configUserKeys conf)
-  case lookupKeybinding e keyMap of
-    Just kh -> (ehAction $ kehHandler $ khHandler kh) >> return True
-    Nothing -> fallthrough e >> return False
-
-mkHandler :: Text -> MH () -> EventHandler
-mkHandler msg action =
-    EH { ehDescription = msg
-       , ehAction = action
-       }
-
-mkKb :: KeyEvent -> Text -> MH () -> KeyEventHandler
-mkKb ev msg action =
-    KEH { kehHandler = mkHandler msg action
-        , kehEventTrigger = ByEvent ev
-        }
-
-keyHandlerFromConfig :: KeyConfig -> KeyEventHandler -> [KeyHandler]
-keyHandlerFromConfig conf eh =
-    case kehEventTrigger eh of
-        Static key ->
-            [ KH eh key ]
-        ByEvent ev ->
-            [ KH eh (bindingToEvent b) | b <- allBindings ]
-            where allBindings | Just (BindingList ks) <- M.lookup ev conf = ks
-                              | Just Unbound <- M.lookup ev conf = []
-                              | otherwise = defaultBindings ev
-
-staticKb :: Text -> Vty.Event -> MH () -> KeyEventHandler
-staticKb msg event action =
-    KEH { kehHandler = mkHandler msg action
-        , kehEventTrigger = Static event
-        }
-
-mkKeybindings :: [KeyEventHandler] -> KeyConfig -> KeyHandlerMap
-mkKeybindings ks conf = KeyHandlerMap $ M.fromList pairs
-    where
-        pairs = mkPair <$> handlers
-        mkPair h = (khKey h, h)
-        handlers = concat $ keyHandlerFromConfig conf <$> ks
-
-bindingToEvent :: Binding -> Vty.Event
-bindingToEvent binding =
-  Vty.EvKey (kbKey binding) (kbMods binding)
-
-getFirstDefaultBinding :: KeyEvent -> Binding
-getFirstDefaultBinding ev =
-    case defaultBindings ev of
-        [] -> error $ "BUG: event " <> show ev <> " has no default bindings!"
-        (b:_) -> b
-
-defaultBindings :: KeyEvent -> [Binding]
-defaultBindings ev =
-  let meta binding = binding { kbMods = Vty.MMeta : kbMods binding }
-      ctrl binding = binding { kbMods = Vty.MCtrl : kbMods binding }
-      shift binding = binding { kbMods = Vty.MShift : kbMods binding }
-      kb k = Binding { kbMods = [], kbKey = k }
-      key c = Binding { kbMods = [], kbKey = Vty.KChar c }
-      fn n = Binding { kbMods = [], kbKey = Vty.KFun n }
-  in case ev of
-        VtyRefreshEvent               -> [ ctrl (key 'l') ]
-        ShowHelpEvent                 -> [ fn 1 ]
-        EnterSelectModeEvent          -> [ ctrl (key 's') ]
-        ReplyRecentEvent              -> [ ctrl (key 'r') ]
-        ToggleMessagePreviewEvent     -> [ meta (key 'p') ]
-        InvokeEditorEvent             -> [ meta (key 'k') ]
-        EnterFastSelectModeEvent      -> [ ctrl (key 'g') ]
-        QuitEvent                     -> [ ctrl (key 'q') ]
-        NextChannelEvent              -> [ ctrl (key 'n') ]
-        PrevChannelEvent              -> [ ctrl (key 'p') ]
-        NextChannelEventAlternate     -> [ kb Vty.KDown ]
-        PrevChannelEventAlternate     -> [ kb Vty.KUp ]
-        NextUnreadChannelEvent        -> [ meta (key 'a') ]
-        ShowAttachmentListEvent       -> [ ctrl (key 'x') ]
-        NextUnreadUserOrChannelEvent  -> [ ]
-        LastChannelEvent              -> [ meta (key 's') ]
-        EnterOpenURLModeEvent         -> [ ctrl (key 'o') ]
-        ClearUnreadEvent              -> [ meta (key 'l') ]
-        ToggleMultiLineEvent          -> [ meta (key 'e') ]
-        EnterFlaggedPostsEvent        -> [ meta (key '8') ]
-        ToggleChannelListVisibleEvent -> [ fn 2 ]
-        SelectNextTabEvent            -> [ key '\t' ]
-        SelectPreviousTabEvent        -> [ kb Vty.KBackTab ]
-        LoadMoreEvent                 -> [ ctrl (key 'b') ]
-        ScrollUpEvent                 -> [ kb Vty.KUp ]
-        ScrollDownEvent               -> [ kb Vty.KDown ]
-        ScrollLeftEvent               -> [ kb Vty.KLeft ]
-        ScrollRightEvent              -> [ kb Vty.KRight ]
-        PageUpEvent                   -> [ kb Vty.KPageUp ]
-        PageDownEvent                 -> [ kb Vty.KPageDown ]
-        PageLeftEvent                 -> [ shift (kb Vty.KLeft) ]
-        PageRightEvent                -> [ shift (kb Vty.KRight) ]
-        ScrollTopEvent                -> [ kb Vty.KHome ]
-        ScrollBottomEvent             -> [ kb Vty.KEnd ]
-        SelectOldestMessageEvent      -> [ shift (kb Vty.KHome) ]
-        SelectUpEvent                 -> [ key 'k', kb Vty.KUp ]
-        SelectDownEvent               -> [ key 'j', kb Vty.KDown ]
-        ActivateListItemEvent         -> [ kb Vty.KEnter ]
-        SearchSelectUpEvent           -> [ ctrl (key 'p'), kb Vty.KUp ]
-        SearchSelectDownEvent         -> [ ctrl (key 'n'), kb Vty.KDown ]
-        ViewMessageEvent              -> [ key 'v' ]
-        FillGapEvent                  -> [ kb Vty.KEnter ]
-        FlagMessageEvent              -> [ key 'f' ]
-        PinMessageEvent               -> [ key 'p' ]
-        YankMessageEvent              -> [ key 'y' ]
-        YankWholeMessageEvent         -> [ key 'Y' ]
-        DeleteMessageEvent            -> [ key 'd' ]
-        EditMessageEvent              -> [ key 'e' ]
-        ReplyMessageEvent             -> [ key 'r' ]
-        ReactToMessageEvent           -> [ key 'a' ]
-        OpenMessageURLEvent           -> [ key 'o' ]
-        AttachmentListAddEvent        -> [ key 'a' ]
-        AttachmentListDeleteEvent     -> [ key 'd' ]
-        AttachmentOpenEvent           -> [ key 'o' ]
-        CancelEvent                   -> [ kb Vty.KEsc, ctrl (key 'c') ]
-        EditorBolEvent                -> [ ctrl (key 'a') ]
-        EditorEolEvent                -> [ ctrl (key 'e') ]
-        EditorTransposeCharsEvent     -> [ ctrl (key 't') ]
-        EditorDeleteCharacter         -> [ ctrl (key 'd') ]
-        EditorKillToBolEvent          -> [ ctrl (key 'u') ]
-        EditorKillToEolEvent          -> [ ctrl (key 'k') ]
-        EditorPrevCharEvent           -> [ ctrl (key 'b') ]
-        EditorNextCharEvent           -> [ ctrl (key 'f') ]
-        EditorPrevWordEvent           -> [ meta (key 'b') ]
-        EditorNextWordEvent           -> [ meta (key 'f') ]
-        EditorDeleteNextWordEvent     -> [ meta (key 'd') ]
-        EditorDeletePrevWordEvent     -> [ ctrl (key 'w'), meta (kb Vty.KBS) ]
-        EditorHomeEvent               -> [ kb Vty.KHome ]
-        EditorEndEvent                -> [ kb Vty.KEnd ]
-        EditorYankEvent               -> [ ctrl (key 'y') ]
-        FormSubmitEvent               -> [ kb Vty.KEnter ]
-
--- | Given a configuration, we want to check it for internal consistency
--- (i.e. that a given keybinding isn't associated with multiple events
--- which both need to get generated in the same UI mode) and also for
--- basic usability (i.e. we shouldn't be binding events which can appear
--- in the main UI to a key like @e@, which would prevent us from being
--- able to type messages containing an @e@ in them!
-ensureKeybindingConsistency :: KeyConfig -> [(String, KeyConfig -> KeyHandlerMap)] -> Either String ()
-ensureKeybindingConsistency kc modeMaps = mapM_ checkGroup allBindings
-  where
-    -- This is a list of lists, grouped by keybinding, of all the
-    -- keybinding/event associations that are going to be used with the
-    -- provided key configuration.
-    allBindings = groupWith fst $ concat
-      [ case M.lookup ev kc of
-          Nothing -> zip (defaultBindings ev) (repeat (False, ev))
-          Just (BindingList bs) -> zip bs (repeat (True, ev))
-          Just Unbound -> []
-      | ev <- allEvents
-      ]
-
-    -- The invariant here is that each call to checkGroup is made with a
-    -- list where the first element of every list is the same binding.
-    -- The Bool value in these is True if the event was associated with
-    -- the binding by the user, and False if it's a Matterhorn default.
-    checkGroup :: [(Binding, (Bool, KeyEvent))] -> Either String ()
-    checkGroup [] = error "[ensureKeybindingConsistency: unreachable]"
-    checkGroup evs@((b, _):_) = do
-
-      -- We find out which modes an event can be used in and then invert
-      -- the map, so this is a map from mode to the events contains
-      -- which are bound by the binding included above.
-      let modesFor :: M.Map String [(Bool, KeyEvent)]
-          modesFor = M.unionsWith (++)
-            [ M.fromList [ (m, [(i, ev)]) | m <- modeMap ev ]
-            | (_, (i, ev)) <- evs
-            ]
-
-      -- If there is ever a situation where the same key is bound to two
-      -- events which can appear in the same mode, then we want to throw
-      -- an error, and also be informative about why. It is still okay
-      -- to bind the same key to two events, so long as those events
-      -- never appear in the same UI mode.
-      forM_ (M.assocs modesFor) $ \ (_, vs) ->
-         when (length vs > 1) $
-           Left $ concat $
-             "Multiple overlapping events bound to `" :
-             T.unpack (ppBinding b) :
-             "`:\n" :
-             concat [ [ " - `"
-                      , T.unpack (keyEventName ev)
-                      , "` "
-                      , if isFromUser
-                          then "(via user override)"
-                          else "(matterhorn default)"
-                      , "\n"
-                      ]
-                    | (isFromUser, ev) <- vs
-                    ]
-
-      -- Check for overlap a set of built-in keybindings when we're in a
-      -- mode where the user is typing. (These are perfectly fine when
-      -- we're in other modes.)
-      when ("main" `M.member` modesFor && isBareBinding b) $ do
-        Left $ concat $
-          [ "The keybinding `"
-          , T.unpack (ppBinding b)
-          , "` is bound to the "
-          , case map (ppEvent . snd . snd) evs of
-              [] -> error "unreachable"
-              [e] -> "event " ++ e
-              es  -> "events " ++ intercalate " and " es
-          , "\n"
-          , "This is probably not what you want, as it will interfere "
-          , "with the ability to write messages!\n"
-          ]
-
-    -- Events get some nice formatting!
-    ppEvent ev = "`" ++ T.unpack (keyEventName ev) ++ "`"
-
-    -- This check should get more nuanced, but as a first approximation,
-    -- we shouldn't bind to any bare character key in the main mode.
-    isBareBinding (Binding [] (Vty.KChar {})) = True
-    isBareBinding _ = False
-
-    -- We generate the which-events-are-valid-in-which-modes map from
-    -- our actual keybinding set, so this should never get out of date.
-    modeMap :: KeyEvent -> [String]
-    modeMap ev =
-      let matches kh = ByEvent ev == (kehEventTrigger $ khHandler kh)
-      in [ mode
-         | (mode, mkBindings) <- modeMaps
-         , let KeyHandlerMap m = mkBindings kc
-           in not $ null $ M.filter matches m
-         ]
diff --git a/src/Events/LeaveChannelConfirm.hs b/src/Events/LeaveChannelConfirm.hs
deleted file mode 100644
--- a/src/Events/LeaveChannelConfirm.hs
+++ /dev/null
@@ -1,19 +0,0 @@
-module Events.LeaveChannelConfirm where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Graphics.Vty as Vty
-
-import           State.Channels
-import           Types
-
-
-onEventLeaveChannelConfirm :: Vty.Event -> MH ()
-onEventLeaveChannelConfirm (Vty.EvKey k []) = do
-    case k of
-        Vty.KChar c | c `elem` ("yY"::String) ->
-            leaveCurrentChannel
-        _ -> return ()
-    setMode Main
-onEventLeaveChannelConfirm _ = return ()
diff --git a/src/Events/Main.hs b/src/Events/Main.hs
deleted file mode 100644
--- a/src/Events/Main.hs
+++ /dev/null
@@ -1,148 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-module Events.Main where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Widgets.Edit
-import qualified Graphics.Vty as Vty
-
-import           Command
-import           Events.Keybindings
-import           State.Attachments
-import           State.ChannelSelect
-import           State.Channels
-import           State.Editing
-import           State.MessageSelect
-import           State.PostListOverlay ( enterFlaggedPostListMode )
-import           State.UrlSelect
-import           Types
-
-
-onEventMain :: Vty.Event -> MH ()
-onEventMain =
-  void . handleKeyboardEvent mainKeybindings (\ ev -> do
-      resetReturnChannel
-      case ev of
-          (Vty.EvPaste bytes) -> handlePaste bytes
-          _ -> handleEditingInput ev
-  )
-
-mainKeybindings :: KeyConfig -> KeyHandlerMap
-mainKeybindings = mkKeybindings mainKeyHandlers
-
-mainKeyHandlers :: [KeyEventHandler]
-mainKeyHandlers =
-    [ mkKb EnterSelectModeEvent
-        "Select a message to edit/reply/delete"
-        beginMessageSelect
-
-    , mkKb ReplyRecentEvent
-        "Reply to the most recent message"
-        replyToLatestMessage
-
-    , mkKb ToggleMessagePreviewEvent "Toggle message preview"
-        toggleMessagePreview
-
-    , mkKb ToggleChannelListVisibleEvent "Toggle channel list visibility"
-        toggleChannelListVisibility
-
-    , mkKb
-        InvokeEditorEvent
-        "Invoke `$EDITOR` to edit the current message"
-        invokeExternalEditor
-
-    , mkKb
-        EnterFastSelectModeEvent
-        "Enter fast channel selection mode"
-         beginChannelSelect
-
-    , mkKb
-        QuitEvent
-        "Quit"
-        requestQuit
-
-    , staticKb "Tab-complete forward"
-         (Vty.EvKey (Vty.KChar '\t') []) $
-         tabComplete Forwards
-
-    , staticKb "Tab-complete backward"
-         (Vty.EvKey (Vty.KBackTab) []) $
-         tabComplete Backwards
-
-    , mkKb
-        ScrollUpEvent
-        "Scroll up in the channel input history" $ do
-             -- Up in multiline mode does the usual thing; otherwise we
-             -- navigate the history.
-             isMultiline <- use (csEditState.cedEphemeral.eesMultiline)
-             case isMultiline of
-                 True -> mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent
-                                           (Vty.EvKey Vty.KUp [])
-                 False -> channelHistoryBackward
-
-    , mkKb
-        ScrollDownEvent
-        "Scroll down in the channel input history" $ do
-             -- Down in multiline mode does the usual thing; otherwise
-             -- we navigate the history.
-             isMultiline <- use (csEditState.cedEphemeral.eesMultiline)
-             case isMultiline of
-                 True -> mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent
-                                           (Vty.EvKey Vty.KDown [])
-                 False -> channelHistoryForward
-
-    , mkKb PageUpEvent "Page up in the channel message list (enters message select mode)" $ do
-             beginMessageSelect
-
-    , mkKb SelectOldestMessageEvent "Scroll to top of channel message list" $ do
-             beginMessageSelect
-             messageSelectFirst
-
-    , mkKb NextChannelEvent "Change to the next channel in the channel list"
-         nextChannel
-
-    , mkKb PrevChannelEvent "Change to the previous channel in the channel list"
-         prevChannel
-
-    , mkKb NextUnreadChannelEvent "Change to the next channel with unread messages or return to the channel marked '~'"
-         nextUnreadChannel
-
-    , mkKb ShowAttachmentListEvent "Show the attachment list"
-         showAttachmentList
-
-    , mkKb NextUnreadUserOrChannelEvent
-         "Change to the next channel with unread messages preferring direct messages"
-         nextUnreadUserOrChannel
-
-    , mkKb LastChannelEvent "Change to the most recently-focused channel"
-         recentChannel
-
-    , staticKb "Send the current message"
-         (Vty.EvKey Vty.KEnter []) $ do
-             isMultiline <- use (csEditState.cedEphemeral.eesMultiline)
-             case isMultiline of
-                 -- Normally, this event causes the current message to
-                 -- be sent. But in multiline mode we want to insert a
-                 -- newline instead.
-                 True -> handleEditingInput (Vty.EvKey Vty.KEnter [])
-                 False -> do
-                     cId <- use csCurrentChannelId
-                     content <- getEditorContent
-                     handleInputSubmission cId content
-
-    , mkKb EnterOpenURLModeEvent "Select and open a URL posted to the current channel"
-           startUrlSelect
-
-    , mkKb ClearUnreadEvent "Clear the current channel's unread / edited indicators" $
-           clearChannelUnreadStatus =<< use csCurrentChannelId
-
-    , mkKb ToggleMultiLineEvent "Toggle multi-line message compose mode"
-           toggleMultilineEditing
-
-    , mkKb CancelEvent "Cancel autocomplete, message reply, or edit, in that order"
-         cancelAutocompleteOrReplyOrEdit
-
-    , mkKb EnterFlaggedPostsEvent "View currently flagged posts"
-         enterFlaggedPostListMode
-    ]
diff --git a/src/Events/ManageAttachments.hs b/src/Events/ManageAttachments.hs
deleted file mode 100644
--- a/src/Events/ManageAttachments.hs
+++ /dev/null
@@ -1,182 +0,0 @@
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-module Events.ManageAttachments
-  ( onEventManageAttachments
-  , attachmentListKeybindings
-  , attachmentBrowseKeyHandlers
-  , attachmentBrowseKeybindings
-  , attachmentListKeyHandlers
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Control.Exception as E
-import qualified Brick.Widgets.FileBrowser as FB
-import qualified Brick.Widgets.List as L
-import qualified Data.ByteString as BS
-import qualified Data.Text as T
-import qualified Data.Vector as Vector
-import qualified Graphics.Vty as V
-import           Lens.Micro.Platform ( (?=), (%=), to )
-
-import           Types
-import           Types.KeyEvents
-import           Events.Keybindings
-import           State.Attachments
-import           State.Common
-
-
-onEventManageAttachments :: V.Event -> MH ()
-onEventManageAttachments e = do
-    mode <- gets appMode
-    case mode of
-        ManageAttachments -> void $ onEventAttachmentList e
-        ManageAttachmentsBrowseFiles -> onEventBrowseFile e
-        _ -> error "BUG: onEventManageAttachments called in invalid mode"
-
-onEventAttachmentList :: V.Event -> MH Bool
-onEventAttachmentList =
-    handleKeyboardEvent attachmentListKeybindings $
-        mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent
-
-attachmentListKeybindings :: KeyConfig -> KeyHandlerMap
-attachmentListKeybindings = mkKeybindings attachmentListKeyHandlers
-
-attachmentListKeyHandlers :: [KeyEventHandler]
-attachmentListKeyHandlers =
-    [ mkKb CancelEvent "Close attachment list"
-          (setMode Main)
-    , mkKb SelectUpEvent "Move cursor up" $
-          mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent (V.EvKey V.KUp [])
-    , mkKb SelectDownEvent "Move cursor down" $
-          mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent (V.EvKey V.KDown [])
-    , mkKb AttachmentListAddEvent "Add a new attachment to the attachment list"
-          showAttachmentFileBrowser
-    , mkKb AttachmentOpenEvent "Open the selected attachment using the URL open command"
-          openSelectedAttachment
-    , mkKb AttachmentListDeleteEvent "Delete the selected attachment from the attachment list"
-          deleteSelectedAttachment
-    ]
-
-attachmentBrowseKeybindings :: KeyConfig -> KeyHandlerMap
-attachmentBrowseKeybindings = mkKeybindings attachmentBrowseKeyHandlers
-
-attachmentBrowseKeyHandlers :: [KeyEventHandler]
-attachmentBrowseKeyHandlers =
-    [ mkKb CancelEvent "Cancel attachment file browse"
-      cancelAttachmentBrowse
-    , mkKb AttachmentOpenEvent "Open the selected file using the URL open command"
-          openSelectedBrowserEntry
-    ]
-
-withFileBrowser :: ((FB.FileBrowser Name) -> MH ()) -> MH ()
-withFileBrowser f = do
-    use (csEditState.cedFileBrowser) >>= \case
-        Nothing -> do
-            -- The widget has not been created yet.  This should
-            -- normally not occur, because the ManageAttachments
-            -- events should not fire when there is no FileBrowser
-            -- Widget active to cause Brick to generate these events.
-            -- This could therefore be implemented as an `error "BUG:
-            -- ..."` handler, but the more benign approach is to
-            -- simply create an available FileBrowser at this stage.
-            new_b <- liftIO $ FB.newFileBrowser FB.selectNonDirectories AttachmentFileBrowser Nothing
-            csEditState.cedFileBrowser ?= new_b
-            f new_b
-        Just b -> f b
-
-openSelectedAttachment :: MH ()
-openSelectedAttachment = do
-    cur <- use (csEditState.cedAttachmentList.to L.listSelectedElement)
-    case cur of
-        Nothing -> return ()
-        Just (_, entry) -> void $ openURL (OpenLocalFile $ FB.fileInfoFilePath $
-                                           attachmentDataFileInfo entry)
-
-openSelectedBrowserEntry :: MH ()
-openSelectedBrowserEntry = withFileBrowser $ \b ->
-    case FB.fileBrowserCursor b of
-        Nothing -> return ()
-        Just entry -> void $ openURL (OpenLocalFile $ FB.fileInfoFilePath entry)
-
-onEventBrowseFile :: V.Event -> MH ()
-onEventBrowseFile e = do
-    withFileBrowser $ \b -> do
-        case FB.fileBrowserIsSearching b of
-            False ->
-                void $ handleKeyboardEvent attachmentBrowseKeybindings handleFileBrowserEvent e
-            True ->
-                handleFileBrowserEvent e
-
-    -- n.b. the FileBrowser may have been updated above, so re-acquire it
-    withFileBrowser $ \b -> do
-        case FB.fileBrowserException b of
-            Nothing -> return ()
-            Just ex -> do
-                mhLog LogError $ T.pack $ "FileBrowser exception: " <> show ex
-
-cancelAttachmentBrowse :: MH ()
-cancelAttachmentBrowse = do
-    es <- use (csEditState.cedAttachmentList.L.listElementsL)
-    case length es of
-        0 -> setMode Main
-        _ -> setMode ManageAttachments
-
-handleFileBrowserEvent :: V.Event -> MH ()
-handleFileBrowserEvent e = do
-  let fbHandle ev = sequence . (fmap (FB.handleFileBrowserEvent ev))
-  mhHandleEventLensed (csEditState.cedFileBrowser) fbHandle e
-
-  withFileBrowser $ \b -> do
-    -- TODO: Check file browser exception state
-    let entries = FB.fileBrowserSelection b
-    forM_ entries $ \entry -> do
-        -- Is the entry already present? If so, ignore the selection.
-        es <- use (csEditState.cedAttachmentList.L.listElementsL)
-        let matches = (== (FB.fileInfoFilePath entry)) .
-                      FB.fileInfoFilePath .
-                      attachmentDataFileInfo
-        case Vector.find matches es of
-            Just _ -> return ()
-            Nothing -> do
-                let path = FB.fileInfoFilePath entry
-                readResult <- liftIO $ E.try $ BS.readFile path
-                case readResult of
-                    Left (_::E.SomeException) ->
-                        -- TODO: report the error
-                        return ()
-                    Right bytes -> do
-                        let a = AttachmentData { attachmentDataFileInfo = entry
-                                               , attachmentDataBytes = bytes
-                                               }
-                        oldIdx <- use (csEditState.cedAttachmentList.L.listSelectedL)
-                        let newIdx = if Vector.null es
-                                     then Just 0
-                                     else oldIdx
-                        csEditState.cedAttachmentList %= L.listReplace (Vector.snoc es a) newIdx
-
-    when (not $ null entries) $ setMode Main
-
-deleteSelectedAttachment :: MH ()
-deleteSelectedAttachment = do
-    es <- use (csEditState.cedAttachmentList.L.listElementsL)
-    mSel <- use (csEditState.cedAttachmentList.to L.listSelectedElement)
-    case mSel of
-        Nothing ->
-            return ()
-        Just (pos, _) -> do
-            oldIdx <- use (csEditState.cedAttachmentList.L.listSelectedL)
-            let idx = if Vector.length es == 1
-                      then Nothing
-                      else case oldIdx of
-                          Nothing -> Just 0
-                          Just old -> if pos >= old
-                                      then Just $ pos - 1
-                                      else Just pos
-            csEditState.cedAttachmentList %= L.listReplace (deleteAt pos es) idx
-
-deleteAt :: Int -> Vector.Vector a -> Vector.Vector a
-deleteAt p as | p < 0 || p >= length as = as
-              | otherwise = Vector.take p as <> Vector.drop (p + 1) as
diff --git a/src/Events/MessageSelect.hs b/src/Events/MessageSelect.hs
deleted file mode 100644
--- a/src/Events/MessageSelect.hs
+++ /dev/null
@@ -1,85 +0,0 @@
-module Events.MessageSelect where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Data.Text as T
-import qualified Graphics.Vty as Vty
-
-import           Events.Keybindings
-import           State.MessageSelect
-import           State.ReactionEmojiListOverlay
-import           Types
-
-
-messagesPerPageOperation :: Int
-messagesPerPageOperation = 10
-
-onEventMessageSelect :: Vty.Event -> MH ()
-onEventMessageSelect =
-  void . handleKeyboardEvent messageSelectKeybindings (const $ return ())
-
-onEventMessageSelectDeleteConfirm :: Vty.Event -> MH ()
-onEventMessageSelectDeleteConfirm (Vty.EvKey (Vty.KChar 'y') []) = do
-    deleteSelectedMessage
-    setMode Main
-onEventMessageSelectDeleteConfirm _ =
-    setMode Main
-
-messageSelectKeybindings :: KeyConfig -> KeyHandlerMap
-messageSelectKeybindings = mkKeybindings messageSelectKeyHandlers
-
-messageSelectKeyHandlers :: [KeyEventHandler]
-messageSelectKeyHandlers =
-    [ mkKb CancelEvent "Cancel message selection" $
-        setMode Main
-
-    , mkKb SelectUpEvent "Select the previous message" messageSelectUp
-    , mkKb SelectDownEvent "Select the next message" messageSelectDown
-    , mkKb ScrollTopEvent "Scroll to top and select the oldest message"
-        messageSelectFirst
-    , mkKb ScrollBottomEvent "Scroll to bottom and select the latest message"
-        messageSelectLast
-    , mkKb
-        PageUpEvent
-        (T.pack $ "Move the cursor up by " <> show messagesPerPageOperation <> " messages")
-        (messageSelectUpBy messagesPerPageOperation)
-    , mkKb
-        PageDownEvent
-        (T.pack $ "Move the cursor down by " <> show messagesPerPageOperation <> " messages")
-        (messageSelectDownBy messagesPerPageOperation)
-
-    , mkKb OpenMessageURLEvent "Open all URLs in the selected message"
-        openSelectedMessageURLs
-
-    , mkKb ReplyMessageEvent "Begin composing a reply to the selected message"
-         beginReplyCompose
-
-    , mkKb EditMessageEvent "Begin editing the selected message"
-         beginEditMessage
-
-    , mkKb DeleteMessageEvent "Delete the selected message (with confirmation)"
-         beginConfirmDeleteSelectedMessage
-
-    , mkKb YankMessageEvent "Copy a verbatim section or message to the clipboard"
-         yankSelectedMessageVerbatim
-
-    , mkKb YankWholeMessageEvent "Copy an entire message to the clipboard"
-         yankSelectedMessage
-
-    , mkKb PinMessageEvent "Toggle whether the selected message is pinned"
-         pinSelectedMessage
-
-    , mkKb FlagMessageEvent "Flag the selected message"
-         flagSelectedMessage
-
-    , mkKb ViewMessageEvent "View the selected message"
-         viewSelectedMessage
-
-    , mkKb FillGapEvent "Fetch messages for the selected gap"
-         fillSelectedGap
-
-    , mkKb ReactToMessageEvent "Post a reaction to the selected message"
-         enterReactionEmojiListOverlayMode
-
-    ]
diff --git a/src/Events/PostListOverlay.hs b/src/Events/PostListOverlay.hs
deleted file mode 100644
--- a/src/Events/PostListOverlay.hs
+++ /dev/null
@@ -1,28 +0,0 @@
-module Events.PostListOverlay where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Graphics.Vty as Vty
-
-import           Types
-import           Events.Keybindings
-import           State.PostListOverlay
-
-
-onEventPostListOverlay :: Vty.Event -> MH ()
-onEventPostListOverlay =
-  void . handleKeyboardEvent postListOverlayKeybindings (const $ return ())
-
--- | The keybindings we want to use while viewing a post list overlay
-postListOverlayKeybindings :: KeyConfig -> KeyHandlerMap
-postListOverlayKeybindings = mkKeybindings postListOverlayKeyHandlers
-
-postListOverlayKeyHandlers :: [KeyEventHandler]
-postListOverlayKeyHandlers =
-  [ mkKb CancelEvent "Exit post browsing" exitPostListMode
-  , mkKb SelectUpEvent "Select the previous message" postListSelectUp
-  , mkKb SelectDownEvent "Select the next message" postListSelectDown
-  , mkKb FlagMessageEvent "Toggle the selected message flag" postListUnflagSelected
-  , mkKb ActivateListItemEvent "Jump to and select current message" postListJumpToCurrent
-  ]
diff --git a/src/Events/ReactionEmojiListOverlay.hs b/src/Events/ReactionEmojiListOverlay.hs
deleted file mode 100644
--- a/src/Events/ReactionEmojiListOverlay.hs
+++ /dev/null
@@ -1,35 +0,0 @@
-module Events.ReactionEmojiListOverlay
-  ( onEventReactionEmojiListOverlay
-  , reactionEmojiListOverlayKeybindings
-  , reactionEmojiListOverlayKeyHandlers
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Graphics.Vty as Vty
-
-import           Events.Keybindings
-import           State.ReactionEmojiListOverlay
-import           State.ListOverlay
-import           Types
-
-
-onEventReactionEmojiListOverlay :: Vty.Event -> MH ()
-onEventReactionEmojiListOverlay =
-    void . onEventListOverlay csReactionEmojiListOverlay reactionEmojiListOverlayKeybindings
-
--- | The keybindings we want to use while viewing an emoji list overlay
-reactionEmojiListOverlayKeybindings :: KeyConfig -> KeyHandlerMap
-reactionEmojiListOverlayKeybindings = mkKeybindings reactionEmojiListOverlayKeyHandlers
-
-reactionEmojiListOverlayKeyHandlers :: [KeyEventHandler]
-reactionEmojiListOverlayKeyHandlers =
-    [ mkKb CancelEvent "Close the emoji search window" (exitListOverlay csReactionEmojiListOverlay)
-    , mkKb SearchSelectUpEvent "Select the previous emoji" reactionEmojiListSelectUp
-    , mkKb SearchSelectDownEvent "Select the next emoji" reactionEmojiListSelectDown
-    , mkKb PageDownEvent "Page down in the emoji list" reactionEmojiListPageDown
-    , mkKb PageUpEvent "Page up in the emoji list" reactionEmojiListPageUp
-    , mkKb ActivateListItemEvent "Post the selected emoji reaction" (listOverlayActivateCurrent csReactionEmojiListOverlay)
-    ]
diff --git a/src/Events/ShowHelp.hs b/src/Events/ShowHelp.hs
deleted file mode 100644
--- a/src/Events/ShowHelp.hs
+++ /dev/null
@@ -1,44 +0,0 @@
-module Events.ShowHelp where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import qualified Graphics.Vty as Vty
-
-import           Constants
-import           Events.Keybindings
-import           Types
-
-
-onEventShowHelp :: Vty.Event -> MH Bool
-onEventShowHelp =
-  handleKeyboardEvent helpKeybindings $ \ e -> case e of
-    Vty.EvKey _ _ -> popMode
-    _ -> return ()
-
-helpKeybindings :: KeyConfig -> KeyHandlerMap
-helpKeybindings = mkKeybindings helpKeyHandlers
-
-helpKeyHandlers :: [KeyEventHandler]
-helpKeyHandlers =
-    [ mkKb ScrollUpEvent "Scroll up" $
-        mh $ vScrollBy (viewportScroll HelpViewport) (-1)
-    , mkKb ScrollDownEvent "Scroll down" $
-        mh $ vScrollBy (viewportScroll HelpViewport) 1
-    , mkKb PageUpEvent "Page up" $
-        mh $ vScrollBy (viewportScroll HelpViewport) (-1 * pageAmount)
-    , mkKb PageDownEvent "Page down" $
-        mh $ vScrollBy (viewportScroll HelpViewport) (1 * pageAmount)
-    , mkKb CancelEvent "Return to the previous interface" $
-        popMode
-    , mkKb ScrollBottomEvent "Scroll to the end of the help" $
-        mh $ vScrollToEnd (viewportScroll HelpViewport)
-    , mkKb ScrollTopEvent "Scroll to the beginning of the help" $
-        mh $ vScrollToBeginning (viewportScroll HelpViewport)
-    ]
-
-popMode :: MH ()
-popMode = do
-    ShowHelp _ prevMode <- gets appMode
-    setMode prevMode
diff --git a/src/Events/TabbedWindow.hs b/src/Events/TabbedWindow.hs
deleted file mode 100644
--- a/src/Events/TabbedWindow.hs
+++ /dev/null
@@ -1,56 +0,0 @@
-{-# LANGUAGE RankNTypes #-}
-module Events.TabbedWindow
-  ( handleTabbedWindowEvent
-  , tabbedWindowKeybindings
-  , tabbedWindowKeyHandlers
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Graphics.Vty as Vty
-import           Lens.Micro.Platform ( Lens', (.=) )
-
-import           Types
-import           Types.KeyEvents
-import           Events.Keybindings
-
-handleTabbedWindowEvent :: (Show a, Eq a)
-                        => Lens' ChatState (TabbedWindow a)
-                        -> Vty.Event
-                        -> MH Bool
-handleTabbedWindowEvent target e = do
-    w <- use target
-    handleKeyboardEvent (tabbedWindowKeybindings target) (forwardEvent w) e
-
-forwardEvent :: (Show a, Eq a)
-             => TabbedWindow a
-             -> Vty.Event
-             -> MH ()
-forwardEvent w e = do
-    let cur = getCurrentTabbedWindowEntry w
-    tweHandleEvent cur (twValue w) e
-
-tabbedWindowKeybindings :: (Show a, Eq a)
-                        => Lens' ChatState (TabbedWindow a)
-                        -> KeyConfig
-                        -> KeyHandlerMap
-tabbedWindowKeybindings target = mkKeybindings $ tabbedWindowKeyHandlers target
-
-tabbedWindowKeyHandlers :: (Show a, Eq a)
-                        => Lens' ChatState (TabbedWindow a)
-                        -> [KeyEventHandler]
-tabbedWindowKeyHandlers target =
-    [ mkKb CancelEvent "Close window" $ do
-        w <- use target
-        setMode (twReturnMode w)
-
-    , mkKb SelectNextTabEvent "Select next tab" $ do
-        w' <- tabbedWindowNextTab =<< use target
-        target .= w'
-
-    , mkKb SelectPreviousTabEvent "Select previous tab" $ do
-        w' <- tabbedWindowPreviousTab =<< use target
-        target .= w'
-    ]
diff --git a/src/Events/ThemeListOverlay.hs b/src/Events/ThemeListOverlay.hs
deleted file mode 100644
--- a/src/Events/ThemeListOverlay.hs
+++ /dev/null
@@ -1,30 +0,0 @@
-module Events.ThemeListOverlay where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Graphics.Vty as Vty
-
-import           Events.Keybindings
-import           State.ThemeListOverlay
-import           State.ListOverlay
-import           Types
-
-
-onEventThemeListOverlay :: Vty.Event -> MH ()
-onEventThemeListOverlay =
-    void . onEventListOverlay csThemeListOverlay themeListOverlayKeybindings
-
--- | The keybindings we want to use while viewing a user list overlay
-themeListOverlayKeybindings :: KeyConfig -> KeyHandlerMap
-themeListOverlayKeybindings = mkKeybindings themeListOverlayKeyHandlers
-
-themeListOverlayKeyHandlers :: [KeyEventHandler]
-themeListOverlayKeyHandlers =
-    [ mkKb CancelEvent "Close the theme list" (exitListOverlay csThemeListOverlay)
-    , mkKb SearchSelectUpEvent "Select the previous theme" themeListSelectUp
-    , mkKb SearchSelectDownEvent "Select the next theme" themeListSelectDown
-    , mkKb PageDownEvent "Page down in the theme list" themeListPageDown
-    , mkKb PageUpEvent "Page up in the theme list" themeListPageUp
-    , mkKb ActivateListItemEvent "Switch to the selected color theme" (listOverlayActivateCurrent csThemeListOverlay)
-    ]
diff --git a/src/Events/UrlSelect.hs b/src/Events/UrlSelect.hs
deleted file mode 100644
--- a/src/Events/UrlSelect.hs
+++ /dev/null
@@ -1,40 +0,0 @@
-module Events.UrlSelect where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Widgets.List
-import qualified Graphics.Vty as Vty
-
-import           Events.Keybindings
-import           State.UrlSelect
-import           Types
-
-
-onEventUrlSelect :: Vty.Event -> MH Bool
-onEventUrlSelect =
-  handleKeyboardEvent urlSelectKeybindings $ \ ev ->
-    mhHandleEventLensed csUrlList handleListEvent ev
-
-urlSelectKeybindings :: KeyConfig -> KeyHandlerMap
-urlSelectKeybindings = mkKeybindings urlSelectKeyHandlers
-
-urlSelectKeyHandlers :: [KeyEventHandler]
-urlSelectKeyHandlers =
-    [ staticKb "Open the selected URL, if any"
-         (Vty.EvKey Vty.KEnter []) $ do
-             openSelectedURL
-             setMode Main
-
-    , mkKb CancelEvent "Cancel URL selection" stopUrlSelect
-
-    , mkKb SelectUpEvent "Move cursor up" $
-        mhHandleEventLensed csUrlList handleListEvent (Vty.EvKey Vty.KUp [])
-
-    , mkKb SelectDownEvent "Move cursor down" $
-        mhHandleEventLensed csUrlList handleListEvent (Vty.EvKey Vty.KDown [])
-
-    , staticKb "Cancel URL selection"
-         (Vty.EvKey (Vty.KChar 'q') []) $ stopUrlSelect
-
-    ]
diff --git a/src/Events/UserListOverlay.hs b/src/Events/UserListOverlay.hs
deleted file mode 100644
--- a/src/Events/UserListOverlay.hs
+++ /dev/null
@@ -1,30 +0,0 @@
-module Events.UserListOverlay where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Graphics.Vty as Vty
-
-import           Events.Keybindings
-import           State.UserListOverlay
-import           State.ListOverlay
-import           Types
-
-
-onEventUserListOverlay :: Vty.Event -> MH ()
-onEventUserListOverlay =
-    void . onEventListOverlay csUserListOverlay userListOverlayKeybindings
-
--- | The keybindings we want to use while viewing a user list overlay
-userListOverlayKeybindings :: KeyConfig -> KeyHandlerMap
-userListOverlayKeybindings = mkKeybindings userListOverlayKeyHandlers
-
-userListOverlayKeyHandlers :: [KeyEventHandler]
-userListOverlayKeyHandlers =
-    [ mkKb CancelEvent "Close the user search list" (exitListOverlay csUserListOverlay)
-    , mkKb SearchSelectUpEvent "Select the previous user" userListSelectUp
-    , mkKb SearchSelectDownEvent "Select the next user" userListSelectDown
-    , mkKb PageDownEvent "Page down in the user list" userListPageDown
-    , mkKb PageUpEvent "Page up in the user list" userListPageUp
-    , mkKb ActivateListItemEvent "Interact with the selected user" (listOverlayActivateCurrent csUserListOverlay)
-    ]
diff --git a/src/FilePaths.hs b/src/FilePaths.hs
deleted file mode 100644
--- a/src/FilePaths.hs
+++ /dev/null
@@ -1,167 +0,0 @@
-{-# LANGUAGE TupleSections #-}
-module FilePaths
-  ( historyFilePath
-  , historyFileName
-
-  , lastRunStateFilePath
-  , lastRunStateFileName
-
-  , configFileName
-
-  , xdgName
-  , locateConfig
-  , xdgSyntaxDir
-  , syntaxDirName
-  , userEmojiJsonPath
-  , bundledEmojiJsonPath
-  , emojiJsonFilename
-
-  , Script(..)
-  , locateScriptPath
-  , getAllScripts
-  )
-where
-
-import Prelude ()
-import Prelude.MH
-
-import Data.Text ( unpack )
-import System.Directory ( doesFileExist
-                        , doesDirectoryExist
-                        , getDirectoryContents
-                        , getPermissions
-                        , executable
-                        )
-import System.Environment ( getExecutablePath )
-import System.Environment.XDG.BaseDir ( getUserConfigFile
-                                      , getAllConfigFiles
-                                      , getUserConfigDir
-                                      )
-import System.FilePath ( (</>), takeBaseName, takeDirectory, splitPath, joinPath )
-
-
-xdgName :: String
-xdgName = "matterhorn"
-
-historyFileName :: FilePath
-historyFileName = "history.txt"
-
-lastRunStateFileName :: Text -> FilePath
-lastRunStateFileName teamId = "last_run_state_" ++ unpack teamId ++ ".json"
-
-configFileName :: FilePath
-configFileName = "config.ini"
-
-historyFilePath :: IO FilePath
-historyFilePath = getUserConfigFile xdgName historyFileName
-
-lastRunStateFilePath :: Text -> IO FilePath
-lastRunStateFilePath teamId =
-  getUserConfigFile xdgName (lastRunStateFileName teamId)
-
--- | Get the XDG path to the user-specific syntax definition directory.
--- The path does not necessarily exist.
-xdgSyntaxDir :: IO FilePath
-xdgSyntaxDir = (</> syntaxDirName) <$> getUserConfigDir xdgName
-
--- | Get the XDG path to the user-specific emoji JSON file. The path
--- does not necessarily exist.
-userEmojiJsonPath :: IO FilePath
-userEmojiJsonPath = (</> emojiJsonFilename) <$> getUserConfigDir xdgName
-
--- | Get the emoji JSON path relative to the development binary location
--- or the release binary location.
-bundledEmojiJsonPath :: IO FilePath
-bundledEmojiJsonPath = do
-    selfPath <- getExecutablePath
-    let distDir = "dist-newstyle/"
-        pathBits = splitPath selfPath
-
-    return $ if distDir `elem` pathBits
-             then
-                 -- We're in development, so use the development
-                 -- executable path to locate the emoji path in the
-                 -- development tree.
-                 (joinPath $ takeWhile (/= distDir) pathBits) </> emojiDirName </> emojiJsonFilename
-             else
-                 -- In this case we assume the binary is being run from
-                 -- a release, in which case the syntax directory is a
-                 -- sibling of the executable path.
-                 takeDirectory selfPath </> emojiDirName </> emojiJsonFilename
-
-emojiJsonFilename :: FilePath
-emojiJsonFilename = "emoji.json"
-
-emojiDirName :: FilePath
-emojiDirName = "emoji"
-
-syntaxDirName :: FilePath
-syntaxDirName = "syntax"
-
--- | Find a specified configuration file by looking in all of the
--- supported locations.
-locateConfig :: FilePath -> IO (Maybe FilePath)
-locateConfig filename = do
-  xdgLocations <- getAllConfigFiles xdgName filename
-  let confLocations = ["./" <> filename] ++
-                      xdgLocations ++
-                      ["/etc/matterhorn/" <> filename]
-  results <- forM confLocations $ \fp -> (fp,) <$> doesFileExist fp
-  return $ listToMaybe $ fst <$> filter snd results
-
-scriptDirName :: FilePath
-scriptDirName = "scripts"
-
-data Script
-  = ScriptPath FilePath
-  | NonexecScriptPath FilePath
-  | ScriptNotFound
-    deriving (Eq, Read, Show)
-
-toScript :: FilePath -> IO (Script)
-toScript fp = do
-  perm <- getPermissions fp
-  return $ if executable perm
-    then ScriptPath fp
-    else NonexecScriptPath fp
-
-isExecutable :: FilePath -> IO Bool
-isExecutable fp = do
-  perm <- getPermissions fp
-  return (executable perm)
-
-locateScriptPath :: FilePath -> IO Script
-locateScriptPath name
-  | head name == '.' = return ScriptNotFound
-  | otherwise = do
-    xdgLocations <- getAllConfigFiles xdgName scriptDirName
-    let cmdLocations = [ xdgLoc ++ "/" ++ name
-                       | xdgLoc <- xdgLocations
-                       ] ++ [ "/etc/matterhorn/scripts/" <> name ]
-    existingFiles <- filterM doesFileExist cmdLocations
-    executables <- mapM toScript existingFiles
-    return $ case executables of
-      (path:_) -> path
-      _        -> ScriptNotFound
-
--- | This returns a list of valid scripts, and a list of non-executable
---   scripts.
-getAllScripts :: IO ([FilePath], [FilePath])
-getAllScripts = do
-  xdgLocations <- getAllConfigFiles xdgName scriptDirName
-  let cmdLocations = xdgLocations ++ ["/etc/matterhorn/scripts"]
-  let getCommands dir = do
-        exists <- doesDirectoryExist dir
-        if exists
-          then map ((dir ++ "/") ++) `fmap` getDirectoryContents dir
-          else return []
-  let isNotHidden f = case f of
-        ('.':_) -> False
-        []      -> False
-        _       -> True
-  allScripts <- concat `fmap` mapM getCommands cmdLocations
-  execs <- filterM isExecutable allScripts
-  nonexecs <- filterM (fmap not . isExecutable) allScripts
-  return ( filter isNotHidden $ map takeBaseName execs
-         , filter isNotHidden $ map takeBaseName nonexecs
-         )
diff --git a/src/HelpTopics.hs b/src/HelpTopics.hs
deleted file mode 100644
--- a/src/HelpTopics.hs
+++ /dev/null
@@ -1,49 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-module HelpTopics
-  ( helpTopics
-  , lookupHelpTopic
-  , themeHelpTopic
-  , mainHelpTopic
-  )
-where
-
-import Prelude ()
-import Prelude.MH
-
-import Types
-
-
-helpTopics :: [HelpTopic]
-helpTopics =
-    [ mainHelpTopic
-    , scriptHelpTopic
-    , themeHelpTopic
-    , keybindingHelpTopic
-    , syntaxHighlightingHelpTopic
-    ]
-
-mainHelpTopic :: HelpTopic
-mainHelpTopic =
-    HelpTopic "main" "This help page" MainHelp HelpText
-
-scriptHelpTopic :: HelpTopic
-scriptHelpTopic =
-    HelpTopic "scripts" "Help on available scripts" ScriptHelp ScriptHelpText
-
-themeHelpTopic :: HelpTopic
-themeHelpTopic =
-    HelpTopic "themes" "Help on color themes" ThemeHelp ThemeHelpText
-
-keybindingHelpTopic :: HelpTopic
-keybindingHelpTopic =
-    HelpTopic "keybindings" "Help on overriding keybindings"
-      KeybindingHelp KeybindingHelpText
-
-syntaxHighlightingHelpTopic :: HelpTopic
-syntaxHighlightingHelpTopic =
-    HelpTopic "syntax" "Help on syntax highlighing"
-      SyntaxHighlightHelp SyntaxHighlightHelpText
-
-lookupHelpTopic :: Text -> Maybe HelpTopic
-lookupHelpTopic topic =
-    listToMaybe $ filter ((== topic) . helpTopicName) helpTopics
diff --git a/src/IOUtil.hs b/src/IOUtil.hs
deleted file mode 100644
--- a/src/IOUtil.hs
+++ /dev/null
@@ -1,20 +0,0 @@
-module IOUtil
-  ( convertIOException
-  )
-where
-
-import Prelude ()
-import Prelude.MH
-
-import Control.Exception
-import Control.Monad.Trans.Except
-import System.IO.Error ( ioeGetErrorString )
-
-
-convertIOException :: IO a -> ExceptT String IO a
-convertIOException act = do
-    result <- liftIO $ (Right <$> act) `catch`
-                       (\(e::IOError) -> return $ Left $ ioeGetErrorString e)
-    case result of
-        Left e -> throwE e
-        Right v -> return v
diff --git a/src/InputHistory.hs b/src/InputHistory.hs
deleted file mode 100644
--- a/src/InputHistory.hs
+++ /dev/null
@@ -1,76 +0,0 @@
-{-# LANGUAGE TemplateHaskell #-}
-module InputHistory
-  ( InputHistory
-  , newHistory
-  , readHistory
-  , writeHistory
-  , addHistoryEntry
-  , getHistoryEntry
-  , removeChannelHistory
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Control.Monad.Trans.Except
-import qualified Data.HashMap.Strict as HM
-import qualified Data.Vector as V
-import           Lens.Micro.Platform ( (.~), (^?), (%~), at, ix, makeLenses )
-import           System.Directory ( createDirectoryIfMissing )
-import           System.FilePath ( dropFileName )
-import qualified System.IO.Strict as S
-import qualified System.Posix.Files as P
-import qualified System.Posix.Types as P
-
-import           Network.Mattermost.Types ( ChannelId )
-
-import           FilePaths
-import           IOUtil
-
-
-data InputHistory =
-    InputHistory { _historyEntries :: HashMap ChannelId (V.Vector Text)
-                 }
-                 deriving (Show)
-
-makeLenses ''InputHistory
-
-newHistory :: InputHistory
-newHistory = InputHistory mempty
-
-removeChannelHistory :: ChannelId -> InputHistory -> InputHistory
-removeChannelHistory cId ih = ih & historyEntries.at cId .~ Nothing
-
-historyFileMode :: P.FileMode
-historyFileMode = P.unionFileModes P.ownerReadMode P.ownerWriteMode
-
-writeHistory :: InputHistory -> IO ()
-writeHistory ih = do
-    historyFile <- historyFilePath
-    createDirectoryIfMissing True $ dropFileName historyFile
-    let entries = (\(cId, z) -> (cId, V.toList z)) <$>
-                  (HM.toList $ ih^.historyEntries)
-    writeFile historyFile $ show entries
-    P.setFileMode historyFile historyFileMode
-
-readHistory :: IO (Either String InputHistory)
-readHistory = runExceptT $ do
-    contents <- convertIOException (S.readFile =<< historyFilePath)
-    case reads contents of
-        [(val, "")] -> do
-            let entries = (\(cId, es) -> (cId, V.fromList es)) <$> val
-            return $ InputHistory $ HM.fromList entries
-        _ -> throwE "Failed to parse history file"
-
-addHistoryEntry :: Text -> ChannelId -> InputHistory -> InputHistory
-addHistoryEntry e cId ih = ih & historyEntries.at cId %~ insertEntry
-    where
-    insertEntry Nothing  = Just $ V.singleton e
-    insertEntry (Just v) =
-      Just $ V.cons e (V.filter (/= e) v)
-
-getHistoryEntry :: ChannelId -> Int -> InputHistory -> Maybe Text
-getHistoryEntry cId i ih = do
-    es <- ih^.historyEntries.at cId
-    es ^? ix i
diff --git a/src/KeyMap.hs b/src/KeyMap.hs
deleted file mode 100644
--- a/src/KeyMap.hs
+++ /dev/null
@@ -1,28 +0,0 @@
-module KeyMap
-  ( keybindingModeMap
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Events.Keybindings
-import           Events.ChannelSelect
-import           Events.Main
-import           Events.MessageSelect
-import           Events.PostListOverlay
-import           Events.ShowHelp
-import           Events.UrlSelect
-import           Events.ManageAttachments
-
-keybindingModeMap :: [(String, KeyConfig -> KeyHandlerMap)]
-keybindingModeMap =
-    [ ("main", mainKeybindings)
-    , ("help screen", helpKeybindings)
-    , ("channel select", channelSelectKeybindings)
-    , ("url select", urlSelectKeybindings)
-    , ("message select", messageSelectKeybindings)
-    , ("post list overlay", postListOverlayKeybindings)
-    , ("attachment list", attachmentListKeybindings)
-    , ("attachment file browse", attachmentBrowseKeybindings)
-    ]
diff --git a/src/LastRunState.hs b/src/LastRunState.hs
deleted file mode 100644
--- a/src/LastRunState.hs
+++ /dev/null
@@ -1,99 +0,0 @@
-{-# LANGUAGE TemplateHaskell #-}
-module LastRunState
-  ( LastRunState
-  , lrsHost
-  , lrsPort
-  , lrsUserId
-  , lrsSelectedChannelId
-  , writeLastRunState
-  , readLastRunState
-  , isValidLastRunState
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Control.Monad.Trans.Except
-import qualified Data.Aeson as A
-import qualified Data.ByteString as BS
-import qualified Data.ByteString.Lazy as LBS
-import           Lens.Micro.Platform ( makeLenses )
-import           System.Directory ( createDirectoryIfMissing )
-import           System.FilePath ( dropFileName )
-import qualified System.Posix.Files as P
-import qualified System.Posix.Types as P
-
-import           Network.Mattermost.Lenses
-import           Network.Mattermost.Types
-
-import           FilePaths
-import           IOUtil
-import           Types
-
-
--- | Run state of the program. This is saved in a file on program exit and
--- | looked up from the file on program startup.
-data LastRunState = LastRunState
-  { _lrsHost              :: Hostname  -- ^ Host of the server
-  , _lrsPort              :: Port      -- ^ Post of the server
-  , _lrsUserId            :: UserId    -- ^ ID of the logged-in user
-  , _lrsSelectedChannelId :: ChannelId -- ^ ID of the last selected channel
-  }
-
-instance A.ToJSON LastRunState where
-  toJSON lrs = A.object [ "host"           A..= _lrsHost lrs
-                        , "port"           A..= _lrsPort lrs
-                        , "user_id"        A..= _lrsUserId lrs
-                        , "sel_channel_id" A..= _lrsSelectedChannelId lrs
-                        ]
-
-instance A.FromJSON LastRunState where
-  parseJSON = A.withObject "LastRunState" $ \v ->
-    LastRunState
-    <$> v A..: "host"
-    <*> v A..: "port"
-    <*> v A..: "user_id"
-    <*> v A..: "sel_channel_id"
-
-makeLenses ''LastRunState
-
-toLastRunState :: ChatState -> LastRunState
-toLastRunState cs = LastRunState
-  { _lrsHost              = cs^.csResources.crConn.cdHostnameL
-  , _lrsPort              = cs^.csResources.crConn.cdPortL
-  , _lrsUserId            = myUserId cs
-  , _lrsSelectedChannelId = cs^.csCurrentChannelId
-  }
-
-lastRunStateFileMode :: P.FileMode
-lastRunStateFileMode = P.unionFileModes P.ownerReadMode P.ownerWriteMode
-
--- | Writes the run state to a file. The file is specific to the current team.
--- | Writes only if the current channel is an ordrinary or a private channel.
-writeLastRunState :: ChatState -> IO ()
-writeLastRunState cs =
-  when (cs^.csCurrentChannel.ccInfo.cdType `elem` [Ordinary, Private]) $ do
-    let runState = toLastRunState cs
-        tId      = myTeamId cs
-
-    lastRunStateFile <- lastRunStateFilePath $ unId $ toId tId
-    createDirectoryIfMissing True $ dropFileName lastRunStateFile
-    BS.writeFile lastRunStateFile $ LBS.toStrict $ A.encode runState
-    P.setFileMode lastRunStateFile lastRunStateFileMode
-
--- | Reads the last run state from a file given the current team ID.
-readLastRunState :: TeamId -> IO (Either String LastRunState)
-readLastRunState tId = runExceptT $ do
-  contents <- convertIOException $
-    lastRunStateFilePath (unId $ toId tId) >>= BS.readFile
-  case A.eitherDecodeStrict' contents of
-    Right val -> return val
-    Left err -> throwE $ "Failed to parse lastRunState file: " ++ err
-
--- | Checks if the given last run state is valid for the current server and user.
-isValidLastRunState :: ChatResources -> User -> LastRunState -> Bool
-isValidLastRunState cr me rs =
-     rs^.lrsHost   == cr^.crConn.cdHostnameL
-  && rs^.lrsPort   == cr^.crConn.cdPortL
-  && rs^.lrsUserId == me^.userIdL
diff --git a/src/Login.hs b/src/Login.hs
deleted file mode 100644
--- a/src/Login.hs
+++ /dev/null
@@ -1,594 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-{-# LANGUAGE TemplateHaskell #-}
-{-# LANGUAGE RankNTypes #-}
--- | This module provides the login interface for Matterhorn.
---
--- * Overview
---
--- The interface provides a set of form fields for the user to use to
--- enter their server information and credentials. The user enters
--- this information and presses Enter, and then this module
--- attempts to connect to the server. The module's main function,
--- interactiveGetLoginSession, returns the result of that connection
--- attempt, if any.
---
--- * Details
---
--- The interactiveGetLoginSession function takes the Matterhorn
--- configuration's initial connection information as input. If the
--- configuration provided a complete set of values needed to make a
--- login attempt, this module goes ahead and immediately makes a login
--- attempt before even showing the user the login form. This case is
--- the case where the configuration provided all four values needed:
--- server host name, port, username, and password. When the interface
--- immediately makes a login attempt under these conditions, this is
--- referred to as an "initial" attempt in various docstrings below.
--- Otherwise, the user is prompted to fill out the form to enter any
--- missing values. On pressing Enter, a login attempt is made.
---
--- A status message about whether a connection is underway is shown in
--- both cases: in the case where the user has edited the credentials and
--- pressed Enter, and in the case where the original credentials
--- provided to interactiveGetLoginSession caused an initial connection
--- attempt.
---
--- The "initial" login case is special because in addition to not
--- showing the form, we want to ensure that the "connecting to..."
--- status message that is shown is shown long enough for the user to
--- see what is happening (rather than just flashing by in the case
--- of a fast server connection). For this usability reason, we have
--- a "startup timer" thread: the thread waits a specified number
--- of milliseconds (see 'startupTimerMilliseconds' below) and then
--- notifies the interface that it has timed out. If there is an initial
--- connection attempt underway that succeeds *before* the timer
--- fires, we wait until the timer fires before quitting the Login
--- application and returning control to Matterhorn. This ensures that
--- the "connecting to..." message stays on the screen long enough to not
--- be jarring, and to show the user what is happening. If the connection
--- fails before the timer fires, we just resume normal operation and
--- show the login form so the user can intervene.
-module Login
-  ( LoginAttempt(..)
-  , interactiveGetLoginSession
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.BChan
-import           Brick.Focus
-import           Brick.Forms
-import           Brick.Widgets.Border
-import           Brick.Widgets.Center
-import           Brick.Widgets.Edit
-import           Control.Concurrent ( forkIO, threadDelay )
-import           Control.Exception ( SomeException, catch, try )
-import           Data.Char (isHexDigit)
-import           Data.List (tails, inits)
-import           System.IO.Error ( catchIOError )
-import qualified Data.Text as T
-import           Graphics.Vty hiding (mkVty)
-import           Lens.Micro.Platform ( (.~), Lens', makeLenses )
-import qualified System.IO.Error as Err
-import           Network.URI ( URI(..), URIAuth(..), parseURI )
-
-import           Network.Mattermost ( ConnectionData )
-import           Network.Mattermost.Types.Internal ( Token(..) )
-import           Network.Mattermost.Types ( Session(..), User, Login(..), ConnectionPoolConfig(..)
-                                          , initConnectionData, ConnectionType(..), UserParam(..) )
-import           Network.Mattermost.Exceptions ( LoginFailureException(..) )
-import           Network.Mattermost.Endpoints ( mmGetUser, mmGetLimitedClientConfiguration, mmLogin )
-
-import           Draw.RichText
-import           Themes ( clientEmphAttr )
-import           Types ( ConnectionInfo(..)
-                       , ciPassword, ciUsername, ciHostname, ciUrlPath
-                       , ciPort, ciType, AuthenticationException(..)
-                       , LogManager, LogCategory(..), ioLogWithManager
-                       , ciAccessToken
-                       )
-
-
--- | Resource names for the login interface.
-data Name =
-      Server
-    | Username
-    | Password
-    | AccessToken
-    deriving (Ord, Eq, Show)
-
--- | The result of an authentication attempt.
-data LoginAttempt =
-    AttemptFailed AuthenticationException
-    -- ^ The attempt failed with the corresponding error.
-    | AttemptSucceeded ConnectionInfo ConnectionData Session User (Maybe Text) --team
-    -- ^ The attempt succeeded.
-
--- | The state of the login interface: whether a login attempt is
--- currently in progress.
-data LoginState =
-    Idle
-    -- ^ No login attempt is in progress.
-    | Connecting Bool Text
-    -- ^ A login attempt to the specified host is in progress. The
-    -- boolean flag indicates whether this login was user initiated
-    -- (False) or triggered immediately when starting the interface
-    -- (True). This "initial" flag is used to determine whether the
-    -- login form is shown while the connection attempt is underway.
-    deriving (Eq)
-
--- | Requests that we can make to the login worker thead.
-data LoginRequest =
-    DoLogin Bool ConnectionInfo
-    -- ^ Request a login using the specified connection information.
-    -- The boolean flag is the "initial" flag value corresponding to the
-    -- "Connecting" constructor flag of the "LoginState" type.
-
--- | The messages that the login worker thread can send to the user
--- interface event handler.
-data LoginEvent =
-    StartConnect Bool Text
-    -- ^ A connection to the specified host has begun. The boolean
-    -- value is whether this was an "initial" connection attempt (see
-    -- LoginState).
-    | LoginResult LoginAttempt
-    -- ^ A login attempt finished with the specified result.
-    | StartupTimeout
-    -- ^ The startup timer thread fired.
-
--- | The login application state.
-data State =
-    State { _loginForm :: Form ConnectionInfo LoginEvent Name
-          , _lastAttempt :: Maybe LoginAttempt
-          , _currentState :: LoginState
-          , _reqChan :: BChan LoginRequest
-          , _timeoutFired :: Bool
-          }
-
-makeLenses ''State
-
--- | The HTTP connection pool settings for the login worker thread.
-poolCfg :: ConnectionPoolConfig
-poolCfg = ConnectionPoolConfig { cpIdleConnTimeout = 60
-                               , cpStripesCount = 1
-                               , cpMaxConnCount = 5
-                               }
-
--- | Run an IO action and convert various kinds of thrown exceptions
--- into a returned AuthenticationException.
-convertLoginExceptions :: IO a -> IO (Either AuthenticationException a)
-convertLoginExceptions act =
-    (Right <$> act)
-        `catch` (\e -> return $ Left $ ResolveError e)
-        `catch` (\e -> return $ Left $ ConnectError e)
-        `catchIOError` (\e -> return $ Left $ AuthIOError e)
-        `catch` (\e -> return $ Left $ OtherAuthError e)
-
--- | The login worker thread.
-loginWorker :: (ConnectionData -> ConnectionData)
-            -- ^ The function used to set the logger on the
-            -- ConnectionData that results from a successful login
-            -- attempt.
-            -> LogManager
-            -- ^ The log manager used to do logging.
-            -> BChan LoginRequest
-            -- ^ The channel on which we'll await requests.
-            -> BChan LoginEvent
-            -- ^ The channel to which we'll send login attempt results.
-            -> IO ()
-loginWorker setLogger logMgr requestChan respChan = forever $ do
-    req <- readBChan requestChan
-    case req of
-        DoLogin initial connInfo -> do
-            writeBChan respChan $ StartConnect initial $ connInfo^.ciHostname
-            let doLog = ioLogWithManager logMgr Nothing LogGeneral
-
-            doLog $ "Attempting authentication to " <> connInfo^.ciHostname
-
-            cdResult <- findConnectionData connInfo
-            case cdResult of
-              Left e ->
-                do writeBChan respChan $ LoginResult $ AttemptFailed $ OtherAuthError e
-              Right (cd_, mbTeam) -> do
-                  let cd = setLogger cd_
-                      token = connInfo^.ciAccessToken
-                  case T.null token of
-                      False -> do
-                          let sess = Session cd $ Token $ T.unpack token
-
-                          userResult <- try $ mmGetUser UserMe sess
-                          writeBChan respChan $ case userResult of
-                              Left (e::SomeException) ->
-                                  LoginResult $ AttemptFailed $ OtherAuthError e
-                              Right user ->
-                                  LoginResult $ AttemptSucceeded connInfo cd sess user mbTeam
-                      True -> do
-                          let login = Login { username = connInfo^.ciUsername
-                                            , password = connInfo^.ciPassword
-                                            }
-
-                          result <- convertLoginExceptions $ mmLogin cd login
-                          case result of
-                              Left e -> do
-                                  doLog $ "Error authenticating to " <> connInfo^.ciHostname <> ": " <> (T.pack $ show e)
-                                  writeBChan respChan $ LoginResult $ AttemptFailed e
-                              Right (Left e) -> do
-                                  doLog $ "Error authenticating to " <> connInfo^.ciHostname <> ": " <> (T.pack $ show e)
-                                  writeBChan respChan $ LoginResult $ AttemptFailed $ LoginError e
-                              Right (Right (sess, user)) -> do
-                                  doLog $ "Authenticated successfully to " <> connInfo^.ciHostname <> " as " <> connInfo^.ciUsername
-                                  writeBChan respChan $ LoginResult $ AttemptSucceeded connInfo cd sess user mbTeam
-
-
-
--- | Searches prefixes of the given URL to determine Mattermost API URL
--- path and team name
-findConnectionData :: ConnectionInfo -> IO (Either SomeException (ConnectionData, Maybe Text))
-findConnectionData connInfo = startSearch
-  where
-    components = filter (not . T.null) (T.split ('/'==) (connInfo^.ciUrlPath))
-
-    -- the candidates list is never empty because inits never returns an
-    -- empty list
-    primary:alternatives =
-        reverse
-        [ (T.intercalate "/" l, listToMaybe r)
-        | (l,r) <- zip (inits components) (tails components)
-        ]
-
-    tryCandidate :: (Text, Maybe Text)
-                 -> IO (Either SomeException (ConnectionData, Maybe Text))
-    tryCandidate (path, team) =
-       do cd  <- initConnectionData (connInfo^.ciHostname)
-                                    (fromIntegral (connInfo^.ciPort))
-                                    path (connInfo^.ciType) poolCfg
-          res <- try (mmGetLimitedClientConfiguration cd)
-          pure $! case res of
-                    Left e  -> Left e
-                    Right{} -> Right (cd, team)
-
-    -- This code prefers to report the error from the URL corresponding
-    -- to what the user actually provided. Errors from derived URLs are
-    -- lost in favor of this first error.
-    startSearch =
-      do res1 <- tryCandidate primary
-         case res1 of
-           Left e -> search e alternatives
-           Right cd -> pure (Right cd)
-
-    search e [] = pure (Left e)
-    search e (x:xs) =
-      do res <- tryCandidate x
-         case res of
-           Left {}  -> search e xs
-           Right cd -> pure (Right cd)
-
-
--- | The amount of time that the startup timer thread will wait before
--- firing.
-startupTimerMilliseconds :: Int
-startupTimerMilliseconds = 750
-
--- | The startup timer thread.
-startupTimer :: BChan LoginEvent -> IO ()
-startupTimer respChan = do
-    threadDelay $ startupTimerMilliseconds * 1000
-    writeBChan respChan StartupTimeout
-
--- | The main function of this module: interactively present a login
--- interface, get the user's input, and attempt to log into the user's
--- specified mattermost server.
---
--- This always returns the final terminal state handle. If the user
--- makes no login attempt, this returns Nothing. Otherwise it returns
--- Just the result of the latest attempt.
-interactiveGetLoginSession :: Vty
-                           -- ^ The initial terminal state handle to use.
-                           -> IO Vty
-                           -- ^ An action to build a new state handle
-                           -- if one is needed. (In practice this
-                           -- never fires since the login app doesn't
-                           -- use suspendAndResume, but we need it to
-                           -- satisfy the Brick API.)
-                           -> (ConnectionData -> ConnectionData)
-                           -- ^ The function to set the logger on
-                           -- connection handles.
-                           -> LogManager
-                           -- ^ The log manager used to do logging.
-                           -> ConnectionInfo
-                           -- ^ Initial connection info to use to
-                           -- populate the login form. If the connection
-                           -- info provided here is fully populated, an
-                           -- initial connection attempt is made without
-                           -- first getting the user to hit Enter.
-                           -> IO (Maybe LoginAttempt, Vty)
-interactiveGetLoginSession vty mkVty setLogger logMgr initialConfig = do
-    requestChan <- newBChan 10
-    respChan <- newBChan 10
-
-    void $ forkIO $ loginWorker setLogger logMgr requestChan respChan
-    void $ forkIO $ startupTimer respChan
-
-    let initialState = mkState initialConfig requestChan
-
-    startState <- case (populatedConnectionInfo initialConfig) of
-        True -> do
-            writeBChan requestChan $ DoLogin True initialConfig
-            return $ initialState & currentState .~ Connecting True (initialConfig^.ciHostname)
-        False -> do
-            return initialState
-
-    (finalSt, finalVty) <- customMainWithVty vty mkVty (Just respChan) app startState
-
-    return (finalSt^.lastAttempt, finalVty)
-
--- | Is the specified ConnectionInfo sufficiently populated for us to
--- bother attempting to use it to connect?
-populatedConnectionInfo :: ConnectionInfo -> Bool
-populatedConnectionInfo ci =
-    and [ not $ T.null $ ci^.ciHostname
-        , or [ and [ not $ T.null $ ci^.ciUsername
-                   , not $ T.null $ ci^.ciPassword
-                   ]
-             , not $ T.null $ ci^.ciAccessToken
-             ]
-        , ci^.ciPort > 0
-        ]
-
--- | Make an initial login application state.
-mkState :: ConnectionInfo -> BChan LoginRequest -> State
-mkState cInfo chan = state
-    where
-        state = State { _loginForm = form { formFocus = focusSetCurrent initialFocus (formFocus form)
-                                          }
-                      , _currentState = Idle
-                      , _lastAttempt = Nothing
-                      , _reqChan = chan
-                      , _timeoutFired = False
-                      }
-        form = mkForm cInfo
-        initialFocus = if | T.null (cInfo^.ciHostname) -> Server
-                          | T.null (cInfo^.ciUsername) -> Username
-                          | T.null (cInfo^.ciPassword) -> Password
-                          | otherwise                  -> Server
-
-app :: App State LoginEvent Name
-app = App
-  { appDraw         = credsDraw
-  , appChooseCursor = showFirstCursor
-  , appHandleEvent  = onEvent
-  , appStartEvent   = return
-  , appAttrMap      = const colorTheme
-  }
-
-onEvent :: State -> BrickEvent Name LoginEvent -> EventM Name (Next State)
-onEvent st (VtyEvent (EvKey KEsc [])) = do
-    halt $ st & lastAttempt .~ Nothing
-onEvent st (AppEvent (StartConnect initial host)) = do
-    continue $ st & currentState .~ Connecting initial host
-                  & lastAttempt .~ Nothing
-onEvent st (AppEvent StartupTimeout) = do
-    -- If the startup timer fired and we have already succeeded, halt.
-    case st^.lastAttempt of
-        Just (AttemptSucceeded {}) -> halt st
-        _ -> continue $ st & timeoutFired .~ True
-onEvent st (AppEvent (LoginResult attempt)) = do
-    let st' = st & lastAttempt .~ Just attempt
-                 & currentState .~ Idle
-
-    case attempt of
-        AttemptSucceeded {} -> do
-            -- If the startup timer already fired, halt. Otherwise wait
-            -- until that timer fires.
-            case st^.timeoutFired of
-                True -> halt st'
-                False -> continue st'
-        AttemptFailed {} -> continue st'
-
-onEvent st (VtyEvent (EvKey KEnter [])) = do
-    -- Ignore the keypress if we are already attempting a connection, or
-    -- if have already successfully connected but are waiting on the
-    -- startup timer.
-    case st^.currentState of
-        Connecting {} -> return ()
-        Idle ->
-            case st^.lastAttempt of
-                Just (AttemptSucceeded {}) -> return ()
-                _ -> do
-                    let ci = formState $ st^.loginForm
-                    when (populatedConnectionInfo ci) $ do
-                        liftIO $ writeBChan (st^.reqChan) $ DoLogin False ci
-
-    continue st
-onEvent st e = do
-    f' <- handleFormEvent e (st^.loginForm)
-    continue $ st & loginForm .~ f'
-
-mkForm :: ConnectionInfo -> Form ConnectionInfo e Name
-mkForm =
-    let label s w = padBottom (Pad 1) $
-                    (vLimit 1 $ hLimit 22 $ str s <+> fill ' ') <+> w
-        above s w = hCenter (str s) <=> w
-    in newForm [ label "Server URL:"     @@= editServer
-               , (above "Provide a username and password:" .
-                  label "Username:")     @@= editTextField ciUsername Username (Just 1)
-               , label "Password:"       @@= editPasswordField ciPassword Password
-               , (above "Or provide a Session or Personal Access Token:" .
-                  label "Access Token:") @@= editPasswordField ciAccessToken AccessToken
-               ]
-
-serverLens :: Lens' ConnectionInfo (Text, Int, Text, ConnectionType)
-serverLens f ci = fmap (\(x,y,z,w) -> ci { _ciHostname = x, _ciPort = y, _ciUrlPath = z, _ciType = w})
-                       (f (ci^.ciHostname, ci^.ciPort, ci^.ciUrlPath, ci^.ciType))
-
--- | Validate a server URI @hostname[:port][/path]@. Result is either an error message
--- indicating why validation failed or a tuple of (hostname, port, path)
-validServer :: [Text] -> Either String (Text, Int, Text, ConnectionType)
-validServer ts =
-
-  do t <- case ts of
-            []  -> Left "No input"
-            [t] -> Right t
-            _   -> Left "Too many lines"
-
-     let inputWithScheme
-           | "://" `T.isInfixOf` t = t
-           | otherwise = "https://" <> t
-
-     uri <- case parseURI (T.unpack inputWithScheme) of
-              Nothing  -> Left "Unable to parse URI"
-              Just uri -> Right uri
-
-     auth <- case uriAuthority uri of
-               Nothing   -> Left "Missing authority part"
-               Just auth -> Right auth
-
-     ty <- case uriScheme uri of
-             "http:"           -> Right ConnectHTTP
-             "https:"          -> Right (ConnectHTTPS True)
-             "https-insecure:" -> Right (ConnectHTTPS False)
-             _                 -> Left "Unknown scheme"
-
-     port <- case (ty, uriPort auth) of
-               (ConnectHTTP   , "") -> Right 80
-               (ConnectHTTPS{}, "") -> Right 443
-               (_, ':':portStr)
-                 | Just port <- readMaybe portStr -> Right port
-               _ -> Left "Invalid port"
-
-     let host
-           | not (null (uriRegName auth))
-           , '[' == head (uriRegName auth)
-           , ']' == last (uriRegName auth)
-           = init (tail (uriRegName auth))
-           | otherwise = uriRegName auth
-
-     if null (uriRegName auth) then Left "Missing server name" else Right ()
-     if null (uriQuery uri) then Right () else Left "Unexpected URI query"
-     if null (uriFragment uri) then Right () else Left "Unexpected URI fragment"
-     if null (uriUserInfo auth) then Right () else Left "Unexpected credentials"
-
-     Right (T.pack host, port, T.pack (uriPath uri), ty)
-
-
-renderServer :: (Text, Int, Text, ConnectionType) -> Text
-renderServer (h,p,u,t) = scheme <> hStr <> portStr <> uStr
-  where
-    hStr
-      | T.all (\x -> isHexDigit x || ':'==x) h
-      , T.any (':'==) h = "[" <> h <> "]"
-      | otherwise = h
-
-    scheme =
-      case t of
-        ConnectHTTP        -> "http://"
-        ConnectHTTPS True  -> ""
-        ConnectHTTPS False -> "https-insecure://"
-
-    uStr
-      | T.null u = u
-      | otherwise = T.cons '/' (T.dropWhile ('/'==) u)
-
-    portStr =
-      case t of
-        ConnectHTTP    | p ==  80 -> T.empty
-        ConnectHTTPS{} | p == 443 -> T.empty
-        _                         -> T.pack (':':show p)
-
-editServer :: ConnectionInfo -> FormFieldState ConnectionInfo e Name
-editServer =
-    let val ts = case validServer ts of
-                   Left{} -> Nothing
-                   Right x-> Just x
-        limit = Just 1
-        renderTxt [""] = str "(Paste your Mattermost URL here)"
-        renderTxt ts = txt (T.unlines ts)
-    in editField serverLens Server limit renderServer val renderTxt id
-
-errorAttr :: AttrName
-errorAttr = "errorMessage"
-
-colorTheme :: AttrMap
-colorTheme = attrMap defAttr
-  [ (editAttr, black `on` white)
-  , (editFocusedAttr, black `on` yellow)
-  , (errorAttr, fg red)
-  , (focusedFormInputAttr, black `on` yellow)
-  , (invalidFormInputAttr, white `on` red)
-  , (clientEmphAttr, fg white `withStyle` bold)
-  ]
-
-credsDraw :: State -> [Widget Name]
-credsDraw st =
-    [ center $ vBox [ if shouldShowForm st then credentialsForm st else emptyWidget
-                    , currentStateDisplay st
-                    , lastAttemptDisplay (st^.lastAttempt)
-                    ]
-    ]
-
--- | Whether the login form should be displayed.
-shouldShowForm :: State -> Bool
-shouldShowForm st =
-    case st^.currentState of
-        -- If we're connecting, only show the form if the connection
-        -- attempt is not an initial one.
-        Connecting initial _ -> not initial
-
-        -- If we're idle, we want to show the form - unless we have
-        -- already connected and are waiting for the startup timer to
-        -- fire.
-        Idle -> case st^.lastAttempt of
-            Just (AttemptSucceeded {}) -> st^.timeoutFired
-            _ -> True
-
--- | The "current state" of the login process. Show a connecting status
--- message if a connection is underway, or if one is already established
--- and the startup timer has not fired.
-currentStateDisplay :: State -> Widget Name
-currentStateDisplay st =
-    let msg host = padTop (Pad 1) $ hCenter $
-                   txt "Connecting to " <+> withDefAttr clientEmphAttr (txt host) <+> txt "..."
-    in case st^.currentState of
-          Idle -> case st^.lastAttempt of
-              Just (AttemptSucceeded ci _ _ _ _) -> msg (ci^.ciHostname)
-              _ -> emptyWidget
-          (Connecting _ host) -> msg host
-
-lastAttemptDisplay :: Maybe LoginAttempt -> Widget Name
-lastAttemptDisplay Nothing = emptyWidget
-lastAttemptDisplay (Just (AttemptSucceeded {})) = emptyWidget
-lastAttemptDisplay (Just (AttemptFailed e)) =
-    hCenter $ hLimit uiWidth $
-    padTop (Pad 1) $ renderError $ renderText $
-    "Error: " <> renderAuthError e
-
-renderAuthError :: AuthenticationException -> Text
-renderAuthError (ConnectError _) =
-    "Could not connect to server"
-renderAuthError (ResolveError _) =
-    "Could not resolve server hostname"
-renderAuthError (AuthIOError err)
-  | Err.isDoesNotExistErrorType (Err.ioeGetErrorType err) =
-    "Unable to connect to the network"
-  | otherwise = "GetAddrInfo: " <> T.pack (Err.ioeGetErrorString err)
-renderAuthError (OtherAuthError e) =
-    T.pack $ show e
-renderAuthError (LoginError (LoginFailureException msg)) =
-    T.pack msg
-
-renderError :: Widget a -> Widget a
-renderError = withDefAttr errorAttr
-
-uiWidth :: Int
-uiWidth = 60
-
-credentialsForm :: State -> Widget Name
-credentialsForm st =
-    hCenter $ hLimit uiWidth $ vLimit 15 $
-    border $
-    vBox [ renderText "Please enter your Mattermost credentials to log in."
-         , padTop (Pad 1) $ renderForm (st^.loginForm)
-         , hCenter $ renderText "Press Enter to log in or Esc to exit."
-         ]
diff --git a/src/Main.hs b/src/Main.hs
deleted file mode 100644
--- a/src/Main.hs
+++ /dev/null
@@ -1,60 +0,0 @@
-module Main where
-
-import Prelude ()
-import Prelude.MH
-import qualified Data.Text.IO as T
-
-import System.Exit ( exitFailure, exitSuccess )
-
-import Config
-import Options
-import App
-import Events.Keybindings ( ensureKeybindingConsistency )
-import KeyMap ( keybindingModeMap )
-import Draw.ShowHelp ( keybindingMarkdownTable, keybindingTextTable
-                     , commandMarkdownTable, commandTextTable )
-
-
-main :: IO ()
-main = do
-    opts <- grabOptions
-
-    configResult <- if optIgnoreConfig opts
-                    then return $ Right defaultConfig
-                    else fmap snd <$> findConfig (optConfLocation opts)
-
-    config <- case configResult of
-        Left err -> do
-            putStrLn $ "Error loading config: " <> err
-            exitFailure
-        Right c -> return c
-
-    let keyConfig = configUserKeys config
-        format = optPrintFormat opts
-
-    printedCommands <- case optPrintCommands opts of
-        False -> return False
-        True -> do
-            case format of
-                Markdown -> T.putStrLn commandMarkdownTable
-                Plain -> T.putStrLn commandTextTable
-            return True
-
-    printedKeybindings <- case optPrintKeybindings opts of
-        False -> return False
-        True -> do
-            case format of
-                Markdown -> T.putStrLn $ keybindingMarkdownTable keyConfig
-                Plain -> T.putStrLn $ keybindingTextTable keyConfig
-            return True
-
-    when (printedKeybindings || printedCommands) exitSuccess
-
-    case ensureKeybindingConsistency keyConfig keybindingModeMap of
-        Right () -> return ()
-        Left err -> do
-            putStrLn $ "Configuration error: " <> err
-            exitFailure
-
-    finalSt <- runMatterhorn opts config
-    closeMatterhorn finalSt
diff --git a/src/Matterhorn/App.hs b/src/Matterhorn/App.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/App.hs
@@ -0,0 +1,115 @@
+module Matterhorn.App
+  ( runMatterhorn
+  , closeMatterhorn
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Control.Monad.Trans.Except ( runExceptT )
+import qualified Graphics.Vty as Vty
+import           Text.Aspell ( stopAspell )
+import           GHC.Conc (getNumProcessors, setNumCapabilities)
+import           System.Posix.IO ( stdInput )
+
+import           Network.Mattermost
+
+import           Matterhorn.Config
+import           Matterhorn.Draw
+import qualified Matterhorn.Events as Events
+import           Matterhorn.IOUtil
+import           Matterhorn.InputHistory
+import           Matterhorn.LastRunState
+import           Matterhorn.Options hiding ( ShowHelp )
+import           Matterhorn.State.Setup
+import           Matterhorn.State.Setup.Threads.Logging ( shutdownLogManager )
+import           Matterhorn.Types
+
+
+app :: App ChatState MHEvent Name
+app = App
+  { appDraw         = draw
+  , appChooseCursor = \s cs -> case appMode s of
+      Main                          -> showFirstCursor s cs
+      ChannelSelect                 -> showFirstCursor s cs
+      UserListOverlay               -> showFirstCursor s cs
+      ReactionEmojiListOverlay      -> showFirstCursor s cs
+      ChannelListOverlay            -> showFirstCursor s cs
+      ManageAttachmentsBrowseFiles  -> showFirstCursor s cs
+      ThemeListOverlay              -> showFirstCursor s cs
+      ChannelTopicWindow            -> showCursorNamed ChannelTopicEditor cs
+      LeaveChannelConfirm           -> Nothing
+      DeleteChannelConfirm          -> Nothing
+      MessageSelect                 -> Nothing
+      MessageSelectDeleteConfirm    -> Nothing
+      PostListOverlay _             -> Nothing
+      ManageAttachments             -> Nothing
+      ViewMessage                   -> Nothing
+      ShowHelp _ _                  -> Nothing
+      UrlSelect                     -> Nothing
+      EditNotifyPrefs               -> Nothing
+  , appHandleEvent  = Events.onEvent
+  , appStartEvent   = return
+  , appAttrMap      = (^.csResources.crTheme)
+  }
+
+applicationMaxCPUs :: Int
+applicationMaxCPUs = 2
+
+setupCpuUsage :: Config -> IO ()
+setupCpuUsage config = do
+    actualNumCpus <- getNumProcessors
+
+    let requestedCPUs = case configCpuUsagePolicy config of
+            SingleCPU -> 1
+            MultipleCPUs -> min applicationMaxCPUs actualNumCpus
+
+    setNumCapabilities requestedCPUs
+
+runMatterhorn :: Options -> Config -> IO ChatState
+runMatterhorn opts config = do
+    setupCpuUsage config
+
+    let mkVty = do
+          mEraseChar <- Vty.getTtyEraseChar stdInput
+          let addEraseChar cfg = case mEraseChar of
+                  Nothing -> cfg
+                  Just ch -> cfg { Vty.inputMap = (Nothing, [ch], Vty.EvKey Vty.KBS []) : Vty.inputMap cfg }
+
+          vty <- Vty.mkVty $ addEraseChar Vty.defaultConfig
+          let output = Vty.outputIface vty
+          Vty.setMode output Vty.BracketedPaste True
+          Vty.setMode output Vty.Hyperlink $ configHyperlinkingMode config
+          return vty
+
+    (st, vty) <- setupState mkVty (optLogLocation opts) config
+    finalSt <- customMain vty mkVty (Just $ st^.csResources.crEventQueue) app st
+
+    case finalSt^.csEditState.cedSpellChecker of
+        Nothing -> return ()
+        Just (s, _) -> stopAspell s
+
+    return finalSt
+
+-- | Cleanup resources and save data for restoring on program restart.
+closeMatterhorn :: ChatState -> IO ()
+closeMatterhorn finalSt = do
+  logIfError (mmCloseSession $ getResourceSession $ finalSt^.csResources)
+      "Error in closing session"
+
+  logIfError (writeHistory (finalSt^.csEditState.cedInputHistory))
+      "Error in writing history"
+
+  logIfError (writeLastRunState finalSt)
+      "Error in writing last run state"
+
+  shutdownLogManager $ finalSt^.csResources.crLogManager
+
+  where
+    logIfError action msg = do
+      done <- runExceptT $ convertIOException $ action
+      case done of
+        Left err -> putStrLn $ msg <> ": " <> err
+        Right _  -> return ()
diff --git a/src/Matterhorn/Clipboard.hs b/src/Matterhorn/Clipboard.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Clipboard.hs
@@ -0,0 +1,31 @@
+module Matterhorn.Clipboard
+  ( copyToClipboard
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Control.Exception ( try )
+import qualified Data.Text as T
+import           System.Hclip ( setClipboard, ClipboardException(..) )
+
+import           Matterhorn.Types
+
+
+copyToClipboard :: Text -> MH ()
+copyToClipboard txt = do
+  result <- liftIO (try (setClipboard (T.unpack txt)))
+  case result of
+    Left e -> do
+      let errMsg = case e of
+            UnsupportedOS _ ->
+              "Matterhorn does not support yanking on this operating system."
+            NoTextualData ->
+              "Textual data is required to set the clipboard."
+            MissingCommands cmds ->
+              "Could not set clipboard due to missing one of the " <>
+              "required program(s): " <> (T.pack $ show cmds)
+      mhError $ ClipboardError errMsg
+    Right () ->
+      return ()
diff --git a/src/Matterhorn/Command.hs b/src/Matterhorn/Command.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Command.hs
@@ -0,0 +1,338 @@
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE OverloadedStrings #-}
+module Matterhorn.Command
+  ( commandList
+  , dispatchCommand
+  , printArgSpec
+  , toggleMessagePreview
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Main ( invalidateCache )
+import qualified Control.Exception as Exn
+import qualified Data.Char as Char
+import qualified Data.Text as T
+import           Lens.Micro.Platform ( (%=) )
+
+import qualified Network.Mattermost.Endpoints as MM
+import qualified Network.Mattermost.Exceptions as MM
+import qualified Network.Mattermost.Types as MM
+
+import           Matterhorn.Connection ( connectWebsockets )
+import           Matterhorn.Constants ( userSigil, normalChannelSigil )
+import           Matterhorn.HelpTopics
+import           Matterhorn.Scripts
+import           Matterhorn.State.Help
+import           Matterhorn.State.Editing
+import           Matterhorn.State.Channels
+import           Matterhorn.State.ChannelTopicWindow
+import           Matterhorn.State.ChannelSelect
+import           Matterhorn.State.Logging
+import           Matterhorn.State.PostListOverlay
+import           Matterhorn.State.UserListOverlay
+import           Matterhorn.State.ChannelListOverlay
+import           Matterhorn.State.ThemeListOverlay
+import           Matterhorn.State.Messages
+import           Matterhorn.State.NotifyPrefs
+import           Matterhorn.State.Common ( postInfoMessage )
+import           Matterhorn.State.Users
+import           Matterhorn.Themes ( attrForUsername )
+import           Matterhorn.Types
+
+
+-- | This function skips any initial whitespace and returns the first
+-- 'token' (i.e. any sequence of non-whitespace characters) as well as
+-- the trailing rest of the string, after any whitespace. This is used
+-- for tokenizing the first bits of command input while leaving the
+-- subsequent chunks unchanged, preserving newlines and other
+-- important formatting.
+unwordHead :: Text -> Maybe (Text, Text)
+unwordHead t =
+  let t' = T.dropWhile Char.isSpace t
+      (w, rs)  = T.break Char.isSpace t'
+  in if T.null w
+       then Nothing
+       else Just (w, T.dropWhile Char.isSpace rs)
+
+printArgSpec :: CmdArgs a -> Text
+printArgSpec NoArg = ""
+printArgSpec (LineArg ts) = "<" <> ts <> ">"
+printArgSpec (TokenArg t NoArg) = "<" <> t <> ">"
+printArgSpec (UserArg rs) = "<" <> userSigil <> "user>" <> addSpace (printArgSpec rs)
+printArgSpec (ChannelArg rs) = "<" <> normalChannelSigil <> "channel>" <> addSpace (printArgSpec rs)
+printArgSpec (TokenArg t rs) = "<" <> t <> ">" <> addSpace (printArgSpec rs)
+
+addSpace :: Text -> Text
+addSpace "" = ""
+addSpace t = " " <> t
+
+matchArgs :: CmdArgs a -> Text -> Either Text a
+matchArgs NoArg t = case unwordHead t of
+  Nothing -> return ()
+  Just (a, as)
+    | not (T.all Char.isSpace as) -> Left ("Unexpected arguments '" <> t <> "'")
+    | otherwise -> Left ("Unexpected argument '" <> a <> "'")
+matchArgs (LineArg _) t = return t
+matchArgs spec@(UserArg rs) t = case unwordHead t of
+  Nothing -> case rs of
+    NoArg -> Left ("Missing argument: " <> printArgSpec spec)
+    _     -> Left ("Missing arguments: " <> printArgSpec spec)
+  Just (a, as) -> (,) <$> pure a <*> matchArgs rs as
+matchArgs spec@(ChannelArg rs) t = case unwordHead t of
+  Nothing -> case rs of
+    NoArg -> Left ("Missing argument: " <> printArgSpec spec)
+    _     -> Left ("Missing arguments: " <> printArgSpec spec)
+  Just (a, as) -> (,) <$> pure a <*> matchArgs rs as
+matchArgs spec@(TokenArg _ rs) t = case unwordHead t of
+  Nothing -> case rs of
+    NoArg -> Left ("Missing argument: " <> printArgSpec spec)
+    _     -> Left ("Missing arguments: " <> printArgSpec spec)
+  Just (a, as) -> (,) <$> pure a <*> matchArgs rs as
+
+commandList :: [Cmd]
+commandList =
+  [ Cmd "quit" "Exit Matterhorn" NoArg $ \ () -> requestQuit
+
+  , Cmd "right" "Focus on the next channel" NoArg $ \ () ->
+      nextChannel
+
+  , Cmd "left" "Focus on the previous channel" NoArg $ \ () ->
+      prevChannel
+
+  , Cmd "create-channel" "Create a new public channel"
+    (LineArg "channel name") $ \ name ->
+      createOrdinaryChannel True name
+
+  , Cmd "create-private-channel" "Create a new private channel"
+    (LineArg "channel name") $ \ name ->
+      createOrdinaryChannel False name
+
+  , Cmd "delete-channel" "Delete the current channel"
+    NoArg $ \ () ->
+      beginCurrentChannelDeleteConfirm
+
+  , Cmd "hide" "Hide the current DM or group channel from the channel list"
+    NoArg $ \ () -> do
+      hideDMChannel =<< use csCurrentChannelId
+
+  , Cmd "reconnect" "Force a reconnection attempt to the server"
+    NoArg $ \ () ->
+      connectWebsockets
+
+  , Cmd "members" "Show the current channel's members"
+    NoArg $ \ () ->
+      enterChannelMembersUserList
+
+  , Cmd "leave" "Leave a normal channel or hide a DM channel" NoArg $ \ () ->
+      startLeaveCurrentChannel
+
+  , Cmd "join" "Find a channel to join" NoArg $ \ () ->
+      enterChannelListOverlayMode
+
+  , Cmd "join" "Join the specified channel" (ChannelArg NoArg) $ \(n, ()) ->
+      joinChannelByName n
+
+  , Cmd "theme" "List the available themes" NoArg $ \ () ->
+      enterThemeListMode
+
+  , Cmd "theme" "Set the color theme"
+    (TokenArg "theme" NoArg) $ \ (themeName, ()) ->
+      setTheme themeName
+
+  , Cmd "topic" "Set the current channel's topic (header) interactively"
+    NoArg $ \ () ->
+      openChannelTopicWindow
+
+  , Cmd "topic" "Set the current channel's topic (header)"
+    (LineArg "topic") $ \ p ->
+      if not (T.null p) then setChannelTopic p else return ()
+
+  , Cmd "add-user" "Search for a user to add to the current channel"
+    NoArg $ \ () ->
+        enterChannelInviteUserList
+
+  , Cmd "msg" "Search for a user to enter a private chat"
+    NoArg $ \ () ->
+        enterDMSearchUserList
+
+  , Cmd "msg" "Chat with the specified user"
+    (UserArg NoArg) $ \ (name, ()) ->
+        changeChannelByName name
+
+  , Cmd "username-attribute" "Display the attribute used to color the specified username"
+    (UserArg NoArg) $ \ (name, ()) ->
+        displayUsernameAttribute name
+
+  , Cmd "msg" "Go to a user's channel and send the specified message or command"
+    (UserArg $ LineArg "message or command") $ \ (name, msg) -> do
+        withFetchedUserMaybe (UserFetchByUsername name) $ \foundUser -> do
+            case foundUser of
+                Just user -> createOrFocusDMChannel user $ Just $ \cId ->
+                    handleInputSubmission cId msg
+                Nothing -> mhError $ NoSuchUser name
+
+  , Cmd "log-start" "Begin logging debug information to the specified path"
+    (TokenArg "path" NoArg) $ \ (path, ()) ->
+        startLogging $ T.unpack path
+
+  , Cmd "log-snapshot" "Dump the current debug log buffer to the specified path"
+    (TokenArg "path" NoArg) $ \ (path, ()) ->
+        logSnapshot $ T.unpack path
+
+  , Cmd "log-stop" "Stop logging"
+    NoArg $ \ () ->
+        stopLogging
+
+  , Cmd "log-mark" "Add a custom marker message to the Matterhorn debug log"
+    (LineArg "message") $ \ markMsg ->
+        mhLog LogUserMark markMsg
+
+  , Cmd "log-status" "Show current debug logging status"
+    NoArg $ \ () ->
+        getLogDestination
+
+  , Cmd "add-user" "Add a user to the current channel"
+    (UserArg NoArg) $ \ (uname, ()) ->
+        addUserByNameToCurrentChannel uname
+
+  , Cmd "remove-user" "Remove a user from the current channel"
+    (UserArg NoArg) $ \ (uname, ()) ->
+        removeUserFromCurrentChannel uname
+
+  , Cmd "user" "Show users to initiate a private DM chat channel"
+    -- n.b. this is identical to "msg", but is provided as an
+    -- alternative mental model for useability.
+    NoArg $ \ () ->
+        enterDMSearchUserList
+
+  , Cmd "message-preview" "Toggle preview of the current message" NoArg $ \_ ->
+        toggleMessagePreview
+
+  , Cmd "toggle-channel-list" "Toggle channel list visibility" NoArg $ \_ ->
+        toggleChannelListVisibility
+
+  , Cmd "toggle-message-timestamps" "Toggle message timestamps" NoArg $ \_ ->
+        toggleMessageTimestamps
+
+  , Cmd "toggle-expanded-topics" "Toggle expanded channel topics" NoArg $ \_ ->
+        toggleExpandedChannelTopics
+
+  , Cmd "focus" "Focus on a channel or user"
+    (ChannelArg NoArg) $ \ (name, ()) ->
+        changeChannelByName name
+
+  , Cmd "focus" "Select from available channels" NoArg $ \ () ->
+        beginChannelSelect
+
+  , Cmd "help" "Show the main help screen" NoArg $ \ _ ->
+        showHelpScreen mainHelpTopic
+
+  , Cmd "shortcuts" "Show keyboard shortcuts" NoArg $ \ _ ->
+        showHelpScreen mainHelpTopic
+
+  , Cmd "help" "Show help about a particular topic"
+      (TokenArg "topic" NoArg) $ \ (topicName, ()) ->
+          case lookupHelpTopic topicName of
+              Nothing -> mhError $ NoSuchHelpTopic topicName
+              Just topic -> showHelpScreen topic
+
+  , Cmd "sh" "List the available shell scripts" NoArg $ \ () ->
+      listScripts
+
+  , Cmd "group-msg" "Create a group chat"
+    (LineArg (userSigil <> "user [" <> userSigil <> "user ...]")) createGroupChannel
+
+  , Cmd "sh" "Run a prewritten shell script"
+    (TokenArg "script" (LineArg "message")) $ \ (script, text) -> do
+        cId <- use csCurrentChannelId
+        findAndRunScript cId script text
+
+  , Cmd "flags" "Open a window of your flagged posts" NoArg $ \ () ->
+      enterFlaggedPostListMode
+
+  , Cmd "pinned-posts" "Open a window of this channel's pinned posts" NoArg $ \ () ->
+      enterPinnedPostListMode
+
+  , Cmd "search" "Search for posts with given terms" (LineArg "terms") $
+      enterSearchResultPostListMode
+
+  , Cmd "notify-prefs" "Edit the current channel's notification preferences" NoArg $ \_ ->
+          enterEditNotifyPrefsMode
+
+  ]
+
+displayUsernameAttribute :: Text -> MH ()
+displayUsernameAttribute name = do
+    let an = attrForUsername trimmed
+        trimmed = trimUserSigil name
+    postInfoMessage $ "The attribute used for " <> userSigil <> trimmed <>
+                      " is " <> (attrNameToConfig an)
+
+execMMCommand :: Text -> Text -> MH ()
+execMMCommand name rest = do
+  cId      <- use csCurrentChannelId
+  session  <- getSession
+  em       <- use (csEditState.cedEditMode)
+  tId      <- gets myTeamId
+  let mc = MM.MinCommand
+             { MM.minComChannelId = cId
+             , MM.minComCommand   = "/" <> name <> " " <> rest
+             , MM.minComParentId  = case em of
+                 Replying _ p -> Just $ MM.getId p
+                 Editing p _  -> MM.postRootId p
+                 _            -> Nothing
+             , MM.minComRootId  = case em of
+                 Replying _ p -> MM.postRootId p <|> (Just $ MM.postId p)
+                 Editing p _  -> MM.postRootId p
+                 _            -> Nothing
+             , MM.minComTeamId = tId
+             }
+      runCmd = liftIO $ do
+        void $ MM.mmExecuteCommand mc session
+      handleHTTP (MM.HTTPResponseException err) =
+        return (Just (T.pack err))
+        -- XXX: this might be a bit brittle in the future, because it
+        -- assumes the shape of an error message. We might want to
+        -- think about a better way of discovering this error and
+        -- reporting it accordingly?
+      handleCmdErr (MM.MattermostServerError err) =
+        let (_, msg) = T.breakOn ": " err in
+          return (Just (T.drop 2 msg))
+      handleMMErr (MM.MattermostError
+                     { MM.mattermostErrorMessage = msg }) =
+        return (Just msg)
+  errMsg <- liftIO $ (runCmd >> return Nothing) `Exn.catch` handleHTTP
+                                                `Exn.catch` handleCmdErr
+                                                `Exn.catch` handleMMErr
+  case errMsg of
+    Nothing -> return ()
+    Just err ->
+      mhError $ GenericError ("Error running command: " <> err)
+
+dispatchCommand :: Text -> MH ()
+dispatchCommand cmd =
+  case unwordHead cmd of
+    Just (x, xs)
+      | matchingCmds <- [ c
+                        | c@(Cmd name _ _ _) <- commandList
+                        , name == x
+                        ] -> go [] matchingCmds
+      where go [] [] = do
+              execMMCommand x xs
+            go errs [] = do
+              let msg = ("error running command /" <> x <> ":\n" <>
+                         mconcat [ "    " <> e | e <- errs ])
+              mhError $ GenericError msg
+            go errs (Cmd _ _ spec exe : cs) =
+              case matchArgs spec xs of
+                Left e -> go (e:errs) cs
+                Right args -> exe args
+    _ -> return ()
+
+toggleMessagePreview :: MH ()
+toggleMessagePreview = do
+    mh invalidateCache
+    csShowMessagePreview %= not
diff --git a/src/Matterhorn/Command.hs-boot b/src/Matterhorn/Command.hs-boot
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Command.hs-boot
@@ -0,0 +1,9 @@
+module Matterhorn.Command where
+
+import Data.Text ( Text )
+
+import Matterhorn.Types ( MH, Cmd, CmdArgs )
+
+commandList :: [Cmd]
+printArgSpec :: CmdArgs a -> Text
+dispatchCommand :: Text -> MH ()
diff --git a/src/Matterhorn/Config.hs b/src/Matterhorn/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Config.hs
@@ -0,0 +1,365 @@
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TupleSections #-}
+{-# LANGUAGE MultiWayIf #-}
+module Matterhorn.Config
+  ( Config(..)
+  , PasswordSource(..)
+  , findConfig
+  , defaultConfig
+  , configConnectionType
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Control.Monad.Trans.Except
+import           Control.Monad.Trans.Class ( lift )
+import           Data.Char ( isDigit, isAlpha )
+import           Data.List ( isPrefixOf )
+import           Data.List.Split ( splitOn )
+import qualified Data.Map.Strict as M
+import qualified Data.Text as T
+import qualified Data.Text.IO as T
+import           System.Directory ( makeAbsolute, getHomeDirectory )
+import           System.Environment ( getExecutablePath )
+import           System.FilePath ( (</>), takeDirectory, splitPath, joinPath )
+import           System.Process ( readProcess )
+import           Network.Mattermost.Types (ConnectionType(..))
+import           Network.URI ( isIPv4address, isIPv6address )
+
+import           Matterhorn.Config.Schema
+import           Matterhorn.FilePaths
+import           Matterhorn.IOUtil
+import           Matterhorn.Types
+import           Matterhorn.Types.KeyEvents
+
+
+defaultPort :: Int
+defaultPort = 443
+
+bundledSyntaxPlaceholderName :: String
+bundledSyntaxPlaceholderName = "BUNDLED_SYNTAX"
+
+userSyntaxPlaceholderName :: String
+userSyntaxPlaceholderName = "USER_SYNTAX"
+
+defaultSkylightingPaths :: IO [FilePath]
+defaultSkylightingPaths = do
+    xdg <- xdgSyntaxDir
+    adjacent <- getBundledSyntaxPath
+    return [xdg, adjacent]
+
+getBundledSyntaxPath :: IO FilePath
+getBundledSyntaxPath = do
+    selfPath <- getExecutablePath
+    let distDir = "dist-newstyle/"
+        pathBits = splitPath selfPath
+
+    return $ if distDir `elem` pathBits
+             then
+                 -- We're in development, so use the development
+                 -- executable path to locate the XML path in the
+                 -- development tree.
+                 (joinPath $ takeWhile (/= distDir) pathBits) </> syntaxDirName
+             else
+                 -- In this case we assume the binary is being run from
+                 -- a release, in which case the syntax directory is a
+                 -- sibling of the executable path.
+                 takeDirectory selfPath </> syntaxDirName
+
+fromIni :: IniParser Config
+fromIni = do
+  conf <- section "mattermost" $ do
+    configUser           <- fieldMbOf "user" stringField
+    configHost           <- fieldMbOf "host" hostField
+    configTeam           <- fieldMbOf "team" stringField
+    configPort           <- fieldDefOf "port" number (configPort defaultConfig)
+    configUrlPath        <- fieldMbOf "urlPath" stringField
+    configChannelListWidth <- fieldDefOf "channelListWidth" number
+                              (configChannelListWidth defaultConfig)
+    configCpuUsagePolicy <- fieldDefOf "cpuUsagePolicy" cpuUsagePolicy
+                            (configCpuUsagePolicy defaultConfig)
+    configLogMaxBufferSize <- fieldDefOf "logMaxBufferSize" number
+                              (configLogMaxBufferSize defaultConfig)
+    configTimeFormat     <- fieldMbOf "timeFormat" stringField
+    configDateFormat     <- fieldMbOf "dateFormat" stringField
+    configTheme          <- fieldMbOf "theme" stringField
+    configThemeCustomizationFile <- fieldMbOf "themeCustomizationFile" stringField
+    configAspellDictionary <- fieldMbOf "aspellDictionary" stringField
+    configURLOpenCommand <- fieldMbOf "urlOpenCommand" stringField
+    configURLOpenCommandInteractive <- fieldFlagDef "urlOpenCommandIsInteractive" False
+    configSmartBacktick  <- fieldFlagDef "smartbacktick"
+      (configSmartBacktick defaultConfig)
+    configSmartEditing <- fieldFlagDef "smartediting"
+      (configSmartEditing defaultConfig)
+    configShowOlderEdits <- fieldFlagDef "showOlderEdits"
+      (configShowOlderEdits defaultConfig)
+    configShowBackground <- fieldDefOf "showBackgroundActivity" backgroundField
+      (configShowBackground defaultConfig)
+    configShowMessagePreview <- fieldFlagDef "showMessagePreview"
+      (configShowMessagePreview defaultConfig)
+    configShowChannelList <- fieldFlagDef "showChannelList"
+      (configShowChannelList defaultConfig)
+    configShowExpandedChannelTopics <- fieldFlagDef "showExpandedChannelTopics"
+      (configShowExpandedChannelTopics defaultConfig)
+    configShowTypingIndicator <- fieldFlagDef "showTypingIndicator"
+      (configShowTypingIndicator defaultConfig)
+    configEnableAspell <- fieldFlagDef "enableAspell"
+      (configEnableAspell defaultConfig)
+    configSyntaxDirs <- fieldDefOf "syntaxDirectories" syntaxDirsField []
+    configActivityNotifyCommand <- fieldMb "activityNotifyCommand"
+    configShowMessageTimestamps <- fieldFlagDef "showMessageTimestamps"
+      (configShowMessageTimestamps defaultConfig)
+    configActivityBell <- fieldFlagDef "activityBell"
+      (configActivityBell defaultConfig)
+    configHyperlinkingMode <- fieldFlagDef "hyperlinkURLs"
+      (configHyperlinkingMode defaultConfig)
+    configPass <- (Just . PasswordCommand <$> field "passcmd") <!>
+                  (Just . PasswordString  <$> field "pass") <!>
+                  pure Nothing
+    configChannelListOrientation <- fieldDefOf "channelListOrientation"
+        channelListOrientationField
+        (configChannelListOrientation defaultConfig)
+    configToken <- (Just . TokenCommand  <$> field "tokencmd") <!>
+                  pure Nothing
+    configUnsafeUseHTTP <-
+      fieldFlagDef "unsafeUseUnauthenticatedConnection" False
+    configValidateServerCertificate <-
+      fieldFlagDef "validateServerCertificate" True
+    configDirectChannelExpirationDays <- fieldDefOf "directChannelExpirationDays" number
+      (configDirectChannelExpirationDays defaultConfig)
+    configDefaultAttachmentPath <- fieldMbOf "defaultAttachmentPath" filePathField
+
+    let configAbsPath = Nothing
+        configUserKeys = mempty
+    return Config { .. }
+  keys <- sectionMb "keybindings" $ do
+      fmap (M.fromList . catMaybes) $ forM allEvents $ \ ev -> do
+          kb <- fieldMbOf (keyEventName ev) parseBindingList
+          case kb of
+              Nothing      -> return Nothing
+              Just binding -> return (Just (ev, binding))
+  return conf { configUserKeys = fromMaybe mempty keys }
+
+channelListOrientationField :: Text -> Either String ChannelListOrientation
+channelListOrientationField t =
+    case T.toLower t of
+        "left" -> return ChannelListLeft
+        "right" -> return ChannelListRight
+        _ -> Left $ "Invalid value for channelListOrientation: " <> show t
+
+syntaxDirsField :: Text -> Either String [FilePath]
+syntaxDirsField = listWithSeparator ":" string
+
+validHostnameFragmentChar :: Char -> Bool
+validHostnameFragmentChar c = isAlpha c || isDigit c || c == '-'
+
+isHostnameFragment :: String -> Bool
+isHostnameFragment "" = False
+isHostnameFragment s = all validHostnameFragmentChar s
+
+isHostname :: String -> Bool
+isHostname "" = False
+isHostname s =
+    let parts@(h:_) = splitOn "." s
+    in all isHostnameFragment parts && not ("-" `isPrefixOf` h)
+
+hostField :: Text -> Either String Text
+hostField t =
+    let s = T.unpack t
+        valid = or [ isIPv4address s
+                   , isIPv6address s
+                   , isHostname s
+                   ]
+    in if valid
+       then Right t
+       else Left "Invalid 'host' value, must be a hostname or IPv4/IPv6 address"
+
+expandTilde :: FilePath -> FilePath -> FilePath
+expandTilde homeDir p =
+    let parts = splitPath p
+        f part | part == "~/" = homeDir <> "/"
+               | otherwise    = part
+    in joinPath $ f <$> parts
+
+backgroundField :: Text -> Either String BackgroundInfo
+backgroundField t =
+    case t of
+        "Disabled" -> Right Disabled
+        "Active" -> Right Active
+        "ActiveCount" -> Right ActiveCount
+        _ -> Left ("Invalid value " <> show t
+                  <> "; must be one of: Disabled, Active, ActiveCount")
+
+cpuUsagePolicy :: Text -> Either String CPUUsagePolicy
+cpuUsagePolicy t =
+    case T.toLower t of
+        "single" -> return SingleCPU
+        "multiple" -> return MultipleCPUs
+        _ -> Left $ "Invalid CPU usage policy value: " <> show t
+
+stringField :: Text -> Either e Text
+stringField t =
+    case isQuoted t of
+        True -> Right $ parseQuotedString t
+        False -> Right t
+
+filePathField :: Text -> Either e FilePath
+filePathField t = let path = T.unpack t in Right path
+
+parseQuotedString :: Text -> Text
+parseQuotedString t =
+    let body = T.drop 1 $ T.init t
+        unescapeQuotes s | T.null s = s
+                         | "\\\"" `T.isPrefixOf` s = "\"" <> unescapeQuotes (T.drop 2 s)
+                         | otherwise = (T.singleton $ T.head s) <> unescapeQuotes (T.drop 1 s)
+    in unescapeQuotes body
+
+isQuoted :: Text -> Bool
+isQuoted t =
+    let quote = "\""
+    in (quote `T.isPrefixOf` t) &&
+       (quote `T.isSuffixOf` t)
+
+defaultConfig :: Config
+defaultConfig =
+    Config { configAbsPath                     = Nothing
+           , configUser                        = Nothing
+           , configHost                        = Nothing
+           , configTeam                        = Nothing
+           , configPort                        = defaultPort
+           , configUrlPath                     = Nothing
+           , configPass                        = Nothing
+           , configToken                       = Nothing
+           , configTimeFormat                  = Nothing
+           , configDateFormat                  = Nothing
+           , configTheme                       = Nothing
+           , configThemeCustomizationFile      = Nothing
+           , configSmartBacktick               = True
+           , configSmartEditing                = True
+           , configURLOpenCommand              = Nothing
+           , configURLOpenCommandInteractive   = False
+           , configActivityNotifyCommand       = Nothing
+           , configActivityBell                = False
+           , configShowMessageTimestamps       = True
+           , configShowBackground              = Disabled
+           , configShowMessagePreview          = False
+           , configShowChannelList             = True
+           , configShowExpandedChannelTopics   = True
+           , configEnableAspell                = False
+           , configAspellDictionary            = Nothing
+           , configUnsafeUseHTTP               = False
+           , configValidateServerCertificate   = True
+           , configChannelListWidth            = 20
+           , configLogMaxBufferSize            = 200
+           , configShowOlderEdits              = True
+           , configUserKeys                    = mempty
+           , configShowTypingIndicator         = False
+           , configHyperlinkingMode            = True
+           , configSyntaxDirs                  = []
+           , configDirectChannelExpirationDays = 7
+           , configCpuUsagePolicy              = MultipleCPUs
+           , configDefaultAttachmentPath       = Nothing
+           , configChannelListOrientation      = ChannelListLeft
+           }
+
+findConfig :: Maybe FilePath -> IO (Either String ([String], Config))
+findConfig Nothing = runExceptT $ do
+    cfg <- lift $ locateConfig configFileName
+    (warns, config) <-
+      case cfg of
+        Nothing -> return ([], defaultConfig)
+        Just path -> getConfig path
+    config' <- fixupPaths config
+    return (warns, config')
+findConfig (Just path) =
+    runExceptT $ do (warns, config) <- getConfig path
+                    config' <- fixupPaths config
+                    return (warns, config')
+
+-- | Fix path references in the configuration:
+--
+-- * Rewrite the syntax directory path list with 'fixupSyntaxDirs'
+-- * Expand "~" encountered in any setting that contains a path value
+fixupPaths :: Config -> ExceptT String IO Config
+fixupPaths initial = do
+    new <- fixupSyntaxDirs initial
+    homeDir <- liftIO getHomeDirectory
+    let fixP = expandTilde homeDir
+        fixPText = T.pack . expandTilde homeDir . T.unpack
+    return $ new { configThemeCustomizationFile = fixPText <$> configThemeCustomizationFile new
+                 , configSyntaxDirs             = fixP <$> configSyntaxDirs new
+                 , configURLOpenCommand         = fixPText <$> configURLOpenCommand new
+                 , configActivityNotifyCommand  = fixPText <$> configActivityNotifyCommand new
+                 , configDefaultAttachmentPath  = fixP <$> configDefaultAttachmentPath new
+                 }
+
+-- | If the configuration has no syntax directories specified (the
+-- default if the user did not provide the setting), fill in the
+-- list with the defaults. Otherwise replace any bundled directory
+-- placeholders in the config's syntax path list.
+fixupSyntaxDirs :: Config -> ExceptT String IO Config
+fixupSyntaxDirs c =
+    if configSyntaxDirs c == []
+    then do
+        dirs <- liftIO defaultSkylightingPaths
+        return $ c { configSyntaxDirs = dirs }
+    else do
+        newDirs <- forM (configSyntaxDirs c) $ \dir ->
+            if | dir == bundledSyntaxPlaceholderName -> liftIO getBundledSyntaxPath
+               | dir == userSyntaxPlaceholderName    -> liftIO xdgSyntaxDir
+               | otherwise                           -> return dir
+
+        return $ c { configSyntaxDirs = newDirs }
+
+getConfig :: FilePath -> ExceptT String IO ([String], Config)
+getConfig fp = do
+    absPath <- convertIOException $ makeAbsolute fp
+    t <- (convertIOException $ T.readFile absPath) `catchE`
+         (\e -> throwE $ "Could not read " <> show absPath <> ": " <> e)
+
+    -- HACK ALERT FIXME:
+    --
+    -- The config parser library we use, config-ini (as of 0.2.4.0)
+    -- cannot handle configuration files without trailing newlines.
+    -- Since that's not a really good reason for this function to raise
+    -- an exception (and is fixable on the fly), we have the following
+    -- check. This check is admittedly not a great thing to have to do,
+    -- and we should definitely get rid of it when config-ini fixes this
+    -- issue.
+    let t' = if "\n" `T.isSuffixOf` t then t else t <> "\n"
+
+    case parseIniFile t' fromIni of
+        Left err -> do
+            throwE $ "Unable to parse " ++ absPath ++ ":" ++ fatalString err
+        Right (warns, conf) -> do
+            actualPass <- case configPass conf of
+                Just (PasswordCommand cmdString) -> do
+                    let (cmd:rest) = T.unpack <$> T.words cmdString
+                    output <- convertIOException (readProcess cmd rest "") `catchE`
+                              (\e -> throwE $ "Could not execute password command: " <> e)
+                    return $ Just $ T.pack (takeWhile (/= '\n') output)
+                Just (PasswordString pass) -> return $ Just pass
+                Nothing -> return Nothing
+
+            actualToken <- case configToken conf of
+                Just (TokenCommand cmdString) -> do
+                    let (cmd:rest) = T.unpack <$> T.words cmdString
+                    output <- convertIOException (readProcess cmd rest "") `catchE`
+                              (\e -> throwE $ "Could not execute token command: " <> e)
+                    return $ Just $ T.pack (takeWhile (/= '\n') output)
+                Just (TokenString _) -> error $ "BUG: getConfig: token in the Config was already a TokenString"
+                Nothing -> return Nothing
+
+            let conf' = conf
+                  { configPass = PasswordString <$> actualPass
+                  , configToken = TokenString <$> actualToken
+                  , configAbsPath = Just absPath
+                  }
+            return (map warningString warns, conf')
+
+configConnectionType :: Config -> ConnectionType
+configConnectionType config
+  | configUnsafeUseHTTP config = ConnectHTTP
+  | otherwise = ConnectHTTPS (configValidateServerCertificate config)
diff --git a/src/Matterhorn/Config/Schema.hs b/src/Matterhorn/Config/Schema.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Config/Schema.hs
@@ -0,0 +1,203 @@
+{- |
+
+This module provides an INI schema validator that is able to track unused
+sections and fields in order to report warning messages to the user.
+
+
+ -}
+module Matterhorn.Config.Schema
+  ( IniParser
+  , parseIniFile
+  , (<!>)
+
+  , Fatal(..)
+  , fatalString
+
+  , Warning(..)
+  , warningString
+
+  , section
+  , sectionMb
+  , fieldMbOf
+  , fieldMb
+  , field
+  , fieldDefOf
+  , fieldFlagDef
+
+  -- * Re-exports
+  , number
+  , string
+  , listWithSeparator
+  ) where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Data.Map (Map)
+import qualified Data.Map as Map
+import           Data.List.NonEmpty (NonEmpty)
+import qualified Data.List.NonEmpty as NonEmpty
+import qualified Data.Text as T
+import           Control.Monad
+import           Data.Ini.Config (flag, number, listWithSeparator, string)
+import           Data.Ini.Config.Raw
+
+
+------------------------------------------------------------------------
+
+newtype Parser e t a = Parser { unParser :: e -> Map NormalizedText (NonEmpty t) -> Either Fatal (Map NormalizedText (NonEmpty t), [Warning], a) }
+
+instance Functor (Parser e t) where
+  fmap = liftM
+
+instance Applicative (Parser e t) where
+  pure x = Parser $ \_ s -> Right (s, [], x)
+  (<*>) = ap
+
+instance Monad (Parser e t) where
+  m >>= f = Parser $ \e s0 ->
+              do (s1, ws1, x1) <- unParser m e s0
+                 (s2, ws2, x2) <- unParser (f x1) e s1
+                 Right (s2, ws1++ws2, x2)
+
+
+
+(<!>) :: Parser e t a -> Parser e t a -> Parser e t a
+p <!> q = Parser $ \e s ->
+  case unParser p e s of
+    Right r -> Right r
+    Left {} -> unParser q e s
+
+getenv :: Parser e t e
+getenv = Parser $ \e s -> Right (s, [], e)
+
+request :: Text -> Parser e t (Maybe t)
+request name = Parser $ \_ s ->
+  let name' = normalize name in
+  Right $!
+  case Map.lookup name' s of
+    Nothing                 -> (s , [], Nothing)
+    Just (x NonEmpty.:| xs) -> (s', [], Just x)
+      where
+        s' = case NonEmpty.nonEmpty xs of
+               Nothing -> Map.delete name' s
+               Just ne -> Map.insert name' ne s
+
+fatal :: Fatal -> Parser e t a
+fatal e = Parser $ \_ _ -> Left e
+
+warnings :: [Warning] -> IniParser ()
+warnings ws = Parser $ \_ s -> Right (s, ws, ())
+
+------------------------------------------------------------------------
+
+type IniParser = Parser RawIni IniSection
+
+type SectionParser = Parser IniSection IniValue
+
+data Fatal
+  = NoSection Text
+  | MissingField IniSection Text
+  | BadField IniSection IniValue String
+  | ParseError String
+  deriving Show
+
+data Warning
+  = UnusedSection IniSection
+  | UnusedField IniSection IniValue
+  deriving Show
+
+------------------------------------------------------------------------
+
+fatalString :: Fatal -> String
+fatalString (NoSection name) = "No top-level section named " ++ show name
+fatalString (MissingField sec name) = "Missing field " ++ show name ++ " in section " ++ show (isName sec)
+fatalString (BadField sec val err) =
+  "Line " ++ show (vLineNo val) ++
+  " in section " ++ show (isName sec) ++
+  ": " ++ err
+fatalString (ParseError err) = err
+
+warningString :: Warning -> String
+warningString (UnusedSection sec) = "Unused section " ++ show (isName sec)
+warningString (UnusedField sec val) =
+  "Line " ++ show (vLineNo val) ++
+  " in section " ++ show (isName sec) ++
+  ": unused field"
+
+------------------------------------------------------------------------
+
+parseIniFile :: Text -> IniParser a -> Either Fatal ([Warning], a)
+parseIniFile text parser =
+  case parseRawIni text of
+    Left e -> Left (ParseError e)
+    Right ini ->
+      let entries = Map.fromListWith (<>)
+                       [ (k, pure v) | (k,v) <- toList (fromRawIni ini) ]
+      in
+      case unParser parser ini entries of
+        Left e -> Left e
+        Right (entries', ws, x) -> Right (ws ++ unused, x)
+          where
+            unused = [ UnusedSection e | e <- concatMap toList entries' ]
+
+------------------------------------------------------------------------
+
+section :: Text -> SectionParser a -> IniParser a
+section name parser =
+  do mb <- sectionMb name parser
+     case mb of
+       Nothing -> fatal (NoSection name)
+       Just x  -> pure x
+
+sectionMb :: Text -> SectionParser a -> IniParser (Maybe a)
+sectionMb name parser =
+  do mb <- request name
+     case mb of
+       Nothing -> pure Nothing
+       Just sec ->
+         let entries = Map.fromListWith (<>)
+                       [ (k, pure v) | (k,v) <- toList (isVals sec) ]
+         in
+         case unParser parser sec entries of
+           Left e -> fatal e
+           Right (entries', ws, result) ->
+             do warnings (ws ++ [ UnusedField sec v | v <- concatMap toList entries' ])
+                pure (Just result)
+
+------------------------------------------------------------------------
+
+field :: Text -> SectionParser Text
+field name =
+  do mb <- fieldMb name
+     case mb of
+       Just x -> pure x
+       Nothing ->
+         do s <- getenv
+            fatal (MissingField s name)
+
+fieldMbOf :: Text -> (Text -> Either String a) -> SectionParser (Maybe a)
+fieldMbOf name validate =
+  do mb <- request name
+     case mb of
+       Nothing -> pure Nothing
+       Just val ->
+         case validate (getVal val) of
+           Left e ->
+             do sec <- getenv
+                fatal (BadField sec val e)
+           Right x -> pure (Just x)
+
+fieldMb :: Text -> SectionParser (Maybe Text)
+fieldMb name = fmap getVal <$> request name
+
+fieldDefOf :: Text -> (Text -> Either String a) -> a -> SectionParser a
+fieldDefOf name validate def = fromMaybe def <$> fieldMbOf name validate
+
+fieldFlagDef :: Text -> Bool -> SectionParser Bool
+fieldFlagDef name def = fieldDefOf name flag def
+
+------------------------------------------------------------------------
+
+getVal :: IniValue -> Text
+getVal = T.strip . vValue
diff --git a/src/Matterhorn/Connection.hs b/src/Matterhorn/Connection.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Connection.hs
@@ -0,0 +1,104 @@
+module Matterhorn.Connection where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Control.Concurrent ( forkIO, threadDelay, killThread )
+import qualified Control.Concurrent.STM as STM
+import           Control.Exception ( SomeException, catch, AsyncException(..), throwIO )
+import qualified Data.HashMap.Strict as HM
+import           Data.Int (Int64)
+import           Data.Semigroup ( Max(..) )
+import qualified Data.Text as T
+import           Data.Time ( UTCTime(..), secondsToDiffTime, getCurrentTime
+                           , diffUTCTime )
+import           Data.Time.Calendar ( Day(..) )
+import           Lens.Micro.Platform ( (.=) )
+
+import           Network.Mattermost.Types ( ChannelId )
+import qualified Network.Mattermost.WebSocket as WS
+
+import           Matterhorn.Constants
+import           Matterhorn.Types
+
+
+connectWebsockets :: MH ()
+connectWebsockets = do
+  logger <- mhGetIOLogger
+
+  -- If we have an old websocket thread, kill it.
+  mOldTid <- use (csResources.crWebsocketThreadId)
+  case mOldTid of
+      Nothing -> return ()
+      Just oldTid -> liftIO $ do
+          logger LogWebsocket "Terminating previous websocket thread"
+          killThread oldTid
+
+  st <- use id
+  session <- getSession
+
+  tid <- liftIO $ do
+    let shunt (Left msg) = writeBChan (st^.csResources.crEventQueue) (WebsocketParseError msg)
+        shunt (Right (Right e)) = writeBChan (st^.csResources.crEventQueue) (WSEvent e)
+        shunt (Right (Left e)) = writeBChan (st^.csResources.crEventQueue) (WSActionResponse e)
+        runWS = WS.mmWithWebSocket session shunt $ \ws -> do
+                  writeBChan (st^.csResources.crEventQueue) WebsocketConnect
+                  processWebsocketActions st ws 1 HM.empty
+    logger LogWebsocket "Starting new websocket thread"
+    forkIO $ runWS `catch` ignoreThreadKilled
+                   `catch` handleTimeout logger 1 st
+                   `catch` handleError logger 5 st
+
+  csResources.crWebsocketThreadId .= Just tid
+
+ignoreThreadKilled :: AsyncException -> IO ()
+ignoreThreadKilled ThreadKilled = return ()
+ignoreThreadKilled e = throwIO e
+
+-- | Take websocket actions from the websocket action channel in the
+-- ChatState and send them to the server over the websocket.
+--
+-- Takes and propagates the action sequence number which is incremented
+-- for each successful send.
+--
+-- Keeps and propagates a map of channel id to last user_typing
+-- notification send time so that the new user_typing actions are
+-- throttled to be send only once in two seconds.
+processWebsocketActions :: ChatState -> WS.MMWebSocket -> Int64 -> HashMap ChannelId (Max UTCTime) -> IO ()
+processWebsocketActions st ws s userTypingLastNotifTimeMap = do
+  action <- STM.atomically $ STM.readTChan (st^.csResources.crWebsocketActionChan)
+  if (shouldSendAction action)
+    then do
+      WS.mmSendWSAction (st^.csResources.crConn) ws $ convert action
+      now <- getCurrentTime
+      processWebsocketActions st ws (s + 1) $ userTypingLastNotifTimeMap' action now
+    else do
+      processWebsocketActions st ws s userTypingLastNotifTimeMap
+  where
+    convert (UserTyping _ cId pId) = WS.UserTyping s cId pId
+
+    shouldSendAction (UserTyping ts cId _) =
+      diffUTCTime ts (userTypingLastNotifTime cId) >= (userTypingExpiryInterval / 2 - 0.5)
+
+    userTypingLastNotifTime cId = getMax $ HM.lookupDefault (Max zeroTime) cId userTypingLastNotifTimeMap
+
+    zeroTime = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime 0)
+
+    userTypingLastNotifTimeMap' (UserTyping _ cId _) now =
+      HM.insertWith (<>) cId (Max now) userTypingLastNotifTimeMap
+
+handleTimeout :: (LogCategory -> Text -> IO ()) -> Int -> ChatState -> WS.MMWebSocketTimeoutException -> IO ()
+handleTimeout logger seconds st e = do
+    logger LogWebsocket $ T.pack $ "Websocket timeout exception: " <> show e
+    reconnectAfter seconds st
+
+handleError :: (LogCategory -> Text -> IO ()) -> Int -> ChatState -> SomeException -> IO ()
+handleError logger seconds st e = do
+    logger LogWebsocket $ T.pack $ "Websocket error: " <> show e
+    reconnectAfter seconds st
+
+reconnectAfter :: Int -> ChatState -> IO ()
+reconnectAfter seconds st = do
+  writeBChan (st^.csResources.crEventQueue) WebsocketDisconnect
+  threadDelay (seconds * 1000 * 1000)
+  writeBChan (st^.csResources.crEventQueue) RefreshWebsocketEvent
diff --git a/src/Matterhorn/Constants.hs b/src/Matterhorn/Constants.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Constants.hs
@@ -0,0 +1,35 @@
+module Matterhorn.Constants
+  ( pageAmount
+  , userTypingExpiryInterval
+  , numScrollbackPosts
+  , previewMaxHeight
+  , normalChannelSigil
+  , userSigil
+  )
+where
+
+import Prelude ()
+import Matterhorn.Prelude
+
+
+-- | The number of rows to consider a "page" when scrolling
+pageAmount :: Int
+pageAmount = 15
+
+-- | The expiry interval in seconds for user typing notifications.
+userTypingExpiryInterval :: NominalDiffTime
+userTypingExpiryInterval = 5
+
+numScrollbackPosts :: Int
+numScrollbackPosts = 100
+
+-- | The maximum height of the message preview, in lines.
+previewMaxHeight :: Int
+previewMaxHeight = 5
+
+-- Sigils
+normalChannelSigil :: Text
+normalChannelSigil = "~"
+
+userSigil :: Text
+userSigil = "@"
diff --git a/src/Matterhorn/Draw.hs b/src/Matterhorn/Draw.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw.hs
@@ -0,0 +1,52 @@
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE PackageImports #-}
+module Matterhorn.Draw (draw) where
+
+import Prelude ()
+import Matterhorn.Prelude
+
+import Brick
+
+import Lens.Micro.Platform ( _2, singular, _Just )
+
+import Matterhorn.Draw.ChannelTopicWindow
+import Matterhorn.Draw.DeleteChannelConfirm
+import Matterhorn.Draw.LeaveChannelConfirm
+import Matterhorn.Draw.Main
+import Matterhorn.Draw.ThemeListOverlay
+import Matterhorn.Draw.PostListOverlay
+import Matterhorn.Draw.ShowHelp
+import Matterhorn.Draw.UserListOverlay
+import Matterhorn.Draw.ChannelListOverlay
+import Matterhorn.Draw.ReactionEmojiListOverlay
+import Matterhorn.Draw.TabbedWindow
+import Matterhorn.Draw.ManageAttachments
+import Matterhorn.Draw.NotifyPrefs
+import Matterhorn.Types
+
+
+draw :: ChatState -> [Widget Name]
+draw st =
+    case appMode st of
+        Main                         -> mainLayers
+        UrlSelect                    -> mainLayers
+        ChannelSelect                -> mainLayers
+        MessageSelect                -> mainLayers
+        MessageSelectDeleteConfirm   -> mainLayers
+        ShowHelp topic _             -> drawShowHelp topic st
+        ThemeListOverlay             -> drawThemeListOverlay st : mainLayers
+        LeaveChannelConfirm          -> drawLeaveChannelConfirm st : mainLayersMonochrome
+        DeleteChannelConfirm         -> drawDeleteChannelConfirm st : mainLayersMonochrome
+        PostListOverlay contents     -> drawPostListOverlay contents st : mainLayersMonochrome
+        UserListOverlay              -> drawUserListOverlay st : mainLayersMonochrome
+        ChannelListOverlay           -> drawChannelListOverlay st : mainLayersMonochrome
+        ReactionEmojiListOverlay     -> drawReactionEmojiListOverlay st : mainLayersMonochrome
+        ViewMessage                  -> drawTabbedWindow messageViewWindow st : mainLayersMonochrome
+        ManageAttachments            -> drawManageAttachments st : mainLayersMonochrome
+        ManageAttachmentsBrowseFiles -> drawManageAttachments st : mainLayersMonochrome
+        EditNotifyPrefs              -> drawNotifyPrefs st : mainLayersMonochrome
+        ChannelTopicWindow           -> drawChannelTopicWindow st : mainLayersMonochrome
+    where
+        mainLayers = drawMain True st
+        mainLayersMonochrome = drawMain False st
+        messageViewWindow = st^.csViewedMessage.singular _Just._2
diff --git a/src/Matterhorn/Draw/Autocomplete.hs b/src/Matterhorn/Draw/Autocomplete.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/Autocomplete.hs
@@ -0,0 +1,205 @@
+module Matterhorn.Draw.Autocomplete
+  ( autocompleteLayer
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Widgets.Border
+import           Brick.Widgets.List ( renderList, listElementsL, listSelectedFocusedAttr
+                                    , listSelectedElement
+                                    )
+import qualified Data.Text as T
+
+import           Network.Mattermost.Types ( User(..), Channel(..) )
+
+import           Matterhorn.Constants ( normalChannelSigil )
+import           Matterhorn.Draw.Util
+import           Matterhorn.Themes
+import           Matterhorn.Types
+import           Matterhorn.Types.Common ( sanitizeUserText )
+
+
+autocompleteLayer :: ChatState -> Widget Name
+autocompleteLayer st =
+    case st^.csEditState.cedAutocomplete of
+        Nothing ->
+            emptyWidget
+        Just ac ->
+            renderAutocompleteBox st ac
+
+userNotInChannelMarker :: T.Text
+userNotInChannelMarker = "*"
+
+elementTypeLabel :: AutocompletionType -> Text
+elementTypeLabel ACUsers = "Users"
+elementTypeLabel ACChannels = "Channels"
+elementTypeLabel ACCodeBlockLanguage = "Languages"
+elementTypeLabel ACEmoji = "Emoji"
+elementTypeLabel ACCommands = "Commands"
+
+renderAutocompleteBox :: ChatState -> AutocompleteState -> Widget Name
+renderAutocompleteBox st ac =
+    let matchList = _acCompletionList ac
+        maxListHeight = 5
+        visibleHeight = min maxListHeight numResults
+        numResults = length elements
+        elements = matchList^.listElementsL
+        label = withDefAttr clientMessageAttr $
+                txt $ elementTypeLabel (ac^.acType) <> ": " <> (T.pack $ show numResults) <>
+                     " match" <> (if numResults == 1 then "" else "es") <>
+                     " (Tab/Shift-Tab to select)"
+
+        selElem = snd <$> listSelectedElement matchList
+        curChan = st^.csCurrentChannel
+        footer = case renderAutocompleteFooterFor curChan =<< selElem of
+            Just w -> hBorderWithLabel w
+            _ -> hBorder
+        curUser = myUsername st
+
+    in if numResults == 0
+       then emptyWidget
+       else Widget Greedy Greedy $ do
+           ctx <- getContext
+           let rowOffset = ctx^.availHeightL - 3 - editorOffset - visibleHeight
+               editorOffset = if st^.csEditState.cedEphemeral.eesMultiline
+                              then multilineHeightLimit
+                              else 0
+           render $ translateBy (Location (0, rowOffset)) $
+                    vBox [ hBorderWithLabel label
+                         , vLimit visibleHeight $
+                           renderList (renderAutocompleteAlternative curUser) True matchList
+                         , footer
+                         ]
+
+renderAutocompleteFooterFor :: ClientChannel -> AutocompleteAlternative -> Maybe (Widget Name)
+renderAutocompleteFooterFor _ (SpecialMention MentionChannel) = Nothing
+renderAutocompleteFooterFor _ (SpecialMention MentionAll) = Nothing
+renderAutocompleteFooterFor ch (UserCompletion _ False) =
+    Just $ hBox [ txt $ "("
+                , withDefAttr clientEmphAttr (txt userNotInChannelMarker)
+                , txt ": not a member of "
+                , withDefAttr channelNameAttr (txt $ normalChannelSigil <> ch^.ccInfo.cdName)
+                , txt ")"
+                ]
+renderAutocompleteFooterFor _ (ChannelCompletion False ch) =
+    Just $ hBox [ txt $ "("
+                , withDefAttr clientEmphAttr (txt userNotInChannelMarker)
+                , txt ": you are not a member of "
+                , withDefAttr channelNameAttr (txt $ normalChannelSigil <> sanitizeUserText (channelName ch))
+                , txt ")"
+                ]
+renderAutocompleteFooterFor _ (CommandCompletion src _ _ _) =
+    case src of
+        Server ->
+            Just $ hBox [ txt $ "("
+                        , withDefAttr clientEmphAttr (txt serverCommandMarker)
+                        , txt ": command provided by the server)"
+                        ]
+        Client -> Nothing
+renderAutocompleteFooterFor _ _ =
+    Nothing
+
+serverCommandMarker :: Text
+serverCommandMarker = "*"
+
+renderAutocompleteAlternative :: Text -> Bool -> AutocompleteAlternative -> Widget Name
+renderAutocompleteAlternative _ sel (EmojiCompletion e) =
+    padRight Max $ renderEmojiCompletion sel e
+renderAutocompleteAlternative _ sel (SpecialMention m) =
+    padRight Max $ renderSpecialMention m sel
+renderAutocompleteAlternative curUser sel (UserCompletion u inChan) =
+    padRight Max $ renderUserCompletion curUser u inChan sel
+renderAutocompleteAlternative _ sel (ChannelCompletion inChan c) =
+    padRight Max $ renderChannelCompletion c inChan sel
+renderAutocompleteAlternative _ _ (SyntaxCompletion t) =
+    padRight Max $ txt t
+renderAutocompleteAlternative _ _ (CommandCompletion src n args desc) =
+    padRight Max $ renderCommandCompletion src n args desc
+
+renderSpecialMention :: SpecialMention -> Bool -> Widget Name
+renderSpecialMention m sel =
+    let usernameWidth = 18
+        padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')
+        maybeForce = if sel
+                     then forceAttr listSelectedFocusedAttr
+                     else id
+        t = autocompleteAlternativeReplacement $ SpecialMention m
+        desc = case m of
+            MentionChannel -> "Notifies all users in this channel"
+            MentionAll     -> "Mentions all users in this channel"
+    in maybeForce $
+       hBox [ txt "  "
+            , padTo usernameWidth $ withDefAttr clientEmphAttr $ txt t
+            , txt desc
+            ]
+
+renderEmojiCompletion :: Bool -> T.Text -> Widget Name
+renderEmojiCompletion sel e =
+    let maybeForce = if sel
+                     then forceAttr listSelectedFocusedAttr
+                     else id
+    in maybeForce $
+       padLeft (Pad 2) $
+       withDefAttr emojiAttr $
+       txt $
+       autocompleteAlternativeReplacement $ EmojiCompletion e
+
+renderUserCompletion :: Text -> User -> Bool -> Bool -> Widget Name
+renderUserCompletion curUser u inChan selected =
+    let usernameWidth = 18
+        fullNameWidth = 25
+        padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')
+        username = userUsername u
+        fullName = (sanitizeUserText $ userFirstName u) <> " " <>
+                   (sanitizeUserText $ userLastName u)
+        nickname = sanitizeUserText $ userNickname u
+        maybeForce = if selected
+                     then forceAttr listSelectedFocusedAttr
+                     else id
+        memberDisplay = if inChan
+                        then txt "  "
+                        else withDefAttr clientEmphAttr $
+                             txt $ userNotInChannelMarker <> " "
+    in maybeForce $
+       hBox [ memberDisplay
+            , padTo usernameWidth $ colorUsername curUser username ("@" <> username)
+            , padTo fullNameWidth $ txt fullName
+            , txt nickname
+            ]
+
+renderChannelCompletion :: Channel -> Bool -> Bool -> Widget Name
+renderChannelCompletion c inChan selected =
+    let urlNameWidth = 30
+        displayNameWidth = 30
+        padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')
+        maybeForce = if selected
+                     then forceAttr listSelectedFocusedAttr
+                     else id
+        memberDisplay = if inChan
+                        then txt "  "
+                        else withDefAttr clientEmphAttr $
+                             txt $ userNotInChannelMarker <> " "
+    in maybeForce $
+       hBox [ memberDisplay
+            , padTo urlNameWidth $
+              withDefAttr channelNameAttr $
+              txt $ normalChannelSigil <> (sanitizeUserText $ channelName c)
+            , padTo displayNameWidth $
+              withDefAttr channelNameAttr $
+              txt $ sanitizeUserText $ channelDisplayName c
+            , txt $ sanitizeUserText $ channelPurpose c
+            ]
+
+renderCommandCompletion :: CompletionSource -> Text -> Text -> Text -> Widget Name
+renderCommandCompletion src name args desc =
+    (txt $ " " <> srcTxt <> " ") <+>
+    withDefAttr clientMessageAttr
+        (txt $ "/" <> name <> if T.null args then "" else " " <> args) <+>
+    (txt $ " - " <> desc)
+    where
+        srcTxt = case src of
+            Server -> serverCommandMarker
+            Client -> " "
diff --git a/src/Matterhorn/Draw/Buttons.hs b/src/Matterhorn/Draw/Buttons.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/Buttons.hs
@@ -0,0 +1,29 @@
+module Matterhorn.Draw.Buttons
+  ( drawButton
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Focus
+import           Brick.Widgets.Center
+
+import qualified Data.Text as T
+
+import           Matterhorn.Themes
+
+
+buttonWidth :: Int
+buttonWidth = 10
+
+drawButton :: (Eq n) => FocusRing n -> n -> T.Text -> Widget n
+drawButton f n label =
+    let attr = if focusGetCurrent f == Just n
+               then buttonFocusedAttr
+               else buttonAttr
+    in withDefAttr attr $
+       hLimit buttonWidth $
+       hCenter $
+       txt label
diff --git a/src/Matterhorn/Draw/ChannelList.hs b/src/Matterhorn/Draw/ChannelList.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/ChannelList.hs
@@ -0,0 +1,239 @@
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE RankNTypes #-}
+
+-- | This module provides the Drawing functionality for the
+-- ChannelList sidebar.  The sidebar is divided vertically into groups
+-- and each group is rendered separately.
+--
+-- There are actually two UI modes handled by this code:
+--
+--   * Normal display of the channels, with various markers to
+--     indicate the current channel, channels with unread messages,
+--     user state (for Direct Message channels), etc.
+--
+--   * ChannelSelect display where the user is typing match characters
+--     into a prompt at the ChannelList sidebar is showing only those
+--     channels matching the entered text (and highlighting the
+--     matching portion).
+
+module Matterhorn.Draw.ChannelList (renderChannelList, renderChannelListHeader) where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Widgets.Border
+import           Brick.Widgets.Center (hCenter)
+import qualified Data.Text as T
+import           Lens.Micro.Platform (non)
+
+import qualified Network.Mattermost.Types as MM
+
+import           Matterhorn.Constants ( userSigil )
+import           Matterhorn.Draw.Util
+import           Matterhorn.State.Channels
+import           Matterhorn.Themes
+import           Matterhorn.Types
+import           Matterhorn.Types.Common ( sanitizeUserText )
+import qualified Matterhorn.Zipper as Z
+
+-- | Internal record describing each channel entry and its associated
+-- attributes.  This is the object passed to the rendering function so
+-- that it can determine how to render each channel.
+data ChannelListEntryData =
+    ChannelListEntryData { entrySigil       :: Text
+                         , entryLabel       :: Text
+                         , entryHasUnread   :: Bool
+                         , entryMentions    :: Int
+                         , entryIsRecent    :: Bool
+                         , entryIsReturn    :: Bool
+                         , entryIsCurrent   :: Bool
+                         , entryIsMuted     :: Bool
+                         , entryUserStatus  :: Maybe UserStatus
+                         }
+
+renderChannelListHeader :: ChatState -> Widget Name
+renderChannelListHeader st =
+    vBox [ teamHeader
+         , selfHeader
+         , unreadCountHeader
+         ]
+    where
+        myUsername_ = myUsername st
+        teamHeader = hCenter $
+                     withDefAttr clientEmphAttr $
+                     txt $ "Team: " <> teamNameStr
+        selfHeader = hCenter $
+                     colorUsername myUsername_ myUsername_
+                         (T.singleton statusSigil <> " " <> userSigil <> myUsername_)
+        teamNameStr = sanitizeUserText $ MM.teamDisplayName $ st^.csMyTeam
+        statusSigil = maybe ' ' userSigilFromInfo me
+        me = userById (myUserId st) st
+        unreadCountHeader = hCenter $ txt $ "Unread: " <> (T.pack $ show unreadCount)
+        unreadCount = sum $ (channelListGroupUnread . fst) <$> Z.toList (st^.csFocus)
+
+renderChannelList :: ChatState -> Widget Name
+renderChannelList st =
+    viewport ChannelList Vertical body
+    where
+        myUsername_ = myUsername st
+        renderEntry s e = renderChannelListEntry myUsername_ $ mkChannelEntryData s e
+        body = case appMode st of
+            ChannelSelect ->
+                let zipper = st^.csChannelSelectState.channelSelectMatches
+                    matches = if Z.isEmpty zipper
+                              then [hCenter $ txt "No matches"]
+                              else (renderChannelListGroup st
+                                       (renderChannelSelectListEntry (Z.focus zipper)) <$>
+                                   Z.toList zipper)
+                in vBox $
+                   renderChannelListHeader st :
+                   matches
+            _ ->
+                cached ChannelSidebar $
+                vBox $
+                renderChannelListHeader st :
+                (renderChannelListGroup st renderEntry <$> Z.toList (st^.csFocus))
+
+renderChannelListGroupHeading :: ChannelListGroup -> Widget Name
+renderChannelListGroupHeading g =
+    let (unread, label) = case g of
+            ChannelGroupPublicChannels u -> (u, "Public Channels")
+            ChannelGroupPrivateChannels u -> (u, "Private Channels")
+            ChannelGroupDirectMessages u -> (u, "Direct Messages")
+        addUnread = if unread > 0
+                    then (<+> (withDefAttr unreadGroupMarkerAttr $ txt "*"))
+                    else id
+        labelWidget = addUnread $ withDefAttr channelListHeaderAttr $ txt label
+    in hBorderWithLabel labelWidget
+
+renderChannelListGroup :: ChatState
+                       -> (ChatState -> e -> Widget Name)
+                       -> (ChannelListGroup, [e])
+                       -> Widget Name
+renderChannelListGroup st renderEntry (group, es) =
+    let heading = renderChannelListGroupHeading group
+        entryWidgets = renderEntry st <$> es
+    in if null entryWidgets
+       then emptyWidget
+       else vBox (heading : entryWidgets)
+
+mkChannelEntryData :: ChatState
+                   -> ChannelListEntry
+                   -> ChannelListEntryData
+mkChannelEntryData st e =
+    ChannelListEntryData { entrySigil       = sigilWithSpace
+                         , entryLabel       = name
+                         , entryHasUnread   = unread
+                         , entryMentions    = mentions
+                         , entryIsRecent    = recent
+                         , entryIsReturn    = ret
+                         , entryIsCurrent   = current
+                         , entryIsMuted     = muted
+                         , entryUserStatus  = status
+                         }
+    where
+        cId = channelListEntryChannelId e
+        Just chan = findChannelById cId (st^.csChannels)
+        unread = hasUnread' chan
+        recent = isRecentChannel st cId
+        ret = isReturnChannel st cId
+        current = isCurrentChannel st cId
+        muted = isMuted chan
+        (name, normalSigil, addSpace, status) = case e of
+            CLChannel _ ->
+                (chan^.ccInfo.cdDisplayName, Nothing, False, Nothing)
+            CLGroupDM _ ->
+                (chan^.ccInfo.cdDisplayName, Just " ", True, Nothing)
+            CLUserDM _ uId ->
+                let Just u = userById uId st
+                    uname = if useNickname st
+                            then u^.uiNickName.non (u^.uiName)
+                            else u^.uiName
+                in (uname, Just $ T.singleton $ userSigilFromInfo u,
+                    True, Just $ u^.uiStatus)
+        sigilWithSpace = sigil <> if addSpace then " " else ""
+        prevEditSigil = "»"
+        sigil = if current
+                then fromMaybe "" normalSigil
+                else case chan^.ccEditState.eesInputHistoryPosition of
+                    Just _ -> prevEditSigil
+                    Nothing ->
+                        case chan^.ccEditState.eesLastInput of
+                            ("", _) -> fromMaybe "" normalSigil
+                            _       -> prevEditSigil
+        mentions = chan^.ccInfo.cdMentionCount
+
+-- | Render an individual Channel List entry (in Normal mode) with
+-- appropriate visual decorations.
+renderChannelListEntry :: Text -> ChannelListEntryData -> Widget Name
+renderChannelListEntry myUName entry = body
+    where
+    body = decorate $ decorateEntry entry $ decorateMentions entry $ padRight Max $
+           entryWidget $ entrySigil entry <> entryLabel entry
+    decorate = if | entryIsCurrent entry ->
+                      visible . forceAttr currentChannelNameAttr
+                  | entryMentions entry > 0 && not (entryIsMuted entry) ->
+                      forceAttr mentionsChannelAttr
+                  | entryHasUnread entry ->
+                      forceAttr unreadChannelAttr
+                  | otherwise -> id
+    entryWidget = case entryUserStatus entry of
+                    Just Offline -> withDefAttr clientMessageAttr . txt
+                    Just _       -> colorUsername myUName (entryLabel entry)
+                    Nothing      -> txt
+
+-- | Render an individual entry when in Channel Select mode,
+-- highlighting the matching portion, or completely suppressing the
+-- entry if it doesn't match.
+renderChannelSelectListEntry :: Maybe ChannelSelectMatch
+                             -> ChatState
+                             -> ChannelSelectMatch
+                             -> Widget Name
+renderChannelSelectListEntry curMatch st match =
+    let ChannelSelectMatch preMatch inMatch postMatch _ entry = match
+        maybeSelect = if (Just entry) == (matchEntry <$> curMatch)
+                      then visible . withDefAttr currentChannelNameAttr
+                      else id
+        entryData = mkChannelEntryData st entry
+        decorate = if | entryMentions entryData > 0 && not (entryIsMuted entryData) ->
+                          withDefAttr mentionsChannelAttr
+                      | entryHasUnread entryData ->
+                          withDefAttr unreadChannelAttr
+                      | otherwise -> id
+    in decorate $ maybeSelect $
+       decorateEntry entryData $ decorateMentions entryData $
+       padRight Max $
+         hBox [ txt $ entrySigil entryData <> preMatch
+              , forceAttr channelSelectMatchAttr $ txt inMatch
+              , txt postMatch
+              ]
+
+-- If this channel is the return channel, add a decoration to denote
+-- that.
+--
+-- Otherwise, if this channel is the most recently viewed channel (prior
+-- to the currently viewed channel), add a decoration to denote that.
+decorateEntry :: ChannelListEntryData -> Widget n -> Widget n
+decorateEntry entry =
+    if entryIsReturn entry
+    then (<+> (withDefAttr recentMarkerAttr $ str returnChannelSigil))
+    else if entryIsRecent entry
+         then (<+> (withDefAttr recentMarkerAttr $ str recentChannelSigil))
+         else id
+
+decorateMentions :: ChannelListEntryData -> Widget n -> Widget n
+decorateMentions entry
+  | entryMentions entry > 9 =
+      (<+> str "(9+)")
+  | entryMentions entry > 0 =
+      (<+> str ("(" <> show (entryMentions entry) <> ")"))
+  | entryIsMuted entry =
+      (<+> str "(m)")
+  | otherwise = id
+
+recentChannelSigil :: String
+recentChannelSigil = "<"
+
+returnChannelSigil :: String
+returnChannelSigil = "~"
diff --git a/src/Matterhorn/Draw/ChannelListOverlay.hs b/src/Matterhorn/Draw/ChannelListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/ChannelListOverlay.hs
@@ -0,0 +1,53 @@
+module Matterhorn.Draw.ChannelListOverlay
+  ( drawChannelListOverlay
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import qualified Data.Text as T
+import           Text.Wrap ( defaultWrapSettings, preserveIndentation )
+
+import           Network.Mattermost.Types
+import           Network.Mattermost.Lenses
+
+import           Matterhorn.Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )
+import           Matterhorn.Types
+import           Matterhorn.Types.Common ( sanitizeUserText )
+import           Matterhorn.Themes
+
+
+drawChannelListOverlay :: ChatState -> Widget Name
+drawChannelListOverlay st =
+    let overlay = drawListOverlay (st^.csChannelListOverlay) channelSearchScopeHeader
+                                  channelSearchScopeNoResults channelSearchScopePrompt
+                                  renderChannel
+                                  Nothing
+                                  OverlayCenter
+                                  80
+    in joinBorders overlay
+
+channelSearchScopePrompt :: ChannelSearchScope -> Widget Name
+channelSearchScopePrompt scope =
+    txt $ case scope of
+        AllChannels -> "Search channels:"
+
+channelSearchScopeNoResults :: ChannelSearchScope -> Widget Name
+channelSearchScopeNoResults scope =
+    txt $ case scope of
+        AllChannels -> "No matching channels found."
+
+channelSearchScopeHeader :: ChannelSearchScope -> Widget Name
+channelSearchScopeHeader scope =
+    withDefAttr clientEmphAttr $ txt $ case scope of
+        AllChannels -> "Join a Channel"
+
+renderChannel :: Bool -> Channel -> Widget Name
+renderChannel _ chan =
+    let baseStr = (sanitizeUserText $ chan^.channelNameL) <> " (" <>
+                  (sanitizeUserText $ chan^.channelDisplayNameL) <> ")"
+        s = "  " <> (T.strip $ sanitizeUserText $ chan^.channelPurposeL)
+    in (vLimit 1 $ padRight Max $ withDefAttr clientEmphAttr $ txt baseStr) <=>
+       (vLimit 1 $ txtWrapWith (defaultWrapSettings { preserveIndentation = True }) s)
diff --git a/src/Matterhorn/Draw/ChannelTopicWindow.hs b/src/Matterhorn/Draw/ChannelTopicWindow.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/ChannelTopicWindow.hs
@@ -0,0 +1,56 @@
+module Matterhorn.Draw.ChannelTopicWindow
+  ( drawChannelTopicWindow
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Focus
+import           Brick.Widgets.Border
+import           Brick.Widgets.Center
+import           Brick.Widgets.Edit
+
+import           Control.Arrow ( (>>>) )
+import qualified Data.Text as T
+import           Data.Text.Zipper ( insertChar, gotoEOL )
+
+import           Matterhorn.Types
+import           Matterhorn.Draw.Buttons
+import           Matterhorn.Draw.RichText
+import           Matterhorn.Themes
+
+
+drawChannelTopicWindow :: ChatState -> Widget Name
+drawChannelTopicWindow st =
+    centerLayer $
+    hLimit maxWindowWidth $
+    joinBorders $
+    borderWithLabel (withDefAttr clientEmphAttr $ txt "Edit Channel Topic") $
+    vBox [ vLimit editorHeight $
+           withFocusRing foc (renderEditor drawTopicEditorTxt) ed
+         , hBorderWithLabel (withDefAttr clientEmphAttr $ txt "Preview")
+         , vLimit previewHeight $
+           viewport ChannelTopicEditorPreview Vertical $
+           renderText' (Just baseUrl) "" hSet topicTxtWithCursor
+         , hBorder
+         , hBox [ padRight Max $
+                  padLeft (Pad 1) $
+                  drawButton foc ChannelTopicSaveButton "Save"
+                , padRight (Pad 1) $
+                  drawButton foc ChannelTopicCancelButton "Cancel"
+                ]
+         ]
+    where
+        baseUrl = serverBaseUrl st
+        editorHeight = 5
+        previewHeight = 5
+        maxWindowWidth = 70
+        foc = st^.csChannelTopicDialog.channelTopicDialogFocus
+        ed = st^.csChannelTopicDialog.channelTopicDialogEditor
+        hSet = getHighlightSet st
+        topicTxtWithCursor = T.unlines $
+                             getEditContents $
+                             applyEdit (gotoEOL >>> insertChar cursorSentinel) ed
+        drawTopicEditorTxt = txt . T.unlines
diff --git a/src/Matterhorn/Draw/DeleteChannelConfirm.hs b/src/Matterhorn/Draw/DeleteChannelConfirm.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/DeleteChannelConfirm.hs
@@ -0,0 +1,28 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Matterhorn.Draw.DeleteChannelConfirm
+    ( drawDeleteChannelConfirm
+    )
+where
+
+import Prelude ()
+import Matterhorn.Prelude
+
+import Brick
+import Brick.Widgets.Border
+import Brick.Widgets.Center
+
+import Matterhorn.Themes
+import Matterhorn.Types
+
+
+drawDeleteChannelConfirm :: ChatState -> Widget Name
+drawDeleteChannelConfirm st =
+    let cName = st^.csCurrentChannel.ccInfo.cdName
+    in centerLayer $ hLimit 50 $ vLimit 15 $
+       withDefAttr dialogAttr $
+       borderWithLabel (txt "Confirm Delete Channel") $
+       vBox [ padBottom (Pad 1) $ hCenter $ txt "Are you sure you want to delete this channel?"
+            , padBottom (Pad 1) $ hCenter $ withDefAttr dialogEmphAttr $ txt cName
+            , hCenter $ txt "Press " <+> (withDefAttr dialogEmphAttr $ txt "Y") <+> txt " to delete the channel"
+            , hCenter $ txt "or any other key to cancel."
+            ]
diff --git a/src/Matterhorn/Draw/LeaveChannelConfirm.hs b/src/Matterhorn/Draw/LeaveChannelConfirm.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/LeaveChannelConfirm.hs
@@ -0,0 +1,28 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Matterhorn.Draw.LeaveChannelConfirm
+    ( drawLeaveChannelConfirm
+    )
+where
+
+import Prelude ()
+import Matterhorn.Prelude
+
+import Brick
+import Brick.Widgets.Border
+import Brick.Widgets.Center
+
+import Matterhorn.Themes
+import Matterhorn.Types
+
+
+drawLeaveChannelConfirm :: ChatState -> Widget Name
+drawLeaveChannelConfirm st =
+    let cName = st^.csCurrentChannel.ccInfo.cdName
+    in centerLayer $ hLimit 50 $ vLimit 15 $
+       withDefAttr dialogAttr $
+       borderWithLabel (txt "Confirm Leave Channel") $
+       vBox [ padBottom (Pad 1) $ hCenter $ txt "Are you sure you want to leave this channel?"
+            , padBottom (Pad 1) $ hCenter $ withDefAttr dialogEmphAttr $ txt cName
+            , hCenter $ txt "Press " <+> (withDefAttr dialogEmphAttr $ txt "Y") <+> txt " to leave the channel"
+            , hCenter $ txt "or any other key to cancel."
+            ]
diff --git a/src/Matterhorn/Draw/ListOverlay.hs b/src/Matterhorn/Draw/ListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/ListOverlay.hs
@@ -0,0 +1,134 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module Matterhorn.Draw.ListOverlay
+  ( drawListOverlay
+  , OverlayPosition(..)
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Widgets.Border
+import           Brick.Widgets.Center
+import           Brick.Widgets.Edit
+import qualified Brick.Widgets.List as L
+import           Control.Monad.Trans.Reader ( withReaderT )
+import qualified Data.Foldable as F
+import qualified Data.Text as T
+import           Graphics.Vty ( imageWidth, translateX)
+import           Lens.Micro.Platform ( (%~), (.~), to )
+
+import           Matterhorn.Themes
+import           Matterhorn.Types
+
+
+hLimitWithPadding :: Int -> Widget n -> Widget n
+hLimitWithPadding pad contents = Widget
+  { hSize  = Fixed
+  , vSize  = (vSize contents)
+  , render =
+      withReaderT (& availWidthL  %~ (\ n -> n - (2 * pad))) $ render $ cropToContext contents
+  }
+
+data OverlayPosition =
+    OverlayCenter
+    | OverlayUpperRight
+    deriving (Eq, Show)
+
+-- | Draw a ListOverlayState. This draws a bordered box with the
+-- overlay's search input and results list inside the box. The provided
+-- functions determine how to render the overlay in various states.
+drawListOverlay :: ListOverlayState a b
+                -- ^ The overlay state
+                -> (b -> Widget Name)
+                -- ^ The function to build the window title from the
+                -- current search scope
+                -> (b -> Widget Name)
+                -- ^ The function to generate a message for the search
+                -- scope indicating that no results were found
+                -> (b -> Widget Name)
+                -- ^ The function to generate the editor prompt for the
+                -- search scope
+                -> (Bool -> a -> Widget Name)
+                -- ^ The function to render an item from the overlay's
+                -- list
+                -> Maybe (Widget Name)
+                -- ^ The footer widget to render underneath the search
+                -- results
+                -> OverlayPosition
+                -- ^ How to position the overlay layer
+                -> Int
+                -- ^ The maximum window width in columns
+                -> Widget Name
+drawListOverlay st scopeHeader scopeNoResults scopePrompt renderItem footer layerPos maxWinWidth =
+  positionLayer $ hLimitWithPadding 10 $ vLimit 25 $
+  hLimit maxWinWidth $
+  borderWithLabel title body
+  where
+      title = withDefAttr clientEmphAttr $
+              hBox [ scopeHeader scope
+                   , case st^.listOverlayRecordCount of
+                         Nothing -> emptyWidget
+                         Just c -> txt " (" <+> str (show c) <+> txt ")"
+                   ]
+      positionLayer = case layerPos of
+          OverlayCenter -> centerLayer
+          OverlayUpperRight -> upperRightLayer
+      body = vBox [ (padRight (Pad 1) promptMsg) <+>
+                    renderEditor (txt . T.unlines) True (st^.listOverlaySearchInput)
+                  , cursorPositionBorder
+                  , showResults
+                  , fromMaybe emptyWidget footer
+                  ]
+      plural 1 = ""
+      plural _ = "s"
+      cursorPositionBorder =
+          if st^.listOverlaySearching
+          then hBorderWithLabel $ txt "[Searching...]"
+          else case st^.listOverlaySearchResults.L.listSelectedL of
+              Nothing -> hBorder
+              Just _ ->
+                  let showingFirst = "Showing first " <> show numSearchResults <>
+                                     " result" <> plural numSearchResults
+                      showingAll = "Showing all " <> show numSearchResults <>
+                                   " result" <> plural numSearchResults
+                      showing = "Showing " <> show numSearchResults <>
+                                " result" <> plural numSearchResults
+                      msg = case getEditContents (st^.listOverlaySearchInput) of
+                          [""] ->
+                              case st^.listOverlayRecordCount of
+                                  Nothing -> showing
+                                  Just total -> if numSearchResults < total
+                                                then showingFirst
+                                                else showingAll
+                          _ -> showing
+                  in hBorderWithLabel $ str $ "[" <> msg <> "]"
+
+      scope = st^.listOverlaySearchScope
+      promptMsg = scopePrompt scope
+
+      showMessage = center . withDefAttr clientEmphAttr
+
+      showResults
+        | numSearchResults == 0 = showMessage $ scopeNoResults scope
+        | otherwise = renderedUserList
+
+      renderedUserList = L.renderList renderItem True (st^.listOverlaySearchResults)
+      numSearchResults = F.length $ st^.listOverlaySearchResults.L.listElementsL
+
+upperRightLayer :: Widget a -> Widget a
+upperRightLayer w =
+    Widget (hSize w) (vSize w) $ do
+        result <- render w
+        c <- getContext
+        let rWidth = result^.imageL.to imageWidth
+            leftPaddingAmount = max 0 $ c^.availWidthL - rWidth
+            paddedImage = translateX leftPaddingAmount $ result^.imageL
+            off = Location (leftPaddingAmount, 0)
+        if leftPaddingAmount == 0 then
+            return result else
+            return $ addResultOffset off
+                   $ result & imageL .~ paddedImage
diff --git a/src/Matterhorn/Draw/Main.hs b/src/Matterhorn/Draw/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/Main.hs
@@ -0,0 +1,721 @@
+{-# LANGUAGE MultiWayIf #-}
+module Matterhorn.Draw.Main (drawMain) where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Widgets.Border
+import           Brick.Widgets.Border.Style
+import           Brick.Widgets.Center ( hCenter )
+import           Brick.Widgets.List ( listElements )
+import           Brick.Widgets.Edit ( editContentsL, renderEditor, getEditContents )
+import           Control.Arrow ( (>>>) )
+import           Data.Char ( isSpace, isPunctuation )
+import           Data.List ( intersperse )
+import qualified Data.Map as M
+import qualified Data.Sequence as Seq
+import qualified Data.Set as S
+import qualified Data.Text as T
+import           Data.Text.Zipper ( cursorPosition, insertChar, getText, gotoEOL )
+import           Data.Time.Calendar ( fromGregorian )
+import           Data.Time.Clock ( UTCTime(..) )
+import qualified Graphics.Vty as Vty
+import           Lens.Micro.Platform ( (.~), (^?!), to, view, folding )
+
+import           Network.Mattermost.Types ( ChannelId, Type(Direct, Private, Group)
+                                          , ServerTime(..), UserId
+                                          )
+
+
+import           Matterhorn.Constants
+import           Matterhorn.Draw.ChannelList ( renderChannelList, renderChannelListHeader )
+import           Matterhorn.Draw.Messages
+import           Matterhorn.Draw.Autocomplete
+import           Matterhorn.Draw.URLList
+import           Matterhorn.Draw.Util
+import           Matterhorn.Draw.RichText
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.Events.MessageSelect
+import           Matterhorn.State.MessageSelect
+import           Matterhorn.Themes
+import           Matterhorn.TimeUtils ( justAfter, justBefore )
+import           Matterhorn.Types
+import           Matterhorn.Types.RichText ( parseMarkdown, TeamBaseURL )
+import           Matterhorn.Types.KeyEvents
+
+
+previewFromInput :: TeamBaseURL -> Maybe MessageType -> UserId -> Text -> Maybe Message
+previewFromInput _ _ _ s | s == T.singleton cursorSentinel = Nothing
+previewFromInput baseUrl overrideTy uId s =
+    -- If it starts with a slash but not /me, this has no preview
+    -- representation
+    let isCommand = "/" `T.isPrefixOf` s
+        isEmoteCmd = "/me " `T.isPrefixOf` s
+        content = if isEmoteCmd
+                  then T.stripStart $ T.drop 3 s
+                  else s
+        msgTy = fromMaybe (if isEmoteCmd then CP Emote else CP NormalPost) overrideTy
+    in if isCommand && not isEmoteCmd
+       then Nothing
+       else Just $ Message { _mText          = parseMarkdown (Just baseUrl) content
+                           , _mMarkdownSource = content
+                           , _mUser          = UserI False uId
+                           , _mDate          = ServerTime $ UTCTime (fromGregorian 1970 1 1) 0
+                           -- The date is not used for preview
+                           -- rendering, but we need to provide one.
+                           -- Ideally we'd just use today's date, but
+                           -- the rendering function is pure so we
+                           -- can't.
+                           , _mType          = msgTy
+                           , _mPending       = False
+                           , _mDeleted       = False
+                           , _mAttachments   = mempty
+                           , _mInReplyToMsg  = NotAReply
+                           , _mMessageId     = Nothing
+                           , _mReactions     = mempty
+                           , _mOriginalPost  = Nothing
+                           , _mFlagged       = False
+                           , _mPinned        = False
+                           , _mChannelId     = Nothing
+                           }
+
+-- | Tokens in spell check highlighting.
+data Token =
+    Ignore Text
+    -- ^ This bit of text is to be ignored for the purposes of
+    -- spell-checking.
+    | Check Text
+    -- ^ This bit of text should be checked against the spell checker's
+    -- misspelling list.
+    deriving (Show)
+
+drawEditorContents :: ChatState -> HighlightSet -> [Text] -> Widget Name
+drawEditorContents st hs =
+    let noHighlight = txt . T.unlines
+    in case st^.csEditState.cedSpellChecker of
+        Nothing -> noHighlight
+        Just _ ->
+            case S.null (st^.csEditState.cedMisspellings) of
+                True -> noHighlight
+                False -> doHighlightMisspellings
+                           hs
+                           (st^.csEditState.cedMisspellings)
+
+-- | This function takes a set of misspellings from the spell
+-- checker, the editor lines, and builds a rendering of the text with
+-- misspellings highlighted.
+--
+-- This function processes each line of text from the editor as follows:
+--
+-- * Tokenize the line based on our rules for what constitutes
+--   whitespace. We do this because we need to check "words" in the
+--   user's input against the list of misspellings returned by the spell
+--   checker. But to do this we need to ignore the same things that
+--   Aspell ignores, and it ignores whitespace and lots of puncutation.
+--   We also do this because once we have identified the misspellings
+--   present in the input, we need to reconstruct the user's input and
+--   that means preserving whitespace so that the input looks as it was
+--   originally typed.
+--
+-- * Once we have a list of tokens -- the whitespace tokens to be
+--   preserved but ignored and the tokens to be checked -- we check
+--   each non-whitespace token for presence in the list of misspellings
+--   reported by the checker.
+--
+-- * Having indicated which tokens correspond to misspelled words, we
+--   then need to coallesce adjacent tokens that are of the same
+--   "misspelling status", i.e., two neighboring tokens (of whitespace
+--   or check type) need to be coallesced if they both correspond to
+--   text that is a misspelling or if they both are NOT a misspelling.
+--   We do this so that the final Brick widget is optimal in that it
+--   uses a minimal number of box cells to display substrings that have
+--   the same attribute.
+--
+-- * Finally we build a widget out of these coallesced tokens and apply
+--   the misspellingAttr attribute to the misspelled tokens.
+--
+-- Note that since we have to come to our own conclusion about which
+-- words are worth checking in the checker's output, sometimes our
+-- algorithm will differ from aspell in what is considered "part of a
+-- word" and what isn't. In particular, Aspell is smart about sometimes
+-- noticing that "'" is an apostrophe and at other times that it is
+-- a single quote as part of a quoted string. As a result there will
+-- be cases where Markdown formatting characters interact poorly
+-- with Aspell's checking to result in misspellings that are *not*
+-- highlighted.
+--
+-- One way to deal with this would be to *not* parse the user's input
+-- as done here, complete with all its Markdown metacharacters, but to
+-- instead 1) parse the input as Markdown, 2) traverse the Markdown AST
+-- and extract the words from the relevant subtrees, and 3) spell-check
+-- those words. The reason we don't do it that way in the first place is
+-- because 1) the user's input might not be valid markdown and 2) even
+-- if we did that, we'd still have to do this tokenization operation to
+-- annotate misspellings and reconstruct the user's raw input.
+doHighlightMisspellings :: HighlightSet -> S.Set Text -> [Text] -> Widget Name
+doHighlightMisspellings hSet misspellings contents =
+    -- Traverse the input, gathering non-whitespace into tokens and
+    -- checking if they appear in the misspelling collection
+    let whitelist = S.union (hUserSet hSet) (hChannelSet hSet)
+
+        handleLine t | t == "" = txt " "
+        handleLine t =
+            -- For annotated tokens, coallesce tokens of the same type
+            -- and add attributes for misspellings.
+            let mkW (Left tok) =
+                    let s = getTokenText tok
+                    in if T.null s
+                       then emptyWidget
+                       else withDefAttr misspellingAttr $ txt $ getTokenText tok
+                mkW (Right tok) =
+                    let s = getTokenText tok
+                    in if T.null s
+                       then emptyWidget
+                       else txt s
+
+                go :: Either Token Token -> [Either Token Token] -> [Either Token Token]
+                go lst [] = [lst]
+                go lst (tok:toks) =
+                    case (lst, tok) of
+                        (Left a, Left b)   -> go (Left $ combineTokens a b) toks
+                        (Right a, Right b) -> go (Right $ combineTokens a b) toks
+                        _                  -> lst : go tok toks
+
+            in hBox $ mkW <$> (go (Right $ Ignore "") $ annotatedTokens t)
+
+        combineTokens (Ignore a) (Ignore b) = Ignore $ a <> b
+        combineTokens (Check a) (Check b) = Check $ a <> b
+        combineTokens (Ignore a) (Check b) = Check $ a <> b
+        combineTokens (Check a) (Ignore b) = Check $ a <> b
+
+        getTokenText (Ignore a) = a
+        getTokenText (Check a) = a
+
+        annotatedTokens t =
+            -- For every token, check on whether it is a misspelling.
+            -- The result is Either Token Token where the Left is a
+            -- misspelling and the Right is not.
+            checkMisspelling <$> tokenize t (Ignore "")
+
+        checkMisspelling t@(Ignore _) = Right t
+        checkMisspelling t@(Check s) =
+            if s `S.member` whitelist
+            then Right t
+            else if s `S.member` misspellings
+                 then Left t
+                 else Right t
+
+        ignoreChar c = isSpace c || isPunctuation c || c == '`' || c == '/' ||
+                       T.singleton c == userSigil || T.singleton c == normalChannelSigil
+
+        tokenize t curTok
+            | T.null t = [curTok]
+            | ignoreChar $ T.head t =
+                case curTok of
+                    Ignore s -> tokenize (T.tail t) (Ignore $ s <> (T.singleton $ T.head t))
+                    Check s -> Check s : tokenize (T.tail t) (Ignore $ T.singleton $ T.head t)
+            | otherwise =
+                case curTok of
+                    Ignore s -> Ignore s : tokenize (T.tail t) (Check $ T.singleton $ T.head t)
+                    Check s -> tokenize (T.tail t) (Check $ s <> (T.singleton $ T.head t))
+
+    in vBox $ handleLine <$> contents
+
+renderUserCommandBox :: ChatState -> HighlightSet -> Widget Name
+renderUserCommandBox st hs =
+    let prompt = txt $ case st^.csEditState.cedEditMode of
+            Replying _ _ -> "reply> "
+            Editing _ _  ->  "edit> "
+            NewPost      ->      "> "
+        inputBox = renderEditor (drawEditorContents st hs) True (st^.csEditState.cedEditor)
+        curContents = getEditContents $ st^.csEditState.cedEditor
+        multilineContent = length curContents > 1
+        multilineHints =
+            hBox [ borderElem bsHorizontal
+                 , str $ "[" <> (show $ (+1) $ fst $ cursorPosition $
+                                        st^.csEditState.cedEditor.editContentsL) <>
+                         "/" <> (show $ length curContents) <> "]"
+                 , hBorderWithLabel $ withDefAttr clientEmphAttr $
+                   txt $ "In multi-line mode. Press " <> multiLineToggleKey <>
+                         " to finish."
+                 ]
+
+        replyDisplay = case st^.csEditState.cedEditMode of
+            Replying msg _ ->
+                let msgWithoutParent = msg & mInReplyToMsg .~ NotAReply
+                in hBox [ replyArrow
+                        , addEllipsis $ renderMessage MessageData
+                          { mdMessage           = msgWithoutParent
+                          , mdUserName          = msgWithoutParent^.mUser.to (nameForUserRef st)
+                          , mdParentMessage     = Nothing
+                          , mdParentUserName    = Nothing
+                          , mdHighlightSet      = hs
+                          , mdEditThreshold     = Nothing
+                          , mdShowOlderEdits    = False
+                          , mdRenderReplyParent = True
+                          , mdIndentBlocks      = False
+                          , mdThreadState       = NoThread
+                          , mdShowReactions     = True
+                          , mdMessageWidthLimit = Nothing
+                          , mdMyUsername        = myUsername st
+                          , mdWrapNonhighlightedCodeBlocks = True
+                          }
+                        ]
+            _ -> emptyWidget
+
+        multiLineToggleKey = ppBinding $ getFirstDefaultBinding ToggleMultiLineEvent
+
+        commandBox = case st^.csEditState.cedEphemeral.eesMultiline of
+            False ->
+                let linesStr = "line" <> if numLines == 1 then "" else "s"
+                    numLines = length curContents
+                in vLimit 1 $ hBox $
+                   prompt : if multilineContent
+                            then [ withDefAttr clientEmphAttr $
+                                   str $ "[" <> show numLines <> " " <> linesStr <>
+                                         "; Enter: send, " <> T.unpack multiLineToggleKey <>
+                                         ": edit, Backspace: cancel] "
+                                 , txt $ head curContents
+                                 , showCursor MessageInput (Location (0,0)) $ str " "
+                                 ]
+                            else [inputBox]
+            True -> vLimit multilineHeightLimit inputBox <=> multilineHints
+    in replyDisplay <=> commandBox
+
+renderChannelHeader :: ChatState -> HighlightSet -> ClientChannel -> Widget Name
+renderChannelHeader st hs chan =
+    let chnType = chan^.ccInfo.cdType
+        topicStr = chan^.ccInfo.cdHeader
+        userHeader u = let s = T.intercalate " " $ filter (not . T.null) parts
+                           parts = [ chanName
+                                   , if (all T.null names)
+                                     then mempty
+                                     else "is"
+                                   ] <> names <> [
+                                     if T.null (u^.uiEmail)
+                                     then mempty
+                                     else "(" <> u^.uiEmail <> ")"
+                                   ]
+                           names = [ u^.uiFirstName
+                                   , nick
+                                   , u^.uiLastName
+                                   ]
+                           quote n = "\"" <> n <> "\""
+                           nick = maybe "" quote $ u^.uiNickName
+                       in s
+        firstTopicLine = case T.lines topicStr of
+            [h] -> h
+            (h:_:_) -> h
+            _ -> ""
+        maybeTopic = if T.null topicStr
+                     then ""
+                     else " - " <> if st^.csShowExpandedChannelTopics
+                                   then topicStr
+                                   else firstTopicLine
+        channelNameString = case chnType of
+            Direct ->
+                case chan^.ccInfo.cdDMUserId >>= flip userById st of
+                    Nothing -> chanName
+                    Just u -> userHeader u
+            Private ->
+                channelNamePair <> " (Private)"
+            Group ->
+                channelNamePair <> " (Private group)"
+            _ ->
+                channelNamePair
+        channelNamePair = chanName <> " - " <> (chan^.ccInfo.cdDisplayName)
+        chanName = mkChannelName st (chan^.ccInfo)
+        baseUrl = serverBaseUrl st
+
+    in renderText' (Just baseUrl) (myUsername st)
+         hs
+         (channelNameString <> maybeTopic)
+
+renderCurrentChannelDisplay :: ChatState -> HighlightSet -> Widget Name
+renderCurrentChannelDisplay st hs = header <=> hBorder <=> messages
+    where
+    header =
+        if st^.csShowChannelList
+        then channelHeader
+        else headerWithStatus
+
+    headerWithStatus =
+        -- Render the channel list header next to the channel header
+        -- itself. We want them to be separated by a vertical border,
+        -- but we want the border to be as high as the tallest of the
+        -- two. To make that work we need to render the two and then
+        -- render a border between them that is the same height as the
+        -- taller of the two. We can't do that without making a custom
+        -- widget which is why we take this approach here rather than
+        -- just putting them all in an hBox.
+        Widget Fixed Fixed $ do
+            ctx <- getContext
+            statusBox <- render $
+                hLimit (configChannelListWidth $ st^.csResources.crConfiguration) $
+                       (renderChannelListHeader st)
+
+            let channelHeaderWidth = ctx^.availWidthL -
+                                     (Vty.imageWidth $ statusBox^.imageL) - 1
+
+            channelHeaderResult <- render $ hLimit channelHeaderWidth channelHeader
+
+            let maxHeight = max (Vty.imageHeight $ statusBox^.imageL)
+                                (Vty.imageHeight $ channelHeaderResult^.imageL)
+                statusBoxWidget = Widget Fixed Fixed $ return statusBox
+                headerWidget = Widget Fixed Fixed $ return channelHeaderResult
+                borderWidget = vLimit maxHeight vBorder
+
+            render $ if appMode st == ChannelSelect
+                        then headerWidget
+                        else hBox $ case st^.csChannelListOrientation of
+                            ChannelListLeft ->
+                                [ statusBoxWidget
+                                , borderWidget
+                                , headerWidget
+                                ]
+                            ChannelListRight ->
+                                [ headerWidget
+                                , borderWidget
+                                , statusBoxWidget
+                                ]
+
+    channelHeader =
+        withDefAttr channelHeaderAttr $
+        padRight Max $
+        renderChannelHeader st hs chan
+
+    messages = padTop Max chatText
+
+    chatText = case appMode st of
+        MessageSelect ->
+            renderMessagesWithSelect (st^.csMessageSelect) channelMessages
+        MessageSelectDeleteConfirm ->
+            renderMessagesWithSelect (st^.csMessageSelect) channelMessages
+        _ ->
+            cached (ChannelMessages cId) $
+            renderLastMessages st hs editCutoff $
+            retrogradeMsgsWithThreadStates $
+            reverseMessages channelMessages
+
+    renderMessagesWithSelect (MessageSelectState selMsgId) msgs =
+        -- In this case, we want to fill the message list with messages
+        -- but use the post ID as a cursor. To do this efficiently we
+        -- only want to render enough messages to fill the screen.
+        --
+        -- If the message area is H rows high, this actually renders at
+        -- most 2H rows' worth of messages and then does the appropriate
+        -- cropping. This way we can simplify the math needed to figure
+        -- out how to crop while bounding the number of messages we
+        -- render around the cursor.
+        --
+        -- First, we sanity-check the application state because under
+        -- some conditions, the selected message might be gone (e.g.
+        -- deleted).
+        let (s, (before, after)) = splitDirSeqOn (\(m, _) -> m^.mMessageId == selMsgId) msgsWithStates
+            msgsWithStates = chronologicalMsgsWithThreadStates msgs
+        in case s of
+             Nothing ->
+                 renderLastMessages st hs editCutoff before
+             Just m ->
+                 unsafeRenderMessageSelection (m, (before, after)) (renderSingleMessage st hs Nothing)
+
+    cutoff = getNewMessageCutoff cId st
+    editCutoff = getEditedMessageCutoff cId st
+    channelMessages =
+        insertTransitions (getMessageListing cId st)
+                          cutoff
+                          (getDateFormat st)
+                          (st ^. timeZone)
+
+    cId = st^.csCurrentChannelId
+    chan = st^.csCurrentChannel
+
+getMessageListing :: ChannelId -> ChatState -> Messages
+getMessageListing cId st =
+    st ^?! csChannels.folding (findChannelById cId) . ccContents . cdMessages . to (filterMessages isShown)
+    where isShown m
+            | st^.csResources.crUserPreferences.userPrefShowJoinLeave = True
+            | otherwise = not $ isJoinLeave m
+
+insertTransitions :: Messages -> Maybe NewMessageIndicator -> Text -> TimeZoneSeries -> Messages
+insertTransitions ms cutoff = insertDateMarkers $ foldr addMessage ms newMessagesT
+    where anyNondeletedNewMessages t =
+              isJust $ findLatestUserMessage (not . view mDeleted) (messagesAfter t ms)
+          newMessagesT = case cutoff of
+              Nothing -> []
+              Just Hide -> []
+              Just (NewPostsAfterServerTime t)
+                  | anyNondeletedNewMessages t -> [newMessagesMsg $ justAfter t]
+                  | otherwise -> []
+              Just (NewPostsStartingAt t)
+                  | anyNondeletedNewMessages (justBefore t) -> [newMessagesMsg $ justBefore t]
+                  | otherwise -> []
+          newMessagesMsg d = newMessageOfType (T.pack "New Messages")
+                             (C NewMessagesTransition) d
+
+renderChannelSelectPrompt :: ChatState -> Widget Name
+renderChannelSelectPrompt st =
+    let e = st^.csChannelSelectState.channelSelectInput
+    in withDefAttr channelSelectPromptAttr $
+       (txt "Switch to channel [use ^ and $ to anchor]: ") <+>
+       (renderEditor (txt . T.concat) True e)
+
+drawMain :: Bool -> ChatState -> [Widget Name]
+drawMain useColor st =
+    let maybeColor = if useColor then id else forceAttr "invalid"
+    in maybeColor <$>
+           [ connectionLayer st
+           , autocompleteLayer st
+           , joinBorders $ mainInterface st
+           ]
+
+connectionLayer :: ChatState -> Widget Name
+connectionLayer st =
+    case st^.csConnectionStatus of
+        Connected -> emptyWidget
+        Disconnected ->
+            Widget Fixed Fixed $ do
+                ctx <- getContext
+                let aw = ctx^.availWidthL
+                    w = length msg + 2
+                    msg = "NOT CONNECTED"
+                render $ translateBy (Location (max 0 (aw - w), 0)) $
+                         withDefAttr errorMessageAttr $
+                         border $ str msg
+
+messageSelectBottomBar :: ChatState -> Widget Name
+messageSelectBottomBar st =
+    case getSelectedMessage st of
+        Nothing -> emptyWidget
+        Just postMsg ->
+            let optionList = if null usableOptions
+                             then txt "(no actions available for this message)"
+                             else hBox $ intersperse (txt " ") usableOptions
+                usableOptions = catMaybes $ mkOption <$> options
+                mkOption (f, k, desc) = if f postMsg
+                                        then Just $ withDefAttr messageSelectStatusAttr (txt k) <+>
+                                                    txt (":" <> desc)
+                                        else Nothing
+                numURLs = Seq.length $ msgURLs postMsg
+                s = if numURLs == 1 then "" else "s"
+                hasURLs = numURLs > 0
+                openUrlsMsg = "open " <> (T.pack $ show numURLs) <> " URL" <> s
+                hasVerb = isJust (findVerbatimChunk (postMsg^.mText))
+                -- make sure these keybinding pieces are up-to-date!
+                ev e =
+                  let keyconf = st^.csResources.crConfiguration.to configUserKeys
+                      KeyHandlerMap keymap = messageSelectKeybindings keyconf
+                  in T.intercalate ","
+                       [ ppBinding (eventToBinding k)
+                       | KH { khKey     = k
+                            , khHandler = h
+                            } <- M.elems keymap
+                       , kehEventTrigger h == ByEvent e
+                       ]
+                options = [ ( not . isGap
+                            , ev YankWholeMessageEvent
+                            , "yank-all"
+                            )
+                          , ( \m -> isFlaggable m && not (m^.mFlagged)
+                            , ev FlagMessageEvent
+                            , "flag"
+                            )
+                          , ( \m -> isFlaggable m && m^.mFlagged
+                            , ev FlagMessageEvent
+                            , "unflag"
+                            )
+                          , ( \m -> isPinnable m && not (m^.mPinned)
+                            , ev PinMessageEvent
+                            , "pin"
+                            )
+                          , ( \m -> isPinnable m && m^.mPinned
+                            , ev PinMessageEvent
+                            , "unpin"
+                            )
+                          , ( isReplyable
+                            , ev ReplyMessageEvent
+                            , "reply"
+                            )
+                          , ( not . isGap
+                            , ev ViewMessageEvent
+                            , "view"
+                            )
+                          , ( isGap
+                            , ev FillGapEvent
+                            , "load messages"
+                            )
+                          , ( \m -> isMine st m && isEditable m
+                            , ev EditMessageEvent
+                            , "edit"
+                            )
+                          , ( \m -> isMine st m && isDeletable m
+                            , ev DeleteMessageEvent
+                            , "delete"
+                            )
+                          , ( const hasURLs
+                            , ev OpenMessageURLEvent
+                            , openUrlsMsg
+                            )
+                          , ( const hasVerb
+                            , ev YankMessageEvent
+                            , "yank-code"
+                            )
+                          , ( isReactable
+                            , ev ReactToMessageEvent
+                            , "react"
+                            )
+                          ]
+
+            in hBox [ borderElem bsHorizontal
+                    , txt "["
+                    , txt "Message select: "
+                    , optionList
+                    , txt "]"
+                    , hBorder
+                    ]
+
+maybePreviewViewport :: Widget Name -> Widget Name
+maybePreviewViewport w =
+    Widget Greedy Fixed $ do
+        result <- render w
+        case (Vty.imageHeight $ result^.imageL) > previewMaxHeight of
+            False -> return result
+            True ->
+                render $ vLimit previewMaxHeight $ viewport MessagePreviewViewport Vertical $
+                         (Widget Fixed Fixed $ return result)
+
+inputPreview :: ChatState -> HighlightSet -> Widget Name
+inputPreview st hs | not $ st^.csShowMessagePreview = emptyWidget
+                   | otherwise = thePreview
+    where
+    uId = myUserId st
+    -- Insert a cursor sentinel into the input text just before
+    -- rendering the preview. We use the inserted sentinel (which is
+    -- not rendered) to get brick to ensure that the line the cursor is
+    -- on is visible in the preview viewport. We put the sentinel at
+    -- the *end* of the line because it will still influence markdown
+    -- parsing and can create undesirable/confusing churn in the
+    -- rendering while the cursor moves around. If the cursor is at the
+    -- end of whatever line the user is editing, that is very unlikely
+    -- to be a problem.
+    curContents = getText $ (gotoEOL >>> insertChar cursorSentinel) $
+                  st^.csEditState.cedEditor.editContentsL
+    curStr = T.intercalate "\n" curContents
+    overrideTy = case st^.csEditState.cedEditMode of
+        Editing _ ty -> Just ty
+        _ -> Nothing
+    baseUrl = serverBaseUrl st
+    previewMsg = previewFromInput baseUrl overrideTy uId curStr
+    thePreview = let noPreview = str "(No preview)"
+                     msgPreview = case previewMsg of
+                       Nothing -> noPreview
+                       Just pm -> if T.null curStr
+                                  then noPreview
+                                  else prview pm $ getParentMessage st pm
+                     prview m p = renderMessage MessageData
+                                  { mdMessage           = m
+                                  , mdUserName          = m^.mUser.to (nameForUserRef st)
+                                  , mdParentMessage     = p
+                                  , mdParentUserName    = p >>= (^.mUser.to (nameForUserRef st))
+                                  , mdHighlightSet      = hs
+                                  , mdEditThreshold     = Nothing
+                                  , mdShowOlderEdits    = False
+                                  , mdRenderReplyParent = True
+                                  , mdIndentBlocks      = True
+                                  , mdThreadState       = NoThread
+                                  , mdShowReactions     = True
+                                  , mdMessageWidthLimit = Nothing
+                                  , mdMyUsername        = myUsername st
+                                  , mdWrapNonhighlightedCodeBlocks = True
+                                  }
+                 in (maybePreviewViewport msgPreview) <=>
+                    hBorderWithLabel (withDefAttr clientEmphAttr $ str "[Preview ↑]")
+
+userInputArea :: ChatState -> HighlightSet -> Widget Name
+userInputArea st hs =
+    case appMode st of
+        ChannelSelect -> renderChannelSelectPrompt st
+        UrlSelect     -> hCenter $ hBox [ txt "Press "
+                                        , withDefAttr clientEmphAttr $ txt "Enter"
+                                        , txt " to open the selected URL or "
+                                        , withDefAttr clientEmphAttr $ txt "Escape"
+                                        , txt " to cancel."
+                                        ]
+        MessageSelectDeleteConfirm -> renderDeleteConfirm
+        _             -> renderUserCommandBox st hs
+
+renderDeleteConfirm :: Widget Name
+renderDeleteConfirm =
+    hCenter $ txt "Are you sure you want to delete the selected message? (y/n)"
+
+mainInterface :: ChatState -> Widget Name
+mainInterface st =
+    vBox [ body
+         , bottomBorder
+         , inputPreview st hs
+         , userInputArea st hs
+         ]
+    where
+    body = if st^.csShowChannelList || appMode st == ChannelSelect
+           then case st^.csChannelListOrientation of
+               ChannelListLeft ->
+                   hBox [channelList, vBorder, mainDisplay]
+               ChannelListRight ->
+                   hBox [mainDisplay, vBorder, channelList]
+           else mainDisplay
+    channelList = hLimit channelListWidth (renderChannelList st)
+    hs = getHighlightSet st
+    channelListWidth = configChannelListWidth $ st^.csResources.crConfiguration
+    mainDisplay = case appMode st of
+        UrlSelect -> renderUrlList st
+        _         -> maybeSubdue $ renderCurrentChannelDisplay st hs
+
+    bottomBorder = case appMode st of
+        MessageSelect -> messageSelectBottomBar st
+        _ -> maybeSubdue $ hBox
+             [ showAttachmentCount
+             , hBorder
+             , showTypingUsers
+             , showBusy
+             ]
+
+    showAttachmentCount =
+        let count = length $ listElements $ st^.csEditState.cedAttachmentList
+        in if count == 0
+           then emptyWidget
+           else hBox [ borderElem bsHorizontal
+                     , withDefAttr clientMessageAttr $
+                       txt $ "(" <> (T.pack $ show count) <> " attachment" <>
+                             (if count == 1 then "" else "s") <> "; "
+                     , withDefAttr clientEmphAttr $
+                       txt $ ppBinding (getFirstDefaultBinding ShowAttachmentListEvent)
+                     , txt " to manage)"
+                     ]
+
+    showTypingUsers =
+        let format = renderText' Nothing (myUsername st) hs
+        in case allTypingUsers (st^.csCurrentChannel.ccInfo.cdTypingUsers) of
+            [] -> emptyWidget
+            [uId] | Just un <- usernameForUserId uId st ->
+               format $ "[" <> userSigil <> un <> " is typing]"
+            [uId1, uId2] | Just un1 <- usernameForUserId uId1 st
+                         , Just un2 <- usernameForUserId uId2 st ->
+               format $ "[" <> userSigil <> un1 <> " and " <> userSigil <> un2 <> " are typing]"
+            _ -> format "[several people are typing]"
+
+    showBusy = case st^.csWorkerIsBusy of
+                 Just (Just n) -> hLimit 2 hBorder <+> txt (T.pack $ "*" <> show n)
+                 Just Nothing -> hLimit 2 hBorder <+> txt "*"
+                 Nothing -> emptyWidget
+
+    maybeSubdue = if appMode st == ChannelSelect
+                  then forceAttr ""
+                  else id
+
+replyArrow :: Widget a
+replyArrow =
+    Widget Fixed Fixed $ do
+        ctx <- getContext
+        let bs = ctx^.ctxBorderStyleL
+        render $ str [' ', bsCornerTL bs, '▸']
diff --git a/src/Matterhorn/Draw/ManageAttachments.hs b/src/Matterhorn/Draw/ManageAttachments.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/ManageAttachments.hs
@@ -0,0 +1,64 @@
+module Matterhorn.Draw.ManageAttachments
+  ( drawManageAttachments
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Widgets.Border
+import           Brick.Widgets.Center
+import qualified Brick.Widgets.FileBrowser as FB
+import           Brick.Widgets.List
+import           Data.Maybe ( fromJust )
+
+import           Matterhorn.Types
+import           Matterhorn.Types.KeyEvents
+import           Matterhorn.Events.Keybindings ( getFirstDefaultBinding )
+import           Matterhorn.Themes
+
+
+drawManageAttachments :: ChatState -> Widget Name
+drawManageAttachments st =
+    topLayer
+    where
+        topLayer = case appMode st of
+            ManageAttachments -> drawAttachmentList st
+            ManageAttachmentsBrowseFiles -> drawFileBrowser st
+            _ -> error "BUG: drawManageAttachments called in invalid mode"
+
+drawAttachmentList :: ChatState -> Widget Name
+drawAttachmentList st =
+    let addBinding = ppBinding $ getFirstDefaultBinding AttachmentListAddEvent
+        delBinding = ppBinding $ getFirstDefaultBinding AttachmentListDeleteEvent
+        escBinding = ppBinding $ getFirstDefaultBinding CancelEvent
+        openBinding = ppBinding $ getFirstDefaultBinding AttachmentOpenEvent
+    in centerLayer $
+       hLimit 60 $
+       vLimit 15 $
+       joinBorders $
+       borderWithLabel (withDefAttr clientEmphAttr $ txt "Attachments") $
+       vBox [ renderList renderAttachmentItem True (st^.csEditState.cedAttachmentList)
+            , hBorder
+            , hCenter $ withDefAttr clientMessageAttr $
+                        txt $ addBinding <> ":add " <>
+                              delBinding <> ":delete " <>
+                              openBinding <> ":open " <>
+                              escBinding <> ":close"
+            ]
+
+renderAttachmentItem :: Bool -> AttachmentData -> Widget Name
+renderAttachmentItem _ d =
+    padRight Max $ str $ FB.fileInfoSanitizedFilename $ attachmentDataFileInfo d
+
+drawFileBrowser :: ChatState -> Widget Name
+drawFileBrowser st =
+    centerLayer $
+    hLimit 60 $
+    vLimit 20 $
+    borderWithLabel (withDefAttr clientEmphAttr $ txt "Attach File") $
+    -- invariant: cedFileBrowser is not Nothing if appMode is
+    -- ManageAttachmentsBrowseFiles, and that is the only way to reach
+    -- this code, ergo the fromJust.
+    FB.renderFileBrowser True $ fromJust (st^.csEditState.cedFileBrowser)
diff --git a/src/Matterhorn/Draw/Messages.hs b/src/Matterhorn/Draw/Messages.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/Messages.hs
@@ -0,0 +1,209 @@
+{-# LANGUAGE MultiWayIf #-}
+module Matterhorn.Draw.Messages
+  ( nameForUserRef
+  , renderSingleMessage
+  , unsafeRenderMessageSelection
+  , renderLastMessages
+  )
+where
+
+import           Brick
+import           Brick.Widgets.Border
+import           Control.Monad.Trans.Reader ( withReaderT )
+import qualified Data.Foldable as F
+import qualified Graphics.Vty as Vty
+import           Lens.Micro.Platform ( (.~), to )
+import           Network.Mattermost.Types ( ServerTime(..), userUsername )
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Matterhorn.Draw.Util
+import           Matterhorn.Draw.RichText
+import           Matterhorn.Themes
+import           Matterhorn.Types
+import           Matterhorn.Types.DirectionalSeq
+
+maxMessageHeight :: Int
+maxMessageHeight = 200
+
+-- | nameForUserRef converts the UserRef into a printable name, based
+-- on the current known user data.
+nameForUserRef :: ChatState -> UserRef -> Maybe Text
+nameForUserRef st uref =
+    case uref of
+        NoUser -> Nothing
+        UserOverride _ t -> Just t
+        UserI _ uId -> displayNameForUserId uId st
+
+-- | renderSingleMessage is the main message drawing function.
+--
+-- The `ind` argument specifies an "indicator boundary".  Showing
+-- various indicators (e.g. "edited") is not typically done for
+-- messages that are older than this boundary value.
+renderSingleMessage :: ChatState
+                    -> HighlightSet
+                    -> Maybe ServerTime
+                    -> Message
+                    -> ThreadState
+                    -> Widget Name
+renderSingleMessage st hs ind m threadState =
+  renderChatMessage st hs ind threadState (withBrackets . renderTime st . withServerTime) m
+
+renderChatMessage :: ChatState
+                  -> HighlightSet
+                  -> Maybe ServerTime
+                  -> ThreadState
+                  -> (ServerTime -> Widget Name)
+                  -> Message
+                  -> Widget Name
+renderChatMessage st hs ind threadState renderTimeFunc msg =
+    let showOlderEdits = configShowOlderEdits config
+        showTimestamp = configShowMessageTimestamps config
+        config = st^.csResources.crConfiguration
+        parent = case msg^.mInReplyToMsg of
+          NotAReply -> Nothing
+          InReplyTo pId -> getMessageForPostId st pId
+        m = renderMessage MessageData
+              { mdMessage           = msg
+              , mdUserName          = msg^.mUser.to (nameForUserRef st)
+              , mdParentMessage     = parent
+              , mdParentUserName    = parent >>= (^.mUser.to (nameForUserRef st))
+              , mdEditThreshold     = ind
+              , mdHighlightSet      = hs
+              , mdShowOlderEdits    = showOlderEdits
+              , mdRenderReplyParent = True
+              , mdIndentBlocks      = True
+              , mdThreadState       = threadState
+              , mdShowReactions     = True
+              , mdMessageWidthLimit = Nothing
+              , mdMyUsername        = userUsername $ myUser st
+              , mdWrapNonhighlightedCodeBlocks = True
+              }
+        fullMsg =
+          case msg^.mUser of
+            NoUser
+              | isGap msg -> withDefAttr gapMessageAttr m
+              | otherwise ->
+                case msg^.mType of
+                    C DateTransition ->
+                        withDefAttr dateTransitionAttr (hBorderWithLabel m)
+                    C NewMessagesTransition ->
+                        withDefAttr newMessageTransitionAttr (hBorderWithLabel m)
+                    C Error ->
+                        withDefAttr errorMessageAttr m
+                    _ ->
+                        withDefAttr clientMessageAttr m
+            _ | isJoinLeave msg -> withDefAttr clientMessageAttr m
+              | otherwise -> m
+        maybeRenderTime w =
+            if showTimestamp
+            then let maybePadTime = if threadState == InThreadShowParent
+                                    then (txt " " <=>) else id
+                 in hBox [maybePadTime $ renderTimeFunc (msg^.mDate), txt " ", w]
+            else w
+        maybeRenderTimeWith f = if isTransition msg then id else f
+    in maybeRenderTimeWith maybeRenderTime fullMsg
+
+-- | Render a selected message with focus, including the messages
+-- before and the messages after it. The foldable parameters exist
+-- because (depending on the situation) we might use either of the
+-- message list types for the 'before' and 'after' (i.e. the
+-- chronological or retrograde message sequences).
+unsafeRenderMessageSelection :: (Foldable f, Foldable g)
+                             => ((Message, ThreadState), (f (Message, ThreadState), g (Message, ThreadState)))
+                             -> (Message -> ThreadState -> Widget Name)
+                             -> Widget Name
+unsafeRenderMessageSelection ((curMsg, curThreadState), (before, after)) doMsgRender =
+  Widget Greedy Greedy $ do
+    ctx <- getContext
+    curMsgResult <- withReaderT relaxHeight $ render $
+                    forceAttr messageSelectAttr $
+                    padRight Max $ doMsgRender curMsg curThreadState
+
+    let targetHeight = ctx^.availHeightL
+        upperHeight = targetHeight `div` 2
+        lowerHeight = targetHeight - upperHeight
+
+        lowerRender img (m, tState) = render1HLimit doMsgRender Vty.vertJoin targetHeight img tState m
+        upperRender img (m, tState) = render1HLimit doMsgRender (flip Vty.vertJoin) targetHeight img tState m
+
+    lowerHalf <- foldM lowerRender Vty.emptyImage after
+    upperHalf <- foldM upperRender Vty.emptyImage before
+
+    let curHeight = Vty.imageHeight $ curMsgResult^.imageL
+        uncropped = upperHalf Vty.<-> curMsgResult^.imageL Vty.<-> lowerHalf
+        img = if | Vty.imageHeight lowerHalf < (lowerHeight - curHeight) ->
+                     Vty.cropTop targetHeight uncropped
+                 | Vty.imageHeight upperHalf < upperHeight ->
+                     Vty.cropBottom targetHeight uncropped
+                 | otherwise ->
+                     Vty.cropTop upperHeight upperHalf Vty.<-> curMsgResult^.imageL Vty.<->
+                        (if curHeight < lowerHeight
+                          then Vty.cropBottom (lowerHeight - curHeight) lowerHalf
+                          else Vty.cropBottom lowerHeight lowerHalf)
+    return $ emptyResult & imageL .~ img
+
+renderLastMessages :: ChatState
+                   -> HighlightSet
+                   -> Maybe ServerTime
+                   -> DirectionalSeq Retrograde (Message, ThreadState)
+                   -> Widget Name
+renderLastMessages st hs editCutoff msgs =
+    Widget Greedy Greedy $ do
+        ctx <- getContext
+        let targetHeight = ctx^.availHeightL
+            doMsgRender = renderSingleMessage st hs editCutoff
+
+            newMessagesTransitions = filterMessages (isNewMessagesTransition . fst) msgs
+            newMessageTransition = fst <$> (listToMaybe $ F.toList newMessagesTransitions)
+
+            isBelow m transition = m^.mDate > transition^.mDate
+
+            go :: Vty.Image -> DirectionalSeq Retrograde (Message, ThreadState) -> RenderM Name Vty.Image
+            go img ms | messagesLength ms == 0 = return img
+            go img ms = do
+                let Just (m, threadState) = messagesHead ms
+                    newMessagesAbove = maybe False (isBelow m) newMessageTransition
+                newImg <- render1HLimit doMsgRender (flip Vty.vertJoin) targetHeight img threadState m
+                -- If the new message fills the window, check whether
+                -- there is still a "New Messages" transition that is
+                -- not displayed. If there is, then we need to replace
+                -- the top line of the new image with a "New Messages"
+                -- indicator.
+                if Vty.imageHeight newImg >= targetHeight && newMessagesAbove
+                then do
+                    transitionResult <- render $ withDefAttr newMessageTransitionAttr $
+                                                 hBorderWithLabel (txt "New Messages ↑")
+                    let newImg2 = Vty.vertJoin (transitionResult^.imageL)
+                                               (Vty.cropTop (targetHeight - 1) newImg)
+                    return newImg2
+                else go newImg $ messagesDrop 1 ms
+
+        img <- go Vty.emptyImage msgs
+        return $ emptyResult & imageL .~ (Vty.cropTop targetHeight img)
+
+relaxHeight :: Context -> Context
+relaxHeight c = c & availHeightL .~ (max maxMessageHeight (c^.availHeightL))
+
+render1HLimit :: (Message -> ThreadState -> Widget Name)
+              -> (Vty.Image -> Vty.Image -> Vty.Image)
+              -> Int
+              -> Vty.Image
+              -> ThreadState
+              -> Message
+              -> RenderM Name Vty.Image
+render1HLimit doMsgRender fjoin lim img threadState msg
+  | Vty.imageHeight img >= lim = return img
+  | otherwise = fjoin img <$> render1 doMsgRender threadState msg
+
+render1 :: (Message -> ThreadState -> Widget Name)
+        -> ThreadState
+        -> Message
+        -> RenderM Name Vty.Image
+render1 doMsgRender threadState msg = case msg^.mDeleted of
+    True -> return Vty.emptyImage
+    False -> do
+        r <- withReaderT relaxHeight $
+             render $ padRight Max $
+             doMsgRender msg threadState
+        return $ r^.imageL
diff --git a/src/Matterhorn/Draw/NotifyPrefs.hs b/src/Matterhorn/Draw/NotifyPrefs.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/NotifyPrefs.hs
@@ -0,0 +1,35 @@
+module Matterhorn.Draw.NotifyPrefs
+  ( drawNotifyPrefs
+  )
+where
+
+import Prelude ()
+import Matterhorn.Prelude
+
+import Brick
+import Brick.Widgets.Border
+import Brick.Widgets.Center
+import Brick.Forms (renderForm)
+import Data.List (intersperse)
+
+import Matterhorn.Draw.Util (renderKeybindingHelp)
+import Matterhorn.Types
+import Matterhorn.Types.KeyEvents
+import Matterhorn.Themes
+
+drawNotifyPrefs :: ChatState -> Widget Name
+drawNotifyPrefs st =
+    let Just form = st^.csNotifyPrefs
+        label = forceAttr clientEmphAttr $ str "Notification Preferences"
+        formKeys = withDefAttr clientEmphAttr <$> txt <$> ["Tab", "BackTab"]
+        bindings = vBox $ hCenter <$> [ renderKeybindingHelp "Save" [FormSubmitEvent] <+> txt "  " <+>
+                                        renderKeybindingHelp "Cancel" [CancelEvent]
+                                      , hBox ((intersperse (txt "/") formKeys) <> [txt (":Cycle form fields")])
+                                      , hBox [withDefAttr clientEmphAttr $ txt "Space", txt ":Toggle form field"]
+                                      ]
+    in centerLayer $
+       vLimit 25 $
+       hLimit 39 $
+       joinBorders $
+       borderWithLabel label $
+       (padAll 1 $ renderForm form) <=> hBorder <=> bindings
diff --git a/src/Matterhorn/Draw/PostListOverlay.hs b/src/Matterhorn/Draw/PostListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/PostListOverlay.hs
@@ -0,0 +1,109 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TupleSections #-}
+
+module Matterhorn.Draw.PostListOverlay where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Widgets.Border
+import           Brick.Widgets.Center
+import           Control.Monad.Trans.Reader ( withReaderT )
+import qualified Data.Text as T
+import           Lens.Micro.Platform ( (^?), (%~), to )
+
+import           Network.Mattermost.Lenses
+import           Network.Mattermost.Types
+
+import           Matterhorn.Constants ( userSigil )
+import           Matterhorn.Draw.Messages
+import           Matterhorn.Draw.Util
+import           Matterhorn.Themes
+import           Matterhorn.Types
+
+
+hLimitWithPadding :: Int -> Widget n -> Widget n
+hLimitWithPadding pad contents = Widget
+  { hSize  = Fixed
+  , vSize  = (vSize contents)
+  , render =
+      withReaderT (& availWidthL  %~ (\ n -> n - (2 * pad))) $ render $ cropToContext contents
+  }
+
+drawPostListOverlay :: PostListContents -> ChatState -> Widget Name
+drawPostListOverlay contents st = joinBorders $ drawPostsBox contents st
+
+-- | Draw a PostListOverlay as a floating overlay on top of whatever
+-- is rendered beneath it
+drawPostsBox :: PostListContents -> ChatState -> Widget Name
+drawPostsBox contents st =
+  centerLayer $ hLimitWithPadding 10 $ borderWithLabel contentHeader $
+    padRight (Pad 1) messageListContents
+  where -- The 'window title' of the overlay
+        hs = getHighlightSet st
+        contentHeader = withAttr channelListHeaderAttr $ txt $ case contents of
+          PostListFlagged -> "Flagged posts"
+          PostListPinned cId ->
+              let cName = case findChannelById cId (st^.csChannels) of
+                      Nothing -> "<UNKNOWN>"
+                      Just cc -> mkChannelName st (cc^.ccInfo)
+              in "Posts pinned in " <> cName
+          PostListSearch terms searching -> "Search results" <> if searching
+            then ": " <> terms
+            else " (" <> (T.pack . show . length) messages <> "): " <> terms
+
+        messages = insertDateMarkers
+                     (filterMessages knownChannel $ st^.csPostListOverlay.postListPosts)
+                     (getDateFormat st)
+                     (st^.timeZone)
+
+        knownChannel msg =
+            case msg^.mChannelId of
+                Just cId | Nothing <- st^?csChannels.channelByIdL(cId) -> False
+                _ -> True
+
+        -- The overall contents, with a sensible default even if there
+        -- are no messages
+        messageListContents
+          | null messages =
+            padTopBottom 1 $
+            hCenter $
+            withDefAttr clientEmphAttr $
+            str $ case contents of
+              PostListFlagged -> "You have no flagged messages."
+              PostListPinned _ -> "This channel has no pinned messages."
+              PostListSearch _ searching ->
+                if searching
+                  then "Searching ..."
+                  else "No search results found"
+          | otherwise = vBox renderedMessageList
+
+        -- The render-message function we're using
+        renderMessageForOverlay msg tState =
+          let renderedMsg = renderSingleMessage st hs Nothing msg tState
+          in case msg^.mOriginalPost of
+            -- We should factor out some of the channel name logic at
+            -- some point, but we can do that later
+            Just post
+              | Just chan <- st^?csChannels.channelByIdL(post^.postChannelIdL) ->
+                 case chan^.ccInfo.cdType of
+                  Direct
+                    | Just u <- flip userById st =<< chan^.ccInfo.cdDMUserId ->
+                        (forceAttr channelNameAttr (txt (userSigil <> u^.uiName)) <=>
+                          (str "  " <+> renderedMsg))
+                  _ -> (forceAttr channelNameAttr (txt (chan^.ccInfo.to (mkChannelName st))) <=>
+                         (str "  " <+> renderedMsg))
+            _ | CP _ <- msg^.mType -> str "[BUG: unknown channel]"
+              | otherwise -> renderedMsg
+
+        -- The full message list, rendered with the current selection
+        renderedMessageList =
+          let (s, (before, after)) = splitDirSeqOn matchesMessage messagesWithStates
+              matchesMessage (m, _) = m^.mMessageId == (MessagePostId <$> st^.csPostListOverlay.postListSelected)
+              messagesWithStates = (, InThreadShowParent) <$> messages
+          in case s of
+            Nothing ->
+                map (uncurry renderMessageForOverlay) (reverse (toList messagesWithStates))
+            Just curMsg ->
+              [unsafeRenderMessageSelection (curMsg, (after, before)) renderMessageForOverlay]
diff --git a/src/Matterhorn/Draw/ReactionEmojiListOverlay.hs b/src/Matterhorn/Draw/ReactionEmojiListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/ReactionEmojiListOverlay.hs
@@ -0,0 +1,39 @@
+module Matterhorn.Draw.ReactionEmojiListOverlay
+  ( drawReactionEmojiListOverlay
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Widgets.List ( listSelectedFocusedAttr )
+import qualified Data.Text as T
+
+import           Matterhorn.Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )
+import           Matterhorn.Types
+import           Matterhorn.Themes
+
+
+drawReactionEmojiListOverlay :: ChatState -> Widget Name
+drawReactionEmojiListOverlay st =
+    let overlay = drawListOverlay (st^.csReactionEmojiListOverlay)
+                                  (const $ txt "Search Emoji")
+                                  (const $ txt "No matching emoji found.")
+                                  (const $ txt "Search emoji:")
+                                  renderEmoji
+                                  Nothing
+                                  OverlayCenter
+                                  80
+    in joinBorders overlay
+
+renderEmoji :: Bool -> (Bool, T.Text) -> Widget Name
+renderEmoji sel (mine, e) =
+    let maybeForce = if sel
+                     then forceAttr listSelectedFocusedAttr
+                     else id
+    in maybeForce $
+       padRight Max $
+       hBox [ if mine then txt " * " else txt "   "
+            , withDefAttr emojiAttr $ txt $ ":" <> e <> ":"
+            ]
diff --git a/src/Matterhorn/Draw/RichText.hs b/src/Matterhorn/Draw/RichText.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/RichText.hs
@@ -0,0 +1,619 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE ParallelListComp #-}
+{-# LANGUAGE RecordWildCards #-}
+module Matterhorn.Draw.RichText
+  ( MessageData(..)
+  , renderRichText
+  , renderMessage
+  , renderText
+  , renderText'
+  , renderElementSeq
+  , cursorSentinel
+  , addEllipsis
+  , findVerbatimChunk
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick ( (<+>), Widget, hLimit, imageL
+                       , raw, render, Size(..), Widget(..)
+                       )
+import qualified Brick as B
+import qualified Brick.Widgets.Border as B
+import qualified Brick.Widgets.Skylighting as BS
+import           Control.Monad.Reader
+import qualified Data.Foldable as F
+import qualified Data.Map.Strict as Map
+import qualified Data.Sequence as Seq
+import           Data.Sequence ( ViewL(..)
+                               , ViewR(..)
+                               , (<|)
+                               , (|>)
+                               , viewl
+                               , viewr)
+import qualified Data.Sequence as S
+import qualified Data.Set as Set
+import qualified Data.Text as T
+import qualified Graphics.Vty as V
+import qualified Skylighting.Core as Sky
+
+import           Network.Mattermost.Lenses ( postEditAtL, postCreateAtL )
+import           Network.Mattermost.Types ( ServerTime(..), PostId )
+
+import           Matterhorn.Constants ( normalChannelSigil, userSigil )
+import           Matterhorn.Themes
+import           Matterhorn.Types ( Name, HighlightSet(..) )
+import           Matterhorn.Types.Messages
+import           Matterhorn.Types.Posts
+import           Matterhorn.Types.RichText
+
+
+emptyHSet :: HighlightSet
+emptyHSet = HighlightSet Set.empty Set.empty mempty
+
+omittedUsernameType :: MessageType -> Bool
+omittedUsernameType = \case
+  CP Join -> True
+  CP Leave -> True
+  CP TopicChange -> True
+  _ -> False
+
+-- Add the edit sentinel to the end of the last block in the sequence.
+-- If the last block is a paragraph, append it to that paragraph.
+-- Otherwise, append a new block so it appears beneath the last
+-- block-level element.
+addEditSentinel :: ElementData -> Seq RichTextBlock -> Seq RichTextBlock
+addEditSentinel d bs =
+    case viewr bs of
+        EmptyR -> bs
+        (rest :> b) -> rest <> appendEditSentinel d b
+
+appendEditSentinel :: ElementData -> RichTextBlock -> Seq RichTextBlock
+appendEditSentinel sentinel b =
+    let s = Para (S.singleton m)
+        m = Element Normal sentinel
+    in case b of
+        Para is -> S.singleton $ Para (is |> Element Normal ESpace |> m)
+        _ -> S.fromList [b, s]
+
+-- | A bundled structure that includes all the information necessary
+-- to render a given message
+data MessageData =
+    MessageData { mdEditThreshold :: Maybe ServerTime
+                -- ^ If specified, any messages edited before this point
+                -- in time are not indicated as edited.
+                , mdShowOlderEdits :: Bool
+                -- ^ Indicates whether "edited" markers should be shown
+                -- for old messages (i.e., ignore the mdEditThreshold
+                -- value).
+                , mdShowReactions :: Bool
+                -- ^ Whether to render reactions.
+                , mdMessage :: Message
+                -- ^ The message to render.
+                , mdUserName :: Maybe Text
+                -- ^ The username of the message's author, if any. This
+                -- is passed here rather than obtaining from the message
+                -- because we need to do lookups in the ChatState to
+                -- compute this, and we don't pass the ChatState into
+                -- renderMessage.
+                , mdParentMessage :: Maybe Message
+                -- ^ The parent message of this message, if any.
+                , mdParentUserName :: Maybe Text
+                -- ^ The author of the parent message, if any.
+                , mdThreadState :: ThreadState
+                -- ^ The thread state of this message.
+                , mdRenderReplyParent :: Bool
+                -- ^ Whether to render the parent message.
+                , mdHighlightSet :: HighlightSet
+                -- ^ The highlight set to use to highlight usernames,
+                -- channel names, etc.
+                , mdIndentBlocks :: Bool
+                -- ^ Whether to indent the message underneath the
+                -- author's name (True) or just display it to the right
+                -- of the author's name (False).
+                , mdMessageWidthLimit :: Maybe Int
+                -- ^ A width override to use to wrap non-code blocks
+                -- and code blocks without syntax highlighting. If
+                -- unspecified, all blocks in the message will be
+                -- wrapped and truncated at the width specified by the
+                -- rendering context. If specified, all non-code blocks
+                -- will be wrapped at this width and highlighted code
+                -- blocks will be rendered using the context's width.
+                , mdMyUsername :: Text
+                -- ^ The username of the user running Matterhorn.
+                , mdWrapNonhighlightedCodeBlocks :: Bool
+                -- ^ Whether to wrap text in non-highlighted code
+                -- blocks.
+                }
+
+-- | renderMessage performs markdown rendering of the specified message.
+renderMessage :: MessageData -> Widget Name
+renderMessage md@MessageData { mdMessage = msg, .. } =
+    let msgUsr = case mdUserName of
+          Just u -> if omittedUsernameType (msg^.mType) then Nothing else Just u
+          Nothing -> Nothing
+        botElem = if isBotMessage msg then B.txt "[BOT]" else B.emptyWidget
+        nameElems = case msgUsr of
+          Just un
+            | isEmote msg ->
+                [ B.withDefAttr pinnedMessageIndicatorAttr $ B.txt $ if msg^.mPinned then "[PIN]" else ""
+                , B.txt $ (if msg^.mFlagged then "[!] " else "") <> "*"
+                , colorUsername mdMyUsername un un
+                , botElem
+                , B.txt " "
+                ]
+            | otherwise ->
+                [ B.withDefAttr pinnedMessageIndicatorAttr $ B.txt $ if msg^.mPinned then "[PIN] " else ""
+                , colorUsername mdMyUsername un un
+                , botElem
+                , B.txt $ (if msg^.mFlagged then "[!]" else "") <> ": "
+                ]
+          Nothing -> []
+
+        -- Use the editing threshold to determine whether to append an
+        -- editing indication to this message.
+        maybeAugment bs = case msg^.mOriginalPost of
+            Nothing -> bs
+            Just p ->
+                if p^.postEditAtL > p^.postCreateAtL
+                then case mdEditThreshold of
+                    Just cutoff | p^.postEditAtL >= cutoff ->
+                        addEditSentinel (EEditSentinel True) bs
+                    _ -> if mdShowOlderEdits
+                         then addEditSentinel (EEditSentinel False) bs
+                         else bs
+                else bs
+
+        augmentedText = maybeAugment $ msg^.mText
+        msgWidget =
+            vBox $ (layout mdHighlightSet mdMessageWidthLimit nameElems augmentedText . viewl) augmentedText :
+                   catMaybes [msgAtch, msgReac]
+        replyIndent = B.Widget B.Fixed B.Fixed $ do
+            ctx <- B.getContext
+            -- NB: The amount subtracted here must be the total padding
+            -- added below (pad 1 + vBorder)
+            w <- B.render $ B.hLimit (ctx^.B.availWidthL - 2) msgWidget
+            B.render $ B.vLimit (V.imageHeight $ w^.B.imageL) $
+                B.padRight (B.Pad 1) B.vBorder B.<+> (B.Widget B.Fixed B.Fixed $ return w)
+        msgAtch = if Seq.null (msg^.mAttachments)
+          then Nothing
+          else Just $ B.withDefAttr clientMessageAttr $ vBox
+                 [ B.txt ("  [attached: `" <> a^.attachmentName <> "`]")
+                 | a <- toList (msg^.mAttachments)
+                 ]
+        msgReac = if Map.null (msg^.mReactions) || (not mdShowReactions)
+          then Nothing
+          else let renderR e us =
+                       let n = Set.size us
+                       in if | n == 1    -> " [" <> e <> "]"
+                             | n > 1     -> " [" <> e <> " " <> T.pack (show n) <> "]"
+                             | otherwise -> ""
+                   reactionMsg = Map.foldMapWithKey renderR (msg^.mReactions)
+               in Just $ B.withDefAttr emojiAttr $ B.txt ("   " <> reactionMsg)
+        withParent p =
+            case mdThreadState of
+                NoThread -> msgWidget
+                InThreadShowParent -> p B.<=> replyIndent
+                InThread -> replyIndent
+    in if not mdRenderReplyParent
+       then msgWidget
+       else case msg^.mInReplyToMsg of
+          NotAReply -> msgWidget
+          InReplyTo _ ->
+              case mdParentMessage of
+                  Nothing -> withParent (B.str "[loading...]")
+                  Just pm ->
+                      let parentMsg = renderMessage md
+                            { mdShowOlderEdits    = False
+                            , mdMessage           = pm
+                            , mdUserName          = mdParentUserName
+                            , mdParentMessage     = Nothing
+                            , mdRenderReplyParent = False
+                            , mdIndentBlocks      = False
+                            }
+                      in withParent (addEllipsis $ B.forceAttr replyParentAttr parentMsg)
+
+    where
+        layout :: HighlightSet -> Maybe Int -> [Widget Name] -> Seq RichTextBlock
+               -> ViewL RichTextBlock -> Widget Name
+        layout hs w nameElems bs xs | length xs > 1     = multiLnLayout hs w nameElems bs
+        layout hs w nameElems bs (Blockquote {} :< _) = multiLnLayout hs w nameElems bs
+        layout hs w nameElems bs (CodeBlock {} :< _)  = multiLnLayout hs w nameElems bs
+        layout hs w nameElems bs (HTMLBlock {} :< _)  = multiLnLayout hs w nameElems bs
+        layout hs w nameElems bs (List {} :< _)       = multiLnLayout hs w nameElems bs
+        layout hs w nameElems bs (Para inlns :< _)
+            | F.any breakCheck inlns                    = multiLnLayout hs w nameElems bs
+        layout hs w nameElems bs _                      = nameNextToMessage hs w nameElems bs
+
+        multiLnLayout hs w nameElems bs =
+            if mdIndentBlocks
+               then vBox [ hBox nameElems
+                         , hBox [B.txt "  ", renderRichText mdMyUsername hs ((subtract 2) <$> w) mdWrapNonhighlightedCodeBlocks bs]
+                         ]
+               else nameNextToMessage hs w nameElems bs
+
+        nameNextToMessage hs w nameElems bs =
+            Widget Fixed Fixed $ do
+                nameResult <- render $ hBox nameElems
+                let newW = subtract (V.imageWidth (nameResult^.imageL)) <$> w
+                render $ hBox [raw (nameResult^.imageL), renderRichText mdMyUsername hs newW mdWrapNonhighlightedCodeBlocks bs]
+
+        breakCheck e = eData e `elem` [ELineBreak, ESoftBreak]
+
+addEllipsis :: Widget a -> Widget a
+addEllipsis w = B.Widget (B.hSize w) (B.vSize w) $ do
+    ctx <- B.getContext
+    let aw = ctx^.B.availWidthL
+    result <- B.render w
+    let withEllipsis = (B.hLimit (aw - 3) $ B.vLimit 1 $ (B.Widget B.Fixed B.Fixed $ return result)) <+>
+                       B.str "..."
+    if (V.imageHeight (result^.B.imageL) > 1) || (V.imageWidth (result^.B.imageL) == aw) then
+        B.render withEllipsis else
+        return result
+
+-- Cursor sentinel for tracking the user's cursor position in previews.
+cursorSentinel :: Char
+cursorSentinel = '‸'
+
+-- Render markdown with username highlighting
+renderRichText :: Text -> HighlightSet -> Maybe Int -> Bool -> Seq RichTextBlock -> Widget a
+renderRichText curUser hSet w wrap bs =
+    runReader (do
+              blocks <- mapM blockToWidget (addBlankLines bs)
+              return $ B.vBox $ toList blocks)
+              (DrawCfg { drawCurUser = curUser
+                       , drawHighlightSet = hSet
+                       , drawLineWidth = w
+                       , drawDoLineWrapping = wrap
+                       })
+
+-- Add blank lines only between adjacent elements of the same type, to
+-- save space
+addBlankLines :: Seq RichTextBlock -> Seq RichTextBlock
+addBlankLines = go' . viewl
+  where go' EmptyL = S.empty
+        go' (x :< xs) = go x (viewl xs)
+        go a@Para {} (b@Para {} :< rs) =
+             a <| blank <| go b (viewl rs)
+        go a@Header {} (b@Header {} :< rs) =
+             a <| blank <| go b (viewl rs)
+        go a@Blockquote {} (b@Blockquote {} :< rs) =
+             a <| blank <| go b (viewl rs)
+        go a@List {} (b@List {} :< rs) =
+             a <| blank <| go b (viewl rs)
+        go a@CodeBlock {} (b@CodeBlock {} :< rs) =
+             a <| blank <| go b (viewl rs)
+        go a@HTMLBlock {} (b@HTMLBlock {} :< rs) =
+             a <| blank <| go b (viewl rs)
+        go x (y :< rs) = x <| go y (viewl rs)
+        go x (EmptyL) = S.singleton x
+        blank = Para (S.singleton (Element Normal ESpace))
+
+-- Render text to markdown without username highlighting or permalink detection
+renderText :: Text -> Widget a
+renderText txt = renderText' Nothing "" emptyHSet txt
+
+renderText' :: Maybe TeamBaseURL -> Text -> HighlightSet -> Text -> Widget a
+renderText' baseUrl curUser hSet t = renderRichText curUser hSet Nothing True rtBs
+  where rtBs = parseMarkdown baseUrl t
+
+vBox :: F.Foldable f => f (Widget a) -> Widget a
+vBox = B.vBox . toList
+
+hBox :: F.Foldable f => f (Widget a) -> Widget a
+hBox = B.hBox . toList
+
+header :: Int -> Widget a
+header n = B.txt (T.replicate n "#")
+
+maybeHLimit :: Maybe Int -> Widget a -> Widget a
+maybeHLimit Nothing w = w
+maybeHLimit (Just i) w = hLimit i w
+
+type M a = Reader DrawCfg a
+
+data DrawCfg =
+    DrawCfg { drawCurUser :: Text
+            , drawHighlightSet :: HighlightSet
+            , drawLineWidth :: Maybe Int
+            , drawDoLineWrapping :: Bool
+            }
+
+blockToWidget :: RichTextBlock -> M (Widget a)
+blockToWidget (Para is) =
+    toElementChunk is
+blockToWidget (Header n is) = do
+    headerTxt <- withReader (\c -> c { drawLineWidth = subtract 1 <$> drawLineWidth c }) $
+                 toElementChunk is
+    return $ B.withDefAttr clientHeaderAttr $
+        hBox [ B.padRight (B.Pad 1) $ header n
+             , headerTxt
+             ]
+blockToWidget (Blockquote is) = do
+    w <- asks drawLineWidth
+    bs <- mapM blockToWidget is
+    return $ maybeHLimit w $ addQuoting $ vBox bs
+blockToWidget (List _ l bs) = do
+    w <- asks drawLineWidth
+    lst <- blocksToList l bs
+    return $ maybeHLimit w lst
+blockToWidget (CodeBlock ci tx) = do
+    hSet <- asks drawHighlightSet
+
+    let f = maybe rawCodeBlockToWidget
+                  (codeBlockToWidget (hSyntaxMap hSet))
+                  mSyntax
+        mSyntax = do
+            lang <- codeBlockLanguage ci
+            Sky.lookupSyntax lang (hSyntaxMap hSet)
+    f tx
+blockToWidget (HTMLBlock t) = do
+    w <- asks drawLineWidth
+    return $ maybeHLimit w $ textWithCursor t
+blockToWidget (HRule) = do
+    w <- asks drawLineWidth
+    return $ maybeHLimit w $ B.vLimit 1 (B.fill '*')
+
+quoteChar :: Char
+quoteChar = '>'
+
+addQuoting :: B.Widget n -> B.Widget n
+addQuoting w =
+    B.Widget B.Fixed (B.vSize w) $ do
+        ctx <- B.getContext
+        childResult <- B.render $ B.hLimit (ctx^.B.availWidthL - 2) w
+
+        let quoteBorder = B.raw $ V.charFill (ctx^.B.attrL) quoteChar 1 height
+            height = V.imageHeight $ childResult^.B.imageL
+
+        B.render $ B.hBox [ B.padRight (B.Pad 1) quoteBorder
+                          , B.Widget B.Fixed B.Fixed $ return childResult
+                          ]
+
+codeBlockToWidget :: Sky.SyntaxMap -> Sky.Syntax -> Text -> M (Widget a)
+codeBlockToWidget syntaxMap syntax tx = do
+    let result = Sky.tokenize cfg syntax tx
+        cfg = Sky.TokenizerConfig syntaxMap False
+    case result of
+        Left _ -> rawCodeBlockToWidget tx
+        Right tokLines -> do
+            let padding = B.padLeftRight 1 (B.vLimit (length tokLines) B.vBorder)
+            return $ (B.txt $ "[" <> Sky.sName syntax <> "]") B.<=>
+                     (padding <+> BS.renderRawSource textWithCursor tokLines)
+
+rawCodeBlockToWidget :: Text -> M (Widget a)
+rawCodeBlockToWidget tx = do
+    wrap <- asks drawDoLineWrapping
+
+    let hPolicy = if wrap then Greedy else Fixed
+    return $ B.withDefAttr codeAttr $
+        Widget hPolicy Fixed $ do
+            c <- B.getContext
+            let theLines = expandEmpty <$> T.lines tx
+                expandEmpty "" = " "
+                expandEmpty s  = s
+                wrapFunc = if wrap then wrappedTextWithCursor
+                                   else textWithCursor
+            renderedText <- render (B.hLimit (c^.B.availWidthL - 3) $ B.vBox $
+                                    wrapFunc <$> theLines)
+
+            let textHeight = V.imageHeight $ renderedText^.imageL
+                padding = B.padLeftRight 1 (B.vLimit textHeight B.vBorder)
+
+            render $ padding <+> (Widget Fixed Fixed $ return renderedText)
+
+toElementChunk :: Seq Element -> M (Widget a)
+toElementChunk es = do
+    w <- asks drawLineWidth
+    hSet <- asks drawHighlightSet
+    curUser <- asks drawCurUser
+
+    return $ B.Widget B.Fixed B.Fixed $ do
+        ctx <- B.getContext
+        let width = fromMaybe (ctx^.B.availWidthL) w
+            ws    = fmap (renderElementSeq curUser) (wrapLine width hSet es)
+        B.render (vBox (fmap hBox ws))
+
+blocksToList :: ListType -> Seq (Seq RichTextBlock) -> M (Widget a)
+blocksToList lt bs = do
+    let is = case lt of
+          Bullet _ -> repeat ("• ")
+          Numbered Period s ->
+            [ T.pack (show (n :: Int)) <> ". " | n <- [s..] ]
+          Numbered Paren s ->
+            [ T.pack (show (n :: Int)) <> ") " | n <- [s..] ]
+
+    results <- forM (zip is $ F.toList bs) $ \(i, b) -> do
+        blocks <- mapM blockToWidget b
+        return $ B.txt i <+> vBox blocks
+
+    return $ vBox results
+
+data SplitState = SplitState
+  { splitChunks  :: Seq (Seq Element)
+  , splitCurrCol :: Int
+  }
+
+wrapLine :: Int -> HighlightSet -> Seq Element -> Seq (Seq Element)
+wrapLine maxCols hSet = splitChunks . go (SplitState (S.singleton S.empty) 0)
+  where go st (viewl-> e :< es) = go st' es
+          where
+              HighlightSet { hUserSet = uSet, hChannelSet = cSet } = hSet
+
+              -- Right before we check the width of the token, we see
+              -- if the token is a user or channel reference. If so, we
+              -- check if it is valid. If it is valid, we leave it in
+              -- place; otherwise we translate it into an ordinary text
+              -- element so that it does not render highlighted as a
+              -- valid user or channel reference.
+              newElement = e { eData = newEData }
+              newEData = case eData e of
+                  EUser u ->
+                      if u `Set.member` uSet
+                      then EUser u
+                      else EText $ userSigil <> u
+                  EChannel c ->
+                      if c `Set.member` cSet
+                      then EChannel c
+                      else EText $ normalChannelSigil <> c
+                  d -> d
+
+              addHyperlink url el = setElementStyle (Hyperlink url (eStyle el)) el
+
+              linkOpenBracket = Element Normal (EText "<")
+              linkCloseBracket = Element Normal (EText ">")
+              addOpenBracket l =
+                  case Seq.viewl l of
+                      EmptyL -> l
+                      h :< t ->
+                          let h' = Element Normal
+                                           (ENonBreaking $ Seq.fromList [linkOpenBracket, h])
+                          in h' <| t
+              addCloseBracket l =
+                  case Seq.viewr l of
+                      EmptyR -> l
+                      h :> t ->
+                          let t' = Element Normal
+                                           (ENonBreaking $ Seq.fromList [t, linkCloseBracket])
+                          in h |> t'
+              decorateLinkLabel = addOpenBracket .  addCloseBracket
+
+              st' =
+                  case newEData of
+                      EHyperlink url (Just labelEs) ->
+                          go st $ addHyperlink url <$> decorateLinkLabel labelEs
+                      EHyperlink url Nothing ->
+                          go st $ addHyperlink url <$> decorateLinkLabel (Seq.fromList [Element Normal $ EText $ unURL url])
+                      EPermalink _tName _pId (Just labelEs) ->
+                          go st $ setElementStyle Permalink <$> decorateLinkLabel labelEs
+                      EImage url (Just labelEs) ->
+                          go st $ addHyperlink url <$> decorateLinkLabel labelEs
+                      _ ->
+                          if | newEData == ESoftBreak || newEData == ELineBreak ->
+                                 st { splitChunks = splitChunks st |> S.empty
+                                    , splitCurrCol = 0
+                                    }
+                             | available >= eWidth ->
+                                 st { splitChunks  = addElement newElement (splitChunks st)
+                                    , splitCurrCol = splitCurrCol st + eWidth
+                                    }
+                             | newEData == ESpace ->
+                                 st { splitChunks = splitChunks st |> S.empty
+                                    , splitCurrCol = 0
+                                    }
+                             | otherwise ->
+                                 st { splitChunks  = splitChunks st |> S.singleton newElement
+                                    , splitCurrCol = eWidth
+                                    }
+              available = maxCols - splitCurrCol st
+              eWidth = elementWidth newElement
+              addElement x (viewr-> ls :> l) = ( ls |> (l |> x))
+              addElement _ _ = error "[unreachable]"
+        go st _                 = st
+
+renderElementSeq :: Text -> Seq Element -> Seq (Widget a)
+renderElementSeq curUser es = renderElement curUser <$> es
+
+renderElement :: Text -> Element -> Widget a
+renderElement curUser e = addStyle sty widget
+    where
+        sty = eStyle e
+        dat = eData e
+        addStyle s = case s of
+                Normal                  -> id
+                Emph                    -> B.withDefAttr clientEmphAttr
+                Strikethrough           -> B.withDefAttr strikeThroughAttr
+                Strong                  -> B.withDefAttr clientStrongAttr
+                Code                    -> B.withDefAttr codeAttr
+                Permalink               -> B.withDefAttr permalinkAttr
+                Hyperlink (URL url) innerSty ->
+                    B.hyperlink url . B.withDefAttr urlAttr .  addStyle innerSty
+        rawText = B.txt . removeCursor
+        widget = case dat of
+            -- Cursor sentinels get parsed as individual text nodes by
+            -- Cheapskate, meaning that we get a text node with exactly
+            -- one character in it. That's why we compare accordingly
+            -- above. In addition, since that character has no width, we
+            -- have to replace it with something that has a size (such
+            -- as a space) to get Brick's visibility logic to scroll the
+            -- viewport to get it into view.
+            EText t                      -> if t == T.singleton (cursorSentinel)
+                                            then B.visible $ B.txt " "
+                                            else textWithCursor t
+
+            ESpace                       -> B.txt " "
+            EPermalink _ pId mLabel      -> drawPermalink curUser pId mLabel
+            ENonBreaking es              -> hBox $ renderElement curUser <$> es
+            ERawHtml t                   -> textWithCursor t
+            EEditSentinel recent         -> let attr = if recent
+                                                       then editedRecentlyMarkingAttr
+                                                       else editedMarkingAttr
+                                            in B.withDefAttr attr $ B.txt editMarking
+            EUser u                      -> colorUsername curUser u $ userSigil <> u
+            EChannel c                   -> B.withDefAttr channelNameAttr $
+                                            B.txt $ normalChannelSigil <> c
+            EHyperlink (URL url) Nothing -> rawText url
+            EImage (URL url) Nothing     -> rawText url
+            EEmoji em                    -> B.withDefAttr emojiAttr $
+                                            B.txt $ ":" <> em <> ":"
+
+            -- Hyperlink and image nodes with labels should not appear
+            -- at this point because line-wrapping should break them up
+            -- into normal text node sequences with hyperlink styles
+            -- attached.
+            EHyperlink {}                -> rawText "(Report renderElement bug #1)"
+            EImage {}                    -> rawText "(Report renderElement bug #2)"
+
+            -- Line breaks should never need to get rendered since the
+            -- line-wrapping algorithm removes them.
+            ESoftBreak                   -> B.emptyWidget
+            ELineBreak                   -> B.emptyWidget
+
+drawPermalink :: Text -> PostId -> Maybe (Seq Element) -> Widget a
+drawPermalink _ _ Nothing =
+    B.txt permalinkPlaceholder
+drawPermalink curUser _ (Just label) =
+    hBox $ F.toList $ B.txt "<" <| (renderElementSeq curUser label |> B.txt ">")
+
+permalinkPlaceholder :: Text
+permalinkPlaceholder = "<post link>"
+
+textWithCursor :: Text -> Widget a
+textWithCursor t
+    | T.any (== cursorSentinel) t = B.visible $ B.txt $ removeCursor t
+    | otherwise = B.txt t
+
+wrappedTextWithCursor :: Text -> Widget a
+wrappedTextWithCursor t
+    | T.any (== cursorSentinel) t = B.visible $ B.txtWrap $ removeCursor t
+    | otherwise = B.txtWrap t
+
+removeCursor :: Text -> Text
+removeCursor = T.filter (/= cursorSentinel)
+
+editMarking :: Text
+editMarking = "(edited)"
+
+elementWidth :: Element -> Int
+elementWidth e =
+    case eData e of
+        ENonBreaking es              -> sum $ elementWidth <$> es
+        EText t                      -> B.textWidth t
+        ERawHtml t                   -> B.textWidth t
+        EUser t                      -> T.length userSigil + B.textWidth t
+        EChannel t                   -> T.length normalChannelSigil + B.textWidth t
+        EEditSentinel _              -> B.textWidth editMarking
+        EImage (URL url) Nothing     -> B.textWidth url
+        EImage _ (Just is)           -> sum $ elementWidth <$> is
+        EHyperlink (URL url) Nothing -> B.textWidth url
+        EHyperlink _ (Just is)       -> sum $ elementWidth <$> is
+        EEmoji t                     -> B.textWidth t + 2
+        EPermalink _ _ Nothing       -> T.length permalinkPlaceholder
+        EPermalink _ _ (Just label)  -> 2 + (sum $ elementWidth <$> label)
+        ESpace                       -> 1
+        ELineBreak                   -> 0
+        ESoftBreak                   -> 0
diff --git a/src/Matterhorn/Draw/ShowHelp.hs b/src/Matterhorn/Draw/ShowHelp.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/ShowHelp.hs
@@ -0,0 +1,566 @@
+module Matterhorn.Draw.ShowHelp
+  ( drawShowHelp
+  , keybindingMarkdownTable
+  , keybindingTextTable
+  , commandTextTable
+  , commandMarkdownTable
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Themes ( themeDescriptions )
+import           Brick.Widgets.Center ( hCenter )
+import           Brick.Widgets.List ( listSelectedFocusedAttr )
+import qualified Data.Map as M
+import qualified Data.Text as T
+import qualified Graphics.Vty as Vty
+import           Lens.Micro.Platform ( singular, _Just, _2 )
+
+import           Network.Mattermost.Version ( mmApiVersion )
+
+import           Matterhorn.Command
+import           Matterhorn.Events
+import           Matterhorn.Events.ChannelSelect
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.Events.Main
+import           Matterhorn.Events.MessageSelect
+import           Matterhorn.Events.ThemeListOverlay
+import           Matterhorn.Events.PostListOverlay
+import           Matterhorn.Events.ShowHelp
+import           Matterhorn.Events.UrlSelect
+import           Matterhorn.Events.UserListOverlay
+import           Matterhorn.Events.ChannelListOverlay
+import           Matterhorn.Events.ReactionEmojiListOverlay
+import           Matterhorn.Events.ManageAttachments
+import           Matterhorn.Events.TabbedWindow
+import           Matterhorn.Windows.ViewMessage
+import           Matterhorn.HelpTopics ( helpTopics )
+import           Matterhorn.Draw.RichText ( renderText )
+import           Matterhorn.Options ( mhVersion )
+import           Matterhorn.State.Editing ( editingKeyHandlers )
+import           Matterhorn.Themes
+import           Matterhorn.Types
+import           Matterhorn.Types.KeyEvents ( BindingState(..), Binding(..)
+                                 , ppBinding, nonCharKeys, eventToBinding )
+
+
+drawShowHelp :: HelpTopic -> ChatState -> [Widget Name]
+drawShowHelp topic st =
+    [helpBox (helpTopicViewportName topic) $ helpTopicDraw topic st]
+
+helpTopicDraw :: HelpTopic -> ChatState -> Widget Name
+helpTopicDraw topic st =
+    overrideAttr codeAttr helpEmphAttr $
+    hCenter $
+    hLimit helpContentWidth $
+    case helpTopicScreen topic of
+        MainHelp -> mainHelp (configUserKeys (st^.csResources.crConfiguration))
+        ScriptHelp -> scriptHelp
+        ThemeHelp -> themeHelp
+        SyntaxHighlightHelp -> syntaxHighlightHelp (configSyntaxDirs $ st^.csResources.crConfiguration)
+        KeybindingHelp -> keybindingHelp (configUserKeys (st^.csResources.crConfiguration))
+
+mainHelp :: KeyConfig -> Widget Name
+mainHelp kc = summary
+  where
+    summary = vBox entries
+    entries = [ heading $ T.pack mhVersion
+              , headingNoPad $ T.pack mmApiVersion
+              , heading "Help Topics"
+              , drawHelpTopics
+              , heading "Commands"
+              , padTop (Pad 1) mkCommandHelpText
+              , heading "Keybindings"
+              ] <>
+              (mkKeybindingHelp kc <$> keybindSections)
+
+    mkCommandHelpText :: Widget Name
+    mkCommandHelpText =
+      let commandNameWidth = 2 + (maximum $ T.length <$> fst <$> commandHelpInfo)
+      in vBox [ (emph $ txt $ padTo commandNameWidth info) <+> renderText desc
+              | (info, desc) <- commandHelpInfo
+              ]
+
+commandHelpInfo :: [(T.Text, T.Text)]
+commandHelpInfo = pairs
+    where
+        pairs = [ (info, desc)
+                | Cmd cmd desc args _ <- cs
+                , let argSpec = printArgSpec args
+                      spc = if T.null argSpec then "" else " "
+                      info = T.cons '/' cmd <> spc <> argSpec
+                ]
+        cs = sortWith commandName commandList
+
+commandTextTable :: T.Text
+commandTextTable =
+    let commandNameWidth = 4 + (maximum $ T.length <$> fst <$> commandHelpInfo)
+    in T.intercalate "\n" $
+       [ padTo commandNameWidth info <> desc
+       | (info, desc) <- commandHelpInfo
+       ]
+
+commandMarkdownTable :: T.Text
+commandMarkdownTable =
+    T.intercalate "\n" $
+    [ "# Commands"
+    , ""
+    , "| Command | Description |"
+    , "| ------- | ----------- |"
+    ] <>
+    [ "| `" <> info <> "` | " <> desc <> " |"
+    | (info, desc) <- commandHelpInfo
+    ]
+
+drawHelpTopics :: Widget Name
+drawHelpTopics =
+    let allHelpTopics = drawTopic <$> helpTopics
+        topicNameWidth = 4 + (maximum $ T.length <$> helpTopicName <$> helpTopics)
+        drawTopic t = (emph $ txt (padTo topicNameWidth $ helpTopicName t)) <+>
+                      txt (helpTopicDescription t)
+    in vBox $ (padBottom (Pad 1) $
+               para "Learn more about these topics with `/help <topic>`:")
+            : allHelpTopics
+
+helpContentWidth :: Int
+helpContentWidth = 72
+
+scriptHelp :: Widget Name
+scriptHelp = heading "Using Scripts" <=> vBox scriptHelpText
+  where scriptHelpText = map paraL
+          [ [ "Matterhorn has a special feature that allows you to use "
+             , "prewritten shell scripts to preprocess messages. "
+             , "For example, this can allow you to run various filters over "
+             , "your written text, do certain kinds of automated formatting, "
+             , "or just automatically cowsay-ify a message." ]
+           , [ "These scripts can be any kind of executable file, "
+             , "as long as the file lives in "
+             , "*~/.config/matterhorn/scripts* (unless you've explicitly "
+             , "moved your XDG configuration directory elsewhere). "
+             , "Those executables are given no arguments "
+             , "on the command line and are passed your typed message on "
+             , "*stdin*; whatever they produce on *stdout* is sent "
+             , "as a message. If the script exits successfully, then everything "
+             , "that appeared on *stderr* is discarded; if it instead exits with "
+             , "a failing exit code, your message is *not* sent, and you are "
+             , "presented with whatever was printed on stderr as a "
+             , "local error message." ]
+           , [ "To run a script, simply type" ]
+           , [ "> *> /sh [script-name] [my-message]*" ]
+           , [ "And the script named *[script-name]* will be invoked with "
+             , "the text of *[my-message]*. If the script does not exist, "
+             , "or if it exists but is not marked as executable, you'll be "
+             , "presented with an appropriate error message." ]
+           , [ "For example, if you want to use a basic script to "
+             , "automatically ROT13 your message, you can write a shell "
+             , "script using the standard Unix *tr* utility, like this:" ]
+           , [ "> *#!/bin/bash -e*"
+             , "> *tr '[A-Za-z]' '[N-ZA-Mn-za-m]'*" ]
+           , [ "Move this script to *~/.config/matterhorn/scripts/rot13* "
+             , "and be sure it's executable with" ]
+           , [ "> *$ chmod u+x ~/.config/matterhorn/scripts/rot13*" ]
+           , [ "after which you can send ROT13 messages with the "
+             , "Matterhorn command " ]
+           , [ "> *> /sh rot13 Hello, world!*" ]
+           ]
+
+keybindingMarkdownTable :: KeyConfig -> Text
+keybindingMarkdownTable kc = title <> keybindSectionStrings
+    where title = "# Keybindings\n"
+          keybindSectionStrings = T.concat $ sectionText <$> keybindSections
+          sectionText = mkKeybindEventSectionHelp kc keybindEventHelpMarkdown T.unlines mkHeading
+          mkHeading n =
+              "\n# " <> n <>
+              "\n| Keybinding | Event Name | Description |" <>
+              "\n| ---------- | ---------- | ----------- |"
+
+keybindingTextTable :: KeyConfig -> Text
+keybindingTextTable kc = title <> keybindSectionStrings
+    where title = "Keybindings\n===========\n"
+          keybindSectionStrings = T.concat $ sectionText <$> keybindSections
+          sectionText = mkKeybindEventSectionHelp kc (keybindEventHelpText keybindingWidth eventNameWidth) T.unlines mkHeading
+          keybindingWidth = 15
+          eventNameWidth = 30
+          mkHeading n =
+              "\n" <> n <>
+              "\n" <> (T.replicate (T.length n) "=")
+
+keybindingHelp :: KeyConfig -> Widget Name
+keybindingHelp kc = vBox $
+  [ heading "Configurable Keybindings"
+  , padBottom (Pad 1) $ vBox keybindingHelpText
+  ] ++ keybindSectionWidgets
+    ++
+  [ headingNoPad "Keybinding Syntax"
+  , vBox validKeys
+  ]
+  where keybindSectionWidgets = sectionWidget <$> keybindSections
+        sectionWidget = mkKeybindEventSectionHelp kc keybindEventHelpWidget vBox headingNoPad
+
+        keybindingHelpText = map paraL
+          [ [ "Many of the keybindings used in Matterhorn can be "
+            , "modified from within Matterhorn's **config.ini** file. "
+            , "To do this, include a section called **[KEYBINDINGS]** "
+            , "in your config file and use the event names listed below as "
+            , "keys and the desired key sequence as values. "
+            , "See the end of this page for documentation on the valid "
+            , "syntax for key sequences."
+            ]
+          , [ "For example, by default, the keybinding to move to the next "
+            , "channel in the public channel list is **"
+            , nextChanBinding
+            , "**, and the corresponding "
+            , "previous channel binding is **"
+            , prevChanBinding
+            , "**. You might want to remap these "
+            , "to other keys: say, **C-j** and **C-k**. We can do this with the following "
+            , "configuration snippet:"
+            ]
+          , [ "```ini\n"
+            , "[KEYBINDINGS]\n"
+            , "focus-next-channel = C-j\n"
+            , "focus-prev-channel = C-k\n"
+            , "```"
+            ]
+          , [ "You can remap a command to more than one key sequence, in which "
+            , "case any one of the key sequences provided can be used to invoke "
+            , "the relevant command. To do this, provide the desired bindings as "
+            , "a comma-separated list. Additionally, some key combinations are "
+            , "used in multiple modes (such as URL select or help viewing) and "
+            , "therefore share the same name, such as **cancel** or **scroll-up**."
+            ]
+          , [ "Additionally, some keys simply cannot be remapped, mostly in the "
+            , "case of editing keybindings. If you feel that a particular key "
+            , "event should be rebindable and isn't, then please feel free to "
+            , "let us know by posting an issue in the Matterhorn issue tracker."
+            ]
+          , [ "It is also possible to entirely unbind a key event by setting its "
+            , "key to **unbound**, thus avoiding conflicts between default bindings "
+            , "and new ones:"
+            ]
+          , [ "```ini\n"
+            , "[KEYBINDINGS]\n"
+            , "focus-next-channel = unbound\n"
+            , "```"
+            ]
+          , [ "The rebindable key events, along with their **current** "
+            , "values, are as follows:"
+            ]
+           ]
+        nextChanBinding = ppBinding (getFirstDefaultBinding NextChannelEvent)
+        prevChanBinding = ppBinding (getFirstDefaultBinding PrevChannelEvent)
+        validKeys = map paraL
+          [ [ "The syntax used for key sequences consists of zero or more "
+            , "single-character modifier characters followed by a keystroke, "
+            , "all separated by dashes. The available modifier keys are "
+            , "**S** for Shift, **C** for Ctrl, **A** for Alt, and **M** for "
+            , "Meta. So, for example, **"
+            , ppBinding (Binding [] (Vty.KFun 2))
+            , "** is the F2 key pressed with no "
+            , "modifier keys; **"
+            , ppBinding (Binding [Vty.MCtrl] (Vty.KChar 'x'))
+            , "** is Ctrl and X pressed together, "
+            , "and **"
+            , ppBinding (Binding [Vty.MShift, Vty.MCtrl] (Vty.KChar 'x'))
+            , "** is Shift, Ctrl, and X all pressed together. "
+            , "Although Matterhorn will pretty-print all key combinations "
+            , "with specific capitalization, the parser is **not** case-sensitive "
+            , "and will ignore any capitalization."
+            ]
+          , [ "Your terminal emulator might not recognize some particular "
+            , "keypress combinations, or it might reserve certain combinations of "
+            , "keys for some terminal-specific operation. Matterhorn does not have a "
+            , "reliable way of testing this, so it is up to you to avoid setting "
+            , "keybindings that your terminal emulator does not deliver to applications."
+            ]
+          , [ "Letter keys, number keys, and function keys are specified with "
+            , "their obvious name, such as **x** for the X key, **8** for the 8 "
+            , "key, and **f5** for the F5 key. Other valid keys include: "
+            , T.intercalate ", " [ "**" <> key <> "**" | key <- nonCharKeys ]
+            , "."
+            ]
+          ]
+
+emph :: Widget a -> Widget a
+emph = withDefAttr helpEmphAttr
+
+para :: Text -> Widget a
+para t = padTop (Pad 1) $ renderText t
+
+paraL :: [Text] -> Widget a
+paraL = para . mconcat
+
+heading :: Text -> Widget a
+heading = padTop (Pad 1) . headingNoPad
+
+headingNoPad :: Text -> Widget a
+headingNoPad t = hCenter $ emph $ renderText t
+
+syntaxHighlightHelp :: [FilePath] -> Widget a
+syntaxHighlightHelp dirs = vBox
+  [ heading "Syntax Highlighting"
+
+  , para $ "Matterhorn supports syntax highlighting in Markdown code blocks when the " <>
+           "name of the code block language follows the block opening sytnax:"
+  , para $ "```<language>"
+  , para $ "The possible values of `language` are determined by the available syntax " <>
+           "definitions. The available definitions are loaded from the following " <>
+           "directories according to the configuration setting `syntaxDirectories`. " <>
+           "If the setting is omitted, it defaults to the following sequence of directories:"
+  , para $ T.pack $ intercalate "\n" $ (\d -> "`" <> d <> "`") <$> dirs
+  , para $ "Syntax definitions are in the Kate XML format. Files with an " <>
+           "`xml` extension are loaded from each directory, with directories earlier " <>
+           "in the list taking precedence over later directories when more than one " <>
+           "directory provides a definition file for the same syntax."
+  , para $ "To place custom definitions in a directory, place a Kate " <>
+           "XML syntax definition in the directory and ensure that a copy of " <>
+           "`language.dtd` is also present. The file `language.dtd` can be found in " <>
+           "the `syntax/` directory of your Matterhorn distribution."
+  ]
+
+themeHelp :: Widget a
+themeHelp = vBox
+  [ heading "Using Themes"
+  , para "Matterhorn provides these built-in color themes:"
+  , padTop (Pad 1) $ vBox $ hCenter <$> emph <$>
+                            txt <$> internalThemeName <$> internalThemes
+  , para $
+        "These themes can be selected with the */theme* command. To automatically " <>
+        "select a theme at startup, set the *theme* configuration file option to one " <>
+        "of the themes listed above."
+
+  , heading "Customizing the Theme"
+  , para $
+        "Theme customization is also supported. To customize the selected theme, " <>
+        "create a theme customization file and set the `themeCustomizationFile` " <>
+        "configuration option to the path to the customization file. If the path " <>
+        "to the file is relative, Matterhorn will look for it in the same directory " <>
+        "as the Matterhorn configuration file."
+
+  , para $
+        "Theme customization files are INI-style files that can customize any " <>
+        "foreground color, background color, or style of any aspect of the " <>
+        "Matterhorn user interface. Here is an example:"
+
+  , para $
+        "```\n" <>
+        "[default]\n" <>
+        "default.fg = blue\n" <>
+        "default.bg = black\n" <>
+        "\n" <>
+        "[other]\n" <>
+        attrNameToConfig codeAttr <> ".fg = magenta\n" <>
+        attrNameToConfig codeAttr <> ".style = bold\n" <>
+        attrNameToConfig clientEmphAttr <> ".fg = cyan\n" <>
+        attrNameToConfig clientEmphAttr <> ".style = [bold, underline]\n" <>
+        attrNameToConfig listSelectedFocusedAttr <> ".fg = brightGreen\n" <>
+        "```"
+
+  , para $
+        "In the example above, the theme's default foreground and background colors " <>
+        "are both customized to *blue* and *black*, respectively. The *default* section " <>
+        "contains only customizations for the *default* attribute. All other customizations " <>
+        "go in the *other* section. We can also set the style for attributes; we can either " <>
+        "set just one style (as with the bold setting above) or multiple styles at once " <>
+        "(as in the bold/underline example).\n"
+
+  , para $
+        "Available colors are:\n" <>
+        " * black\n" <>
+        " * red\n" <>
+        " * green\n" <>
+        " * yellow\n" <>
+        " * blue\n" <>
+        " * magenta\n" <>
+        " * cyan\n" <>
+        " * white\n" <>
+        " * brightBlack\n" <>
+        " * brightRed\n" <>
+        " * brightGreen\n" <>
+        " * brightYellow\n" <>
+        " * brightBlue\n" <>
+        " * brightMagenta\n" <>
+        " * brightCyan\n" <>
+        " * brightWhite"
+
+  , para $
+        "Available styles are:\n" <>
+        " * standout\n" <>
+        " * underline\n" <>
+        " * italic\n" <>
+        " * strikethrough\n" <>
+        " * reverseVideo\n" <>
+        " * blink\n" <>
+        " * dim\n" <>
+        " * bold\n"
+
+  , para $
+        "It is also possible to specify RGB values using HTML syntax: `#RRGGBB`. " <>
+        "Bear in mind that such colors are clamped to the nearest 256-color palette " <>
+        "entry, so it is not possible to get the exact color specified.\n\n" <>
+        "In addition, a special value of *default* is possible for either color " <>
+        "setting of an attribute. This value indicates that the attribute should " <>
+        "use the terminal emulator's default foreground or background color of " <>
+        "choice rather than a specific ANSI color."
+
+  , heading "Username Highlighting"
+  , para $
+        "Username colors are chosen by hashing each username and then using the hash " <>
+        "to choose a color from a list of predefined username colors. If you would like " <>
+        "to change the color in a given entry of this list, we provide the " <>
+        "\"username.N\" attributes, where N is the index in the username color list."
+
+  , heading "Theme Attributes"
+  , para $
+        "This section lists all possible theme attributes for use in customization " <>
+        "files along with a description of how each one is used in Matterhorn. Each " <>
+        "option listed can be set in the *other* section of the customization file. " <>
+        "Each provides three customization settings:"
+
+  , para $
+        " * *<option>.fg = <color>*\n" <>
+        " * *<option>.bg = <color>*\n" <>
+        " * *<option>.style = <style>* or *<option>.style = [<style>, ...]*\n"
+
+  , let names = sort $
+                 (\(n, msg) -> (n, attrNameToConfig n, msg)) <$>
+                 (M.toList $ themeDescriptions themeDocs)
+        mkEntry (n, opt, msg) =
+            padTop (Pad 1) $
+            vBox [ hBox [ withDefAttr clientEmphAttr $ txt opt
+                        , padLeft Max $ forceAttr n $ txt "(demo)"
+                        ]
+                 , txt msg
+                 ]
+    in vBox $ mkEntry <$> names
+  ]
+
+keybindSections :: [(Text, [KeyEventHandler])]
+keybindSections =
+    [ ("Global Keybindings", globalKeyHandlers)
+    , ("Help Page", helpKeyHandlers)
+    , ("Main Interface", mainKeyHandlers)
+    , ("Text Editing", editingKeyHandlers (csEditState.cedEditor))
+    , ("Channel Select Mode", channelSelectKeyHandlers)
+    , ("Message Select Mode", messageSelectKeyHandlers)
+    , ("User Listings", userListOverlayKeyHandlers)
+    , ("URL Select Mode", urlSelectKeyHandlers)
+    , ("Theme List Window", themeListOverlayKeyHandlers)
+    , ("Channel Search Window", channelListOverlayKeyHandlers)
+    , ("Message Viewer: Common", tabbedWindowKeyHandlers (csViewedMessage.singular _Just._2))
+    , ("Message Viewer: Message tab", viewMessageKeyHandlers)
+    , ("Message Viewer: Reactions tab", viewMessageReactionsKeyHandlers)
+    , ("Attachment List", attachmentListKeyHandlers)
+    , ("Attachment File Browser", attachmentBrowseKeyHandlers)
+    , ("Flagged Messages", postListOverlayKeyHandlers)
+    , ("Reaction Emoji Search Window", reactionEmojiListOverlayKeyHandlers)
+    ]
+
+helpBox :: Name -> Widget Name -> Widget Name
+helpBox n helpText =
+    withDefAttr helpAttr $
+    viewport HelpViewport Vertical $
+    cached n helpText
+
+kbColumnWidth :: Int
+kbColumnWidth = 14
+
+kbDescColumnWidth :: Int
+kbDescColumnWidth = 60
+
+mkKeybindingHelp :: KeyConfig -> (Text, [KeyEventHandler]) -> Widget Name
+mkKeybindingHelp kc (sectionName, kbs) =
+    (heading sectionName) <=>
+    (padTop (Pad 1) $ hCenter $ vBox $ snd <$> sortWith fst results)
+    where
+        results = mkKeybindHelp kc <$> kbs
+
+mkKeybindHelp :: KeyConfig -> KeyEventHandler -> (Text, Widget Name)
+mkKeybindHelp kc h =
+    let unbound = ["(unbound)"]
+        label = case kehEventTrigger h of
+            Static k -> ppBinding $ eventToBinding k
+            ByEvent ev ->
+                let bindings = case M.lookup ev kc of
+                        Nothing ->
+                            let bs = defaultBindings ev
+                            in if not $ null bs
+                               then ppBinding <$> defaultBindings ev
+                               else unbound
+                        Just Unbound -> unbound
+                        Just (BindingList bs) | not (null bs) -> ppBinding <$> bs
+                                              | otherwise -> unbound
+                in T.intercalate ", " bindings
+
+        rendering = (emph $ txt $ padTo kbColumnWidth $
+                      label) <+> txt " " <+>
+                    (hLimit kbDescColumnWidth $ padRight Max $ renderText $
+                     ehDescription $ kehHandler h)
+    in (label, rendering)
+
+mkKeybindEventSectionHelp :: KeyConfig
+                          -> ((Either Text Text, Text, [Text]) -> a)
+                          -> ([a] -> a)
+                          -> (Text -> a)
+                          -> (Text, [KeyEventHandler])
+                          -> a
+mkKeybindEventSectionHelp kc mkKeybindHelpFunc vertCat mkHeading (sectionName, kbs) =
+  vertCat $ (mkHeading sectionName) :
+            (mkKeybindHelpFunc <$> (mkKeybindEventHelp kc <$> kbs))
+
+keybindEventHelpWidget :: (Either Text Text, Text, [Text]) -> Widget Name
+keybindEventHelpWidget (evName, desc, evs) =
+    let evText = T.intercalate ", " evs
+        label = case evName of
+            Left s -> txt $ "; " <> s
+            Right s -> emph $ txt s
+    in padBottom (Pad 1) $
+       vBox [ txtWrap ("; " <> desc)
+            , label <+> txt (" = " <> evText)
+            ]
+
+keybindEventHelpMarkdown :: (Either Text Text, Text, [Text]) -> Text
+keybindEventHelpMarkdown (evName, desc, evs) =
+    let quote s = "`" <> s <> "`"
+        name = case evName of
+            Left s -> s
+            Right s -> quote s
+    in "| " <> (T.intercalate ", " $ quote <$> evs) <>
+       " | " <> name <>
+       " | " <> desc <>
+       " |"
+
+keybindEventHelpText :: Int -> Int -> (Either Text Text, Text, [Text]) -> Text
+keybindEventHelpText width eventNameWidth (evName, desc, evs) =
+    let name = case evName of
+            Left s -> s
+            Right s -> s
+    in padTo width (T.intercalate ", " evs) <> " " <>
+       padTo eventNameWidth name <> " " <>
+       desc
+
+mkKeybindEventHelp :: KeyConfig -> KeyEventHandler -> (Either Text Text, Text, [Text])
+mkKeybindEventHelp kc h =
+  let trig = kehEventTrigger h
+      unbound = ["(unbound)"]
+      (label, evText) = case trig of
+          Static key -> (Left "(non-customizable key)", [ppBinding $ eventToBinding key])
+          ByEvent ev -> case M.lookup ev kc of
+              Nothing ->
+                  let name = keyEventName ev
+                  in if not (null (defaultBindings ev))
+                     then (Right name, ppBinding <$> defaultBindings ev)
+                     else (Left name, unbound)
+              Just Unbound -> (Right $ keyEventName ev, unbound)
+              Just (BindingList bs) -> (Right $ keyEventName ev,
+                                        if not (null bs)
+                                        then ppBinding <$> bs
+                                        else unbound
+                                        )
+  in (label, ehDescription $ kehHandler h, evText)
+
+padTo :: Int -> Text -> Text
+padTo n s = s <> T.replicate (n - T.length s) " "
diff --git a/src/Matterhorn/Draw/TabbedWindow.hs b/src/Matterhorn/Draw/TabbedWindow.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/TabbedWindow.hs
@@ -0,0 +1,77 @@
+module Matterhorn.Draw.TabbedWindow
+  ( drawTabbedWindow
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Widgets.Border
+import           Brick.Widgets.Center
+import           Data.List ( intersperse )
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Draw.Util ( renderKeybindingHelp )
+import           Matterhorn.Types
+import           Matterhorn.Themes
+import           Matterhorn.Types.KeyEvents
+
+-- | Render a tabbed window.
+drawTabbedWindow :: (Eq a, Show a)
+                 => TabbedWindow a
+                 -> ChatState
+                 -> Widget Name
+drawTabbedWindow w cs =
+    let cur = getCurrentTabbedWindowEntry w
+        tabBody = tweRender cur (twValue w) cs
+        title = forceAttr clientEmphAttr $ twtTitle (twTemplate w) (tweValue cur)
+    in centerLayer $
+       vLimit (twWindowHeight w) $
+       hLimit (twWindowWidth w) $
+       joinBorders $
+       borderWithLabel title $
+       (tabBar w <=> tabBody <=> hBorder <=> hCenter keybindingHelp)
+
+-- | Keybinding help to show at the bottom of a tabbed window.
+keybindingHelp :: Widget Name
+keybindingHelp =
+    let pairs = [ ("Switch tabs", [SelectNextTabEvent, SelectPreviousTabEvent])
+                , ("Scroll", [ScrollUpEvent, ScrollDownEvent, ScrollLeftEvent, ScrollRightEvent, PageLeftEvent, PageRightEvent])
+                ]
+    in hBox $ intersperse (txt "  ") $ (uncurry renderKeybindingHelp) <$> pairs
+
+-- | The scrollable tab bar to show at the top of a tabbed window.
+tabBar :: (Eq a, Show a)
+       => TabbedWindow a
+       -> Widget Name
+tabBar w =
+    let cur = getCurrentTabbedWindowEntry w
+        entries = twtEntries (twTemplate w)
+        renderEntry e =
+            let useAttr = if isCurrent
+                          then withDefAttr tabSelectedAttr
+                          else withDefAttr tabUnselectedAttr
+                isCurrent = tweValue e == tweValue cur
+                makeVisible = if isCurrent then visible else id
+                decorateTab v = Widget Fixed Fixed $ do
+                    result <- render v
+                    let width = Vty.imageWidth (result^.imageL)
+                    if isCurrent
+                       then
+                           render $ padBottom (Pad 1) $ raw $ result^.imageL
+                       else
+                           render $ vBox [raw $ result^.imageL, hLimit width hBorder]
+            in makeVisible $
+               decorateTab $
+               useAttr $
+               padLeftRight 2 $
+               txt $
+               tweTitle e (tweValue e) isCurrent
+        contents = Widget Fixed Fixed $ do
+            ctx <- getContext
+            let width = ctx^.availWidthL
+            render $ hBox $ (intersperse divider $ renderEntry <$> entries) <>
+                            [divider, padTop (Pad 1) $ hLimit width hBorder]
+        divider = vLimit 1 vBorder <=> joinableBorder (Edges True False False False)
+    in vLimit 2 $ viewport TabbedWindowTabBar Horizontal contents
diff --git a/src/Matterhorn/Draw/ThemeListOverlay.hs b/src/Matterhorn/Draw/ThemeListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/ThemeListOverlay.hs
@@ -0,0 +1,52 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module Matterhorn.Draw.ThemeListOverlay
+  ( drawThemeListOverlay
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import qualified Brick.Widgets.List as L
+import           Brick.Widgets.Border ( hBorder )
+import           Brick.Widgets.Center ( hCenter )
+
+import           Matterhorn.Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )
+import           Matterhorn.Themes
+import           Matterhorn.Types
+import           Matterhorn.Types.KeyEvents ( ppBinding )
+import           Matterhorn.Events.Keybindings
+
+
+drawThemeListOverlay :: ChatState -> Widget Name
+drawThemeListOverlay st =
+    let overlay = drawListOverlay (st^.csThemeListOverlay)
+                                  (const $ txt "Themes")
+                                  (const $ txt "No matching themes found.")
+                                  (const $ txt "Search built-in themes:")
+                                  renderInternalTheme
+                                  (Just footer)
+                                  OverlayUpperRight
+                                  50
+        footer = hBorder <=>
+                 (hCenter $ hBox [ enter
+                                 , txt $ ":choose theme  "
+                                 , close
+                                 , txt ":close"
+                                 ])
+        enter = emph $ txt $ ppBinding (getFirstDefaultBinding ActivateListItemEvent)
+        close = emph $ txt $ ppBinding (getFirstDefaultBinding CancelEvent)
+        emph = withDefAttr clientEmphAttr
+    in joinBorders overlay
+
+renderInternalTheme :: Bool -> InternalTheme -> Widget Name
+renderInternalTheme foc it =
+    (if foc then forceAttr L.listSelectedFocusedAttr else id) $
+    (padRight Max $
+     withDefAttr clientEmphAttr $
+     txt $ internalThemeName it) <=>
+    (vLimit 2 $
+     (padLeft (Pad 2) $ txtWrap $ internalThemeDesc it) <=> fill ' ')
diff --git a/src/Matterhorn/Draw/URLList.hs b/src/Matterhorn/Draw/URLList.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/URLList.hs
@@ -0,0 +1,64 @@
+module Matterhorn.Draw.URLList
+  ( renderUrlList
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Widgets.Border ( hBorder )
+import           Brick.Widgets.List ( renderList )
+import qualified Data.Foldable as F
+import           Lens.Micro.Platform ( to )
+
+import           Network.Mattermost.Types ( ServerTime(..), idString )
+
+import           Matterhorn.Draw.Messages
+import           Matterhorn.Draw.Util
+import           Matterhorn.Draw.RichText
+import           Matterhorn.Themes
+import           Matterhorn.Types
+import           Matterhorn.Types.RichText ( unURL, TeamURLName(..) )
+
+
+renderUrlList :: ChatState -> Widget Name
+renderUrlList st =
+    header <=> urlDisplay
+    where
+        header = (withDefAttr channelHeaderAttr $ vLimit 1 $
+                 (renderText' Nothing "" (getHighlightSet st) $
+                  "URLs: " <> (mkChannelName st (st^.csCurrentChannel.ccInfo))) <+>
+                 fill ' ') <=> hBorder
+
+        urlDisplay = if F.length urls == 0
+                     then str "No URLs found in this channel."
+                     else renderList renderItem True urls
+
+        urls = st^.csUrlList
+
+        me = myUsername st
+
+        renderItem sel link =
+          let time = link^.linkTime
+          in attr sel $ vLimit 2 $
+            (vLimit 1 $
+             hBox [ let u = maybe "<server>" id (link^.linkUser.to (nameForUserRef st))
+                    in colorUsername me u u
+                  , case link^.linkLabel of
+                      Nothing -> emptyWidget
+                      Just label -> txt ": " <+> hBox (F.toList $ renderElementSeq me label)
+                  , fill ' '
+                  , renderDate st $ withServerTime time
+                  , str " "
+                  , renderTime st $ withServerTime time
+                  ] ) <=>
+            (vLimit 1 (renderLinkTarget (link^.linkTarget)))
+
+        renderLinkTarget (LinkPermalink (TeamURLName tName) pId) =
+            renderText $ "Team: " <> tName <> ", post " <> idString pId
+        renderLinkTarget (LinkURL url) = renderText $ unURL url
+        renderLinkTarget (LinkFileId _) = txt " "
+
+        attr True = forceAttr urlListSelectedAttr
+        attr False = id
diff --git a/src/Matterhorn/Draw/UserListOverlay.hs b/src/Matterhorn/Draw/UserListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/UserListOverlay.hs
@@ -0,0 +1,77 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module Matterhorn.Draw.UserListOverlay
+  ( drawUserListOverlay
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import qualified Brick.Widgets.List as L
+import qualified Data.Text as T
+import qualified Graphics.Vty as V
+
+import           Matterhorn.Draw.Util ( userSigilFromInfo )
+import           Matterhorn.Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )
+import           Matterhorn.Themes
+import           Matterhorn.Types
+
+
+drawUserListOverlay :: ChatState -> Widget Name
+drawUserListOverlay st =
+    let overlay = drawListOverlay (st^.csUserListOverlay) userSearchScopeHeader
+                                  userSearchScopeNoResults userSearchScopePrompt
+                                  (renderUser (myUsername st))
+                                  Nothing
+                                  OverlayCenter
+                                  80
+    in joinBorders overlay
+
+userSearchScopePrompt :: UserSearchScope -> Widget Name
+userSearchScopePrompt scope =
+    txt $ case scope of
+        ChannelMembers _ _    -> "Search channel members:"
+        ChannelNonMembers _ _ -> "Search users:"
+        AllUsers Nothing      -> "Search users:"
+        AllUsers (Just _)     -> "Search team members:"
+
+userSearchScopeNoResults :: UserSearchScope -> Widget Name
+userSearchScopeNoResults scope =
+    txt $ case scope of
+        ChannelMembers _ _    -> "No users in channel."
+        ChannelNonMembers _ _ -> "All users in your team are already in this channel."
+        AllUsers _            -> "No users found."
+
+userSearchScopeHeader :: UserSearchScope -> Widget Name
+userSearchScopeHeader scope =
+    txt $ case scope of
+        ChannelMembers {}    -> "Channel Members"
+        ChannelNonMembers {} -> "Invite Users to Channel"
+        AllUsers Nothing     -> "Users On This Server"
+        AllUsers (Just _)    -> "Users In My Team"
+
+renderUser :: Text -> Bool -> UserInfo -> Widget Name
+renderUser myUName foc ui =
+    (if foc then forceAttr L.listSelectedFocusedAttr else id) $
+    vLimit 2 $
+    padRight Max $
+    hBox $ (padRight (Pad 1) $ colorUsername myUName (ui^.uiName) (T.singleton $ userSigilFromInfo ui))
+           : (hLimit usernameWidth $ padRight Max $ colorUsername myUName (ui^.uiName) (ui^.uiName))
+           : extras
+    where
+        sanitize = T.strip . T.replace "\t" " "
+        usernameWidth = 20
+        extras = padRight (Pad 1) <$> catMaybes [mFullname, mNickname, mEmail]
+        mFullname = if (not (T.null (ui^.uiFirstName)) || not (T.null (ui^.uiLastName)))
+                    then Just $ txt $ (sanitize $ ui^.uiFirstName) <> " " <> (sanitize $ ui^.uiLastName)
+                    else Nothing
+        mNickname = case ui^.uiNickName of
+                      Just n | n /= (ui^.uiName) -> Just $ txt $ "(" <> n <> ")"
+                      _ -> Nothing
+        mEmail = if (T.null $ ui^.uiEmail)
+                 then Nothing
+                 else Just $ modifyDefAttr (`V.withURL` ("mailto:" <> ui^.uiEmail)) $
+                             withDefAttr urlAttr (txt ("<" <> ui^.uiEmail <> ">"))
diff --git a/src/Matterhorn/Draw/Util.hs b/src/Matterhorn/Draw/Util.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Draw/Util.hs
@@ -0,0 +1,103 @@
+module Matterhorn.Draw.Util
+  ( withBrackets
+  , renderTime
+  , renderDate
+  , renderKeybindingHelp
+  , insertDateMarkers
+  , getDateFormat
+  , mkChannelName
+  , userSigilFromInfo
+  , multilineHeightLimit
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Data.List ( intersperse )
+import qualified Data.Set as Set
+import qualified Data.Text as T
+import           Lens.Micro.Platform ( to )
+import           Network.Mattermost.Types
+
+import           Matterhorn.Constants ( userSigil, normalChannelSigil )
+import           Matterhorn.Themes
+import           Matterhorn.TimeUtils
+import           Matterhorn.Types
+import           Matterhorn.Types.KeyEvents
+import           Matterhorn.Events.Keybindings ( getFirstDefaultBinding )
+
+
+defaultTimeFormat :: Text
+defaultTimeFormat = "%R"
+
+defaultDateFormat :: Text
+defaultDateFormat = "%Y-%m-%d"
+
+multilineHeightLimit :: Int
+multilineHeightLimit = 5
+
+getTimeFormat :: ChatState -> Text
+getTimeFormat st =
+    maybe defaultTimeFormat id (st^.csResources.crConfiguration.to configTimeFormat)
+
+getDateFormat :: ChatState -> Text
+getDateFormat st =
+    maybe defaultDateFormat id (st^.csResources.crConfiguration.to configDateFormat)
+
+renderTime :: ChatState -> UTCTime -> Widget Name
+renderTime st = renderUTCTime (getTimeFormat st) (st^.timeZone)
+
+renderDate :: ChatState -> UTCTime -> Widget Name
+renderDate st = renderUTCTime (getDateFormat st) (st^.timeZone)
+
+renderUTCTime :: Text -> TimeZoneSeries -> UTCTime -> Widget a
+renderUTCTime fmt tz t =
+    if T.null fmt
+    then emptyWidget
+    else withDefAttr timeAttr (txt $ localTimeText fmt $ asLocalTime tz t)
+
+renderKeybindingHelp :: Text -> [KeyEvent] -> Widget Name
+renderKeybindingHelp label evs =
+  let ppEv ev = withDefAttr clientEmphAttr $ txt (ppBinding (getFirstDefaultBinding ev))
+  in hBox $ (intersperse (txt "/") $ ppEv <$> evs) <> [txt (":" <> label)]
+
+-- | Generates a local matterhorn-only client message that creates a
+-- date marker.  The server date is converted to a local time (via
+-- timezone), and midnight of that timezone used to generate date
+-- markers.  Note that the actual time of the server and this client
+-- are still not synchronized, but no manipulations here actually use
+-- the client time.
+insertDateMarkers :: Messages -> Text -> TimeZoneSeries -> Messages
+insertDateMarkers ms datefmt tz = foldr (addMessage . dateMsg) ms dateRange
+    where dateRange = foldr checkDateChange Set.empty ms
+          checkDateChange m = let msgDay = startOfDay (Just tz) (withServerTime (m^.mDate))
+                              in if m^.mDeleted then id else Set.insert msgDay
+          dateMsg d = let t = localTimeText datefmt $ asLocalTime tz d
+                      in newMessageOfType t (C DateTransition) (ServerTime d)
+
+
+withBrackets :: Widget a -> Widget a
+withBrackets w = hBox [str "[", w, str "]"]
+
+userSigilFromInfo :: UserInfo -> Char
+userSigilFromInfo u = case u^.uiStatus of
+    Offline      -> ' '
+    Online       -> '+'
+    Away         -> '-'
+    DoNotDisturb -> '×'
+    Other _      -> '?'
+
+mkChannelName :: ChatState -> ChannelInfo -> Text
+mkChannelName st c = T.append sigil t
+    where
+        t = case c^.cdDMUserId >>= flip userById st of
+            Nothing -> c^.cdName
+            Just u -> u^.uiName
+        sigil = case c^.cdType of
+            Private   -> mempty
+            Ordinary  -> normalChannelSigil
+            Group     -> mempty
+            Direct    -> userSigil
+            Unknown _ -> mempty
diff --git a/src/Matterhorn/Emoji.hs b/src/Matterhorn/Emoji.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Emoji.hs
@@ -0,0 +1,108 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+module Matterhorn.Emoji
+  ( EmojiCollection
+  , loadEmoji
+  , emptyEmojiCollection
+  , getMatchingEmoji
+  , matchesEmoji
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Control.Exception as E
+import           Control.Monad.Except
+import qualified Data.Aeson as A
+import qualified Data.ByteString.Lazy as BSL
+import qualified Data.Foldable as F
+import qualified Data.Text as T
+import qualified Data.Sequence as Seq
+
+import           Network.Mattermost.Types ( Session )
+import qualified Network.Mattermost.Endpoints as MM
+
+
+newtype EmojiData = EmojiData (Seq.Seq T.Text)
+
+-- | The collection of all emoji names we loaded from a JSON disk file.
+-- You might rightly ask: why don't we use a Trie here, for efficient
+-- lookups? The answer is that we need infix lookups; prefix matches are
+-- not enough. In practice it seems not to matter that much; despite the
+-- O(n) search we get good enough performance that we aren't worried
+-- about this. If at some point this becomes an issue, other data
+-- structures with good infix lookup performance should be identified
+-- (full-text search, perhaps?).
+newtype EmojiCollection = EmojiCollection [T.Text]
+
+instance A.FromJSON EmojiData where
+    parseJSON = A.withArray "EmojiData" $ \v -> do
+        aliasVecs <- forM v $ \val ->
+            flip (A.withObject "EmojiData Entry") val $ \obj -> do
+                as <- obj A..: "aliases"
+                forM as $ A.withText "Alias list element" return
+
+        return $ EmojiData $ mconcat $ F.toList aliasVecs
+
+emptyEmojiCollection :: EmojiCollection
+emptyEmojiCollection = EmojiCollection mempty
+
+-- | Load an EmojiCollection from a JSON disk file.
+loadEmoji :: FilePath -> IO (Either String EmojiCollection)
+loadEmoji path = runExceptT $ do
+    result <- lift $ E.try $ BSL.readFile path
+    case result of
+        Left (e::E.SomeException) -> throwError $ show e
+        Right bs -> do
+            EmojiData es <- ExceptT $ return $ A.eitherDecode bs
+            return $ EmojiCollection $ T.toLower <$> F.toList es
+
+-- | Look up matching emoji in the collection using the provided search
+-- string. This does a case-insensitive infix match. The search string
+-- may be provided with or without leading and trailing colons.
+lookupEmoji :: EmojiCollection -> T.Text -> [T.Text]
+lookupEmoji (EmojiCollection es) search =
+    filter (matchesEmoji search) es
+
+-- | Match a search string against an emoji.
+matchesEmoji :: T.Text
+             -- ^ The search string (will be converted to lowercase and
+             -- colons will be removed)
+             -> T.Text
+             -- ^ The emoji string (assumed to be lowercase and without
+             -- leading/trailing colons)
+             -> Bool
+matchesEmoji searchString e =
+    sanitizeEmojiSearch searchString `T.isInfixOf` e
+
+sanitizeEmojiSearch :: T.Text -> T.Text
+sanitizeEmojiSearch = stripColons . T.toLower . T.strip
+
+-- | Perform an emoji search against both the local EmojiCollection as
+-- well as the server's custom emoji. Return the results, sorted. If the
+-- empty string is specified, all local and all custom emoji will be
+-- included in the returned list.
+getMatchingEmoji :: Session -> EmojiCollection -> T.Text -> IO [T.Text]
+getMatchingEmoji session em rawSearchString = do
+    let localAlts = lookupEmoji em rawSearchString
+        sanitized = sanitizeEmojiSearch rawSearchString
+    customResult <- E.try $ case T.null sanitized of
+        True -> MM.mmGetListOfCustomEmoji Nothing Nothing session
+        False -> MM.mmSearchCustomEmoji sanitized session
+
+    let custom = case customResult of
+            Left (_::E.SomeException) -> []
+            Right result -> result
+
+    return $ sort $ (MM.emojiName <$> custom) <> localAlts
+
+stripColons :: T.Text -> T.Text
+stripColons t =
+    stripHeadColon $ stripTailColon t
+    where
+        stripHeadColon v = if ":" `T.isPrefixOf` v
+                           then T.tail v
+                           else v
+        stripTailColon v = if ":" `T.isSuffixOf` v
+                           then T.init v
+                           else v
diff --git a/src/Matterhorn/Events.hs b/src/Matterhorn/Events.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events.hs
@@ -0,0 +1,387 @@
+module Matterhorn.Events
+  ( onEvent
+  , globalKeybindings
+  , globalKeyHandlers
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import qualified Data.Sequence as Seq
+import qualified Data.Set as Set
+import qualified Data.Text as T
+import qualified Graphics.Vty as Vty
+import           Lens.Micro.Platform ( (.=), preuse, _2, singular, _Just )
+
+import qualified Network.Mattermost.Endpoints as MM
+import           Network.Mattermost.Exceptions ( mattermostErrorMessage )
+import           Network.Mattermost.Lenses
+import           Network.Mattermost.Types
+import           Network.Mattermost.WebSocket
+
+import           Matterhorn.Connection
+import           Matterhorn.Constants ( userSigil, normalChannelSigil )
+import           Matterhorn.HelpTopics
+import           Matterhorn.State.Channels
+import           Matterhorn.State.Common
+import           Matterhorn.State.Flagging
+import           Matterhorn.State.Help
+import           Matterhorn.State.Messages
+import           Matterhorn.State.Reactions
+import           Matterhorn.State.Users
+import           Matterhorn.Types
+import           Matterhorn.Types.Common
+
+import           Matterhorn.Events.ChannelSelect
+import           Matterhorn.Events.ChannelTopicWindow
+import           Matterhorn.Events.DeleteChannelConfirm
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.Events.LeaveChannelConfirm
+import           Matterhorn.Events.Main
+import           Matterhorn.Events.MessageSelect
+import           Matterhorn.Events.ThemeListOverlay
+import           Matterhorn.Events.PostListOverlay
+import           Matterhorn.Events.ShowHelp
+import           Matterhorn.Events.UrlSelect
+import           Matterhorn.Events.UserListOverlay
+import           Matterhorn.Events.ChannelListOverlay
+import           Matterhorn.Events.ReactionEmojiListOverlay
+import           Matterhorn.Events.TabbedWindow
+import           Matterhorn.Events.ManageAttachments
+import           Matterhorn.Events.EditNotifyPrefs
+
+
+onEvent :: ChatState -> BrickEvent Name MHEvent -> EventM Name (Next ChatState)
+onEvent st ev = runMHEvent st $ do
+    onBrickEvent ev
+    doPendingUserFetches
+    doPendingUserStatusFetches
+
+onBrickEvent :: BrickEvent Name MHEvent -> MH ()
+onBrickEvent (AppEvent e) =
+    onAppEvent e
+onBrickEvent (VtyEvent (Vty.EvKey (Vty.KChar 'l') [Vty.MCtrl])) = do
+    vty <- mh getVtyHandle
+    liftIO $ Vty.refresh vty
+onBrickEvent (VtyEvent e) =
+    onVtyEvent e
+onBrickEvent _ =
+    return ()
+
+onAppEvent :: MHEvent -> MH ()
+onAppEvent RefreshWebsocketEvent =
+    connectWebsockets
+onAppEvent WebsocketDisconnect = do
+    csConnectionStatus .= Disconnected
+    disconnectChannels
+onAppEvent WebsocketConnect = do
+    csConnectionStatus .= Connected
+    refreshChannelsAndUsers
+    refreshClientConfig
+    fetchVisibleIfNeeded
+onAppEvent (RateLimitExceeded winSz) =
+    mhError $ GenericError $ T.pack $
+        let s = if winSz == 1 then "" else "s"
+        in "The server's API request rate limit was exceeded; Matterhorn will " <>
+           "retry the failed request in " <> show winSz <> " second" <> s <>
+           ". Please contact your Mattermost administrator " <>
+           "about API rate limiting issues."
+onAppEvent RateLimitSettingsMissing =
+    mhError $ GenericError $
+        "A request was rate-limited but could not be retried due to rate " <>
+        "limit settings missing"
+onAppEvent RequestDropped =
+    mhError $ GenericError $
+        "An API request was retried and dropped due to a rate limit. Matterhorn " <>
+        "may now be inconsistent with the server. Please contact your " <>
+        "Mattermost administrator about API rate limiting issues."
+onAppEvent BGIdle =
+    csWorkerIsBusy .= Nothing
+onAppEvent (BGBusy n) =
+    csWorkerIsBusy .= Just n
+onAppEvent (WSEvent we) =
+    handleWSEvent we
+onAppEvent (WSActionResponse r) =
+    handleWSActionResponse r
+onAppEvent (RespEvent f) = f
+onAppEvent (WebsocketParseError e) = do
+    let msg = "A websocket message could not be parsed:\n  " <>
+              T.pack e <>
+              "\nPlease report this error at https://github.com/matterhorn-chat/matterhorn/issues"
+    mhError $ GenericError msg
+onAppEvent (IEvent e) = do
+    handleIEvent e
+
+handleIEvent :: InternalEvent -> MH ()
+handleIEvent (DisplayError e) =
+    postErrorMessage' $ formatError e
+handleIEvent (LoggingStarted path) =
+    postInfoMessage $ "Logging to " <> T.pack path
+handleIEvent (LogDestination dest) =
+    case dest of
+        Nothing ->
+            postInfoMessage "Logging is currently disabled. Enable it with /log-start."
+        Just path ->
+            postInfoMessage $ T.pack $ "Logging to " <> path
+handleIEvent (LogSnapshotSucceeded path) =
+    postInfoMessage $ "Log snapshot written to " <> T.pack path
+handleIEvent (LoggingStopped path) =
+    postInfoMessage $ "Stopped logging to " <> T.pack path
+handleIEvent (LogStartFailed path err) =
+    postErrorMessage' $ "Could not start logging to " <> T.pack path <>
+                        ", error: " <> T.pack err
+handleIEvent (LogSnapshotFailed path err) =
+    postErrorMessage' $ "Could not write log snapshot to " <> T.pack path <>
+                        ", error: " <> T.pack err
+
+formatError :: MHError -> T.Text
+formatError (GenericError msg) =
+    msg
+formatError (NoSuchChannel chan) =
+    T.pack $ "No such channel: " <> show chan
+formatError (NoSuchUser user) =
+    T.pack $ "No such user: " <> show user
+formatError (AmbiguousName name) =
+    (T.pack $ "The input " <> show name <> " matches both channels ") <>
+    "and users. Try using '" <> userSigil <> "' or '" <>
+    normalChannelSigil <> "' to disambiguate."
+formatError (ServerError e) =
+    mattermostErrorMessage e
+formatError (ClipboardError msg) =
+    msg
+formatError (ConfigOptionMissing opt) =
+    T.pack $ "Config option " <> show opt <> " missing"
+formatError (ProgramExecutionFailed progName logPath) =
+    T.pack $ "An error occurred when running " <> show progName <>
+             "; see " <> show logPath <> " for details."
+formatError (NoSuchScript name) =
+    "No script named " <> name <> " was found"
+formatError (NoSuchHelpTopic topic) =
+    let knownTopics = ("  - " <>) <$> helpTopicName <$> helpTopics
+    in "Unknown help topic: `" <> topic <> "`. " <>
+       (T.unlines $ "Available topics are:" : knownTopics)
+formatError (AsyncErrEvent e) =
+    "An unexpected error has occurred! The exception encountered was:\n  " <>
+    T.pack (show e) <>
+    "\nPlease report this error at https://github.com/matterhorn-chat/matterhorn/issues"
+
+onVtyEvent :: Vty.Event -> MH ()
+onVtyEvent e = do
+    case e of
+        (Vty.EvResize _ _) ->
+            -- On resize, invalidate the entire rendering cache since
+            -- many things depend on the window size.
+            --
+            -- Note: we fall through after this because it is sometimes
+            -- important for modes to have their own additional logic
+            -- to run when a resize occurs, so we don't want to stop
+            -- processing here.
+            mh invalidateCache
+        _ -> return ()
+
+    void $ handleKeyboardEvent globalKeybindings handleGlobalEvent e
+
+handleGlobalEvent :: Vty.Event -> MH ()
+handleGlobalEvent e = do
+    mode <- gets appMode
+    case mode of
+        Main                       -> onEventMain e
+        ShowHelp _ _               -> void $ onEventShowHelp e
+        ChannelSelect              -> void $ onEventChannelSelect e
+        UrlSelect                  -> void $ onEventUrlSelect e
+        LeaveChannelConfirm        -> onEventLeaveChannelConfirm e
+        MessageSelect              -> onEventMessageSelect e
+        MessageSelectDeleteConfirm -> onEventMessageSelectDeleteConfirm e
+        DeleteChannelConfirm       -> onEventDeleteChannelConfirm e
+        ThemeListOverlay           -> onEventThemeListOverlay e
+        PostListOverlay _          -> onEventPostListOverlay e
+        UserListOverlay            -> onEventUserListOverlay e
+        ChannelListOverlay         -> onEventChannelListOverlay e
+        ReactionEmojiListOverlay   -> onEventReactionEmojiListOverlay e
+        ViewMessage                -> void $ handleTabbedWindowEvent (csViewedMessage.singular _Just._2) e
+        ManageAttachments          -> onEventManageAttachments e
+        ManageAttachmentsBrowseFiles -> onEventManageAttachments e
+        EditNotifyPrefs            -> void $ onEventEditNotifyPrefs e
+        ChannelTopicWindow         -> onEventChannelTopicWindow e
+
+globalKeybindings :: KeyConfig -> KeyHandlerMap
+globalKeybindings = mkKeybindings globalKeyHandlers
+
+globalKeyHandlers :: [KeyEventHandler]
+globalKeyHandlers =
+    [ mkKb ShowHelpEvent
+        "Show this help screen"
+        (showHelpScreen mainHelpTopic)
+    ]
+
+handleWSActionResponse :: WebsocketActionResponse -> MH ()
+handleWSActionResponse r =
+    case warStatus r of
+        WebsocketActionStatusOK -> return ()
+
+handleWSEvent :: WebsocketEvent -> MH ()
+handleWSEvent we = do
+    myId <- gets myUserId
+    myTId <- gets myTeamId
+    case weEvent we of
+        WMPosted
+            | Just p <- wepPost (weData we) ->
+                when (wepTeamId (weData we) == Just myTId ||
+                      wepTeamId (weData we) == Nothing) $ do
+                    let wasMentioned = maybe False (Set.member myId) $ wepMentions (weData we)
+                    addNewPostedMessage $ RecentPost p wasMentioned
+                    cId <- use csCurrentChannelId
+                    when (postChannelId p /= cId) $
+                        showChannelInSidebar (p^.postChannelIdL) False
+            | otherwise -> return ()
+
+        WMPostEdited
+            | Just p <- wepPost (weData we) -> do
+                editMessage p
+                cId <- use csCurrentChannelId
+                when (postChannelId p == cId) (updateViewed False)
+                when (postChannelId p /= cId) $
+                    showChannelInSidebar (p^.postChannelIdL) False
+            | otherwise -> return ()
+
+        WMPostDeleted
+            | Just p <- wepPost (weData we) -> do
+                deleteMessage p
+                cId <- use csCurrentChannelId
+                when (postChannelId p == cId) (updateViewed False)
+                when (postChannelId p /= cId) $
+                    showChannelInSidebar (p^.postChannelIdL) False
+            | otherwise -> return ()
+
+        WMStatusChange
+            | Just status <- wepStatus (weData we)
+            , Just uId <- wepUserId (weData we) ->
+                setUserStatus uId status
+            | otherwise -> return ()
+
+        WMUserAdded
+            | Just cId <- webChannelId (weBroadcast we) ->
+                when (wepUserId (weData we) == Just myId &&
+                      wepTeamId (weData we) == Just myTId) $
+                    handleChannelInvite cId
+            | otherwise -> return ()
+
+        WMNewUser
+            | Just uId <- wepUserId $ weData we ->
+                handleNewUsers (Seq.singleton uId) (return ())
+            | otherwise -> return ()
+
+        WMUserRemoved
+            | Just cId <- wepChannelId (weData we) ->
+                when (webUserId (weBroadcast we) == Just myId) $
+                    removeChannelFromState cId
+            | otherwise -> return ()
+
+        WMTyping
+            | Just uId <- wepUserId $ weData we
+            , Just cId <- webChannelId (weBroadcast we) -> handleTypingUser uId cId
+            | otherwise -> return ()
+
+        WMChannelDeleted
+            | Just cId <- wepChannelId (weData we) ->
+                when (webTeamId (weBroadcast we) == Just myTId) $
+                    removeChannelFromState cId
+            | otherwise -> return ()
+
+        WMDirectAdded
+            | Just cId <- webChannelId (weBroadcast we) -> handleChannelInvite cId
+            | otherwise -> return ()
+
+        -- An 'ephemeral message' is just Mattermost's version of our
+        -- 'client message'. This can be a little bit wacky, e.g.
+        -- if the user types '/shortcuts' in the browser, we'll get
+        -- an ephemeral message even in MatterHorn with the browser
+        -- shortcuts, but it's probably a good idea to handle these
+        -- messages anyway.
+        WMEphemeralMessage
+            | Just p <- wepPost $ weData we -> postInfoMessage (sanitizeUserText $ p^.postMessageL)
+            | otherwise -> return ()
+
+        WMPreferenceChanged
+            | Just prefs <- wepPreferences (weData we) ->
+                mapM_ applyPreferenceChange prefs
+            | otherwise -> return ()
+
+        WMPreferenceDeleted
+            | Just pref <- wepPreferences (weData we)
+            , Just fps <- mapM preferenceToFlaggedPost pref ->
+              forM_ fps $ \f ->
+                  updateMessageFlag (flaggedPostId f) False
+            | otherwise -> return ()
+
+        WMReactionAdded
+            | Just r <- wepReaction (weData we)
+            , Just cId <- webChannelId (weBroadcast we) -> addReactions cId [r]
+            | otherwise -> return ()
+
+        WMReactionRemoved
+            | Just r <- wepReaction (weData we)
+            , Just cId <- webChannelId (weBroadcast we) -> removeReaction r cId
+            | otherwise -> return ()
+
+        WMChannelViewed
+            | Just cId <- wepChannelId $ weData we -> refreshChannelById cId
+            | otherwise -> return ()
+
+        WMChannelUpdated
+            | Just cId <- webChannelId $ weBroadcast we -> do
+                mChan <- preuse (csChannel(cId))
+                when (isJust mChan) $ do
+                    refreshChannelById cId
+                    updateSidebar
+            | otherwise -> return ()
+
+        WMGroupAdded
+            | Just cId <- webChannelId (weBroadcast we) -> handleChannelInvite cId
+            | otherwise -> return ()
+
+        WMChannelMemberUpdated
+            | Just channelMember <- wepChannelMember $ weData we ->
+                  when (channelMemberUserId channelMember == myId) $
+                      updateChannelNotifyProps
+                      (channelMemberChannelId channelMember)
+                      (channelMemberNotifyProps channelMember)
+            | otherwise -> return ()
+
+        -- We are pretty sure we should do something about these:
+        WMAddedToTeam -> return ()
+
+        -- We aren't sure whether there is anything we should do about
+        -- these yet:
+        WMUpdateTeam -> return ()
+        WMTeamDeleted -> return ()
+        WMUserUpdated -> return ()
+        WMLeaveTeam -> return ()
+
+        -- We deliberately ignore these events:
+        WMChannelCreated -> return ()
+        WMEmojiAdded -> return ()
+        WMWebRTC -> return ()
+        WMHello -> return ()
+        WMAuthenticationChallenge -> return ()
+        WMUserRoleUpdated -> return ()
+        WMPluginStatusesChanged -> return ()
+        WMPluginEnabled -> return ()
+        WMPluginDisabled -> return ()
+        WMUnknownEvent {} ->
+            mhLog LogWebsocket $ T.pack $
+                "Websocket event not handled due to unknown event type: " <> show we
+
+-- | Refresh client-accessible server configuration information. This
+-- is usually triggered when a reconnect event for the WebSocket to the
+-- server occurs.
+refreshClientConfig :: MH ()
+refreshClientConfig = do
+    session <- getSession
+    doAsyncWith Preempt $ do
+        cfg <- MM.mmGetClientConfiguration (Just "old") session
+        return $ Just $ do
+            csClientConfig .= Just cfg
+            updateSidebar
diff --git a/src/Matterhorn/Events/ChannelListOverlay.hs b/src/Matterhorn/Events/ChannelListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/ChannelListOverlay.hs
@@ -0,0 +1,35 @@
+module Matterhorn.Events.ChannelListOverlay
+  ( onEventChannelListOverlay
+  , channelListOverlayKeybindings
+  , channelListOverlayKeyHandlers
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.ChannelListOverlay
+import           Matterhorn.State.ListOverlay
+import           Matterhorn.Types
+
+
+onEventChannelListOverlay :: Vty.Event -> MH ()
+onEventChannelListOverlay =
+    void . onEventListOverlay csChannelListOverlay channelListOverlayKeybindings
+
+-- | The keybindings we want to use while viewing a channel list overlay
+channelListOverlayKeybindings :: KeyConfig -> KeyHandlerMap
+channelListOverlayKeybindings = mkKeybindings channelListOverlayKeyHandlers
+
+channelListOverlayKeyHandlers :: [KeyEventHandler]
+channelListOverlayKeyHandlers =
+    [ mkKb CancelEvent "Close the channel search list" (exitListOverlay csChannelListOverlay)
+    , mkKb SearchSelectUpEvent "Select the previous channel" channelListSelectUp
+    , mkKb SearchSelectDownEvent "Select the next channel" channelListSelectDown
+    , mkKb PageDownEvent "Page down in the channel list" channelListPageDown
+    , mkKb PageUpEvent "Page up in the channel list" channelListPageUp
+    , mkKb ActivateListItemEvent "Join the selected channel" (listOverlayActivateCurrent csChannelListOverlay)
+    ]
diff --git a/src/Matterhorn/Events/ChannelSelect.hs b/src/Matterhorn/Events/ChannelSelect.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/ChannelSelect.hs
@@ -0,0 +1,45 @@
+module Matterhorn.Events.ChannelSelect where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Widgets.Edit ( handleEditorEvent )
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.Channels
+import           Matterhorn.State.ChannelSelect
+import           Matterhorn.State.Editing ( editingKeybindings )
+import           Matterhorn.Types
+import qualified Matterhorn.Zipper as Z
+
+
+onEventChannelSelect :: Vty.Event -> MH Bool
+onEventChannelSelect =
+  handleKeyboardEvent channelSelectKeybindings $ \e -> do
+      handled <- handleKeyboardEvent (editingKeybindings (csChannelSelectState.channelSelectInput)) (const $ return ()) e
+      when (not handled) $
+          mhHandleEventLensed (csChannelSelectState.channelSelectInput) handleEditorEvent e
+
+      updateChannelSelectMatches
+
+channelSelectKeybindings :: KeyConfig -> KeyHandlerMap
+channelSelectKeybindings = mkKeybindings channelSelectKeyHandlers
+
+channelSelectKeyHandlers :: [KeyEventHandler]
+channelSelectKeyHandlers =
+    [ staticKb "Switch to selected channel"
+         (Vty.EvKey Vty.KEnter []) $ do
+             matches <- use (csChannelSelectState.channelSelectMatches)
+             case Z.focus matches of
+                 Nothing -> return ()
+                 Just match -> do
+                     setMode Main
+                     setFocus $ channelListEntryChannelId $ matchEntry match
+
+    , mkKb CancelEvent "Cancel channel selection" $ setMode Main
+    , mkKb NextChannelEvent "Select next match" channelSelectNext
+    , mkKb PrevChannelEvent "Select previous match" channelSelectPrevious
+    , mkKb NextChannelEventAlternate "Select next match (alternate binding)" channelSelectNext
+    , mkKb PrevChannelEventAlternate "Select previous match (alternate binding)" channelSelectPrevious
+    ]
diff --git a/src/Matterhorn/Events/ChannelTopicWindow.hs b/src/Matterhorn/Events/ChannelTopicWindow.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/ChannelTopicWindow.hs
@@ -0,0 +1,48 @@
+module Matterhorn.Events.ChannelTopicWindow
+  ( onEventChannelTopicWindow
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Focus
+import           Brick.Widgets.Edit ( handleEditorEvent, getEditContents )
+import qualified Data.Text as T
+import           Lens.Micro.Platform ( (%=) )
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Types
+import           Matterhorn.State.Channels ( setChannelTopic )
+
+
+onEventChannelTopicWindow :: Vty.Event -> MH ()
+onEventChannelTopicWindow (Vty.EvKey (Vty.KChar '\t') []) =
+    csChannelTopicDialog.channelTopicDialogFocus %= focusNext
+onEventChannelTopicWindow (Vty.EvKey Vty.KBackTab []) =
+    csChannelTopicDialog.channelTopicDialogFocus %= focusPrev
+onEventChannelTopicWindow e@(Vty.EvKey Vty.KEnter []) = do
+    f <- use (csChannelTopicDialog.channelTopicDialogFocus)
+    case focusGetCurrent f of
+        Just ChannelTopicSaveButton -> do
+            ed <- use (csChannelTopicDialog.channelTopicDialogEditor)
+            let topic = T.unlines $ getEditContents ed
+            setChannelTopic topic
+            setMode Main
+        Just ChannelTopicEditor ->
+            mhHandleEventLensed (csChannelTopicDialog.channelTopicDialogEditor)
+                                handleEditorEvent e
+        Just ChannelTopicCancelButton ->
+            setMode Main
+        _ ->
+            setMode Main
+onEventChannelTopicWindow (Vty.EvKey Vty.KEsc []) = do
+    setMode Main
+onEventChannelTopicWindow e = do
+    f <- use (csChannelTopicDialog.channelTopicDialogFocus)
+    case focusGetCurrent f of
+        Just ChannelTopicEditor ->
+            mhHandleEventLensed (csChannelTopicDialog.channelTopicDialogEditor)
+                                handleEditorEvent e
+        _ ->
+            return ()
diff --git a/src/Matterhorn/Events/DeleteChannelConfirm.hs b/src/Matterhorn/Events/DeleteChannelConfirm.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/DeleteChannelConfirm.hs
@@ -0,0 +1,20 @@
+module Matterhorn.Events.DeleteChannelConfirm where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Types
+import           Matterhorn.State.Channels
+
+
+onEventDeleteChannelConfirm :: Vty.Event -> MH ()
+onEventDeleteChannelConfirm (Vty.EvKey k []) = do
+    case k of
+        Vty.KChar c | c `elem` ("yY"::String) ->
+            deleteCurrentChannel
+        _ -> return ()
+    setMode Main
+onEventDeleteChannelConfirm _ = do
+    setMode Main
diff --git a/src/Matterhorn/Events/EditNotifyPrefs.hs b/src/Matterhorn/Events/EditNotifyPrefs.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/EditNotifyPrefs.hs
@@ -0,0 +1,48 @@
+module Matterhorn.Events.EditNotifyPrefs
+    ( onEventEditNotifyPrefs
+    , editNotifyPrefsKeybindings
+    , editNotifyPrefsKeyHandlers
+    )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Forms (handleFormEvent, formState)
+import           Data.Maybe (fromJust)
+import qualified Graphics.Vty as V
+import qualified Network.Mattermost.Endpoints as MM
+
+import           Lens.Micro.Platform (_Just, (.=), singular)
+
+import           Matterhorn.Types
+import           Matterhorn.Types.KeyEvents
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.NotifyPrefs
+import           Matterhorn.State.Async
+
+onEventEditNotifyPrefs :: V.Event -> MH Bool
+onEventEditNotifyPrefs =
+    let fallback e = do
+            form <- use (csNotifyPrefs.singular _Just)
+            updatedForm <- mh $ handleFormEvent (VtyEvent e) form
+            csNotifyPrefs .= Just updatedForm
+    in handleKeyboardEvent editNotifyPrefsKeybindings fallback
+
+editNotifyPrefsKeybindings :: KeyConfig -> KeyHandlerMap
+editNotifyPrefsKeybindings = mkKeybindings editNotifyPrefsKeyHandlers
+
+editNotifyPrefsKeyHandlers :: [KeyEventHandler]
+editNotifyPrefsKeyHandlers =
+    [ mkKb CancelEvent "Close channel notification preferences" exitEditNotifyPrefsMode
+    , mkKb FormSubmitEvent "Save channel notification preferences" $ do
+        st <- use id
+        let form = fromJust $ st^.csNotifyPrefs
+            cId = st^.csCurrentChannelId
+
+        doAsyncChannelMM Preempt cId
+          (\s _ -> MM.mmUpdateChannelNotifications cId (myUserId st) (formState form) s)
+          (\_ _ -> Nothing)
+        exitEditNotifyPrefsMode
+    ]
diff --git a/src/Matterhorn/Events/Keybindings.hs b/src/Matterhorn/Events/Keybindings.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/Keybindings.hs
@@ -0,0 +1,323 @@
+module Matterhorn.Events.Keybindings
+  ( defaultBindings
+  , lookupKeybinding
+  , getFirstDefaultBinding
+
+  , mkKb
+  , staticKb
+  , mkKeybindings
+
+  , handleKeyboardEvent
+
+  , EventHandler(..)
+  , KeyHandler(..)
+  , KeyEventHandler(..)
+  , KeyEventTrigger(..)
+  , KeyHandlerMap(..)
+
+  -- Re-exports:
+  , KeyEvent (..)
+  , KeyConfig
+  , allEvents
+  , parseBinding
+  , keyEventName
+  , keyEventFromName
+
+  , ensureKeybindingConsistency
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.Text as T
+import qualified Data.Map.Strict as M
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Types
+import           Matterhorn.Types.KeyEvents
+
+
+-- * Keybindings
+
+-- | An 'EventHandler' represents a event handler.
+data EventHandler =
+    EH { ehDescription :: Text
+       -- ^ The description of this handler's behavior.
+       , ehAction :: MH ()
+       -- ^ The action to take when this handler is invoked.
+       }
+
+-- | A trigger for a key event.
+data KeyEventTrigger =
+    Static Vty.Event
+    -- ^ The key event is always triggered by a specific key.
+    | ByEvent KeyEvent
+    -- ^ The key event is always triggered by an abstract key event (and
+    -- thus configured to be bound to specific key(s) in the KeyConfig).
+    deriving (Show, Eq, Ord)
+
+-- | A handler for an abstract key event.
+data KeyEventHandler =
+    KEH { kehHandler :: EventHandler
+        -- ^ The handler to invoke.
+        , kehEventTrigger :: KeyEventTrigger
+        -- ^ The trigger for the handler.
+        }
+
+-- | A handler for a specific key.
+data KeyHandler =
+    KH { khHandler :: KeyEventHandler
+       -- ^ The handler to invoke.
+       , khKey :: Vty.Event
+       -- ^ The specific key that should trigger this handler.
+       }
+
+newtype KeyHandlerMap = KeyHandlerMap (M.Map Vty.Event KeyHandler)
+
+-- | Find a keybinding that matches a Vty Event
+lookupKeybinding :: Vty.Event -> KeyHandlerMap -> Maybe KeyHandler
+lookupKeybinding e (KeyHandlerMap m) = M.lookup e m
+
+-- | Handle a keyboard event by looking it up in a map of bindings and
+-- invoking the matching binding's handler. If no match can be found,
+-- invoke a fallback action instead. Return True if the key event was
+-- handled with a matching binding; False if not (the fallback case).
+handleKeyboardEvent :: (KeyConfig -> KeyHandlerMap)
+                    -- ^ The function to build a key handler map from a
+                    -- key configuration.
+                    -> (Vty.Event -> MH ())
+                    -- ^ The fallback action to invoke if no matching
+                    -- binding can be found.
+                    -> Vty.Event
+                    -- ^ The event to handle.
+                    -> MH Bool
+handleKeyboardEvent mkKeyMap fallthrough e = do
+  conf <- use (csResources.crConfiguration)
+  let keyMap = mkKeyMap (configUserKeys conf)
+  case lookupKeybinding e keyMap of
+    Just kh -> (ehAction $ kehHandler $ khHandler kh) >> return True
+    Nothing -> fallthrough e >> return False
+
+mkHandler :: Text -> MH () -> EventHandler
+mkHandler msg action =
+    EH { ehDescription = msg
+       , ehAction = action
+       }
+
+mkKb :: KeyEvent -> Text -> MH () -> KeyEventHandler
+mkKb ev msg action =
+    KEH { kehHandler = mkHandler msg action
+        , kehEventTrigger = ByEvent ev
+        }
+
+keyHandlerFromConfig :: KeyConfig -> KeyEventHandler -> [KeyHandler]
+keyHandlerFromConfig conf eh =
+    case kehEventTrigger eh of
+        Static key ->
+            [ KH eh key ]
+        ByEvent ev ->
+            [ KH eh (bindingToEvent b) | b <- allBindings ]
+            where allBindings | Just (BindingList ks) <- M.lookup ev conf = ks
+                              | Just Unbound <- M.lookup ev conf = []
+                              | otherwise = defaultBindings ev
+
+staticKb :: Text -> Vty.Event -> MH () -> KeyEventHandler
+staticKb msg event action =
+    KEH { kehHandler = mkHandler msg action
+        , kehEventTrigger = Static event
+        }
+
+mkKeybindings :: [KeyEventHandler] -> KeyConfig -> KeyHandlerMap
+mkKeybindings ks conf = KeyHandlerMap $ M.fromList pairs
+    where
+        pairs = mkPair <$> handlers
+        mkPair h = (khKey h, h)
+        handlers = concat $ keyHandlerFromConfig conf <$> ks
+
+bindingToEvent :: Binding -> Vty.Event
+bindingToEvent binding =
+  Vty.EvKey (kbKey binding) (kbMods binding)
+
+getFirstDefaultBinding :: KeyEvent -> Binding
+getFirstDefaultBinding ev =
+    case defaultBindings ev of
+        [] -> error $ "BUG: event " <> show ev <> " has no default bindings!"
+        (b:_) -> b
+
+defaultBindings :: KeyEvent -> [Binding]
+defaultBindings ev =
+  let meta binding = binding { kbMods = Vty.MMeta : kbMods binding }
+      ctrl binding = binding { kbMods = Vty.MCtrl : kbMods binding }
+      shift binding = binding { kbMods = Vty.MShift : kbMods binding }
+      kb k = Binding { kbMods = [], kbKey = k }
+      key c = Binding { kbMods = [], kbKey = Vty.KChar c }
+      fn n = Binding { kbMods = [], kbKey = Vty.KFun n }
+  in case ev of
+        VtyRefreshEvent               -> [ ctrl (key 'l') ]
+        ShowHelpEvent                 -> [ fn 1 ]
+        EnterSelectModeEvent          -> [ ctrl (key 's') ]
+        ReplyRecentEvent              -> [ ctrl (key 'r') ]
+        ToggleMessagePreviewEvent     -> [ meta (key 'p') ]
+        InvokeEditorEvent             -> [ meta (key 'k') ]
+        EnterFastSelectModeEvent      -> [ ctrl (key 'g') ]
+        QuitEvent                     -> [ ctrl (key 'q') ]
+        NextChannelEvent              -> [ ctrl (key 'n') ]
+        PrevChannelEvent              -> [ ctrl (key 'p') ]
+        NextChannelEventAlternate     -> [ kb Vty.KDown ]
+        PrevChannelEventAlternate     -> [ kb Vty.KUp ]
+        NextUnreadChannelEvent        -> [ meta (key 'a') ]
+        ShowAttachmentListEvent       -> [ ctrl (key 'x') ]
+        NextUnreadUserOrChannelEvent  -> [ ]
+        LastChannelEvent              -> [ meta (key 's') ]
+        EnterOpenURLModeEvent         -> [ ctrl (key 'o') ]
+        ClearUnreadEvent              -> [ meta (key 'l') ]
+        ToggleMultiLineEvent          -> [ meta (key 'e') ]
+        EnterFlaggedPostsEvent        -> [ meta (key '8') ]
+        ToggleChannelListVisibleEvent -> [ fn 2 ]
+        ToggleExpandedChannelTopicsEvent -> [ fn 3 ]
+        SelectNextTabEvent            -> [ key '\t' ]
+        SelectPreviousTabEvent        -> [ kb Vty.KBackTab ]
+        LoadMoreEvent                 -> [ ctrl (key 'b') ]
+        ScrollUpEvent                 -> [ kb Vty.KUp ]
+        ScrollDownEvent               -> [ kb Vty.KDown ]
+        ScrollLeftEvent               -> [ kb Vty.KLeft ]
+        ScrollRightEvent              -> [ kb Vty.KRight ]
+        PageUpEvent                   -> [ kb Vty.KPageUp ]
+        PageDownEvent                 -> [ kb Vty.KPageDown ]
+        PageLeftEvent                 -> [ shift (kb Vty.KLeft) ]
+        PageRightEvent                -> [ shift (kb Vty.KRight) ]
+        ScrollTopEvent                -> [ kb Vty.KHome ]
+        ScrollBottomEvent             -> [ kb Vty.KEnd ]
+        SelectOldestMessageEvent      -> [ shift (kb Vty.KHome) ]
+        SelectUpEvent                 -> [ key 'k', kb Vty.KUp ]
+        SelectDownEvent               -> [ key 'j', kb Vty.KDown ]
+        ActivateListItemEvent         -> [ kb Vty.KEnter ]
+        SearchSelectUpEvent           -> [ ctrl (key 'p'), kb Vty.KUp ]
+        SearchSelectDownEvent         -> [ ctrl (key 'n'), kb Vty.KDown ]
+        ViewMessageEvent              -> [ key 'v' ]
+        FillGapEvent                  -> [ kb Vty.KEnter ]
+        FlagMessageEvent              -> [ key 'f' ]
+        PinMessageEvent               -> [ key 'p' ]
+        YankMessageEvent              -> [ key 'y' ]
+        YankWholeMessageEvent         -> [ key 'Y' ]
+        DeleteMessageEvent            -> [ key 'd' ]
+        EditMessageEvent              -> [ key 'e' ]
+        ReplyMessageEvent             -> [ key 'r' ]
+        ReactToMessageEvent           -> [ key 'a' ]
+        OpenMessageURLEvent           -> [ key 'o' ]
+        AttachmentListAddEvent        -> [ key 'a' ]
+        AttachmentListDeleteEvent     -> [ key 'd' ]
+        AttachmentOpenEvent           -> [ key 'o' ]
+        CancelEvent                   -> [ kb Vty.KEsc, ctrl (key 'c') ]
+        EditorBolEvent                -> [ ctrl (key 'a') ]
+        EditorEolEvent                -> [ ctrl (key 'e') ]
+        EditorTransposeCharsEvent     -> [ ctrl (key 't') ]
+        EditorDeleteCharacter         -> [ ctrl (key 'd') ]
+        EditorKillToBolEvent          -> [ ctrl (key 'u') ]
+        EditorKillToEolEvent          -> [ ctrl (key 'k') ]
+        EditorPrevCharEvent           -> [ ctrl (key 'b') ]
+        EditorNextCharEvent           -> [ ctrl (key 'f') ]
+        EditorPrevWordEvent           -> [ meta (key 'b') ]
+        EditorNextWordEvent           -> [ meta (key 'f') ]
+        EditorDeleteNextWordEvent     -> [ meta (key 'd') ]
+        EditorDeletePrevWordEvent     -> [ ctrl (key 'w'), meta (kb Vty.KBS) ]
+        EditorHomeEvent               -> [ kb Vty.KHome ]
+        EditorEndEvent                -> [ kb Vty.KEnd ]
+        EditorYankEvent               -> [ ctrl (key 'y') ]
+        FormSubmitEvent               -> [ kb Vty.KEnter ]
+
+-- | Given a configuration, we want to check it for internal consistency
+-- (i.e. that a given keybinding isn't associated with multiple events
+-- which both need to get generated in the same UI mode) and also for
+-- basic usability (i.e. we shouldn't be binding events which can appear
+-- in the main UI to a key like @e@, which would prevent us from being
+-- able to type messages containing an @e@ in them!
+ensureKeybindingConsistency :: KeyConfig -> [(String, KeyConfig -> KeyHandlerMap)] -> Either String ()
+ensureKeybindingConsistency kc modeMaps = mapM_ checkGroup allBindings
+  where
+    -- This is a list of lists, grouped by keybinding, of all the
+    -- keybinding/event associations that are going to be used with the
+    -- provided key configuration.
+    allBindings = groupWith fst $ concat
+      [ case M.lookup ev kc of
+          Nothing -> zip (defaultBindings ev) (repeat (False, ev))
+          Just (BindingList bs) -> zip bs (repeat (True, ev))
+          Just Unbound -> []
+      | ev <- allEvents
+      ]
+
+    -- The invariant here is that each call to checkGroup is made with a
+    -- list where the first element of every list is the same binding.
+    -- The Bool value in these is True if the event was associated with
+    -- the binding by the user, and False if it's a Matterhorn default.
+    checkGroup :: [(Binding, (Bool, KeyEvent))] -> Either String ()
+    checkGroup [] = error "[ensureKeybindingConsistency: unreachable]"
+    checkGroup evs@((b, _):_) = do
+
+      -- We find out which modes an event can be used in and then invert
+      -- the map, so this is a map from mode to the events contains
+      -- which are bound by the binding included above.
+      let modesFor :: M.Map String [(Bool, KeyEvent)]
+          modesFor = M.unionsWith (++)
+            [ M.fromList [ (m, [(i, ev)]) | m <- modeMap ev ]
+            | (_, (i, ev)) <- evs
+            ]
+
+      -- If there is ever a situation where the same key is bound to two
+      -- events which can appear in the same mode, then we want to throw
+      -- an error, and also be informative about why. It is still okay
+      -- to bind the same key to two events, so long as those events
+      -- never appear in the same UI mode.
+      forM_ (M.assocs modesFor) $ \ (_, vs) ->
+         when (length vs > 1) $
+           Left $ concat $
+             "Multiple overlapping events bound to `" :
+             T.unpack (ppBinding b) :
+             "`:\n" :
+             concat [ [ " - `"
+                      , T.unpack (keyEventName ev)
+                      , "` "
+                      , if isFromUser
+                          then "(via user override)"
+                          else "(matterhorn default)"
+                      , "\n"
+                      ]
+                    | (isFromUser, ev) <- vs
+                    ]
+
+      -- Check for overlap a set of built-in keybindings when we're in a
+      -- mode where the user is typing. (These are perfectly fine when
+      -- we're in other modes.)
+      when ("main" `M.member` modesFor && isBareBinding b) $ do
+        Left $ concat $
+          [ "The keybinding `"
+          , T.unpack (ppBinding b)
+          , "` is bound to the "
+          , case map (ppEvent . snd . snd) evs of
+              [] -> error "unreachable"
+              [e] -> "event " ++ e
+              es  -> "events " ++ intercalate " and " es
+          , "\n"
+          , "This is probably not what you want, as it will interfere "
+          , "with the ability to write messages!\n"
+          ]
+
+    -- Events get some nice formatting!
+    ppEvent ev = "`" ++ T.unpack (keyEventName ev) ++ "`"
+
+    -- This check should get more nuanced, but as a first approximation,
+    -- we shouldn't bind to any bare character key in the main mode.
+    isBareBinding (Binding [] (Vty.KChar {})) = True
+    isBareBinding _ = False
+
+    -- We generate the which-events-are-valid-in-which-modes map from
+    -- our actual keybinding set, so this should never get out of date.
+    modeMap :: KeyEvent -> [String]
+    modeMap ev =
+      let matches kh = ByEvent ev == (kehEventTrigger $ khHandler kh)
+      in [ mode
+         | (mode, mkBindings) <- modeMaps
+         , let KeyHandlerMap m = mkBindings kc
+           in not $ null $ M.filter matches m
+         ]
diff --git a/src/Matterhorn/Events/LeaveChannelConfirm.hs b/src/Matterhorn/Events/LeaveChannelConfirm.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/LeaveChannelConfirm.hs
@@ -0,0 +1,19 @@
+module Matterhorn.Events.LeaveChannelConfirm where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.State.Channels
+import           Matterhorn.Types
+
+
+onEventLeaveChannelConfirm :: Vty.Event -> MH ()
+onEventLeaveChannelConfirm (Vty.EvKey k []) = do
+    case k of
+        Vty.KChar c | c `elem` ("yY"::String) ->
+            leaveCurrentChannel
+        _ -> return ()
+    setMode Main
+onEventLeaveChannelConfirm _ = return ()
diff --git a/src/Matterhorn/Events/Main.hs b/src/Matterhorn/Events/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/Main.hs
@@ -0,0 +1,151 @@
+{-# LANGUAGE MultiWayIf #-}
+module Matterhorn.Events.Main where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Widgets.Edit
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Command
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.Attachments
+import           Matterhorn.State.ChannelSelect
+import           Matterhorn.State.Channels
+import           Matterhorn.State.Editing
+import           Matterhorn.State.MessageSelect
+import           Matterhorn.State.PostListOverlay ( enterFlaggedPostListMode )
+import           Matterhorn.State.UrlSelect
+import           Matterhorn.Types
+
+
+onEventMain :: Vty.Event -> MH ()
+onEventMain =
+  void . handleKeyboardEvent mainKeybindings (\ ev -> do
+      resetReturnChannel
+      case ev of
+          (Vty.EvPaste bytes) -> handlePaste bytes
+          _ -> handleEditingInput ev
+  )
+
+mainKeybindings :: KeyConfig -> KeyHandlerMap
+mainKeybindings = mkKeybindings mainKeyHandlers
+
+mainKeyHandlers :: [KeyEventHandler]
+mainKeyHandlers =
+    [ mkKb EnterSelectModeEvent
+        "Select a message to edit/reply/delete"
+        beginMessageSelect
+
+    , mkKb ReplyRecentEvent
+        "Reply to the most recent message"
+        replyToLatestMessage
+
+    , mkKb ToggleMessagePreviewEvent "Toggle message preview"
+        toggleMessagePreview
+
+    , mkKb ToggleChannelListVisibleEvent "Toggle channel list visibility"
+        toggleChannelListVisibility
+
+    , mkKb ToggleExpandedChannelTopicsEvent "Toggle display of expanded channel topics"
+        toggleExpandedChannelTopics
+
+    , mkKb
+        InvokeEditorEvent
+        "Invoke `$EDITOR` to edit the current message"
+        invokeExternalEditor
+
+    , mkKb
+        EnterFastSelectModeEvent
+        "Enter fast channel selection mode"
+         beginChannelSelect
+
+    , mkKb
+        QuitEvent
+        "Quit"
+        requestQuit
+
+    , staticKb "Tab-complete forward"
+         (Vty.EvKey (Vty.KChar '\t') []) $
+         tabComplete Forwards
+
+    , staticKb "Tab-complete backward"
+         (Vty.EvKey (Vty.KBackTab) []) $
+         tabComplete Backwards
+
+    , mkKb
+        ScrollUpEvent
+        "Scroll up in the channel input history" $ do
+             -- Up in multiline mode does the usual thing; otherwise we
+             -- navigate the history.
+             isMultiline <- use (csEditState.cedEphemeral.eesMultiline)
+             case isMultiline of
+                 True -> mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent
+                                           (Vty.EvKey Vty.KUp [])
+                 False -> channelHistoryBackward
+
+    , mkKb
+        ScrollDownEvent
+        "Scroll down in the channel input history" $ do
+             -- Down in multiline mode does the usual thing; otherwise
+             -- we navigate the history.
+             isMultiline <- use (csEditState.cedEphemeral.eesMultiline)
+             case isMultiline of
+                 True -> mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent
+                                           (Vty.EvKey Vty.KDown [])
+                 False -> channelHistoryForward
+
+    , mkKb PageUpEvent "Page up in the channel message list (enters message select mode)" $ do
+             beginMessageSelect
+
+    , mkKb SelectOldestMessageEvent "Scroll to top of channel message list" $ do
+             beginMessageSelect
+             messageSelectFirst
+
+    , mkKb NextChannelEvent "Change to the next channel in the channel list"
+         nextChannel
+
+    , mkKb PrevChannelEvent "Change to the previous channel in the channel list"
+         prevChannel
+
+    , mkKb NextUnreadChannelEvent "Change to the next channel with unread messages or return to the channel marked '~'"
+         nextUnreadChannel
+
+    , mkKb ShowAttachmentListEvent "Show the attachment list"
+         showAttachmentList
+
+    , mkKb NextUnreadUserOrChannelEvent
+         "Change to the next channel with unread messages preferring direct messages"
+         nextUnreadUserOrChannel
+
+    , mkKb LastChannelEvent "Change to the most recently-focused channel"
+         recentChannel
+
+    , staticKb "Send the current message"
+         (Vty.EvKey Vty.KEnter []) $ do
+             isMultiline <- use (csEditState.cedEphemeral.eesMultiline)
+             case isMultiline of
+                 -- Normally, this event causes the current message to
+                 -- be sent. But in multiline mode we want to insert a
+                 -- newline instead.
+                 True -> handleEditingInput (Vty.EvKey Vty.KEnter [])
+                 False -> do
+                     cId <- use csCurrentChannelId
+                     content <- getEditorContent
+                     handleInputSubmission cId content
+
+    , mkKb EnterOpenURLModeEvent "Select and open a URL posted to the current channel"
+           startUrlSelect
+
+    , mkKb ClearUnreadEvent "Clear the current channel's unread / edited indicators" $
+           clearChannelUnreadStatus =<< use csCurrentChannelId
+
+    , mkKb ToggleMultiLineEvent "Toggle multi-line message compose mode"
+           toggleMultilineEditing
+
+    , mkKb CancelEvent "Cancel autocomplete, message reply, or edit, in that order"
+         cancelAutocompleteOrReplyOrEdit
+
+    , mkKb EnterFlaggedPostsEvent "View currently flagged posts"
+         enterFlaggedPostListMode
+    ]
diff --git a/src/Matterhorn/Events/ManageAttachments.hs b/src/Matterhorn/Events/ManageAttachments.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/ManageAttachments.hs
@@ -0,0 +1,182 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+module Matterhorn.Events.ManageAttachments
+  ( onEventManageAttachments
+  , attachmentListKeybindings
+  , attachmentBrowseKeyHandlers
+  , attachmentBrowseKeybindings
+  , attachmentListKeyHandlers
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Control.Exception as E
+import qualified Brick.Widgets.FileBrowser as FB
+import qualified Brick.Widgets.List as L
+import qualified Data.ByteString as BS
+import qualified Data.Text as T
+import qualified Data.Vector as Vector
+import qualified Graphics.Vty as V
+import           Lens.Micro.Platform ( (?=), (%=), to )
+
+import           Matterhorn.Types
+import           Matterhorn.Types.KeyEvents
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.Attachments
+import           Matterhorn.State.Common
+
+
+onEventManageAttachments :: V.Event -> MH ()
+onEventManageAttachments e = do
+    mode <- gets appMode
+    case mode of
+        ManageAttachments -> void $ onEventAttachmentList e
+        ManageAttachmentsBrowseFiles -> onEventBrowseFile e
+        _ -> error "BUG: onEventManageAttachments called in invalid mode"
+
+onEventAttachmentList :: V.Event -> MH Bool
+onEventAttachmentList =
+    handleKeyboardEvent attachmentListKeybindings $
+        mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent
+
+attachmentListKeybindings :: KeyConfig -> KeyHandlerMap
+attachmentListKeybindings = mkKeybindings attachmentListKeyHandlers
+
+attachmentListKeyHandlers :: [KeyEventHandler]
+attachmentListKeyHandlers =
+    [ mkKb CancelEvent "Close attachment list"
+          (setMode Main)
+    , mkKb SelectUpEvent "Move cursor up" $
+          mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent (V.EvKey V.KUp [])
+    , mkKb SelectDownEvent "Move cursor down" $
+          mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent (V.EvKey V.KDown [])
+    , mkKb AttachmentListAddEvent "Add a new attachment to the attachment list"
+          showAttachmentFileBrowser
+    , mkKb AttachmentOpenEvent "Open the selected attachment using the URL open command"
+          openSelectedAttachment
+    , mkKb AttachmentListDeleteEvent "Delete the selected attachment from the attachment list"
+          deleteSelectedAttachment
+    ]
+
+attachmentBrowseKeybindings :: KeyConfig -> KeyHandlerMap
+attachmentBrowseKeybindings = mkKeybindings attachmentBrowseKeyHandlers
+
+attachmentBrowseKeyHandlers :: [KeyEventHandler]
+attachmentBrowseKeyHandlers =
+    [ mkKb CancelEvent "Cancel attachment file browse"
+      cancelAttachmentBrowse
+    , mkKb AttachmentOpenEvent "Open the selected file using the URL open command"
+          openSelectedBrowserEntry
+    ]
+
+withFileBrowser :: ((FB.FileBrowser Name) -> MH ()) -> MH ()
+withFileBrowser f = do
+    use (csEditState.cedFileBrowser) >>= \case
+        Nothing -> do
+            -- The widget has not been created yet.  This should
+            -- normally not occur, because the ManageAttachments
+            -- events should not fire when there is no FileBrowser
+            -- Widget active to cause Brick to generate these events.
+            -- This could therefore be implemented as an `error "BUG:
+            -- ..."` handler, but the more benign approach is to
+            -- simply create an available FileBrowser at this stage.
+            new_b <- liftIO $ FB.newFileBrowser FB.selectNonDirectories AttachmentFileBrowser Nothing
+            csEditState.cedFileBrowser ?= new_b
+            f new_b
+        Just b -> f b
+
+openSelectedAttachment :: MH ()
+openSelectedAttachment = do
+    cur <- use (csEditState.cedAttachmentList.to L.listSelectedElement)
+    case cur of
+        Nothing -> return ()
+        Just (_, entry) -> void $ openFilePath (FB.fileInfoFilePath $
+                                                attachmentDataFileInfo entry)
+
+openSelectedBrowserEntry :: MH ()
+openSelectedBrowserEntry = withFileBrowser $ \b ->
+    case FB.fileBrowserCursor b of
+        Nothing -> return ()
+        Just entry -> void $ openFilePath (FB.fileInfoFilePath entry)
+
+onEventBrowseFile :: V.Event -> MH ()
+onEventBrowseFile e = do
+    withFileBrowser $ \b -> do
+        case FB.fileBrowserIsSearching b of
+            False ->
+                void $ handleKeyboardEvent attachmentBrowseKeybindings handleFileBrowserEvent e
+            True ->
+                handleFileBrowserEvent e
+
+    -- n.b. the FileBrowser may have been updated above, so re-acquire it
+    withFileBrowser $ \b -> do
+        case FB.fileBrowserException b of
+            Nothing -> return ()
+            Just ex -> do
+                mhLog LogError $ T.pack $ "FileBrowser exception: " <> show ex
+
+cancelAttachmentBrowse :: MH ()
+cancelAttachmentBrowse = do
+    es <- use (csEditState.cedAttachmentList.L.listElementsL)
+    case length es of
+        0 -> setMode Main
+        _ -> setMode ManageAttachments
+
+handleFileBrowserEvent :: V.Event -> MH ()
+handleFileBrowserEvent e = do
+  let fbHandle ev = sequence . (fmap (FB.handleFileBrowserEvent ev))
+  mhHandleEventLensed (csEditState.cedFileBrowser) fbHandle e
+
+  withFileBrowser $ \b -> do
+    -- TODO: Check file browser exception state
+    let entries = FB.fileBrowserSelection b
+    forM_ entries $ \entry -> do
+        -- Is the entry already present? If so, ignore the selection.
+        es <- use (csEditState.cedAttachmentList.L.listElementsL)
+        let matches = (== (FB.fileInfoFilePath entry)) .
+                      FB.fileInfoFilePath .
+                      attachmentDataFileInfo
+        case Vector.find matches es of
+            Just _ -> return ()
+            Nothing -> do
+                let path = FB.fileInfoFilePath entry
+                readResult <- liftIO $ E.try $ BS.readFile path
+                case readResult of
+                    Left (_::E.SomeException) ->
+                        -- TODO: report the error
+                        return ()
+                    Right bytes -> do
+                        let a = AttachmentData { attachmentDataFileInfo = entry
+                                               , attachmentDataBytes = bytes
+                                               }
+                        oldIdx <- use (csEditState.cedAttachmentList.L.listSelectedL)
+                        let newIdx = if Vector.null es
+                                     then Just 0
+                                     else oldIdx
+                        csEditState.cedAttachmentList %= L.listReplace (Vector.snoc es a) newIdx
+
+    when (not $ null entries) $ setMode Main
+
+deleteSelectedAttachment :: MH ()
+deleteSelectedAttachment = do
+    es <- use (csEditState.cedAttachmentList.L.listElementsL)
+    mSel <- use (csEditState.cedAttachmentList.to L.listSelectedElement)
+    case mSel of
+        Nothing ->
+            return ()
+        Just (pos, _) -> do
+            oldIdx <- use (csEditState.cedAttachmentList.L.listSelectedL)
+            let idx = if Vector.length es == 1
+                      then Nothing
+                      else case oldIdx of
+                          Nothing -> Just 0
+                          Just old -> if pos >= old
+                                      then Just $ pos - 1
+                                      else Just pos
+            csEditState.cedAttachmentList %= L.listReplace (deleteAt pos es) idx
+
+deleteAt :: Int -> Vector.Vector a -> Vector.Vector a
+deleteAt p as | p < 0 || p >= length as = as
+              | otherwise = Vector.take p as <> Vector.drop (p + 1) as
diff --git a/src/Matterhorn/Events/MessageSelect.hs b/src/Matterhorn/Events/MessageSelect.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/MessageSelect.hs
@@ -0,0 +1,85 @@
+module Matterhorn.Events.MessageSelect where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.Text as T
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.MessageSelect
+import           Matterhorn.State.ReactionEmojiListOverlay
+import           Matterhorn.Types
+
+
+messagesPerPageOperation :: Int
+messagesPerPageOperation = 10
+
+onEventMessageSelect :: Vty.Event -> MH ()
+onEventMessageSelect =
+  void . handleKeyboardEvent messageSelectKeybindings (const $ return ())
+
+onEventMessageSelectDeleteConfirm :: Vty.Event -> MH ()
+onEventMessageSelectDeleteConfirm (Vty.EvKey (Vty.KChar 'y') []) = do
+    deleteSelectedMessage
+    setMode Main
+onEventMessageSelectDeleteConfirm _ =
+    setMode Main
+
+messageSelectKeybindings :: KeyConfig -> KeyHandlerMap
+messageSelectKeybindings = mkKeybindings messageSelectKeyHandlers
+
+messageSelectKeyHandlers :: [KeyEventHandler]
+messageSelectKeyHandlers =
+    [ mkKb CancelEvent "Cancel message selection" $
+        setMode Main
+
+    , mkKb SelectUpEvent "Select the previous message" messageSelectUp
+    , mkKb SelectDownEvent "Select the next message" messageSelectDown
+    , mkKb ScrollTopEvent "Scroll to top and select the oldest message"
+        messageSelectFirst
+    , mkKb ScrollBottomEvent "Scroll to bottom and select the latest message"
+        messageSelectLast
+    , mkKb
+        PageUpEvent
+        (T.pack $ "Move the cursor up by " <> show messagesPerPageOperation <> " messages")
+        (messageSelectUpBy messagesPerPageOperation)
+    , mkKb
+        PageDownEvent
+        (T.pack $ "Move the cursor down by " <> show messagesPerPageOperation <> " messages")
+        (messageSelectDownBy messagesPerPageOperation)
+
+    , mkKb OpenMessageURLEvent "Open all URLs in the selected message"
+        openSelectedMessageURLs
+
+    , mkKb ReplyMessageEvent "Begin composing a reply to the selected message"
+         beginReplyCompose
+
+    , mkKb EditMessageEvent "Begin editing the selected message"
+         beginEditMessage
+
+    , mkKb DeleteMessageEvent "Delete the selected message (with confirmation)"
+         beginConfirmDeleteSelectedMessage
+
+    , mkKb YankMessageEvent "Copy a verbatim section or message to the clipboard"
+         yankSelectedMessageVerbatim
+
+    , mkKb YankWholeMessageEvent "Copy an entire message to the clipboard"
+         yankSelectedMessage
+
+    , mkKb PinMessageEvent "Toggle whether the selected message is pinned"
+         pinSelectedMessage
+
+    , mkKb FlagMessageEvent "Flag the selected message"
+         flagSelectedMessage
+
+    , mkKb ViewMessageEvent "View the selected message"
+         viewSelectedMessage
+
+    , mkKb FillGapEvent "Fetch messages for the selected gap"
+         fillSelectedGap
+
+    , mkKb ReactToMessageEvent "Post a reaction to the selected message"
+         enterReactionEmojiListOverlayMode
+
+    ]
diff --git a/src/Matterhorn/Events/PostListOverlay.hs b/src/Matterhorn/Events/PostListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/PostListOverlay.hs
@@ -0,0 +1,28 @@
+module Matterhorn.Events.PostListOverlay where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Types
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.PostListOverlay
+
+
+onEventPostListOverlay :: Vty.Event -> MH ()
+onEventPostListOverlay =
+  void . handleKeyboardEvent postListOverlayKeybindings (const $ return ())
+
+-- | The keybindings we want to use while viewing a post list overlay
+postListOverlayKeybindings :: KeyConfig -> KeyHandlerMap
+postListOverlayKeybindings = mkKeybindings postListOverlayKeyHandlers
+
+postListOverlayKeyHandlers :: [KeyEventHandler]
+postListOverlayKeyHandlers =
+  [ mkKb CancelEvent "Exit post browsing" exitPostListMode
+  , mkKb SelectUpEvent "Select the previous message" postListSelectUp
+  , mkKb SelectDownEvent "Select the next message" postListSelectDown
+  , mkKb FlagMessageEvent "Toggle the selected message flag" postListUnflagSelected
+  , mkKb ActivateListItemEvent "Jump to and select current message" postListJumpToCurrent
+  ]
diff --git a/src/Matterhorn/Events/ReactionEmojiListOverlay.hs b/src/Matterhorn/Events/ReactionEmojiListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/ReactionEmojiListOverlay.hs
@@ -0,0 +1,35 @@
+module Matterhorn.Events.ReactionEmojiListOverlay
+  ( onEventReactionEmojiListOverlay
+  , reactionEmojiListOverlayKeybindings
+  , reactionEmojiListOverlayKeyHandlers
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.ReactionEmojiListOverlay
+import           Matterhorn.State.ListOverlay
+import           Matterhorn.Types
+
+
+onEventReactionEmojiListOverlay :: Vty.Event -> MH ()
+onEventReactionEmojiListOverlay =
+    void . onEventListOverlay csReactionEmojiListOverlay reactionEmojiListOverlayKeybindings
+
+-- | The keybindings we want to use while viewing an emoji list overlay
+reactionEmojiListOverlayKeybindings :: KeyConfig -> KeyHandlerMap
+reactionEmojiListOverlayKeybindings = mkKeybindings reactionEmojiListOverlayKeyHandlers
+
+reactionEmojiListOverlayKeyHandlers :: [KeyEventHandler]
+reactionEmojiListOverlayKeyHandlers =
+    [ mkKb CancelEvent "Close the emoji search window" (exitListOverlay csReactionEmojiListOverlay)
+    , mkKb SearchSelectUpEvent "Select the previous emoji" reactionEmojiListSelectUp
+    , mkKb SearchSelectDownEvent "Select the next emoji" reactionEmojiListSelectDown
+    , mkKb PageDownEvent "Page down in the emoji list" reactionEmojiListPageDown
+    , mkKb PageUpEvent "Page up in the emoji list" reactionEmojiListPageUp
+    , mkKb ActivateListItemEvent "Post the selected emoji reaction" (listOverlayActivateCurrent csReactionEmojiListOverlay)
+    ]
diff --git a/src/Matterhorn/Events/ShowHelp.hs b/src/Matterhorn/Events/ShowHelp.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/ShowHelp.hs
@@ -0,0 +1,44 @@
+module Matterhorn.Events.ShowHelp where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Constants
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.Types
+
+
+onEventShowHelp :: Vty.Event -> MH Bool
+onEventShowHelp =
+  handleKeyboardEvent helpKeybindings $ \ e -> case e of
+    Vty.EvKey _ _ -> popMode
+    _ -> return ()
+
+helpKeybindings :: KeyConfig -> KeyHandlerMap
+helpKeybindings = mkKeybindings helpKeyHandlers
+
+helpKeyHandlers :: [KeyEventHandler]
+helpKeyHandlers =
+    [ mkKb ScrollUpEvent "Scroll up" $
+        mh $ vScrollBy (viewportScroll HelpViewport) (-1)
+    , mkKb ScrollDownEvent "Scroll down" $
+        mh $ vScrollBy (viewportScroll HelpViewport) 1
+    , mkKb PageUpEvent "Page up" $
+        mh $ vScrollBy (viewportScroll HelpViewport) (-1 * pageAmount)
+    , mkKb PageDownEvent "Page down" $
+        mh $ vScrollBy (viewportScroll HelpViewport) (1 * pageAmount)
+    , mkKb CancelEvent "Return to the previous interface" $
+        popMode
+    , mkKb ScrollBottomEvent "Scroll to the end of the help" $
+        mh $ vScrollToEnd (viewportScroll HelpViewport)
+    , mkKb ScrollTopEvent "Scroll to the beginning of the help" $
+        mh $ vScrollToBeginning (viewportScroll HelpViewport)
+    ]
+
+popMode :: MH ()
+popMode = do
+    ShowHelp _ prevMode <- gets appMode
+    setMode prevMode
diff --git a/src/Matterhorn/Events/TabbedWindow.hs b/src/Matterhorn/Events/TabbedWindow.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/TabbedWindow.hs
@@ -0,0 +1,56 @@
+{-# LANGUAGE RankNTypes #-}
+module Matterhorn.Events.TabbedWindow
+  ( handleTabbedWindowEvent
+  , tabbedWindowKeybindings
+  , tabbedWindowKeyHandlers
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Graphics.Vty as Vty
+import           Lens.Micro.Platform ( Lens', (.=) )
+
+import           Matterhorn.Types
+import           Matterhorn.Types.KeyEvents
+import           Matterhorn.Events.Keybindings
+
+handleTabbedWindowEvent :: (Show a, Eq a)
+                        => Lens' ChatState (TabbedWindow a)
+                        -> Vty.Event
+                        -> MH Bool
+handleTabbedWindowEvent target e = do
+    w <- use target
+    handleKeyboardEvent (tabbedWindowKeybindings target) (forwardEvent w) e
+
+forwardEvent :: (Show a, Eq a)
+             => TabbedWindow a
+             -> Vty.Event
+             -> MH ()
+forwardEvent w e = do
+    let cur = getCurrentTabbedWindowEntry w
+    tweHandleEvent cur (twValue w) e
+
+tabbedWindowKeybindings :: (Show a, Eq a)
+                        => Lens' ChatState (TabbedWindow a)
+                        -> KeyConfig
+                        -> KeyHandlerMap
+tabbedWindowKeybindings target = mkKeybindings $ tabbedWindowKeyHandlers target
+
+tabbedWindowKeyHandlers :: (Show a, Eq a)
+                        => Lens' ChatState (TabbedWindow a)
+                        -> [KeyEventHandler]
+tabbedWindowKeyHandlers target =
+    [ mkKb CancelEvent "Close window" $ do
+        w <- use target
+        setMode (twReturnMode w)
+
+    , mkKb SelectNextTabEvent "Select next tab" $ do
+        w' <- tabbedWindowNextTab =<< use target
+        target .= w'
+
+    , mkKb SelectPreviousTabEvent "Select previous tab" $ do
+        w' <- tabbedWindowPreviousTab =<< use target
+        target .= w'
+    ]
diff --git a/src/Matterhorn/Events/ThemeListOverlay.hs b/src/Matterhorn/Events/ThemeListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/ThemeListOverlay.hs
@@ -0,0 +1,30 @@
+module Matterhorn.Events.ThemeListOverlay where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.ThemeListOverlay
+import           Matterhorn.State.ListOverlay
+import           Matterhorn.Types
+
+
+onEventThemeListOverlay :: Vty.Event -> MH ()
+onEventThemeListOverlay =
+    void . onEventListOverlay csThemeListOverlay themeListOverlayKeybindings
+
+-- | The keybindings we want to use while viewing a user list overlay
+themeListOverlayKeybindings :: KeyConfig -> KeyHandlerMap
+themeListOverlayKeybindings = mkKeybindings themeListOverlayKeyHandlers
+
+themeListOverlayKeyHandlers :: [KeyEventHandler]
+themeListOverlayKeyHandlers =
+    [ mkKb CancelEvent "Close the theme list" (exitListOverlay csThemeListOverlay)
+    , mkKb SearchSelectUpEvent "Select the previous theme" themeListSelectUp
+    , mkKb SearchSelectDownEvent "Select the next theme" themeListSelectDown
+    , mkKb PageDownEvent "Page down in the theme list" themeListPageDown
+    , mkKb PageUpEvent "Page up in the theme list" themeListPageUp
+    , mkKb ActivateListItemEvent "Switch to the selected color theme" (listOverlayActivateCurrent csThemeListOverlay)
+    ]
diff --git a/src/Matterhorn/Events/UrlSelect.hs b/src/Matterhorn/Events/UrlSelect.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/UrlSelect.hs
@@ -0,0 +1,39 @@
+module Matterhorn.Events.UrlSelect where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Widgets.List
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.UrlSelect
+import           Matterhorn.Types
+
+
+onEventUrlSelect :: Vty.Event -> MH Bool
+onEventUrlSelect =
+  handleKeyboardEvent urlSelectKeybindings $ \ ev ->
+    mhHandleEventLensed csUrlList handleListEvent ev
+
+urlSelectKeybindings :: KeyConfig -> KeyHandlerMap
+urlSelectKeybindings = mkKeybindings urlSelectKeyHandlers
+
+urlSelectKeyHandlers :: [KeyEventHandler]
+urlSelectKeyHandlers =
+    [ staticKb "Open the selected URL, if any"
+         (Vty.EvKey Vty.KEnter []) $
+             openSelectedURL
+
+    , mkKb CancelEvent "Cancel URL selection" stopUrlSelect
+
+    , mkKb SelectUpEvent "Move cursor up" $
+        mhHandleEventLensed csUrlList handleListEvent (Vty.EvKey Vty.KUp [])
+
+    , mkKb SelectDownEvent "Move cursor down" $
+        mhHandleEventLensed csUrlList handleListEvent (Vty.EvKey Vty.KDown [])
+
+    , staticKb "Cancel URL selection"
+         (Vty.EvKey (Vty.KChar 'q') []) $ stopUrlSelect
+
+    ]
diff --git a/src/Matterhorn/Events/UserListOverlay.hs b/src/Matterhorn/Events/UserListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Events/UserListOverlay.hs
@@ -0,0 +1,30 @@
+module Matterhorn.Events.UserListOverlay where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.UserListOverlay
+import           Matterhorn.State.ListOverlay
+import           Matterhorn.Types
+
+
+onEventUserListOverlay :: Vty.Event -> MH ()
+onEventUserListOverlay =
+    void . onEventListOverlay csUserListOverlay userListOverlayKeybindings
+
+-- | The keybindings we want to use while viewing a user list overlay
+userListOverlayKeybindings :: KeyConfig -> KeyHandlerMap
+userListOverlayKeybindings = mkKeybindings userListOverlayKeyHandlers
+
+userListOverlayKeyHandlers :: [KeyEventHandler]
+userListOverlayKeyHandlers =
+    [ mkKb CancelEvent "Close the user search list" (exitListOverlay csUserListOverlay)
+    , mkKb SearchSelectUpEvent "Select the previous user" userListSelectUp
+    , mkKb SearchSelectDownEvent "Select the next user" userListSelectDown
+    , mkKb PageDownEvent "Page down in the user list" userListPageDown
+    , mkKb PageUpEvent "Page up in the user list" userListPageUp
+    , mkKb ActivateListItemEvent "Interact with the selected user" (listOverlayActivateCurrent csUserListOverlay)
+    ]
diff --git a/src/Matterhorn/FilePaths.hs b/src/Matterhorn/FilePaths.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/FilePaths.hs
@@ -0,0 +1,167 @@
+{-# LANGUAGE TupleSections #-}
+module Matterhorn.FilePaths
+  ( historyFilePath
+  , historyFileName
+
+  , lastRunStateFilePath
+  , lastRunStateFileName
+
+  , configFileName
+
+  , xdgName
+  , locateConfig
+  , xdgSyntaxDir
+  , syntaxDirName
+  , userEmojiJsonPath
+  , bundledEmojiJsonPath
+  , emojiJsonFilename
+
+  , Script(..)
+  , locateScriptPath
+  , getAllScripts
+  )
+where
+
+import Prelude ()
+import Matterhorn.Prelude
+
+import Data.Text ( unpack )
+import System.Directory ( doesFileExist
+                        , doesDirectoryExist
+                        , getDirectoryContents
+                        , getPermissions
+                        , executable
+                        )
+import System.Environment ( getExecutablePath )
+import System.Environment.XDG.BaseDir ( getUserConfigFile
+                                      , getAllConfigFiles
+                                      , getUserConfigDir
+                                      )
+import System.FilePath ( (</>), takeBaseName, takeDirectory, splitPath, joinPath )
+
+
+xdgName :: String
+xdgName = "matterhorn"
+
+historyFileName :: FilePath
+historyFileName = "history.txt"
+
+lastRunStateFileName :: Text -> FilePath
+lastRunStateFileName teamId = "last_run_state_" ++ unpack teamId ++ ".json"
+
+configFileName :: FilePath
+configFileName = "config.ini"
+
+historyFilePath :: IO FilePath
+historyFilePath = getUserConfigFile xdgName historyFileName
+
+lastRunStateFilePath :: Text -> IO FilePath
+lastRunStateFilePath teamId =
+  getUserConfigFile xdgName (lastRunStateFileName teamId)
+
+-- | Get the XDG path to the user-specific syntax definition directory.
+-- The path does not necessarily exist.
+xdgSyntaxDir :: IO FilePath
+xdgSyntaxDir = (</> syntaxDirName) <$> getUserConfigDir xdgName
+
+-- | Get the XDG path to the user-specific emoji JSON file. The path
+-- does not necessarily exist.
+userEmojiJsonPath :: IO FilePath
+userEmojiJsonPath = (</> emojiJsonFilename) <$> getUserConfigDir xdgName
+
+-- | Get the emoji JSON path relative to the development binary location
+-- or the release binary location.
+bundledEmojiJsonPath :: IO FilePath
+bundledEmojiJsonPath = do
+    selfPath <- getExecutablePath
+    let distDir = "dist-newstyle/"
+        pathBits = splitPath selfPath
+
+    return $ if distDir `elem` pathBits
+             then
+                 -- We're in development, so use the development
+                 -- executable path to locate the emoji path in the
+                 -- development tree.
+                 (joinPath $ takeWhile (/= distDir) pathBits) </> emojiDirName </> emojiJsonFilename
+             else
+                 -- In this case we assume the binary is being run from
+                 -- a release, in which case the syntax directory is a
+                 -- sibling of the executable path.
+                 takeDirectory selfPath </> emojiDirName </> emojiJsonFilename
+
+emojiJsonFilename :: FilePath
+emojiJsonFilename = "emoji.json"
+
+emojiDirName :: FilePath
+emojiDirName = "emoji"
+
+syntaxDirName :: FilePath
+syntaxDirName = "syntax"
+
+-- | Find a specified configuration file by looking in all of the
+-- supported locations.
+locateConfig :: FilePath -> IO (Maybe FilePath)
+locateConfig filename = do
+  xdgLocations <- getAllConfigFiles xdgName filename
+  let confLocations = ["./" <> filename] ++
+                      xdgLocations ++
+                      ["/etc/matterhorn/" <> filename]
+  results <- forM confLocations $ \fp -> (fp,) <$> doesFileExist fp
+  return $ listToMaybe $ fst <$> filter snd results
+
+scriptDirName :: FilePath
+scriptDirName = "scripts"
+
+data Script
+  = ScriptPath FilePath
+  | NonexecScriptPath FilePath
+  | ScriptNotFound
+    deriving (Eq, Read, Show)
+
+toScript :: FilePath -> IO (Script)
+toScript fp = do
+  perm <- getPermissions fp
+  return $ if executable perm
+    then ScriptPath fp
+    else NonexecScriptPath fp
+
+isExecutable :: FilePath -> IO Bool
+isExecutable fp = do
+  perm <- getPermissions fp
+  return (executable perm)
+
+locateScriptPath :: FilePath -> IO Script
+locateScriptPath name
+  | head name == '.' = return ScriptNotFound
+  | otherwise = do
+    xdgLocations <- getAllConfigFiles xdgName scriptDirName
+    let cmdLocations = [ xdgLoc ++ "/" ++ name
+                       | xdgLoc <- xdgLocations
+                       ] ++ [ "/etc/matterhorn/scripts/" <> name ]
+    existingFiles <- filterM doesFileExist cmdLocations
+    executables <- mapM toScript existingFiles
+    return $ case executables of
+      (path:_) -> path
+      _        -> ScriptNotFound
+
+-- | This returns a list of valid scripts, and a list of non-executable
+--   scripts.
+getAllScripts :: IO ([FilePath], [FilePath])
+getAllScripts = do
+  xdgLocations <- getAllConfigFiles xdgName scriptDirName
+  let cmdLocations = xdgLocations ++ ["/etc/matterhorn/scripts"]
+  let getCommands dir = do
+        exists <- doesDirectoryExist dir
+        if exists
+          then map ((dir ++ "/") ++) `fmap` getDirectoryContents dir
+          else return []
+  let isNotHidden f = case f of
+        ('.':_) -> False
+        []      -> False
+        _       -> True
+  allScripts <- concat `fmap` mapM getCommands cmdLocations
+  execs <- filterM isExecutable allScripts
+  nonexecs <- filterM (fmap not . isExecutable) allScripts
+  return ( filter isNotHidden $ map takeBaseName execs
+         , filter isNotHidden $ map takeBaseName nonexecs
+         )
diff --git a/src/Matterhorn/HelpTopics.hs b/src/Matterhorn/HelpTopics.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/HelpTopics.hs
@@ -0,0 +1,49 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Matterhorn.HelpTopics
+  ( helpTopics
+  , lookupHelpTopic
+  , themeHelpTopic
+  , mainHelpTopic
+  )
+where
+
+import Prelude ()
+import Matterhorn.Prelude
+
+import Matterhorn.Types
+
+
+helpTopics :: [HelpTopic]
+helpTopics =
+    [ mainHelpTopic
+    , scriptHelpTopic
+    , themeHelpTopic
+    , keybindingHelpTopic
+    , syntaxHighlightingHelpTopic
+    ]
+
+mainHelpTopic :: HelpTopic
+mainHelpTopic =
+    HelpTopic "main" "This help page" MainHelp HelpText
+
+scriptHelpTopic :: HelpTopic
+scriptHelpTopic =
+    HelpTopic "scripts" "Help on available scripts" ScriptHelp ScriptHelpText
+
+themeHelpTopic :: HelpTopic
+themeHelpTopic =
+    HelpTopic "themes" "Help on color themes" ThemeHelp ThemeHelpText
+
+keybindingHelpTopic :: HelpTopic
+keybindingHelpTopic =
+    HelpTopic "keybindings" "Help on overriding keybindings"
+      KeybindingHelp KeybindingHelpText
+
+syntaxHighlightingHelpTopic :: HelpTopic
+syntaxHighlightingHelpTopic =
+    HelpTopic "syntax" "Help on syntax highlighing"
+      SyntaxHighlightHelp SyntaxHighlightHelpText
+
+lookupHelpTopic :: Text -> Maybe HelpTopic
+lookupHelpTopic topic =
+    listToMaybe $ filter ((== topic) . helpTopicName) helpTopics
diff --git a/src/Matterhorn/IOUtil.hs b/src/Matterhorn/IOUtil.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/IOUtil.hs
@@ -0,0 +1,20 @@
+module Matterhorn.IOUtil
+  ( convertIOException
+  )
+where
+
+import Prelude ()
+import Matterhorn.Prelude
+
+import Control.Exception
+import Control.Monad.Trans.Except
+import System.IO.Error ( ioeGetErrorString )
+
+
+convertIOException :: IO a -> ExceptT String IO a
+convertIOException act = do
+    result <- liftIO $ (Right <$> act) `catch`
+                       (\(e::IOError) -> return $ Left $ ioeGetErrorString e)
+    case result of
+        Left e -> throwE e
+        Right v -> return v
diff --git a/src/Matterhorn/InputHistory.hs b/src/Matterhorn/InputHistory.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/InputHistory.hs
@@ -0,0 +1,76 @@
+{-# LANGUAGE TemplateHaskell #-}
+module Matterhorn.InputHistory
+  ( InputHistory
+  , newHistory
+  , readHistory
+  , writeHistory
+  , addHistoryEntry
+  , getHistoryEntry
+  , removeChannelHistory
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Control.Monad.Trans.Except
+import qualified Data.HashMap.Strict as HM
+import qualified Data.Vector as V
+import           Lens.Micro.Platform ( (.~), (^?), (%~), at, ix, makeLenses )
+import           System.Directory ( createDirectoryIfMissing )
+import           System.FilePath ( dropFileName )
+import qualified System.IO.Strict as S
+import qualified System.Posix.Files as P
+import qualified System.Posix.Types as P
+
+import           Network.Mattermost.Types ( ChannelId )
+
+import           Matterhorn.FilePaths
+import           Matterhorn.IOUtil
+
+
+data InputHistory =
+    InputHistory { _historyEntries :: HashMap ChannelId (V.Vector Text)
+                 }
+                 deriving (Show)
+
+makeLenses ''InputHistory
+
+newHistory :: InputHistory
+newHistory = InputHistory mempty
+
+removeChannelHistory :: ChannelId -> InputHistory -> InputHistory
+removeChannelHistory cId ih = ih & historyEntries.at cId .~ Nothing
+
+historyFileMode :: P.FileMode
+historyFileMode = P.unionFileModes P.ownerReadMode P.ownerWriteMode
+
+writeHistory :: InputHistory -> IO ()
+writeHistory ih = do
+    historyFile <- historyFilePath
+    createDirectoryIfMissing True $ dropFileName historyFile
+    let entries = (\(cId, z) -> (cId, V.toList z)) <$>
+                  (HM.toList $ ih^.historyEntries)
+    writeFile historyFile $ show entries
+    P.setFileMode historyFile historyFileMode
+
+readHistory :: IO (Either String InputHistory)
+readHistory = runExceptT $ do
+    contents <- convertIOException (S.readFile =<< historyFilePath)
+    case reads contents of
+        [(val, "")] -> do
+            let entries = (\(cId, es) -> (cId, V.fromList es)) <$> val
+            return $ InputHistory $ HM.fromList entries
+        _ -> throwE "Failed to parse history file"
+
+addHistoryEntry :: Text -> ChannelId -> InputHistory -> InputHistory
+addHistoryEntry e cId ih = ih & historyEntries.at cId %~ insertEntry
+    where
+    insertEntry Nothing  = Just $ V.singleton e
+    insertEntry (Just v) =
+      Just $ V.cons e (V.filter (/= e) v)
+
+getHistoryEntry :: ChannelId -> Int -> InputHistory -> Maybe Text
+getHistoryEntry cId i ih = do
+    es <- ih^.historyEntries.at cId
+    es ^? ix i
diff --git a/src/Matterhorn/KeyMap.hs b/src/Matterhorn/KeyMap.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/KeyMap.hs
@@ -0,0 +1,28 @@
+module Matterhorn.KeyMap
+  ( keybindingModeMap
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.Events.ChannelSelect
+import           Matterhorn.Events.Main
+import           Matterhorn.Events.MessageSelect
+import           Matterhorn.Events.PostListOverlay
+import           Matterhorn.Events.ShowHelp
+import           Matterhorn.Events.UrlSelect
+import           Matterhorn.Events.ManageAttachments
+
+keybindingModeMap :: [(String, KeyConfig -> KeyHandlerMap)]
+keybindingModeMap =
+    [ ("main", mainKeybindings)
+    , ("help screen", helpKeybindings)
+    , ("channel select", channelSelectKeybindings)
+    , ("url select", urlSelectKeybindings)
+    , ("message select", messageSelectKeybindings)
+    , ("post list overlay", postListOverlayKeybindings)
+    , ("attachment list", attachmentListKeybindings)
+    , ("attachment file browse", attachmentBrowseKeybindings)
+    ]
diff --git a/src/Matterhorn/LastRunState.hs b/src/Matterhorn/LastRunState.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/LastRunState.hs
@@ -0,0 +1,99 @@
+{-# LANGUAGE TemplateHaskell #-}
+module Matterhorn.LastRunState
+  ( LastRunState
+  , lrsHost
+  , lrsPort
+  , lrsUserId
+  , lrsSelectedChannelId
+  , writeLastRunState
+  , readLastRunState
+  , isValidLastRunState
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Control.Monad.Trans.Except
+import qualified Data.Aeson as A
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as LBS
+import           Lens.Micro.Platform ( makeLenses )
+import           System.Directory ( createDirectoryIfMissing )
+import           System.FilePath ( dropFileName )
+import qualified System.Posix.Files as P
+import qualified System.Posix.Types as P
+
+import           Network.Mattermost.Lenses
+import           Network.Mattermost.Types
+
+import           Matterhorn.FilePaths
+import           Matterhorn.IOUtil
+import           Matterhorn.Types
+
+
+-- | Run state of the program. This is saved in a file on program exit and
+-- | looked up from the file on program startup.
+data LastRunState = LastRunState
+  { _lrsHost              :: Hostname  -- ^ Host of the server
+  , _lrsPort              :: Port      -- ^ Post of the server
+  , _lrsUserId            :: UserId    -- ^ ID of the logged-in user
+  , _lrsSelectedChannelId :: ChannelId -- ^ ID of the last selected channel
+  }
+
+instance A.ToJSON LastRunState where
+  toJSON lrs = A.object [ "host"           A..= _lrsHost lrs
+                        , "port"           A..= _lrsPort lrs
+                        , "user_id"        A..= _lrsUserId lrs
+                        , "sel_channel_id" A..= _lrsSelectedChannelId lrs
+                        ]
+
+instance A.FromJSON LastRunState where
+  parseJSON = A.withObject "LastRunState" $ \v ->
+    LastRunState
+    <$> v A..: "host"
+    <*> v A..: "port"
+    <*> v A..: "user_id"
+    <*> v A..: "sel_channel_id"
+
+makeLenses ''LastRunState
+
+toLastRunState :: ChatState -> LastRunState
+toLastRunState cs = LastRunState
+  { _lrsHost              = cs^.csResources.crConn.cdHostnameL
+  , _lrsPort              = cs^.csResources.crConn.cdPortL
+  , _lrsUserId            = myUserId cs
+  , _lrsSelectedChannelId = cs^.csCurrentChannelId
+  }
+
+lastRunStateFileMode :: P.FileMode
+lastRunStateFileMode = P.unionFileModes P.ownerReadMode P.ownerWriteMode
+
+-- | Writes the run state to a file. The file is specific to the current team.
+-- | Writes only if the current channel is an ordrinary or a private channel.
+writeLastRunState :: ChatState -> IO ()
+writeLastRunState cs =
+  when (cs^.csCurrentChannel.ccInfo.cdType `elem` [Ordinary, Private]) $ do
+    let runState = toLastRunState cs
+        tId      = myTeamId cs
+
+    lastRunStateFile <- lastRunStateFilePath $ unId $ toId tId
+    createDirectoryIfMissing True $ dropFileName lastRunStateFile
+    BS.writeFile lastRunStateFile $ LBS.toStrict $ A.encode runState
+    P.setFileMode lastRunStateFile lastRunStateFileMode
+
+-- | Reads the last run state from a file given the current team ID.
+readLastRunState :: TeamId -> IO (Either String LastRunState)
+readLastRunState tId = runExceptT $ do
+  contents <- convertIOException $
+    lastRunStateFilePath (unId $ toId tId) >>= BS.readFile
+  case A.eitherDecodeStrict' contents of
+    Right val -> return val
+    Left err -> throwE $ "Failed to parse lastRunState file: " ++ err
+
+-- | Checks if the given last run state is valid for the current server and user.
+isValidLastRunState :: ChatResources -> User -> LastRunState -> Bool
+isValidLastRunState cr me rs =
+     rs^.lrsHost   == cr^.crConn.cdHostnameL
+  && rs^.lrsPort   == cr^.crConn.cdPortL
+  && rs^.lrsUserId == me^.userIdL
diff --git a/src/Matterhorn/Login.hs b/src/Matterhorn/Login.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Login.hs
@@ -0,0 +1,594 @@
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE RankNTypes #-}
+-- | This module provides the login interface for Matterhorn.
+--
+-- * Overview
+--
+-- The interface provides a set of form fields for the user to use to
+-- enter their server information and credentials. The user enters
+-- this information and presses Enter, and then this module
+-- attempts to connect to the server. The module's main function,
+-- interactiveGetLoginSession, returns the result of that connection
+-- attempt, if any.
+--
+-- * Details
+--
+-- The interactiveGetLoginSession function takes the Matterhorn
+-- configuration's initial connection information as input. If the
+-- configuration provided a complete set of values needed to make a
+-- login attempt, this module goes ahead and immediately makes a login
+-- attempt before even showing the user the login form. This case is
+-- the case where the configuration provided all four values needed:
+-- server host name, port, username, and password. When the interface
+-- immediately makes a login attempt under these conditions, this is
+-- referred to as an "initial" attempt in various docstrings below.
+-- Otherwise, the user is prompted to fill out the form to enter any
+-- missing values. On pressing Enter, a login attempt is made.
+--
+-- A status message about whether a connection is underway is shown in
+-- both cases: in the case where the user has edited the credentials and
+-- pressed Enter, and in the case where the original credentials
+-- provided to interactiveGetLoginSession caused an initial connection
+-- attempt.
+--
+-- The "initial" login case is special because in addition to not
+-- showing the form, we want to ensure that the "connecting to..."
+-- status message that is shown is shown long enough for the user to
+-- see what is happening (rather than just flashing by in the case
+-- of a fast server connection). For this usability reason, we have
+-- a "startup timer" thread: the thread waits a specified number
+-- of milliseconds (see 'startupTimerMilliseconds' below) and then
+-- notifies the interface that it has timed out. If there is an initial
+-- connection attempt underway that succeeds *before* the timer
+-- fires, we wait until the timer fires before quitting the Login
+-- application and returning control to Matterhorn. This ensures that
+-- the "connecting to..." message stays on the screen long enough to not
+-- be jarring, and to show the user what is happening. If the connection
+-- fails before the timer fires, we just resume normal operation and
+-- show the login form so the user can intervene.
+module Matterhorn.Login
+  ( LoginAttempt(..)
+  , interactiveGetLoginSession
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.BChan
+import           Brick.Focus
+import           Brick.Forms
+import           Brick.Widgets.Border
+import           Brick.Widgets.Center
+import           Brick.Widgets.Edit
+import           Control.Concurrent ( forkIO, threadDelay )
+import           Control.Exception ( SomeException, catch, try )
+import           Data.Char (isHexDigit)
+import           Data.List (tails, inits)
+import           System.IO.Error ( catchIOError )
+import qualified Data.Text as T
+import           Graphics.Vty hiding (mkVty)
+import           Lens.Micro.Platform ( (.~), Lens', makeLenses )
+import qualified System.IO.Error as Err
+import           Network.URI ( URI(..), URIAuth(..), parseURI )
+
+import           Network.Mattermost ( ConnectionData )
+import           Network.Mattermost.Types.Internal ( Token(..) )
+import           Network.Mattermost.Types ( Session(..), User, Login(..), ConnectionPoolConfig(..)
+                                          , initConnectionData, ConnectionType(..), UserParam(..) )
+import           Network.Mattermost.Exceptions ( LoginFailureException(..) )
+import           Network.Mattermost.Endpoints ( mmGetUser, mmGetLimitedClientConfiguration, mmLogin )
+
+import           Matterhorn.Draw.RichText
+import           Matterhorn.Themes ( clientEmphAttr )
+import           Matterhorn.Types ( ConnectionInfo(..)
+                       , ciPassword, ciUsername, ciHostname, ciUrlPath
+                       , ciPort, ciType, AuthenticationException(..)
+                       , LogManager, LogCategory(..), ioLogWithManager
+                       , ciAccessToken
+                       )
+
+
+-- | Resource names for the login interface.
+data Name =
+      Server
+    | Username
+    | Password
+    | AccessToken
+    deriving (Ord, Eq, Show)
+
+-- | The result of an authentication attempt.
+data LoginAttempt =
+    AttemptFailed AuthenticationException
+    -- ^ The attempt failed with the corresponding error.
+    | AttemptSucceeded ConnectionInfo ConnectionData Session User (Maybe Text) --team
+    -- ^ The attempt succeeded.
+
+-- | The state of the login interface: whether a login attempt is
+-- currently in progress.
+data LoginState =
+    Idle
+    -- ^ No login attempt is in progress.
+    | Connecting Bool Text
+    -- ^ A login attempt to the specified host is in progress. The
+    -- boolean flag indicates whether this login was user initiated
+    -- (False) or triggered immediately when starting the interface
+    -- (True). This "initial" flag is used to determine whether the
+    -- login form is shown while the connection attempt is underway.
+    deriving (Eq)
+
+-- | Requests that we can make to the login worker thead.
+data LoginRequest =
+    DoLogin Bool ConnectionInfo
+    -- ^ Request a login using the specified connection information.
+    -- The boolean flag is the "initial" flag value corresponding to the
+    -- "Connecting" constructor flag of the "LoginState" type.
+
+-- | The messages that the login worker thread can send to the user
+-- interface event handler.
+data LoginEvent =
+    StartConnect Bool Text
+    -- ^ A connection to the specified host has begun. The boolean
+    -- value is whether this was an "initial" connection attempt (see
+    -- LoginState).
+    | LoginResult LoginAttempt
+    -- ^ A login attempt finished with the specified result.
+    | StartupTimeout
+    -- ^ The startup timer thread fired.
+
+-- | The login application state.
+data State =
+    State { _loginForm :: Form ConnectionInfo LoginEvent Name
+          , _lastAttempt :: Maybe LoginAttempt
+          , _currentState :: LoginState
+          , _reqChan :: BChan LoginRequest
+          , _timeoutFired :: Bool
+          }
+
+makeLenses ''State
+
+-- | The HTTP connection pool settings for the login worker thread.
+poolCfg :: ConnectionPoolConfig
+poolCfg = ConnectionPoolConfig { cpIdleConnTimeout = 60
+                               , cpStripesCount = 1
+                               , cpMaxConnCount = 5
+                               }
+
+-- | Run an IO action and convert various kinds of thrown exceptions
+-- into a returned AuthenticationException.
+convertLoginExceptions :: IO a -> IO (Either AuthenticationException a)
+convertLoginExceptions act =
+    (Right <$> act)
+        `catch` (\e -> return $ Left $ ResolveError e)
+        `catch` (\e -> return $ Left $ ConnectError e)
+        `catchIOError` (\e -> return $ Left $ AuthIOError e)
+        `catch` (\e -> return $ Left $ OtherAuthError e)
+
+-- | The login worker thread.
+loginWorker :: (ConnectionData -> ConnectionData)
+            -- ^ The function used to set the logger on the
+            -- ConnectionData that results from a successful login
+            -- attempt.
+            -> LogManager
+            -- ^ The log manager used to do logging.
+            -> BChan LoginRequest
+            -- ^ The channel on which we'll await requests.
+            -> BChan LoginEvent
+            -- ^ The channel to which we'll send login attempt results.
+            -> IO ()
+loginWorker setLogger logMgr requestChan respChan = forever $ do
+    req <- readBChan requestChan
+    case req of
+        DoLogin initial connInfo -> do
+            writeBChan respChan $ StartConnect initial $ connInfo^.ciHostname
+            let doLog = ioLogWithManager logMgr Nothing LogGeneral
+
+            doLog $ "Attempting authentication to " <> connInfo^.ciHostname
+
+            cdResult <- findConnectionData connInfo
+            case cdResult of
+              Left e ->
+                do writeBChan respChan $ LoginResult $ AttemptFailed $ OtherAuthError e
+              Right (cd_, mbTeam) -> do
+                  let cd = setLogger cd_
+                      token = connInfo^.ciAccessToken
+                  case T.null token of
+                      False -> do
+                          let sess = Session cd $ Token $ T.unpack token
+
+                          userResult <- try $ mmGetUser UserMe sess
+                          writeBChan respChan $ case userResult of
+                              Left (e::SomeException) ->
+                                  LoginResult $ AttemptFailed $ OtherAuthError e
+                              Right user ->
+                                  LoginResult $ AttemptSucceeded connInfo cd sess user mbTeam
+                      True -> do
+                          let login = Login { username = connInfo^.ciUsername
+                                            , password = connInfo^.ciPassword
+                                            }
+
+                          result <- convertLoginExceptions $ mmLogin cd login
+                          case result of
+                              Left e -> do
+                                  doLog $ "Error authenticating to " <> connInfo^.ciHostname <> ": " <> (T.pack $ show e)
+                                  writeBChan respChan $ LoginResult $ AttemptFailed e
+                              Right (Left e) -> do
+                                  doLog $ "Error authenticating to " <> connInfo^.ciHostname <> ": " <> (T.pack $ show e)
+                                  writeBChan respChan $ LoginResult $ AttemptFailed $ LoginError e
+                              Right (Right (sess, user)) -> do
+                                  doLog $ "Authenticated successfully to " <> connInfo^.ciHostname <> " as " <> connInfo^.ciUsername
+                                  writeBChan respChan $ LoginResult $ AttemptSucceeded connInfo cd sess user mbTeam
+
+
+
+-- | Searches prefixes of the given URL to determine Mattermost API URL
+-- path and team name
+findConnectionData :: ConnectionInfo -> IO (Either SomeException (ConnectionData, Maybe Text))
+findConnectionData connInfo = startSearch
+  where
+    components = filter (not . T.null) (T.split ('/'==) (connInfo^.ciUrlPath))
+
+    -- the candidates list is never empty because inits never returns an
+    -- empty list
+    primary:alternatives =
+        reverse
+        [ (T.intercalate "/" l, listToMaybe r)
+        | (l,r) <- zip (inits components) (tails components)
+        ]
+
+    tryCandidate :: (Text, Maybe Text)
+                 -> IO (Either SomeException (ConnectionData, Maybe Text))
+    tryCandidate (path, team) =
+       do cd  <- initConnectionData (connInfo^.ciHostname)
+                                    (fromIntegral (connInfo^.ciPort))
+                                    path (connInfo^.ciType) poolCfg
+          res <- try (mmGetLimitedClientConfiguration cd)
+          pure $! case res of
+                    Left e  -> Left e
+                    Right{} -> Right (cd, team)
+
+    -- This code prefers to report the error from the URL corresponding
+    -- to what the user actually provided. Errors from derived URLs are
+    -- lost in favor of this first error.
+    startSearch =
+      do res1 <- tryCandidate primary
+         case res1 of
+           Left e -> search e alternatives
+           Right cd -> pure (Right cd)
+
+    search e [] = pure (Left e)
+    search e (x:xs) =
+      do res <- tryCandidate x
+         case res of
+           Left {}  -> search e xs
+           Right cd -> pure (Right cd)
+
+
+-- | The amount of time that the startup timer thread will wait before
+-- firing.
+startupTimerMilliseconds :: Int
+startupTimerMilliseconds = 750
+
+-- | The startup timer thread.
+startupTimer :: BChan LoginEvent -> IO ()
+startupTimer respChan = do
+    threadDelay $ startupTimerMilliseconds * 1000
+    writeBChan respChan StartupTimeout
+
+-- | The main function of this module: interactively present a login
+-- interface, get the user's input, and attempt to log into the user's
+-- specified mattermost server.
+--
+-- This always returns the final terminal state handle. If the user
+-- makes no login attempt, this returns Nothing. Otherwise it returns
+-- Just the result of the latest attempt.
+interactiveGetLoginSession :: Vty
+                           -- ^ The initial terminal state handle to use.
+                           -> IO Vty
+                           -- ^ An action to build a new state handle
+                           -- if one is needed. (In practice this
+                           -- never fires since the login app doesn't
+                           -- use suspendAndResume, but we need it to
+                           -- satisfy the Brick API.)
+                           -> (ConnectionData -> ConnectionData)
+                           -- ^ The function to set the logger on
+                           -- connection handles.
+                           -> LogManager
+                           -- ^ The log manager used to do logging.
+                           -> ConnectionInfo
+                           -- ^ Initial connection info to use to
+                           -- populate the login form. If the connection
+                           -- info provided here is fully populated, an
+                           -- initial connection attempt is made without
+                           -- first getting the user to hit Enter.
+                           -> IO (Maybe LoginAttempt, Vty)
+interactiveGetLoginSession vty mkVty setLogger logMgr initialConfig = do
+    requestChan <- newBChan 10
+    respChan <- newBChan 10
+
+    void $ forkIO $ loginWorker setLogger logMgr requestChan respChan
+    void $ forkIO $ startupTimer respChan
+
+    let initialState = mkState initialConfig requestChan
+
+    startState <- case (populatedConnectionInfo initialConfig) of
+        True -> do
+            writeBChan requestChan $ DoLogin True initialConfig
+            return $ initialState & currentState .~ Connecting True (initialConfig^.ciHostname)
+        False -> do
+            return initialState
+
+    (finalSt, finalVty) <- customMainWithVty vty mkVty (Just respChan) app startState
+
+    return (finalSt^.lastAttempt, finalVty)
+
+-- | Is the specified ConnectionInfo sufficiently populated for us to
+-- bother attempting to use it to connect?
+populatedConnectionInfo :: ConnectionInfo -> Bool
+populatedConnectionInfo ci =
+    and [ not $ T.null $ ci^.ciHostname
+        , or [ and [ not $ T.null $ ci^.ciUsername
+                   , not $ T.null $ ci^.ciPassword
+                   ]
+             , not $ T.null $ ci^.ciAccessToken
+             ]
+        , ci^.ciPort > 0
+        ]
+
+-- | Make an initial login application state.
+mkState :: ConnectionInfo -> BChan LoginRequest -> State
+mkState cInfo chan = state
+    where
+        state = State { _loginForm = form { formFocus = focusSetCurrent initialFocus (formFocus form)
+                                          }
+                      , _currentState = Idle
+                      , _lastAttempt = Nothing
+                      , _reqChan = chan
+                      , _timeoutFired = False
+                      }
+        form = mkForm cInfo
+        initialFocus = if | T.null (cInfo^.ciHostname) -> Server
+                          | T.null (cInfo^.ciUsername) -> Username
+                          | T.null (cInfo^.ciPassword) -> Password
+                          | otherwise                  -> Server
+
+app :: App State LoginEvent Name
+app = App
+  { appDraw         = credsDraw
+  , appChooseCursor = showFirstCursor
+  , appHandleEvent  = onEvent
+  , appStartEvent   = return
+  , appAttrMap      = const colorTheme
+  }
+
+onEvent :: State -> BrickEvent Name LoginEvent -> EventM Name (Next State)
+onEvent st (VtyEvent (EvKey KEsc [])) = do
+    halt $ st & lastAttempt .~ Nothing
+onEvent st (AppEvent (StartConnect initial host)) = do
+    continue $ st & currentState .~ Connecting initial host
+                  & lastAttempt .~ Nothing
+onEvent st (AppEvent StartupTimeout) = do
+    -- If the startup timer fired and we have already succeeded, halt.
+    case st^.lastAttempt of
+        Just (AttemptSucceeded {}) -> halt st
+        _ -> continue $ st & timeoutFired .~ True
+onEvent st (AppEvent (LoginResult attempt)) = do
+    let st' = st & lastAttempt .~ Just attempt
+                 & currentState .~ Idle
+
+    case attempt of
+        AttemptSucceeded {} -> do
+            -- If the startup timer already fired, halt. Otherwise wait
+            -- until that timer fires.
+            case st^.timeoutFired of
+                True -> halt st'
+                False -> continue st'
+        AttemptFailed {} -> continue st'
+
+onEvent st (VtyEvent (EvKey KEnter [])) = do
+    -- Ignore the keypress if we are already attempting a connection, or
+    -- if have already successfully connected but are waiting on the
+    -- startup timer.
+    case st^.currentState of
+        Connecting {} -> return ()
+        Idle ->
+            case st^.lastAttempt of
+                Just (AttemptSucceeded {}) -> return ()
+                _ -> do
+                    let ci = formState $ st^.loginForm
+                    when (populatedConnectionInfo ci) $ do
+                        liftIO $ writeBChan (st^.reqChan) $ DoLogin False ci
+
+    continue st
+onEvent st e = do
+    f' <- handleFormEvent e (st^.loginForm)
+    continue $ st & loginForm .~ f'
+
+mkForm :: ConnectionInfo -> Form ConnectionInfo e Name
+mkForm =
+    let label s w = padBottom (Pad 1) $
+                    (vLimit 1 $ hLimit 22 $ str s <+> fill ' ') <+> w
+        above s w = hCenter (str s) <=> w
+    in newForm [ label "Server URL:"     @@= editServer
+               , (above "Provide a username and password:" .
+                  label "Username:")     @@= editTextField ciUsername Username (Just 1)
+               , label "Password:"       @@= editPasswordField ciPassword Password
+               , (above "Or provide a Session or Personal Access Token:" .
+                  label "Access Token:") @@= editPasswordField ciAccessToken AccessToken
+               ]
+
+serverLens :: Lens' ConnectionInfo (Text, Int, Text, ConnectionType)
+serverLens f ci = fmap (\(x,y,z,w) -> ci { _ciHostname = x, _ciPort = y, _ciUrlPath = z, _ciType = w})
+                       (f (ci^.ciHostname, ci^.ciPort, ci^.ciUrlPath, ci^.ciType))
+
+-- | Validate a server URI @hostname[:port][/path]@. Result is either an error message
+-- indicating why validation failed or a tuple of (hostname, port, path)
+validServer :: [Text] -> Either String (Text, Int, Text, ConnectionType)
+validServer ts =
+
+  do t <- case ts of
+            []  -> Left "No input"
+            [t] -> Right t
+            _   -> Left "Too many lines"
+
+     let inputWithScheme
+           | "://" `T.isInfixOf` t = t
+           | otherwise = "https://" <> t
+
+     uri <- case parseURI (T.unpack inputWithScheme) of
+              Nothing  -> Left "Unable to parse URI"
+              Just uri -> Right uri
+
+     auth <- case uriAuthority uri of
+               Nothing   -> Left "Missing authority part"
+               Just auth -> Right auth
+
+     ty <- case uriScheme uri of
+             "http:"           -> Right ConnectHTTP
+             "https:"          -> Right (ConnectHTTPS True)
+             "https-insecure:" -> Right (ConnectHTTPS False)
+             _                 -> Left "Unknown scheme"
+
+     port <- case (ty, uriPort auth) of
+               (ConnectHTTP   , "") -> Right 80
+               (ConnectHTTPS{}, "") -> Right 443
+               (_, ':':portStr)
+                 | Just port <- readMaybe portStr -> Right port
+               _ -> Left "Invalid port"
+
+     let host
+           | not (null (uriRegName auth))
+           , '[' == head (uriRegName auth)
+           , ']' == last (uriRegName auth)
+           = init (tail (uriRegName auth))
+           | otherwise = uriRegName auth
+
+     if null (uriRegName auth) then Left "Missing server name" else Right ()
+     if null (uriQuery uri) then Right () else Left "Unexpected URI query"
+     if null (uriFragment uri) then Right () else Left "Unexpected URI fragment"
+     if null (uriUserInfo auth) then Right () else Left "Unexpected credentials"
+
+     Right (T.pack host, port, T.pack (uriPath uri), ty)
+
+
+renderServer :: (Text, Int, Text, ConnectionType) -> Text
+renderServer (h,p,u,t) = scheme <> hStr <> portStr <> uStr
+  where
+    hStr
+      | T.all (\x -> isHexDigit x || ':'==x) h
+      , T.any (':'==) h = "[" <> h <> "]"
+      | otherwise = h
+
+    scheme =
+      case t of
+        ConnectHTTP        -> "http://"
+        ConnectHTTPS True  -> ""
+        ConnectHTTPS False -> "https-insecure://"
+
+    uStr
+      | T.null u = u
+      | otherwise = T.cons '/' (T.dropWhile ('/'==) u)
+
+    portStr =
+      case t of
+        ConnectHTTP    | p ==  80 -> T.empty
+        ConnectHTTPS{} | p == 443 -> T.empty
+        _                         -> T.pack (':':show p)
+
+editServer :: ConnectionInfo -> FormFieldState ConnectionInfo e Name
+editServer =
+    let val ts = case validServer ts of
+                   Left{} -> Nothing
+                   Right x-> Just x
+        limit = Just 1
+        renderTxt [""] = str "(Paste your Mattermost URL here)"
+        renderTxt ts = txt (T.unlines ts)
+    in editField serverLens Server limit renderServer val renderTxt id
+
+errorAttr :: AttrName
+errorAttr = "errorMessage"
+
+colorTheme :: AttrMap
+colorTheme = attrMap defAttr
+  [ (editAttr, black `on` white)
+  , (editFocusedAttr, black `on` yellow)
+  , (errorAttr, fg red)
+  , (focusedFormInputAttr, black `on` yellow)
+  , (invalidFormInputAttr, white `on` red)
+  , (clientEmphAttr, fg white `withStyle` bold)
+  ]
+
+credsDraw :: State -> [Widget Name]
+credsDraw st =
+    [ center $ vBox [ if shouldShowForm st then credentialsForm st else emptyWidget
+                    , currentStateDisplay st
+                    , lastAttemptDisplay (st^.lastAttempt)
+                    ]
+    ]
+
+-- | Whether the login form should be displayed.
+shouldShowForm :: State -> Bool
+shouldShowForm st =
+    case st^.currentState of
+        -- If we're connecting, only show the form if the connection
+        -- attempt is not an initial one.
+        Connecting initial _ -> not initial
+
+        -- If we're idle, we want to show the form - unless we have
+        -- already connected and are waiting for the startup timer to
+        -- fire.
+        Idle -> case st^.lastAttempt of
+            Just (AttemptSucceeded {}) -> st^.timeoutFired
+            _ -> True
+
+-- | The "current state" of the login process. Show a connecting status
+-- message if a connection is underway, or if one is already established
+-- and the startup timer has not fired.
+currentStateDisplay :: State -> Widget Name
+currentStateDisplay st =
+    let msg host = padTop (Pad 1) $ hCenter $
+                   txt "Connecting to " <+> withDefAttr clientEmphAttr (txt host) <+> txt "..."
+    in case st^.currentState of
+          Idle -> case st^.lastAttempt of
+              Just (AttemptSucceeded ci _ _ _ _) -> msg (ci^.ciHostname)
+              _ -> emptyWidget
+          (Connecting _ host) -> msg host
+
+lastAttemptDisplay :: Maybe LoginAttempt -> Widget Name
+lastAttemptDisplay Nothing = emptyWidget
+lastAttemptDisplay (Just (AttemptSucceeded {})) = emptyWidget
+lastAttemptDisplay (Just (AttemptFailed e)) =
+    hCenter $ hLimit uiWidth $
+    padTop (Pad 1) $ renderError $ renderText $
+    "Error: " <> renderAuthError e
+
+renderAuthError :: AuthenticationException -> Text
+renderAuthError (ConnectError _) =
+    "Could not connect to server"
+renderAuthError (ResolveError _) =
+    "Could not resolve server hostname"
+renderAuthError (AuthIOError err)
+  | Err.isDoesNotExistErrorType (Err.ioeGetErrorType err) =
+    "Unable to connect to the network"
+  | otherwise = "GetAddrInfo: " <> T.pack (Err.ioeGetErrorString err)
+renderAuthError (OtherAuthError e) =
+    T.pack $ show e
+renderAuthError (LoginError (LoginFailureException msg)) =
+    T.pack msg
+
+renderError :: Widget a -> Widget a
+renderError = withDefAttr errorAttr
+
+uiWidth :: Int
+uiWidth = 60
+
+credentialsForm :: State -> Widget Name
+credentialsForm st =
+    hCenter $ hLimit uiWidth $ vLimit 15 $
+    border $
+    vBox [ renderText "Please enter your Mattermost credentials to log in."
+         , padTop (Pad 1) $ renderForm (st^.loginForm)
+         , hCenter $ renderText "Press Enter to log in or Esc to exit."
+         ]
diff --git a/src/Matterhorn/Options.hs b/src/Matterhorn/Options.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Options.hs
@@ -0,0 +1,157 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Matterhorn.Options where
+
+import Prelude ()
+import Matterhorn.Prelude
+
+import Data.Char ( toLower )
+import Data.Foldable (traverse_)
+import Data.Tuple ( swap )
+import Data.Version ( showVersion )
+import Development.GitRev
+import Network.Mattermost.Version ( mmApiVersion )
+import Paths_matterhorn ( version )
+import System.Console.GetOpt
+import System.Environment ( getArgs )
+import System.Exit ( exitFailure, exitSuccess )
+import System.IO ( hPutStrLn, stderr )
+
+import Matterhorn.Config
+
+
+data Behaviour
+  = Normal
+  | ShowVersion
+  | ShowHelp
+  | CheckConfig
+    deriving (Eq, Show)
+
+data PrintFormat =
+    Markdown | Plain deriving (Eq, Show)
+
+data Options = Options
+  { optConfLocation     :: Maybe FilePath
+  , optLogLocation      :: Maybe FilePath
+  , optBehaviour        :: Behaviour
+  , optIgnoreConfig     :: Bool
+  , optPrintKeybindings :: Bool
+  , optPrintCommands    :: Bool
+  , optPrintFormat      :: PrintFormat
+  } deriving (Eq, Show)
+
+defaultOptions :: Options
+defaultOptions = Options
+  { optConfLocation     = Nothing
+  , optLogLocation      = Nothing
+  , optBehaviour        = Normal
+  , optIgnoreConfig     = False
+  , optPrintKeybindings = False
+  , optPrintCommands    = False
+  , optPrintFormat      = Plain
+  }
+
+optDescrs :: [OptDescr (Options -> Options)]
+optDescrs =
+  [ Option ['c'] ["config"]
+    (ReqArg (\ path c -> c { optConfLocation = Just path }) "PATH")
+    "Path to the configuration file"
+  , Option ['l'] ["logs"]
+    (ReqArg (\ path c -> c { optLogLocation = Just path }) "PATH")
+    "Debug log output path"
+  , Option ['v'] ["version"]
+    (NoArg (\ c -> c { optBehaviour = ShowVersion }))
+    "Print version information and exit"
+  , Option ['h'] ["help"]
+    (NoArg (\ c -> c { optBehaviour = ShowHelp }))
+    "Print help for command-line flags and exit"
+  , Option ['i'] ["ignore-config"]
+    (NoArg (\ c -> c { optIgnoreConfig = True }))
+    "Start with no configuration"
+  , Option ['k'] ["keybindings"]
+    (NoArg (\ c -> c { optPrintKeybindings = True }))
+    "Print keybindings effective for the current configuration"
+  , Option ['m'] ["commands"]
+    (NoArg (\ c -> c { optPrintCommands = True }))
+    "Print available commands"
+  , Option ['f'] ["format"]
+    (ReqArg handleFormat "FORMAT")
+    ("Print keybinding or command output in the specified format " <>
+     "(options: " <> formatChoicesStr <> ", default: " <>
+     formatStringFor (optPrintFormat defaultOptions) <> ")")
+  , Option [] ["check-config"]
+    (NoArg (\ c -> c { optBehaviour = CheckConfig }))
+    "Validate configuration file"
+  ]
+
+formatChoices :: [(String, PrintFormat)]
+formatChoices =
+    [ ("plain", Plain)
+    , ("markdown", Markdown)
+    ]
+
+formatStringFor :: PrintFormat -> String
+formatStringFor fmt =
+    case lookup fmt (swap <$> formatChoices) of
+        Nothing -> error $ "BUG: no format string for " <> show fmt
+        Just s -> s
+
+formatChoicesStr :: String
+formatChoicesStr = intercalate ", " $ fst <$> formatChoices
+
+handleFormat :: String -> Options -> Options
+handleFormat fmtStr c =
+    let fmt = case lookup (toLower <$> fmtStr) formatChoices of
+            Just f -> f
+            Nothing ->
+                error $ "Invalid format: " <> show fmtStr <> ", choices: " <>
+                        formatChoicesStr
+    in c { optPrintFormat = fmt }
+
+mhVersion :: String
+mhVersion
+  | $(gitHash) == ("UNKNOWN" :: String) = "matterhorn " ++ showVersion version
+  | otherwise = "matterhorn " ++ showVersion version ++ " (" ++
+                $(gitBranch) ++ "@" ++ take 7 $(gitHash) ++ ")"
+
+fullVersionString :: String
+fullVersionString = mhVersion ++ "\n using " ++ mmApiVersion
+
+usage :: IO ()
+usage = putStr (usageInfo "matterhorn" optDescrs)
+
+grabOptions :: IO Options
+grabOptions = do
+  args <- getArgs
+  case getOpt Permute optDescrs args of
+    (aps, [], []) -> do
+      let rs = foldr (.) id aps defaultOptions
+      case optBehaviour rs of
+        Normal -> return rs
+        ShowHelp -> usage >> exitSuccess
+        ShowVersion -> putStrLn fullVersionString >> exitSuccess
+        CheckConfig -> checkConfiguration (optConfLocation rs)
+    (_, _, errs) -> do
+      mapM_ putStr errs
+      usage
+      exitFailure
+
+checkConfiguration :: Maybe FilePath -> IO a
+checkConfiguration mb =
+  do res <- findConfig mb
+     let writeLn = hPutStrLn stderr
+         printLocation Nothing = "No configuration file"
+         printLocation (Just fp) = "Location: " ++ fp
+     case res of
+       Left e ->
+         do writeLn e
+            exitFailure
+       Right ([], config) ->
+         do writeLn "Configuration file valid"
+            writeLn (printLocation (configAbsPath config))
+            exitSuccess
+       Right (ws, config) ->
+         do writeLn "Configuration file generated warnings"
+            writeLn (printLocation (configAbsPath config))
+            traverse_ writeLn ws
+            exitFailure
diff --git a/src/Matterhorn/Prelude.hs b/src/Matterhorn/Prelude.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Prelude.hs
@@ -0,0 +1,115 @@
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# LANGUAGE CPP #-}
+
+{-| This module is for internal re-exports of commonly-used
+  functions. This also lets us avoid churn between versions of GHC by
+  putting changed functions behind CPP in a single place.
+-}
+
+{-
+  GHC version <--> base version (https://wiki.haskell.org/Base_package) includes:
+     8.0.1      4.9.0.0
+     8.2.1      4.10.0.0
+     8.4.1      4.11.0.0
+
+  GHC distributions include a set of core packages; overriding these
+  with new versions is unwise.  Core packages include (see
+  https://downloads.haskell.org/~ghc/X.Y.Z/docs/html/users_guide/X.Y.Z-notes.html#included-libraries
+  ):
+
+    base, Cabal, array, bytestring, containers, deepseq, directory,
+    filepath, mtl, parsec, process, text, time, unix
+-}
+
+module Matterhorn.Prelude
+  ( module P
+#if !MIN_VERSION_base(4,9,0)
+  , (<>)
+#endif
+  , (<|>)
+  -- commonly-used functions from Maybe
+  , Maybe.isJust
+  , Maybe.isNothing
+  , Maybe.listToMaybe
+  , Maybe.maybeToList
+  , Maybe.fromMaybe
+  , Maybe.catMaybes
+
+  -- a non-partial Read function
+  , Read.readMaybe
+
+  -- commonly-used functions from Monad
+  , Monad.forM
+  , Monad.forM_
+  , Monad.filterM
+  , Monad.when
+  , Monad.unless
+  , Monad.void
+  , Monad.join
+  , Monad.forever
+  , Monad.foldM
+  , Monad.MonadIO(..)
+
+  -- commonly-used functions from List
+  , Foldable.toList
+  , List.find
+  , List.sort
+  , List.intercalate
+  , Exts.sortWith
+  , Exts.groupWith
+
+  -- common read-only lens operators
+  , (Lens.&)
+  , (Lens.^.)
+  , Lens.use
+
+  -- not available in all versions of GHC currently in use
+#if MIN_VERSION_base(4,10,0)
+  , Clock.nominalDay
+#else
+  , nominalDay
+#endif
+
+  -- various type aliases
+  , Text
+  , HashMap
+  , Seq
+  , Set
+  , Time.UTCTime
+  , Time.TimeZoneSeries
+  , Time.NominalDiffTime
+  )
+where
+
+
+#if !MIN_VERSION_base(4,11,0)
+import           Data.Semigroup ( (<>) )
+#endif
+
+import           Control.Applicative ( (<|>) )
+import qualified Control.Monad as Monad
+import qualified Control.Monad.IO.Class as Monad
+import qualified Data.Foldable as Foldable
+import qualified Data.List as List
+import qualified Data.Maybe as Maybe
+import qualified Data.Time as Time
+#if MIN_VERSION_base(4,10,0)
+import qualified Data.Time.Clock as Clock
+#endif
+import qualified Data.Time.LocalTime.TimeZone.Series as Time
+import qualified GHC.Exts as Exts
+import qualified Lens.Micro.Platform as Lens
+import           Prelude.Compat
+import qualified Prelude.Compat as P
+import qualified Text.Read as Read
+
+-- these below we import only for type aliases
+import           Data.HashMap.Strict ( HashMap )
+import           Data.Sequence ( Seq )
+import           Data.Set ( Set )
+import           Data.Text ( Text )
+
+#if !MIN_VERSION_base(4,10,0)
+nominalDay :: Time.NominalDiffTime
+nominalDay = 86400
+#endif
diff --git a/src/Matterhorn/Scripts.hs b/src/Matterhorn/Scripts.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Scripts.hs
@@ -0,0 +1,81 @@
+module Matterhorn.Scripts
+  ( findAndRunScript
+  , listScripts
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Control.Concurrent ( takeMVar, newEmptyMVar )
+import qualified Control.Concurrent.STM as STM
+import qualified Data.Text as T
+import           System.Exit ( ExitCode(..) )
+
+import           Network.Mattermost.Types ( ChannelId )
+
+import           Matterhorn.FilePaths ( Script(..), getAllScripts, locateScriptPath )
+import           Matterhorn.State.Common
+import           Matterhorn.State.Messages ( sendMessage )
+import           Matterhorn.Types
+
+
+findAndRunScript :: ChannelId -> Text -> Text -> MH ()
+findAndRunScript cId scriptName input = do
+    fpMb <- liftIO $ locateScriptPath (T.unpack scriptName)
+    outputChan <- use (csResources.crSubprocessLog)
+    case fpMb of
+      ScriptPath scriptPath -> do
+        doAsyncWith Preempt $ runScript cId outputChan scriptPath input
+      NonexecScriptPath scriptPath -> do
+        let msg = ("The script `" <> T.pack scriptPath <> "` cannot be " <>
+             "executed. Try running\n" <>
+             "```\n" <>
+             "$ chmod u+x " <> T.pack scriptPath <> "\n" <>
+             "```\n" <>
+             "to correct this error. " <> scriptHelpAddendum)
+        mhError $ GenericError msg
+      ScriptNotFound -> do
+        mhError $ NoSuchScript scriptName
+
+runScript :: ChannelId -> STM.TChan ProgramOutput -> FilePath -> Text -> IO (Maybe (MH ()))
+runScript cId outputChan fp text = do
+  outputVar <- newEmptyMVar
+  runLoggedCommand outputChan fp [] (Just $ T.unpack text) (Just outputVar)
+  po <- takeMVar outputVar
+  return $ case programExitCode po of
+    ExitSuccess -> do
+        case null $ programStderr po of
+            True -> Just $ do
+                mode <- use (csEditState.cedEditMode)
+                sendMessage cId mode (T.pack $ programStdout po) []
+            False -> Nothing
+    ExitFailure _ -> Nothing
+
+listScripts :: MH ()
+listScripts = do
+  (execs, nonexecs) <- liftIO getAllScripts
+  let scripts = ("Available scripts are:\n" <>
+                 mconcat [ "  - " <> T.pack cmd <> "\n"
+                         | cmd <- execs
+                         ])
+  postInfoMessage scripts
+  case nonexecs of
+    [] -> return ()
+    _  -> do
+      let errMsg = ("Some non-executable script files are also " <>
+                    "present. If you want to run these as scripts " <>
+                    "in Matterhorn, mark them executable with \n" <>
+                    "```\n" <>
+                    "$ chmod u+x [script path]\n" <>
+                    "```\n" <>
+                    "\n" <>
+                    mconcat [ "  - " <> T.pack cmd <> "\n"
+                            | cmd <- nonexecs
+                            ] <> "\n" <> scriptHelpAddendum)
+      mhError $ GenericError errMsg
+
+scriptHelpAddendum :: Text
+scriptHelpAddendum =
+  "For more help with scripts, run the command\n" <>
+  "```\n/help scripts\n```\n"
diff --git a/src/Matterhorn/State/Async.hs b/src/Matterhorn/State/Async.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Async.hs
@@ -0,0 +1,161 @@
+module Matterhorn.State.Async
+  ( AsyncPriority(..)
+  , doAsync
+  , doAsyncIO
+  , doAsyncWith
+  , doAsyncChannelMM
+  , doAsyncWithIO
+  , doAsyncMM
+  , tryMM
+  , endAsyncNOP
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Control.Concurrent.STM as STM
+import           Control.Exception ( try )
+
+import           Network.Mattermost.Types
+
+import           Matterhorn.Types
+
+
+-- | Try to run a computation, posting an informative error
+--   message if it fails with a 'MattermostServerError'.
+tryMM :: IO a
+      -- ^ The action to try (usually a MM API call)
+      -> (a -> IO (Maybe (MH ())))
+      -- ^ What to do on success
+      -> IO (Maybe (MH ()))
+tryMM act onSuccess = do
+    result <- liftIO $ try act
+    case result of
+        Left e -> return $ Just $ mhError $ ServerError e
+        Right value -> liftIO $ onSuccess value
+
+-- * Background Computation
+
+-- $background_computation
+--
+-- The main context for Matterhorn is the EventM context provided by
+-- the 'Brick' library.  This context is normally waiting for user
+-- input (or terminal resizing, etc.) which gets turned into an
+-- MHEvent and the 'onEvent' event handler is called to process that
+-- event, after which the display is redrawn as necessary and brick
+-- awaits the next input.
+--
+-- However, it is often convenient to communicate with the Mattermost
+-- server in the background, so that large numbers of
+-- synchronously-blocking events (e.g. on startup) or refreshes can
+-- occur whenever needed and without negatively impacting the UI
+-- updates or responsiveness.  This is handled by a 'forkIO' context
+-- that waits on an STM channel for work to do, performs the work, and
+-- then sends brick an MHEvent containing the completion or failure
+-- information for that work.
+--
+-- The /doAsyncWith/ family of functions here facilitates that
+-- asynchronous functionality.  This is typically used in the
+-- following fashion:
+--
+-- > doSomething :: MH ()
+-- > doSomething = do
+-- >    got <- something
+-- >    doAsyncWith Normal $ do
+-- >       r <- mmFetchR ....
+-- >       return $ do
+-- >          csSomething.here %= processed r
+--
+-- The second argument is an IO monad operation (because 'forkIO' runs
+-- in the IO Monad context), but it returns an MH monad operation.
+-- The IO monad has access to the closure of 'doSomething' (e.g. the
+-- 'got' value), but it should be aware that the state of the MH monad
+-- may have been changed by the time the IO monad runs in the
+-- background, so the closure is a snapshot of information at the time
+-- the 'doAsyncWith' was called.
+--
+-- Similarly, the returned MH monad operation is *not* run in the
+-- context of the 'forkIO' background, but it is instead passed via an
+-- MHEvent back to the main brick thread, where it is executed in an
+-- EventM handler's MH monad context.  This operation therefore has
+-- access to the combined closure of the pre- 'doAsyncWith' code and
+-- the closure of the IO operation.  It is important that the final MH
+-- monad operation should *re-obtain* state information from the MH
+-- monad instead of using or setting the state obtained prior to the
+-- 'doAsyncWith' call.
+
+-- | Priority setting for asynchronous work items. Preempt means that
+-- the queued item will be the next work item begun (i.e. it goes to the
+-- front of the queue); normal means it will go last in the queue.
+data AsyncPriority = Preempt | Normal
+
+-- | Run a computation in the background, ignoring any results from it.
+doAsync :: AsyncPriority -> IO () -> MH ()
+doAsync prio act = doAsyncWith prio (act >> return Nothing)
+
+-- | Run a computation in the background, returning a computation to be
+-- called on the 'ChatState' value.
+doAsyncWith :: AsyncPriority -> IO (Maybe (MH ())) -> MH ()
+doAsyncWith prio act = do
+    let putChan = case prio of
+          Preempt -> STM.unGetTChan
+          Normal  -> STM.writeTChan
+    queue <- use (csResources.crRequestQueue)
+    liftIO $ STM.atomically $ putChan queue act
+
+doAsyncIO :: AsyncPriority -> ChatState -> IO () -> IO ()
+doAsyncIO prio st act =
+  doAsyncWithIO prio st (act >> return Nothing)
+
+-- | Run a computation in the background, returning a computation to be
+-- called on the 'ChatState' value.
+doAsyncWithIO :: AsyncPriority -> ChatState -> IO (Maybe (MH ())) -> IO ()
+doAsyncWithIO prio st act = do
+    let putChan = case prio of
+          Preempt -> STM.unGetTChan
+          Normal  -> STM.writeTChan
+    let queue = st^.csResources.crRequestQueue
+    STM.atomically $ putChan queue act
+
+-- | Performs an asynchronous IO operation. On completion, the final
+-- argument a completion function is executed in an MH () context in the
+-- main (brick) thread.
+doAsyncMM :: AsyncPriority
+          -- ^ the priority for this async operation
+          -> (Session -> IO a)
+          -- ^ the async MM channel-based IO operation
+          -> (a -> Maybe (MH ()))
+          -- ^ function to process the results in brick event handling
+          -- context
+          -> MH ()
+doAsyncMM prio mmOp eventHandler = do
+  session <- getSession
+  doAsyncWith prio $ do
+    r <- mmOp session
+    return $ eventHandler r
+
+-- | Helper type for a function to perform an asynchronous MM operation
+-- on a channel and then invoke an MH completion event.
+type DoAsyncChannelMM a =
+    AsyncPriority
+    -- ^ the priority for this async operation
+    -> ChannelId
+    -- ^ The channel
+    -> (Session -> ChannelId -> IO a)
+    -- ^ the asynchronous Mattermost channel-based IO operation
+    -> (ChannelId -> a -> Maybe (MH ()))
+    -- ^ function to process the results in brick event handling context
+    -> MH ()
+
+-- | Performs an asynchronous IO operation on a specific channel. On
+-- completion, the final argument a completion function is executed in
+-- an MH () context in the main (brick) thread.
+doAsyncChannelMM :: DoAsyncChannelMM a
+doAsyncChannelMM prio cId mmOp eventHandler =
+  doAsyncMM prio (\s -> mmOp s cId) (eventHandler cId)
+
+-- | Use this convenience function if no operation needs to be
+-- performed in the MH state after an async operation completes.
+endAsyncNOP :: ChannelId -> a -> Maybe (MH ())
+endAsyncNOP _ _ = Nothing
diff --git a/src/Matterhorn/State/Attachments.hs b/src/Matterhorn/State/Attachments.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Attachments.hs
@@ -0,0 +1,53 @@
+module Matterhorn.State.Attachments
+  ( showAttachmentList
+  , resetAttachmentList
+  , showAttachmentFileBrowser
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+import qualified Control.Exception as E
+import           Data.Either ( isRight )
+import           System.Directory ( doesDirectoryExist, getDirectoryContents )
+import           Data.Bool ( bool )
+
+import           Brick ( vScrollToBeginning, viewportScroll )
+import qualified Brick.Widgets.List as L
+import qualified Brick.Widgets.FileBrowser as FB
+import           Lens.Micro.Platform ( (.=) )
+
+import           Matterhorn.Types
+
+validateAttachmentPath :: FilePath -> IO (Maybe FilePath)
+validateAttachmentPath path = bool Nothing (Just path) <$> do
+    ex <- doesDirectoryExist path
+    case ex of
+        False -> return False
+        True -> do
+            result :: Either E.SomeException [FilePath]
+                   <- E.try $ getDirectoryContents path
+            return $ isRight result
+
+defaultAttachmentsPath :: Config -> IO (Maybe FilePath)
+defaultAttachmentsPath = maybe (return Nothing) validateAttachmentPath . configDefaultAttachmentPath
+
+showAttachmentList :: MH ()
+showAttachmentList = do
+    lst <- use (csEditState.cedAttachmentList)
+    case length (L.listElements lst) of
+        0 -> showAttachmentFileBrowser
+        _ -> setMode ManageAttachments
+
+resetAttachmentList :: MH ()
+resetAttachmentList = do
+    csEditState.cedAttachmentList .= L.list AttachmentList mempty 1
+    mh $ vScrollToBeginning $ viewportScroll AttachmentList
+
+showAttachmentFileBrowser :: MH ()
+showAttachmentFileBrowser = do
+    config <- use (csResources.crConfiguration)
+    filePath <- liftIO $ defaultAttachmentsPath config
+    browser <- liftIO $ Just <$> FB.newFileBrowser FB.selectNonDirectories AttachmentFileBrowser filePath
+    csEditState.cedFileBrowser .= browser
+    setMode ManageAttachmentsBrowseFiles
diff --git a/src/Matterhorn/State/Autocomplete.hs b/src/Matterhorn/State/Autocomplete.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Autocomplete.hs
@@ -0,0 +1,373 @@
+module Matterhorn.State.Autocomplete
+  ( AutocompleteContext(..)
+  , checkForAutocompletion
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Main ( viewportScroll, vScrollToBeginning )
+import           Brick.Widgets.Edit ( editContentsL )
+import qualified Brick.Widgets.List as L
+import           Data.Char ( isSpace )
+import qualified Data.Foldable as F
+import qualified Data.HashMap.Strict as HM
+import           Data.List ( sortBy, partition )
+import qualified Data.Map as M
+import qualified Data.Sequence as Seq
+import qualified Data.Text as T
+import qualified Data.Text.Zipper as Z
+import qualified Data.Vector as V
+import           Lens.Micro.Platform ( (%=), (.=), (.~), _Just, preuse )
+import qualified Skylighting.Types as Sky
+
+import           Network.Mattermost.Types (userId, channelId, Command(..))
+import qualified Network.Mattermost.Endpoints as MM
+
+import           Matterhorn.Constants ( userSigil, normalChannelSigil )
+import {-# SOURCE #-} Matterhorn.Command ( commandList, printArgSpec )
+import           Matterhorn.State.Common
+import {-# SOURCE #-} Matterhorn.State.Editing ( Direction(..), tabComplete )
+import           Matterhorn.Types hiding ( newState )
+import           Matterhorn.Emoji
+
+
+data AutocompleteContext =
+    AutocompleteContext { autocompleteManual :: Bool
+                        -- ^ Whether the autocompletion was manual
+                        -- (True) or automatic (False). The automatic
+                        -- case is the case where the autocomplete
+                        -- lookups and UI are triggered merely by
+                        -- entering some initial text (such as "@").
+                        -- The manual case is the case where the
+                        -- autocomplete lookups and UI are triggered
+                        -- explicitly by a user's TAB keypress.
+                        , autocompleteFirstMatch :: Bool
+                        -- ^ Once the results of the autocomplete lookup
+                        -- are available, this flag determines whether
+                        -- the user's input is replaced immediately
+                        -- with the first available match (True) or not
+                        -- (False).
+                        }
+
+-- | Check for whether the currently-edited word in the message editor
+-- should cause an autocompletion UI to appear. If so, initiate a server
+-- query or local cache lookup to present the completion alternatives
+-- for the word at the cursor.
+checkForAutocompletion :: AutocompleteContext -> MH ()
+checkForAutocompletion ctx = do
+    result <- getCompleterForInput ctx
+    case result of
+        Nothing -> resetAutocomplete
+        Just (ty, runUpdater, searchString) -> do
+            prevResult <- use (csEditState.cedAutocomplete)
+            -- We should update the completion state if EITHER:
+            --
+            -- 1) The type changed
+            --
+            -- or
+            --
+            -- 2) The search string changed but the type did NOT change
+            let shouldUpdate = ((maybe True ((/= searchString) . _acPreviousSearchString)
+                                 prevResult) &&
+                                (maybe True ((== ty) . _acType) prevResult)) ||
+                               (maybe False ((/= ty) . _acType) prevResult)
+            when shouldUpdate $ do
+                csEditState.cedAutocompletePending .= Just searchString
+                runUpdater ty ctx searchString
+
+getCompleterForInput :: AutocompleteContext
+                     -> MH (Maybe (AutocompletionType, AutocompletionType -> AutocompleteContext -> Text -> MH (), Text))
+getCompleterForInput ctx = do
+    z <- use (csEditState.cedEditor.editContentsL)
+
+    let col = snd $ Z.cursorPosition z
+        curLine = Z.currentLine z
+
+    return $ case wordAtColumn col curLine of
+        Just (startCol, w)
+            | userSigil `T.isPrefixOf` w ->
+                Just (ACUsers, doUserAutoCompletion, T.tail w)
+            | normalChannelSigil `T.isPrefixOf` w ->
+                Just (ACChannels, doChannelAutoCompletion, T.tail w)
+            | ":" `T.isPrefixOf` w && autocompleteManual ctx ->
+                Just (ACEmoji, doEmojiAutoCompletion, T.tail w)
+            | "```" `T.isPrefixOf` w ->
+                Just (ACCodeBlockLanguage, doSyntaxAutoCompletion, T.drop 3 w)
+            | "/" `T.isPrefixOf` w && startCol == 0 ->
+                Just (ACCommands, doCommandAutoCompletion, T.tail w)
+        _ -> Nothing
+
+-- Completion implementations
+
+doEmojiAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()
+doEmojiAutoCompletion ty ctx searchString = do
+    session <- getSession
+    em <- use (csResources.crEmoji)
+    withCachedAutocompleteResults ctx ty searchString $
+        doAsyncWith Preempt $ do
+            results <- getMatchingEmoji session em searchString
+            let alts = EmojiCompletion <$> results
+            return $ Just $ setCompletionAlternatives ctx searchString alts ty
+
+doSyntaxAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()
+doSyntaxAutoCompletion ty ctx searchString = do
+    mapping <- use (csResources.crSyntaxMap)
+    let allNames = Sky.sShortname <$> M.elems mapping
+        (prefixed, notPrefixed) = partition isPrefixed $ filter match allNames
+        match = (((T.toLower searchString) `T.isInfixOf`) . T.toLower)
+        isPrefixed = (((T.toLower searchString) `T.isPrefixOf`) . T.toLower)
+        alts = SyntaxCompletion <$> (sort prefixed <> sort notPrefixed)
+    setCompletionAlternatives ctx searchString alts ty
+
+-- | This list of server commands should be hidden because they make
+-- assumptions about a web-based client or otherwise just don't make
+-- sense for Matterhorn.
+--
+-- It's worth mentioning that other official mattermost client
+-- implementations use this technique, too. The web client maintains
+-- a list of commands to exclude when they aren't supported in the
+-- mobile client. (Really this is a design flaw; they should never be
+-- advertised by the server to begin with.)
+hiddenServerCommands :: [Text]
+hiddenServerCommands =
+    -- These commands all only work in the web client.
+    [ "settings"
+    , "help"
+    , "collapse"
+    , "expand"
+
+    -- We don't think this command makes sense for Matterhorn.
+    , "logout"
+
+    -- We provide a version of /leave with confirmation.
+    , "leave"
+
+    -- We provide our own join UI.
+    , "join"
+
+    -- We provide our own version of this command that opens our own
+    -- help UI.
+    , "shortcuts"
+
+    -- Hidden because we provide other mechanisms to switch between
+    -- channels.
+    , "open"
+    ]
+
+hiddenCommand :: Command -> Bool
+hiddenCommand c = (T.toLower $ commandTrigger c) `elem` hiddenServerCommands
+
+isDeletedCommand :: Command -> Bool
+isDeletedCommand cmd = commandDeleteAt cmd > commandCreateAt cmd
+
+doCommandAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()
+doCommandAutoCompletion ty ctx searchString = do
+    session <- getSession
+    myTid <- gets myTeamId
+
+    mCache <- preuse (csEditState.cedAutocomplete._Just.acCachedResponses)
+    mActiveTy <- preuse (csEditState.cedAutocomplete._Just.acType)
+
+    -- Command completion works a little differently than the other
+    -- modes. To do command autocompletion, we want to query the server
+    -- for the list of available commands and merge that list with
+    -- our own list of client-provided commands. But the server's API
+    -- doesn't support *searching* commands; we can only ask for the
+    -- full list. That means that, unlike the other completion modes
+    -- where we want to ask the server repeatedly as the search string
+    -- is refined, in this case we want to ask the server only once
+    -- and avoid repeating the request for the same data as the user
+    -- types more of the search string. To accomplish that, we use a
+    -- special cache key -- the empty string, which normal user input
+    -- processing will never use -- as the cache key for the "full" list
+    -- of commands obtained by merging the server's list with our own.
+    -- We populate that cache entry when completion starts and then
+    -- subsequent completions consult *that* list instead of asking the
+    -- server again. Subsequent completions then filter and match the
+    -- cached list against the user's search string.
+    let entry = HM.lookup serverResponseKey =<< mCache
+        -- The special cache key to use to store the merged server and
+        -- client command list, sorted but otherwise unfiltered except
+        -- for eliminating deleted or hidden commands.
+        serverResponseKey = ""
+        lowerSearch = T.toLower searchString
+        matches (CommandCompletion _ name _ desc) =
+            lowerSearch `T.isInfixOf` (T.toLower name) ||
+            lowerSearch `T.isInfixOf` (T.toLower desc)
+        matches _ = False
+
+    if (isNothing entry || (mActiveTy /= (Just ACCommands)))
+       then doAsyncWith Preempt $ do
+                let clientAlts = mkAlt <$> commandList
+                    mkAlt (Cmd name desc args _) =
+                        (Client, name, printArgSpec args, desc)
+
+                serverCommands <- MM.mmListCommandsForTeam myTid False session
+                let filteredServerCommands =
+                        filter (\c -> not (hiddenCommand c || isDeletedCommand c)) $
+                        F.toList serverCommands
+                    serverAlts = mkTuple <$> filteredServerCommands
+                    mkTuple cmd =
+                        ( Server
+                        , commandTrigger cmd
+                        , commandAutoCompleteHint cmd
+                        , commandAutoCompleteDesc cmd
+                        )
+                    mkCompletion (src, name, args, desc) =
+                        CommandCompletion src name args desc
+                    alts = fmap mkCompletion $
+                           clientAlts <> serverAlts
+
+                return $ Just $ do
+                    -- Store the complete list of alterantives in the cache
+                    setCompletionAlternatives ctx serverResponseKey alts ty
+
+                    -- Also store the list of alternatives specific to
+                    -- this search string
+                    let newAlts = sortBy (compareCommandAlts searchString) $
+                                  filter matches alts
+                    setCompletionAlternatives ctx searchString newAlts ty
+
+       else case entry of
+           Just alts | mActiveTy == Just ACCommands ->
+               let newAlts = sortBy (compareCommandAlts searchString) $
+                             filter matches alts
+               in setCompletionAlternatives ctx searchString newAlts ty
+           _ -> return ()
+
+compareCommandAlts :: Text -> AutocompleteAlternative -> AutocompleteAlternative -> Ordering
+compareCommandAlts s (CommandCompletion _ nameA _ _)
+                     (CommandCompletion _ nameB _ _) =
+    let isAPrefix = s `T.isPrefixOf` nameA
+        isBPrefix = s `T.isPrefixOf` nameB
+    in if isAPrefix == isBPrefix
+       then compare nameA nameB
+       else if isAPrefix
+            then LT
+            else GT
+compareCommandAlts _ _ _ = LT
+
+doUserAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()
+doUserAutoCompletion ty ctx searchString = do
+    session <- getSession
+    myTid <- gets myTeamId
+    myUid <- gets myUserId
+    cId <- use csCurrentChannelId
+
+    withCachedAutocompleteResults ctx ty searchString $
+        doAsyncWith Preempt $ do
+            ac <- MM.mmAutocompleteUsers (Just myTid) (Just cId) searchString session
+
+            let active = Seq.filter (\u -> userId u /= myUid && (not $ userDeleted u))
+                alts = F.toList $
+                       ((\u -> UserCompletion u True) <$> (active $ MM.userAutocompleteUsers ac)) <>
+                       (maybe mempty (fmap (\u -> UserCompletion u False) . active) $
+                              MM.userAutocompleteOutOfChannel ac)
+
+                specials = [ MentionAll
+                           , MentionChannel
+                           ]
+                extras = [ SpecialMention m | m <- specials
+                         , (T.toLower searchString) `T.isPrefixOf` specialMentionName m
+                         ]
+
+            return $ Just $ setCompletionAlternatives ctx searchString (alts <> extras) ty
+
+doChannelAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()
+doChannelAutoCompletion ty ctx searchString = do
+    session <- getSession
+    tId <- gets myTeamId
+    cs <- use csChannels
+
+    withCachedAutocompleteResults ctx ty searchString $ do
+        doAsyncWith Preempt $ do
+            results <- MM.mmAutocompleteChannels tId searchString session
+            let alts = F.toList $ (ChannelCompletion True <$> inChannels) <>
+                                  (ChannelCompletion False <$> notInChannels)
+                (inChannels, notInChannels) = Seq.partition isMember results
+                isMember c = isJust $ findChannelById (channelId c) cs
+            return $ Just $ setCompletionAlternatives ctx searchString alts ty
+
+-- Utility functions
+
+-- | Attempt to re-use a cached autocomplete alternative list for
+-- a given search string. If the cache contains no such entry (keyed
+-- on search string), run the specified action, which is assumed to be
+-- responsible for fetching the completion results from the server.
+withCachedAutocompleteResults :: AutocompleteContext
+                              -- ^ The autocomplete context
+                              -> AutocompletionType
+                              -- ^ The type of autocompletion we're
+                              -- doing
+                              -> Text
+                              -- ^ The search string to look for in the
+                              -- cache
+                              -> MH ()
+                              -- ^ The action to execute on a cache miss
+                              -> MH ()
+withCachedAutocompleteResults ctx ty searchString act = do
+    mCache <- preuse (csEditState.cedAutocomplete._Just.acCachedResponses)
+    mActiveTy <- preuse (csEditState.cedAutocomplete._Just.acType)
+
+    case Just ty == mActiveTy of
+        True ->
+            -- Does the cache have results for this search string? If
+            -- so, use them; otherwise invoke the specified action.
+            case HM.lookup searchString =<< mCache of
+                Just alts -> setCompletionAlternatives ctx searchString alts ty
+                Nothing -> act
+        False -> act
+
+setCompletionAlternatives :: AutocompleteContext
+                          -> Text
+                          -> [AutocompleteAlternative]
+                          -> AutocompletionType
+                          -> MH ()
+setCompletionAlternatives ctx searchString alts ty = do
+    let list = L.list CompletionList (V.fromList $ F.toList alts) 1
+        state = AutocompleteState { _acPreviousSearchString = searchString
+                                  , _acCompletionList =
+                                      list & L.listSelectedL .~ Nothing
+                                  , _acCachedResponses = HM.fromList [(searchString, alts)]
+                                  , _acType = ty
+                                  }
+
+    pending <- use (csEditState.cedAutocompletePending)
+    case pending of
+        Just val | val == searchString -> do
+
+            -- If there is already state, update it, but also cache the
+            -- search results.
+            csEditState.cedAutocomplete %= \prev ->
+                let newState = case prev of
+                        Nothing ->
+                            state
+                        Just oldState ->
+                            state & acCachedResponses .~
+                                HM.insert searchString alts (oldState^.acCachedResponses)
+                in Just newState
+
+            mh $ vScrollToBeginning $ viewportScroll CompletionList
+
+            when (autocompleteFirstMatch ctx) $
+                tabComplete Forwards
+        _ ->
+            -- Do not update the state if this result does not
+            -- correspond to the search string we used most recently.
+            -- This happens when the editor changes faster than the
+            -- async completion responses arrive from the server. If we
+            -- don't check this, we show completion results that are
+            -- wrong for the editor state.
+            return ()
+
+wordAtColumn :: Int -> Text -> Maybe (Int, Text)
+wordAtColumn i t =
+    let tokens = T.groupBy (\a b -> isSpace a == isSpace b) t
+        go _ j _ | j < 0 = Nothing
+        go col j ts = case ts of
+            [] -> Nothing
+            (w:rest) | j <= T.length w && not (isSpace $ T.head w) -> Just (col, w)
+                     | otherwise -> go (col + T.length w) (j - T.length w) rest
+    in go 0 i tokens
diff --git a/src/Matterhorn/State/ChannelListOverlay.hs b/src/Matterhorn/State/ChannelListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/ChannelListOverlay.hs
@@ -0,0 +1,82 @@
+module Matterhorn.State.ChannelListOverlay
+  ( enterChannelListOverlayMode
+
+  , channelListSelectDown
+  , channelListSelectUp
+  , channelListPageDown
+  , channelListPageUp
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Brick.Widgets.List as L
+import qualified Data.Vector as Vec
+import qualified Data.Sequence as Seq
+import           Data.Function ( on )
+import           Lens.Micro.Platform ( to )
+
+import           Network.Mattermost.Types
+import qualified Network.Mattermost.Endpoints as MM
+
+import           Matterhorn.State.ListOverlay
+import           Matterhorn.State.Channels
+import           Matterhorn.Types
+
+
+enterChannelListOverlayMode :: MH ()
+enterChannelListOverlayMode = do
+    myTId <- gets myTeamId
+    myChannels <- use (csChannels.to (filteredChannelIds (const True)))
+    enterListOverlayMode csChannelListOverlay ChannelListOverlay
+        AllChannels enterHandler (fetchResults myTId myChannels)
+
+enterHandler :: Channel -> MH Bool
+enterHandler chan = do
+    joinChannel (getId chan)
+    return True
+
+fetchResults :: TeamId
+             -> [ChannelId]
+             -- ^ The channels to exclude from the results
+             -> ChannelSearchScope
+             -- ^ The scope to search
+             -> Session
+             -- ^ The connection session
+             -> Text
+             -- ^ The search string
+             -> IO (Vec.Vector Channel)
+fetchResults myTId exclude AllChannels session searchString = do
+    resultChans <- MM.mmSearchChannels myTId searchString session
+    let filteredChans = Seq.filter (\ c -> not (channelId c `elem` exclude)) resultChans
+        sortedChans = Vec.fromList $ toList $ Seq.sortBy (compare `on` channelName) filteredChans
+    return sortedChans
+
+-- | Move the selection up in the channel list overlay by one channel.
+channelListSelectUp :: MH ()
+channelListSelectUp = channelListMove L.listMoveUp
+
+-- | Move the selection down in the channel list overlay by one channel.
+channelListSelectDown :: MH ()
+channelListSelectDown = channelListMove L.listMoveDown
+
+-- | Move the selection up in the channel list overlay by a page of channels
+-- (channelListPageSize).
+channelListPageUp :: MH ()
+channelListPageUp = channelListMove (L.listMoveBy (-1 * channelListPageSize))
+
+-- | Move the selection down in the channel list overlay by a page of channels
+-- (channelListPageSize).
+channelListPageDown :: MH ()
+channelListPageDown = channelListMove (L.listMoveBy channelListPageSize)
+
+-- | Transform the channel list results in some way, e.g. by moving the
+-- cursor, and then check to see whether the modification warrants a
+-- prefetch of more search results.
+channelListMove :: (L.List Name Channel -> L.List Name Channel) -> MH ()
+channelListMove = listOverlayMove csChannelListOverlay
+
+-- | The number of channels in a "page" for cursor movement purposes.
+channelListPageSize :: Int
+channelListPageSize = 10
diff --git a/src/Matterhorn/State/ChannelSelect.hs b/src/Matterhorn/State/ChannelSelect.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/ChannelSelect.hs
@@ -0,0 +1,155 @@
+{-# LANGUAGE MultiWayIf #-}
+module Matterhorn.State.ChannelSelect
+  ( beginChannelSelect
+  , updateChannelSelectMatches
+  , channelSelectNext
+  , channelSelectPrevious
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Widgets.Edit ( getEditContents )
+import           Data.Char ( isUpper )
+import qualified Data.Text as T
+import           Lens.Micro.Platform
+
+import qualified Network.Mattermost.Types as MM
+
+import           Matterhorn.Constants ( userSigil, normalChannelSigil )
+import           Matterhorn.Types
+import qualified Matterhorn.Zipper as Z
+
+beginChannelSelect :: MH ()
+beginChannelSelect = do
+    setMode ChannelSelect
+    csChannelSelectState .= emptyChannelSelectState
+    updateChannelSelectMatches
+
+    -- Preserve the current channel selection when initializing channel
+    -- selection mode
+    zipper <- use csFocus
+    let isCurrentFocus m = Just (matchEntry m) == Z.focus zipper
+    csChannelSelectState.channelSelectMatches %= Z.findRight isCurrentFocus
+
+-- Select the next match in channel selection mode.
+channelSelectNext :: MH ()
+channelSelectNext = updateSelectedMatch Z.right
+
+-- Select the previous match in channel selection mode.
+channelSelectPrevious :: MH ()
+channelSelectPrevious = updateSelectedMatch Z.left
+
+updateChannelSelectMatches :: MH ()
+updateChannelSelectMatches = do
+    st <- use id
+
+    input <- use (csChannelSelectState.channelSelectInput)
+    cconfig <- use csClientConfig
+    prefs <- use (csResources.crUserPreferences)
+
+    let pat = parseChannelSelectPattern $ T.concat $ getEditContents input
+        chanNameMatches e = case pat of
+            Nothing -> const Nothing
+            Just p -> applySelectPattern p e
+        patTy = case pat of
+            Nothing -> Nothing
+            Just CSPAny -> Nothing
+            Just (CSP ty _) -> Just ty
+
+    let chanMatches e chan =
+            if patTy == Just PrefixDMOnly
+            then Nothing
+            else if chan^.ccInfo.cdType /= MM.Group
+                 then chanNameMatches e $ chan^.ccInfo.cdDisplayName
+                 else Nothing
+        groupChanMatches e chan =
+            if patTy == Just PrefixNonDMOnly
+            then Nothing
+            else if chan^.ccInfo.cdType == MM.Group
+                 then chanNameMatches e $ chan^.ccInfo.cdDisplayName
+                 else Nothing
+        displayName uInfo = displayNameForUser uInfo cconfig prefs
+        userMatches e uInfo =
+            if patTy == Just PrefixNonDMOnly
+            then Nothing
+            else (chanNameMatches e . displayName) uInfo
+        matches e@(CLChannel cId) = findChannelById cId (st^.csChannels) >>= chanMatches e
+        matches e@(CLUserDM _ uId) = userById uId st >>= userMatches e
+        matches e@(CLGroupDM cId) = findChannelById cId (st^.csChannels) >>= groupChanMatches e
+
+        preserveFocus Nothing _ = False
+        preserveFocus (Just m) m2 = matchEntry m == matchEntry m2
+
+    csChannelSelectState.channelSelectMatches %= (Z.updateListBy preserveFocus $ Z.toList $ Z.maybeMapZipper matches (st^.csFocus))
+
+applySelectPattern :: ChannelSelectPattern -> ChannelListEntry -> Text -> Maybe ChannelSelectMatch
+applySelectPattern CSPAny entry chanName = do
+    return $ ChannelSelectMatch "" "" chanName chanName entry
+applySelectPattern (CSP ty pat) entry chanName = do
+    let applyType Infix | pat `T.isInfixOf` normalizedChanName =
+            case T.breakOn pat normalizedChanName of
+                (pre, _) ->
+                    return ( T.take (T.length pre) chanName
+                           , T.take (T.length pat) $ T.drop (T.length pre) chanName
+                           , T.drop (T.length pat + T.length pre) chanName
+                           )
+
+        applyType Prefix | pat `T.isPrefixOf` normalizedChanName = do
+            let (b, a) = T.splitAt (T.length pat) chanName
+            return ("", b, a)
+
+        applyType PrefixDMOnly | pat `T.isPrefixOf` normalizedChanName = do
+            let (b, a) = T.splitAt (T.length pat) chanName
+            return ("", b, a)
+
+        applyType PrefixNonDMOnly | pat `T.isPrefixOf` normalizedChanName = do
+            let (b, a) = T.splitAt (T.length pat) chanName
+            return ("", b, a)
+
+        applyType Suffix | pat `T.isSuffixOf` normalizedChanName = do
+            let (b, a) = T.splitAt (T.length chanName - T.length pat) chanName
+            return (b, a, "")
+
+        applyType Equal  | pat == normalizedChanName =
+            return ("", chanName, "")
+
+        applyType _ = Nothing
+
+        caseSensitive = T.any isUpper pat
+        normalizedChanName = if caseSensitive
+                             then chanName
+                             else T.toLower chanName
+
+    (pre, m, post) <- applyType ty
+    return $ ChannelSelectMatch pre m post chanName entry
+
+parseChannelSelectPattern :: Text -> Maybe ChannelSelectPattern
+parseChannelSelectPattern "" = return CSPAny
+parseChannelSelectPattern pat = do
+    let only = if | userSigil `T.isPrefixOf` pat -> Just $ CSP PrefixDMOnly $ T.tail pat
+                  | normalChannelSigil `T.isPrefixOf` pat -> Just $ CSP PrefixNonDMOnly $ T.tail pat
+                  | otherwise -> Nothing
+
+    (pat1, pfx) <- case "^" `T.isPrefixOf` pat of
+        True  -> return (T.tail pat, Just Prefix)
+        False -> return (pat, Nothing)
+
+    (pat2, sfx) <- case "$" `T.isSuffixOf` pat1 of
+        True  -> return (T.init pat1, Just Suffix)
+        False -> return (pat1, Nothing)
+
+    only <|> case (pfx, sfx) of
+        (Nothing, Nothing)         -> return $ CSP Infix  pat2
+        (Just Prefix, Nothing)     -> return $ CSP Prefix pat2
+        (Nothing, Just Suffix)     -> return $ CSP Suffix pat2
+        (Just Prefix, Just Suffix) -> return $ CSP Equal  pat2
+        tys                        -> error $ "BUG: invalid channel select case: " <> show tys
+
+-- Update the channel selection mode match cursor. The argument function
+-- determines how to navigate to the next item.
+updateSelectedMatch :: (Z.Zipper ChannelListGroup ChannelSelectMatch -> Z.Zipper ChannelListGroup ChannelSelectMatch)
+                    -> MH ()
+updateSelectedMatch nextItem =
+    csChannelSelectState.channelSelectMatches %= nextItem
diff --git a/src/Matterhorn/State/ChannelTopicWindow.hs b/src/Matterhorn/State/ChannelTopicWindow.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/ChannelTopicWindow.hs
@@ -0,0 +1,16 @@
+module Matterhorn.State.ChannelTopicWindow
+  ( openChannelTopicWindow
+  )
+where
+
+import           Lens.Micro.Platform ( (.=) )
+
+import           Matterhorn.Types
+import           Matterhorn.State.Channels ( getCurrentChannelTopic )
+
+
+openChannelTopicWindow :: MH ()
+openChannelTopicWindow = do
+    t <- getCurrentChannelTopic
+    csChannelTopicDialog .= newChannelTopicDialog t
+    setMode ChannelTopicWindow
diff --git a/src/Matterhorn/State/Channels.hs b/src/Matterhorn/State/Channels.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Channels.hs
@@ -0,0 +1,1134 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MultiWayIf #-}
+module Matterhorn.State.Channels
+  ( updateSidebar
+  , updateViewed
+  , updateViewedChan
+  , refreshChannel
+  , refreshChannelsAndUsers
+  , setFocus
+  , refreshChannelById
+  , applyPreferenceChange
+  , leaveChannel
+  , leaveCurrentChannel
+  , getNextUnreadChannel
+  , getNextUnreadUserOrChannel
+  , nextUnreadChannel
+  , nextUnreadUserOrChannel
+  , createOrFocusDMChannel
+  , clearChannelUnreadStatus
+  , prevChannel
+  , nextChannel
+  , recentChannel
+  , setReturnChannel
+  , resetReturnChannel
+  , hideDMChannel
+  , createGroupChannel
+  , showGroupChannelPref
+  , channelHistoryForward
+  , channelHistoryBackward
+  , handleNewChannel
+  , createOrdinaryChannel
+  , handleChannelInvite
+  , addUserByNameToCurrentChannel
+  , addUserToCurrentChannel
+  , removeUserFromCurrentChannel
+  , removeChannelFromState
+  , isRecentChannel
+  , isReturnChannel
+  , isCurrentChannel
+  , deleteCurrentChannel
+  , startLeaveCurrentChannel
+  , joinChannel
+  , joinChannel'
+  , joinChannelByName
+  , changeChannelByName
+  , setChannelTopic
+  , getCurrentChannelTopic
+  , beginCurrentChannelDeleteConfirm
+  , toggleChannelListVisibility
+  , toggleExpandedChannelTopics
+  , showChannelInSidebar
+  , updateChannelNotifyProps
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Main ( getVtyHandle, viewportScroll, vScrollToBeginning
+                            , invalidateCache, invalidateCacheEntry )
+import           Brick.Widgets.Edit ( applyEdit, getEditContents, editContentsL )
+import           Control.Concurrent.Async ( runConcurrently, Concurrently(..) )
+import           Control.Exception ( SomeException, try )
+import           Data.Char ( isAlphaNum )
+import qualified Data.HashMap.Strict as HM
+import qualified Data.Foldable as F
+import           Data.List ( nub )
+import           Data.Maybe ( fromJust )
+import qualified Data.Set as S
+import qualified Data.Sequence as Seq
+import qualified Data.Text as T
+import           Data.Text.Zipper ( textZipper, clearZipper, insertMany, gotoEOL )
+import           Data.Time.Clock ( getCurrentTime )
+import qualified Graphics.Vty as Vty
+import           Lens.Micro.Platform
+
+import qualified Network.Mattermost.Endpoints as MM
+import           Network.Mattermost.Lenses
+import           Network.Mattermost.Types
+
+import           Matterhorn.Constants ( normalChannelSigil )
+import           Matterhorn.InputHistory
+import           Matterhorn.State.Common
+import {-# SOURCE #-} Matterhorn.State.Messages ( fetchVisibleIfNeeded )
+import           Matterhorn.State.Users
+import           Matterhorn.State.Flagging
+import           Matterhorn.Types
+import           Matterhorn.Types.Common
+import           Matterhorn.Zipper ( Zipper )
+import qualified Matterhorn.Zipper as Z
+
+
+updateSidebar :: MH ()
+updateSidebar = do
+    -- Invalidate the cached sidebar rendering since we are about to
+    -- change the underlying state
+    mh $ invalidateCacheEntry ChannelSidebar
+
+    -- Get the currently-focused channel ID so we can compare after the
+    -- zipper is rebuilt
+    cconfig <- use csClientConfig
+    oldCid <- use csCurrentChannelId
+
+    -- Update the zipper
+    cs <- use csChannels
+    us <- getUsers
+    prefs <- use (csResources.crUserPreferences)
+    now <- liftIO getCurrentTime
+    config <- use (csResources.crConfiguration)
+
+    let zl = mkChannelZipperList now config cconfig prefs cs us
+    csFocus %= Z.updateList zl
+
+    -- Schedule the current sidebar for user status updates at the end
+    -- of this MH action.
+    newZ <- use csFocus
+    myId <- gets myUserId
+    scheduleUserStatusFetches $ myId : userIdsFromZipper newZ
+
+    -- Update the window title based on the unread status of the
+    -- channels.
+    let unread = sum $ (channelListGroupUnread . fst) <$> zl
+        title = "matterhorn" <> if unread > 0 then "(" <> show unread <> ")" else ""
+
+    vty <- mh getVtyHandle
+    liftIO $ Vty.setWindowTitle vty title
+
+    -- If the zipper rebuild caused the current channel to change, such
+    -- as when the previously-focused channel was removed, we need to
+    -- call fetchVisibleIfNeeded on the newly-focused channel to ensure
+    -- that it gets loaded.
+    newCid <- use csCurrentChannelId
+    when (newCid /= oldCid) $
+        fetchVisibleIfNeeded
+
+updateViewed :: Bool -> MH ()
+updateViewed updatePrev = do
+    csCurrentChannel.ccInfo.cdMentionCount .= 0
+    updateViewedChan updatePrev =<< use csCurrentChannelId
+
+-- | When a new channel has been selected for viewing, this will
+-- notify the server of the change, and also update the local channel
+-- state to set the last-viewed time for the previous channel and
+-- update the viewed time to now for the newly selected channel.
+--
+-- The boolean argument indicates whether the view time of the previous
+-- channel (if any) should be updated, too. We typically want to do that
+-- only on channel switching; when we just want to update the view time
+-- of the specified channel, False should be provided.
+updateViewedChan :: Bool -> ChannelId -> MH ()
+updateViewedChan updatePrev cId = use csConnectionStatus >>= \case
+    Connected -> do
+        -- Only do this if we're connected to avoid triggering noisy
+        -- exceptions.
+        pId <- if updatePrev
+               then use csRecentChannel
+               else return Nothing
+        doAsyncChannelMM Preempt cId
+          (\s c -> MM.mmViewChannel UserMe c pId s)
+          (\c () -> Just $ setLastViewedFor pId c)
+    Disconnected ->
+        -- Cannot update server; make no local updates to avoid getting
+        -- out of sync with the server. Assumes that this is a temporary
+        -- break in connectivity and that after the connection is
+        -- restored, the user's normal activities will update state as
+        -- appropriate. If connectivity is permanently lost, managing
+        -- this state is irrelevant.
+        return ()
+
+toggleChannelListVisibility :: MH ()
+toggleChannelListVisibility = do
+    mh invalidateCache
+    csShowChannelList %= not
+
+toggleExpandedChannelTopics :: MH ()
+toggleExpandedChannelTopics = do
+    mh invalidateCache
+    csShowExpandedChannelTopics %= not
+
+-- | If the current channel is a DM channel with a single user or a
+-- group of users, hide it from the sidebar and adjust the server-side
+-- preference to hide it persistently.
+--
+-- If the current channel is any other kind of channel, complain with a
+-- usage error.
+hideDMChannel :: ChannelId -> MH ()
+hideDMChannel cId = do
+    me <- gets myUser
+    session <- getSession
+    withChannel cId $ \chan -> do
+        case chan^.ccInfo.cdType of
+            Direct -> do
+                let pref = showDirectChannelPref (me^.userIdL) uId False
+                    Just uId = chan^.ccInfo.cdDMUserId
+                csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing
+                doAsyncWith Preempt $ do
+                    MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session
+                    return Nothing
+            Group -> do
+                let pref = hideGroupChannelPref cId (me^.userIdL)
+                csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing
+                doAsyncWith Preempt $ do
+                    MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session
+                    return Nothing
+            _ -> do
+                mhError $ GenericError "Cannot hide this channel. Consider using /leave instead."
+
+-- | Called on async completion when the currently viewed channel has
+-- been updated (i.e., just switched to this channel) to update local
+-- state.
+setLastViewedFor :: Maybe ChannelId -> ChannelId -> MH ()
+setLastViewedFor prevId cId = do
+    chan <- use (csChannels.to (findChannelById cId))
+    -- Update new channel's viewed time, creating the channel if needed
+    case chan of
+        Nothing ->
+            -- It's possible for us to get spurious WMChannelViewed
+            -- events from the server, e.g. for channels that have been
+            -- deleted. So here we ignore the request since it's hard to
+            -- detect it before this point.
+            return ()
+        Just _  ->
+          -- The server has been sent a viewed POST update, but there is
+          -- no local information on what timestamp the server actually
+          -- recorded. There are a couple of options for setting the
+          -- local value of the viewed time:
+          --
+          --   1. Attempting to locally construct a value, which would
+          --      involve scanning all (User) messages in the channel
+          --      to find the maximum of the created date, the modified
+          --      date, or the deleted date, and assuming that maximum
+          --      mostly matched the server's viewed time.
+          --
+          --   2. Issuing a channel metadata request to get the server's
+          --      new concept of the viewed time.
+          --
+          --   3. Having the "chan/viewed" POST that was just issued
+          --      return a value from the server. See
+          --      https://github.com/mattermost/platform/issues/6803.
+          --
+          -- Method 3 would be the best and most lightweight. Until that
+          -- is available, Method 2 will be used. The downside to Method
+          -- 2 is additional client-server messaging, and a delay in
+          -- updating the client data, but it's also immune to any new
+          -- or removed Message date fields, or anything else that would
+          -- contribute to the viewed/updated times on the server.
+          doAsyncChannelMM Preempt cId (\ s _ ->
+                                           (,) <$> MM.mmGetChannel cId s
+                                               <*> MM.mmGetChannelMember cId UserMe s)
+          (\pcid (cwd, member) -> Just $ csChannel(pcid).ccInfo %= channelInfoFromChannelWithData cwd member)
+
+    -- Update the old channel's previous viewed time (allows tracking of
+    -- new messages)
+    case prevId of
+      Nothing -> return ()
+      Just p -> clearChannelUnreadStatus p
+
+-- | Refresh information about all channels and users. This is usually
+-- triggered when a reconnect event for the WebSocket to the server
+-- occurs.
+refreshChannelsAndUsers :: MH ()
+refreshChannelsAndUsers = do
+    session <- getSession
+    myTId <- gets myTeamId
+    me <- gets myUser
+    knownUsers <- gets allUserIds
+    doAsyncWith Preempt $ do
+      (chans, datas) <- runConcurrently $ (,)
+                       <$> Concurrently (MM.mmGetChannelsForUser UserMe myTId session)
+                       <*> Concurrently (MM.mmGetChannelMembersForUser UserMe myTId session)
+
+      -- Collect all user IDs associated with DM channels so we can
+      -- bulk-fetch their user records.
+      let dmUsers = catMaybes $ flip map (F.toList chans) $ \chan ->
+              case chan^.channelTypeL of
+                  Direct -> case userIdForDMChannel (userId me) (sanitizeUserText $ channelName chan) of
+                        Nothing -> Nothing
+                        Just otherUserId -> Just otherUserId
+                  _ -> Nothing
+          uIdsToFetch = nub $ userId me : knownUsers <> dmUsers
+
+          dataMap = HM.fromList $ toList $ (\d -> (channelMemberChannelId d, d)) <$> datas
+          mkPair chan = (chan, fromJust $ HM.lookup (channelId chan) dataMap)
+          chansWithData = mkPair <$> chans
+
+      return $ Just $
+          -- Fetch user data associated with DM channels
+          handleNewUsers (Seq.fromList uIdsToFetch) $ do
+              -- Then refresh all loaded channels
+              forM_ chansWithData $ uncurry (refreshChannel SidebarUpdateDeferred)
+              updateSidebar
+
+-- | Refresh information about a specific channel.  The channel
+-- metadata is refreshed, and if this is a loaded channel, the
+-- scrollback is updated as well.
+--
+-- The sidebar update argument indicates whether this refresh should
+-- also update the sidebar. Ordinarily you want this, so pass
+-- SidebarUpdateImmediate unless you are very sure you know what you are
+-- doing, i.e., you are very sure that a call to refreshChannel will
+-- be followed immediately by a call to updateSidebar. We provide this
+-- control so that channel refreshes can be batched and then a single
+-- updateSidebar call can be used instead of the default behavior of
+-- calling it once per refreshChannel call, which is the behavior if the
+-- immediate setting is passed here.
+refreshChannel :: SidebarUpdate -> Channel -> ChannelMember -> MH ()
+refreshChannel upd chan member = do
+    let cId = getId chan
+    myTId <- gets myTeamId
+    let ourTeam = channelTeamId chan == Nothing ||
+                  Just myTId == channelTeamId chan
+
+    case not ourTeam of
+        True -> return ()
+        False -> do
+            -- If this channel is unknown, register it first.
+            mChan <- preuse (csChannel(cId))
+            when (isNothing mChan) $
+                handleNewChannel False upd chan member
+
+            updateChannelInfo cId chan member
+
+handleNewChannel :: Bool -> SidebarUpdate -> Channel -> ChannelMember -> MH ()
+handleNewChannel = handleNewChannel_ True
+
+handleNewChannel_ :: Bool
+                  -- ^ Whether to permit this call to recursively
+                  -- schedule itself for later if it can't locate
+                  -- a DM channel user record. This is to prevent
+                  -- uncontrolled recursion.
+                  -> Bool
+                  -- ^ Whether to switch to the new channel once it has
+                  -- been installed.
+                  -> SidebarUpdate
+                  -- ^ Whether to update the sidebar, in case the caller
+                  -- wants to batch these before updating it. Pass
+                  -- SidebarUpdateImmediate unless you know what
+                  -- you are doing, i.e., unless you intend to call
+                  -- updateSidebar yourself after calling this.
+                  -> Channel
+                  -- ^ The channel to install.
+                  -> ChannelMember
+                  -> MH ()
+handleNewChannel_ permitPostpone switch sbUpdate nc member = do
+    -- Only add the channel to the state if it isn't already known.
+    me <- gets myUser
+    mChan <- preuse (csChannel(getId nc))
+    case mChan of
+        Just _ -> when switch $ setFocus (getId nc)
+        Nothing -> do
+            -- Create a new ClientChannel structure
+            cChannel <- (ccInfo %~ channelInfoFromChannelWithData nc member) <$>
+                       makeClientChannel (me^.userIdL) nc
+
+            st <- use id
+
+            -- Add it to the message map, and to the name map so we
+            -- can look it up by name. The name we use for the channel
+            -- depends on its type:
+            let chType = nc^.channelTypeL
+
+            -- Get the channel name. If we couldn't, that means we have
+            -- async work to do before we can register this channel (in
+            -- which case abort because we got rescheduled).
+            register <- case chType of
+                Direct -> case userIdForDMChannel (myUserId st) (sanitizeUserText $ channelName nc) of
+                    Nothing -> return True
+                    Just otherUserId ->
+                        case userById otherUserId st of
+                            -- If we found a user ID in the channel
+                            -- name string but don't have that user's
+                            -- metadata, postpone adding this channel
+                            -- until we have fetched the metadata. This
+                            -- can happen when we have a channel record
+                            -- for a user that is no longer in the
+                            -- current team. To avoid recursion due to a
+                            -- problem, ensure that the rescheduled new
+                            -- channel handler is not permitted to try
+                            -- this again.
+                            --
+                            -- If we're already in a recursive attempt
+                            -- to register this channel and still
+                            -- couldn't find a username, just bail and
+                            -- use the synthetic name (this has the same
+                            -- problems as above).
+                            Nothing -> do
+                                case permitPostpone of
+                                    False -> return True
+                                    True -> do
+                                        mhLog LogAPI $ T.pack $ "handleNewChannel_: about to call handleNewUsers for " <> show otherUserId
+                                        handleNewUsers (Seq.singleton otherUserId) (return ())
+                                        doAsyncWith Normal $
+                                            return $ Just $ handleNewChannel_ False switch sbUpdate nc member
+                                        return False
+                            Just _ -> return True
+                _ -> return True
+
+            when register $ do
+                csChannels %= addChannel (getId nc) cChannel
+                when (sbUpdate == SidebarUpdateImmediate) $ do
+                    -- Note that we only check for whether we should
+                    -- switch to this channel when doing a sidebar
+                    -- update, since that's the only case where it's
+                    -- possible to do so.
+                    updateSidebar
+
+                    -- Finally, set our focus to the newly created
+                    -- channel if the caller requested a change of
+                    -- channel. Also consider the last join request
+                    -- state field in case this is an asynchronous
+                    -- channel addition triggered by a /join.
+                    pending1 <- checkPendingChannelChange (getId nc)
+                    pending2 <- case cChannel^.ccInfo.cdDMUserId of
+                        Nothing -> return False
+                        Just uId -> checkPendingChannelChangeByUserId uId
+
+                    when (switch || isJust pending1 || pending2) $ do
+                        setFocus (getId nc)
+                        case pending1 of
+                            Just (Just act) -> act
+                            _ -> return ()
+
+-- | Check to see whether the specified channel has been queued up to
+-- be switched to.  Note that this condition is only cleared by the
+-- actual setFocus switch to the channel because there may be multiple
+-- operations that must complete before the channel is fully ready for
+-- display/use.
+--
+-- Returns Just if the specified channel has a pending switch. The
+-- result is an optional action to invoke after changing to the
+-- specified channel.
+checkPendingChannelChange :: ChannelId -> MH (Maybe (Maybe (MH ())))
+checkPendingChannelChange cId = do
+    ch <- use csPendingChannelChange
+    return $ case ch of
+        Just (ChangeByChannelId i act) ->
+            if i == cId then Just act else Nothing
+        _ -> Nothing
+
+-- | Check to see whether the specified channel has been queued up to
+-- be switched to.  Note that this condition is only cleared by the
+-- actual setFocus switch to the channel because there may be multiple
+-- operations that must complete before the channel is fully ready for
+-- display/use.
+--
+-- Returns Just if the specified channel has a pending switch. The
+-- result is an optional action to invoke after changing to the
+-- specified channel.
+checkPendingChannelChangeByUserId :: UserId -> MH Bool
+checkPendingChannelChangeByUserId uId = do
+    ch <- use csPendingChannelChange
+    return $ case ch of
+        Just (ChangeByUserId i) ->
+            i == uId
+        _ ->
+            False
+
+-- | Update the indicated Channel entry with the new data retrieved from
+-- the Mattermost server. Also update the channel name if it changed.
+updateChannelInfo :: ChannelId -> Channel -> ChannelMember -> MH ()
+updateChannelInfo cid new member = do
+    mh $ invalidateCacheEntry $ ChannelMessages cid
+    csChannel(cid).ccInfo %= channelInfoFromChannelWithData new member
+    updateSidebar
+
+setFocus :: ChannelId -> MH ()
+setFocus cId = do
+    showChannelInSidebar cId True
+    setFocusWith True (Z.findRight ((== cId) . channelListEntryChannelId)) (return ())
+
+showChannelInSidebar :: ChannelId -> Bool -> MH ()
+showChannelInSidebar cId setPending = do
+    mChan <- preuse $ csChannel cId
+    me <- gets myUser
+    prefs <- use (csResources.crUserPreferences)
+    session <- getSession
+
+    case mChan of
+        Nothing ->
+          -- The requested channel doesn't actually exist yet, so no
+          -- action can be taken.  It's likely that this is a
+          -- pendingChannel situation and not all of the operations to
+          -- locally define the channel have completed, in which case
+          -- this code will be re-entered later and the mChan will be
+          -- known.
+          return ()
+        Just ch -> do
+
+            -- Able to successfully switch to a known channel.  This
+            -- should clear any pending channel intention.  If the
+            -- intention was for this channel, then: done.  If the
+            -- intention was for a different channel, reaching this
+            -- point means that the pending is still outstanding but
+            -- that the user identified a new channel which *was*
+            -- displayable, and the UI should always prefer to SATISFY
+            -- the user's latest request over any pending/background
+            -- task.
+            csPendingChannelChange .= Nothing
+
+            now <- liftIO getCurrentTime
+            csChannel(cId).ccInfo.cdSidebarShowOverride .= Just now
+            updateSidebar
+
+            case ch^.ccInfo.cdType of
+                Direct -> do
+                    let Just uId = ch^.ccInfo.cdDMUserId
+                    case dmChannelShowPreference prefs uId of
+                        Just False -> do
+                            let pref = showDirectChannelPref (me^.userIdL) uId True
+                            when setPending $
+                                csPendingChannelChange .= Just (ChangeByChannelId (ch^.ccInfo.cdChannelId) Nothing)
+                            doAsyncWith Preempt $ do
+                                MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session
+                                return Nothing
+                        _ -> return ()
+
+                Group ->
+                    case groupChannelShowPreference prefs cId of
+                        Just False -> do
+                            let pref = showGroupChannelPref cId (me^.userIdL)
+                            when setPending $
+                                csPendingChannelChange .= Just (ChangeByChannelId (ch^.ccInfo.cdChannelId) Nothing)
+                            doAsyncWith Preempt $ do
+                                MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session
+                                return Nothing
+                        _ -> return ()
+
+                _ -> return ()
+
+setFocusWith :: Bool
+             -> (Zipper ChannelListGroup ChannelListEntry
+             -> Zipper ChannelListGroup ChannelListEntry)
+             -> MH ()
+             -> MH ()
+setFocusWith updatePrev f onNoChange = do
+    oldZipper <- use csFocus
+    let newZipper = f oldZipper
+        newFocus = Z.focus newZipper
+        oldFocus = Z.focus oldZipper
+
+    -- If we aren't changing anything, skip all the book-keeping because
+    -- we'll end up clobbering things like csRecentChannel.
+    if newFocus /= oldFocus
+       then do
+          mh $ invalidateCacheEntry ChannelSidebar
+          resetAutocomplete
+          preChangeChannelCommon
+          csFocus .= newZipper
+
+          now <- liftIO getCurrentTime
+          newCid <- use csCurrentChannelId
+          csChannel(newCid).ccInfo.cdSidebarShowOverride .= Just now
+
+          updateViewed updatePrev
+          postChangeChannelCommon
+       else onNoChange
+
+postChangeChannelCommon :: MH ()
+postChangeChannelCommon = do
+    resetEditorState
+    updateChannelListScroll
+    loadLastEdit
+    fetchVisibleIfNeeded
+
+loadLastEdit :: MH ()
+loadLastEdit = do
+    cId <- use csCurrentChannelId
+
+    oldEphemeral <- preuse (csChannel(cId).ccEditState)
+    case oldEphemeral of
+        Nothing -> return ()
+        Just e -> csEditState.cedEphemeral .= e
+
+    loadLastChannelInput
+
+loadLastChannelInput :: MH ()
+loadLastChannelInput = do
+    cId <- use csCurrentChannelId
+    inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)
+    case inputHistoryPos of
+        Just i -> loadHistoryEntryToEditor cId i
+        Nothing -> do
+            (lastEdit, lastEditMode) <- use (csEditState.cedEphemeral.eesLastInput)
+            csEditState.cedEditor %= (applyEdit $ insertMany lastEdit . clearZipper)
+            csEditState.cedEditMode .= lastEditMode
+
+updateChannelListScroll :: MH ()
+updateChannelListScroll = do
+    mh $ vScrollToBeginning (viewportScroll ChannelList)
+
+preChangeChannelCommon :: MH ()
+preChangeChannelCommon = do
+    cId <- use csCurrentChannelId
+    csRecentChannel .= Just cId
+    saveCurrentEdit
+
+resetEditorState :: MH ()
+resetEditorState = do
+    csEditState.cedEditMode .= NewPost
+    clearEditor
+
+clearEditor :: MH ()
+clearEditor = csEditState.cedEditor %= applyEdit clearZipper
+
+saveCurrentEdit :: MH ()
+saveCurrentEdit = do
+    saveCurrentChannelInput
+
+    oldEphemeral <- use (csEditState.cedEphemeral)
+    cId <- use csCurrentChannelId
+    csChannel(cId).ccEditState .= oldEphemeral
+
+saveCurrentChannelInput :: MH ()
+saveCurrentChannelInput = do
+    cmdLine <- use (csEditState.cedEditor)
+    mode <- use (csEditState.cedEditMode)
+
+    -- Only save the editor contents if the user is not navigating the
+    -- history.
+    inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)
+
+    when (isNothing inputHistoryPos) $
+        csEditState.cedEphemeral.eesLastInput .=
+           (T.intercalate "\n" $ getEditContents $ cmdLine, mode)
+
+hideGroupChannelPref :: ChannelId -> UserId -> Preference
+hideGroupChannelPref cId uId =
+    Preference { preferenceCategory = PreferenceCategoryGroupChannelShow
+               , preferenceValue = PreferenceValue "false"
+               , preferenceName = PreferenceName $ idString cId
+               , preferenceUserId = uId
+               }
+
+showGroupChannelPref :: ChannelId -> UserId -> Preference
+showGroupChannelPref cId uId =
+    Preference { preferenceCategory = PreferenceCategoryGroupChannelShow
+               , preferenceValue = PreferenceValue "true"
+               , preferenceName = PreferenceName $ idString cId
+               , preferenceUserId = uId
+               }
+
+showDirectChannelPref :: UserId -> UserId -> Bool -> Preference
+showDirectChannelPref myId otherId s =
+    Preference { preferenceCategory = PreferenceCategoryDirectChannelShow
+               , preferenceValue = if s then PreferenceValue "true"
+                                        else PreferenceValue "false"
+               , preferenceName = PreferenceName $ idString otherId
+               , preferenceUserId = myId
+               }
+
+applyPreferenceChange :: Preference -> MH ()
+applyPreferenceChange pref = do
+    -- always update our user preferences accordingly
+    csResources.crUserPreferences %= setUserPreferences (Seq.singleton pref)
+
+    -- Invalidate the entire rendering cache since many things depend on
+    -- user preferences
+    mh invalidateCache
+
+    if
+      | Just f <- preferenceToFlaggedPost pref -> do
+          updateMessageFlag (flaggedPostId f) (flaggedPostStatus f)
+
+      | Just d <- preferenceToDirectChannelShowStatus pref -> do
+          updateSidebar
+
+          cs <- use csChannels
+
+          -- We need to check on whether this preference was to show a
+          -- channel and, if so, whether it was the one we attempted to
+          -- switch to (thus triggering the preference change). If so,
+          -- we need to switch to it now.
+          let Just cId = getDmChannelFor (directChannelShowUserId d) cs
+          case directChannelShowValue d of
+              True -> do
+                  pending <- checkPendingChannelChange cId
+                  case pending of
+                      Just mAct -> do
+                          setFocus cId
+                          fromMaybe (return ()) mAct
+                      Nothing -> return ()
+              False -> do
+                  csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing
+
+      | Just g <- preferenceToGroupChannelPreference pref -> do
+          updateSidebar
+
+          -- We need to check on whether this preference was to show a
+          -- channel and, if so, whether it was the one we attempted to
+          -- switch to (thus triggering the preference change). If so,
+          -- we need to switch to it now.
+          let cId = groupChannelId g
+          case groupChannelShow g of
+              True -> do
+                  pending <- checkPendingChannelChange cId
+                  case pending of
+                      Just mAct -> do
+                          setFocus cId
+                          fromMaybe (return ()) mAct
+                      Nothing -> return ()
+              False -> do
+                  csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing
+
+      | otherwise -> return ()
+
+refreshChannelById :: ChannelId -> MH ()
+refreshChannelById cId = do
+    session <- getSession
+    doAsyncWith Preempt $ do
+        cwd <- MM.mmGetChannel cId session
+        member <- MM.mmGetChannelMember cId UserMe session
+        return $ Just $ do
+            refreshChannel SidebarUpdateImmediate cwd member
+
+removeChannelFromState :: ChannelId -> MH ()
+removeChannelFromState cId = do
+    withChannel cId $ \ chan -> do
+        when (chan^.ccInfo.cdType /= Direct) $ do
+            origFocus <- use csCurrentChannelId
+            when (origFocus == cId) nextChannelSkipPrevView
+            -- Update input history
+            csEditState.cedInputHistory         %= removeChannelHistory cId
+            -- Update msgMap
+            csChannels                          %= removeChannel cId
+            -- Remove from focus zipper
+            csFocus %= Z.filterZipper ((/= cId) . channelListEntryChannelId)
+            updateSidebar
+
+nextChannel :: MH ()
+nextChannel = do
+    resetReturnChannel
+    setFocusWith True Z.right (return ())
+
+-- | This is almost never what you want; we use this when we delete a
+-- channel and we don't want to update the deleted channel's view time.
+nextChannelSkipPrevView :: MH ()
+nextChannelSkipPrevView = setFocusWith False Z.right (return ())
+
+prevChannel :: MH ()
+prevChannel = do
+    resetReturnChannel
+    setFocusWith True Z.left (return ())
+
+recentChannel :: MH ()
+recentChannel = do
+  recent <- use csRecentChannel
+  case recent of
+    Nothing  -> return ()
+    Just cId -> do
+        ret <- use csReturnChannel
+        when (ret == Just cId) resetReturnChannel
+        setFocus cId
+
+resetReturnChannel :: MH ()
+resetReturnChannel = do
+  val <- use csReturnChannel
+  case val of
+      Nothing -> return ()
+      Just _ -> do
+          mh $ invalidateCacheEntry ChannelSidebar
+          csReturnChannel .= Nothing
+
+gotoReturnChannel :: MH ()
+gotoReturnChannel = do
+  ret <- use csReturnChannel
+  case ret of
+    Nothing  -> return ()
+    Just cId -> do
+        resetReturnChannel
+        setFocus cId
+
+setReturnChannel :: MH ()
+setReturnChannel = do
+  ret <- use csReturnChannel
+  case ret of
+    Nothing  -> do
+        cId <- use csCurrentChannelId
+        csReturnChannel .= Just cId
+        mh $ invalidateCacheEntry ChannelSidebar
+    Just _ -> return ()
+
+nextUnreadChannel :: MH ()
+nextUnreadChannel = do
+    st <- use id
+    setReturnChannel
+    setFocusWith True (getNextUnreadChannel st) gotoReturnChannel
+
+nextUnreadUserOrChannel :: MH ()
+nextUnreadUserOrChannel = do
+    st <- use id
+    setReturnChannel
+    setFocusWith True (getNextUnreadUserOrChannel st) gotoReturnChannel
+
+leaveChannel :: ChannelId -> MH ()
+leaveChannel cId = leaveChannelIfPossible cId False
+
+leaveChannelIfPossible :: ChannelId -> Bool -> MH ()
+leaveChannelIfPossible cId delete = do
+    st <- use id
+    me <- gets myUser
+    let isMe u = u^.userIdL == me^.userIdL
+
+    case st ^? csChannel(cId).ccInfo of
+        Nothing -> return ()
+        Just cInfo -> case canLeaveChannel cInfo of
+            False -> return ()
+            True ->
+                -- The server will reject an attempt to leave a private
+                -- channel if we're the only member. To check this, we
+                -- just ask for the first two members of the channel.
+                -- If there is only one, it must be us: hence the "all
+                -- isMe" check below. If there are two members, it
+                -- doesn't matter who they are, because we just know
+                -- that we aren't the only remaining member, so we can't
+                -- delete the channel.
+                doAsyncChannelMM Preempt cId
+                    (\s _ ->
+                      let query = MM.defaultUserQuery
+                           { MM.userQueryPage = Just 0
+                           , MM.userQueryPerPage = Just 2
+                           , MM.userQueryInChannel = Just cId
+                           }
+                      in toList <$> MM.mmGetUsers query s)
+                    (\_ members -> Just $ do
+                        -- If the channel is private:
+                        --  * leave it if we aren't the last member.
+                        --  * delete it if we are.
+                        --
+                        -- Otherwise:
+                        --  * leave (or delete) the channel as specified
+                        --    by the delete argument.
+                        let func = case cInfo^.cdType of
+                                Private -> case all isMe members of
+                                    True -> (\ s c -> MM.mmDeleteChannel c s)
+                                    False -> (\ s c -> MM.mmRemoveUserFromChannel c UserMe s)
+                                Group ->
+                                    \s _ ->
+                                        let pref = hideGroupChannelPref cId (me^.userIdL)
+                                        in MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) s
+                                _ -> if delete
+                                     then (\ s c -> MM.mmDeleteChannel c s)
+                                     else (\ s c -> MM.mmRemoveUserFromChannel c UserMe s)
+
+                        doAsyncChannelMM Preempt cId func endAsyncNOP
+                    )
+
+getNextUnreadChannel :: ChatState
+                     -> (Zipper a ChannelListEntry -> Zipper a ChannelListEntry)
+getNextUnreadChannel st =
+    -- The next channel with unread messages must also be a channel
+    -- other than the current one, since the zipper may be on a channel
+    -- that has unread messages and will stay that way until we leave
+    -- it- so we need to skip that channel when doing the zipper search
+    -- for the next candidate channel.
+    Z.findRight (\e ->
+                let cId = channelListEntryChannelId e
+                in hasUnread st cId && (cId /= st^.csCurrentChannelId))
+
+getNextUnreadUserOrChannel :: ChatState
+                           -> Zipper a ChannelListEntry
+                           -> Zipper a ChannelListEntry
+getNextUnreadUserOrChannel st z =
+    -- Find the next unread channel, prefering direct messages
+    let cur = st^.csCurrentChannelId
+        matches e = entryIsDMEntry e && isFresh (channelListEntryChannelId e)
+        isFresh c = hasUnread st c && (c /= cur)
+    in fromMaybe (Z.findRight (isFresh . channelListEntryChannelId) z)
+                 (Z.maybeFindRight matches z)
+
+leaveCurrentChannel :: MH ()
+leaveCurrentChannel = use csCurrentChannelId >>= leaveChannel
+
+createGroupChannel :: Text -> MH ()
+createGroupChannel usernameList = do
+    me <- gets myUser
+    session <- getSession
+    cs <- use csChannels
+
+    doAsyncWith Preempt $ do
+        let usernames = Seq.fromList $ fmap trimUserSigil $ T.words usernameList
+        results <- MM.mmGetUsersByUsernames usernames session
+
+        -- If we found all of the users mentioned, then create the group
+        -- channel.
+        case length results == length usernames of
+            True -> do
+                chan <- MM.mmCreateGroupMessageChannel (userId <$> results) session
+                return $ Just $ do
+                    case findChannelById (channelId chan) cs of
+                      Just _ ->
+                          -- If we already know about the channel ID,
+                          -- that means the channel already exists so
+                          -- we can just switch to it.
+                          setFocus (channelId chan)
+                      Nothing -> do
+                          csPendingChannelChange .= (Just $ ChangeByChannelId (channelId chan) Nothing)
+                          let pref = showGroupChannelPref (channelId chan) (me^.userIdL)
+                          doAsyncWith Normal $ do
+                            MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session
+                            return $ Just $ applyPreferenceChange pref
+            False -> do
+                let foundUsernames = userUsername <$> results
+                    missingUsernames = S.toList $
+                                       S.difference (S.fromList $ F.toList usernames)
+                                                    (S.fromList $ F.toList foundUsernames)
+                return $ Just $ do
+                    forM_ missingUsernames (mhError . NoSuchUser)
+
+channelHistoryForward :: MH ()
+channelHistoryForward = do
+    resetAutocomplete
+
+    cId <- use csCurrentChannelId
+    inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)
+    case inputHistoryPos of
+        Just i
+          | i == 0 -> do
+            -- Transition out of history navigation
+            csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing
+            loadLastChannelInput
+          | otherwise -> do
+            let newI = i - 1
+            loadHistoryEntryToEditor cId newI
+            csEditState.cedEphemeral.eesInputHistoryPosition .= (Just newI)
+        _ -> return ()
+
+loadHistoryEntryToEditor :: ChannelId -> Int -> MH ()
+loadHistoryEntryToEditor cId idx = do
+    inputHistory <- use (csEditState.cedInputHistory)
+    case getHistoryEntry cId idx inputHistory of
+        Nothing -> return ()
+        Just entry -> do
+            let eLines = T.lines entry
+                mv = if length eLines == 1 then gotoEOL else id
+            csEditState.cedEditor.editContentsL .= (mv $ textZipper eLines Nothing)
+
+channelHistoryBackward :: MH ()
+channelHistoryBackward = do
+    resetAutocomplete
+
+    cId <- use csCurrentChannelId
+    inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)
+    saveCurrentChannelInput
+
+    let newI = maybe 0 (+ 1) inputHistoryPos
+    loadHistoryEntryToEditor cId newI
+    csEditState.cedEphemeral.eesInputHistoryPosition .= (Just newI)
+
+createOrdinaryChannel :: Bool -> Text -> MH ()
+createOrdinaryChannel public name = do
+    session <- getSession
+    myTId <- gets myTeamId
+    doAsyncWith Preempt $ do
+        -- create a new chat channel
+        let slug = T.map (\ c -> if isAlphaNum c then c else '-') (T.toLower name)
+            minChannel = MinChannel
+              { minChannelName        = slug
+              , minChannelDisplayName = name
+              , minChannelPurpose     = Nothing
+              , minChannelHeader      = Nothing
+              , minChannelType        = if public then Ordinary else Private
+              , minChannelTeamId      = myTId
+              }
+        tryMM (do c <- MM.mmCreateChannel minChannel session
+                  chan <- MM.mmGetChannel (getId c) session
+                  member <- MM.mmGetChannelMember (getId c) UserMe session
+                  return (chan, member)
+              )
+              (return . Just . uncurry (handleNewChannel True SidebarUpdateImmediate))
+
+-- | When we are added to a channel not locally known about, we need
+-- to fetch the channel info for that channel.
+handleChannelInvite :: ChannelId -> MH ()
+handleChannelInvite cId = do
+    session <- getSession
+    doAsyncWith Normal $ do
+        member <- MM.mmGetChannelMember cId UserMe session
+        tryMM (MM.mmGetChannel cId session)
+              (\cwd -> return $ Just $ do
+                  pending <- checkPendingChannelChange cId
+                  handleNewChannel (isJust pending) SidebarUpdateImmediate cwd member)
+
+addUserByNameToCurrentChannel :: Text -> MH ()
+addUserByNameToCurrentChannel uname =
+    withFetchedUser (UserFetchByUsername uname) addUserToCurrentChannel
+
+addUserToCurrentChannel :: UserInfo -> MH ()
+addUserToCurrentChannel u = do
+    cId <- use csCurrentChannelId
+    session <- getSession
+    let channelMember = MinChannelMember (u^.uiId) cId
+    doAsyncWith Normal $ do
+        tryMM (void $ MM.mmAddUser cId channelMember session)
+              (const $ return Nothing)
+
+removeUserFromCurrentChannel :: Text -> MH ()
+removeUserFromCurrentChannel uname =
+    withFetchedUser (UserFetchByUsername uname) $ \u -> do
+        cId <- use csCurrentChannelId
+        session <- getSession
+        doAsyncWith Normal $ do
+            tryMM (void $ MM.mmRemoveUserFromChannel cId (UserById $ u^.uiId) session)
+                  (const $ return Nothing)
+
+startLeaveCurrentChannel :: MH ()
+startLeaveCurrentChannel = do
+    cInfo <- use (csCurrentChannel.ccInfo)
+    case cInfo^.cdType of
+        Direct -> hideDMChannel (cInfo^.cdChannelId)
+        Group -> hideDMChannel (cInfo^.cdChannelId)
+        _ -> setMode LeaveChannelConfirm
+
+deleteCurrentChannel :: MH ()
+deleteCurrentChannel = do
+    setMode Main
+    cId <- use csCurrentChannelId
+    leaveChannelIfPossible cId True
+
+isCurrentChannel :: ChatState -> ChannelId -> Bool
+isCurrentChannel st cId = st^.csCurrentChannelId == cId
+
+isRecentChannel :: ChatState -> ChannelId -> Bool
+isRecentChannel st cId = st^.csRecentChannel == Just cId
+
+isReturnChannel :: ChatState -> ChannelId -> Bool
+isReturnChannel st cId = st^.csReturnChannel == Just cId
+
+joinChannelByName :: Text -> MH ()
+joinChannelByName rawName = do
+    session <- getSession
+    tId <- gets myTeamId
+    doAsyncWith Preempt $ do
+        result <- try $ MM.mmGetChannelByName tId (trimChannelSigil rawName) session
+        return $ Just $ case result of
+            Left (_::SomeException) -> mhError $ NoSuchChannel rawName
+            Right chan -> joinChannel $ getId chan
+
+-- | If the user is not a member of the specified channel, submit a
+-- request to join it. Otherwise switch to the channel.
+joinChannel :: ChannelId -> MH ()
+joinChannel chanId = joinChannel' chanId Nothing
+
+joinChannel' :: ChannelId -> Maybe (MH ()) -> MH ()
+joinChannel' chanId act = do
+    setMode Main
+    mChan <- preuse (csChannel(chanId))
+    case mChan of
+        Just _ -> do
+            setFocus chanId
+            fromMaybe (return ()) act
+        Nothing -> do
+            myId <- gets myUserId
+            let member = MinChannelMember myId chanId
+            csPendingChannelChange .= (Just $ ChangeByChannelId chanId act)
+            doAsyncChannelMM Preempt chanId (\ s c -> MM.mmAddUser c member s) (const $ return act)
+
+createOrFocusDMChannel :: UserInfo -> Maybe (ChannelId -> MH ()) -> MH ()
+createOrFocusDMChannel user successAct = do
+    cs <- use csChannels
+    case getDmChannelFor (user^.uiId) cs of
+        Just cId -> do
+            setFocus cId
+            case successAct of
+                Nothing -> return ()
+                Just act -> act cId
+        Nothing -> do
+            -- We have a user of that name but no channel. Time to make one!
+            myId <- gets myUserId
+            session <- getSession
+            csPendingChannelChange .= (Just $ ChangeByUserId $ user^.uiId)
+            doAsyncWith Normal $ do
+                -- create a new channel
+                chan <- MM.mmCreateDirectMessageChannel (user^.uiId, myId) session
+                return $ successAct <*> pure (channelId chan)
+
+-- | This switches to the named channel or creates it if it is a missing
+-- but valid user channel.
+changeChannelByName :: Text -> MH ()
+changeChannelByName name = do
+    mCId <- gets (channelIdByChannelName name)
+    mDMCId <- gets (channelIdByUsername name)
+
+    withFetchedUserMaybe (UserFetchByUsername name) $ \foundUser -> do
+        let err = mhError $ AmbiguousName name
+        case (mCId, mDMCId) of
+          (Nothing, Nothing) ->
+              case foundUser of
+                  -- We know about the user but there isn't already a DM
+                  -- channel, so create one.
+                  Just user -> createOrFocusDMChannel user Nothing
+                  -- There were no matches of any kind.
+                  Nothing -> mhError $ NoSuchChannel name
+          (Just cId, Nothing)
+              -- We matched a channel and there was an explicit sigil, so we
+              -- don't care about the username match.
+              | normalChannelSigil `T.isPrefixOf` name -> setFocus cId
+              -- We matched both a channel and a user, even though there is
+              -- no DM channel.
+              | Just _ <- foundUser -> err
+              -- We matched a channel only.
+              | otherwise -> setFocus cId
+          (Nothing, Just cId) ->
+              -- We matched a DM channel only.
+              setFocus cId
+          (Just _, Just _) ->
+              -- We matched both a channel and a DM channel.
+              err
+
+setChannelTopic :: Text -> MH ()
+setChannelTopic msg = do
+    cId <- use csCurrentChannelId
+    let patch = defaultChannelPatch { channelPatchHeader = Just msg }
+    doAsyncChannelMM Preempt cId
+        (\s _ -> MM.mmPatchChannel cId patch s)
+        (\_ _ -> Nothing)
+
+getCurrentChannelTopic :: MH Text
+getCurrentChannelTopic = do
+    ch <- use csCurrentChannel
+    return $ ch^.ccInfo.cdHeader
+
+beginCurrentChannelDeleteConfirm :: MH ()
+beginCurrentChannelDeleteConfirm = do
+    cId <- use csCurrentChannelId
+    withChannel cId $ \chan -> do
+        let chType = chan^.ccInfo.cdType
+        if chType /= Direct
+            then setMode DeleteChannelConfirm
+            else mhError $ GenericError "Direct message channels cannot be deleted."
+
+updateChannelNotifyProps :: ChannelId -> ChannelNotifyProps -> MH ()
+updateChannelNotifyProps cId notifyProps = do
+    mh $ invalidateCacheEntry ChannelSidebar
+    csChannel(cId).ccInfo.cdNotifyProps .= notifyProps
diff --git a/src/Matterhorn/State/Common.hs b/src/Matterhorn/State/Common.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Common.hs
@@ -0,0 +1,374 @@
+module Matterhorn.State.Common
+  (
+  -- * System interface
+    openFilePath
+  , openWithOpener
+  , runLoggedCommand
+  , prepareAttachment
+
+  -- * Posts
+  , installMessagesFromPosts
+  , updatePostMap
+
+  -- * Utilities
+  , postInfoMessage
+  , postErrorMessageIO
+  , postErrorMessage'
+  , addEmoteFormatting
+  , removeEmoteFormatting
+
+  , fetchMentionedUsers
+  , doPendingUserFetches
+  , doPendingUserStatusFetches
+
+  , module Matterhorn.State.Async
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Main ( invalidateCacheEntry )
+import           Control.Concurrent ( MVar, putMVar, forkIO )
+import           Control.Concurrent.Async ( concurrently )
+import qualified Control.Concurrent.STM as STM
+import           Control.Exception ( SomeException, try )
+import qualified Data.ByteString as BS
+import qualified Data.HashMap.Strict as HM
+import qualified Data.Sequence as Seq
+import qualified Data.Set as Set
+import qualified Data.Text as T
+import           Lens.Micro.Platform ( (.=), (%=), (%~), (.~) )
+import           System.Directory ( createDirectoryIfMissing )
+import           System.Environment.XDG.BaseDir ( getUserCacheDir )
+import           System.Exit ( ExitCode(..) )
+import           System.FilePath
+import           System.IO ( hGetContents, hFlush, hPutStrLn )
+import           System.Process ( proc, std_in, std_out, std_err, StdStream(..)
+                                , createProcess, waitForProcess )
+
+import           Network.Mattermost.Endpoints
+import           Network.Mattermost.Lenses
+import           Network.Mattermost.Types
+
+import           Matterhorn.FilePaths ( xdgName )
+import           Matterhorn.State.Async
+import           Matterhorn.Types
+import           Matterhorn.Types.Common
+
+
+-- * Client Messages
+
+-- | Given a collection of posts from the server, save the posts in the
+-- global post map. Also convert the posts to Matterhorn's Message type
+-- and return them along with the set of all usernames mentioned in the
+-- text of the resulting messages.
+--
+-- This also sets the mFlagged field of each message based on whether
+-- its post ID is a flagged post according to crFlaggedPosts at the time
+-- of this call.
+installMessagesFromPosts :: Posts -> MH Messages
+installMessagesFromPosts postCollection = do
+  flags <- use (csResources.crFlaggedPosts)
+
+  -- Add all posts in this collection to the global post cache
+  updatePostMap postCollection
+
+  baseUrl <- getServerBaseUrl
+
+  -- Build the ordered list of posts. Note that postsOrder lists the
+  -- posts most recent first, but we want most recent last.
+  let postsInOrder = findPost <$> (Seq.reverse $ postsOrder postCollection)
+      mkClientPost p = toClientPost baseUrl p (postId <$> parent p)
+      clientPosts = mkClientPost <$> postsInOrder
+
+      addNext cp (msgs, us) =
+          let (msg, mUsernames) = clientPostToMessage cp
+          in (addMessage (maybeFlag flags msg) msgs, Set.union us mUsernames)
+      (ms, mentions) = foldr addNext (noMessages, mempty) clientPosts
+
+  fetchMentionedUsers mentions
+  return ms
+    where
+        maybeFlag flagSet msg
+          | Just (MessagePostId pId) <- msg^.mMessageId, pId `Set.member` flagSet
+            = msg & mFlagged .~ True
+          | otherwise = msg
+        parent x = do
+            parentId <- x^.postRootIdL
+            HM.lookup parentId (postCollection^.postsPostsL)
+        findPost pId = case HM.lookup pId (postsPosts postCollection) of
+            Nothing -> error $ "BUG: could not find post for post ID " <> show pId
+            Just post -> post
+
+-- Add all posts in this collection to the global post cache
+updatePostMap :: Posts -> MH ()
+updatePostMap postCollection = do
+  -- Build a map from post ID to Matterhorn message, then add the new
+  -- messages to the global post map. We use the "postsPosts" field for
+  -- this because that might contain more messages than the "postsOrder"
+  -- list, since the former can contain other messages in threads that
+  -- the server sent us, even if those messages are not part of the
+  -- ordered post listing of "postsOrder."
+  baseUrl <- getServerBaseUrl
+  let postMap = HM.fromList
+          [ ( pId
+            , fst $ clientPostToMessage (toClientPost baseUrl x Nothing)
+            )
+          | (pId, x) <- HM.toList (postCollection^.postsPostsL)
+          ]
+  csPostMap %= HM.union postMap
+
+-- | Add a 'ClientMessage' to the current channel's message list
+addClientMessage :: ClientMessage -> MH ()
+addClientMessage msg = do
+  cid <- use csCurrentChannelId
+  uuid <- generateUUID
+  let addCMsg = ccContents.cdMessages %~
+          (addMessage $ clientMessageToMessage msg & mMessageId .~ Just (MessageUUID uuid))
+  csChannels %= modifyChannelById cid addCMsg
+  mh $ invalidateCacheEntry $ ChannelMessages cid
+  mh $ invalidateCacheEntry ChannelSidebar
+
+  let msgTy = case msg^.cmType of
+        Error -> LogError
+        _     -> LogGeneral
+
+  mhLog msgTy $ T.pack $ show msg
+
+-- | Add a new 'ClientMessage' representing an error message to
+--   the current channel's message list
+postInfoMessage :: Text -> MH ()
+postInfoMessage info =
+    addClientMessage =<< newClientMessage Informative (sanitizeUserText' info)
+
+-- | Add a new 'ClientMessage' representing an error message to
+--   the current channel's message list
+postErrorMessage' :: Text -> MH ()
+postErrorMessage' err =
+    addClientMessage =<< newClientMessage Error (sanitizeUserText' err)
+
+postErrorMessageIO :: Text -> ChatState -> IO ChatState
+postErrorMessageIO err st = do
+  msg <- newClientMessage Error err
+  uuid <- generateUUID_IO
+  let cId = st ^. csCurrentChannelId
+      addEMsg = ccContents.cdMessages %~
+          (addMessage $ clientMessageToMessage msg & mMessageId .~ Just (MessageUUID uuid))
+  return $ st & csChannels %~ modifyChannelById cId addEMsg
+
+openFilePath :: FilePath -> MH Bool
+openFilePath path = openWithOpener (return path)
+
+openWithOpener :: MH String -> MH Bool
+openWithOpener getTarget = do
+    cfg <- use (csResources.crConfiguration)
+    case configURLOpenCommand cfg of
+        Nothing ->
+            return False
+        Just urlOpenCommand -> do
+            target <- getTarget
+
+            -- Is the URL-opening command interactive? If so, pause
+            -- Matterhorn and run the opener interactively. Otherwise
+            -- run the opener asynchronously and continue running
+            -- Matterhorn interactively.
+            case configURLOpenCommandInteractive cfg of
+                False -> do
+                    outputChan <- use (csResources.crSubprocessLog)
+                    doAsyncWith Preempt $ do
+                        runLoggedCommand outputChan (T.unpack urlOpenCommand)
+                                         [target] Nothing Nothing
+                        return Nothing
+                True -> do
+                    -- If there isn't a new message cutoff showing in
+                    -- the current channel, set one. This way, while the
+                    -- user is gone using their interactive URL opener,
+                    -- when they return, any messages that arrive in the
+                    -- current channel will be displayed as new.
+                    curChan <- use csCurrentChannel
+                    let msgs = curChan^.ccContents.cdMessages
+                    case findLatestUserMessage isEditable msgs of
+                        Nothing -> return ()
+                        Just m ->
+                            case m^.mOriginalPost of
+                                Nothing -> return ()
+                                Just p ->
+                                    case curChan^.ccInfo.cdNewMessageIndicator of
+                                        Hide ->
+                                            csCurrentChannel.ccInfo.cdNewMessageIndicator .= (NewPostsAfterServerTime (p^.postCreateAtL))
+                                        _ -> return ()
+                    -- No need to add a gap here: the websocket
+                    -- disconnect/reconnect events will automatically
+                    -- handle management of messages delivered while
+                    -- suspended.
+
+                    mhSuspendAndResume $ \st -> do
+                        result <- runInteractiveCommand (T.unpack urlOpenCommand) [target]
+
+                        let waitForKeypress = do
+                                putStrLn "Press any key to return to Matterhorn."
+                                void getChar
+
+                        case result of
+                            Right ExitSuccess -> return ()
+                            Left err -> do
+                                putStrLn $ "URL opener subprocess " <> (show urlOpenCommand) <>
+                                           " could not be run: " <> err
+                                waitForKeypress
+                            Right (ExitFailure code) -> do
+                                putStrLn $ "URL opener subprocess " <> (show urlOpenCommand) <>
+                                           " exited with non-zero status " <> show code
+                                waitForKeypress
+
+                        return $ setMode' Main st
+
+            return True
+
+runInteractiveCommand :: String
+                      -> [String]
+                      -> IO (Either String ExitCode)
+runInteractiveCommand cmd args = do
+    let opener = (proc cmd args) { std_in = Inherit
+                                 , std_out = Inherit
+                                 , std_err = Inherit
+                                 }
+    result <- try $ createProcess opener
+    case result of
+        Left (e::SomeException) -> return $ Left $ show e
+        Right (_, _, _, ph) -> do
+            ec <- waitForProcess ph
+            return $ Right ec
+
+runLoggedCommand :: STM.TChan ProgramOutput
+                 -- ^ The output channel to send the output to
+                 -> String
+                 -- ^ The program name
+                 -> [String]
+                 -- ^ Arguments
+                 -> Maybe String
+                 -- ^ The stdin to send, if any
+                 -> Maybe (MVar ProgramOutput)
+                 -- ^ Where to put the program output when it is ready
+                 -> IO ()
+runLoggedCommand outputChan cmd args mInput mOutputVar = void $ forkIO $ do
+    let stdIn = maybe NoStream (const CreatePipe) mInput
+        opener = (proc cmd args) { std_in = stdIn
+                                 , std_out = CreatePipe
+                                 , std_err = CreatePipe
+                                 }
+    result <- try $ createProcess opener
+    case result of
+        Left (e::SomeException) -> do
+            let po = ProgramOutput cmd args "" (show e) (ExitFailure 1)
+            STM.atomically $ STM.writeTChan outputChan po
+            maybe (return ()) (flip putMVar po) mOutputVar
+        Right (stdinResult, Just outh, Just errh, ph) -> do
+            case stdinResult of
+                Just inh -> do
+                    let Just input = mInput
+                    hPutStrLn inh input
+                    hFlush inh
+                Nothing -> return ()
+
+            ec <- waitForProcess ph
+            outResult <- hGetContents outh
+            errResult <- hGetContents errh
+            let po = ProgramOutput cmd args outResult errResult ec
+            STM.atomically $ STM.writeTChan outputChan po
+            maybe (return ()) (flip putMVar po) mOutputVar
+        Right _ ->
+            error $ "BUG: createProcess returned unexpected result, report this at " <>
+                    "https://github.com/matterhorn-chat/matterhorn"
+
+prepareAttachment :: FileId -> Session -> IO String
+prepareAttachment fId sess = do
+    -- The link is for an attachment, so fetch it and then
+    -- open the local copy.
+
+    (info, contents) <- concurrently (mmGetMetadataForFile fId sess) (mmGetFile fId sess)
+    cacheDir <- getUserCacheDir xdgName
+
+    let dir   = cacheDir </> "files" </> T.unpack (idString fId)
+        fname = dir </> T.unpack (fileInfoName info)
+
+    createDirectoryIfMissing True dir
+    BS.writeFile fname contents
+    return fname
+
+removeEmoteFormatting :: T.Text -> T.Text
+removeEmoteFormatting t
+    | "*" `T.isPrefixOf` t &&
+      "*" `T.isSuffixOf` t = T.init $ T.drop 1 t
+    | otherwise = t
+
+addEmoteFormatting :: T.Text -> T.Text
+addEmoteFormatting t = "*" <> t <> "*"
+
+fetchMentionedUsers :: Set.Set MentionedUser -> MH ()
+fetchMentionedUsers ms
+    | Set.null ms = return ()
+    | otherwise = do
+        let convertMention (UsernameMention u) = UserFetchByUsername u
+            convertMention (UserIdMention i) = UserFetchById i
+        scheduleUserFetches $ convertMention <$> Set.toList ms
+
+doPendingUserStatusFetches :: MH ()
+doPendingUserStatusFetches = do
+    mz <- getScheduledUserStatusFetches
+    case mz of
+        Nothing -> return ()
+        Just z -> do
+            statusChan <- use (csResources.crStatusUpdateChan)
+            liftIO $ STM.atomically $ STM.writeTChan statusChan z
+
+doPendingUserFetches :: MH ()
+doPendingUserFetches = do
+    fs <- getScheduledUserFetches
+
+    let getUsername (UserFetchByUsername u) = Just u
+        getUsername _ = Nothing
+
+        getUserId (UserFetchById i) = Just i
+        getUserId _ = Nothing
+
+    fetchUsers (catMaybes $ getUsername <$> fs) (catMaybes $ getUserId <$> fs)
+
+-- | Given a list of usernames, ensure that we have a user record for
+-- each one in the state, either by confirming that a local record
+-- exists or by issuing a request for user records.
+fetchUsers :: [Text] -> [UserId] -> MH ()
+fetchUsers rawUsernames uids = do
+    st <- use id
+    session <- getSession
+    let usernames = trimUserSigil <$> rawUsernames
+        missingUsernames = filter isMissing usernames
+        isMissing n = and [ not $ T.null n
+                          , not $ isSpecialMention n
+                          , isNothing $ userByUsername n st
+                          ]
+        missingIds = filter (\i -> isNothing $ userById i st) uids
+
+    when (not $ null missingUsernames) $ do
+        mhLog LogGeneral $ T.pack $ "fetchUsers: getting " <> show missingUsernames
+
+    when (not $ null missingIds) $ do
+        mhLog LogGeneral $ T.pack $ "fetchUsers: getting " <> show missingIds
+
+    when ((not $ null missingUsernames) || (not $ null missingIds)) $ do
+        doAsyncWith Normal $ do
+            act1 <- case null missingUsernames of
+                True -> return $ return ()
+                False -> do
+                    results <- mmGetUsersByUsernames (Seq.fromList missingUsernames) session
+                    return $ do
+                        forM_ results (\u -> addNewUser $ userInfoFromUser u True)
+
+            act2 <- case null missingIds of
+                True -> return $ return ()
+                False -> do
+                    results <- mmGetUsersByIds (Seq.fromList missingIds) session
+                    return $ do
+                        forM_ results (\u -> addNewUser $ userInfoFromUser u True)
+
+            return $ Just $ act1 >> act2
diff --git a/src/Matterhorn/State/Editing.hs b/src/Matterhorn/State/Editing.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Editing.hs
@@ -0,0 +1,525 @@
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE RankNTypes #-}
+module Matterhorn.State.Editing
+  ( requestSpellCheck
+  , editingKeybindings
+  , editingKeyHandlers
+  , messageEditingKeybindings
+  , toggleMultilineEditing
+  , invokeExternalEditor
+  , handlePaste
+  , handleInputSubmission
+  , getEditorContent
+  , handleEditingInput
+  , cancelAutocompleteOrReplyOrEdit
+  , replyToLatestMessage
+  , Direction(..)
+  , tabComplete
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Main ( invalidateCache, invalidateCacheEntry )
+import           Brick.Widgets.Edit ( Editor, applyEdit , handleEditorEvent
+                                    , getEditContents, editContentsL )
+import qualified Brick.Widgets.List as L
+import qualified Codec.Binary.UTF8.Generic as UTF8
+import           Control.Arrow
+import qualified Control.Concurrent.STM as STM
+import qualified Data.ByteString as BS
+import           Data.Char ( isSpace )
+import qualified Data.Foldable as F
+import qualified Data.Map as M
+import           Data.Maybe ( fromJust )
+import qualified Data.Set as S
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+import qualified Data.Text.Zipper as Z
+import qualified Data.Text.Zipper.Generic.Words as Z
+import           Data.Time ( getCurrentTime )
+import           Graphics.Vty ( Event(..), Key(..) )
+import           Lens.Micro.Platform ( Lens', (%=), (.=), (.~), to, _Just )
+import qualified System.Environment as Sys
+import qualified System.Exit as Sys
+import qualified System.IO as Sys
+import qualified System.IO.Temp as Sys
+import qualified System.Process as Sys
+import           Text.Aspell ( AspellResponse(..), mistakeWord, askAspell )
+
+import           Network.Mattermost.Types ( Post(..), ChannelId )
+
+import           Matterhorn.Config
+import {-# SOURCE #-} Matterhorn.Command ( dispatchCommand )
+import           Matterhorn.InputHistory
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.State.Common
+import           Matterhorn.State.Autocomplete
+import           Matterhorn.State.Attachments
+import           Matterhorn.State.Messages
+import           Matterhorn.Types hiding ( newState )
+import           Matterhorn.Types.Common ( sanitizeUserText' )
+
+
+startMultilineEditing :: MH ()
+startMultilineEditing = do
+    mh invalidateCache
+    csEditState.cedEphemeral.eesMultiline .= True
+
+toggleMultilineEditing :: MH ()
+toggleMultilineEditing = do
+    mh invalidateCache
+    csEditState.cedEphemeral.eesMultiline %= not
+
+    -- If multiline is now disabled and there is more than one line in
+    -- the editor, that means we're showing the multiline message status
+    -- (see Draw.Main.renderUserCommandBox.commandBox) so we want to be
+    -- sure no autocomplete UI is present in case the cursor was left on
+    -- a word that would otherwise show completion alternatives.
+    multiline <- use (csEditState.cedEphemeral.eesMultiline)
+    numLines <- use (csEditState.cedEditor.to getEditContents.to length)
+    when (not multiline && numLines > 1) resetAutocomplete
+
+invokeExternalEditor :: MH ()
+invokeExternalEditor = do
+    -- If EDITOR is in the environment, write the current message to a
+    -- temp file, invoke EDITOR on it, read the result, remove the temp
+    -- file, and update the program state.
+    --
+    -- If EDITOR is not present, fall back to 'vi'.
+    mEnv <- liftIO $ Sys.lookupEnv "EDITOR"
+    let editorProgram = maybe "vi" id mEnv
+
+    mhSuspendAndResume $ \ st -> do
+      Sys.withSystemTempFile "matterhorn_editor.md" $ \tmpFileName tmpFileHandle -> do
+        -- Write the current message to the temp file
+        Sys.hPutStr tmpFileHandle $ T.unpack $ T.intercalate "\n" $
+            getEditContents $ st^.csEditState.cedEditor
+        Sys.hClose tmpFileHandle
+
+        -- Run the editor
+        status <- Sys.system (editorProgram <> " " <> tmpFileName)
+
+        -- On editor exit, if exited with zero status, read temp file.
+        -- If non-zero status, skip temp file read.
+        case status of
+            Sys.ExitSuccess -> do
+                tmpBytes <- BS.readFile tmpFileName
+                case T.decodeUtf8' tmpBytes of
+                    Left _ -> do
+                        postErrorMessageIO "Failed to decode file contents as UTF-8" st
+                    Right t -> do
+                        let tmpLines = T.lines $ sanitizeUserText' t
+                        return $ st & csEditState.cedEditor.editContentsL .~ (Z.textZipper tmpLines Nothing)
+            Sys.ExitFailure _ -> return st
+
+handlePaste :: BS.ByteString -> MH ()
+handlePaste bytes = do
+  let pasteStr = T.pack (UTF8.toString bytes)
+  csEditState.cedEditor %= applyEdit (Z.insertMany (sanitizeUserText' pasteStr))
+  contents <- use (csEditState.cedEditor.to getEditContents)
+  case length contents > 1 of
+      True -> startMultilineEditing
+      False -> return ()
+
+editingPermitted :: ChatState -> Bool
+editingPermitted st =
+    (length (getEditContents $ st^.csEditState.cedEditor) == 1) ||
+    st^.csEditState.cedEphemeral.eesMultiline
+
+messageEditingKeybindings :: KeyConfig -> KeyHandlerMap
+messageEditingKeybindings kc =
+    let KeyHandlerMap m = editingKeybindings (csEditState.cedEditor) kc
+    in KeyHandlerMap $ M.map withUserTypingAction m
+
+withUserTypingAction :: KeyHandler -> KeyHandler
+withUserTypingAction kh =
+    kh { khHandler = newH }
+    where
+        oldH = khHandler kh
+        newH = oldH { kehHandler = newKEH }
+        oldKEH = kehHandler oldH
+        newKEH = oldKEH { ehAction = ehAction oldKEH >> sendUserTypingAction }
+
+editingKeybindings :: Lens' ChatState (Editor T.Text Name) -> KeyConfig -> KeyHandlerMap
+editingKeybindings editor = mkKeybindings $ editingKeyHandlers editor
+
+editingKeyHandlers :: Lens' ChatState (Editor T.Text Name) -> [KeyEventHandler]
+editingKeyHandlers editor =
+  [ mkKb EditorTransposeCharsEvent
+    "Transpose the final two characters"
+    (editor %= applyEdit Z.transposeChars)
+  , mkKb EditorBolEvent
+    "Go to the start of the current line"
+    (editor %= applyEdit Z.gotoBOL)
+  , mkKb EditorEolEvent
+    "Go to the end of the current line"
+    (editor %= applyEdit Z.gotoEOL)
+  , mkKb EditorDeleteCharacter
+    "Delete the character at the cursor"
+    (editor %= applyEdit Z.deleteChar)
+  , mkKb EditorKillToBolEvent
+    "Delete from the cursor to the start of the current line"
+    (editor %= applyEdit Z.killToBOL)
+  , mkKb EditorKillToEolEvent
+    "Kill the line to the right of the current position and copy it" $ do
+      z <- use (editor.editContentsL)
+      let restOfLine = Z.currentLine (Z.killToBOL z)
+      csEditState.cedYankBuffer .= restOfLine
+      editor %= applyEdit Z.killToEOL
+  , mkKb EditorNextCharEvent
+    "Move one character to the right"
+    (editor %= applyEdit Z.moveRight)
+  , mkKb EditorPrevCharEvent
+    "Move one character to the left"
+    (editor %= applyEdit Z.moveLeft)
+  , mkKb EditorNextWordEvent
+    "Move one word to the right"
+    (editor %= applyEdit Z.moveWordRight)
+  , mkKb EditorPrevWordEvent
+    "Move one word to the left"
+    (editor %= applyEdit Z.moveWordLeft)
+  , mkKb EditorDeletePrevWordEvent
+    "Delete the word to the left of the cursor" $ do
+    editor %= applyEdit Z.deletePrevWord
+  , mkKb EditorDeleteNextWordEvent
+    "Delete the word to the right of the cursor" $ do
+    editor %= applyEdit Z.deleteWord
+  , mkKb EditorHomeEvent
+    "Move the cursor to the beginning of the input" $ do
+    editor %= applyEdit gotoHome
+  , mkKb EditorEndEvent
+    "Move the cursor to the end of the input" $ do
+    editor %= applyEdit gotoEnd
+  , mkKb EditorYankEvent
+    "Paste the current buffer contents at the cursor" $ do
+      buf <- use (csEditState.cedYankBuffer)
+      editor %= applyEdit (Z.insertMany buf)
+  ]
+
+getEditorContent :: MH Text
+getEditorContent = do
+    cmdLine <- use (csEditState.cedEditor)
+    let (line:rest) = getEditContents cmdLine
+    return $ T.intercalate "\n" $ line : rest
+
+-- | Handle an input submission in the message editor.
+--
+-- This handles the specified input text as if it were user input for
+-- the specified channel. This means that if the specified input text
+-- contains a command ("/...") then it is executed as normal. Otherwise
+-- the text is sent as a message to the specified channel.
+--
+-- However, this function assumes that the message editor is the
+-- *source* of the text, so it also takes care of clearing the editor,
+-- resetting the edit mode, updating the input history for the specified
+-- channel, etc.
+handleInputSubmission :: ChannelId -> Text -> MH ()
+handleInputSubmission cId content = do
+    -- We clean up before dispatching the command or sending the message
+    -- since otherwise the command could change the state and then doing
+    -- cleanup afterwards could clean up the wrong things.
+    csEditState.cedEditor %= applyEdit Z.clearZipper
+    csEditState.cedInputHistory %= addHistoryEntry content cId
+    csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing
+
+    case T.uncons content of
+      Just ('/', cmd) ->
+          dispatchCommand cmd
+      _ -> do
+          attachments <- use (csEditState.cedAttachmentList.L.listElementsL)
+          mode <- use (csEditState.cedEditMode)
+          sendMessage cId mode content $ F.toList attachments
+
+    -- Reset the autocomplete UI
+    resetAutocomplete
+
+    -- Empty the attachment list
+    resetAttachmentList
+
+    -- Reset the edit mode *after* handling the input so that the input
+    -- handler can tell whether we're editing, replying, etc.
+    csEditState.cedEditMode .= NewPost
+
+closingPunctuationMarks :: String
+closingPunctuationMarks = ".,'\";:)]!?"
+
+isSmartClosingPunctuation :: Event -> Bool
+isSmartClosingPunctuation (EvKey (KChar c) []) = c `elem` closingPunctuationMarks
+isSmartClosingPunctuation _ = False
+
+handleEditingInput :: Event -> MH ()
+handleEditingInput e = do
+    -- Only handle input events to the editor if we permit editing:
+    -- if multiline mode is off, or if there is only one line of text
+    -- in the editor. This means we skip input this catch-all handler
+    -- if we're *not* in multiline mode *and* there are multiple lines,
+    -- i.e., we are showing the user the status message about the
+    -- current editor state and editing is not permitted.
+
+    -- Record the input line count before handling the editing event
+    -- so we can tell whether the editing event changes the line count
+    -- later.
+    beforeLineCount <- use (csEditState.cedEditor.to getEditContents.to length)
+
+    smartBacktick <- use (csResources.crConfiguration.to configSmartBacktick)
+    let smartChars = "*`_"
+    st <- use id
+    csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing
+
+    smartEditing <- use (csResources.crConfiguration.to configSmartEditing)
+    justCompleted <- use (csEditState.cedJustCompleted)
+
+    conf <- use (csResources.crConfiguration)
+    let keyMap = editingKeybindings (csEditState.cedEditor) (configUserKeys conf)
+    case lookupKeybinding e keyMap of
+      Just kb | editingPermitted st -> (ehAction $ kehHandler $ khHandler kb)
+      _ -> do
+        case e of
+          -- Not editing; backspace here means cancel multi-line message
+          -- composition
+          EvKey KBS [] | (not $ editingPermitted st) ->
+            csEditState.cedEditor %= applyEdit Z.clearZipper
+
+          -- Backspace in editing mode with smart pair insertion means
+          -- smart pair removal when possible
+          EvKey KBS [] | editingPermitted st && smartBacktick ->
+              let backspace = csEditState.cedEditor %= applyEdit Z.deletePrevChar
+              in case cursorAtOneOf smartChars (st^.csEditState.cedEditor) of
+                  Nothing -> backspace
+                  Just ch ->
+                      -- Smart char removal:
+                      if | (cursorAtChar ch $ applyEdit Z.moveLeft $ st^.csEditState.cedEditor) &&
+                           (cursorIsAtEnd $ applyEdit Z.moveRight $ st^.csEditState.cedEditor) ->
+                             csEditState.cedEditor %= applyEdit (Z.deleteChar >>> Z.deletePrevChar)
+                         | otherwise -> backspace
+
+          EvKey (KChar ch) []
+            | editingPermitted st && smartBacktick && ch `elem` smartChars ->
+              -- Smart char insertion:
+              let doInsertChar = do
+                    csEditState.cedEditor %= applyEdit (Z.insertChar ch)
+                    sendUserTypingAction
+                  curLine = Z.currentLine $ st^.csEditState.cedEditor.editContentsL
+              -- First case: if the cursor is at the end of the current
+              -- line and it contains "``" and the user entered a third
+              -- "`", enable multi-line mode since they're likely typing
+              -- a code block.
+              in if | (cursorIsAtEnd $ st^.csEditState.cedEditor) &&
+                         curLine == "``" &&
+                         ch == '`' -> do
+                        csEditState.cedEditor %= applyEdit (Z.insertMany (T.singleton ch))
+                        csEditState.cedEphemeral.eesMultiline .= True
+                    -- Second case: user entered some smart character
+                    -- (don't care which) on an empty line or at the end
+                    -- of the line after whitespace, so enter a pair of
+                    -- the smart chars and put the cursor between them.
+                    | (editorEmpty $ st^.csEditState.cedEditor) ||
+                         ((cursorAtChar ' ' (applyEdit Z.moveLeft $ st^.csEditState.cedEditor)) &&
+                          (cursorIsAtEnd $ st^.csEditState.cedEditor)) ->
+                        csEditState.cedEditor %= applyEdit (Z.insertMany (T.pack $ ch:ch:[]) >>> Z.moveLeft)
+                    -- Third case: the cursor is already on a smart
+                    -- character and that character is the last one
+                    -- on the line, so instead of inserting a new
+                    -- character, just move past it.
+                    | (cursorAtChar ch $ st^.csEditState.cedEditor) &&
+                      (cursorIsAtEnd $ applyEdit Z.moveRight $ st^.csEditState.cedEditor) ->
+                        csEditState.cedEditor %= applyEdit Z.moveRight
+                    -- Fall-through case: just insert one of the chars
+                    -- without doing anything smart.
+                    | otherwise -> doInsertChar
+            | editingPermitted st -> do
+
+              -- If the most recent editing event was a tab completion,
+              -- there is a trailing space that we want to remove if the
+              -- next input character is punctuation.
+              when (smartEditing && justCompleted && isSmartClosingPunctuation e) $
+                  csEditState.cedEditor %= applyEdit Z.deletePrevChar
+
+              csEditState.cedEditor %= applyEdit (Z.insertMany (sanitizeUserText' $ T.singleton ch))
+              sendUserTypingAction
+          _ | editingPermitted st -> do
+              mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent e
+              sendUserTypingAction
+            | otherwise -> return ()
+
+    let ctx = AutocompleteContext { autocompleteManual = False
+                                  , autocompleteFirstMatch = False
+                                  }
+    checkForAutocompletion ctx
+    liftIO $ resetSpellCheckTimer $ st^.csEditState
+
+    -- If the preview is enabled and multi-line editing is enabled and
+    -- the line count changed, we need to invalidate the rendering cache
+    -- entry for the channel messages because we want to redraw them to
+    -- fit in the space just changed by the size of the preview area.
+    afterLineCount <- use (csEditState.cedEditor.to getEditContents.to length)
+    isMultiline <- use (csEditState.cedEphemeral.eesMultiline)
+    isPreviewing <- use csShowMessagePreview
+    when (beforeLineCount /= afterLineCount && isMultiline && isPreviewing) $ do
+        cId <- use csCurrentChannelId
+        mh $ invalidateCacheEntry $ ChannelMessages cId
+
+    -- Reset the recent autocompletion flag to stop smart punctuation
+    -- handling.
+    when justCompleted $
+        csEditState.cedJustCompleted .= False
+
+-- | Send the user_typing action to the server asynchronously, over the
+-- connected websocket. If the websocket is not connected, drop the
+-- action silently.
+sendUserTypingAction :: MH ()
+sendUserTypingAction = do
+  st <- use id
+  when (configShowTypingIndicator (st^.csResources.crConfiguration)) $
+    case st^.csConnectionStatus of
+      Connected -> do
+        let pId = case st^.csEditState.cedEditMode of
+                    Replying _ post -> Just $ postId post
+                    _               -> Nothing
+        liftIO $ do
+          now <- getCurrentTime
+          let action = UserTyping now (st^.csCurrentChannelId) pId
+          STM.atomically $ STM.writeTChan (st^.csResources.crWebsocketActionChan) action
+      Disconnected -> return ()
+
+-- Kick off an async request to the spell checker for the current editor
+-- contents.
+requestSpellCheck :: MH ()
+requestSpellCheck = do
+    st <- use id
+    case st^.csEditState.cedSpellChecker of
+        Nothing -> return ()
+        Just (checker, _) -> do
+            -- Get the editor contents.
+            contents <- getEditContents <$> use (csEditState.cedEditor)
+            doAsyncWith Preempt $ do
+                -- For each line in the editor, submit an aspell request.
+                let query = concat <$> mapM (askAspell checker) contents
+                    postMistakes :: [AspellResponse] -> MH ()
+                    postMistakes responses = do
+                        let getMistakes AllCorrect = []
+                            getMistakes (Mistakes ms) = mistakeWord <$> ms
+                            allMistakes = S.fromList $ concat $ getMistakes <$> responses
+                        csEditState.cedMisspellings .= allMistakes
+
+                tryMM query (return . Just . postMistakes)
+
+editorEmpty :: Editor Text a -> Bool
+editorEmpty e = cursorIsAtEnd e &&
+                cursorIsAtBeginning e
+
+cursorIsAtEnd :: Editor Text a -> Bool
+cursorIsAtEnd e =
+    let col = snd $ Z.cursorPosition z
+        curLine = Z.currentLine z
+        z = e^.editContentsL
+    in col == T.length curLine
+
+cursorIsAtBeginning :: Editor Text a -> Bool
+cursorIsAtBeginning e =
+    let col = snd $ Z.cursorPosition z
+        z = e^.editContentsL
+    in col == 0
+
+cursorAtOneOf :: [Char] -> Editor Text a -> Maybe Char
+cursorAtOneOf [] _ = Nothing
+cursorAtOneOf (c:cs) e =
+    if cursorAtChar c e
+    then Just c
+    else cursorAtOneOf cs e
+
+cursorAtChar :: Char -> Editor Text a -> Bool
+cursorAtChar ch e =
+    let col = snd $ Z.cursorPosition z
+        curLine = Z.currentLine z
+        z = e^.editContentsL
+    in (T.singleton ch) `T.isPrefixOf` T.drop col curLine
+
+gotoHome :: Z.TextZipper Text -> Z.TextZipper Text
+gotoHome = Z.moveCursor (0, 0)
+
+gotoEnd :: Z.TextZipper Text -> Z.TextZipper Text
+gotoEnd z =
+    let zLines = Z.getText z
+        numLines = length zLines
+        lastLineLength = T.length $ last zLines
+    in if numLines > 0
+       then Z.moveCursor (numLines - 1, lastLineLength) z
+       else z
+
+cancelAutocompleteOrReplyOrEdit :: MH ()
+cancelAutocompleteOrReplyOrEdit = do
+    cId <- use csCurrentChannelId
+    mh $ invalidateCacheEntry $ ChannelMessages cId
+    ac <- use (csEditState.cedAutocomplete)
+    case ac of
+        Just _ -> do
+            resetAutocomplete
+        Nothing -> do
+            mode <- use (csEditState.cedEditMode)
+            case mode of
+                NewPost -> return ()
+                _ -> do
+                    csEditState.cedEditMode .= NewPost
+                    csEditState.cedEditor %= applyEdit Z.clearZipper
+                    resetAttachmentList
+
+replyToLatestMessage :: MH ()
+replyToLatestMessage = do
+  msgs <- use (csCurrentChannel . ccContents . cdMessages)
+  case findLatestUserMessage isReplyable msgs of
+    Just msg | isReplyable msg ->
+        do rootMsg <- getReplyRootMessage msg
+           setMode Main
+           cId <- use csCurrentChannelId
+           mh $ invalidateCacheEntry $ ChannelMessages cId
+           csEditState.cedEditMode .= Replying rootMsg (fromJust $ rootMsg^.mOriginalPost)
+    _ -> return ()
+
+data Direction = Forwards | Backwards
+
+tabComplete :: Direction -> MH ()
+tabComplete dir = do
+    let transform list =
+            let len = list^.L.listElementsL.to length
+            in case dir of
+                Forwards ->
+                    if (L.listSelected list == Just (len - 1)) ||
+                       (L.listSelected list == Nothing && len > 0)
+                    then L.listMoveTo 0 list
+                    else L.listMoveBy 1 list
+                Backwards ->
+                    if (L.listSelected list == Just 0) ||
+                       (L.listSelected list == Nothing && len > 0)
+                    then L.listMoveTo (len - 1) list
+                    else L.listMoveBy (-1) list
+    csEditState.cedAutocomplete._Just.acCompletionList %= transform
+
+    mac <- use (csEditState.cedAutocomplete)
+    case mac of
+        Nothing -> do
+            let ctx = AutocompleteContext { autocompleteManual = True
+                                          , autocompleteFirstMatch = True
+                                          }
+            checkForAutocompletion ctx
+        Just ac -> do
+            case ac^.acCompletionList.to L.listSelectedElement of
+                Nothing -> return ()
+                Just (_, alternative) -> do
+                    let replacement = autocompleteAlternativeReplacement alternative
+                        maybeEndOfWord z =
+                            if maybe True isSpace (Z.currentChar z)
+                            then z
+                            else Z.moveWordRight z
+                    csEditState.cedEditor %=
+                        applyEdit (Z.insertChar ' ' . Z.insertMany replacement . Z.deletePrevWord .
+                                   maybeEndOfWord)
+                    csEditState.cedJustCompleted .= True
+
+                    -- If there was only one completion alternative,
+                    -- hide the autocomplete listing now that we've
+                    -- completed the only completion.
+                    when (ac^.acCompletionList.to L.listElements.to length == 1) resetAutocomplete
diff --git a/src/Matterhorn/State/Editing.hs-boot b/src/Matterhorn/State/Editing.hs-boot
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Editing.hs-boot
@@ -0,0 +1,10 @@
+module Matterhorn.State.Editing
+  ( Direction(..)
+  , tabComplete
+  )
+where
+
+import Matterhorn.Types ( MH )
+
+data Direction = Forwards | Backwards
+tabComplete :: Direction -> MH ()
diff --git a/src/Matterhorn/State/Flagging.hs b/src/Matterhorn/State/Flagging.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Flagging.hs
@@ -0,0 +1,66 @@
+module Matterhorn.State.Flagging
+  ( loadFlaggedMessages
+  , updateMessageFlag
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Data.Function ( on )
+import qualified Data.Set as Set
+import           Lens.Micro.Platform
+
+import           Network.Mattermost.Types
+
+import           Matterhorn.State.Common
+import           Matterhorn.Types
+
+
+loadFlaggedMessages :: Seq FlaggedPost -> ChatState -> IO ()
+loadFlaggedMessages prefs st = doAsyncWithIO Normal st $ do
+  return $ Just $ do
+      sequence_ [ updateMessageFlag (flaggedPostId fp) True
+                | fp <- toList prefs
+                , flaggedPostStatus fp
+                ]
+
+
+-- | Update the UI to reflect the flagged/unflagged state of a
+-- message. This __does not__ talk to the Mattermost server, but
+-- rather is the function we call when the Mattermost server notifies
+-- us of flagged or unflagged messages.
+updateMessageFlag :: PostId -> Bool -> MH ()
+updateMessageFlag pId f = do
+  if f
+    then csResources.crFlaggedPosts %= Set.insert pId
+    else csResources.crFlaggedPosts %= Set.delete pId
+  msgMb <- use (csPostMap.at(pId))
+  case msgMb of
+    Just msg
+      | Just cId <- msg^.mChannelId -> do
+      let isTargetMessage m = m^.mMessageId == Just (MessagePostId pId)
+      csChannel(cId).ccContents.cdMessages.traversed.filtered isTargetMessage.mFlagged .= f
+      csPostMap.ix(pId).mFlagged .= f
+      -- We also want to update the post overlay if this happens while
+      -- we're we're observing it
+      mode <- gets appMode
+      case mode of
+        PostListOverlay PostListFlagged
+          | f ->
+              csPostListOverlay.postListPosts %=
+                addMessage (msg & mFlagged .~ True)
+          -- deleting here is tricky, because it means that we need to
+          -- move the focus somewhere: we'll try moving it _up_ unless
+          -- we can't, in which case we'll try moving it down.
+          | otherwise -> do
+              selId <- use (csPostListOverlay.postListSelected)
+              posts <- use (csPostListOverlay.postListPosts)
+              let nextId = case getNextPostId selId posts of
+                    Nothing -> getPrevPostId selId posts
+                    Just x  -> Just x
+              csPostListOverlay.postListSelected .= nextId
+              csPostListOverlay.postListPosts %=
+                filterMessages (((/=) `on` _mMessageId) msg)
+        _ -> return ()
+    _ -> return ()
diff --git a/src/Matterhorn/State/Help.hs b/src/Matterhorn/State/Help.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Help.hs
@@ -0,0 +1,21 @@
+module Matterhorn.State.Help
+  ( showHelpScreen
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Main ( viewportScroll, vScrollToBeginning )
+
+import           Matterhorn.Types
+
+
+showHelpScreen :: HelpTopic -> MH ()
+showHelpScreen topic = do
+    curMode <- gets appMode
+    case curMode of
+        ShowHelp {} -> return ()
+        _ -> do
+            mh $ vScrollToBeginning (viewportScroll HelpViewport)
+            setMode $ ShowHelp topic curMode
diff --git a/src/Matterhorn/State/Links.hs b/src/Matterhorn/State/Links.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Links.hs
@@ -0,0 +1,23 @@
+module Matterhorn.State.Links
+  ( openLinkTarget
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.Text as T
+
+import           Matterhorn.State.Common
+import           Matterhorn.State.Messages ( jumpToPost )
+import           Matterhorn.Types
+import           Matterhorn.Types.RichText ( unURL )
+
+
+openLinkTarget :: LinkTarget -> MH Bool
+openLinkTarget target = do
+    session <- getSession
+    case target of
+        LinkURL url -> openWithOpener (return $ T.unpack $ unURL url)
+        LinkFileId fId -> openWithOpener (liftIO $ prepareAttachment fId session)
+        LinkPermalink _ pId -> jumpToPost pId >> return True
diff --git a/src/Matterhorn/State/ListOverlay.hs b/src/Matterhorn/State/ListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/ListOverlay.hs
@@ -0,0 +1,145 @@
+{-# LANGUAGE RankNTypes #-}
+module Matterhorn.State.ListOverlay
+  ( listOverlayActivateCurrent
+  , listOverlaySearchString
+  , listOverlayMove
+  , exitListOverlay
+  , enterListOverlayMode
+  , resetListOverlaySearch
+  , onEventListOverlay
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Brick.Widgets.List as L
+import qualified Brick.Widgets.Edit as E
+import qualified Data.Text.Zipper as Z
+import qualified Data.Vector as Vec
+import           Lens.Micro.Platform ( Lens', (%=), (.=) )
+import           Network.Mattermost.Types ( Session )
+import qualified Graphics.Vty as Vty
+
+import           Matterhorn.Types
+import           Matterhorn.State.Common
+import           Matterhorn.State.Editing ( editingKeybindings )
+import           Matterhorn.Events.Keybindings ( KeyConfig, KeyHandlerMap, handleKeyboardEvent )
+
+
+-- | Activate the specified list overlay's selected item by invoking the
+-- overlay's configured enter keypress handler function.
+listOverlayActivateCurrent :: Lens' ChatState (ListOverlayState a b) -> MH ()
+listOverlayActivateCurrent which = do
+  mItem <- L.listSelectedElement <$> use (which.listOverlaySearchResults)
+  case mItem of
+      Nothing -> return ()
+      Just (_, val) -> do
+          handler <- use (which.listOverlayEnterHandler)
+          activated <- handler val
+          if activated
+             then setMode Main
+             else return ()
+
+-- | Get the current search string for the specified overlay.
+listOverlaySearchString :: Lens' ChatState (ListOverlayState a b) -> MH Text
+listOverlaySearchString which =
+    (head . E.getEditContents) <$> use (which.listOverlaySearchInput)
+
+-- | Move the list cursor in the specified overlay.
+listOverlayMove :: Lens' ChatState (ListOverlayState a b)
+                -- ^ Which overlay
+                -> (L.List Name a -> L.List Name a)
+                -- ^ How to transform the list in the overlay
+                -> MH ()
+listOverlayMove which how = which.listOverlaySearchResults %= how
+
+-- | Clear the state of the specified list overlay and return to the
+-- Main mode.
+exitListOverlay :: Lens' ChatState (ListOverlayState a b)
+                -- ^ Which overlay to reset
+                -> MH ()
+exitListOverlay which = do
+    st <- use which
+    newList <- use (which.listOverlayNewList)
+    which.listOverlaySearchResults .= newList mempty
+    which.listOverlayEnterHandler .= (const $ return False)
+    setMode (st^.listOverlayReturnMode)
+
+-- | Initialize a list overlay with the specified arguments and switch
+-- to the specified mode.
+enterListOverlayMode :: (Lens' ChatState (ListOverlayState a b))
+                     -- ^ Which overlay to initialize
+                     -> Mode
+                     -- ^ The mode to change to
+                     -> b
+                     -- ^ The overlay's initial search scope
+                     -> (a -> MH Bool)
+                     -- ^ The overlay's enter keypress handler
+                     -> (b -> Session -> Text -> IO (Vec.Vector a))
+                     -- ^ The overlay's results fetcher function
+                     -> MH ()
+enterListOverlayMode which mode scope enterHandler fetcher = do
+    which.listOverlaySearchScope .= scope
+    which.listOverlaySearchInput.E.editContentsL %= Z.clearZipper
+    which.listOverlayEnterHandler .= enterHandler
+    which.listOverlayFetchResults .= fetcher
+    which.listOverlaySearching .= False
+    newList <- use (which.listOverlayNewList)
+    which.listOverlaySearchResults .= newList mempty
+    setMode mode
+    resetListOverlaySearch which
+
+-- | Reset the overlay's search by initiating a new search request for
+-- the string that is currently in the overlay's editor. This does
+-- nothing if a search for this overlay is already in progress.
+resetListOverlaySearch :: Lens' ChatState (ListOverlayState a b) -> MH ()
+resetListOverlaySearch which = do
+    searchPending <- use (which.listOverlaySearching)
+
+    when (not searchPending) $ do
+        searchString <- listOverlaySearchString which
+        which.listOverlaySearching .= True
+        newList <- use (which.listOverlayNewList)
+        session <- getSession
+        scope <- use (which.listOverlaySearchScope)
+        fetcher <- use (which.listOverlayFetchResults)
+        doAsyncWith Preempt $ do
+            results <- fetcher scope session searchString
+            return $ Just $ do
+                which.listOverlaySearchResults .= newList results
+                which.listOverlaySearching .= False
+
+                -- Now that the results are available, check to see if the
+                -- search string changed since this request was submitted.
+                -- If so, issue another search.
+                afterSearchString <- listOverlaySearchString which
+                when (searchString /= afterSearchString) $ resetListOverlaySearch which
+
+-- | Generically handle an event for the list overlay state targeted
+-- by the specified lens. Automatically dispatches new searches in the
+-- overlay's editor if the editor contents change.
+onEventListOverlay :: Lens' ChatState (ListOverlayState a b)
+                   -- ^ Which overlay to dispatch to?
+                   -> (KeyConfig -> KeyHandlerMap)
+                   -- ^ The keybinding builder
+                   -> Vty.Event
+                   -- ^ The event
+                   -> MH Bool
+onEventListOverlay which keybindings =
+    handleKeyboardEvent keybindings $ \e -> do
+        -- Get the editor content before the event.
+        before <- listOverlaySearchString which
+
+        -- First find a matching keybinding in the keybinding list.
+        handled <- handleKeyboardEvent (editingKeybindings (which.listOverlaySearchInput)) (const $ return ()) e
+
+        -- If we didn't find a matching binding, just handle the event
+        -- as a normal editor input event.
+        when (not handled) $
+            mhHandleEventLensed (which.listOverlaySearchInput) E.handleEditorEvent e
+
+        -- Get the editor content after the event. If the string changed,
+        -- start a new search.
+        after <- listOverlaySearchString which
+        when (before /= after) $ resetListOverlaySearch which
diff --git a/src/Matterhorn/State/Logging.hs b/src/Matterhorn/State/Logging.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Logging.hs
@@ -0,0 +1,33 @@
+module Matterhorn.State.Logging
+  ( startLogging
+  , stopLogging
+  , logSnapshot
+  , getLogDestination
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Matterhorn.Types
+
+
+startLogging :: FilePath -> MH ()
+startLogging path = do
+    mgr <- use (csResources.crLogManager)
+    liftIO $ startLoggingToFile mgr path
+
+stopLogging :: MH ()
+stopLogging = do
+    mgr <- use (csResources.crLogManager)
+    liftIO $ stopLoggingToFile mgr
+
+logSnapshot :: FilePath -> MH ()
+logSnapshot path = do
+    mgr <- use (csResources.crLogManager)
+    liftIO $ requestLogSnapshot mgr path
+
+getLogDestination :: MH ()
+getLogDestination = do
+    mgr <- use (csResources.crLogManager)
+    liftIO $ requestLogDestination mgr
diff --git a/src/Matterhorn/State/MessageSelect.hs b/src/Matterhorn/State/MessageSelect.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/MessageSelect.hs
@@ -0,0 +1,294 @@
+module Matterhorn.State.MessageSelect
+  (
+  -- * Message selection mode
+    beginMessageSelect
+  , flagSelectedMessage
+  , pinSelectedMessage
+  , viewSelectedMessage
+  , fillSelectedGap
+  , yankSelectedMessageVerbatim
+  , yankSelectedMessage
+  , openSelectedMessageURLs
+  , beginConfirmDeleteSelectedMessage
+  , messageSelectUp
+  , messageSelectUpBy
+  , messageSelectDown
+  , messageSelectDownBy
+  , messageSelectFirst
+  , messageSelectLast
+  , deleteSelectedMessage
+  , beginReplyCompose
+  , beginEditMessage
+  , flagMessage
+  , getSelectedMessage
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Widgets.Edit ( applyEdit )
+import           Data.Text.Zipper ( clearZipper, insertMany )
+import           Lens.Micro.Platform
+
+import qualified Network.Mattermost.Endpoints as MM
+import           Network.Mattermost.Types
+
+import           Matterhorn.Clipboard ( copyToClipboard )
+import           Matterhorn.State.Common
+import           Matterhorn.State.Links
+import           Matterhorn.State.Messages
+import           Matterhorn.Types
+import           Matterhorn.Types.RichText ( findVerbatimChunk )
+import           Matterhorn.Types.Common
+import           Matterhorn.Windows.ViewMessage
+
+
+-- | In these modes, we allow access to the selected message state.
+messageSelectCompatibleModes :: [Mode]
+messageSelectCompatibleModes =
+    [ MessageSelect
+    , MessageSelectDeleteConfirm
+    , ReactionEmojiListOverlay
+    ]
+
+getSelectedMessage :: ChatState -> Maybe Message
+getSelectedMessage st
+    | not (appMode st `elem` messageSelectCompatibleModes) = Nothing
+    | otherwise = do
+        selMsgId <- selectMessageId $ st^.csMessageSelect
+        let chanMsgs = st ^. csCurrentChannel . ccContents . cdMessages
+        findMessage selMsgId chanMsgs
+
+beginMessageSelect :: MH ()
+beginMessageSelect = do
+    -- Get the number of messages in the current channel and set the
+    -- currently selected message index to be the most recently received
+    -- message that corresponds to a Post (i.e. exclude informative
+    -- messages).
+    --
+    -- If we can't find one at all, we ignore the mode switch request
+    -- and just return.
+    chanMsgs <- use(csCurrentChannel . ccContents . cdMessages)
+    let recentMsg = getLatestSelectableMessage chanMsgs
+
+    when (isJust recentMsg) $ do
+        setMode MessageSelect
+        csMessageSelect .= MessageSelectState (recentMsg >>= _mMessageId)
+
+-- | Tell the server that the message we currently have selected
+-- should have its flagged state toggled.
+flagSelectedMessage :: MH ()
+flagSelectedMessage = do
+  selected <- use (to getSelectedMessage)
+  case selected of
+    Just msg
+      | isFlaggable msg, Just pId <- messagePostId msg ->
+        flagMessage pId (not (msg^.mFlagged))
+    _        -> return ()
+
+-- | Tell the server that the message we currently have selected
+-- should have its pinned state toggled.
+pinSelectedMessage :: MH ()
+pinSelectedMessage = do
+  selected <- use (to getSelectedMessage)
+  case selected of
+    Just msg
+      | isPinnable msg, Just pId <- messagePostId msg ->
+        pinMessage pId (not (msg^.mPinned))
+    _ -> return ()
+
+viewSelectedMessage :: MH ()
+viewSelectedMessage = do
+  selected <- use (to getSelectedMessage)
+  case selected of
+    Just msg
+      | not (isGap msg) -> viewMessage msg
+    _        -> return ()
+
+fillSelectedGap :: MH ()
+fillSelectedGap = do
+  selected <- use (to getSelectedMessage)
+  case selected of
+    Just msg
+      | isGap msg -> do cId <- use csCurrentChannelId
+                        asyncFetchMessagesForGap cId msg
+    _        -> return ()
+
+viewMessage :: Message -> MH ()
+viewMessage m = do
+    let w = tabbedWindow VMTabMessage viewMessageWindowTemplate MessageSelect (78, 25)
+    csViewedMessage .= Just (m, w)
+    runTabShowHandlerFor (twValue w) w
+    setMode ViewMessage
+
+yankSelectedMessageVerbatim :: MH ()
+yankSelectedMessageVerbatim = do
+    selectedMessage <- use (to getSelectedMessage)
+    case selectedMessage of
+        Nothing -> return ()
+        Just m -> do
+            setMode Main
+            case findVerbatimChunk (m^.mText) of
+                Just txt -> copyToClipboard txt
+                Nothing  -> return ()
+
+yankSelectedMessage :: MH ()
+yankSelectedMessage = do
+    selectedMessage <- use (to getSelectedMessage)
+    case selectedMessage of
+        Nothing -> return ()
+        Just m -> do
+            setMode Main
+            copyToClipboard $ m^.mMarkdownSource
+
+openSelectedMessageURLs :: MH ()
+openSelectedMessageURLs = whenMode MessageSelect $ do
+    mCurMsg <- use (to getSelectedMessage)
+    curMsg <- case mCurMsg of
+        Nothing -> error "BUG: openSelectedMessageURLs: no selected message available"
+        Just m -> return m
+
+    let urls = msgURLs curMsg
+    when (not (null urls)) $ do
+        openedAll <- and <$> mapM (openLinkTarget . _linkTarget) urls
+        case openedAll of
+            True -> return ()
+            False ->
+                mhError $ ConfigOptionMissing "urlOpenCommand"
+
+beginConfirmDeleteSelectedMessage :: MH ()
+beginConfirmDeleteSelectedMessage = do
+    st <- use id
+    selected <- use (to getSelectedMessage)
+    case selected of
+        Just msg | isDeletable msg && isMine st msg ->
+            setMode MessageSelectDeleteConfirm
+        _ -> return ()
+
+messageSelectUp :: MH ()
+messageSelectUp = do
+    mode <- gets appMode
+    selected <- use (csMessageSelect.to selectMessageId)
+    case selected of
+        Just _ | mode == MessageSelect -> do
+            chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)
+            let nextMsgId = getPrevMessageId selected chanMsgs
+            csMessageSelect .= MessageSelectState (nextMsgId <|> selected)
+        _ -> return ()
+
+messageSelectDown :: MH ()
+messageSelectDown = do
+    selected <- use (csMessageSelect.to selectMessageId)
+    case selected of
+        Just _ -> whenMode MessageSelect $ do
+            chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)
+            let nextMsgId = getNextMessageId selected chanMsgs
+            csMessageSelect .= MessageSelectState (nextMsgId <|> selected)
+        _ -> return ()
+
+messageSelectDownBy :: Int -> MH ()
+messageSelectDownBy amt
+    | amt <= 0 = return ()
+    | otherwise =
+        messageSelectDown >> messageSelectDownBy (amt - 1)
+
+messageSelectUpBy :: Int -> MH ()
+messageSelectUpBy amt
+    | amt <= 0 = return ()
+    | otherwise =
+      messageSelectUp >> messageSelectUpBy (amt - 1)
+
+messageSelectFirst :: MH ()
+messageSelectFirst = do
+    selected <- use (csMessageSelect.to selectMessageId)
+    case selected of
+        Just _ -> whenMode MessageSelect $ do
+            chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)
+            case getEarliestSelectableMessage chanMsgs of
+              Just firstMsg ->
+                csMessageSelect .= MessageSelectState (firstMsg^.mMessageId <|> selected)
+              Nothing -> mhLog LogError "No first message found from current message?!"
+        _ -> return ()
+
+messageSelectLast :: MH ()
+messageSelectLast = do
+    selected <- use (csMessageSelect.to selectMessageId)
+    case selected of
+        Just _ -> whenMode MessageSelect $ do
+            chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)
+            case getLatestSelectableMessage chanMsgs of
+              Just lastSelMsg ->
+                csMessageSelect .= MessageSelectState (lastSelMsg^.mMessageId <|> selected)
+              Nothing -> mhLog LogError "No last message found from current message?!"
+        _ -> return ()
+
+deleteSelectedMessage :: MH ()
+deleteSelectedMessage = do
+    selectedMessage <- use (to getSelectedMessage)
+    st <- use id
+    cId <- use csCurrentChannelId
+    case selectedMessage of
+        Just msg | isMine st msg && isDeletable msg ->
+            case msg^.mOriginalPost of
+              Just p ->
+                  doAsyncChannelMM Preempt cId
+                      (\s _ -> MM.mmDeletePost (postId p) s)
+                      (\_ _ -> Just $ do
+                          csEditState.cedEditMode .= NewPost
+                          setMode Main)
+              Nothing -> return ()
+        _ -> return ()
+
+beginReplyCompose :: MH ()
+beginReplyCompose = do
+    selected <- use (to getSelectedMessage)
+    case selected of
+        Just msg | isReplyable msg -> do
+            rootMsg <- getReplyRootMessage msg
+            let Just p = rootMsg^.mOriginalPost
+            setMode Main
+            csEditState.cedEditMode .= Replying rootMsg p
+        _ -> return ()
+
+beginEditMessage :: MH ()
+beginEditMessage = do
+    selected <- use (to getSelectedMessage)
+    st <- use id
+    case selected of
+        Just msg | isMine st msg && isEditable msg -> do
+            let Just p = msg^.mOriginalPost
+            setMode Main
+            csEditState.cedEditMode .= Editing p (msg^.mType)
+            -- If the post that we're editing is an emote, we need
+            -- to strip the formatting because that's only there to
+            -- indicate that the post is an emote. This is annoying and
+            -- can go away one day when there is an actual post type
+            -- value of "emote" that we can look at. Note that the
+            -- removed formatting needs to be reinstated just prior to
+            -- issuing the API call to update the post.
+            let sanitized = sanitizeUserText $ postMessage p
+            let toEdit = if isEmote msg
+                         then removeEmoteFormatting sanitized
+                         else sanitized
+            csEditState.cedEditor %= applyEdit (insertMany toEdit . clearZipper)
+        _ -> return ()
+
+-- | Tell the server that we have flagged or unflagged a message.
+flagMessage :: PostId -> Bool -> MH ()
+flagMessage pId f = do
+  session <- getSession
+  myId <- gets myUserId
+  doAsyncWith Normal $ do
+    let doFlag = if f then MM.mmFlagPost else MM.mmUnflagPost
+    doFlag myId pId session
+    return Nothing
+
+-- | Tell the server that we have pinned or unpinned a message.
+pinMessage :: PostId -> Bool -> MH ()
+pinMessage pId f = do
+  session <- getSession
+  doAsyncWith Normal $ do
+    let doPin = if f then MM.mmPinPostToChannel else MM.mmUnpinPostToChannel
+    void $ doPin pId session
+    return Nothing
diff --git a/src/Matterhorn/State/Messages.hs b/src/Matterhorn/State/Messages.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Messages.hs
@@ -0,0 +1,929 @@
+{-# LANGUAGE MultiWayIf #-}
+module Matterhorn.State.Messages
+  ( PostToAdd(..)
+  , addDisconnectGaps
+  , lastMsg
+  , sendMessage
+  , editMessage
+  , deleteMessage
+  , addNewPostedMessage
+  , addObtainedMessages
+  , asyncFetchMoreMessages
+  , asyncFetchMessagesForGap
+  , asyncFetchMessagesSurrounding
+  , fetchVisibleIfNeeded
+  , disconnectChannels
+  , toggleMessageTimestamps
+  , jumpToPost
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Main ( getVtyHandle, invalidateCacheEntry, invalidateCache )
+import qualified Brick.Widgets.FileBrowser as FB
+import           Control.Exception ( SomeException, try )
+import qualified Data.Foldable as F
+import qualified Data.HashMap.Strict as HM
+import qualified Data.Set as Set
+import qualified Data.Sequence as Seq
+import qualified Data.Text as T
+import           Graphics.Vty ( outputIface )
+import           Graphics.Vty.Output.Interface ( ringTerminalBell )
+import           Lens.Micro.Platform ( Traversal', (.=), (%=), (%~), (.~), (^?)
+                                     , to, at, traversed, filtered, ix, _1 )
+
+import           Network.Mattermost
+import qualified Network.Mattermost.Endpoints as MM
+import           Network.Mattermost.Lenses
+import           Network.Mattermost.Types
+
+import           Matterhorn.Constants
+import           Matterhorn.State.Channels
+import           Matterhorn.State.Common
+import           Matterhorn.State.Reactions
+import           Matterhorn.State.Users
+import           Matterhorn.TimeUtils
+import           Matterhorn.Types
+import           Matterhorn.Types.Common ( sanitizeUserText )
+import           Matterhorn.Types.DirectionalSeq ( DirectionalSeq, SeqDirection )
+
+
+-- ----------------------------------------------------------------------
+-- Message gaps
+
+
+-- | Called to add an UnknownGap to the end of the Messages collection
+-- for all channels when the client has become disconnected from the
+-- server.  This gaps will later be removed by successful fetching
+-- overlaps if the connection is re-established.  Note that the
+-- disconnect is re-iterated periodically via a re-connect timer
+-- attempt, so do not duplicate gaps.  Also clear any flags
+-- representing a pending exchange with the server (which will now
+-- never complete).
+addDisconnectGaps :: MH ()
+addDisconnectGaps = mapM_ onEach . filteredChannelIds (const True) =<< use csChannels
+    where onEach c = do addEndGap c
+                        clearPendingFlags c
+                        mh $ invalidateCacheEntry (ChannelMessages c)
+
+-- | Websocket was disconnected, so all channels may now miss some
+-- messages
+disconnectChannels :: MH ()
+disconnectChannels = addDisconnectGaps
+
+toggleMessageTimestamps :: MH ()
+toggleMessageTimestamps = do
+    mh invalidateCache
+    let toggle c = c { configShowMessageTimestamps = not (configShowMessageTimestamps c)
+                     }
+    csResources.crConfiguration %= toggle
+
+clearPendingFlags :: ChannelId -> MH ()
+clearPendingFlags c = csChannel(c).ccContents.cdFetchPending .= False
+
+addEndGap :: ChannelId -> MH ()
+addEndGap cId = withChannel cId $ \chan ->
+    let lastmsg_ = chan^.ccContents.cdMessages.to reverseMessages.to lastMsg
+        lastIsGap = maybe False isGap lastmsg_
+        gapMsg = newGapMessage timeJustAfterLast
+        timeJustAfterLast = maybe t0 (justAfter . _mDate) lastmsg_
+        t0 = ServerTime $ originTime  -- use any time for a channel with no messages yet
+        newGapMessage = newMessageOfType
+                        (T.pack "Disconnected. Will refresh when connected.")
+                        (C UnknownGapAfter)
+    in unless lastIsGap
+           (csChannels %= modifyChannelById cId (ccContents.cdMessages %~ addMessage gapMsg))
+
+lastMsg :: RetrogradeMessages -> Maybe Message
+lastMsg = withFirstMessage id
+
+-- | Send a message and attachments to the specified channel.
+sendMessage :: ChannelId -> EditMode -> Text -> [AttachmentData] -> MH ()
+sendMessage chanId mode msg attachments =
+    when (not $ shouldSkipMessage msg) $ do
+        status <- use csConnectionStatus
+        case status of
+            Disconnected -> do
+                let m = T.concat [ "Cannot send messages while disconnected. Enable logging to "
+                                 , "get disconnection information. If Matterhorn's reconnection "
+                                 , "attempts are failing, use `/reconnect` to attempt to "
+                                 , "reconnect manually."
+                                 ]
+                mhError $ GenericError m
+            Connected -> do
+                session <- getSession
+                doAsync Preempt $ do
+                    -- Upload attachments
+                    fileInfos <- forM attachments $ \a -> do
+                        MM.mmUploadFile chanId (FB.fileInfoFilename $ attachmentDataFileInfo a)
+                            (attachmentDataBytes a) session
+
+                    let fileIds = Seq.fromList $
+                                  fmap fileInfoId $
+                                  concat $
+                                  (F.toList . MM.uploadResponseFileInfos) <$> fileInfos
+
+                    case mode of
+                        NewPost -> do
+                            let pendingPost = (rawPost msg chanId) { rawPostFileIds = fileIds }
+                            void $ MM.mmCreatePost pendingPost session
+                        Replying _ p -> do
+                            let pendingPost = (rawPost msg chanId) { rawPostRootId = postRootId p <|> (Just $ postId p)
+                                                                   , rawPostFileIds = fileIds
+                                                                   }
+                            void $ MM.mmCreatePost pendingPost session
+                        Editing p ty -> do
+                            let body = case ty of
+                                         CP Emote -> addEmoteFormatting msg
+                                         _ -> msg
+                                update = (postUpdateBody body) { postUpdateFileIds = if null fileIds
+                                                                                     then Nothing
+                                                                                     else Just fileIds
+                                                               }
+                            void $ MM.mmPatchPost (postId p) update session
+
+shouldSkipMessage :: Text -> Bool
+shouldSkipMessage "" = True
+shouldSkipMessage s = T.all (`elem` (" \t"::String)) s
+
+editMessage :: Post -> MH ()
+editMessage new = do
+    myId <- gets myUserId
+    baseUrl <- getServerBaseUrl
+    let isEditedMessage m = m^.mMessageId == Just (MessagePostId $ new^.postIdL)
+        (msg, mentionedUsers) = clientPostToMessage (toClientPost baseUrl new (new^.postRootIdL))
+        chan = csChannel (new^.postChannelIdL)
+    chan . ccContents . cdMessages . traversed . filtered isEditedMessage .= msg
+    mh $ invalidateCacheEntry (ChannelMessages $ new^.postChannelIdL)
+
+    fetchMentionedUsers mentionedUsers
+
+    when (postUserId new /= Just myId) $
+        chan %= adjustEditedThreshold new
+
+    csPostMap.ix(postId new) .= msg
+    asyncFetchReactionsForPost (postChannelId new) new
+    asyncFetchAttachments new
+
+deleteMessage :: Post -> MH ()
+deleteMessage new = do
+    let isDeletedMessage m = m^.mMessageId == Just (MessagePostId $ new^.postIdL) ||
+                             isReplyTo (new^.postIdL) m
+        chan :: Traversal' ChatState ClientChannel
+        chan = csChannel (new^.postChannelIdL)
+    chan.ccContents.cdMessages.traversed.filtered isDeletedMessage %= (& mDeleted .~ True)
+    chan %= adjustUpdated new
+    mh $ invalidateCacheEntry (ChannelMessages $ new^.postChannelIdL)
+
+addNewPostedMessage :: PostToAdd -> MH ()
+addNewPostedMessage p =
+    addMessageToState True True p >>= postProcessMessageAdd
+
+-- | Adds the set of Posts to the indicated channel.  The Posts must
+-- all be for the specified Channel.  The reqCnt argument indicates
+-- how many posts were requested, which will determine whether a gap
+-- message is added to either end of the posts list or not.
+--
+-- The addTrailingGap is only True when fetching the very latest messages
+-- for the channel, and will suppress the generation of a Gap message
+-- following the added block of messages.
+addObtainedMessages :: ChannelId -> Int -> Bool -> Posts -> MH PostProcessMessageAdd
+addObtainedMessages cId reqCnt addTrailingGap posts =
+  if null $ posts^.postsOrderL
+  then do when addTrailingGap $
+            -- Fetched at the end of the channel, but nothing was
+            -- available.  This is common if this is a new channel
+            -- with no messages in it.  Need to remove any gaps that
+            -- exist at the end of the channel.
+            csChannels %= modifyChannelById cId
+              (ccContents.cdMessages %~
+               \msgs -> let startPoint = join $ _mMessageId <$> getLatestPostMsg msgs
+                        in fst $ removeMatchesFromSubset isGap startPoint Nothing msgs)
+          return NoAction
+  else
+    -- Adding a block of server-provided messages, which are known to
+    -- be contiguous.  Locally this may overlap with some UnknownGap
+    -- messages, which can therefore be removed.  Alternatively the
+    -- new block may be discontiguous with the local blocks, in which
+    -- case the new block should be surrounded by UnknownGaps.
+    withChannelOrDefault cId NoAction $ \chan -> do
+        let pIdList = toList (posts^.postsOrderL)
+            -- the first and list PostId in the batch to be added
+            earliestPId = last pIdList
+            latestPId = head pIdList
+            earliestDate = postCreateAt $ (posts^.postsPostsL) HM.! earliestPId
+            latestDate = postCreateAt $ (posts^.postsPostsL) HM.! latestPId
+
+            localMessages = chan^.ccContents . cdMessages
+
+            -- Get a list of the duplicated message PostIds between
+            -- the messages already in the channel and the new posts
+            -- to be added.
+
+            match = snd $ removeMatchesFromSubset
+                          (\m -> maybe False (\p -> p `elem` pIdList) (messagePostId m))
+                          (Just (MessagePostId earliestPId))
+                          (Just (MessagePostId latestPId))
+                          localMessages
+
+            accum m l =
+                case messagePostId m of
+                    Just pId -> pId : l
+                    Nothing -> l
+            dupPIds = foldr accum [] match
+
+            -- If there were any matches, then there was overlap of
+            -- the new messages with existing messages.
+
+            -- Don't re-add matching messages (avoid overhead like
+            -- re-checking/re-fetching related post information, and
+            -- do not signal action needed for notifications), and
+            -- remove any gaps in the overlapping region.
+
+            newGapMessage d isOlder =
+              -- newGapMessage is a helper for generating a gap
+              -- message
+              do uuid <- generateUUID
+                 let txt = "Load " <>
+                           (if isOlder then "older" else "newer") <>
+                           " messages" <>
+                           (if isOlder then "  ↥↥↥" else "  ↧↧↧")
+                     ty = if isOlder
+                          then C UnknownGapBefore
+                          else C UnknownGapAfter
+                 return (newMessageOfType txt ty d
+                         & mMessageId .~ Just (MessageUUID uuid))
+
+            -- If this batch contains the latest known messages, do
+            -- not add a following gap.  A gap at this point is added
+            -- by a websocket disconnect, and any fetches thereafter
+            -- are assumed to be the most current information (until
+            -- another disconnect), so no gap is needed.
+            -- Additionally, the presence of a gap at the end for a
+            -- connected client causes a fetch of messages at this
+            -- location, so adding the gap here would cause an
+            -- infinite update loop.
+
+            addingAtEnd = maybe True (latestDate >=) $
+                          (^.mDate) <$> getLatestPostMsg localMessages
+
+            addingAtStart = maybe True (earliestDate <=) $
+                            (^.mDate) <$> getEarliestPostMsg localMessages
+            removeStart = if addingAtStart && noMoreBefore
+                          then Nothing
+                          else Just (MessagePostId earliestPId)
+            removeEnd = if addTrailingGap || (addingAtEnd && noMoreAfter)
+                        then Nothing
+                        else Just (MessagePostId latestPId)
+
+            noMoreBefore = reqCnt < 0 && length pIdList < (-reqCnt)
+            noMoreAfter = addTrailingGap || reqCnt > 0 && length pIdList < reqCnt
+
+            reAddGapBefore = earliestPId `elem` dupPIds || noMoreBefore
+            -- addingAtEnd used to be in reAddGapAfter but does not
+            -- seem to be needed.  I may have missed a specific use
+            -- case/scenario, so I've left it commented out here for
+            -- debug assistance.
+            reAddGapAfter = latestPId `elem` dupPIds || {- addingAtEnd || -} noMoreAfter
+
+        -- The post map returned by the server will *already* have
+        -- all thread messages for each post that is part of a
+        -- thread. By calling installMessagesFromPosts here, we go ahead
+        -- and populate the csPostMap with those posts so that below, in
+        -- addMessageToState, we notice that we already know about reply
+        -- parent messages and can avoid fetching them. This converts
+        -- the posts to Messages and stores those and also returns
+        -- them, but we don't need them here. We just want the post
+        -- map update. This also gathers up the set of all mentioned
+        -- usernames in the text of the messages which we need to use to
+        -- submit a single batch request for user metadata so we don't
+        -- submit one request per mention.
+        void $ installMessagesFromPosts posts
+
+        -- Add all the new *unique* posts into the existing channel
+        -- corpus, generating needed fetches of data associated with
+        -- the post, and determining an notification action to be
+        -- taken (if any).
+        action <- foldr andProcessWith NoAction <$>
+          mapM (addMessageToState False False . OldPost)
+                   [ (posts^.postsPostsL) HM.! p
+                   | p <- toList (posts^.postsOrderL)
+                   , not (p `elem` dupPIds)
+                   ]
+
+        -- The channel messages now include all the fetched messages.
+        -- Things to do at this point are:
+        --
+        --   1. Remove any duplicates just added, as well as any gaps
+        --   2. Add new gaps (if needed) at either end of the added
+        --      messages.
+        --   3. Update the "current selection" if it was on a removed message.
+        --
+        -- Do this with the updated copy of the channel's messages.
+
+        withChannelOrDefault cId () $ \updchan -> do
+          let updMsgs = updchan ^. ccContents . cdMessages
+
+          -- Remove any gaps in the added region.  If there was an
+          -- active message selection and it is one of the removed
+          -- gaps, reset the selection to the beginning or end of the
+          -- added region (if there are any added selectable messages,
+          -- otherwise just the end if the message list in it's
+          -- entirety, or no selection at all).
+
+          let (resultMessages, removedMessages) =
+                removeMatchesFromSubset isGap removeStart removeEnd updMsgs
+          csChannels %= modifyChannelById cId
+            (ccContents.cdMessages .~ resultMessages)
+
+          -- Determine if the current selected message was one of the
+          -- removed messages.
+
+          selMsgId <- use (csMessageSelect.to selectMessageId)
+          let rmvdSel = do
+                i <- selMsgId -- :: Maybe MessageId
+                findMessage i removedMessages
+              rmvdSelType = _mType <$> rmvdSel
+
+          case rmvdSel of
+            Nothing -> return ()
+            Just rm ->
+              if isGap rm
+              then return ()  -- handled during gap insertion below
+              else do
+                -- Replaced a selected message that wasn't a gap.
+                -- This is unlikely, but may occur if the previously
+                -- selected message was just deleted by another user
+                -- and is in the fetched region.  The choices here are
+                -- to move the selection, or cancel the selection.
+                -- Both will be unpleasant surprises for the user, but
+                -- cancelling the selection is probably the better
+                -- choice than allowing the user to perform select
+                -- actions on a message that isn't the one they just
+                -- selected.
+                setMode Main
+                csMessageSelect .= MessageSelectState Nothing
+
+          -- Add a gap at each end of the newly fetched data, unless:
+          --   1. there is an overlap
+          --   2. there is no more in the indicated direction
+          --      a. indicated by adding messages later than any currently
+          --         held messages (see note above re 'addingAtEnd').
+          --      b. the amount returned was less than the amount requested
+
+          if reAddGapBefore
+            then
+              -- No more gaps.  If the selected gap was removed, move
+              -- select to first (earliest) message)
+              case rmvdSelType of
+                Just (C UnknownGapBefore) ->
+                  csMessageSelect .= MessageSelectState (pure $ MessagePostId earliestPId)
+                _ -> return ()
+            else do
+              -- add a gap at the start of the newly fetched block and
+              -- make that the active selection if this fetch removed
+              -- the previously selected gap in this direction.
+              gapMsg <- newGapMessage (justBefore earliestDate) True
+              csChannels %= modifyChannelById cId
+                (ccContents.cdMessages %~ addMessage gapMsg)
+              -- Move selection from old gap to new gap
+              case rmvdSelType of
+                Just (C UnknownGapBefore) -> do
+                  csMessageSelect .= MessageSelectState (gapMsg^.mMessageId)
+                _ -> return ()
+
+          if reAddGapAfter
+            then
+              -- No more gaps.  If the selected gap was removed, move
+              -- select to last (latest) message.
+              case rmvdSelType of
+                Just (C UnknownGapAfter) ->
+                  csMessageSelect .= MessageSelectState (pure $ MessagePostId latestPId)
+                _ -> return ()
+            else do
+              -- add a gap at the end of the newly fetched block and
+              -- make that the active selection if this fetch removed
+              -- the previously selected gap in this direction.
+              gapMsg <- newGapMessage (justAfter latestDate) False
+              csChannels %= modifyChannelById cId
+                (ccContents.cdMessages %~ addMessage gapMsg)
+              -- Move selection from old gap to new gap
+              case rmvdSelType of
+                Just (C UnknownGapAfter) ->
+                  csMessageSelect .= MessageSelectState (gapMsg^.mMessageId)
+                _ -> return ()
+
+        -- Now initiate fetches for use information for any
+        -- as-yet-unknown users related to this new set of messages
+
+        let users = foldr (\post s -> maybe s (flip Set.insert s) (postUserId post))
+                          Set.empty (posts^.postsPostsL)
+            addUnknownUsers inputUserIds = do
+                knownUserIds <- Set.fromList <$> gets allUserIds
+                let unknownUsers = Set.difference inputUserIds knownUserIds
+                if Set.null unknownUsers
+                   then return ()
+                   else handleNewUsers (Seq.fromList $ toList unknownUsers) (return ())
+
+        addUnknownUsers users
+
+        -- Return the aggregated user notification action needed
+        -- relative to the set of added messages.
+
+        return action
+
+-- | Adds a possibly new message to the associated channel contents.
+-- Returns an indicator of whether the user should be potentially
+-- notified of a change (a new message not posted by this user, a
+-- mention of the user, etc.).  This operation has no effect on any
+-- existing UnknownGap entries and should be called when those are
+-- irrelevant.
+--
+-- The first boolean argument ('doFetchMentionedUsers') indicates
+-- whether this function should schedule a fetch for any mentioned
+-- users in the message. This is provided so that callers can batch
+-- this operation if a large collection of messages is being added
+-- together, in which case we don't want this function to schedule a
+-- single request per message (worst case). If you're calling this as
+-- part of scrollback processing, you should pass False. Otherwise if
+-- you're adding only a single message, you should pass True.
+--
+-- The second boolean argument ('fetchAuthor') is similar to the first
+-- boolean argument but it refers to the author of the message instead
+-- of any user mentions within the message body.
+--
+-- The third argument ('newPostData') indicates whether this message
+-- is being added as part of a fetch of old messages (e.g. scrollback)
+-- or if ti is a new message and affects things like whether
+-- notifications are generated and if the "New Messages" marker gets
+-- updated.
+addMessageToState :: Bool -> Bool -> PostToAdd -> MH PostProcessMessageAdd
+addMessageToState doFetchMentionedUsers fetchAuthor newPostData = do
+    let (new, wasMentioned) = case newPostData of
+          -- A post from scrollback history has no mention data, and
+          -- that's okay: we only need to track mentions to tell the
+          -- user that recent posts contained mentions.
+          OldPost p      -> (p, False)
+          RecentPost p m -> (p, m)
+
+    st <- use id
+    case st ^? csChannel(postChannelId new) of
+        Nothing -> do
+            session <- getSession
+            doAsyncWith Preempt $ do
+                nc <- MM.mmGetChannel (postChannelId new) session
+                member <- MM.mmGetChannelMember (postChannelId new) UserMe session
+
+                let chType = nc^.channelTypeL
+                    pref = showGroupChannelPref (postChannelId new) (myUserId st)
+
+                -- If the channel has been archived, we don't want to
+                -- post this message or add the channel to the state.
+                case channelDeleted nc of
+                    True -> return Nothing
+                    False -> return $ Just $ do
+                        -- If the incoming message is for a group
+                        -- channel we don't know about, that's because
+                        -- it was previously hidden by the user. We need
+                        -- to show it, and to do that we need to update
+                        -- the server-side preference. (That, in turn,
+                        -- triggers a channel refresh.)
+                        if chType == Group
+                            then applyPreferenceChange pref
+                            else refreshChannel SidebarUpdateImmediate nc member
+
+                        addMessageToState doFetchMentionedUsers fetchAuthor newPostData >>=
+                            postProcessMessageAdd
+
+            return NoAction
+        Just _ -> do
+            baseUrl <- getServerBaseUrl
+            let cp = toClientPost baseUrl new (new^.postRootIdL)
+                fromMe = (cp^.cpUser == (Just $ myUserId st)) &&
+                         (isNothing $ cp^.cpUserOverride)
+                userPrefs = st^.csResources.crUserPreferences
+                isJoinOrLeave = case cp^.cpType of
+                  Join  -> True
+                  Leave -> True
+                  _     -> False
+                ignoredJoinLeaveMessage =
+                  not (userPrefs^.userPrefShowJoinLeave) && isJoinOrLeave
+                cId = postChannelId new
+
+                doAddMessage = do
+                    -- Do we have the user data for the post author?
+                    case cp^.cpUser of
+                        Nothing -> return ()
+                        Just authorId -> when fetchAuthor $ do
+                            authorResult <- gets (userById authorId)
+                            when (isNothing authorResult) $
+                                handleNewUsers (Seq.singleton authorId) (return ())
+
+                    currCId <- use csCurrentChannelId
+                    flags <- use (csResources.crFlaggedPosts)
+                    let (msg', mentionedUsers) = clientPostToMessage cp
+                                 & _1.mFlagged .~ ((cp^.cpPostId) `Set.member` flags)
+
+                    when doFetchMentionedUsers $
+                        fetchMentionedUsers mentionedUsers
+
+                    csPostMap.at(postId new) .= Just msg'
+                    mh $ invalidateCacheEntry (ChannelMessages cId)
+                    csChannels %= modifyChannelById cId
+                      ((ccContents.cdMessages %~ addMessage msg') .
+                       (if not ignoredJoinLeaveMessage then adjustUpdated new else id) .
+                       (\c -> if currCId == cId
+                              then c
+                              else case newPostData of
+                                     OldPost _ -> c
+                                     RecentPost _ _ ->
+                                       updateNewMessageIndicator new c) .
+                       (\c -> if wasMentioned
+                              then c & ccInfo.cdMentionCount %~ succ
+                              else c)
+                      )
+                    asyncFetchReactionsForPost cId new
+                    asyncFetchAttachments new
+                    postedChanMessage
+
+                doHandleAddedMessage = do
+                    -- If the message is in reply to another message,
+                    -- try to find it in the scrollback for the post's
+                    -- channel. If the message isn't there, fetch it. If
+                    -- we have to fetch it, don't post this message to the
+                    -- channel until we have fetched the parent.
+                    case cp^.cpInReplyToPost of
+                        Just parentId ->
+                            case getMessageForPostId st parentId of
+                                Nothing -> do
+                                    doAsyncChannelMM Preempt cId
+                                        (\s _ -> MM.mmGetThread parentId s)
+                                        (\_ p -> Just $ updatePostMap p)
+                                _ -> return ()
+                        _ -> return ()
+
+                    doAddMessage
+
+                postedChanMessage =
+                  withChannelOrDefault (postChannelId new) NoAction $ \chan -> do
+                      currCId <- use csCurrentChannelId
+
+                      let notifyPref = notifyPreference (myUser st) chan
+                          curChannelAction = if postChannelId new == currCId
+                                             then UpdateServerViewed
+                                             else NoAction
+                          originUserAction =
+                            if | fromMe                            -> NoAction
+                               | ignoredJoinLeaveMessage           -> NoAction
+                               | notifyPref == NotifyOptionAll     -> NotifyUser [newPostData]
+                               | notifyPref == NotifyOptionMention
+                                   && wasMentioned                 -> NotifyUser [newPostData]
+                               | otherwise                         -> NoAction
+
+                      return $ curChannelAction `andProcessWith` originUserAction
+
+            doHandleAddedMessage
+
+-- | PostProcessMessageAdd is an internal value that informs the main
+-- code whether the user should be notified (e.g., ring the bell) or
+-- the server should be updated (e.g., that the channel has been
+-- viewed).  This is a monoid so that it can be folded over when there
+-- are multiple inbound posts to be processed.
+data PostProcessMessageAdd = NoAction
+                           | NotifyUser [PostToAdd]
+                           | UpdateServerViewed
+                           | NotifyUserAndServer [PostToAdd]
+
+andProcessWith
+  :: PostProcessMessageAdd -> PostProcessMessageAdd -> PostProcessMessageAdd
+andProcessWith NoAction x                                        = x
+andProcessWith x NoAction                                        = x
+andProcessWith (NotifyUserAndServer p) UpdateServerViewed        = NotifyUserAndServer p
+andProcessWith (NotifyUserAndServer p1) (NotifyUser p2)          = NotifyUserAndServer (p1 <> p2)
+andProcessWith (NotifyUserAndServer p1) (NotifyUserAndServer p2) = NotifyUserAndServer (p1 <> p2)
+andProcessWith (NotifyUser p1) (NotifyUserAndServer p2)          = NotifyUser (p1 <> p2)
+andProcessWith (NotifyUser p1) (NotifyUser p2)                   = NotifyUser (p1 <> p2)
+andProcessWith (NotifyUser p) UpdateServerViewed                 = NotifyUserAndServer p
+andProcessWith UpdateServerViewed UpdateServerViewed             = UpdateServerViewed
+andProcessWith UpdateServerViewed (NotifyUserAndServer p)        = NotifyUserAndServer p
+andProcessWith UpdateServerViewed (NotifyUser p)                 = NotifyUserAndServer p
+
+-- | postProcessMessageAdd performs the actual actions indicated by
+-- the corresponding input value.
+postProcessMessageAdd :: PostProcessMessageAdd -> MH ()
+postProcessMessageAdd ppma = postOp ppma
+    where
+        postOp NoAction                = return ()
+        postOp UpdateServerViewed      = updateViewed False
+        postOp (NotifyUser p)          = maybeRingBell >> mapM_ maybeNotify p
+        postOp (NotifyUserAndServer p) = updateViewed False >> maybeRingBell >> mapM_ maybeNotify p
+
+maybeNotify :: PostToAdd -> MH ()
+maybeNotify (OldPost _) = do
+    return ()
+maybeNotify (RecentPost post mentioned) = runNotifyCommand post mentioned
+
+maybeRingBell :: MH ()
+maybeRingBell = do
+    doBell <- use (csResources.crConfiguration.to configActivityBell)
+    when doBell $ do
+        vty <- mh getVtyHandle
+        liftIO $ ringTerminalBell $ outputIface vty
+
+-- | When we add posts to the application state, we either get them
+-- from the server during scrollback fetches (here called 'OldPost') or
+-- we get them from websocket events when they are posted in real time
+-- (here called 'RecentPost').
+data PostToAdd =
+    OldPost Post
+    -- ^ A post from the server's history
+    | RecentPost Post Bool
+    -- ^ A message posted to the channel since the user connected, along
+    -- with a flag indicating whether the post triggered any of the
+    -- user's mentions. We need an extra flag because the server
+    -- determines whether the post has any mentions, and that data is
+    -- only available in websocket events (and then provided to this
+    -- constructor).
+
+runNotifyCommand :: Post -> Bool -> MH ()
+runNotifyCommand post mentioned = do
+    outputChan <- use (csResources.crSubprocessLog)
+    st <- use id
+    notifyCommand <- use (csResources.crConfiguration.to configActivityNotifyCommand)
+    case notifyCommand of
+        Nothing -> return ()
+        Just cmd ->
+            doAsyncWith Preempt $ do
+                let messageString = T.unpack $ sanitizeUserText $ postMessage post
+                    notified = if mentioned then "1" else "2"
+                    sender = T.unpack $ maybePostUsername st post
+                runLoggedCommand outputChan (T.unpack cmd)
+                                 [notified, sender, messageString] Nothing Nothing
+                return Nothing
+
+maybePostUsername :: ChatState -> Post -> T.Text
+maybePostUsername st p =
+    fromMaybe T.empty $ do
+        uId <- postUserId p
+        usernameForUserId uId st
+
+-- | Fetches additional message history for the current channel.  This
+-- is generally called when in ChannelScroll mode, in which state the
+-- output is cached and seen via a scrolling viewport; new messages
+-- received in this mode are not normally shown, but this explicit
+-- user-driven fetch should be displayed, so this also invalidates the
+-- cache.
+--
+-- This function assumes it is being called to add "older" messages to
+-- the message history (i.e. near the beginning of the known
+-- messages).  It will normally try to overlap the fetch with the
+-- known existing messages so that when the fetch results are
+-- processed (which should be a contiguous set of messages as provided
+-- by the server) there will be an overlap with existing messages; if
+-- there is no overlap, then a special "gap" must be inserted in the
+-- area between the existing messages and the newly fetched messages
+-- to indicate that this client does not know if there are missing
+-- messages there or not.
+--
+-- In order to achieve an overlap, this code attempts to get the
+-- second oldest messages as the message ID to pass to the server as
+-- the "older than" marker ('postQueryBefore'), so that the oldest
+-- message here overlaps with the fetched results to ensure no gap
+-- needs to be inserted.  However, there may already be a gap between
+-- the oldest and second-oldest messages, so this code must actually
+-- search for the first set of two *contiguous* messages it is aware
+-- of to avoid adding additional gaps. (It's OK if gaps are added, but
+-- the user must explicitly request a check for messages in order to
+-- eliminate them, so it's better to avoid adding them in the first
+-- place).  This code is nearly always used to extend the older
+-- history of a channel that information has already been retrieved
+-- from, so it's almost certain that there are at least two contiguous
+-- messages to use as a starting point, but exceptions are new
+-- channels and empty channels.
+asyncFetchMoreMessages :: MH ()
+asyncFetchMoreMessages = do
+    cId  <- use csCurrentChannelId
+    withChannel cId $ \chan ->
+        let offset = max 0 $ length (chan^.ccContents.cdMessages) - 2
+            page = offset `div` pageAmount
+            usefulMsgs = getTwoContiguousPosts Nothing (chan^.ccContents.cdMessages.to reverseMessages)
+            sndOldestId = (messagePostId . snd) =<< usefulMsgs
+            query = MM.defaultPostQuery
+                      { MM.postQueryPage = maybe (Just page) (const Nothing) sndOldestId
+                      , MM.postQueryPerPage = Just pageAmount
+                      , MM.postQueryBefore = sndOldestId
+                      }
+            addTrailingGap = MM.postQueryBefore query == Nothing &&
+                             MM.postQueryPage query == Just 0
+        in doAsyncChannelMM Preempt cId
+               (\s c -> MM.mmGetPostsForChannel c query s)
+               (\c p -> Just $ do
+                   pp <- addObtainedMessages c (-pageAmount) addTrailingGap p
+                   postProcessMessageAdd pp
+                   mh $ invalidateCacheEntry (ChannelMessages cId))
+
+
+-- | Given a starting point and a direction to move from that point,
+-- returns the closest two adjacent messages on that direction (as a
+-- tuple of closest and next-closest), or Nothing if there are no
+-- adjacent messages in the indicated direction.
+getTwoContiguousPosts :: SeqDirection dir =>
+                         Maybe Message
+                      -> DirectionalSeq dir Message
+                      -> Maybe (Message, Message)
+getTwoContiguousPosts startMsg msgs =
+  let go start =
+        do anchor <- getRelMessageId (_mMessageId =<< start) msgs
+           hinge <- getRelMessageId (anchor^.mMessageId) msgs
+           if isGap anchor || isGap hinge
+             then go $ Just anchor
+             else Just (anchor, hinge)
+  in go startMsg
+
+
+asyncFetchMessagesForGap :: ChannelId -> Message -> MH ()
+asyncFetchMessagesForGap cId gapMessage =
+  when (isGap gapMessage) $
+  withChannel cId $ \chan ->
+    let offset = max 0 $ length (chan^.ccContents.cdMessages) - 2
+        page = offset `div` pageAmount
+        chanMsgs = chan^.ccContents.cdMessages
+        fromMsg = Just gapMessage
+        fetchNewer = case gapMessage^.mType of
+                       C UnknownGapAfter -> True
+                       C UnknownGapBefore -> False
+                       _ -> error "fetch gap messages: unknown gap message type"
+        baseId = messagePostId . snd =<<
+                 case gapMessage^.mType of
+                    C UnknownGapAfter -> getTwoContiguousPosts fromMsg $
+                                         reverseMessages chanMsgs
+                    C UnknownGapBefore -> getTwoContiguousPosts fromMsg chanMsgs
+                    _ -> error "fetch gap messages: unknown gap message type"
+        query = MM.defaultPostQuery
+                { MM.postQueryPage = maybe (Just page) (const Nothing) baseId
+                , MM.postQueryPerPage = Just pageAmount
+                , MM.postQueryBefore = if fetchNewer then Nothing else baseId
+                , MM.postQueryAfter = if fetchNewer then baseId else Nothing
+                }
+        addTrailingGap = MM.postQueryBefore query == Nothing &&
+                         MM.postQueryPage query == Just 0
+    in doAsyncChannelMM Preempt cId
+       (\s c -> MM.mmGetPostsForChannel c query s)
+       (\c p -> Just $ do
+           void $ addObtainedMessages c (-pageAmount) addTrailingGap p
+           mh $ invalidateCacheEntry (ChannelMessages cId))
+
+-- | Given a particular message ID, this fetches n messages before and
+-- after immediately before and after the specified message in order
+-- to establish some context for that message.  This is frequently
+-- used as a background operation when looking at search or flag
+-- results so that jumping to message select mode for one of those
+-- messages will show a bit of context (and it also prevents showing
+-- gap messages for adjacent targets).
+--
+-- The result will be adding at most 2n messages to the channel, with
+-- the input post ID being somewhere in the middle of the added
+-- messages.
+--
+-- Note that this fetch will add messages to the channel, but it
+-- performs no notifications or updates of new-unread indicators
+-- because it is assumed to be used for non-current (previously-seen)
+-- messages in background mode.
+asyncFetchMessagesSurrounding :: ChannelId -> PostId -> MH ()
+asyncFetchMessagesSurrounding cId pId = do
+    let query = MM.defaultPostQuery
+          { MM.postQueryBefore = Just pId
+          , MM.postQueryPerPage = Just reqAmt
+          }
+        reqAmt = 5  -- both before and after
+    doAsyncChannelMM Preempt cId
+      -- first get some messages before the target, no overlap
+      (\s c -> MM.mmGetPostsForChannel c query s)
+      (\c p -> Just $ do
+          let last2ndId = secondToLastPostId p
+          void $ addObtainedMessages c (-reqAmt) False p
+          mh $ invalidateCacheEntry (ChannelMessages cId)
+          -- now start 2nd from end of this fetch to fetch some
+          -- messages forward, also overlapping with this fetch and
+          -- the original message ID to eliminate all gaps in this
+          -- surrounding set of messages.
+          let query' = MM.defaultPostQuery
+                       { MM.postQueryAfter = last2ndId
+                       , MM.postQueryPerPage = Just $ reqAmt + 2
+                       }
+          doAsyncChannelMM Preempt cId
+            (\s' c' -> MM.mmGetPostsForChannel c' query' s')
+            (\c' p' -> Just $ do
+                void $ addObtainedMessages c' (reqAmt + 2) False p'
+                mh $ invalidateCacheEntry (ChannelMessages cId)
+            )
+      )
+      where secondToLastPostId posts =
+              let pl = toList $ postsOrder posts
+              in if length pl > 1 then Just $ last $ init pl else Nothing
+
+
+fetchVisibleIfNeeded :: MH ()
+fetchVisibleIfNeeded = do
+    sts <- use csConnectionStatus
+    when (sts == Connected) $ do
+        cId <- use csCurrentChannelId
+        withChannel cId $ \chan ->
+            let msgs = chan^.ccContents.cdMessages.to reverseMessages
+                (numRemaining, gapInDisplayable, _, rel'pId, overlap) =
+                    foldl gapTrail (numScrollbackPosts, False, Nothing, Nothing, 2) msgs
+                gapTrail a@(_,  True, _, _, _) _ = a
+                gapTrail a@(0,     _, _, _, _) _ = a
+                gapTrail   (a, False, b, c, d) m | isGap m = (a, True, b, c, d)
+                gapTrail (remCnt, _, prev'pId, prev''pId, ovl) msg =
+                    (remCnt - 1, False, msg^.mMessageId <|> prev'pId, prev'pId <|> prev''pId,
+                     ovl + if not (isPostMessage msg) then 1 else 0)
+                numToReq = numRemaining + overlap
+                query = MM.defaultPostQuery
+                        { MM.postQueryPage    = Just 0
+                        , MM.postQueryPerPage = Just numToReq
+                        }
+                finalQuery = case rel'pId of
+                               Just (MessagePostId pid) -> query { MM.postQueryBefore = Just pid }
+                               _ -> query
+                op = \s c -> MM.mmGetPostsForChannel c finalQuery s
+                addTrailingGap = MM.postQueryBefore finalQuery == Nothing &&
+                                 MM.postQueryPage finalQuery == Just 0
+            in when ((not $ chan^.ccContents.cdFetchPending) && gapInDisplayable) $ do
+                      csChannel(cId).ccContents.cdFetchPending .= True
+                      doAsyncChannelMM Preempt cId op
+                          (\c p -> Just $ do
+                              addObtainedMessages c (-numToReq) addTrailingGap p >>= postProcessMessageAdd
+                              csChannel(c).ccContents.cdFetchPending .= False)
+
+asyncFetchAttachments :: Post -> MH ()
+asyncFetchAttachments p = do
+    let cId = p^.postChannelIdL
+        pId = p^.postIdL
+    session <- getSession
+    host <- use (csResources.crConn.cdHostnameL)
+    F.forM_ (p^.postFileIdsL) $ \fId -> doAsyncWith Normal $ do
+        info <- MM.mmGetMetadataForFile fId session
+        let scheme = "https://"
+            attUrl = scheme <> host <> urlForFile fId
+            attachment = mkAttachment (fileInfoName info) attUrl fId
+            addIfMissing a as =
+                if isNothing $ Seq.elemIndexL a as
+                then a Seq.<| as
+                else as
+            addAttachment m
+                | m^.mMessageId == Just (MessagePostId pId) =
+                    m & mAttachments %~ (addIfMissing attachment)
+                | otherwise =
+                    m
+        return $ Just $ do
+            csChannel(cId).ccContents.cdMessages.traversed %= addAttachment
+            mh $ invalidateCacheEntry $ ChannelMessages cId
+
+-- | Given a post ID, switch to that post's channel and select the post
+-- in message selection mode.
+--
+-- This function will do what it can to honor the request even when we
+-- don't know about the post because it hasn't been fetched, or when
+-- the post is in a channel that we aren't a member of. In each case a
+-- reasonable effort will be made (fetch the post, join the channel)
+-- before giving up.
+jumpToPost :: PostId -> MH ()
+jumpToPost pId = do
+    st <- use id
+    case getMessageForPostId st pId of
+      Just msg ->
+        case msg ^. mChannelId of
+          Just cId -> do
+              -- Are we a member of the channel?
+              case findChannelById cId (st^.csChannels) of
+                  Nothing ->
+                      joinChannel' cId (Just $ jumpToPost pId)
+                  Just _ -> do
+                      setFocus cId
+                      setMode MessageSelect
+                      csMessageSelect .= MessageSelectState (msg^.mMessageId)
+          Nothing ->
+            error "INTERNAL: selected Post ID not associated with a channel"
+      Nothing -> do
+          session <- getSession
+          doAsyncWith Preempt $ do
+              result <- try $ MM.mmGetPost pId session
+              return $ Just $ do
+                  case result of
+                      Right p -> do
+                          -- Are we a member of the channel?
+                          case findChannelById (postChannelId p) (st^.csChannels) of
+                              -- If not, join it and then try jumping to
+                              -- the post if the channel join is successful.
+                              Nothing -> do
+                                  joinChannel' (postChannelId p) (Just $ jumpToPost pId)
+                              -- Otherwise add the post to the state and
+                              -- then jump.
+                              Just _ -> do
+                                  void $ addMessageToState True True (OldPost p)
+                                  jumpToPost pId
+                      Left (_::SomeException) ->
+                          postErrorMessage' "Could not fetch linked post"
diff --git a/src/Matterhorn/State/Messages.hs-boot b/src/Matterhorn/State/Messages.hs-boot
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Messages.hs-boot
@@ -0,0 +1,11 @@
+module Matterhorn.State.Messages
+  ( fetchVisibleIfNeeded
+  )
+where
+
+import Matterhorn.Types ( MH )
+
+-- State.Channels needs this in setFocusWith, but that creates an
+-- import cycle because several things in State.Messages need things in
+-- State.Channels.
+fetchVisibleIfNeeded :: MH ()
diff --git a/src/Matterhorn/State/NotifyPrefs.hs b/src/Matterhorn/State/NotifyPrefs.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/NotifyPrefs.hs
@@ -0,0 +1,88 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+module Matterhorn.State.NotifyPrefs
+    ( enterEditNotifyPrefsMode
+    , exitEditNotifyPrefsMode
+    )
+where
+
+import Prelude ()
+import Matterhorn.Prelude
+
+import Network.Mattermost.Types ( ChannelNotifyProps
+                                , User(..)
+                                , UserNotifyProps(..)
+                                , Type(..)
+                                , channelNotifyPropsMarkUnread
+                                , channelNotifyPropsIgnoreChannelMentions
+                                , WithDefault(..)
+                                , NotifyOption(..)
+                                )
+
+import Brick
+import Brick.Forms
+import Lens.Micro.Platform ( Lens', (.=), lens )
+
+import Matterhorn.Types
+
+
+muteLens :: Lens' ChannelNotifyProps Bool
+muteLens = lens (\props -> props^.channelNotifyPropsMarkUnreadL == IsValue NotifyOptionMention)
+           (\props muted -> props { channelNotifyPropsMarkUnread =
+                                          if muted
+                                          then IsValue NotifyOptionMention
+                                          else IsValue NotifyOptionAll
+                                  })
+
+channelMentionLens :: Lens' ChannelNotifyProps Bool
+channelMentionLens = lens (\props -> props^.channelNotifyPropsIgnoreChannelMentionsL == IsValue True)
+                     (\props ignoreChannelMentions ->
+                          props { channelNotifyPropsIgnoreChannelMentions = if ignoreChannelMentions
+                                                                            then IsValue True
+                                                                            else Default
+                                })
+
+notifyOptionName :: NotifyOption -> Text
+notifyOptionName NotifyOptionAll = "All activity"
+notifyOptionName NotifyOptionMention = "Mentions"
+notifyOptionName NotifyOptionNone = "Never"
+
+mkNotifyButtons :: ((WithDefault NotifyOption) -> Name)
+                -> Lens' ChannelNotifyProps (WithDefault NotifyOption)
+                -> NotifyOption
+                -> ChannelNotifyProps
+                -> FormFieldState ChannelNotifyProps e Name
+mkNotifyButtons mkName l globalDefault =
+    let optTuple opt = (IsValue opt, mkName $ IsValue opt, notifyOptionName opt)
+        defaultField = (Default, mkName Default, "Global default (" <> notifyOptionName globalDefault <> ")")
+        nonDefault = optTuple <$> [ NotifyOptionAll
+                                  , NotifyOptionMention
+                                  , NotifyOptionNone
+                                  ]
+    in radioField l (defaultField : nonDefault)
+
+notifyPrefsForm :: UserNotifyProps -> ChannelNotifyProps -> Form ChannelNotifyProps e Name
+notifyPrefsForm globalDefaults =
+    newForm [ checkboxField muteLens MuteToggleField "Mute channel"
+            , (padTop $ Pad 1) @@= checkboxField channelMentionLens ChannelMentionsField "Ignore channel mentions"
+            , radioStyle "Desktop notifications" @@=
+                mkNotifyButtons DesktopNotificationsField channelNotifyPropsDesktopL (userNotifyPropsDesktop globalDefaults)
+            , radioStyle "Push notifications" @@=
+                mkNotifyButtons PushNotificationsField channelNotifyPropsPushL (userNotifyPropsPush globalDefaults)
+            ]
+    where radioStyle label = (padTop $ Pad 1 ) . (str label <=>) . (padLeft $ Pad 1)
+
+enterEditNotifyPrefsMode :: MH ()
+enterEditNotifyPrefsMode = do
+    chanInfo <- use (csCurrentChannel.ccInfo)
+    case chanInfo^.cdType of
+      Direct -> mhError $ GenericError "Cannot open notification preferences for DM channel."
+      _ -> do
+        let props = chanInfo^.cdNotifyProps
+        user <- use csMe
+        csNotifyPrefs .= (Just (notifyPrefsForm (userNotifyProps user) props))
+        setMode EditNotifyPrefs
+
+exitEditNotifyPrefsMode :: MH ()
+exitEditNotifyPrefsMode = do
+    setMode Main
diff --git a/src/Matterhorn/State/PostListOverlay.hs b/src/Matterhorn/State/PostListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/PostListOverlay.hs
@@ -0,0 +1,148 @@
+module Matterhorn.State.PostListOverlay
+  ( enterFlaggedPostListMode
+  , enterPinnedPostListMode
+  , enterSearchResultPostListMode
+  , postListJumpToCurrent
+  , postListSelectUp
+  , postListSelectDown
+  , postListUnflagSelected
+  , exitPostListMode
+  )
+where
+
+import           GHC.Exts ( IsList(..) )
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.Foldable as F
+import qualified Data.Text as T
+import           Lens.Micro.Platform ( (.=) )
+import           Network.Mattermost.Endpoints
+import           Network.Mattermost.Types
+
+import           Matterhorn.State.Messages ( jumpToPost )
+import           Matterhorn.State.Common
+import           Matterhorn.State.MessageSelect
+import           Matterhorn.State.Messages ( addObtainedMessages
+                                           , asyncFetchMessagesSurrounding )
+import           Matterhorn.Types
+import           Matterhorn.Types.DirectionalSeq (emptyDirSeq)
+
+
+-- | Create a PostListOverlay with the given content description and
+-- with a specified list of messages.
+enterPostListMode ::  PostListContents -> Messages -> MH ()
+enterPostListMode contents msgs = do
+  csPostListOverlay.postListPosts .= msgs
+  let mlatest = getLatestPostMsg msgs
+      pId = mlatest >>= messagePostId
+      cId = mlatest >>= \m -> m^.mChannelId
+  csPostListOverlay.postListSelected .= pId
+  setMode $ PostListOverlay contents
+  case (pId, cId) of
+    (Just p, Just c) -> asyncFetchMessagesSurrounding c p
+    _ -> return ()
+
+-- | Clear out the state of a PostListOverlay
+exitPostListMode :: MH ()
+exitPostListMode = do
+  csPostListOverlay.postListPosts .= emptyDirSeq
+  csPostListOverlay.postListSelected .= Nothing
+  setMode Main
+
+
+createPostList :: PostListContents -> (Session -> IO Posts) -> MH ()
+createPostList contentsType fetchOp = do
+  session <- getSession
+  doAsyncWith Preempt $ do
+    posts <- fetchOp session
+    return $ Just $ do
+      messages <- installMessagesFromPosts posts
+      -- n.b. do not use addNewPostedMessage because these messages
+      -- are not new, and so no notifications or channel highlighting
+      -- or other post-processing should be performed.
+      let plist = F.toList $ postsPosts posts
+          postsSpec p = Posts { postsPosts = fromList [(postId p, p)]
+                              , postsOrder = fromList [postId p]
+                              }
+      mapM_ (\p -> addObtainedMessages (postChannelId p) 0 False $ postsSpec p) plist
+      enterPostListMode contentsType messages
+
+
+-- | Create a PostListOverlay with flagged messages from the server.
+enterFlaggedPostListMode :: MH ()
+enterFlaggedPostListMode = createPostList PostListFlagged $
+                           mmGetListOfFlaggedPosts UserMe defaultFlaggedPostsQuery
+
+-- | Create a PostListOverlay with pinned messages from the server for
+-- the current channel.
+enterPinnedPostListMode :: MH ()
+enterPinnedPostListMode = do
+    cId <- use csCurrentChannelId
+    createPostList (PostListPinned cId) $ mmGetChannelPinnedPosts cId
+
+-- | Create a PostListOverlay with post search result messages from the
+-- server.
+enterSearchResultPostListMode :: Text -> MH ()
+enterSearchResultPostListMode terms
+  | T.null (T.strip terms) = postInfoMessage "Search command requires at least one search term."
+  | otherwise = do
+      enterPostListMode (PostListSearch terms True) noMessages
+      tId <- gets myTeamId
+      createPostList (PostListSearch terms False) $
+        mmSearchForTeamPosts tId (SearchPosts terms False)
+
+
+-- | Move the selection up in the PostListOverlay, which corresponds
+-- to finding a chronologically /newer/ message.
+postListSelectUp :: MH ()
+postListSelectUp = do
+  selId <- use (csPostListOverlay.postListSelected)
+  posts <- use (csPostListOverlay.postListPosts)
+  let nextMsg = getNextMessage (MessagePostId <$> selId) posts
+  case nextMsg of
+    Nothing -> return ()
+    Just m -> do
+      let pId = m^.mMessageId >>= messageIdPostId
+      csPostListOverlay.postListSelected .= pId
+      case (m^.mChannelId, pId) of
+        (Just c, Just p) -> asyncFetchMessagesSurrounding c p
+        o -> mhLog LogError
+             (T.pack $ "postListSelectUp" <>
+              " unable to get channel or post ID: " <> show o)
+
+-- | Move the selection down in the PostListOverlay, which corresponds
+-- to finding a chronologically /old/ message.
+postListSelectDown :: MH ()
+postListSelectDown = do
+  selId <- use (csPostListOverlay.postListSelected)
+  posts <- use (csPostListOverlay.postListPosts)
+  let prevMsg = getPrevMessage (MessagePostId <$> selId) posts
+  case prevMsg of
+    Nothing -> return ()
+    Just m -> do
+      let pId = m^.mMessageId >>= messageIdPostId
+      csPostListOverlay.postListSelected .= pId
+      case (m^.mChannelId, pId) of
+        (Just c, Just p) -> asyncFetchMessagesSurrounding c p
+        o -> mhLog LogError
+             (T.pack $ "postListSelectDown" <>
+              " unable to get channel or post ID: " <> show o)
+
+-- | Unflag the post currently selected in the PostListOverlay, if any
+postListUnflagSelected :: MH ()
+postListUnflagSelected = do
+  msgId <- use (csPostListOverlay.postListSelected)
+  case msgId of
+    Nothing  -> return ()
+    Just pId -> flagMessage pId False
+
+
+-- | Jumps to the specified message in the message's main channel
+-- display and changes to MessageSelectState.
+postListJumpToCurrent :: MH ()
+postListJumpToCurrent = do
+  msgId <- use (csPostListOverlay.postListSelected)
+  case msgId of
+    Nothing  -> return ()
+    Just pId -> jumpToPost pId
diff --git a/src/Matterhorn/State/ReactionEmojiListOverlay.hs b/src/Matterhorn/State/ReactionEmojiListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/ReactionEmojiListOverlay.hs
@@ -0,0 +1,125 @@
+{-# LANGUAGE TupleSections #-}
+module Matterhorn.State.ReactionEmojiListOverlay
+  ( enterReactionEmojiListOverlayMode
+
+  , reactionEmojiListSelectDown
+  , reactionEmojiListSelectUp
+  , reactionEmojiListPageDown
+  , reactionEmojiListPageUp
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Brick.Widgets.List as L
+import qualified Data.Vector as Vec
+import qualified Data.Text as T
+import qualified Data.Map as M
+import qualified Data.Set as Set
+import           Data.Function ( on )
+import           Data.List ( nubBy )
+import           Lens.Micro.Platform ( to )
+
+import           Network.Mattermost.Types
+import           Network.Mattermost.Endpoints ( mmPostReaction, mmDeleteReaction )
+
+import           Matterhorn.Emoji
+import           Matterhorn.State.ListOverlay
+import           Matterhorn.State.MessageSelect
+import           Matterhorn.State.Async
+import           Matterhorn.Types
+
+
+enterReactionEmojiListOverlayMode :: MH ()
+enterReactionEmojiListOverlayMode = do
+    selectedMessage <- use (to getSelectedMessage)
+    case selectedMessage of
+        Nothing -> return ()
+        Just msg -> do
+            em <- use (csResources.crEmoji)
+            myId <- gets myUserId
+            enterListOverlayMode csReactionEmojiListOverlay ReactionEmojiListOverlay
+                () enterHandler (fetchResults myId msg em)
+
+enterHandler :: (Bool, T.Text) -> MH Bool
+enterHandler (mine, e) = do
+    session <- getSession
+    myId <- gets myUserId
+
+    selectedMessage <- use (to getSelectedMessage)
+    case selectedMessage of
+        Nothing -> return False
+        Just m -> do
+            case m^.mOriginalPost of
+                Nothing -> return False
+                Just p -> do
+                    case mine of
+                        False ->
+                            doAsyncWith Preempt $ do
+                                mmPostReaction (postId p) myId e session
+                                return Nothing
+                        True ->
+                            doAsyncWith Preempt $ do
+                                mmDeleteReaction (postId p) myId e session
+                                return Nothing
+                    return True
+
+fetchResults :: UserId
+             -- ^ My user ID, so we can see which reactions I haven't
+             -- posted
+             -> Message
+             -- ^ The selected message, so we can include its current
+             -- reactions in the list
+             -> EmojiCollection
+             -- ^ The emoji collection
+             -> ()
+             -- ^ The scope to search
+             -> Session
+             -- ^ The connection session
+             -> Text
+             -- ^ The search string
+             -> IO (Vec.Vector (Bool, T.Text))
+fetchResults myId msg em () session searchString = do
+    let currentReactions = [ (myId `Set.member` uIds, k)
+                           | (k, uIds) <- M.toList (msg^.mReactions)
+                           ]
+        matchingCurrentOtherReactions = [ (mine, r) | (mine, r) <- currentReactions
+                                        , matchesEmoji searchString r
+                                        , not mine
+                                        ]
+        matchingCurrentMyReactions = [ (mine, r) | (mine, r) <- currentReactions
+                                     , matchesEmoji searchString r
+                                     , mine
+                                     ]
+    serverMatches <- getMatchingEmoji session em searchString
+    return $ Vec.fromList $ nubBy ((==) `on` snd) $
+        matchingCurrentOtherReactions <> matchingCurrentMyReactions <> ((False,) <$> serverMatches)
+
+-- | Move the selection up in the emoji list overlay by one emoji.
+reactionEmojiListSelectUp :: MH ()
+reactionEmojiListSelectUp = reactionEmojiListMove L.listMoveUp
+
+-- | Move the selection down in the emoji list overlay by one emoji.
+reactionEmojiListSelectDown :: MH ()
+reactionEmojiListSelectDown = reactionEmojiListMove L.listMoveDown
+
+-- | Move the selection up in the emoji list overlay by a page of emoji
+-- (ReactionEmojiListPageSize).
+reactionEmojiListPageUp :: MH ()
+reactionEmojiListPageUp = reactionEmojiListMove (L.listMoveBy (-1 * reactionEmojiListPageSize))
+
+-- | Move the selection down in the emoji list overlay by a page of emoji
+-- (ReactionEmojiListPageSize).
+reactionEmojiListPageDown :: MH ()
+reactionEmojiListPageDown = reactionEmojiListMove (L.listMoveBy reactionEmojiListPageSize)
+
+-- | Transform the emoji list results in some way, e.g. by moving the
+-- cursor, and then check to see whether the modification warrants a
+-- prefetch of more search results.
+reactionEmojiListMove :: (L.List Name (Bool, T.Text) -> L.List Name (Bool, T.Text)) -> MH ()
+reactionEmojiListMove = listOverlayMove csReactionEmojiListOverlay
+
+-- | The number of emoji in a "page" for cursor movement purposes.
+reactionEmojiListPageSize :: Int
+reactionEmojiListPageSize = 10
diff --git a/src/Matterhorn/State/Reactions.hs b/src/Matterhorn/State/Reactions.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Reactions.hs
@@ -0,0 +1,52 @@
+module Matterhorn.State.Reactions
+  ( asyncFetchReactionsForPost
+  , addReactions
+  , removeReaction
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Main ( invalidateCacheEntry )
+import qualified Data.Map.Strict as Map
+import           Lens.Micro.Platform
+import qualified Data.Set as S
+
+import           Network.Mattermost.Endpoints
+import           Network.Mattermost.Lenses
+import           Network.Mattermost.Types
+
+import           Matterhorn.State.Async
+import           Matterhorn.State.Common ( fetchMentionedUsers )
+import           Matterhorn.Types
+
+
+asyncFetchReactionsForPost :: ChannelId -> Post -> MH ()
+asyncFetchReactionsForPost cId p
+  | not (p^.postHasReactionsL) = return ()
+  | otherwise = doAsyncChannelMM Normal cId
+        (\s _ -> fmap toList (mmGetReactionsForPost (p^.postIdL) s))
+        (\_ rs -> Just $ addReactions cId rs)
+
+addReactions :: ChannelId -> [Reaction] -> MH ()
+addReactions cId rs = do
+    mh $ invalidateCacheEntry $ ChannelMessages cId
+    csChannel(cId).ccContents.cdMessages %= fmap upd
+    let mentions = S.fromList $ UserIdMention <$> reactionUserId <$> rs
+    fetchMentionedUsers mentions
+  where upd msg = msg & mReactions %~ insertAll (msg^.mMessageId)
+        insert mId r
+          | mId == Just (MessagePostId (r^.reactionPostIdL)) =
+              Map.insertWith S.union (r^.reactionEmojiNameL) (S.singleton $ r^.reactionUserIdL)
+          | otherwise = id
+        insertAll mId msg = foldr (insert mId) msg rs
+
+removeReaction :: Reaction -> ChannelId -> MH ()
+removeReaction r cId = do
+    mh $ invalidateCacheEntry $ ChannelMessages cId
+    csChannel(cId).ccContents.cdMessages %= fmap upd
+  where upd m | m^.mMessageId == Just (MessagePostId $ r^.reactionPostIdL) =
+                  m & mReactions %~ (Map.alter delReaction (r^.reactionEmojiNameL))
+              | otherwise = m
+        delReaction mUs = S.delete (r^.reactionUserIdL) <$> mUs
diff --git a/src/Matterhorn/State/Setup.hs b/src/Matterhorn/State/Setup.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Setup.hs
@@ -0,0 +1,281 @@
+{-# LANGUAGE TypeFamilies #-}
+module Matterhorn.State.Setup
+  ( setupState
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.BChan ( newBChan )
+import           Brick.Themes ( themeToAttrMap, loadCustomizations )
+import qualified Control.Concurrent.STM as STM
+import           Data.Maybe ( fromJust )
+import qualified Data.Sequence as Seq
+import qualified Data.Text as T
+import           Data.Time.Clock ( getCurrentTime )
+import qualified Graphics.Vty as Vty
+import           Lens.Micro.Platform ( (.~) )
+import           System.Exit ( exitFailure, exitSuccess )
+import           System.FilePath ( (</>), isRelative, dropFileName )
+
+import           Network.Mattermost.Endpoints
+import           Network.Mattermost.Types
+
+import           Matterhorn.Config
+import           Matterhorn.InputHistory
+import           Matterhorn.LastRunState
+import           Matterhorn.Login
+import           Matterhorn.State.Flagging
+import           Matterhorn.State.Messages ( fetchVisibleIfNeeded )
+import           Matterhorn.State.Setup.Threads
+import           Matterhorn.TeamSelect
+import           Matterhorn.Themes
+import           Matterhorn.TimeUtils ( lookupLocalTimeZone )
+import           Matterhorn.Types
+import           Matterhorn.Types.Common
+import           Matterhorn.Emoji
+import           Matterhorn.FilePaths ( userEmojiJsonPath, bundledEmojiJsonPath )
+import qualified Matterhorn.Zipper as Z
+
+
+incompleteCredentials :: Config -> ConnectionInfo
+incompleteCredentials config = ConnectionInfo
+  { _ciHostname = fromMaybe "" (configHost config)
+  , _ciPort     = configPort config
+  , _ciUrlPath  = fromMaybe "" (configUrlPath config)
+  , _ciUsername = fromMaybe "" (configUser config)
+  , _ciPassword = case configPass config of
+                    Just (PasswordString s) -> s
+                    _                       -> ""
+  , _ciAccessToken = case configToken config of
+                       Just (TokenString s) -> s
+                       _                    -> ""
+  , _ciType     = configConnectionType config
+  }
+
+apiLogEventToLogMessage :: LogEvent -> IO LogMessage
+apiLogEventToLogMessage ev = do
+    now <- getCurrentTime
+    let msg = T.pack $ "Function: " <> logFunction ev <>
+                       ", event: " <> show (logEventType ev)
+    return $ LogMessage { logMessageCategory = LogAPI
+                        , logMessageText = msg
+                        , logMessageContext = Nothing
+                        , logMessageTimestamp = now
+                        }
+
+setupState :: IO Vty.Vty -> Maybe FilePath -> Config -> IO (ChatState, Vty.Vty)
+setupState mkVty mLogLocation config = do
+  initialVty <- mkVty
+
+  eventChan <- newBChan 2500
+  logMgr <- newLogManager eventChan (configLogMaxBufferSize config)
+
+  -- If we got an initial log location, start logging there.
+  case mLogLocation of
+      Nothing -> return ()
+      Just loc -> startLoggingToFile logMgr loc
+
+  let logApiEvent ev = apiLogEventToLogMessage ev >>= sendLogMessage logMgr
+      setLogger cd = cd `withLogger` logApiEvent
+
+  (mLastAttempt, loginVty) <- interactiveGetLoginSession initialVty mkVty
+                                                         setLogger
+                                                         logMgr
+                                                         (incompleteCredentials config)
+
+  let shutdown vty = do
+          Vty.shutdown vty
+          exitSuccess
+
+  (session, me, cd, mbTeam) <- case mLastAttempt of
+      Nothing ->
+          -- The user never attempted a connection and just chose to
+          -- quit.
+          shutdown loginVty
+      Just (AttemptFailed {}) ->
+          -- The user attempted a connection and failed, and then chose
+          -- to quit.
+          shutdown loginVty
+      Just (AttemptSucceeded _ cd sess user mbTeam) ->
+          -- The user attempted a connection and succeeded so continue
+          -- with setup.
+          return (sess, user, cd, mbTeam)
+
+  teams <- mmGetUsersTeams UserMe session
+  when (Seq.null teams) $ do
+      putStrLn "Error: your account is not a member of any teams"
+      exitFailure
+
+  (myTeam, teamSelVty) <- do
+      let foundTeam = do
+             tName <- mbTeam <|> configTeam config
+             let matchingTeam = listToMaybe $ filter matches $ toList teams
+                 matches t = (sanitizeUserText $ teamName t) == tName
+             matchingTeam
+
+      case foundTeam of
+          Just t -> return (t, loginVty)
+          Nothing -> do
+              (mTeam, vty) <- interactiveTeamSelection loginVty mkVty $ toList teams
+              case mTeam of
+                  Nothing -> shutdown vty
+                  Just team -> return (team, vty)
+
+  userStatusChan <- STM.newTChanIO
+  slc <- STM.newTChanIO
+  wac <- STM.newTChanIO
+
+  prefs <- mmGetUsersPreferences UserMe session
+  let userPrefs = setUserPreferences prefs defaultUserPreferences
+      themeName = case configTheme config of
+          Nothing -> internalThemeName defaultTheme
+          Just t -> t
+      baseTheme = internalTheme $ fromMaybe defaultTheme (lookupTheme themeName)
+
+  -- Did the configuration specify a theme customization file? If so,
+  -- load it and customize the theme.
+  custTheme <- case configThemeCustomizationFile config of
+      Nothing -> return baseTheme
+      Just path ->
+          -- If we have no configuration path (i.e. we used the default
+          -- config) then ignore theme customization.
+          let pathStr = T.unpack path
+          in if isRelative pathStr && isNothing (configAbsPath config)
+             then return baseTheme
+             else do
+                 let absPath = if isRelative pathStr
+                               then (dropFileName $ fromJust $ configAbsPath config) </> pathStr
+                               else pathStr
+                 result <- loadCustomizations absPath baseTheme
+                 case result of
+                     Left e -> do
+                         Vty.shutdown teamSelVty
+                         putStrLn $ "Error loading theme customization from " <> show absPath <> ": " <> e
+                         exitFailure
+                     Right t -> return t
+
+  requestChan <- STM.atomically STM.newTChan
+
+  emoji <- either (const emptyEmojiCollection) id <$> do
+      result1 <- loadEmoji =<< userEmojiJsonPath
+      case result1 of
+          Right e -> return $ Right e
+          Left _ -> loadEmoji =<< bundledEmojiJsonPath
+
+  let cr = ChatResources { _crSession             = session
+                         , _crWebsocketThreadId   = Nothing
+                         , _crConn                = cd
+                         , _crRequestQueue        = requestChan
+                         , _crEventQueue          = eventChan
+                         , _crSubprocessLog       = slc
+                         , _crWebsocketActionChan = wac
+                         , _crTheme               = themeToAttrMap custTheme
+                         , _crStatusUpdateChan    = userStatusChan
+                         , _crConfiguration       = config
+                         , _crFlaggedPosts        = mempty
+                         , _crUserPreferences     = userPrefs
+                         , _crSyntaxMap           = mempty
+                         , _crLogManager          = logMgr
+                         , _crEmoji               = emoji
+                         }
+
+  st <- initializeState cr myTeam me
+
+  return (st, teamSelVty)
+
+initializeState :: ChatResources -> Team -> User -> IO ChatState
+initializeState cr myTeam me = do
+  let session = getResourceSession cr
+      requestChan = cr^.crRequestQueue
+      myTId = getId myTeam
+
+  -- Create a predicate to find the last selected channel by reading the
+  -- run state file. If unable to read or decode or validate the file, this
+  -- predicate is just `isTownSquare`.
+  isLastSelectedChannel <- do
+    result <- readLastRunState $ teamId myTeam
+    case result of
+      Right lrs | isValidLastRunState cr me lrs -> return $ \c ->
+           channelId c == lrs^.lrsSelectedChannelId
+      _ -> return isTownSquare
+
+  -- Get all channels, but filter down to just the one we want to start
+  -- in. We get all, rather than requesting by name or ID, because
+  -- we don't know whether the server will give us a last-viewed preference.
+  -- We first try to find a channel matching with the last selected channel ID,
+  -- failing which we look for the Town Square channel by name.
+  userChans <- mmGetChannelsForUser UserMe myTId session
+  let lastSelectedChans = Seq.filter isLastSelectedChannel userChans
+      chans = if Seq.null lastSelectedChans
+                then Seq.filter isTownSquare userChans
+                else lastSelectedChans
+
+  -- Since the only channel we are dealing with is by construction the
+  -- last channel, we don't have to consider other cases here:
+  chanPairs <- forM (toList chans) $ \c -> do
+      cChannel <- makeClientChannel (userId me) c
+      return (getId c, cChannel)
+
+  tz <- lookupLocalTimeZone
+  hist <- do
+      result <- readHistory
+      case result of
+          Left _ -> return newHistory
+          Right h -> return h
+
+  --------------------------------------------------------------------
+  -- Start background worker threads:
+  --
+  --  * Syntax definition loader
+  startSyntaxMapLoaderThread (cr^.crConfiguration) (cr^.crEventQueue)
+
+  --  * Main async queue worker thread
+  startAsyncWorkerThread (cr^.crConfiguration) (cr^.crRequestQueue) (cr^.crEventQueue)
+
+  --  * User status thread
+  startUserStatusUpdateThread (cr^.crStatusUpdateChan) session requestChan
+
+  --  * Refresher for users who are typing currently
+  when (configShowTypingIndicator (cr^.crConfiguration)) $
+    startTypingUsersRefreshThread requestChan
+
+  --  * Timezone change monitor
+  startTimezoneMonitorThread tz requestChan
+
+  --  * Subprocess logger
+  startSubprocessLoggerThread (cr^.crSubprocessLog) requestChan
+
+  --  * Spell checker and spell check timer, if configured
+  spResult <- maybeStartSpellChecker (cr^.crConfiguration) (cr^.crEventQueue)
+
+  -- End thread startup ----------------------------------------------
+
+  now <- getCurrentTime
+  let chanIds = mkChannelZipperList now (cr^.crConfiguration) Nothing (cr^.crUserPreferences) clientChans noUsers
+      chanZip = Z.fromList chanIds
+      clientChans = foldr (uncurry addChannel) noChannels chanPairs
+      startupState =
+          StartupStateInfo { startupStateResources      = cr
+                           , startupStateChannelZipper  = chanZip
+                           , startupStateConnectedUser  = me
+                           , startupStateTeam           = myTeam
+                           , startupStateTimeZone       = tz
+                           , startupStateInitialHistory = hist
+                           , startupStateSpellChecker   = spResult
+                           }
+
+  initialState <- newState startupState
+  let st = initialState & csChannels .~ clientChans
+
+  loadFlaggedMessages (cr^.crUserPreferences.userPrefFlaggedPostList) st
+
+  -- Trigger an initial websocket refresh
+  writeBChan (cr^.crEventQueue) RefreshWebsocketEvent
+
+  -- Refresh initial channel(s)
+  writeBChan (cr^.crEventQueue) $ RespEvent $ do
+      fetchVisibleIfNeeded
+
+  return st
diff --git a/src/Matterhorn/State/Setup/Threads.hs b/src/Matterhorn/State/Setup/Threads.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Setup/Threads.hs
@@ -0,0 +1,367 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Matterhorn.State.Setup.Threads
+  ( startUserStatusUpdateThread
+  , startTypingUsersRefreshThread
+  , startSubprocessLoggerThread
+  , startTimezoneMonitorThread
+  , maybeStartSpellChecker
+  , startAsyncWorkerThread
+  , startSyntaxMapLoaderThread
+  , module Matterhorn.State.Setup.Threads.Logging
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.BChan ( BChan )
+import           Brick.Main ( invalidateCache )
+import           Control.Concurrent ( threadDelay, forkIO )
+import qualified Control.Concurrent.STM as STM
+import           Control.Concurrent.STM.Delay
+import           Control.Exception ( SomeException, try, fromException, catch )
+import           Data.List ( isInfixOf )
+import qualified Data.Map as M
+import qualified Data.Sequence as Seq
+import qualified Data.Text as T
+import           Data.Time ( getCurrentTime, addUTCTime )
+import           Lens.Micro.Platform ( (.=), (%=), (%~), mapped )
+import           Skylighting.Loader ( loadSyntaxesFromDir )
+import           System.Directory ( getTemporaryDirectory )
+import           System.Exit ( ExitCode(ExitSuccess) )
+import           System.IO ( hPutStrLn, hFlush )
+import           System.IO.Temp ( openTempFile )
+import           System.Timeout ( timeout )
+import           Text.Aspell ( Aspell, AspellOption(..), startAspell )
+
+import           Network.Mattermost.Exceptions ( RateLimitException
+                                               , rateLimitExceptionReset )
+import           Network.Mattermost.Endpoints
+import           Network.Mattermost.Types
+
+import           Matterhorn.Constants
+import           Matterhorn.State.Editing ( requestSpellCheck )
+import           Matterhorn.State.Setup.Threads.Logging
+import           Matterhorn.TimeUtils ( lookupLocalTimeZone )
+import           Matterhorn.Types
+
+
+updateUserStatuses :: [UserId] -> Session -> IO (Maybe (MH ()))
+updateUserStatuses uIds session =
+    case null uIds of
+        False -> do
+            statuses <- mmGetUserStatusByIds (Seq.fromList uIds) session
+            return $ Just $ do
+                forM_ statuses $ \s ->
+                    setUserStatus (statusUserId s) (statusStatus s)
+        True -> return Nothing
+
+startUserStatusUpdateThread :: STM.TChan [UserId] -> Session -> RequestChan -> IO ()
+startUserStatusUpdateThread zipperChan session requestChan = void $ forkIO body
+  where
+      seconds = (* (1000 * 1000))
+      userRefreshInterval = 30
+      body = refresh []
+      refresh prev = do
+          result <- timeout (seconds userRefreshInterval)
+                            (STM.atomically $ STM.readTChan zipperChan)
+          let (uIds, update) = case result of
+                  Nothing -> (prev, True)
+                  Just ids -> (ids, ids /= prev)
+
+          when update $ do
+              STM.atomically $ STM.writeTChan requestChan $ do
+                  rs <- try $ updateUserStatuses uIds session
+                  case rs of
+                      Left (_ :: SomeException) -> return Nothing
+                      Right upd -> return upd
+
+          refresh uIds
+
+-- This thread refreshes the map of typing users every second, forever,
+-- to expire users who have stopped typing. Expiry time is 3 seconds.
+startTypingUsersRefreshThread :: RequestChan -> IO ()
+startTypingUsersRefreshThread requestChan = void $ forkIO $ forever refresh
+  where
+    seconds = (* (1000 * 1000))
+    refreshIntervalMicros = ceiling $ seconds $ userTypingExpiryInterval / 2
+    refresh = do
+      STM.atomically $ STM.writeTChan requestChan $ return $ Just $ do
+        now <- liftIO getCurrentTime
+        let expiry = addUTCTime (- userTypingExpiryInterval) now
+        let expireUsers c = c & ccInfo.cdTypingUsers %~ expireTypingUsers expiry
+        csChannels . mapped %= expireUsers
+
+      threadDelay refreshIntervalMicros
+
+startSubprocessLoggerThread :: STM.TChan ProgramOutput -> RequestChan -> IO ()
+startSubprocessLoggerThread logChan requestChan = do
+    let logMonitor mPair = do
+          ProgramOutput progName args out err ec <-
+              STM.atomically $ STM.readTChan logChan
+
+          case ec == ExitSuccess of
+              -- the "good" case, no output and exit sucess
+              True -> logMonitor mPair
+              False -> do
+                  (logPath, logHandle) <- case mPair of
+                      Just p ->
+                          return p
+                      Nothing -> do
+                          tmp <- getTemporaryDirectory
+                          openTempFile tmp "matterhorn-subprocess.log"
+
+                  hPutStrLn logHandle $
+                      unlines [ "Program: " <> progName
+                              , "Arguments: " <> show args
+                              , "Exit code: " <> show ec
+                              , "Stdout:"
+                              , out
+                              , "Stderr:"
+                              , err
+                              ]
+                  hFlush logHandle
+
+                  STM.atomically $ STM.writeTChan requestChan $ do
+                      return $ Just $ mhError $ ProgramExecutionFailed (T.pack progName)
+                                                                       (T.pack logPath)
+
+                  logMonitor (Just (logPath, logHandle))
+
+    void $ forkIO $ logMonitor Nothing
+
+startTimezoneMonitorThread :: TimeZoneSeries -> RequestChan -> IO ()
+startTimezoneMonitorThread tz requestChan = do
+  -- Start the timezone monitor thread
+  let timezoneMonitorSleepInterval = minutes 5
+      minutes = (* (seconds 60))
+      seconds = (* (1000 * 1000))
+      timezoneMonitor prevTz = do
+        threadDelay timezoneMonitorSleepInterval
+
+        newTz <- lookupLocalTimeZone
+        when (newTz /= prevTz) $
+            STM.atomically $ STM.writeTChan requestChan $ do
+                return $ Just $ do
+                    timeZone .= newTz
+                    mh invalidateCache
+
+        timezoneMonitor newTz
+
+  void $ forkIO (timezoneMonitor tz)
+
+maybeStartSpellChecker :: Config -> BChan MHEvent -> IO (Maybe (Aspell, IO ()))
+maybeStartSpellChecker config eventQueue = do
+  case configEnableAspell config of
+      False -> return Nothing
+      True -> do
+          let aspellOpts = catMaybes [ UseDictionary <$> (configAspellDictionary config)
+                                     ]
+              spellCheckerTimeout = 500 * 1000 -- 500k us = 500ms
+          asResult <- either (const Nothing) Just <$> startAspell aspellOpts
+          case asResult of
+              Nothing -> return Nothing
+              Just as -> do
+                  resetSCChan <- startSpellCheckerThread eventQueue spellCheckerTimeout
+                  let resetSCTimer = STM.atomically $ STM.writeTChan resetSCChan ()
+                  return $ Just (as, resetSCTimer)
+
+-- Start the background spell checker delay thread.
+--
+-- The purpose of this thread is to postpone the spell checker query
+-- while the user is actively typing and only wait until they have
+-- stopped typing before bothering with a query. This is to avoid spell
+-- checker queries when the editor contents are changing rapidly.
+-- Avoiding such queries reduces system load and redraw frequency.
+--
+-- We do this by starting a thread whose job is to wait for the event
+-- loop to tell it to schedule a spell check. Spell checks are scheduled
+-- by writing to the channel returned by this function. The scheduler
+-- thread reads from that channel and then works with another worker
+-- thread as follows:
+--
+-- A wakeup of the main spell checker thread causes it to determine
+-- whether the worker thread is already waiting on a timer. When that
+-- timer goes off, a spell check will be requested. If there is already
+-- an active timer that has not yet expired, the timer's expiration is
+-- extended. This is the case where typing is occurring and we want to
+-- continue postponing the spell check. If there is not an active timer
+-- or the active timer has expired, we create a new timer and send it to
+-- the worker thread for waiting.
+--
+-- The worker thread works by reading a timer from its queue, waiting
+-- until the timer expires, and then injecting an event into the main
+-- event loop to request a spell check.
+startSpellCheckerThread :: BChan MHEvent
+                        -- ^ The main event loop's event channel.
+                        -> Int
+                        -- ^ The number of microseconds to wait before
+                        -- requesting a spell check.
+                        -> IO (STM.TChan ())
+startSpellCheckerThread eventChan spellCheckTimeout = do
+  delayWakeupChan <- STM.atomically STM.newTChan
+  delayWorkerChan <- STM.atomically STM.newTChan
+  delVar <- STM.atomically $ STM.newTVar Nothing
+
+  -- The delay worker actually waits on the delay to expire and then
+  -- requests a spell check.
+  void $ forkIO $ forever $ do
+    STM.atomically $ waitDelay =<< STM.readTChan delayWorkerChan
+    writeBChan eventChan (RespEvent requestSpellCheck)
+
+  -- The delay manager waits for requests to start a delay timer and
+  -- signals the worker to begin waiting.
+  void $ forkIO $ forever $ do
+    () <- STM.atomically $ STM.readTChan delayWakeupChan
+
+    oldDel <- STM.atomically $ STM.readTVar delVar
+    mNewDel <- case oldDel of
+        Nothing -> Just <$> newDelay spellCheckTimeout
+        Just del -> do
+            -- It's possible that between this check for expiration and
+            -- the updateDelay below, the timer will expire -- at which
+            -- point this will mean that we won't extend the timer as
+            -- originally desired. But that's alright, because future
+            -- keystrokes will trigger another timer anyway.
+            expired <- tryWaitDelayIO del
+            case expired of
+                True -> Just <$> newDelay spellCheckTimeout
+                False -> do
+                    updateDelay del spellCheckTimeout
+                    return Nothing
+
+    case mNewDel of
+        Nothing -> return ()
+        Just newDel -> STM.atomically $ do
+            STM.writeTVar delVar $ Just newDel
+            STM.writeTChan delayWorkerChan newDel
+
+  return delayWakeupChan
+
+startSyntaxMapLoaderThread :: Config -> BChan MHEvent -> IO ()
+startSyntaxMapLoaderThread config eventChan = void $ forkIO $ do
+    -- Iterate over the configured syntax directories, loading syntax
+    -- maps. Ensure that entries loaded in earlier directories in the
+    -- sequence take precedence over entries loaded later.
+    mMaps <- forM (configSyntaxDirs config) $ \dir -> do
+        result <- try $ loadSyntaxesFromDir dir
+        case result of
+            Left (_::SomeException) -> return Nothing
+            Right (Left _)          -> return Nothing
+            Right (Right m)         -> return $ Just m
+
+    let maps = catMaybes mMaps
+        finalMap = foldl M.union mempty maps
+
+    writeBChan eventChan $ RespEvent $ do
+        csResources.crSyntaxMap .= finalMap
+        mh invalidateCache
+
+-------------------------------------------------------------------
+-- Async worker thread
+
+startAsyncWorkerThread :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()
+startAsyncWorkerThread c r e = void $ forkIO $ asyncWorker c r e
+
+asyncWorker :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()
+asyncWorker c r e = forever $ doAsyncWork c r e
+
+doAsyncWork :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()
+doAsyncWork config requestChan eventChan = do
+    let rateLimitNotify sec = do
+            writeBChan eventChan $ RateLimitExceeded sec
+
+    startWork <- case configShowBackground config of
+        Disabled -> return $ return ()
+        Active -> do chk <- STM.atomically $ STM.tryPeekTChan requestChan
+                     case chk of
+                       Nothing -> do writeBChan eventChan BGIdle
+                                     return $ writeBChan eventChan $ BGBusy Nothing
+                       _ -> return $ return ()
+        ActiveCount -> do
+          chk <- STM.atomically $ do
+            chanCopy <- STM.cloneTChan requestChan
+            let cntMsgs = do m <- STM.tryReadTChan chanCopy
+                             case m of
+                               Nothing -> return 0
+                               Just _ -> (1 +) <$> cntMsgs
+            cntMsgs
+          case chk of
+            0 -> do writeBChan eventChan BGIdle
+                    return (writeBChan eventChan $ BGBusy (Just 1))
+            _ -> do writeBChan eventChan $ BGBusy (Just chk)
+                    return $ return ()
+
+    req <- STM.atomically $ STM.readTChan requestChan
+    startWork
+    -- Run the IO action with up to one additional attempt if it makes
+    -- rate-limited API requests.
+    res <- try $ rateLimitRetry rateLimitNotify req
+    case res of
+      Left e -> do
+          when (not $ shouldIgnore e) $ do
+              case fromException e of
+                  Just (_::RateLimitException) ->
+                      writeBChan eventChan RequestDropped
+                  Nothing -> do
+                      let err = case fromException e of
+                            Nothing -> AsyncErrEvent e
+                            Just mmErr -> ServerError mmErr
+                      writeBChan eventChan $ IEvent $ DisplayError err
+      Right upd ->
+          case upd of
+              -- The IO action triggered a rate limit error but could
+              -- not be retried due to rate limiting information being
+              -- missing.
+              Nothing -> writeBChan eventChan RateLimitSettingsMissing
+
+              -- The IO action was run successfully but returned no
+              -- state transformation.
+              Just Nothing -> return ()
+
+              -- The IO action was run successfully and returned a state
+              -- transformation.
+              Just (Just action) -> writeBChan eventChan (RespEvent action)
+
+-- | Run an IO action. If the action raises a RateLimitException, invoke
+-- the provided rate limit exception handler with the rate limit window
+-- size (time in seconds until rate limit resets). Then block until the
+-- rate limit resets and attempt to run the action one more time.
+--
+-- If the rate limit exception does not contain a rate limit reset
+-- interval, return Nothing. Otherwise return IO action's result.
+rateLimitRetry :: (Int -> IO ()) -> IO a -> IO (Maybe a)
+rateLimitRetry rateLimitNotify act = do
+    let retry e = do
+            case rateLimitExceptionReset e of
+                -- The rate limit exception contains no metadata so we
+                -- cannot retry the action.
+                Nothing -> return Nothing
+
+                -- The rate limit exception contains the size of the
+                -- rate limit reset interval, so block until that has
+                -- passed and retry the action (only) one more time.
+                Just sec -> do
+                    let adjusted = sec + 1
+                    rateLimitNotify adjusted
+                    threadDelay $ adjusted * 1000000
+                    Just <$> act
+
+    (Just <$> act) `catch` retry
+
+-- Filter for exceptions that we don't want to report to the user,
+-- probably because they are not actionable and/or contain no useful
+-- information.
+--
+-- E.g.
+-- https://github.com/matterhorn-chat/matterhorn/issues/391
+shouldIgnore :: SomeException -> Bool
+shouldIgnore e =
+    let eStr = show e
+    in or [ "getAddrInfo" `isInfixOf` eStr
+          , "Network.Socket.recvBuf" `isInfixOf` eStr
+          , "Network.Socket.sendBuf" `isInfixOf` eStr
+          , "resource vanished" `isInfixOf` eStr
+          , "timeout" `isInfixOf` eStr
+          , "partial packet" `isInfixOf` eStr
+          ]
diff --git a/src/Matterhorn/State/Setup/Threads/Logging.hs b/src/Matterhorn/State/Setup/Threads/Logging.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Setup/Threads/Logging.hs
@@ -0,0 +1,317 @@
+{-# LANGUAGE RecordWildCards #-}
+-- | This module implements the logging thread. This thread can
+-- optionally write logged messages to an output file. When the thread
+-- is created, it is initially not writing to any output file and
+-- must be told to do so by issuing a LogCommand using the LogManager
+-- interface.
+--
+-- The logging thread has an internal bounded log message buffer. Logged
+-- messages always get written to the buffer (potentially evicting old
+-- messages to maintain the size bound). If the thread is also writing
+-- to a file, such messages also get written to the file. When the
+-- thread begins logging to a file, the entire buffer is written to the
+-- file so that a historical snapshot of log activity can be saved in
+-- cases where logging is turned on at runtime only once a problematic
+-- behavior is observed.
+module Matterhorn.State.Setup.Threads.Logging
+  ( newLogManager
+  , shutdownLogManager
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.BChan ( BChan )
+import           Control.Concurrent.Async ( Async, async, wait )
+import qualified Control.Concurrent.STM as STM
+import           Control.Exception ( SomeException, try )
+import           Control.Monad.State.Strict
+import qualified Data.Sequence as Seq
+import qualified Data.Text as T
+import           Data.Time ( getCurrentTime )
+import           System.IO ( Handle, IOMode(AppendMode), hPutStr, hPutStrLn
+                           , hFlush, openFile, hClose )
+
+import           Matterhorn.Types
+
+
+-- | Used to remember the last logging output point for various log output targets.
+newtype LogMemory = LogMemory { logMem :: [(FilePath, LogMessage)] }
+
+blankLogMemory :: LogMemory
+blankLogMemory = LogMemory []
+
+-- | Adds or updates the last log message output to this destination.
+-- This is used for the case when logging is re-enabled to that
+-- destination to avoid repeating logging output.
+--
+-- The number of log memories is limited by forgetting the oldest ones
+-- if over a threshold.  This handles a pathological case where the
+-- user has redirected output to a very large number of logfiles.
+-- This is very unlikely to happen (a script or bad paste buffer
+-- maybe?) but this provides defensive behavior to prevent
+-- uncontrolled memory consumption in this case.  The limit here is
+-- arbitrary, but it should suffice and once it's reached the
+-- degradation case is simply the potential for duplicated messages
+-- when returning to logfiles that haven't been recently logged to.
+rememberOutputPoint :: FilePath -> LogMessage -> LogMemory -> LogMemory
+rememberOutputPoint logPath logMsg oldLogMem =
+  LogMemory $
+  take 50 $ -- upper limit on number of logpath+message memories retained
+  (logPath, logMsg) : filter ((/=) logPath . fst) (logMem oldLogMem)
+
+-- | Returns the last LogMessage logged to the specified output log
+-- file path if it was previously logged to.
+memoryOfOutputPath :: FilePath -> LogMemory -> Maybe LogMessage
+memoryOfOutputPath p = lookup p . logMem
+
+-- | The state of the logging thread.
+data LogThreadState =
+    LogThreadState { logThreadDestination :: Maybe (FilePath, Handle)
+                   -- ^ The logging thread's active logging destination.
+                   -- Nothing means log messages are not being written
+                   -- anywhere except the internal buffer.
+                   , logThreadEventChan :: BChan MHEvent
+                   -- ^ The application event channel that we'll use to
+                   -- notify of logging events.
+                   , logThreadCommandChan :: STM.TChan LogCommand
+                   -- ^ The channel on which the logging thread will
+                   -- wait for logging commands.
+                   , logThreadMessageBuffer :: Seq.Seq LogMessage
+                   -- ^ The internal bounded log message buffer.
+                   , logThreadMaxBufferSize :: Int
+                   -- ^ The size bound of the logThreadMessageBuffer.
+                   , logPreviousStopPoint :: LogMemory
+                   -- ^ Previous logging stop points to avoid
+                   -- duplication if logging is re-enabled to the same
+                   -- output
+                   }
+
+-- | Create a new log manager and start a logging thread for it.
+newLogManager :: BChan MHEvent -> Int -> IO LogManager
+newLogManager eventChan maxBufferSize = do
+    chan <- STM.newTChanIO
+    self <- startLoggingThread eventChan chan maxBufferSize
+    let mgr = LogManager { logManagerCommandChannel = chan
+                         , logManagerHandle = self
+                         }
+    return mgr
+
+-- | Shuts down the log manager and blocks until shutdown is complete.
+shutdownLogManager :: LogManager -> IO ()
+shutdownLogManager mgr = do
+    STM.atomically $ STM.writeTChan (logManagerCommandChannel mgr) ShutdownLogging
+    wait $ logManagerHandle mgr
+
+-- | The logging thread.
+startLoggingThread :: BChan MHEvent -> STM.TChan LogCommand -> Int -> IO (Async ())
+startLoggingThread eventChan logChan maxBufferSize = do
+    let initialState = LogThreadState { logThreadDestination = Nothing
+                                      , logThreadEventChan = eventChan
+                                      , logThreadCommandChan = logChan
+                                      , logThreadMessageBuffer = mempty
+                                      , logThreadMaxBufferSize = maxBufferSize
+                                      , logPreviousStopPoint = blankLogMemory
+                                      }
+    async $ void $ runStateT logThreadBody initialState
+
+logThreadBody :: StateT LogThreadState IO ()
+logThreadBody = do
+    cmd <- nextLogCommand
+    continue <- handleLogCommand cmd
+    when continue logThreadBody
+
+-- | Get the next pending log thread command.
+nextLogCommand :: StateT LogThreadState IO LogCommand
+nextLogCommand = do
+    chan <- gets logThreadCommandChan
+    liftIO $ STM.atomically $ STM.readTChan chan
+
+putMarkerMessage :: String -> Handle -> IO ()
+putMarkerMessage msg h = do
+    now <- getCurrentTime
+    hPutStrLn h $ "[" <> show now <> "] " <> msg
+
+-- | Emit a log stop marker to the file.
+putLogEndMarker :: Handle -> IO ()
+putLogEndMarker = putMarkerMessage "<<< Logging end >>>"
+
+-- | Emit a log start marker to the file.
+putLogStartMarker :: Handle -> IO ()
+putLogStartMarker = putMarkerMessage "<<< Logging start >>>"
+
+-- | Emit a log stop marker to the file and close it, then notify the
+-- application that we have stopped logging.
+finishLog :: BChan MHEvent -> FilePath -> Handle -> StateT LogThreadState IO ()
+finishLog eventChan oldPath oldHandle = do
+    liftIO $ do
+        putLogEndMarker oldHandle
+        hClose oldHandle
+        writeBChan eventChan $ IEvent $ LoggingStopped oldPath
+    modify $ \s ->
+      let buf = logThreadMessageBuffer s
+          lastLm = Seq.index buf (Seq.length buf - 1) -- n.b. putLogEndMarker ensures buf not empty
+          stops = rememberOutputPoint oldPath lastLm $ logPreviousStopPoint s
+      in s { logThreadDestination = Nothing
+           , logPreviousStopPoint = stops
+           }
+
+stopLogOutput :: StateT LogThreadState IO ()
+stopLogOutput = do
+    oldDest <- gets logThreadDestination
+    case oldDest of
+        Nothing -> return ()
+        Just (oldPath, oldHandle) -> do
+            eventChan <- gets logThreadEventChan
+            finishLog eventChan oldPath oldHandle
+
+-- | Handle a single logging command.
+handleLogCommand :: LogCommand -> StateT LogThreadState IO Bool
+handleLogCommand (LogSnapshot path) = do
+    -- LogSnapshot: write the current log message buffer to the
+    -- specified path. Ignore the request if it is for the path that we
+    -- are already logging to.
+    eventChan <- gets logThreadEventChan
+    dest <- gets logThreadDestination
+
+    let shouldWrite = case dest of
+          Nothing -> True
+          Just (curPath, _) -> curPath /= path
+
+    when shouldWrite $ do
+        result <- liftIO $ try $ openFile path AppendMode
+        case result of
+            Left (e::SomeException) -> do
+                liftIO $ writeBChan eventChan $ IEvent $ LogSnapshotFailed path (show e)
+            Right handle -> do
+                flushLogMessageBuffer path handle
+                liftIO $ hClose handle
+                liftIO $ writeBChan eventChan $ IEvent $ LogSnapshotSucceeded path
+
+    return True
+handleLogCommand GetLogDestination = do
+    -- GetLogDestination: the application asked us to provide the
+    -- current log destination.
+    dest <- gets logThreadDestination
+    eventChan <- gets logThreadEventChan
+    liftIO $ writeBChan eventChan $ IEvent $ LogDestination $ fst <$> dest
+    return True
+handleLogCommand ShutdownLogging = do
+    -- ShutdownLogging: if we were logging to a file, close it. Then
+    -- unlock the shutdown lock.
+    stopLogOutput
+    return False
+handleLogCommand StopLogging = do
+    -- StopLogging: if we were logging to a file, close it and notify
+    -- the application. Otherwise do nothing.
+    stopLogOutput
+    return True
+handleLogCommand (LogToFile newPath) = do
+    -- LogToFile: if we were logging to a file, close that file, notify
+    -- the application, then attempt to open the new file. If that
+    -- failed, notify the application of the error. If it succeeded,
+    -- start logging and notify the application.
+    eventChan <- gets logThreadEventChan
+    oldDest <- gets logThreadDestination
+
+    shouldChange <- case oldDest of
+        Nothing ->
+            return True
+        Just (oldPath, _) ->
+            return (oldPath /= newPath)
+
+    when shouldChange $ do
+        result <- liftIO $ try $ openFile newPath AppendMode
+        case result of
+            Left (e::SomeException) -> liftIO $ do
+                let msg = "Error in log thread: could not open " <> show newPath <>
+                          ": " <> show e
+                writeBChan eventChan $ IEvent $ LogStartFailed newPath msg
+            Right handle -> do
+                stopLogOutput
+
+                modify $ \s -> s { logThreadDestination = Just (newPath, handle) }
+                flushLogMessageBuffer newPath handle
+                liftIO $ putLogStartMarker handle
+                liftIO $ writeBChan eventChan $ IEvent $ LoggingStarted newPath
+
+    return True
+handleLogCommand (LogAMessage lm) = do
+    -- LogAMessage: log a single message. Write the message to the
+    -- bounded internal buffer (which may cause an older message to be
+    -- evicted). Then, if we are actively logging to a file, write the
+    -- message to that file and flush the output stream.
+    maxBufSize <- gets logThreadMaxBufferSize
+
+    let addMessageToBuffer s =
+            -- Ensure that newSeq is always at most maxBufSize elements
+            -- long.
+            let newSeq = s Seq.|> lm
+                toDrop = Seq.length s - maxBufSize
+            in Seq.drop toDrop newSeq
+
+    -- Append the message to the internal buffer, maintaining the bound
+    -- on the internal buffer size.
+    modify $ \s -> s { logThreadMessageBuffer = addMessageToBuffer (logThreadMessageBuffer s) }
+
+    -- If we have an active log destination, write the message to the
+    -- output file.
+    dest <- gets logThreadDestination
+    case dest of
+        Nothing -> return ()
+        Just (_, handle) -> liftIO $ do
+            hPutLogMessage handle lm
+            hFlush handle
+
+    return True
+
+-- | Write a single log message to the output handle.
+hPutLogMessage :: Handle -> LogMessage -> IO ()
+hPutLogMessage handle (LogMessage {..}) = do
+    hPutStr handle $ "[" <> show logMessageTimestamp <> "] "
+    hPutStr handle $ "[" <> show logMessageCategory <> "] "
+    case logMessageContext of
+        Nothing -> hPutStr handle "[*] "
+        Just c  -> hPutStr handle $ "[" <> show c <> "] "
+    hPutStrLn handle $ T.unpack logMessageText
+
+-- | Flush the contents of the internal log message buffer.
+flushLogMessageBuffer :: FilePath -> Handle -> StateT LogThreadState IO ()
+flushLogMessageBuffer pathOfHandle handle = do
+    buf <- gets logThreadMessageBuffer
+    when (not $ Seq.null buf) $ do
+      lastPoint <- memoryOfOutputPath pathOfHandle <$> gets logPreviousStopPoint
+      case lastPoint of
+        Nothing ->
+          -- never logged to this output point before, so dump the
+          -- current internal buffer of log messages to the beginning of
+          -- the output file.
+          dumpBuf buf
+        Just lm ->
+          -- There was previous logging to this file.  If the log buffer
+          -- contains the entirety of the log messages during the
+          -- logging disable, then just dump that portion; otherwise
+          -- dump the entire buffer and indicate there may be missing
+          -- entries before the buffer.
+          let unseen = Seq.takeWhileR (not . (==) lm) buf
+              firstM = Seq.index buf 0 -- above ensures that buf is not empty here
+          in do when (Seq.length buf == Seq.length unseen) $ liftIO $
+                  hPutStrLn handle $ mkMsg (logMessageTimestamp firstM)
+                                     "<<< Potentially missing log messages here... >>>"
+                dumpBuf unseen
+      where
+        mkMsg t m = "[" <> show t <> "] " <> m
+        dumpBuf buf = liftIO $ do
+          let firstLm = Seq.index buf 0
+              lastLm = Seq.index buf (Seq.length buf - 1)
+
+          hPutStrLn handle $ mkMsg (logMessageTimestamp firstLm)
+                             "<<< Log message buffer begin >>>"
+
+          forM_ buf (hPutLogMessage handle)
+
+          hPutStrLn handle $ mkMsg (logMessageTimestamp lastLm)
+                                   "<<< Log message buffer end >>>"
+
+          hFlush handle
diff --git a/src/Matterhorn/State/ThemeListOverlay.hs b/src/Matterhorn/State/ThemeListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/ThemeListOverlay.hs
@@ -0,0 +1,86 @@
+module Matterhorn.State.ThemeListOverlay
+  ( enterThemeListMode
+
+  , themeListSelectDown
+  , themeListSelectUp
+  , themeListPageDown
+  , themeListPageUp
+
+  , setTheme
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick ( invalidateCache )
+import           Brick.Themes ( themeToAttrMap )
+import qualified Brick.Widgets.List as L
+import qualified Data.Text as T
+import qualified Data.Vector as Vec
+import           Lens.Micro.Platform ( (.=) )
+
+import           Network.Mattermost.Types
+
+import           Matterhorn.State.ListOverlay
+import           Matterhorn.Themes
+import           Matterhorn.Types
+
+
+-- | Show the user list overlay with the given search scope, and issue a
+-- request to gather the first search results.
+enterThemeListMode :: MH ()
+enterThemeListMode =
+    enterListOverlayMode csThemeListOverlay ThemeListOverlay () setInternalTheme getThemesMatching
+
+-- | Move the selection up in the user list overlay by one user.
+themeListSelectUp :: MH ()
+themeListSelectUp = themeListMove L.listMoveUp
+
+-- | Move the selection down in the user list overlay by one user.
+themeListSelectDown :: MH ()
+themeListSelectDown = themeListMove L.listMoveDown
+
+-- | Move the selection up in the user list overlay by a page of users
+-- (themeListPageSize).
+themeListPageUp :: MH ()
+themeListPageUp = themeListMove (L.listMoveBy (-1 * themeListPageSize))
+
+-- | Move the selection down in the user list overlay by a page of users
+-- (themeListPageSize).
+themeListPageDown :: MH ()
+themeListPageDown = themeListMove (L.listMoveBy themeListPageSize)
+
+-- | Transform the user list results in some way, e.g. by moving the
+-- cursor, and then check to see whether the modification warrants a
+-- prefetch of more search results.
+themeListMove :: (L.List Name InternalTheme -> L.List Name InternalTheme) -> MH ()
+themeListMove = listOverlayMove csThemeListOverlay
+
+-- | The number of users in a "page" for cursor movement purposes.
+themeListPageSize :: Int
+themeListPageSize = 10
+
+getThemesMatching :: ()
+                  -> Session
+                  -> Text
+                  -> IO (Vec.Vector InternalTheme)
+getThemesMatching _ _ searchString = do
+    let matching = filter matches internalThemes
+        search = T.toLower searchString
+        matches t = search `T.isInfixOf` T.toLower (internalThemeName t) ||
+                    search `T.isInfixOf` T.toLower (internalThemeDesc t)
+    return $ Vec.fromList matching
+
+setInternalTheme :: InternalTheme -> MH Bool
+setInternalTheme t = do
+    setTheme $ internalThemeName t
+    return False
+
+setTheme :: Text -> MH ()
+setTheme name =
+    case lookupTheme name of
+        Nothing -> enterThemeListMode
+        Just it -> do
+            mh invalidateCache
+            csResources.crTheme .= (themeToAttrMap $ internalTheme it)
diff --git a/src/Matterhorn/State/UrlSelect.hs b/src/Matterhorn/State/UrlSelect.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/UrlSelect.hs
@@ -0,0 +1,48 @@
+module Matterhorn.State.UrlSelect
+  (
+  -- * URL selection mode
+    startUrlSelect
+  , stopUrlSelect
+  , openSelectedURL
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick.Widgets.List ( list, listMoveTo, listSelectedElement )
+import qualified Data.Vector as V
+import           Lens.Micro.Platform ( (.=), to )
+
+import           Matterhorn.State.Links
+import           Matterhorn.Types
+import           Matterhorn.Util
+
+
+startUrlSelect :: MH ()
+startUrlSelect = do
+    urls <- use (csCurrentChannel.to findUrls.to V.fromList)
+    setMode UrlSelect
+    csUrlList .= (listMoveTo (length urls - 1) $ list UrlList urls 2)
+
+stopUrlSelect :: MH ()
+stopUrlSelect = setMode Main
+
+openSelectedURL :: MH ()
+openSelectedURL = whenMode UrlSelect $ do
+    selected <- use (csUrlList.to listSelectedElement)
+    case selected of
+        Nothing -> setMode Main
+        Just (_, link) -> do
+            opened <- openLinkTarget (link^.linkTarget)
+            when (not opened) $ do
+                mhError $ ConfigOptionMissing "urlOpenCommand"
+                setMode Main
+
+findUrls :: ClientChannel -> [LinkChoice]
+findUrls chan =
+    let msgs = chan^.ccContents.cdMessages
+    in removeDuplicates $ concat $ toList $ toList <$> msgURLs <$> msgs
+
+removeDuplicates :: [LinkChoice] -> [LinkChoice]
+removeDuplicates = nubOn (\ l -> (l^.linkTarget, l^.linkUser, l^.linkLabel))
diff --git a/src/Matterhorn/State/UserListOverlay.hs b/src/Matterhorn/State/UserListOverlay.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/UserListOverlay.hs
@@ -0,0 +1,174 @@
+module Matterhorn.State.UserListOverlay
+  ( enterChannelMembersUserList
+  , enterChannelInviteUserList
+  , enterDMSearchUserList
+
+  , userListSelectDown
+  , userListSelectUp
+  , userListPageDown
+  , userListPageUp
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Brick.Widgets.List as L
+import qualified Data.HashMap.Strict as HM
+import qualified Data.Sequence as Seq
+import qualified Data.Text as T
+import qualified Data.Vector as Vec
+import           Lens.Micro.Platform ( (.~), (.=) )
+
+import qualified Network.Mattermost.Endpoints as MM
+import qualified Network.Mattermost.Types.Config as MM
+import           Network.Mattermost.Types
+
+import           Matterhorn.State.Async ( doAsyncWith, AsyncPriority(Preempt) )
+import           Matterhorn.State.Channels ( createOrFocusDMChannel, addUserToCurrentChannel )
+import           Matterhorn.State.ListOverlay
+import           Matterhorn.Types
+
+
+-- | Show the user list overlay for searching/showing members of the
+-- current channel.
+enterChannelMembersUserList :: MH ()
+enterChannelMembersUserList = do
+    cId <- use csCurrentChannelId
+    myId <- gets myUserId
+    myTId <- gets myTeamId
+    session <- getSession
+
+    doAsyncWith Preempt $ do
+        stats <- MM.mmGetChannelStatistics cId session
+        return $ Just $ do
+            enterUserListMode (ChannelMembers cId myTId) (Just $ channelStatsMemberCount stats)
+              (\u -> case u^.uiId /= myId of
+                True -> createOrFocusDMChannel u Nothing >> return True
+                False -> return False
+              )
+
+-- | Show the user list overlay for showing users that are not members
+-- of the current channel for the purpose of adding them to the
+-- channel.
+enterChannelInviteUserList :: MH ()
+enterChannelInviteUserList = do
+    cId <- use csCurrentChannelId
+    myId <- gets myUserId
+    myTId <- gets myTeamId
+    enterUserListMode (ChannelNonMembers cId myTId) Nothing
+      (\u -> case u^.uiId /= myId of
+        True -> addUserToCurrentChannel u >> return True
+        False -> return False
+      )
+
+-- | Show the user list overlay for showing all users for the purpose of
+-- starting a direct message channel with another user.
+enterDMSearchUserList :: MH ()
+enterDMSearchUserList = do
+    myId <- gets myUserId
+    myTId <- gets myTeamId
+    config <- use csClientConfig
+    let restrictTeam = case MM.clientConfigRestrictDirectMessage <$> config of
+            Just MM.RestrictTeam -> Just myTId
+            _ -> Nothing
+    enterUserListMode (AllUsers restrictTeam) Nothing
+      (\u -> case u^.uiId /= myId of
+        True -> createOrFocusDMChannel u Nothing >> return True
+        False -> return False
+      )
+
+-- | Show the user list overlay with the given search scope, and issue a
+-- request to gather the first search results.
+enterUserListMode :: UserSearchScope -> Maybe Int -> (UserInfo -> MH Bool) -> MH ()
+enterUserListMode scope resultCount enterHandler = do
+    csUserListOverlay.listOverlayRecordCount .= resultCount
+    enterListOverlayMode csUserListOverlay UserListOverlay scope enterHandler getUserSearchResults
+
+userInfoFromPair :: User -> Text -> UserInfo
+userInfoFromPair u status =
+    userInfoFromUser u True & uiStatus .~ statusFromText status
+
+-- | Move the selection up in the user list overlay by one user.
+userListSelectUp :: MH ()
+userListSelectUp = userListMove L.listMoveUp
+
+-- | Move the selection down in the user list overlay by one user.
+userListSelectDown :: MH ()
+userListSelectDown = userListMove L.listMoveDown
+
+-- | Move the selection up in the user list overlay by a page of users
+-- (userListPageSize).
+userListPageUp :: MH ()
+userListPageUp = userListMove (L.listMoveBy (-1 * userListPageSize))
+
+-- | Move the selection down in the user list overlay by a page of users
+-- (userListPageSize).
+userListPageDown :: MH ()
+userListPageDown = userListMove (L.listMoveBy userListPageSize)
+
+-- | Transform the user list results in some way, e.g. by moving the
+-- cursor, and then check to see whether the modification warrants a
+-- prefetch of more search results.
+userListMove :: (L.List Name UserInfo -> L.List Name UserInfo) -> MH ()
+userListMove = listOverlayMove csUserListOverlay
+
+-- | The number of users in a "page" for cursor movement purposes.
+userListPageSize :: Int
+userListPageSize = 10
+
+getUserSearchResults :: UserSearchScope
+                     -- ^ The scope to search
+                     -> Session
+                     -- ^ The connection session
+                     -> Text
+                     -- ^ The search string
+                     -> IO (Vec.Vector UserInfo)
+getUserSearchResults scope s searchString = do
+    -- Unfortunately, we don't get pagination control when there is a
+    -- search string in effect. We'll get at most 100 results from a
+    -- search.
+    let query = UserSearch { userSearchTerm = if T.null searchString then " " else searchString
+                           -- Hack alert: Searching with the string " "
+                           -- above is a hack to use the search
+                           -- endpoint to get "all users" instead of
+                           -- those matching a particular non-empty
+                           -- non-whitespace string. This is because
+                           -- only the search endpoint provides a
+                           -- control to eliminate deleted users from
+                           -- the results. If we don't do this, and
+                           -- use the /users endpoint instead, we'll
+                           -- get deleted users in those results and
+                           -- then those deleted users will disappear
+                           -- from the results once the user enters a
+                           -- non-empty string string.
+                           , userSearchAllowInactive = False
+                           , userSearchWithoutTeam = False
+                           , userSearchInChannelId = case scope of
+                               ChannelMembers cId _ -> Just cId
+                               _                    -> Nothing
+                           , userSearchNotInTeamId = Nothing
+                           , userSearchNotInChannelId = case scope of
+                               ChannelNonMembers cId _ -> Just cId
+                               _                       -> Nothing
+                           , userSearchTeamId = case scope of
+                               AllUsers tId            -> tId
+                               ChannelMembers _ tId    -> Just tId
+                               ChannelNonMembers _ tId -> Just tId
+                           }
+    users <- MM.mmSearchUsers query s
+
+    let uList = toList users
+        uIds = userId <$> uList
+
+    -- Now fetch status info for the users we got.
+    case null uList of
+        False -> do
+            statuses <- MM.mmGetUserStatusByIds (Seq.fromList uIds) s
+            let statusMap = HM.fromList [ (statusUserId e, statusStatus e) | e <- toList statuses ]
+                usersWithStatus = [ userInfoFromPair u (fromMaybe "" $ HM.lookup (userId u) statusMap)
+                                  | u <- uList
+                                  ]
+
+            return $ Vec.fromList usersWithStatus
+        True -> return mempty
diff --git a/src/Matterhorn/State/Users.hs b/src/Matterhorn/State/Users.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/State/Users.hs
@@ -0,0 +1,99 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Matterhorn.State.Users
+  ( handleNewUsers
+  , handleTypingUser
+  , withFetchedUser
+  , withFetchedUserMaybe
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.Text as T
+import qualified Data.Foldable as F
+import qualified Data.Sequence as Seq
+import           Data.Time ( getCurrentTime )
+import           Lens.Micro.Platform
+
+import qualified Network.Mattermost.Endpoints as MM
+import           Network.Mattermost.Types
+
+import           Matterhorn.Config
+import           Matterhorn.Types
+import           Matterhorn.State.Common
+
+
+handleNewUsers :: Seq UserId -> MH () -> MH ()
+handleNewUsers newUserIds after = do
+    doAsyncMM Preempt getUserInfo addNewUsers
+    where getUserInfo session =
+              do nUsers <- MM.mmGetUsersByIds newUserIds session
+                 let usrInfo u = userInfoFromUser u True
+                     usrList = toList nUsers
+                 return $ usrInfo <$> usrList
+
+          addNewUsers :: [UserInfo] -> Maybe (MH ())
+          addNewUsers is = Just $ mapM_ addNewUser is >> after
+
+-- | Handle the typing events from the websocket to show the currently
+-- typing users on UI
+handleTypingUser :: UserId -> ChannelId -> MH ()
+handleTypingUser uId cId = do
+    config <- use (csResources.crConfiguration)
+    when (configShowTypingIndicator config) $ do
+        withFetchedUser (UserFetchById uId) $ const $ do
+            ts <- liftIO getCurrentTime
+            csChannels %= modifyChannelById cId (addChannelTypingUser uId ts)
+
+-- | Given a user fetching strategy, locate the user in the state or
+-- fetch it from the server, and pass the result to the specified
+-- action. Assumes a single match is the only expected/valid result.
+withFetchedUser :: UserFetch -> (UserInfo -> MH ()) -> MH ()
+withFetchedUser fetch handle =
+    withFetchedUserMaybe fetch $ \u -> do
+        case u of
+            Nothing -> postErrorMessage' "No such user"
+            Just user -> handle user
+
+withFetchedUserMaybe :: UserFetch -> (Maybe UserInfo -> MH ()) -> MH ()
+withFetchedUserMaybe fetch handle = do
+    st <- use id
+    session <- getSession
+
+    let localMatch = case fetch of
+            UserFetchById uId -> userById uId st
+            UserFetchByUsername uname -> userByUsername uname st
+            UserFetchByNickname nick -> userByNickname nick st
+
+    case localMatch of
+        Just user -> handle $ Just user
+        Nothing -> do
+            mhLog LogGeneral $ T.pack $ "withFetchedUserMaybe: getting " <> show fetch
+            doAsyncWith Normal $ do
+                results <- case fetch of
+                    UserFetchById uId ->
+                        MM.mmGetUsersByIds (Seq.singleton uId) session
+                    UserFetchByUsername uname ->
+                        MM.mmGetUsersByUsernames (Seq.singleton $ trimUserSigil uname) session
+                    UserFetchByNickname nick -> do
+                        let req = UserSearch { userSearchTerm = trimUserSigil nick
+                                             , userSearchAllowInactive = True
+                                             , userSearchWithoutTeam = True
+                                             , userSearchInChannelId = Nothing
+                                             , userSearchNotInTeamId = Nothing
+                                             , userSearchNotInChannelId = Nothing
+                                             , userSearchTeamId = Nothing
+                                             }
+                        MM.mmSearchUsers req session
+
+                return $ Just $ do
+                    infos <- forM (F.toList results) $ \u -> do
+                        let info = userInfoFromUser u True
+                        addNewUser info
+                        return info
+
+                    case infos of
+                        [match] -> handle $ Just match
+                        [] -> handle Nothing
+                        _ -> postErrorMessage' "Error: ambiguous user information"
diff --git a/src/Matterhorn/TeamSelect.hs b/src/Matterhorn/TeamSelect.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/TeamSelect.hs
@@ -0,0 +1,90 @@
+module Matterhorn.TeamSelect
+  ( interactiveTeamSelection
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Widgets.Border
+import           Brick.Widgets.Center
+import           Brick.Widgets.List
+import qualified Data.Function as F
+import           Data.List ( sortBy )
+import qualified Data.Vector as V
+import           Graphics.Vty hiding (mkVty)
+import qualified Data.Text as T
+
+import           Network.Mattermost.Types
+
+import           Matterhorn.Draw.RichText
+import           Matterhorn.Types.Common
+
+
+data State =
+    State { appList :: List () Team
+          , appCancelled :: Bool
+          }
+
+interactiveTeamSelection :: Vty -> IO Vty -> [Team] -> IO (Maybe Team, Vty)
+interactiveTeamSelection vty mkVty teams = do
+    let state = State { appList = list () (V.fromList sortedTeams) 1
+                      , appCancelled = False
+                      }
+        sortedTeams = sortBy (compare `F.on` teamName) teams
+
+    (finalSt, finalVty) <- customMainWithVty vty mkVty Nothing app state
+
+    let result = if appCancelled finalSt
+                 then Nothing
+                 else snd <$> listSelectedElement (appList finalSt)
+
+    return (result, finalVty)
+
+app :: App State e ()
+app = App
+  { appDraw         = teamSelectDraw
+  , appChooseCursor = neverShowCursor
+  , appHandleEvent  = onEvent
+  , appStartEvent   = return
+  , appAttrMap      = const colorTheme
+  }
+
+colorTheme :: AttrMap
+colorTheme = attrMap defAttr
+  [ (listSelectedFocusedAttr, black `on` yellow)
+  ]
+
+teamSelectDraw :: State -> [Widget ()]
+teamSelectDraw st =
+    [ teamSelect st
+    ]
+
+teamSelect :: State -> Widget ()
+teamSelect st =
+    center $ hLimit 50 $ vLimit 15 $
+    vBox [ hCenter $ txt "Welcome to Mattermost. Please select a team:"
+         , txt " "
+         , border theList
+         , txt " "
+         , renderText "Press Enter to select a team and connect or Esc to exit."
+         ]
+    where
+    theList = renderList renderTeamItem True (appList st)
+
+renderTeamItem :: Bool -> Team -> Widget ()
+renderTeamItem _ t =
+    padRight Max $ txt $ (sanitizeUserText $ teamName t) <>
+        if not $ T.null (sanitizeUserText $ teamDisplayName t)
+        then " (" <> (sanitizeUserText $ teamDisplayName t) <> ")"
+        else ""
+
+onEvent :: State -> BrickEvent () e -> EventM () (Next State)
+onEvent st (VtyEvent (EvKey KEsc [])) = do
+    halt $ st { appCancelled = True }
+onEvent st (VtyEvent (EvKey KEnter [])) = halt st
+onEvent st (VtyEvent e) = do
+    list' <- handleListEvent e (appList st)
+    continue $ st { appList = list' }
+onEvent st _ = continue st
diff --git a/src/Matterhorn/Themes.hs b/src/Matterhorn/Themes.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Themes.hs
@@ -0,0 +1,808 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Matterhorn.Themes
+  ( InternalTheme(..)
+
+  , defaultTheme
+  , internalThemes
+  , lookupTheme
+  , themeDocs
+
+  -- * Attribute names
+  , currentUserAttr
+  , timeAttr
+  , channelHeaderAttr
+  , channelListHeaderAttr
+  , currentChannelNameAttr
+  , unreadChannelAttr
+  , unreadGroupMarkerAttr
+  , mentionsChannelAttr
+  , urlAttr
+  , codeAttr
+  , emailAttr
+  , emojiAttr
+  , channelNameAttr
+  , clientMessageAttr
+  , clientHeaderAttr
+  , strikeThroughAttr
+  , clientEmphAttr
+  , clientStrongAttr
+  , dateTransitionAttr
+  , pinnedMessageIndicatorAttr
+  , newMessageTransitionAttr
+  , gapMessageAttr
+  , errorMessageAttr
+  , helpAttr
+  , helpEmphAttr
+  , channelSelectPromptAttr
+  , channelSelectMatchAttr
+  , completionAlternativeListAttr
+  , completionAlternativeCurrentAttr
+  , permalinkAttr
+  , dialogAttr
+  , dialogEmphAttr
+  , recentMarkerAttr
+  , replyParentAttr
+  , loadMoreAttr
+  , urlListSelectedAttr
+  , messageSelectAttr
+  , messageSelectStatusAttr
+  , misspellingAttr
+  , editedMarkingAttr
+  , editedRecentlyMarkingAttr
+  , tabSelectedAttr
+  , tabUnselectedAttr
+  , buttonAttr
+  , buttonFocusedAttr
+
+  -- * Username formatting
+  , colorUsername
+  , attrForUsername
+  , usernameColorHashBuckets
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Themes
+import           Brick.Widgets.List
+import qualified Brick.Widgets.FileBrowser as FB
+import           Brick.Widgets.Skylighting ( attrNameForTokenType
+                                           , attrMappingsForStyle
+                                           , highlightedCodeBlockAttr
+                                           )
+import           Brick.Forms ( focusedFormInputAttr )
+import           Data.Hashable ( hash )
+import qualified Data.Map as M
+import qualified Data.Text as T
+import           Graphics.Vty
+import qualified Skylighting.Styles as Sky
+import           Skylighting.Types ( TokenType(..) )
+
+import           Matterhorn.Types ( InternalTheme(..), specialUserMentions )
+
+
+helpAttr :: AttrName
+helpAttr = "help"
+
+helpEmphAttr :: AttrName
+helpEmphAttr = "helpEmphasis"
+
+recentMarkerAttr :: AttrName
+recentMarkerAttr = "recentChannelMarker"
+
+replyParentAttr :: AttrName
+replyParentAttr = "replyParentPreview"
+
+pinnedMessageIndicatorAttr :: AttrName
+pinnedMessageIndicatorAttr = "pinnedMessageIndicator"
+
+loadMoreAttr :: AttrName
+loadMoreAttr = "loadMoreMessages"
+
+urlListSelectedAttr :: AttrName
+urlListSelectedAttr = "urlListCursor"
+
+messageSelectAttr :: AttrName
+messageSelectAttr = "messageSelectCursor"
+
+editedMarkingAttr :: AttrName
+editedMarkingAttr = "editedMarking"
+
+editedRecentlyMarkingAttr :: AttrName
+editedRecentlyMarkingAttr = "editedRecentlyMarking"
+
+permalinkAttr :: AttrName
+permalinkAttr = "permalink"
+
+dialogAttr :: AttrName
+dialogAttr = "dialog"
+
+dialogEmphAttr :: AttrName
+dialogEmphAttr = "dialogEmphasis"
+
+channelSelectMatchAttr :: AttrName
+channelSelectMatchAttr = "channelSelectMatch"
+
+channelSelectPromptAttr :: AttrName
+channelSelectPromptAttr = "channelSelectPrompt"
+
+completionAlternativeListAttr :: AttrName
+completionAlternativeListAttr = "tabCompletionAlternative"
+
+completionAlternativeCurrentAttr :: AttrName
+completionAlternativeCurrentAttr = "tabCompletionCursor"
+
+timeAttr :: AttrName
+timeAttr = "time"
+
+currentUserAttr :: AttrName
+currentUserAttr = "currentUser"
+
+channelHeaderAttr :: AttrName
+channelHeaderAttr = "channelHeader"
+
+channelListHeaderAttr :: AttrName
+channelListHeaderAttr = "channelListSectionHeader"
+
+currentChannelNameAttr :: AttrName
+currentChannelNameAttr = "currentChannelName"
+
+channelNameAttr :: AttrName
+channelNameAttr = "channelName"
+
+unreadChannelAttr :: AttrName
+unreadChannelAttr = "unreadChannel"
+
+unreadGroupMarkerAttr :: AttrName
+unreadGroupMarkerAttr = "unreadChannelGroupMarker"
+
+mentionsChannelAttr :: AttrName
+mentionsChannelAttr = "channelWithMentions"
+
+tabSelectedAttr :: AttrName
+tabSelectedAttr = "tabSelected"
+
+tabUnselectedAttr :: AttrName
+tabUnselectedAttr = "tabUnselected"
+
+dateTransitionAttr :: AttrName
+dateTransitionAttr = "dateTransition"
+
+newMessageTransitionAttr :: AttrName
+newMessageTransitionAttr = "newMessageTransition"
+
+urlAttr :: AttrName
+urlAttr = "url"
+
+codeAttr :: AttrName
+codeAttr = "codeBlock"
+
+emailAttr :: AttrName
+emailAttr = "email"
+
+emojiAttr :: AttrName
+emojiAttr = "emoji"
+
+clientMessageAttr :: AttrName
+clientMessageAttr = "clientMessage"
+
+clientHeaderAttr :: AttrName
+clientHeaderAttr = "markdownHeader"
+
+strikeThroughAttr :: AttrName
+strikeThroughAttr = "markdownStrikethrough"
+
+clientEmphAttr :: AttrName
+clientEmphAttr = "markdownEmph"
+
+clientStrongAttr :: AttrName
+clientStrongAttr = "markdownStrong"
+
+errorMessageAttr :: AttrName
+errorMessageAttr = "errorMessage"
+
+gapMessageAttr :: AttrName
+gapMessageAttr = "gapMessage"
+
+misspellingAttr :: AttrName
+misspellingAttr = "misspelling"
+
+messageSelectStatusAttr :: AttrName
+messageSelectStatusAttr = "messageSelectStatus"
+
+buttonAttr :: AttrName
+buttonAttr = "button"
+
+buttonFocusedAttr :: AttrName
+buttonFocusedAttr = buttonAttr <> "focused"
+
+lookupTheme :: Text -> Maybe InternalTheme
+lookupTheme n = find ((== n) . internalThemeName) internalThemes
+
+internalThemes :: [InternalTheme]
+internalThemes = validateInternalTheme <$>
+    [ darkColorTheme
+    , darkColor256Theme
+    , lightColorTheme
+    , lightColor256Theme
+    ]
+
+validateInternalTheme :: InternalTheme -> InternalTheme
+validateInternalTheme it =
+    let un = undocumentedAttrNames (internalTheme it)
+    in if not $ null un
+       then error $ "Internal theme " <> show (T.unpack (internalThemeName it)) <>
+                    " references undocumented attribute names: " <> show un
+       else it
+
+undocumentedAttrNames :: Theme -> [AttrName]
+undocumentedAttrNames t =
+    let noDocs k = isNothing $ attrNameDescription themeDocs k
+    in filter noDocs (M.keys $ themeDefaultMapping t)
+
+defaultTheme :: InternalTheme
+defaultTheme = darkColorTheme
+
+lightColorTheme :: InternalTheme
+lightColorTheme = InternalTheme name theme desc
+    where
+        theme = newTheme def $ lightAttrs usernameColors16
+        name = "builtin:light"
+        def = black `on` white
+        desc = "A color theme for terminal windows with light background colors"
+
+lightColor256Theme :: InternalTheme
+lightColor256Theme = InternalTheme name theme desc
+    where
+        theme = newTheme def $ lightAttrs usernameColors256
+        name = "builtin:light256"
+        def = black `on` white
+        desc = "Like builtin:light, but with 256-color username colors"
+
+lightAttrs :: [Attr] -> [(AttrName, Attr)]
+lightAttrs usernameColors =
+    let sty = Sky.kate
+    in [ (timeAttr,                         fg black)
+       , (buttonAttr,                       black `on` cyan)
+       , (buttonFocusedAttr,                black `on` yellow)
+       , (currentUserAttr,                  defAttr `withStyle` bold)
+       , (channelHeaderAttr,                fg black)
+       , (channelListHeaderAttr,            fg cyan)
+       , (currentChannelNameAttr,           black `on` yellow `withStyle` bold)
+       , (unreadChannelAttr,                black `on` cyan   `withStyle` bold)
+       , (unreadGroupMarkerAttr,            fg black `withStyle` bold)
+       , (mentionsChannelAttr,              black `on` red    `withStyle` bold)
+       , (urlAttr,                          fg brightYellow)
+       , (emailAttr,                        fg yellow)
+       , (codeAttr,                         fg magenta)
+       , (emojiAttr,                        fg yellow)
+       , (channelNameAttr,                  fg blue)
+       , (clientMessageAttr,                fg black)
+       , (clientEmphAttr,                   fg black `withStyle` bold)
+       , (clientStrongAttr,                 fg black `withStyle` bold `withStyle` underline)
+       , (clientHeaderAttr,                 fg red `withStyle` bold)
+       , (strikeThroughAttr,                defAttr `withStyle` strikethrough)
+       , (dateTransitionAttr,               fg green)
+       , (newMessageTransitionAttr,         black `on` yellow)
+       , (errorMessageAttr,                 fg red)
+       , (gapMessageAttr,                   fg red)
+       , (helpAttr,                         fg black)
+       , (pinnedMessageIndicatorAttr,       black `on` cyan)
+       , (helpEmphAttr,                     fg blue `withStyle` bold)
+       , (channelSelectMatchAttr,           black `on` magenta `withStyle` underline)
+       , (channelSelectPromptAttr,          fg black)
+       , (completionAlternativeListAttr,    white `on` blue)
+       , (completionAlternativeCurrentAttr, black `on` yellow)
+       , (dialogAttr,                       black `on` cyan)
+       , (dialogEmphAttr,                   fg white)
+       , (permalinkAttr,                    fg green)
+       , (listSelectedFocusedAttr,          black `on` yellow)
+       , (recentMarkerAttr,                 fg black `withStyle` bold)
+       , (loadMoreAttr,                     black `on` cyan)
+       , (urlListSelectedAttr,              black `on` yellow)
+       , (messageSelectAttr,                black `on` yellow)
+       , (messageSelectStatusAttr,          fg black)
+       , (misspellingAttr,                  fg red `withStyle` underline)
+       , (editedMarkingAttr,                fg yellow)
+       , (editedRecentlyMarkingAttr,        black `on` yellow)
+       , (tabSelectedAttr,                  black `on` yellow)
+       , (focusedFormInputAttr,             black `on` yellow)
+       , (FB.fileBrowserCurrentDirectoryAttr, white `on` blue)
+       , (FB.fileBrowserSelectionInfoAttr,  white `on` blue)
+       , (FB.fileBrowserDirectoryAttr,      fg blue)
+       , (FB.fileBrowserBlockDeviceAttr,    fg magenta)
+       , (FB.fileBrowserCharacterDeviceAttr, fg green)
+       , (FB.fileBrowserNamedPipeAttr,      fg yellow)
+       , (FB.fileBrowserSymbolicLinkAttr,   fg cyan)
+       , (FB.fileBrowserUnixSocketAttr,     fg red)
+       ] <>
+       ((\(i, a) -> (usernameAttr i, a)) <$> zip [0..usernameColorHashBuckets-1] (cycle usernameColors)) <>
+       (filter skipBaseCodeblockAttr $ attrMappingsForStyle sty)
+
+darkAttrs :: [Attr] -> [(AttrName, Attr)]
+darkAttrs usernameColors =
+  let sty = Sky.espresso
+  in [ (timeAttr,                         fg white)
+     , (buttonAttr,                       black `on` cyan)
+     , (buttonFocusedAttr,                black `on` yellow)
+     , (currentUserAttr,                  defAttr `withStyle` bold)
+     , (channelHeaderAttr,                fg white)
+     , (channelListHeaderAttr,            fg cyan)
+     , (currentChannelNameAttr,           black `on` yellow `withStyle` bold)
+     , (unreadChannelAttr,                black `on` cyan   `withStyle` bold)
+     , (unreadGroupMarkerAttr,            fg white `withStyle` bold)
+     , (mentionsChannelAttr,              black `on` brightMagenta `withStyle` bold)
+     , (urlAttr,                          fg yellow)
+     , (emailAttr,                        fg yellow)
+     , (codeAttr,                         fg magenta)
+     , (emojiAttr,                        fg yellow)
+     , (channelNameAttr,                  fg cyan)
+     , (pinnedMessageIndicatorAttr,       fg cyan `withStyle` bold)
+     , (clientMessageAttr,                fg white)
+     , (clientEmphAttr,                   fg white `withStyle` bold)
+     , (clientStrongAttr,                 fg white `withStyle` bold `withStyle` underline)
+     , (clientHeaderAttr,                 fg red `withStyle` bold)
+     , (strikeThroughAttr,                defAttr `withStyle` strikethrough)
+     , (dateTransitionAttr,               fg green)
+     , (newMessageTransitionAttr,         fg yellow `withStyle` bold)
+     , (errorMessageAttr,                 fg red)
+     , (gapMessageAttr,                   black `on` yellow)
+     , (helpAttr,                         fg white)
+     , (helpEmphAttr,                     fg cyan `withStyle` bold)
+     , (channelSelectMatchAttr,           black `on` magenta `withStyle` underline)
+     , (channelSelectPromptAttr,          fg white)
+     , (completionAlternativeListAttr,    white `on` blue)
+     , (completionAlternativeCurrentAttr, black `on` yellow)
+     , (dialogAttr,                       black `on` cyan)
+     , (dialogEmphAttr,                   fg white)
+     , (permalinkAttr,                    fg brightCyan)
+     , (listSelectedFocusedAttr,          black `on` yellow)
+     , (recentMarkerAttr,                 fg yellow `withStyle` bold)
+     , (loadMoreAttr,                     black `on` cyan)
+     , (urlListSelectedAttr,              black `on` yellow)
+     , (messageSelectAttr,                black `on` yellow)
+     , (messageSelectStatusAttr,          fg white)
+     , (misspellingAttr,                  fg red `withStyle` underline)
+     , (editedMarkingAttr,                fg yellow)
+     , (editedRecentlyMarkingAttr,        black `on` yellow)
+     , (tabSelectedAttr,                  black `on` yellow)
+     , (focusedFormInputAttr,             black `on` yellow)
+     , (FB.fileBrowserCurrentDirectoryAttr, white `on` blue)
+     , (FB.fileBrowserSelectionInfoAttr,  white `on` blue)
+     , (FB.fileBrowserDirectoryAttr,      fg blue)
+     , (FB.fileBrowserBlockDeviceAttr,    fg magenta)
+     , (FB.fileBrowserCharacterDeviceAttr, fg green)
+     , (FB.fileBrowserNamedPipeAttr,      fg yellow)
+     , (FB.fileBrowserSymbolicLinkAttr,   fg cyan)
+     , (FB.fileBrowserUnixSocketAttr,     fg red)
+     ] <>
+     ((\(i, a) -> (usernameAttr i, a)) <$> zip [0..usernameColorHashBuckets-1] (cycle usernameColors)) <>
+     (filter skipBaseCodeblockAttr $ attrMappingsForStyle sty)
+
+skipBaseCodeblockAttr :: (AttrName, Attr) -> Bool
+skipBaseCodeblockAttr = ((/= highlightedCodeBlockAttr) . fst)
+
+darkColorTheme :: InternalTheme
+darkColorTheme = InternalTheme name theme desc
+    where
+        theme = newTheme def $ darkAttrs usernameColors16
+        name = "builtin:dark"
+        def = defAttr
+        desc = "A color theme for terminal windows with dark background colors"
+
+darkColor256Theme :: InternalTheme
+darkColor256Theme = InternalTheme name theme desc
+    where
+        theme = newTheme def $ darkAttrs usernameColors256
+        name = "builtin:dark256"
+        def = defAttr
+        desc = "Like builtin:dark, but with 256-color username colors"
+
+usernameAttr :: Int -> AttrName
+usernameAttr i = "username" <> (attrName $ show i)
+
+-- | Render a string with a color chosen based on the text of a
+-- username.
+--
+-- This function takes some display text and renders it using an
+-- attribute based on the username associated with the text. If the
+-- username associated with the text is equal to the username of
+-- the user running Matterhorn, the display text is formatted with
+-- 'currentAttr'. Otherwise it is formatted with an attribute chosen
+-- by hashing the associated username and choosing from amongst the
+-- username color hash buckets with 'usernameAttr'.
+--
+-- Usually the first argument to this function will be @myUsername st@,
+-- where @st@ is a 'ChatState'.
+--
+-- The most common way to call this function is
+--
+-- @colorUsername (myUsername st) u u
+--
+-- The third argument is allowed to vary from the second since sometimes
+-- we call this with the user's status sigil as the third argument.
+colorUsername :: Text
+              -- ^ The username for the user currently running
+              -- Matterhorn
+              -> Text
+              -- ^ The username associated with the text to render
+              -> Text
+              -- ^ The text to render
+              -> Widget a
+colorUsername current username display =
+    let aName = attrForUsername username
+        maybeWithCurrentAttr = if current == username
+                               then withAttr currentUserAttr
+                               else id
+    in withDefAttr aName $
+       maybeWithCurrentAttr $
+       txt (display)
+
+-- | Return the attribute name to use for the specified username.
+-- The input username is expected to be the username only (i.e. no
+-- sigil).
+--
+-- If the input username is a special reserved username such as "all",
+-- the @clientEmphAttr@ attribute name will be returned. Otherwise
+-- a hash-bucket username attribute name will be returned based on
+-- the hash value of the username and the number of hash buckets
+-- (@usernameColorHashBuckets@).
+attrForUsername :: Text
+                -- ^ The username to get an attribute for
+                -> AttrName
+attrForUsername username =
+    let normalizedUsername = T.toLower username
+        aName = if normalizedUsername `elem` specialUserMentions
+                then clientEmphAttr
+                else usernameAttr h
+        h = hash normalizedUsername `mod` usernameColorHashBuckets
+    in aName
+
+-- | The number of hash buckets to use when hashing usernames to choose
+-- their colors.
+usernameColorHashBuckets :: Int
+usernameColorHashBuckets = 50
+
+usernameColors16 :: [Attr]
+usernameColors16 =
+    [ fg red
+    , fg green
+    , fg yellow
+    , fg blue
+    , fg magenta
+    , fg cyan
+    , fg brightRed
+    , fg brightGreen
+    , fg brightYellow
+    , fg brightBlue
+    , fg brightMagenta
+    , fg brightCyan
+    ]
+
+usernameColors256 :: [Attr]
+usernameColors256 = mkColor <$> username256ColorChoices
+    where
+        mkColor (r, g, b) = defAttr `withForeColor` rgbColor r g b
+
+username256ColorChoices :: [(Integer, Integer, Integer)]
+username256ColorChoices =
+    [ (255, 0, 86)
+    , (158, 0, 142)
+    , (14, 76, 161)
+    , (255, 229, 2)
+    , (149, 0, 58)
+    , (255, 147, 126)
+    , (164, 36, 0)
+    , (98, 14, 0)
+    , (0, 0, 255)
+    , (106, 130, 108)
+    , (0, 174, 126)
+    , (194, 140, 159)
+    , (0, 143, 156)
+    , (95, 173, 78)
+    , (255, 2, 157)
+    , (255, 116, 163)
+    , (152, 255, 82)
+    , (167, 87, 64)
+    , (254, 137, 0)
+    , (1, 208, 255)
+    , (187, 136, 0)
+    , (117, 68, 177)
+    , (165, 255, 210)
+    , (122, 71, 130)
+    , (0, 71, 84)
+    , (181, 0, 255)
+    , (144, 251, 146)
+    , (189, 211, 147)
+    , (229, 111, 254)
+    , (222, 255, 116)
+    , (0, 255, 120)
+    , (0, 155, 255)
+    , (0, 100, 1)
+    , (0, 118, 255)
+    , (133, 169, 0)
+    , (0, 185, 23)
+    , (120, 130, 49)
+    , (0, 255, 198)
+    , (255, 110, 65)
+    ]
+
+-- Functions for dealing with Skylighting styles
+
+attrNameDescription :: ThemeDocumentation -> AttrName -> Maybe Text
+attrNameDescription td an = M.lookup an (themeDescriptions td)
+
+themeDocs :: ThemeDocumentation
+themeDocs = ThemeDocumentation $ M.fromList $
+    [ ( timeAttr
+      , "Timestamps on chat messages"
+      )
+    , ( channelHeaderAttr
+      , "Channel headers displayed above chat message lists"
+      )
+    , ( channelListHeaderAttr
+      , "The heading of the channel list sections"
+      )
+    , ( currentChannelNameAttr
+      , "The currently selected channel in the channel list"
+      )
+    , ( unreadChannelAttr
+      , "A channel in the channel list with unread messages"
+      )
+    , ( unreadGroupMarkerAttr
+      , "The channel group marker indicating unread messages"
+      )
+    , ( mentionsChannelAttr
+      , "A channel in the channel list with unread mentions"
+      )
+    , ( urlAttr
+      , "A URL in a chat message"
+      )
+    , ( codeAttr
+      , "A code block in a chat message with no language indication"
+      )
+    , ( emailAttr
+      , "An e-mail address in a chat message"
+      )
+    , ( emojiAttr
+      , "A text emoji indication in a chat message"
+      )
+    , ( channelNameAttr
+      , "A channel name in a chat message"
+      )
+    , ( clientMessageAttr
+      , "A Matterhorn diagnostic or informative message"
+      )
+    , ( clientHeaderAttr
+      , "Markdown heading"
+      )
+    , ( strikeThroughAttr
+      , "Markdown strikethrough text"
+      )
+    , ( clientEmphAttr
+      , "Markdown 'emphasized' text"
+      )
+    , ( clientStrongAttr
+      , "Markdown 'strong' text"
+      )
+    , ( dateTransitionAttr
+      , "Date transition lines between chat messages on different days"
+      )
+    , ( pinnedMessageIndicatorAttr
+      , "The indicator for messages that have been pinned"
+      )
+    , ( newMessageTransitionAttr
+      , "The 'New Messages' line that appears above unread messages"
+      )
+    , ( tabSelectedAttr
+      , "Selected tabs in tabbed windows"
+      )
+    , ( tabUnselectedAttr
+      , "Unselected tabs in tabbed windows"
+      )
+    , ( errorMessageAttr
+      , "Matterhorn error messages"
+      )
+    , ( gapMessageAttr
+      , "Matterhorn message gap information"
+      )
+    , ( helpAttr
+      , "The help screen text"
+      )
+    , ( helpEmphAttr
+      , "The help screen's emphasized text"
+      )
+    , ( channelSelectPromptAttr
+      , "Channel selection: prompt"
+      )
+    , ( channelSelectMatchAttr
+      , "Channel selection: the portion of a channel name that matches"
+      )
+    , ( completionAlternativeListAttr
+      , "Tab completion alternatives"
+      )
+    , ( completionAlternativeCurrentAttr
+      , "The currently-selected tab completion alternative"
+      )
+    , ( permalinkAttr
+      , "A post permalink"
+      )
+    , ( dialogAttr
+      , "Dialog box text"
+      )
+    , ( dialogEmphAttr
+      , "Dialog box emphasized text"
+      )
+    , ( recentMarkerAttr
+      , "The marker indicating the channel last visited"
+      )
+    , ( replyParentAttr
+      , "The first line of parent messages appearing above reply messages"
+      )
+    , ( loadMoreAttr
+      , "The 'Load More' line that appears at the top of a chat message list"
+      )
+    , ( urlListSelectedAttr
+      , "URL list: the selected URL"
+      )
+    , ( messageSelectAttr
+      , "Message selection: the currently-selected message"
+      )
+    , ( messageSelectStatusAttr
+      , "Message selection: the message selection actions"
+      )
+    , ( misspellingAttr
+      , "A misspelled word in the chat message editor"
+      )
+    , ( editedMarkingAttr
+      , "The 'edited' marking that appears on edited messages"
+      )
+    , ( editedRecentlyMarkingAttr
+      , "The 'edited' marking that appears on newly-edited messages"
+      )
+    , ( highlightedCodeBlockAttr
+      , "The base attribute for syntax-highlighted code blocks"
+      )
+    , ( attrNameForTokenType KeywordTok
+      , "Syntax highlighting: Keyword"
+      )
+    , ( attrNameForTokenType DataTypeTok
+      , "Syntax highlighting: DataType"
+      )
+    , ( attrNameForTokenType DecValTok
+      , "Syntax highlighting: Declaration"
+      )
+    , ( attrNameForTokenType BaseNTok
+      , "Syntax highlighting: BaseN"
+      )
+    , ( attrNameForTokenType FloatTok
+      , "Syntax highlighting: Float"
+      )
+    , ( attrNameForTokenType ConstantTok
+      , "Syntax highlighting: Constant"
+      )
+    , ( attrNameForTokenType CharTok
+      , "Syntax highlighting: Char"
+      )
+    , ( attrNameForTokenType SpecialCharTok
+      , "Syntax highlighting: Special Char"
+      )
+    , ( attrNameForTokenType StringTok
+      , "Syntax highlighting: String"
+      )
+    , ( attrNameForTokenType VerbatimStringTok
+      , "Syntax highlighting: Verbatim String"
+      )
+    , ( attrNameForTokenType SpecialStringTok
+      , "Syntax highlighting: Special String"
+      )
+    , ( attrNameForTokenType ImportTok
+      , "Syntax highlighting: Import"
+      )
+    , ( attrNameForTokenType CommentTok
+      , "Syntax highlighting: Comment"
+      )
+    , ( attrNameForTokenType DocumentationTok
+      , "Syntax highlighting: Documentation"
+      )
+    , ( attrNameForTokenType AnnotationTok
+      , "Syntax highlighting: Annotation"
+      )
+    , ( attrNameForTokenType CommentVarTok
+      , "Syntax highlighting: Comment"
+      )
+    , ( attrNameForTokenType OtherTok
+      , "Syntax highlighting: Other"
+      )
+    , ( attrNameForTokenType FunctionTok
+      , "Syntax highlighting: Function"
+      )
+    , ( attrNameForTokenType VariableTok
+      , "Syntax highlighting: Variable"
+      )
+    , ( attrNameForTokenType ControlFlowTok
+      , "Syntax highlighting: Control Flow"
+      )
+    , ( attrNameForTokenType OperatorTok
+      , "Syntax highlighting: Operator"
+      )
+    , ( attrNameForTokenType BuiltInTok
+      , "Syntax highlighting: Built-In"
+      )
+    , ( attrNameForTokenType ExtensionTok
+      , "Syntax highlighting: Extension"
+      )
+    , ( attrNameForTokenType PreprocessorTok
+      , "Syntax highlighting: Preprocessor"
+      )
+    , ( attrNameForTokenType AttributeTok
+      , "Syntax highlighting: Attribute"
+      )
+    , ( attrNameForTokenType RegionMarkerTok
+      , "Syntax highlighting: Region Marker"
+      )
+    , ( attrNameForTokenType InformationTok
+      , "Syntax highlighting: Information"
+      )
+    , ( attrNameForTokenType WarningTok
+      , "Syntax highlighting: Warning"
+      )
+    , ( attrNameForTokenType AlertTok
+      , "Syntax highlighting: Alert"
+      )
+    , ( attrNameForTokenType ErrorTok
+      , "Syntax highlighting: Error"
+      )
+    , ( attrNameForTokenType NormalTok
+      , "Syntax highlighting: Normal text"
+      )
+    , ( listSelectedFocusedAttr
+      , "The selected channel"
+      )
+    , ( focusedFormInputAttr
+      , "A form input that has focus"
+      )
+    , ( FB.fileBrowserAttr
+      , "The base file browser attribute"
+      )
+    , ( FB.fileBrowserCurrentDirectoryAttr
+      , "The file browser current directory attribute"
+      )
+    , ( FB.fileBrowserSelectionInfoAttr
+      , "The file browser selection information attribute"
+      )
+    , ( FB.fileBrowserDirectoryAttr
+      , "Attribute for directories in the file browser"
+      )
+    , ( FB.fileBrowserBlockDeviceAttr
+      , "Attribute for block devices in the file browser"
+      )
+    , ( FB.fileBrowserRegularFileAttr
+      , "Attribute for regular files in the file browser"
+      )
+    , ( FB.fileBrowserCharacterDeviceAttr
+      , "Attribute for character devices in the file browser"
+      )
+    , ( FB.fileBrowserNamedPipeAttr
+      , "Attribute for named pipes in the file browser"
+      )
+    , ( FB.fileBrowserSymbolicLinkAttr
+      , "Attribute for symbolic links in the file browser"
+      )
+    , ( FB.fileBrowserUnixSocketAttr
+      , "Attribute for Unix sockets in the file browser"
+      )
+    , ( buttonAttr
+      , "Attribute for input form buttons"
+      )
+    , ( buttonFocusedAttr
+      , "Attribute for focused input form buttons"
+      )
+    , ( currentUserAttr
+      , "Attribute for the username of the user running Matterhorn"
+      )
+    ] <> [ (usernameAttr i, T.pack $ "Username color " <> show i)
+         | i <- [0..usernameColorHashBuckets-1]
+         ]
diff --git a/src/Matterhorn/TimeUtils.hs b/src/Matterhorn/TimeUtils.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/TimeUtils.hs
@@ -0,0 +1,70 @@
+module Matterhorn.TimeUtils
+    ( lookupLocalTimeZone
+    , startOfDay
+    , justAfter, justBefore
+    , asLocalTime
+    , localTimeText
+    , originTime
+    )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.Text as T
+import           Data.Time.Clock ( UTCTime(..) )
+import           Data.Time.Format ( formatTime, defaultTimeLocale )
+import           Data.Time.LocalTime ( LocalTime(..), TimeOfDay(..) )
+import           Data.Time.LocalTime.TimeZone.Olson ( getTimeZoneSeriesFromOlsonFile )
+import           Data.Time.LocalTime.TimeZone.Series ( localTimeToUTC'
+                                                     , utcToLocalTime')
+
+import           Network.Mattermost.Types ( ServerTime(..) )
+
+
+-- | Get the timezone series that should be used for converting UTC
+-- times into local times with appropriate DST adjustments.
+lookupLocalTimeZone :: IO TimeZoneSeries
+lookupLocalTimeZone = getTimeZoneSeriesFromOlsonFile "/etc/localtime"
+
+
+-- | Sometimes it is convenient to render a divider between messages;
+-- the 'justAfter' function can be used to get a time that is after
+-- the input time but by such a small increment that there is unlikely
+-- to be anything between (or at) the result.  Adding the divider
+-- using this timestamp value allows the general sorting based on
+-- timestamps to operate normally (whereas a type-match for a
+-- non-timestamp-entry in the sort operation would be considerably
+-- more complex).
+justAfter :: ServerTime -> ServerTime
+justAfter = ServerTime . justAfterUTC . withServerTime
+    where justAfterUTC time = let UTCTime d t = time in UTCTime d (succ t)
+
+-- | Obtain a time value that is just moments before the input time;
+-- see the comment for the 'justAfter' function for more details.
+justBefore :: ServerTime -> ServerTime
+justBefore = ServerTime . justBeforeUTC . withServerTime
+    where justBeforeUTC time = let UTCTime d t = time in UTCTime d (pred t)
+
+-- | The timestamp for the start of the day associated with the input
+-- timestamp.  If timezone information is supplied, then the returned
+-- value will correspond to when the day started in that timezone;
+-- otherwise it is the start of the day in a timezone aligned with
+-- UTC.
+startOfDay :: Maybe TimeZoneSeries -> UTCTime -> UTCTime
+startOfDay Nothing time = let UTCTime d _ = time in UTCTime d 0
+startOfDay (Just tz) time = let lt = utcToLocalTime' tz time
+                                ls = LocalTime (localDay lt) (TimeOfDay 0 0 0)
+                            in localTimeToUTC' tz ls
+
+-- | Convert a UTC time value to a local time.
+asLocalTime :: TimeZoneSeries -> UTCTime -> LocalTime
+asLocalTime = utcToLocalTime'
+
+-- | Local time in displayable format
+localTimeText :: Text -> LocalTime -> Text
+localTimeText fmt time = T.pack $ formatTime defaultTimeLocale (T.unpack fmt) time
+
+-- | Provides a time value that can be used when there are no other times available
+originTime :: UTCTime
+originTime = UTCTime (toEnum 0) 0
diff --git a/src/Matterhorn/Types.hs b/src/Matterhorn/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Types.hs
@@ -0,0 +1,2165 @@
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE KindSignatures #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TupleSections #-}
+module Matterhorn.Types
+  ( ConnectionStatus(..)
+  , HelpTopic(..)
+  , MessageSelectState(..)
+  , ProgramOutput(..)
+  , MHEvent(..)
+  , InternalEvent(..)
+  , Name(..)
+  , ChannelSelectMatch(..)
+  , StartupStateInfo(..)
+  , MHError(..)
+  , AttachmentData(..)
+  , CPUUsagePolicy(..)
+  , tabbedWindow
+  , getCurrentTabbedWindowEntry
+  , tabbedWindowNextTab
+  , tabbedWindowPreviousTab
+  , runTabShowHandlerFor
+  , getServerBaseUrl
+  , serverBaseUrl
+  , TabbedWindow(..)
+  , TabbedWindowEntry(..)
+  , TabbedWindowTemplate(..)
+  , ConnectionInfo(..)
+  , SidebarUpdate(..)
+  , PendingChannelChange(..)
+  , ViewMessageWindowTab(..)
+  , clearChannelUnreadStatus
+  , ChannelListEntry(..)
+  , ChannelListOrientation(..)
+  , channelListEntryChannelId
+  , channelListEntryUserId
+  , userIdsFromZipper
+  , entryIsDMEntry
+  , ciHostname
+  , ciPort
+  , ciUrlPath
+  , ciUsername
+  , ciPassword
+  , ciType
+  , ciAccessToken
+  , newChannelTopicDialog
+  , ChannelTopicDialogState(..)
+  , channelTopicDialogEditor
+  , channelTopicDialogFocus
+  , Config(..)
+  , HelpScreen(..)
+  , PasswordSource(..)
+  , TokenSource(..)
+  , MatchType(..)
+  , Mode(..)
+  , ChannelSelectPattern(..)
+  , PostListContents(..)
+  , AuthenticationException(..)
+  , BackgroundInfo(..)
+  , RequestChan
+  , UserFetch(..)
+  , writeBChan
+  , InternalTheme(..)
+
+  , attrNameToConfig
+
+  , mkChannelZipperList
+  , ChannelListGroup(..)
+  , channelListGroupUnread
+
+  , trimChannelSigil
+
+  , ChannelSelectState(..)
+  , channelSelectMatches
+  , channelSelectInput
+  , emptyChannelSelectState
+
+  , ChatState
+  , newState
+  , csChannelTopicDialog
+  , csChannelListOrientation
+  , csResources
+  , csFocus
+  , csCurrentChannel
+  , csCurrentChannelId
+  , csUrlList
+  , csShowMessagePreview
+  , csShowChannelList
+  , csShowExpandedChannelTopics
+  , csPostMap
+  , csRecentChannel
+  , csReturnChannel
+  , csThemeListOverlay
+  , csPostListOverlay
+  , csUserListOverlay
+  , csChannelListOverlay
+  , csReactionEmojiListOverlay
+  , csMyTeam
+  , csMessageSelect
+  , csConnectionStatus
+  , csWorkerIsBusy
+  , csChannel
+  , csChannels
+  , csChannelSelectState
+  , csEditState
+  , csClientConfig
+  , csPendingChannelChange
+  , csViewedMessage
+  , csNotifyPrefs
+  , csMe
+  , timeZone
+  , whenMode
+  , setMode
+  , setMode'
+  , appMode
+
+  , ChatEditState
+  , emptyEditState
+  , cedAttachmentList
+  , cedFileBrowser
+  , cedYankBuffer
+  , cedSpellChecker
+  , cedMisspellings
+  , cedEditMode
+  , cedEphemeral
+  , cedEditor
+  , cedInputHistory
+  , cedAutocomplete
+  , cedAutocompletePending
+  , cedJustCompleted
+
+  , AutocompleteState(..)
+  , acPreviousSearchString
+  , acCompletionList
+  , acCachedResponses
+  , acType
+
+  , AutocompletionType(..)
+
+  , CompletionSource(..)
+  , AutocompleteAlternative(..)
+  , autocompleteAlternativeReplacement
+  , SpecialMention(..)
+  , specialMentionName
+  , isSpecialMention
+
+  , PostListOverlayState
+  , postListSelected
+  , postListPosts
+
+  , UserSearchScope(..)
+  , ChannelSearchScope(..)
+
+  , ListOverlayState
+  , listOverlaySearchResults
+  , listOverlaySearchInput
+  , listOverlaySearchScope
+  , listOverlaySearching
+  , listOverlayEnterHandler
+  , listOverlayNewList
+  , listOverlayFetchResults
+  , listOverlayRecordCount
+  , listOverlayReturnMode
+
+  , getUsers
+
+  , ChatResources(..)
+  , crUserPreferences
+  , crEventQueue
+  , crTheme
+  , crStatusUpdateChan
+  , crSubprocessLog
+  , crWebsocketActionChan
+  , crWebsocketThreadId
+  , crRequestQueue
+  , crFlaggedPosts
+  , crConn
+  , crConfiguration
+  , crSyntaxMap
+  , crLogManager
+  , crEmoji
+  , getSession
+  , getResourceSession
+
+  , specialUserMentions
+
+  , UserPreferences(UserPreferences)
+  , userPrefShowJoinLeave
+  , userPrefFlaggedPostList
+  , userPrefGroupChannelPrefs
+  , userPrefDirectChannelPrefs
+  , userPrefTeammateNameDisplayMode
+  , dmChannelShowPreference
+  , groupChannelShowPreference
+
+  , defaultUserPreferences
+  , setUserPreferences
+
+  , WebsocketAction(..)
+
+  , Cmd(..)
+  , commandName
+  , CmdArgs(..)
+
+  , MH
+  , runMHEvent
+  , scheduleUserFetches
+  , scheduleUserStatusFetches
+  , getScheduledUserFetches
+  , getScheduledUserStatusFetches
+  , mh
+  , generateUUID
+  , generateUUID_IO
+  , mhSuspendAndResume
+  , mhHandleEventLensed
+  , St.gets
+  , mhError
+
+  , mhLog
+  , mhGetIOLogger
+  , ioLogWithManager
+  , LogContext(..)
+  , withLogContext
+  , withLogContextChannelId
+  , getLogContext
+  , LogMessage(..)
+  , LogCommand(..)
+  , LogCategory(..)
+
+  , LogManager(..)
+  , startLoggingToFile
+  , stopLoggingToFile
+  , requestLogSnapshot
+  , requestLogDestination
+  , sendLogMessage
+
+  , requestQuit
+  , getMessageForPostId
+  , getParentMessage
+  , getReplyRootMessage
+  , resetSpellCheckTimer
+  , withChannel
+  , withChannelOrDefault
+  , userList
+  , resetAutocomplete
+  , hasUnread
+  , hasUnread'
+  , isMine
+  , setUserStatus
+  , myUser
+  , myUsername
+  , myUserId
+  , myTeamId
+  , usernameForUserId
+  , userByUsername
+  , userByNickname
+  , channelIdByChannelName
+  , channelIdByUsername
+  , channelByName
+  , userById
+  , allUserIds
+  , addNewUser
+  , useNickname
+  , useNickname'
+  , displayNameForUserId
+  , displayNameForUser
+  , raiseInternalEvent
+  , getNewMessageCutoff
+  , getEditedMessageCutoff
+
+  , HighlightSet(..)
+  , UserSet
+  , ChannelSet
+  , getHighlightSet
+
+  , module Matterhorn.Types.Channels
+  , module Matterhorn.Types.Messages
+  , module Matterhorn.Types.Posts
+  , module Matterhorn.Types.Users
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Graphics.Vty as Vty
+import qualified Brick
+import           Brick ( EventM, Next, Widget )
+import           Brick.Focus ( FocusRing, focusRing )
+import           Brick.Themes ( Theme )
+import           Brick.Main ( invalidateCache, invalidateCacheEntry )
+import           Brick.AttrMap ( AttrMap )
+import qualified Brick.BChan as BCH
+import           Brick.Forms (Form)
+import           Brick.Widgets.Edit ( Editor, editor )
+import           Brick.Widgets.List ( List, list )
+import qualified Brick.Widgets.FileBrowser as FB
+import           Control.Concurrent ( ThreadId )
+import           Control.Concurrent.Async ( Async )
+import qualified Control.Concurrent.STM as STM
+import           Control.Exception ( SomeException )
+import qualified Control.Monad.Fail as MHF
+import qualified Control.Monad.State as St
+import qualified Control.Monad.Reader as R
+import qualified Data.Set as Set
+import qualified Data.ByteString as BS
+import qualified Data.Foldable as F
+import qualified Data.Kind as K
+import           Data.Ord ( comparing )
+import qualified Data.HashMap.Strict as HM
+import           Data.List ( sortBy, nub, elemIndex )
+import qualified Data.Sequence as Seq
+import qualified Data.Text as T
+import           Data.Time.Clock ( getCurrentTime, addUTCTime )
+import           Data.UUID ( UUID )
+import qualified Data.Vector as Vec
+import           Lens.Micro.Platform ( at, makeLenses, lens, (%~), (^?!), (.=)
+                                     , (%=), (^?), (.~)
+                                     , _Just, Traversal', preuse, to
+                                     , SimpleGetter
+                                     )
+import           Network.Connection ( HostNotResolved, HostCannotConnect )
+import           Skylighting.Types ( SyntaxMap )
+import           System.Exit ( ExitCode )
+import           System.Random ( randomIO )
+import           Text.Aspell ( Aspell )
+
+import           Network.Mattermost ( ConnectionData )
+import           Network.Mattermost.Exceptions
+import           Network.Mattermost.Lenses
+import           Network.Mattermost.Types
+import           Network.Mattermost.Types.Config
+import           Network.Mattermost.WebSocket ( WebsocketEvent, WebsocketActionResponse )
+
+import           Matterhorn.Constants ( userSigil, normalChannelSigil )
+import           Matterhorn.InputHistory
+import           Matterhorn.Emoji
+import           Matterhorn.Types.Common
+import           Matterhorn.Types.Channels
+import           Matterhorn.Types.DirectionalSeq ( emptyDirSeq )
+import           Matterhorn.Types.KeyEvents
+import           Matterhorn.Types.Messages
+import           Matterhorn.Types.Posts
+import           Matterhorn.Types.RichText ( TeamBaseURL(..), TeamURLName(..) )
+import           Matterhorn.Types.Users
+import qualified Matterhorn.Zipper as Z
+
+
+-- * Configuration
+
+-- | A user password is either given to us directly, or a command
+-- which we execute to find the password.
+data PasswordSource =
+    PasswordString Text
+    | PasswordCommand Text
+    deriving (Eq, Read, Show)
+
+-- | An access token source.
+data TokenSource =
+    TokenString Text
+    | TokenCommand Text
+    deriving (Eq, Read, Show)
+
+-- | The type of channel list group headings. Integer arguments indicate
+-- total number of channels in the group that have unread activity.
+data ChannelListGroup =
+    ChannelGroupPublicChannels Int
+    | ChannelGroupPrivateChannels Int
+    | ChannelGroupDirectMessages Int
+    deriving (Eq)
+
+channelListGroupUnread :: ChannelListGroup -> Int
+channelListGroupUnread (ChannelGroupPublicChannels n)  = n
+channelListGroupUnread (ChannelGroupPrivateChannels n) = n
+channelListGroupUnread (ChannelGroupDirectMessages n)  = n
+
+-- | The type of channel list entries.
+data ChannelListEntry =
+    CLChannel ChannelId
+    -- ^ A non-DM entry
+    | CLUserDM ChannelId UserId
+    -- ^ A single-user DM entry
+    | CLGroupDM ChannelId
+    -- ^ A multi-user DM entry
+    deriving (Eq, Show)
+
+-- | This is how we represent the user's configuration. Most fields
+-- correspond to configuration file settings (see Config.hs) but some
+-- are for internal book-keeping purposes only.
+data Config =
+    Config { configUser :: Maybe Text
+           -- ^ The username to use when connecting.
+           , configHost :: Maybe Text
+           -- ^ The hostname to use when connecting.
+           , configTeam :: Maybe Text
+           -- ^ The team name to use when connecting.
+           , configPort :: Int
+           -- ^ The port to use when connecting.
+           , configUrlPath :: Maybe Text
+           -- ^ The server path to use when connecting.
+           , configPass :: Maybe PasswordSource
+           -- ^ The password source to use when connecting.
+           , configToken :: Maybe TokenSource
+           -- ^ The token source to use when connecting.
+           , configTimeFormat :: Maybe Text
+           -- ^ The format string for timestamps.
+           , configDateFormat :: Maybe Text
+           -- ^ The format string for dates.
+           , configTheme :: Maybe Text
+           -- ^ The name of the theme to use.
+           , configThemeCustomizationFile :: Maybe Text
+           -- ^ The path to the theme customization file, if any.
+           , configSmartBacktick :: Bool
+           -- ^ Whether to enable smart quoting characters.
+           , configSmartEditing :: Bool
+           -- ^ Whether to enable smart editing behaviors.
+           , configURLOpenCommand :: Maybe Text
+           -- ^ The command to use to open URLs.
+           , configURLOpenCommandInteractive :: Bool
+           -- ^ Whether the URL-opening command is interactive (i.e.
+           -- whether it should be given control of the terminal).
+           , configActivityNotifyCommand :: Maybe T.Text
+           -- ^ The command to run for activity notifications.
+           , configActivityBell :: Bool
+           -- ^ Whether to ring the terminal bell on activity.
+           , configShowMessageTimestamps :: Bool
+           -- ^ Whether to show timestamps on messages.
+           , configShowBackground :: BackgroundInfo
+           -- ^ Whether to show async background worker thread info.
+           , configShowMessagePreview :: Bool
+           -- ^ Whether to show the message preview area.
+           , configShowChannelList :: Bool
+           -- ^ Whether to show the channel list.
+           , configShowExpandedChannelTopics :: Bool
+           -- ^ Whether to show expanded channel topics.
+           , configEnableAspell :: Bool
+           -- ^ Whether to enable Aspell spell checking.
+           , configAspellDictionary :: Maybe Text
+           -- ^ A specific Aspell dictionary name to use.
+           , configUnsafeUseHTTP :: Bool
+           -- ^ Whether to permit an insecure HTTP connection.
+           , configValidateServerCertificate :: Bool
+           -- ^ Whether to validate TLS certificates.
+           , configChannelListWidth :: Int
+           -- ^ The width, in columns, of the channel list sidebar.
+           , configLogMaxBufferSize :: Int
+           -- ^ The maximum size, in log entries, of the internal log
+           -- message buffer.
+           , configShowOlderEdits :: Bool
+           -- ^ Whether to highlight the edit indicator on edits made
+           -- prior to the beginning of the current session.
+           , configShowTypingIndicator :: Bool
+           -- ^ Whether to show the typing indicator for other users,
+           -- and whether to send typing notifications to other users.
+           , configAbsPath :: Maybe FilePath
+           -- ^ A book-keeping field for the absolute path to the
+           -- configuration. (Not a user setting.)
+           , configUserKeys :: KeyConfig
+           -- ^ The user's keybinding configuration.
+           , configHyperlinkingMode :: Bool
+           -- ^ Whether to enable terminal hyperlinking mode.
+           , configSyntaxDirs :: [FilePath]
+           -- ^ The search path for syntax description XML files.
+           , configDirectChannelExpirationDays :: Int
+           -- ^ The number of days to show a user in the channel menu after a direct
+           -- message with them.
+           , configCpuUsagePolicy :: CPUUsagePolicy
+           -- ^ The CPU usage policy for the application.
+           , configDefaultAttachmentPath :: Maybe FilePath
+           -- ^ The default path for browsing attachments
+           , configChannelListOrientation :: ChannelListOrientation
+           -- ^ The orientation of the channel list.
+           } deriving (Eq, Show)
+
+-- | The policy for CPU usage.
+--
+-- The idea is that Matterhorn can benefit from using multiple CPUs,
+-- but the exact number is application-determined. We expose this policy
+-- setting to the user in the configuration.
+data CPUUsagePolicy =
+    SingleCPU
+    -- ^ Constrain the application to use one CPU.
+    | MultipleCPUs
+    -- ^ Permit the usage of multiple CPUs (the exact number is
+    -- determined by the application).
+    deriving (Eq, Show)
+
+-- | The state of the UI diagnostic indicator for the async worker
+-- thread.
+data BackgroundInfo =
+    Disabled
+    -- ^ Disable (do not show) the indicator.
+    | Active
+    -- ^ Show the indicator when the thread is working.
+    | ActiveCount
+    -- ^ Show the indicator when the thread is working, but include the
+    -- thread's work queue length.
+    deriving (Eq, Show)
+
+data UserPreferences =
+    UserPreferences { _userPrefShowJoinLeave     :: Bool
+                    , _userPrefFlaggedPostList   :: Seq FlaggedPost
+                    , _userPrefGroupChannelPrefs :: HashMap ChannelId Bool
+                    , _userPrefDirectChannelPrefs :: HashMap UserId Bool
+                    , _userPrefTeammateNameDisplayMode :: Maybe TeammateNameDisplayMode
+                    }
+
+hasUnread :: ChatState -> ChannelId -> Bool
+hasUnread st cId = fromMaybe False $
+    hasUnread' <$> findChannelById cId (_csChannels st)
+
+hasUnread' :: ClientChannel -> Bool
+hasUnread' chan = fromMaybe False $ do
+    let info = _ccInfo chan
+    lastViewTime <- _cdViewed info
+    return $ _cdMentionCount info > 0 ||
+             (not (isMuted chan) &&
+              (((_cdUpdated info) > lastViewTime) ||
+               (isJust $ _cdEditedMessageThreshold info)))
+
+mkChannelZipperList :: UTCTime
+                    -> Config
+                    -> Maybe ClientConfig
+                    -> UserPreferences
+                    -> ClientChannels
+                    -> Users
+                    -> [(ChannelListGroup, [ChannelListEntry])]
+mkChannelZipperList now config cconfig prefs cs us =
+    [ let (unread, entries) = getChannelEntriesInOrder cs Ordinary
+      in (ChannelGroupPublicChannels unread, entries)
+    , let (unread, entries) = getChannelEntriesInOrder cs Private
+      in (ChannelGroupPrivateChannels unread, entries)
+    , let (unread, entries) = getDMChannelEntriesInOrder now config cconfig prefs us cs
+      in (ChannelGroupDirectMessages unread, entries)
+    ]
+
+getChannelEntriesInOrder :: ClientChannels -> Type -> (Int, [ChannelListEntry])
+getChannelEntriesInOrder cs ty =
+    let matches (_, info) = info^.ccInfo.cdType == ty
+        pairs = filteredChannels matches cs
+        unread = length $ filter (== True) $ (hasUnread' . snd) <$> pairs
+        entries = fmap (CLChannel . fst) $
+                  sortBy (comparing ((^.ccInfo.cdDisplayName.to T.toLower) . snd)) pairs
+    in (unread, entries)
+
+getDMChannelEntriesInOrder :: UTCTime
+                           -> Config
+                           -> Maybe ClientConfig
+                           -> UserPreferences
+                           -> Users
+                           -> ClientChannels
+                           -> (Int, [ChannelListEntry])
+getDMChannelEntriesInOrder now config cconfig prefs us cs =
+    let oneOnOneDmChans = getDMChannelEntries now config cconfig prefs us cs
+        groupChans = getGroupDMChannelEntries now config prefs cs
+        allDmChans = groupChans <> oneOnOneDmChans
+        sorter (u1, n1, _) (u2, n2, _) =
+            if u1 == u2
+            then compare n1 n2
+            else if u1 && not u2
+                 then LT
+                 else GT
+        sorted = sortBy sorter allDmChans
+        third (_, _, c) = c
+        fst3 (a, _, _) = a
+        unread = length $ filter id $ fst3 <$> sorted
+    in (unread, third <$> sorted)
+
+useNickname' :: Maybe ClientConfig -> UserPreferences -> Bool
+useNickname' clientConfig prefs =
+    let serverSetting = case clientConfig^?_Just.to clientConfigTeammateNameDisplay of
+            Just TMNicknameOrFullname -> Just True
+            _                         -> Nothing
+        accountSetting = (== TMNicknameOrFullname) <$> (_userPrefTeammateNameDisplayMode prefs)
+        fallback = False
+    in fromMaybe fallback $ accountSetting <|> serverSetting
+
+displayNameForUser :: UserInfo -> Maybe ClientConfig -> UserPreferences -> Text
+displayNameForUser u clientConfig prefs
+    | useNickname' clientConfig prefs =
+        fromMaybe (u^.uiName) (u^.uiNickName)
+    | otherwise =
+        u^.uiName
+
+getGroupDMChannelEntries :: UTCTime
+                         -> Config
+                         -> UserPreferences
+                         -> ClientChannels
+                         -> [(Bool, T.Text, ChannelListEntry)]
+getGroupDMChannelEntries now config prefs cs =
+    let matches (_, info) = info^.ccInfo.cdType == Group &&
+                            groupChannelShouldAppear now config prefs info
+    in fmap (\(cId, ch) -> (hasUnread' ch, ch^.ccInfo.cdDisplayName, CLGroupDM cId)) $
+       filteredChannels matches cs
+
+getDMChannelEntries :: UTCTime
+                    -> Config
+                    -> Maybe ClientConfig
+                    -> UserPreferences
+                    -> Users
+                    -> ClientChannels
+                    -> [(Bool, T.Text, ChannelListEntry)]
+getDMChannelEntries now config cconfig prefs us cs =
+    let mapping = allDmChannelMappings cs
+        mappingWithUserInfo = catMaybes $ getInfo <$> mapping
+        getInfo (uId, cId) = do
+            c <- findChannelById cId cs
+            u <- findUserById uId us
+            case u^.uiDeleted of
+                True -> Nothing
+                False ->
+                    if dmChannelShouldAppear now config prefs c
+                    then return (hasUnread' c, displayNameForUser u cconfig prefs, CLUserDM cId uId)
+                    else Nothing
+    in mappingWithUserInfo
+
+-- Always show a DM channel if it has unread activity.
+--
+-- If it has no unread activity and if the preferences explicitly say to
+-- hide it, hide it.
+--
+-- Otherwise, only show it if at least one of the other conditions are
+-- met (see 'or' below).
+dmChannelShouldAppear :: UTCTime -> Config -> UserPreferences -> ClientChannel -> Bool
+dmChannelShouldAppear now config prefs c =
+    let ndays = configDirectChannelExpirationDays config
+        localCutoff = addUTCTime (nominalDay * (-(fromIntegral ndays))) now
+        cutoff = ServerTime localCutoff
+        updated = c^.ccInfo.cdUpdated
+        Just uId = c^.ccInfo.cdDMUserId
+    in if hasUnread' c || maybe False (>= localCutoff) (c^.ccInfo.cdSidebarShowOverride)
+       then True
+       else case dmChannelShowPreference prefs uId of
+           Just False -> False
+           _ -> or [
+                   -- The channel was updated recently enough
+                     updated >= cutoff
+                   ]
+
+groupChannelShouldAppear :: UTCTime -> Config -> UserPreferences -> ClientChannel -> Bool
+groupChannelShouldAppear now config prefs c =
+    let ndays = configDirectChannelExpirationDays config
+        localCutoff = addUTCTime (nominalDay * (-(fromIntegral ndays))) now
+        cutoff = ServerTime localCutoff
+        updated = c^.ccInfo.cdUpdated
+    in if hasUnread' c || maybe False (>= localCutoff) (c^.ccInfo.cdSidebarShowOverride)
+       then True
+       else case groupChannelShowPreference prefs (c^.ccInfo.cdChannelId) of
+           Just False -> False
+           _ -> or [
+                   -- The channel was updated recently enough
+                     updated >= cutoff
+                   ]
+
+dmChannelShowPreference :: UserPreferences -> UserId -> Maybe Bool
+dmChannelShowPreference ps uId = HM.lookup uId (_userPrefDirectChannelPrefs ps)
+
+groupChannelShowPreference :: UserPreferences -> ChannelId -> Maybe Bool
+groupChannelShowPreference ps cId = HM.lookup cId (_userPrefGroupChannelPrefs ps)
+
+-- * Internal Names and References
+
+-- | This 'Name' type is the type used in 'brick' to identify various
+-- parts of the interface.
+data Name =
+    ChannelMessages ChannelId
+    | MessageInput
+    | ChannelList
+    | HelpViewport
+    | HelpText
+    | ScriptHelpText
+    | ThemeHelpText
+    | SyntaxHighlightHelpText
+    | KeybindingHelpText
+    | ChannelSelectString
+    | CompletionAlternatives
+    | CompletionList
+    | JoinChannelList
+    | UrlList
+    | MessagePreviewViewport
+    | ThemeListSearchInput
+    | UserListSearchInput
+    | JoinChannelListSearchInput
+    | UserListSearchResults
+    | ThemeListSearchResults
+    | ViewMessageArea
+    | ViewMessageReactionsArea
+    | ChannelSidebar
+    | ChannelSelectInput
+    | AttachmentList
+    | AttachmentFileBrowser
+    | MessageReactionsArea
+    | ReactionEmojiList
+    | ReactionEmojiListInput
+    | TabbedWindowTabBar
+    | MuteToggleField
+    | ChannelMentionsField
+    | DesktopNotificationsField (WithDefault NotifyOption)
+    | PushNotificationsField (WithDefault NotifyOption)
+    | ChannelTopicEditor
+    | ChannelTopicSaveButton
+    | ChannelTopicCancelButton
+    | ChannelTopicEditorPreview
+    deriving (Eq, Show, Ord)
+
+-- | The sum type of exceptions we expect to encounter on authentication
+-- failure. We encode them explicitly here so that we can print them in
+-- a more user-friendly manner than just 'show'.
+data AuthenticationException =
+    ConnectError HostCannotConnect
+    | ResolveError HostNotResolved
+    | AuthIOError IOError
+    | LoginError LoginFailureException
+    | OtherAuthError SomeException
+    deriving (Show)
+
+-- | Our 'ConnectionInfo' contains exactly as much information as is
+-- necessary to start a connection with a Mattermost server. This is
+-- built up during interactive authentication and then is used to log
+-- in.
+--
+-- If the access token field is non-empty, that value is used and the
+-- username and password values are ignored.
+data ConnectionInfo =
+    ConnectionInfo { _ciHostname :: Text
+                   , _ciPort     :: Int
+                   , _ciUrlPath  :: Text
+                   , _ciUsername :: Text
+                   , _ciPassword :: Text
+                   , _ciAccessToken :: Text
+                   , _ciType     :: ConnectionType
+                   }
+
+-- | We want to continue referring to posts by their IDs, but we don't
+-- want to have to synthesize new valid IDs for messages from the client
+-- itself (like error messages or informative client responses). To that
+-- end, a PostRef can be either a PostId or a newly-generated client ID.
+data PostRef
+    = MMId PostId
+    | CLId Int
+    deriving (Eq, Show)
+
+-- ** Channel-matching types
+
+-- | A match in channel selection mode.
+data ChannelSelectMatch =
+    ChannelSelectMatch { nameBefore :: Text
+                       -- ^ The content of the match before the user's
+                       -- matching input.
+                       , nameMatched :: Text
+                       -- ^ The potion of the name that matched the
+                       -- user's input.
+                       , nameAfter :: Text
+                       -- ^ The portion of the name that came after the
+                       -- user's matching input.
+                       , matchFull :: Text
+                       -- ^ The full string for this entry so it doesn't
+                       -- have to be reassembled from the parts above.
+                       , matchEntry :: ChannelListEntry
+                       -- ^ The original entry data corresponding to the
+                       -- text match.
+                       }
+                       deriving (Eq, Show)
+
+data ChannelSelectPattern = CSP MatchType Text
+                          | CSPAny
+                          deriving (Eq, Show)
+
+data MatchType =
+    Prefix
+    | Suffix
+    | Infix
+    | Equal
+    | PrefixDMOnly
+    | PrefixNonDMOnly
+    deriving (Eq, Show)
+
+-- * Application State Values
+
+data ProgramOutput =
+    ProgramOutput { program :: FilePath
+                  , programArgs :: [String]
+                  , programStdout :: String
+                  , programStderr :: String
+                  , programExitCode :: ExitCode
+                  }
+
+defaultUserPreferences :: UserPreferences
+defaultUserPreferences =
+    UserPreferences { _userPrefShowJoinLeave     = True
+                    , _userPrefFlaggedPostList   = mempty
+                    , _userPrefGroupChannelPrefs = mempty
+                    , _userPrefDirectChannelPrefs = mempty
+                    , _userPrefTeammateNameDisplayMode = Nothing
+                    }
+
+setUserPreferences :: Seq Preference -> UserPreferences -> UserPreferences
+setUserPreferences = flip (F.foldr go)
+    where go p u
+            | Just fp <- preferenceToFlaggedPost p =
+              u { _userPrefFlaggedPostList =
+                  _userPrefFlaggedPostList u Seq.|> fp
+                }
+            | Just gp <- preferenceToDirectChannelShowStatus p =
+              u { _userPrefDirectChannelPrefs =
+                  HM.insert
+                    (directChannelShowUserId gp)
+                    (directChannelShowValue gp)
+                    (_userPrefDirectChannelPrefs u)
+                }
+            | Just gp <- preferenceToGroupChannelPreference p =
+              u { _userPrefGroupChannelPrefs =
+                  HM.insert
+                    (groupChannelId gp)
+                    (groupChannelShow gp)
+                    (_userPrefGroupChannelPrefs u)
+                }
+            | preferenceName p == PreferenceName "join_leave" =
+              u { _userPrefShowJoinLeave =
+                  preferenceValue p /= PreferenceValue "false" }
+            | preferenceCategory p == PreferenceCategoryDisplaySettings &&
+              preferenceName p == PreferenceName "name_format" =
+                  let PreferenceValue txt = preferenceValue p
+                  in u { _userPrefTeammateNameDisplayMode = Just $ teammateDisplayModeFromText txt }
+            | otherwise = u
+
+-- | Log message tags.
+data LogCategory =
+    LogGeneral
+    | LogAPI
+    | LogWebsocket
+    | LogError
+    | LogUserMark
+    deriving (Eq, Show)
+
+-- | A log message.
+data LogMessage =
+    LogMessage { logMessageText :: !Text
+               -- ^ The text of the log message.
+               , logMessageContext :: !(Maybe LogContext)
+               -- ^ The optional context information relevant to the log
+               -- message.
+               , logMessageCategory :: !LogCategory
+               -- ^ The category of the log message.
+               , logMessageTimestamp :: !UTCTime
+               -- ^ The timestamp of the log message.
+               }
+               deriving (Eq, Show)
+
+-- | A logging thread command.
+data LogCommand =
+    LogToFile FilePath
+    -- ^ Start logging to the specified path.
+    | LogAMessage !LogMessage
+    -- ^ Log the specified message.
+    | StopLogging
+    -- ^ Stop any active logging.
+    | ShutdownLogging
+    -- ^ Shut down.
+    | GetLogDestination
+    -- ^ Ask the logging thread about its active logging destination.
+    | LogSnapshot FilePath
+    -- ^ Ask the logging thread to dump the current buffer to the
+    -- specified destination.
+    deriving (Show)
+
+-- | A handle to the log manager thread.
+data LogManager =
+    LogManager { logManagerCommandChannel :: STM.TChan LogCommand
+               , logManagerHandle :: Async ()
+               }
+
+startLoggingToFile :: LogManager -> FilePath -> IO ()
+startLoggingToFile mgr loc = sendLogCommand mgr $ LogToFile loc
+
+stopLoggingToFile :: LogManager -> IO ()
+stopLoggingToFile mgr = sendLogCommand mgr StopLogging
+
+requestLogSnapshot :: LogManager -> FilePath -> IO ()
+requestLogSnapshot mgr path = sendLogCommand mgr $ LogSnapshot path
+
+requestLogDestination :: LogManager -> IO ()
+requestLogDestination mgr = sendLogCommand mgr GetLogDestination
+
+sendLogMessage :: LogManager -> LogMessage -> IO ()
+sendLogMessage mgr lm = sendLogCommand mgr $ LogAMessage lm
+
+sendLogCommand :: LogManager -> LogCommand -> IO ()
+sendLogCommand mgr c =
+    STM.atomically $ STM.writeTChan (logManagerCommandChannel mgr) c
+
+-- | 'ChatResources' represents configuration and connection-related
+-- information, as opposed to current model or view information.
+-- Information that goes in the 'ChatResources' value should be limited
+-- to information that we read or set up prior to setting up the bulk of
+-- the application state.
+data ChatResources =
+    ChatResources { _crSession             :: Session
+                  , _crWebsocketThreadId   :: Maybe ThreadId
+                  , _crConn                :: ConnectionData
+                  , _crRequestQueue        :: RequestChan
+                  , _crEventQueue          :: BCH.BChan MHEvent
+                  , _crSubprocessLog       :: STM.TChan ProgramOutput
+                  , _crWebsocketActionChan :: STM.TChan WebsocketAction
+                  , _crTheme               :: AttrMap
+                  , _crStatusUpdateChan    :: STM.TChan [UserId]
+                  , _crConfiguration       :: Config
+                  , _crFlaggedPosts        :: Set PostId
+                  , _crUserPreferences     :: UserPreferences
+                  , _crSyntaxMap           :: SyntaxMap
+                  , _crLogManager          :: LogManager
+                  , _crEmoji               :: EmojiCollection
+                  }
+
+-- | A "special" mention that does not map to a specific user, but is an
+-- alias that the server uses to notify users.
+data SpecialMention =
+    MentionAll
+    -- ^ @all: notify everyone in the channel.
+    | MentionChannel
+    -- ^ @channel: notify everyone in the channel.
+
+data AutocompleteAlternative =
+    UserCompletion User Bool
+    -- ^ User, plus whether the user is in the channel that triggered
+    -- the autocomplete
+    | SpecialMention SpecialMention
+    -- ^ A special mention.
+    | ChannelCompletion Bool Channel
+    -- ^ Channel, plus whether the user is a member of the channel
+    | SyntaxCompletion Text
+    -- ^ Name of a skylighting syntax definition
+    | CommandCompletion CompletionSource Text Text Text
+    -- ^ Source, name of a slash command, argspec, and description
+    | EmojiCompletion Text
+    -- ^ The text of an emoji completion
+
+-- | The source of an autocompletion alternative.
+data CompletionSource = Server | Client
+                      deriving (Eq, Show)
+
+specialMentionName :: SpecialMention -> Text
+specialMentionName MentionChannel = "channel"
+specialMentionName MentionAll = "all"
+
+isSpecialMention :: T.Text -> Bool
+isSpecialMention n = isJust $ lookup (T.toLower $ trimUserSigil n) pairs
+    where
+        pairs = mkPair <$> mentions
+        mentions = [ MentionChannel
+                   , MentionAll
+                   ]
+        mkPair v = (specialMentionName v, v)
+
+autocompleteAlternativeReplacement :: AutocompleteAlternative -> Text
+autocompleteAlternativeReplacement (EmojiCompletion e) =
+    ":" <> e <> ":"
+autocompleteAlternativeReplacement (SpecialMention m) =
+    userSigil <> specialMentionName m
+autocompleteAlternativeReplacement (UserCompletion u _) =
+    userSigil <> userUsername u
+autocompleteAlternativeReplacement (ChannelCompletion _ c) =
+    normalChannelSigil <> (sanitizeUserText $ channelName c)
+autocompleteAlternativeReplacement (SyntaxCompletion t) =
+    "```" <> t
+autocompleteAlternativeReplacement (CommandCompletion _ t _ _) =
+    "/" <> t
+
+-- | The type of data that the autocompletion logic supports. We use
+-- this to track the kind of completion underway in case the type of
+-- completion needs to change.
+data AutocompletionType =
+    ACUsers
+    | ACChannels
+    | ACCodeBlockLanguage
+    | ACEmoji
+    | ACCommands
+    deriving (Eq, Show)
+
+data AutocompleteState =
+    AutocompleteState { _acPreviousSearchString :: Text
+                      -- ^ The search string used for the
+                      -- currently-displayed autocomplete results, for
+                      -- use in deciding whether to issue another server
+                      -- query
+                      , _acCompletionList :: List Name AutocompleteAlternative
+                      -- ^ The list of alternatives that the user
+                      -- selects from
+                      , _acType :: AutocompletionType
+                      -- ^ The type of data that we're completing
+                      , _acCachedResponses :: HM.HashMap Text [AutocompleteAlternative]
+                      -- ^ A cache of alternative lists, keyed on search
+                      -- string, for use in avoiding server requests.
+                      -- The idea here is that users type quickly enough
+                      -- (and edit their input) that would normally lead
+                      -- to rapid consecutive requests, some for the
+                      -- same strings during editing, that we can avoid
+                      -- that by caching them here. Note that this cache
+                      -- gets destroyed whenever autocompletion is not
+                      -- on, so this cache does not live very long.
+                      }
+
+-- | The 'ChatEditState' value contains the editor widget itself as well
+-- as history and metadata we need for editing-related operations.
+data ChatEditState =
+    ChatEditState { _cedEditor :: Editor Text Name
+                  , _cedEditMode :: EditMode
+                  , _cedEphemeral :: EphemeralEditState
+                  , _cedInputHistory :: InputHistory
+                  -- ^ The map of per-channel input history for the
+                  -- application. We don't distribute the per-channel
+                  -- history into the per-channel states (like we do
+                  -- for other per-channel state) since keeping it
+                  -- under the InputHistory banner lets us use a nicer
+                  -- startup/shutdown disk file management API.
+                  , _cedYankBuffer :: Text
+                  , _cedSpellChecker :: Maybe (Aspell, IO ())
+                  , _cedMisspellings :: Set Text
+                  , _cedAutocomplete :: Maybe AutocompleteState
+                  -- ^ The autocomplete state. The autocompletion UI is
+                  -- showing only when this state is present.
+                  , _cedAutocompletePending :: Maybe Text
+                  -- ^ The search string associated with the latest
+                  -- in-flight autocompletion request. This is used to
+                  -- determine whether any (potentially late-arriving)
+                  -- API responses are for stale queries since the user
+                  -- can type more quickly than the server can get us
+                  -- the results, and we wouldn't want to show results
+                  -- associated with old editor states.
+                  , _cedAttachmentList :: List Name AttachmentData
+                  -- ^ The list of attachments to be uploaded with the
+                  -- post being edited.
+                  , _cedFileBrowser :: Maybe (FB.FileBrowser Name)
+                  -- ^ The browser for selecting attachment files.
+                  -- This is a Maybe because the instantiation of the
+                  -- FileBrowser causes it to read and ingest the
+                  -- target directory, so this action is deferred
+                  -- until the browser is needed.
+                  , _cedJustCompleted :: Bool
+                  -- A flag that indicates whether the most recent
+                  -- editing event was a tab-completion. This is used by
+                  -- the smart trailing space handling.
+                  }
+
+-- | An attachment.
+data AttachmentData =
+    AttachmentData { attachmentDataFileInfo :: FB.FileInfo
+                   , attachmentDataBytes :: BS.ByteString
+                   }
+                   deriving (Eq, Show)
+
+-- | We can initialize a new 'ChatEditState' value with just an edit
+-- history, which we save locally.
+emptyEditState :: InputHistory -> Maybe (Aspell, IO ()) -> IO ChatEditState
+emptyEditState hist sp =
+    return ChatEditState { _cedEditor               = editor MessageInput Nothing ""
+                         , _cedEphemeral            = defaultEphemeralEditState
+                         , _cedInputHistory         = hist
+                         , _cedEditMode             = NewPost
+                         , _cedYankBuffer           = ""
+                         , _cedSpellChecker         = sp
+                         , _cedMisspellings         = mempty
+                         , _cedAutocomplete         = Nothing
+                         , _cedAutocompletePending  = Nothing
+                         , _cedAttachmentList       = list AttachmentList mempty 1
+                         , _cedFileBrowser          = Nothing
+                         , _cedJustCompleted        = False
+                         }
+
+-- | A 'RequestChan' is a queue of operations we have to perform in the
+-- background to avoid blocking on the main loop
+type RequestChan = STM.TChan (IO (Maybe (MH ())))
+
+-- | The 'HelpScreen' type represents the set of possible 'Help'
+-- dialogues we have to choose from.
+data HelpScreen =
+    MainHelp
+    | ScriptHelp
+    | ThemeHelp
+    | SyntaxHighlightHelp
+    | KeybindingHelp
+    deriving (Eq)
+
+-- | Help topics
+data HelpTopic =
+    HelpTopic { helpTopicName         :: Text
+              , helpTopicDescription  :: Text
+              , helpTopicScreen       :: HelpScreen
+              , helpTopicViewportName :: Name
+              }
+              deriving (Eq)
+
+-- | Mode type for the current contents of the post list overlay
+data PostListContents =
+    PostListFlagged
+    | PostListPinned ChannelId
+    | PostListSearch Text Bool -- for the query and search status
+    deriving (Eq)
+
+-- | The 'Mode' represents the current dominant UI activity
+data Mode =
+    Main
+    | ShowHelp HelpTopic Mode
+    | ChannelSelect
+    | UrlSelect
+    | LeaveChannelConfirm
+    | DeleteChannelConfirm
+    | MessageSelect
+    | MessageSelectDeleteConfirm
+    | PostListOverlay PostListContents
+    | UserListOverlay
+    | ReactionEmojiListOverlay
+    | ChannelListOverlay
+    | ThemeListOverlay
+    | ViewMessage
+    | ManageAttachments
+    | ManageAttachmentsBrowseFiles
+    | EditNotifyPrefs
+    | ChannelTopicWindow
+    deriving (Eq)
+
+-- | We're either connected or we're not.
+data ConnectionStatus = Connected | Disconnected deriving (Eq)
+
+-- | An entry in a tabbed window corresponding to a tab and its content.
+-- Parameterized over an abstract handle type ('a') for the tabs so we
+-- can give each a unique handle.
+data TabbedWindowEntry a =
+    TabbedWindowEntry { tweValue :: a
+                      -- ^ The handle for this tab.
+                      , tweRender :: a -> ChatState -> Widget Name
+                      -- ^ The rendering function to use when this tab
+                      -- is selected.
+                      , tweHandleEvent :: a -> Vty.Event -> MH ()
+                      -- ^ The event-handling function to use when this
+                      -- tab is selected.
+                      , tweTitle :: a -> Bool -> T.Text
+                      -- ^ Title function for this tab, with a boolean
+                      -- indicating whether this is the current tab.
+                      , tweShowHandler :: a -> MH ()
+                      -- ^ A handler to be invoked when this tab is
+                      -- shown.
+                      }
+
+-- | The definition of a tabbed window. Note that this does not track
+-- the *state* of the window; it merely provides a collection of tab
+-- window entries (see above). To track the state of a tabbed window,
+-- use a TabbedWindow.
+--
+-- Parameterized over an abstract handle type ('a') for the tabs so we
+-- can give each a unique handle.
+data TabbedWindowTemplate a =
+    TabbedWindowTemplate { twtEntries :: [TabbedWindowEntry a]
+                         -- ^ The entries in tabbed windows with this
+                         -- structure.
+                         , twtTitle :: a -> Widget Name
+                         -- ^ The title-rendering function for this kind
+                         -- of tabbed window.
+                         }
+
+-- | An instantiated tab window. This is based on a template and tracks
+-- the state of the tabbed window (current tab).
+--
+-- Parameterized over an abstract handle type ('a') for the tabs so we
+-- can give each a unique handle.
+data TabbedWindow a =
+    TabbedWindow { twValue :: a
+                 -- ^ The handle of the currently-selected tab.
+                 , twReturnMode :: Mode
+                 -- ^ The mode to return to when the tab is closed.
+                 , twTemplate :: TabbedWindowTemplate a
+                 -- ^ The template to use as a basis for rendering the
+                 -- window and handling user input.
+                 , twWindowWidth :: Int
+                 , twWindowHeight :: Int
+                 -- ^ Window dimensions
+                 }
+
+-- | Construct a new tabbed window from a template. This will raise an
+-- exception if the initially-selected tab does not exist in the window
+-- template, or if the window template has any duplicated tab handles.
+--
+-- Note that the caller is responsible for determining whether to call
+-- the initially-selected tab's on-show handler.
+tabbedWindow :: (Show a, Eq a)
+             => a
+             -- ^ The handle corresponding to the tab that should be
+             -- selected initially.
+             -> TabbedWindowTemplate a
+             -- ^ The template for the window to construct.
+             -> Mode
+             -- ^ When the window is closed, return to this application
+             -- mode.
+             -> (Int, Int)
+             -- ^ The window dimensions (width, height).
+             -> TabbedWindow a
+tabbedWindow initialVal t retMode (width, height) =
+    let handles = tweValue <$> twtEntries t
+    in if | null handles ->
+              error "BUG: tabbed window template must provide at least one entry"
+          | length handles /= length (nub handles) ->
+              error "BUG: tabbed window should have one entry per handle"
+          | not (initialVal `elem` handles) ->
+              error $ "BUG: tabbed window handle " <>
+                      show initialVal <> " not present in template"
+          | otherwise ->
+              TabbedWindow { twTemplate = t
+                           , twValue = initialVal
+                           , twReturnMode = retMode
+                           , twWindowWidth = width
+                           , twWindowHeight = height
+                           }
+
+-- | Get the currently-selected tab entry for a tabbed window. Raise
+-- an exception if the window's selected tab handle is not found in its
+-- template (which is a bug in the tabbed window infrastructure).
+getCurrentTabbedWindowEntry :: (Show a, Eq a)
+                            => TabbedWindow a
+                            -> TabbedWindowEntry a
+getCurrentTabbedWindowEntry w =
+    lookupTabbedWindowEntry (twValue w) w
+
+-- | Run the on-show handler for the window tab entry with the specified
+-- handle.
+runTabShowHandlerFor :: (Eq a, Show a) => a -> TabbedWindow a -> MH ()
+runTabShowHandlerFor handle w = do
+    let entry = lookupTabbedWindowEntry handle w
+    tweShowHandler entry handle
+
+-- | Look up a tabbed window entry by handle. Raises an exception if no
+-- such entry exists.
+lookupTabbedWindowEntry :: (Eq a, Show a)
+                        => a
+                        -> TabbedWindow a
+                        -> TabbedWindowEntry a
+lookupTabbedWindowEntry handle w =
+    let matchesVal e = tweValue e == handle
+    in case filter matchesVal (twtEntries $ twTemplate w) of
+        [e] -> e
+        _ -> error $ "BUG: tabbed window entry for " <> show (twValue w) <>
+                     " should have matched a single entry"
+
+-- | Switch a tabbed window's selected tab to its next tab, cycling back
+-- to the first tab if the last tab is the selected tab. This also
+-- invokes the on-show handler for the newly-selected tab.
+--
+-- Note that this does nothing if the window has only one tab.
+tabbedWindowNextTab :: (Show a, Eq a)
+                    => TabbedWindow a
+                    -> MH (TabbedWindow a)
+tabbedWindowNextTab w | length (twtEntries $ twTemplate w) == 1 = return w
+tabbedWindowNextTab w = do
+    let curIdx = case elemIndex (tweValue curEntry) allHandles of
+            Nothing ->
+                error $ "BUG: tabbedWindowNextTab: could not find " <>
+                        "current handle in handle list"
+            Just i -> i
+        nextIdx = if curIdx == length allHandles - 1
+                  then 0
+                  else curIdx + 1
+        newHandle = allHandles !! nextIdx
+        allHandles = tweValue <$> twtEntries (twTemplate w)
+        curEntry = getCurrentTabbedWindowEntry w
+        newWin = w { twValue = newHandle }
+
+    runTabShowHandlerFor newHandle newWin
+    return newWin
+
+-- | Switch a tabbed window's selected tab to its previous tab, cycling
+-- to the last tab if the first tab is the selected tab. This also
+-- invokes the on-show handler for the newly-selected tab.
+--
+-- Note that this does nothing if the window has only one tab.
+tabbedWindowPreviousTab :: (Show a, Eq a)
+                        => TabbedWindow a
+                        -> MH (TabbedWindow a)
+tabbedWindowPreviousTab w | length (twtEntries $ twTemplate w) == 1 = return w
+tabbedWindowPreviousTab w = do
+    let curIdx = case elemIndex (tweValue curEntry) allHandles of
+            Nothing ->
+                error $ "BUG: tabbedWindowPreviousTab: could not find " <>
+                        "current handle in handle list"
+            Just i -> i
+        nextIdx = if curIdx == 0
+                  then length allHandles - 1
+                  else curIdx - 1
+        newHandle = allHandles !! nextIdx
+        allHandles = tweValue <$> twtEntries (twTemplate w)
+        curEntry = getCurrentTabbedWindowEntry w
+        newWin = w { twValue = newHandle }
+
+    runTabShowHandlerFor newHandle newWin
+    return newWin
+
+data ChannelListOrientation =
+    ChannelListLeft
+    -- ^ Show the channel list to the left of the message area.
+    | ChannelListRight
+    -- ^ Show the channel list to the right of the message area.
+    deriving (Eq, Show)
+
+-- | This is the giant bundle of fields that represents the current
+-- state of our application at any given time.
+data ChatState =
+    ChatState { _csResources :: ChatResources
+              -- ^ Global application-wide resources that don't change
+              -- much.
+              , _csFocus :: Z.Zipper ChannelListGroup ChannelListEntry
+              -- ^ The channel sidebar zipper that tracks which channel
+              -- is selected.
+              , _csChannelListOrientation :: ChannelListOrientation
+              -- ^ The orientation of the channel list.
+              , _csMe :: User
+              -- ^ The authenticated user.
+              , _csMyTeam :: Team
+              -- ^ The active team of the authenticated user.
+              , _csChannels :: ClientChannels
+              -- ^ The channels that we are showing, including their
+              -- message lists.
+              , _csPostMap :: HashMap PostId Message
+              -- ^ The map of post IDs to messages. This allows us to
+              -- access messages by ID without having to linearly scan
+              -- channel message lists.
+              , _csUsers :: Users
+              -- ^ All of the users we know about.
+              , _timeZone :: TimeZoneSeries
+              -- ^ The client time zone.
+              , _csEditState :: ChatEditState
+              -- ^ The state of the input box used for composing and
+              -- editing messages and commands.
+              , _csMode :: Mode
+              -- ^ The current application mode. This is used to
+              -- dispatch to different rendering and event handling
+              -- routines.
+              , _csShowMessagePreview :: Bool
+              -- ^ Whether to show the message preview area.
+              , _csShowChannelList :: Bool
+              -- ^ Whether to show the channel list.
+              , _csShowExpandedChannelTopics :: Bool
+              -- ^ Whether to show expanded channel topics.
+              , _csChannelSelectState :: ChannelSelectState
+              -- ^ The state of the user's input and selection for
+              -- channel selection mode.
+              , _csRecentChannel :: Maybe ChannelId
+              -- ^ The most recently-selected channel, if any.
+              , _csReturnChannel :: Maybe ChannelId
+              -- ^ The channel to return to after visiting one or more
+              -- unread channels.
+              , _csUrlList :: List Name LinkChoice
+              -- ^ The URL list used to show URLs drawn from messages in
+              -- a channel.
+              , _csConnectionStatus :: ConnectionStatus
+              -- ^ Our view of the connection status.
+              , _csWorkerIsBusy :: Maybe (Maybe Int)
+              -- ^ Whether the async worker thread is busy, and its
+              -- queue length if so.
+              , _csMessageSelect :: MessageSelectState
+              -- ^ The state of message selection mode.
+              , _csThemeListOverlay :: ListOverlayState InternalTheme ()
+              -- ^ The state of the theme list overlay.
+              , _csPostListOverlay :: PostListOverlayState
+              -- ^ The state of the post list overlay.
+              , _csUserListOverlay :: ListOverlayState UserInfo UserSearchScope
+              -- ^ The state of the user list overlay.
+              , _csChannelListOverlay :: ListOverlayState Channel ChannelSearchScope
+              -- ^ The state of the user list overlay.
+              , _csReactionEmojiListOverlay :: ListOverlayState (Bool, T.Text) ()
+              -- ^ The state of the reaction emoji list overlay.
+              , _csClientConfig :: Maybe ClientConfig
+              -- ^ The Mattermost client configuration, as we understand it.
+              , _csPendingChannelChange :: Maybe PendingChannelChange
+              -- ^ A pending channel change that we need to apply once
+              -- the channel in question is available. We set this up
+              -- when we need to change to a channel in the sidebar, but
+              -- it isn't even there yet because we haven't loaded its
+              -- metadata.
+              , _csViewedMessage :: Maybe (Message, TabbedWindow ViewMessageWindowTab)
+              -- ^ Set when the ViewMessage mode is active. The message
+              -- being viewed. Note that this stores a message, not
+              -- a message ID. That's because not all messages have
+              -- message IDs (e.g. client messages) and we still
+              -- want to support viewing of those messages. It's the
+              -- responsibility of code that uses this message to always
+              -- consult the chat state for the latest *version* of any
+              -- message with an ID here, to be sure that the latest
+              -- version is used (e.g. if it gets edited, etc.).
+              , _csNotifyPrefs :: Maybe (Form ChannelNotifyProps MHEvent Name)
+              -- ^ A form for editing the notification preferences for
+              -- the current channel. This is set when entering
+              -- EditNotifyPrefs mode and updated when the user
+              -- changes the form state.
+              , _csChannelTopicDialog :: ChannelTopicDialogState
+              -- ^ The state for the interactive channel topic editor
+              -- window.
+              }
+
+-- | Handles for the View Message window's tabs.
+data ViewMessageWindowTab =
+    VMTabMessage
+    -- ^ The message tab.
+    | VMTabReactions
+    -- ^ The reactions tab.
+    deriving (Eq, Show)
+
+data PendingChannelChange =
+    ChangeByChannelId ChannelId (Maybe (MH ()))
+    | ChangeByUserId UserId
+
+-- | Startup state information that is constructed prior to building a
+-- ChatState.
+data StartupStateInfo =
+    StartupStateInfo { startupStateResources      :: ChatResources
+                     , startupStateChannelZipper  :: Z.Zipper ChannelListGroup ChannelListEntry
+                     , startupStateConnectedUser  :: User
+                     , startupStateTeam           :: Team
+                     , startupStateTimeZone       :: TimeZoneSeries
+                     , startupStateInitialHistory :: InputHistory
+                     , startupStateSpellChecker   :: Maybe (Aspell, IO ())
+                     }
+
+-- | The state of the channel topic editor window.
+data ChannelTopicDialogState =
+    ChannelTopicDialogState { _channelTopicDialogEditor :: Editor T.Text Name
+                            -- ^ The topic string editor state.
+                            , _channelTopicDialogFocus :: FocusRing Name
+                            -- ^ The window focus state (editor/buttons)
+                            }
+
+newState :: StartupStateInfo -> IO ChatState
+newState (StartupStateInfo {..}) = do
+    editState <- emptyEditState startupStateInitialHistory startupStateSpellChecker
+    let config = _crConfiguration startupStateResources
+    return ChatState { _csResources                   = startupStateResources
+                     , _csFocus                       = startupStateChannelZipper
+                     , _csChannelListOrientation      = configChannelListOrientation config
+                     , _csMe                          = startupStateConnectedUser
+                     , _csMyTeam                      = startupStateTeam
+                     , _csChannels                    = noChannels
+                     , _csPostMap                     = HM.empty
+                     , _csUsers                       = noUsers
+                     , _timeZone                      = startupStateTimeZone
+                     , _csEditState                   = editState
+                     , _csMode                        = Main
+                     , _csShowMessagePreview          = configShowMessagePreview config
+                     , _csShowChannelList             = configShowChannelList config
+                     , _csShowExpandedChannelTopics   = configShowExpandedChannelTopics config
+                     , _csChannelSelectState          = emptyChannelSelectState
+                     , _csRecentChannel               = Nothing
+                     , _csReturnChannel               = Nothing
+                     , _csUrlList                     = list UrlList mempty 2
+                     , _csConnectionStatus            = Connected
+                     , _csWorkerIsBusy                = Nothing
+                     , _csMessageSelect               = MessageSelectState Nothing
+                     , _csThemeListOverlay            = nullThemeListOverlayState
+                     , _csPostListOverlay             = PostListOverlayState emptyDirSeq Nothing
+                     , _csUserListOverlay             = nullUserListOverlayState
+                     , _csChannelListOverlay          = nullChannelListOverlayState
+                     , _csReactionEmojiListOverlay    = nullEmojiListOverlayState
+                     , _csClientConfig                = Nothing
+                     , _csPendingChannelChange        = Nothing
+                     , _csViewedMessage               = Nothing
+                     , _csNotifyPrefs                 = Nothing
+                     , _csChannelTopicDialog          = newChannelTopicDialog ""
+                     }
+
+-- | Make a new channel topic editor window state.
+newChannelTopicDialog :: T.Text -> ChannelTopicDialogState
+newChannelTopicDialog t =
+    ChannelTopicDialogState { _channelTopicDialogEditor = editor ChannelTopicEditor Nothing t
+                            , _channelTopicDialogFocus = focusRing [ ChannelTopicEditor
+                                                                   , ChannelTopicSaveButton
+                                                                   , ChannelTopicCancelButton
+                                                                   ]
+                            }
+
+nullChannelListOverlayState :: ListOverlayState Channel ChannelSearchScope
+nullChannelListOverlayState =
+    let newList rs = list JoinChannelList rs 2
+    in ListOverlayState { _listOverlaySearchResults  = newList mempty
+                        , _listOverlaySearchInput    = editor JoinChannelListSearchInput (Just 1) ""
+                        , _listOverlaySearchScope    = AllChannels
+                        , _listOverlaySearching      = False
+                        , _listOverlayEnterHandler   = const $ return False
+                        , _listOverlayNewList        = newList
+                        , _listOverlayFetchResults   = const $ const $ const $ return mempty
+                        , _listOverlayRecordCount    = Nothing
+                        , _listOverlayReturnMode     = Main
+                        }
+
+nullThemeListOverlayState :: ListOverlayState InternalTheme ()
+nullThemeListOverlayState =
+    let newList rs = list ThemeListSearchResults rs 3
+    in ListOverlayState { _listOverlaySearchResults  = newList mempty
+                        , _listOverlaySearchInput    = editor ThemeListSearchInput (Just 1) ""
+                        , _listOverlaySearchScope    = ()
+                        , _listOverlaySearching      = False
+                        , _listOverlayEnterHandler   = const $ return False
+                        , _listOverlayNewList        = newList
+                        , _listOverlayFetchResults   = const $ const $ const $ return mempty
+                        , _listOverlayRecordCount    = Nothing
+                        , _listOverlayReturnMode     = Main
+                        }
+
+nullUserListOverlayState :: ListOverlayState UserInfo UserSearchScope
+nullUserListOverlayState =
+    let newList rs = list UserListSearchResults rs 1
+    in ListOverlayState { _listOverlaySearchResults  = newList mempty
+                        , _listOverlaySearchInput    = editor UserListSearchInput (Just 1) ""
+                        , _listOverlaySearchScope    = AllUsers Nothing
+                        , _listOverlaySearching      = False
+                        , _listOverlayEnterHandler   = const $ return False
+                        , _listOverlayNewList        = newList
+                        , _listOverlayFetchResults   = const $ const $ const $ return mempty
+                        , _listOverlayRecordCount    = Nothing
+                        , _listOverlayReturnMode     = Main
+                        }
+
+nullEmojiListOverlayState :: ListOverlayState (Bool, T.Text) ()
+nullEmojiListOverlayState =
+    let newList rs = list ReactionEmojiList rs 1
+    in ListOverlayState { _listOverlaySearchResults  = newList mempty
+                        , _listOverlaySearchInput    = editor ReactionEmojiListInput (Just 1) ""
+                        , _listOverlaySearchScope    = ()
+                        , _listOverlaySearching      = False
+                        , _listOverlayEnterHandler   = const $ return False
+                        , _listOverlayNewList        = newList
+                        , _listOverlayFetchResults   = const $ const $ const $ return mempty
+                        , _listOverlayRecordCount    = Nothing
+                        , _listOverlayReturnMode     = MessageSelect
+                        }
+
+getServerBaseUrl :: MH TeamBaseURL
+getServerBaseUrl = do
+    st <- use id
+    return $ serverBaseUrl st
+
+serverBaseUrl :: ChatState -> TeamBaseURL
+serverBaseUrl st =
+    let baseUrl = connectionDataURL $ _crConn $ _csResources st
+        tName = teamName $ _csMyTeam st
+    in TeamBaseURL (TeamURLName $ sanitizeUserText tName) baseUrl
+
+-- | The state of channel selection mode.
+data ChannelSelectState =
+    ChannelSelectState { _channelSelectInput :: Editor Text Name
+                       , _channelSelectMatches :: Z.Zipper ChannelListGroup ChannelSelectMatch
+                       }
+
+emptyChannelSelectState :: ChannelSelectState
+emptyChannelSelectState =
+    ChannelSelectState { _channelSelectInput = editor ChannelSelectInput (Just 1) ""
+                       , _channelSelectMatches = Z.fromList []
+                       }
+
+-- | The state of message selection mode.
+data MessageSelectState =
+    MessageSelectState { selectMessageId :: Maybe MessageId
+                       }
+
+-- | The state of the post list overlay.
+data PostListOverlayState =
+    PostListOverlayState { _postListPosts    :: Messages
+                         , _postListSelected :: Maybe PostId
+                         }
+
+data InternalTheme =
+    InternalTheme { internalThemeName :: Text
+                  , internalTheme :: Theme
+                  , internalThemeDesc :: Text
+                  }
+
+-- | The state of the search result list overlay. Type 'a' is the type
+-- of data in the list. Type 'b' is the search scope type.
+data ListOverlayState a b =
+    ListOverlayState { _listOverlaySearchResults :: List Name a
+                     -- ^ The list of search results currently shown in
+                     -- the overlay.
+                     , _listOverlaySearchInput :: Editor Text Name
+                     -- ^ The editor for the overlay's search input.
+                     , _listOverlaySearchScope :: b
+                     -- ^ The overlay's current search scope.
+                     , _listOverlaySearching :: Bool
+                     -- ^ Whether a search is in progress (i.e. whether
+                     -- we are currently awaiting a response from a
+                     -- search query to the server).
+                     , _listOverlayEnterHandler :: a -> MH Bool
+                     -- ^ The handler to invoke on the selected element
+                     -- when the user presses Enter.
+                     , _listOverlayNewList :: Vec.Vector a -> List Name a
+                     -- ^ The function to build a new brick List from a
+                     -- vector of search results.
+                     , _listOverlayFetchResults :: b -> Session -> Text -> IO (Vec.Vector a)
+                     -- ^ The function to call to issue a search query
+                     -- to the server.
+                     , _listOverlayRecordCount :: Maybe Int
+                     -- ^ The total number of available records, if known.
+                     , _listOverlayReturnMode :: Mode
+                     -- ^ The mode to return to when the window closes.
+                     }
+
+-- | The scope for searching for users in a user list overlay.
+data UserSearchScope =
+    ChannelMembers ChannelId TeamId
+    | ChannelNonMembers ChannelId TeamId
+    | AllUsers (Maybe TeamId)
+
+-- | The scope for searching for channels to join.
+data ChannelSearchScope =
+    AllChannels
+
+-- | Actions that can be sent on the websocket to the server.
+data WebsocketAction =
+    UserTyping UTCTime ChannelId (Maybe PostId) -- ^ user typing in the input box
+    deriving (Read, Show, Eq, Ord)
+
+-- * MH Monad
+
+-- | Logging context information, in the event that metadata should
+-- accompany a log message.
+data LogContext =
+    LogContext { logContextChannelId :: Maybe ChannelId
+               }
+               deriving (Eq, Show)
+
+-- | A user fetching strategy.
+data UserFetch =
+    UserFetchById UserId
+    -- ^ Fetch the user with the specified ID.
+    | UserFetchByUsername Text
+    -- ^ Fetch the user with the specified username.
+    | UserFetchByNickname Text
+    -- ^ Fetch the user with the specified nickname.
+    deriving (Eq, Show)
+
+data MHState =
+    MHState { mhCurrentState :: ChatState
+            , mhNextAction :: ChatState -> EventM Name (Next ChatState)
+            , mhUsersToFetch :: [UserFetch]
+            , mhPendingStatusList :: Maybe [UserId]
+            }
+
+-- | A value of type 'MH' @a@ represents a computation that can
+-- manipulate the application state and also request that the
+-- application quit
+newtype MH a =
+    MH { fromMH :: R.ReaderT (Maybe LogContext) (St.StateT MHState (EventM Name)) a }
+
+-- | Use a modified logging context for the duration of the specified MH
+-- action.
+withLogContext :: (Maybe LogContext -> Maybe LogContext) -> MH a -> MH a
+withLogContext modifyContext act =
+    MH $ R.withReaderT modifyContext (fromMH act)
+
+withLogContextChannelId :: ChannelId -> MH a -> MH a
+withLogContextChannelId cId act =
+    let f Nothing = Just $ LogContext (Just cId)
+        f (Just c) = Just $ c { logContextChannelId = Just cId }
+    in withLogContext f act
+
+-- | Get the current logging context.
+getLogContext :: MH (Maybe LogContext)
+getLogContext = MH R.ask
+
+-- | Log a message.
+mhLog :: LogCategory -> Text -> MH ()
+mhLog cat msg = do
+    logger <- mhGetIOLogger
+    liftIO $ logger cat msg
+
+-- | Get a logger suitable for use in IO. The logger always logs using
+-- the MH monad log context at the time of the call to mhGetIOLogger.
+mhGetIOLogger :: MH (LogCategory -> Text -> IO ())
+mhGetIOLogger = do
+    ctx <- getLogContext
+    mgr <- use (to (_crLogManager . _csResources))
+    return $ ioLogWithManager mgr ctx
+
+ioLogWithManager :: LogManager -> Maybe LogContext -> LogCategory -> Text -> IO ()
+ioLogWithManager mgr ctx cat msg = do
+    now <- getCurrentTime
+    let lm = LogMessage { logMessageText = msg
+                        , logMessageContext = ctx
+                        , logMessageCategory = cat
+                        , logMessageTimestamp = now
+                        }
+    sendLogMessage mgr lm
+
+-- | Run an 'MM' computation, choosing whether to continue or halt based
+-- on the resulting
+runMHEvent :: ChatState -> MH () -> EventM Name (Next ChatState)
+runMHEvent st (MH mote) = do
+  let mhSt = MHState { mhCurrentState = st
+                     , mhNextAction = Brick.continue
+                     , mhUsersToFetch = []
+                     , mhPendingStatusList = Nothing
+                     }
+  ((), st') <- St.runStateT (R.runReaderT mote Nothing) mhSt
+  (mhNextAction st') (mhCurrentState st')
+
+scheduleUserFetches :: [UserFetch] -> MH ()
+scheduleUserFetches fs = MH $ do
+    St.modify $ \s -> s { mhUsersToFetch = fs <> mhUsersToFetch s }
+
+scheduleUserStatusFetches :: [UserId] -> MH ()
+scheduleUserStatusFetches is = MH $ do
+    St.modify $ \s -> s { mhPendingStatusList = Just is }
+
+getScheduledUserFetches :: MH [UserFetch]
+getScheduledUserFetches = MH $ St.gets mhUsersToFetch
+
+getScheduledUserStatusFetches :: MH (Maybe [UserId])
+getScheduledUserStatusFetches = MH $ St.gets mhPendingStatusList
+
+-- | lift a computation in 'EventM' into 'MH'
+mh :: EventM Name a -> MH a
+mh = MH . R.lift . St.lift
+
+generateUUID :: MH UUID
+generateUUID = liftIO generateUUID_IO
+
+generateUUID_IO :: IO UUID
+generateUUID_IO = randomIO
+
+mhHandleEventLensed :: Lens' ChatState b -> (e -> b -> EventM Name b) -> e -> MH ()
+mhHandleEventLensed ln f event = MH $ do
+    s <- St.get
+    let st = mhCurrentState s
+    n <- R.lift $ St.lift $ f event (st ^. ln)
+    St.put (s { mhCurrentState = st & ln .~ n })
+
+mhSuspendAndResume :: (ChatState -> IO ChatState) -> MH ()
+mhSuspendAndResume mote = MH $ do
+    s <- St.get
+    St.put $ s { mhNextAction = \ _ -> Brick.suspendAndResume (mote $ mhCurrentState s) }
+
+-- | This will request that after this computation finishes the
+-- application should exit
+requestQuit :: MH ()
+requestQuit = MH $ do
+    s <- St.get
+    St.put $ s { mhNextAction = Brick.halt }
+
+instance Functor MH where
+    fmap f (MH x) = MH (fmap f x)
+
+instance Applicative MH where
+    pure x = MH (pure x)
+    MH f <*> MH x = MH (f <*> x)
+
+instance MHF.MonadFail MH where
+    fail = MH . MHF.fail
+
+instance Monad MH where
+    return x = MH (return x)
+    MH x >>= f = MH (x >>= \ x' -> fromMH (f x'))
+
+-- We want to pretend that the state is only the ChatState, rather
+-- than the ChatState and the Brick continuation
+instance St.MonadState ChatState MH where
+    get = mhCurrentState `fmap` MH St.get
+    put st = MH $ do
+        s <- St.get
+        St.put $ s { mhCurrentState = st }
+
+instance St.MonadIO MH where
+    liftIO = MH . St.liftIO
+
+-- | This represents events that we handle in the main application loop.
+data MHEvent =
+    WSEvent WebsocketEvent
+    -- ^ For events that arise from the websocket
+    | WSActionResponse WebsocketActionResponse
+    -- ^ For responses to websocket actions
+    | RespEvent (MH ())
+    -- ^ For the result values of async IO operations
+    | RefreshWebsocketEvent
+    -- ^ Tell our main loop to refresh the websocket connection
+    | WebsocketParseError String
+    -- ^ We failed to parse an incoming websocket event
+    | WebsocketDisconnect
+    -- ^ The websocket connection went down.
+    | WebsocketConnect
+    -- ^ The websocket connection came up.
+    | BGIdle
+    -- ^ background worker is idle
+    | BGBusy (Maybe Int)
+    -- ^ background worker is busy (with n requests)
+    | RateLimitExceeded Int
+    -- ^ A request initially failed due to a rate limit but will be
+    -- retried if possible. The argument is the number of seconds in
+    -- which the retry will be attempted.
+    | RateLimitSettingsMissing
+    -- ^ A request denied by a rate limit could not be retried because
+    -- the response contained no rate limit metadata
+    | RequestDropped
+    -- ^ A request was reattempted due to a rate limit and was rate
+    -- limited again
+    | IEvent InternalEvent
+    -- ^ MH-internal events
+
+-- | Internal application events.
+data InternalEvent =
+    DisplayError MHError
+    -- ^ Some kind of application error occurred
+    | LoggingStarted FilePath
+    | LoggingStopped FilePath
+    | LogStartFailed FilePath String
+    | LogDestination (Maybe FilePath)
+    | LogSnapshotSucceeded FilePath
+    | LogSnapshotFailed FilePath String
+    -- ^ Logging events from the logging thread
+
+-- | Application errors.
+data MHError =
+    GenericError T.Text
+    -- ^ A generic error message constructor
+    | NoSuchChannel T.Text
+    -- ^ The specified channel does not exist
+    | NoSuchUser T.Text
+    -- ^ The specified user does not exist
+    | AmbiguousName T.Text
+    -- ^ The specified name matches both a user and a channel
+    | ServerError MattermostError
+    -- ^ A Mattermost server error occurred
+    | ClipboardError T.Text
+    -- ^ A problem occurred trying to deal with yanking or the system
+    -- clipboard
+    | ConfigOptionMissing T.Text
+    -- ^ A missing config option is required to perform an operation
+    | ProgramExecutionFailed T.Text T.Text
+    -- ^ Args: program name, path to log file. A problem occurred when
+    -- running the program.
+    | NoSuchScript T.Text
+    -- ^ The specified script was not found
+    | NoSuchHelpTopic T.Text
+    -- ^ The specified help topic was not found
+    | AsyncErrEvent SomeException
+    -- ^ For errors that arise in the course of async IO operations
+    deriving (Show)
+
+-- ** Application State Lenses
+
+makeLenses ''ChatResources
+makeLenses ''ChatState
+makeLenses ''ChatEditState
+makeLenses ''AutocompleteState
+makeLenses ''PostListOverlayState
+makeLenses ''ListOverlayState
+makeLenses ''ChannelSelectState
+makeLenses ''UserPreferences
+makeLenses ''ConnectionInfo
+makeLenses ''ChannelTopicDialogState
+
+getSession :: MH Session
+getSession = use (csResources.crSession)
+
+getResourceSession :: ChatResources -> Session
+getResourceSession = _crSession
+
+whenMode :: Mode -> MH () -> MH ()
+whenMode m act = do
+    curMode <- use csMode
+    when (curMode == m) act
+
+setMode :: Mode -> MH ()
+setMode m = do
+    csMode .= m
+    mh invalidateCache
+
+setMode' :: Mode -> ChatState -> ChatState
+setMode' m = csMode .~ m
+
+appMode :: ChatState -> Mode
+appMode = _csMode
+
+resetSpellCheckTimer :: ChatEditState -> IO ()
+resetSpellCheckTimer s =
+    case s^.cedSpellChecker of
+        Nothing -> return ()
+        Just (_, reset) -> reset
+
+-- ** Utility Lenses
+csCurrentChannelId :: SimpleGetter ChatState ChannelId
+csCurrentChannelId = csFocus.to Z.unsafeFocus.to channelListEntryChannelId
+
+channelListEntryChannelId :: ChannelListEntry -> ChannelId
+channelListEntryChannelId (CLChannel cId) = cId
+channelListEntryChannelId (CLUserDM cId _) = cId
+channelListEntryChannelId (CLGroupDM cId) = cId
+
+channelListEntryUserId :: ChannelListEntry -> Maybe UserId
+channelListEntryUserId (CLUserDM _ uId) = Just uId
+channelListEntryUserId _ = Nothing
+
+userIdsFromZipper :: Z.Zipper ChannelListGroup ChannelListEntry -> [UserId]
+userIdsFromZipper z =
+    concat $ (catMaybes . fmap channelListEntryUserId . snd) <$> Z.toList z
+
+entryIsDMEntry :: ChannelListEntry -> Bool
+entryIsDMEntry (CLUserDM {}) = True
+entryIsDMEntry (CLGroupDM {}) = True
+entryIsDMEntry (CLChannel {}) = False
+
+csCurrentChannel :: Lens' ChatState ClientChannel
+csCurrentChannel =
+    lens (\ st -> findChannelById (st^.csCurrentChannelId) (st^.csChannels) ^?! _Just)
+         (\ st n -> st & csChannels %~ addChannel (st^.csCurrentChannelId) n)
+
+csChannel :: ChannelId -> Traversal' ChatState ClientChannel
+csChannel cId =
+    csChannels . channelByIdL cId
+
+withChannel :: ChannelId -> (ClientChannel -> MH ()) -> MH ()
+withChannel cId = withChannelOrDefault cId ()
+
+withChannelOrDefault :: ChannelId -> a -> (ClientChannel -> MH a) -> MH a
+withChannelOrDefault cId deflt mote = do
+    chan <- preuse (csChannel(cId))
+    case chan of
+        Nothing -> return deflt
+        Just c  -> mote c
+
+-- ** 'ChatState' Helper Functions
+
+raiseInternalEvent :: InternalEvent -> MH ()
+raiseInternalEvent ev = do
+    queue <- use (csResources.crEventQueue)
+    writeBChan queue (IEvent ev)
+
+writeBChan :: (MonadIO m) => BCH.BChan MHEvent -> MHEvent -> m ()
+writeBChan chan e = do
+    written <- liftIO $ BCH.writeBChanNonBlocking chan e
+    when (not written) $
+        error $ "mhSendEvent: BChan full, please report this as a bug!"
+
+-- | Log and raise an error.
+mhError :: MHError -> MH ()
+mhError err = do
+    mhLog LogError $ T.pack $ show err
+    raiseInternalEvent (DisplayError err)
+
+isMine :: ChatState -> Message -> Bool
+isMine st msg =
+    case msg^.mUser of
+        UserI _ uid -> uid == myUserId st
+        _ -> False
+
+getMessageForPostId :: ChatState -> PostId -> Maybe Message
+getMessageForPostId st pId = st^.csPostMap.at(pId)
+
+getParentMessage :: ChatState -> Message -> Maybe Message
+getParentMessage st msg
+    | InReplyTo pId <- msg^.mInReplyToMsg
+      = st^.csPostMap.at(pId)
+    | otherwise = Nothing
+
+getReplyRootMessage :: Message -> MH Message
+getReplyRootMessage msg = do
+    case postRootId =<< (msg^.mOriginalPost) of
+        Nothing -> return msg
+        Just rootId -> do
+            st <- use id
+            case getMessageForPostId st rootId of
+                -- NOTE: this case should never happen. This is the
+                -- case where a message has a root post ID but we
+                -- don't have a copy of the root post in storage. This
+                -- shouldn't happen because whenever we add a message
+                -- to a channel, we always fetch the parent post and
+                -- store it if it is in a thread. That should mean that
+                -- whenever we reply to a post, if that post is itself
+                -- a reply, we should have its root post in storage
+                -- and this case should never match. Even though it
+                -- shouldn't happen, rather than raising a BUG exception
+                -- here we'll just fall back to the input message.
+                Nothing -> return msg
+                Just m -> return m
+
+setUserStatus :: UserId -> Text -> MH ()
+setUserStatus uId t = do
+    csUsers %= modifyUserById uId (uiStatus .~ statusFromText t)
+    mh $ invalidateCacheEntry ChannelSidebar
+
+usernameForUserId :: UserId -> ChatState -> Maybe Text
+usernameForUserId uId st = _uiName <$> findUserById uId (st^.csUsers)
+
+displayNameForUserId :: UserId -> ChatState -> Maybe Text
+displayNameForUserId uId st = do
+    u <- findUserById uId (st^.csUsers)
+    return $ displayNameForUser u (st^.csClientConfig) (st^.csResources.crUserPreferences)
+
+-- | Note: this only searches users we have already loaded. Be
+-- aware that if you think you need a user we haven't fetched, use
+-- withFetchedUser!
+userIdForUsername :: Text -> ChatState -> Maybe UserId
+userIdForUsername name st =
+    fst <$> (findUserByUsername name $ st^.csUsers)
+
+channelIdByChannelName :: Text -> ChatState -> Maybe ChannelId
+channelIdByChannelName name st =
+    let matches (_, cc) = cc^.ccInfo.cdName == (trimChannelSigil name)
+    in listToMaybe $ fst <$> filteredChannels matches (st^.csChannels)
+
+channelIdByUsername :: Text -> ChatState -> Maybe ChannelId
+channelIdByUsername name st = do
+    uId <- userIdForUsername name st
+    getDmChannelFor uId (st^.csChannels)
+
+useNickname :: ChatState -> Bool
+useNickname st =
+    useNickname' (st^.csClientConfig) (st^.csResources.crUserPreferences)
+
+channelByName :: Text -> ChatState -> Maybe ClientChannel
+channelByName n st = do
+    cId <- channelIdByChannelName n st
+    findChannelById cId (st^.csChannels)
+
+trimChannelSigil :: Text -> Text
+trimChannelSigil n
+    | normalChannelSigil `T.isPrefixOf` n = T.tail n
+    | otherwise = n
+
+addNewUser :: UserInfo -> MH ()
+addNewUser u = do
+    csUsers %= addUser u
+    -- Invalidate the cache because channel message rendering may need
+    -- to get updated if this user authored posts in any channels.
+    mh invalidateCache
+
+data SidebarUpdate =
+    SidebarUpdateImmediate
+    | SidebarUpdateDeferred
+    deriving (Eq, Show)
+
+
+resetAutocomplete :: MH ()
+resetAutocomplete = do
+    csEditState.cedAutocomplete .= Nothing
+    csEditState.cedAutocompletePending .= Nothing
+
+
+-- * Slash Commands
+
+-- | The 'CmdArgs' type represents the arguments to a slash-command; the
+-- type parameter represents the argument structure.
+data CmdArgs :: K.Type -> K.Type where
+    NoArg    :: CmdArgs ()
+    LineArg  :: Text -> CmdArgs Text
+    UserArg  :: CmdArgs rest -> CmdArgs (Text, rest)
+    ChannelArg :: CmdArgs rest -> CmdArgs (Text, rest)
+    TokenArg :: Text -> CmdArgs rest -> CmdArgs (Text, rest)
+
+-- | A 'CmdExec' value represents the implementation of a command when
+-- provided with its arguments
+type CmdExec a = a -> MH ()
+
+-- | A 'Cmd' packages up a 'CmdArgs' specifier and the 'CmdExec'
+-- implementation with a name and a description.
+data Cmd =
+    forall a. Cmd { cmdName    :: Text
+                  , cmdDescr   :: Text
+                  , cmdArgSpec :: CmdArgs a
+                  , cmdAction  :: CmdExec a
+                  }
+
+-- | Helper function to extract the name out of a 'Cmd' value
+commandName :: Cmd -> Text
+commandName (Cmd name _ _ _ ) = name
+
+-- *  Channel Updates and Notifications
+
+userList :: ChatState -> [UserInfo]
+userList st = filter showUser $ allUsers (st^.csUsers)
+    where showUser u = not (isSelf u) && (u^.uiInTeam)
+          isSelf u = (myUserId st) == (u^.uiId)
+
+allUserIds :: ChatState -> [UserId]
+allUserIds st = getAllUserIds $ st^.csUsers
+
+-- BEWARE: you probably don't want this, but instead
+-- State.Users.withFetchedUser, since this only looks up users in the
+-- collection we have already loaded rather than all valid users on the
+-- server.
+userById :: UserId -> ChatState -> Maybe UserInfo
+userById uId st = findUserById uId (st^.csUsers)
+
+myUserId :: ChatState -> UserId
+myUserId st = myUser st ^. userIdL
+
+myTeamId :: ChatState -> TeamId
+myTeamId st = st ^. csMyTeam . teamIdL
+
+myUser :: ChatState -> User
+myUser st = st^.csMe
+
+myUsername :: ChatState -> Text
+myUsername st = userUsername $ st^.csMe
+
+-- BEWARE: you probably don't want this, but instead
+-- State.Users.withFetchedUser, since this only looks up users in the
+-- collection we have already loaded rather than all valid users on the
+-- server.
+userByUsername :: Text -> ChatState -> Maybe UserInfo
+userByUsername name st = do
+    snd <$> (findUserByUsername name $ st^.csUsers)
+
+-- BEWARE: you probably don't want this, but instead
+-- State.Users.withFetchedUser, since this only looks up users in the
+-- collection we have already loaded rather than all valid users on the
+-- server.
+userByNickname :: Text -> ChatState -> Maybe UserInfo
+userByNickname name st =
+    snd <$> (findUserByNickname name $ st^.csUsers)
+
+getUsers :: MH Users
+getUsers = use csUsers
+
+-- * HighlightSet
+
+type UserSet = Set Text
+type ChannelSet = Set Text
+
+-- | The set of usernames, channel names, and language names used for
+-- highlighting when rendering messages.
+data HighlightSet =
+    HighlightSet { hUserSet    :: Set Text
+                 , hChannelSet :: Set Text
+                 , hSyntaxMap  :: SyntaxMap
+                 }
+
+getHighlightSet :: ChatState -> HighlightSet
+getHighlightSet st =
+    HighlightSet { hUserSet = addSpecialUserMentions $ getUsernameSet $ st^.csUsers
+                 , hChannelSet = getChannelNameSet $ st^.csChannels
+                 , hSyntaxMap = st^.csResources.crSyntaxMap
+                 }
+
+attrNameToConfig :: Brick.AttrName -> Text
+attrNameToConfig = T.pack . intercalate "." . Brick.attrNameComponents
+
+-- From: https://docs.mattermost.com/help/messaging/mentioning-teammates.html
+specialUserMentions :: [T.Text]
+specialUserMentions = ["all", "channel", "here"]
+
+addSpecialUserMentions :: Set Text -> Set Text
+addSpecialUserMentions s = foldr Set.insert s specialUserMentions
+
+getNewMessageCutoff :: ChannelId -> ChatState -> Maybe NewMessageIndicator
+getNewMessageCutoff cId st = do
+    cc <- st^?csChannel(cId)
+    return $ cc^.ccInfo.cdNewMessageIndicator
+
+getEditedMessageCutoff :: ChannelId -> ChatState -> Maybe ServerTime
+getEditedMessageCutoff cId st = do
+    cc <- st^?csChannel(cId)
+    cc^.ccInfo.cdEditedMessageThreshold
+
+clearChannelUnreadStatus :: ChannelId -> MH ()
+clearChannelUnreadStatus cId = do
+    mh $ invalidateCacheEntry (ChannelMessages cId)
+    csChannel(cId) %= (clearNewMessageIndicator .
+                       clearEditedThreshold)
diff --git a/src/Matterhorn/Types/Channels.hs b/src/Matterhorn/Types/Channels.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Types/Channels.hs
@@ -0,0 +1,420 @@
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE DeriveFunctor #-}
+{-# LANGUAGE DeriveFoldable #-}
+{-# LANGUAGE DeriveTraversable #-}
+{-# LANGUAGE RankNTypes #-}
+
+module Matterhorn.Types.Channels
+  ( ClientChannel(..)
+  , ChannelContents(..)
+  , ChannelInfo(..)
+  , ClientChannels -- constructor remains internal
+  , NewMessageIndicator(..)
+  , EphemeralEditState(..)
+  , EditMode(..)
+  , eesMultiline, eesInputHistoryPosition, eesLastInput
+  , defaultEphemeralEditState
+  -- * Lenses created for accessing ClientChannel fields
+  , ccContents, ccInfo, ccEditState
+  -- * Lenses created for accessing ChannelInfo fields
+  , cdViewed, cdNewMessageIndicator, cdEditedMessageThreshold, cdUpdated
+  , cdName, cdDisplayName, cdHeader, cdPurpose, cdType
+  , cdMentionCount, cdTypingUsers, cdDMUserId, cdChannelId
+  , cdSidebarShowOverride, cdNotifyProps
+  -- * Lenses created for accessing ChannelContents fields
+  , cdMessages, cdFetchPending
+  -- * Creating ClientChannel objects
+  , makeClientChannel
+  -- * Managing ClientChannel collections
+  , noChannels, addChannel, removeChannel, findChannelById, modifyChannelById
+  , channelByIdL, maybeChannelByIdL
+  , filteredChannelIds
+  , filteredChannels
+  -- * Creating ChannelInfo objects
+  , channelInfoFromChannelWithData
+  -- * Channel State management
+  , clearNewMessageIndicator
+  , clearEditedThreshold
+  , adjustUpdated
+  , adjustEditedThreshold
+  , updateNewMessageIndicator
+  , addChannelTypingUser
+  -- * Notification settings
+  , notifyPreference
+  , isMuted
+  , channelNotifyPropsMarkUnreadL
+  , channelNotifyPropsIgnoreChannelMentionsL
+  , channelNotifyPropsDesktopL
+  , channelNotifyPropsPushL
+  -- * Miscellaneous channel-related operations
+  , canLeaveChannel
+  , preferredChannelName
+  , isTownSquare
+  , channelDeleted
+  , getDmChannelFor
+  , allDmChannelMappings
+  , getChannelNameSet
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.HashMap.Strict as HM
+import qualified Data.Set as S
+import qualified Data.Text as T
+import           Lens.Micro.Platform ( (%~), (.~), Traversal', Lens'
+                                     , makeLenses, ix, at
+                                     , to, non )
+
+import           Network.Mattermost.Lenses hiding ( Lens' )
+import           Network.Mattermost.Types ( Channel(..), UserId, ChannelId
+                                          , ChannelMember(..)
+                                          , Type(..)
+                                          , Post
+                                          , User(userNotifyProps)
+                                          , ChannelNotifyProps
+                                          , NotifyOption(..)
+                                          , WithDefault(..)
+                                          , ServerTime
+                                          , emptyChannelNotifyProps
+                                          )
+
+import           Matterhorn.Types.Messages ( Messages, noMessages, addMessage
+                                           , clientMessageToMessage, Message, MessageType )
+import           Matterhorn.Types.Posts ( ClientMessageType(UnknownGapBefore)
+                                        , newClientMessage )
+import           Matterhorn.Types.Users ( TypingUsers, noTypingUsers, addTypingUser )
+import           Matterhorn.Types.Common
+
+
+-- * Channel representations
+
+-- | A 'ClientChannel' contains both the message
+--   listing and the metadata about a channel
+data ClientChannel = ClientChannel
+  { _ccContents :: ChannelContents
+    -- ^ A list of 'Message's in the channel
+  , _ccInfo :: ChannelInfo
+    -- ^ The 'ChannelInfo' for the channel
+  , _ccEditState :: EphemeralEditState
+    -- ^ Editor state that we swap in and out as the current channel is
+    -- changed.
+  }
+
+-- | The input state associated with the message editor.
+data EditMode =
+    NewPost
+    -- ^ The input is for a new post.
+    | Editing Post MessageType
+    -- ^ The input is ultimately to replace the body of an existing post
+    -- of the specified type.
+    | Replying Message Post
+    -- ^ The input is to be used as a new post in reply to the specified
+    -- post.
+    deriving (Show)
+
+data EphemeralEditState =
+    EphemeralEditState { _eesMultiline :: Bool
+                       -- ^ Whether the editor is in multiline mode
+                       , _eesInputHistoryPosition :: Maybe Int
+                       -- ^ The input history position, if any
+                       , _eesLastInput :: (T.Text, EditMode)
+                       -- ^ The input entered into the text editor last
+                       -- time the user was focused on the channel
+                       -- associated with this state.
+                       }
+
+-- Get a channel's name, depending on its type
+preferredChannelName :: Channel -> Text
+preferredChannelName ch
+    | channelType ch == Group = sanitizeUserText $ channelDisplayName ch
+    | otherwise               = sanitizeUserText $ channelName ch
+
+data NewMessageIndicator =
+    Hide
+    | NewPostsAfterServerTime ServerTime
+    | NewPostsStartingAt ServerTime
+    deriving (Eq, Show)
+
+initialChannelInfo :: UserId -> Channel -> ChannelInfo
+initialChannelInfo myId chan =
+    let updated  = chan ^. channelLastPostAtL
+    in ChannelInfo { _cdChannelId              = chan^.channelIdL
+                   , _cdViewed                 = Nothing
+                   , _cdNewMessageIndicator    = Hide
+                   , _cdEditedMessageThreshold = Nothing
+                   , _cdMentionCount           = 0
+                   , _cdUpdated                = updated
+                   , _cdName                   = preferredChannelName chan
+                   , _cdDisplayName            = sanitizeUserText $ channelDisplayName chan
+                   , _cdHeader                 = sanitizeUserText $ chan^.channelHeaderL
+                   , _cdPurpose                = sanitizeUserText $ chan^.channelPurposeL
+                   , _cdType                   = chan^.channelTypeL
+                   , _cdNotifyProps            = emptyChannelNotifyProps
+                   , _cdTypingUsers            = noTypingUsers
+                   , _cdDMUserId               = if chan^.channelTypeL == Direct
+                                                 then userIdForDMChannel myId $
+                                                      sanitizeUserText $ channelName chan
+                                                 else Nothing
+                   , _cdSidebarShowOverride    = Nothing
+                   }
+
+channelInfoFromChannelWithData :: Channel -> ChannelMember -> ChannelInfo -> ChannelInfo
+channelInfoFromChannelWithData chan chanMember ci =
+    let viewed   = chanMember ^. to channelMemberLastViewedAt
+        updated  = chan ^. channelLastPostAtL
+    in ci { _cdViewed           = Just viewed
+          , _cdNewMessageIndicator = case _cdNewMessageIndicator ci of
+              Hide -> if updated > viewed then NewPostsAfterServerTime viewed else Hide
+              v -> v
+          , _cdUpdated          = updated
+          , _cdName             = preferredChannelName chan
+          , _cdDisplayName      = sanitizeUserText $ channelDisplayName chan
+          , _cdHeader           = (sanitizeUserText $ chan^.channelHeaderL)
+          , _cdPurpose          = (sanitizeUserText $ chan^.channelPurposeL)
+          , _cdType             = (chan^.channelTypeL)
+          , _cdMentionCount     = chanMember^.to channelMemberMentionCount
+          , _cdNotifyProps      = chanMember^.to channelMemberNotifyProps
+          }
+
+-- | The 'ChannelContents' is a wrapper for a list of
+--   'Message' values
+data ChannelContents = ChannelContents
+  { _cdMessages :: Messages
+  , _cdFetchPending :: Bool
+  }
+
+-- | An initial empty 'ChannelContents' value.  This also contains an
+-- UnknownGapBefore, which is a signal that causes actual content fetching.
+-- The initial Gap's timestamp is the local client time, but
+-- subsequent fetches will synchronize with the server (and eventually
+-- eliminate this Gap as well).
+emptyChannelContents :: MonadIO m => m ChannelContents
+emptyChannelContents = do
+  gapMsg <- clientMessageToMessage <$> newClientMessage UnknownGapBefore "--Fetching messages--"
+  return $ ChannelContents { _cdMessages = addMessage gapMsg noMessages
+                           , _cdFetchPending = False
+                           }
+
+
+------------------------------------------------------------------------
+
+-- | The 'ChannelInfo' record represents metadata
+--   about a channel
+data ChannelInfo = ChannelInfo
+  { _cdChannelId        :: ChannelId
+    -- ^ The channel's ID
+  , _cdViewed           :: Maybe ServerTime
+    -- ^ The last time we looked at a channel
+  , _cdNewMessageIndicator :: NewMessageIndicator
+    -- ^ The state of the channel's new message indicator.
+  , _cdEditedMessageThreshold :: Maybe ServerTime
+    -- ^ The channel's edited message threshold.
+  , _cdMentionCount     :: Int
+    -- ^ The current number of unread mentions
+  , _cdUpdated          :: ServerTime
+    -- ^ The last time a message showed up in the channel
+  , _cdName             :: Text
+    -- ^ The name of the channel
+  , _cdDisplayName      :: Text
+    -- ^ The display name of the channel
+  , _cdHeader           :: Text
+    -- ^ The header text of a channel
+  , _cdPurpose          :: Text
+    -- ^ The stated purpose of the channel
+  , _cdType             :: Type
+    -- ^ The type of a channel: public, private, or DM
+  , _cdNotifyProps      :: ChannelNotifyProps
+    -- ^ The user's notification settings for this channel
+  , _cdTypingUsers      :: TypingUsers
+    -- ^ The users who are currently typing in this channel
+  , _cdDMUserId         :: Maybe UserId
+    -- ^ The user associated with this channel, if it is a DM channel
+  , _cdSidebarShowOverride :: Maybe UTCTime
+    -- ^ If set, show this channel in the sidebar regardless of other
+    -- considerations as long as the specified timestamp meets a cutoff.
+    -- Otherwise fall back to other application policy to determine
+    -- whether to show the channel.
+  }
+
+-- ** Channel-related Lenses
+
+makeLenses ''ChannelContents
+makeLenses ''ChannelInfo
+makeLenses ''ClientChannel
+makeLenses ''EphemeralEditState
+
+isMuted :: ClientChannel -> Bool
+isMuted cc = cc^.ccInfo.cdNotifyProps.channelNotifyPropsMarkUnreadL ==
+             IsValue NotifyOptionMention
+
+notifyPreference :: User -> ClientChannel -> NotifyOption
+notifyPreference u cc =
+    if isMuted cc then NotifyOptionNone
+    else case cc^.ccInfo.cdNotifyProps.channelNotifyPropsDesktopL of
+             IsValue v -> v
+             Default   -> (userNotifyProps u)^.userNotifyPropsDesktopL
+
+-- ** Miscellaneous channel operations
+
+makeClientChannel :: (MonadIO m) => UserId -> Channel -> m ClientChannel
+makeClientChannel myId nc = emptyChannelContents >>= \contents ->
+  return ClientChannel
+  { _ccContents = contents
+  , _ccInfo = initialChannelInfo myId nc
+  , _ccEditState = defaultEphemeralEditState
+  }
+
+defaultEphemeralEditState :: EphemeralEditState
+defaultEphemeralEditState =
+    EphemeralEditState { _eesMultiline = False
+                       , _eesInputHistoryPosition = Nothing
+                       , _eesLastInput = ("", NewPost)
+                       }
+
+canLeaveChannel :: ChannelInfo -> Bool
+canLeaveChannel cInfo = not $ cInfo^.cdType `elem` [Direct]
+
+-- ** Manage the collection of all Channels
+
+-- | Define a binary kinded type to allow derivation of functor.
+data AllMyChannels a =
+    AllChannels { _chanMap :: HashMap ChannelId a
+                , _userChannelMap :: HashMap UserId ChannelId
+                , _channelNameSet :: S.Set Text
+                }
+                deriving (Functor, Foldable, Traversable)
+
+-- | Define the exported typename which universally binds the
+-- collection to the ChannelInfo type.
+type ClientChannels = AllMyChannels ClientChannel
+
+makeLenses ''AllMyChannels
+
+getChannelNameSet :: ClientChannels -> S.Set Text
+getChannelNameSet = _channelNameSet
+
+-- | Initial collection of Channels with no members
+noChannels :: ClientChannels
+noChannels = AllChannels HM.empty HM.empty mempty
+
+-- | Add a channel to the existing collection.
+addChannel :: ChannelId -> ClientChannel -> ClientChannels -> ClientChannels
+addChannel cId cinfo =
+    (chanMap %~ HM.insert cId cinfo) .
+    (if cinfo^.ccInfo.cdType `notElem` [Direct, Group]
+     then channelNameSet %~ S.insert (cinfo^.ccInfo.cdName)
+     else id) .
+    (case cinfo^.ccInfo.cdDMUserId of
+         Nothing -> id
+         Just uId -> userChannelMap %~ HM.insert uId cId
+    )
+
+-- | Remove a channel from the collection.
+removeChannel :: ChannelId -> ClientChannels -> ClientChannels
+removeChannel cId cs =
+    let mChan = findChannelById cId cs
+        removeChannelName = case mChan of
+            Nothing -> id
+            Just ch -> channelNameSet %~ S.delete (ch^.ccInfo.cdName)
+    in cs & chanMap %~ HM.delete cId
+          & removeChannelName
+          & userChannelMap %~ HM.filter (/= cId)
+
+getDmChannelFor :: UserId -> ClientChannels -> Maybe ChannelId
+getDmChannelFor uId cs = cs^.userChannelMap.at uId
+
+allDmChannelMappings :: ClientChannels -> [(UserId, ChannelId)]
+allDmChannelMappings = HM.toList . _userChannelMap
+
+-- | Get the ChannelInfo information given the ChannelId
+findChannelById :: ChannelId -> ClientChannels -> Maybe ClientChannel
+findChannelById cId = HM.lookup cId . _chanMap
+
+-- | Transform the specified channel in place with provided function.
+modifyChannelById :: ChannelId -> (ClientChannel -> ClientChannel)
+                  -> ClientChannels -> ClientChannels
+modifyChannelById cId f = chanMap.ix(cId) %~ f
+
+-- | A 'Traversal' that will give us the 'ClientChannel' in a
+-- 'ClientChannels' structure if it exists
+channelByIdL :: ChannelId -> Traversal' ClientChannels ClientChannel
+channelByIdL cId = chanMap . ix cId
+
+-- | A 'Lens' that will give us the 'ClientChannel' in a
+-- 'ClientChannels' wrapped in a 'Maybe'
+maybeChannelByIdL :: ChannelId -> Lens' ClientChannels (Maybe ClientChannel)
+maybeChannelByIdL cId = chanMap . at cId
+
+-- | Apply a filter to each ClientChannel and return a list of the
+-- ChannelId values for which the filter matched.
+filteredChannelIds :: (ClientChannel -> Bool) -> ClientChannels -> [ChannelId]
+filteredChannelIds f cc = fst <$> filter (f . snd) (HM.toList (cc^.chanMap))
+
+-- | Filter the ClientChannel collection, keeping only those for which
+-- the provided filter test function returns True.
+filteredChannels :: ((ChannelId, ClientChannel) -> Bool)
+                 -> ClientChannels -> [(ChannelId, ClientChannel)]
+filteredChannels f cc = filter f $ cc^.chanMap.to HM.toList
+
+------------------------------------------------------------------------
+
+-- * Channel State management
+
+
+-- | Add user to the list of users in this channel who are currently typing.
+addChannelTypingUser :: UserId -> UTCTime -> ClientChannel -> ClientChannel
+addChannelTypingUser uId ts = ccInfo.cdTypingUsers %~ (addTypingUser uId ts)
+
+-- | Clear the new message indicator for the specified channel
+clearNewMessageIndicator :: ClientChannel -> ClientChannel
+clearNewMessageIndicator c = c & ccInfo.cdNewMessageIndicator .~ Hide
+
+-- | Clear the edit threshold for the specified channel
+clearEditedThreshold :: ClientChannel -> ClientChannel
+clearEditedThreshold c = c & ccInfo.cdEditedMessageThreshold .~ Nothing
+
+-- | Adjust updated time based on a message, ensuring that the updated
+-- time does not move backward.
+adjustUpdated :: Post -> ClientChannel -> ClientChannel
+adjustUpdated m =
+    ccInfo.cdUpdated %~ max (maxPostTimestamp m)
+
+adjustEditedThreshold :: Post -> ClientChannel -> ClientChannel
+adjustEditedThreshold m c =
+    if m^.postUpdateAtL <= m^.postCreateAtL
+    then c
+    else c & ccInfo.cdEditedMessageThreshold %~ (\mt -> case mt of
+        Just t -> Just $ min (m^.postUpdateAtL) t
+        Nothing -> Just $ m^.postUpdateAtL
+        )
+
+maxPostTimestamp :: Post -> ServerTime
+maxPostTimestamp m = max (m^.postDeleteAtL . non (m^.postUpdateAtL)) (m^.postCreateAtL)
+
+updateNewMessageIndicator :: Post -> ClientChannel -> ClientChannel
+updateNewMessageIndicator m =
+    ccInfo.cdNewMessageIndicator %~
+        (\old ->
+          case old of
+              Hide ->
+                  NewPostsStartingAt $ m^.postCreateAtL
+              NewPostsStartingAt ts ->
+                  NewPostsStartingAt $ min (m^.postCreateAtL) ts
+              NewPostsAfterServerTime ts ->
+                  if m^.postCreateAtL <= ts
+                  then NewPostsStartingAt $ m^.postCreateAtL
+                  else NewPostsAfterServerTime ts
+              )
+
+-- | Town Square is special in that its non-display name cannot be
+-- changed and is a hard-coded constant server-side according to the
+-- developers (as of 8/2/17). So this is a reliable way to check for
+-- whether a channel is in fact that channel, even if the user has
+-- changed its display name.
+isTownSquare :: Channel -> Bool
+isTownSquare c = (sanitizeUserText $ c^.channelNameL) == "town-square"
+
+channelDeleted :: Channel -> Bool
+channelDeleted c = c^.channelDeleteAtL > c^.channelCreateAtL
diff --git a/src/Matterhorn/Types/Common.hs b/src/Matterhorn/Types/Common.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Types/Common.hs
@@ -0,0 +1,41 @@
+{-# LANGUAGE MultiWayIf #-}
+module Matterhorn.Types.Common
+  ( sanitizeUserText
+  , sanitizeUserText'
+  , userIdForDMChannel
+  )
+where
+
+import Prelude ()
+import Matterhorn.Prelude
+
+import qualified Data.Text as T
+
+import Network.Mattermost.Types ( UserText, unsafeUserText, UserId(..), Id(..) )
+
+sanitizeUserText :: UserText -> T.Text
+sanitizeUserText = sanitizeUserText' . unsafeUserText
+
+sanitizeUserText' :: T.Text -> T.Text
+sanitizeUserText' =
+    T.replace "\ESC" "<ESC>" .
+    T.replace "\t" " " .
+    T.filter (\c -> c >= ' ' || c == '\n')  -- remove non-printable
+
+-- | Extract the corresponding other user from a direct channel name.
+-- Returns Nothing if the string is not a direct channel name or if it
+-- is but neither user ID in the name matches the current user's ID.
+userIdForDMChannel :: UserId
+                   -- ^ My user ID
+                   -> Text
+                   -- ^ The channel name
+                   -> Maybe UserId
+userIdForDMChannel me chanName =
+    -- Direct channel names are of the form "UID__UID" where one of the
+    -- UIDs is mine and the other is the other channel participant.
+    let vals = T.splitOn "__" chanName
+    in case vals of
+        [u1, u2] -> if | (UI $ Id u1) == me  -> Just $ UI $ Id u2
+                       | (UI $ Id u2) == me  -> Just $ UI $ Id u1
+                       | otherwise        -> Nothing
+        _ -> Nothing
diff --git a/src/Matterhorn/Types/DirectionalSeq.hs b/src/Matterhorn/Types/DirectionalSeq.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Types/DirectionalSeq.hs
@@ -0,0 +1,97 @@
+{-# LANGUAGE DeriveFoldable #-}
+{-# LANGUAGE DeriveFunctor #-}
+{-# LANGUAGE DeriveTraversable #-}
+{-# LANGUAGE TypeFamilies #-}
+
+{- | These declarations allow the use of a DirectionalSeq, which is a
+   Seq that uses a phantom type to identify the ordering of the
+   elements in the sequence (Forward or Reverse).  The constructors
+   are not exported from this module so that a DirectionalSeq can only
+   be constructed by the functions in this module.
+-}
+
+module Matterhorn.Types.DirectionalSeq where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.Sequence as Seq
+
+
+data Chronological
+data Retrograde
+class SeqDirection a where
+  type ReverseDirection a
+instance SeqDirection Chronological
+  where type ReverseDirection Chronological = Retrograde
+instance SeqDirection Retrograde
+  where type ReverseDirection Retrograde = Chronological
+
+data SeqDirection dir => DirectionalSeq dir a =
+    DSeq { dseq :: Seq a }
+         deriving (Show, Functor, Foldable, Traversable)
+
+emptyDirSeq :: DirectionalSeq dir a
+emptyDirSeq = DSeq mempty
+
+appendDirSeq :: DirectionalSeq dir a -> DirectionalSeq dir a -> DirectionalSeq dir a
+appendDirSeq a b = DSeq $ mappend (dseq a) (dseq b)
+
+onDirectedSeq :: SeqDirection dir => (Seq a -> Seq b)
+              -> DirectionalSeq dir a -> DirectionalSeq dir b
+onDirectedSeq f = DSeq . f . dseq
+
+-- | Uses a start-predicate and and end-predicate to
+-- identify (the first matching) subset that is delineated by
+-- start-predicate and end-predicate (inclusive).  It will then call
+-- the passed operation function on the subset messages to get back a
+-- (possibly modified) set of messages, along with an extracted value.
+-- The 'onDirSeqSubset' function will replace the original subset of
+-- messages with the set returned by the operation function and return
+-- the resulting message list along with the extracted value.
+
+onDirSeqSubset :: SeqDirection dir =>
+                 (e -> Bool) -> (e -> Bool)
+               -> (DirectionalSeq dir e -> (DirectionalSeq dir e, a))
+               -> DirectionalSeq dir e
+               -> (DirectionalSeq dir e, a)
+onDirSeqSubset startPred endPred op entries =
+    let ml = dseq entries
+        (bl, ml1) = Seq.breakl startPred ml
+        (ml2, el) = Seq.breakl endPred ml1
+        -- move match from start of el to end of ml2
+        (ml2', el') = if not (Seq.null el)
+                      then (ml2 <> Seq.take 1 el, Seq.drop 1 el)
+                      else (ml2, el)
+        (ml3, rval) = op $ DSeq ml2'
+    in (DSeq bl `appendDirSeq` ml3 `appendDirSeq` DSeq el', rval)
+
+-- | dirSeqBreakl splits the DirectionalSeq into a tuple where the
+-- first element is the (possibly empty) DirectionalSeq of all
+-- elements from the start for which the predicate returns false; the
+-- second tuple element is the remainder of the list, starting with
+-- the first element for which the predicate matched.
+dirSeqBreakl :: SeqDirection dir =>
+               (e -> Bool) -> DirectionalSeq dir e
+             -> (DirectionalSeq dir e, DirectionalSeq dir e)
+dirSeqBreakl isMatch entries =
+    let (removed, remaining) = Seq.breakl isMatch $ dseq entries
+    in (DSeq removed, DSeq remaining)
+
+-- | dirSeqPartition splits the DirectionalSeq into a tuple of two
+-- DirectionalSeq elements: the first contains all elements for which
+-- the predicate is true and the second contains all elements for
+-- which the predicate is false.
+dirSeqPartition :: SeqDirection dir =>
+                  (e -> Bool) -> DirectionalSeq dir e
+                -> (DirectionalSeq dir e, DirectionalSeq dir e)
+dirSeqPartition isMatch entries =
+    let (match, nomatch) = Seq.partition isMatch $ dseq entries
+    in (DSeq match, DSeq nomatch)
+
+
+withDirSeqHead :: SeqDirection dir => (e -> r) -> DirectionalSeq dir e -> Maybe r
+withDirSeqHead op entries =
+    case Seq.viewl (dseq entries) of
+      Seq.EmptyL -> Nothing
+      e Seq.:< _ -> Just $ op e
diff --git a/src/Matterhorn/Types/KeyEvents.hs b/src/Matterhorn/Types/KeyEvents.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Types/KeyEvents.hs
@@ -0,0 +1,437 @@
+module Matterhorn.Types.KeyEvents
+  (
+  -- * Types
+    KeyEvent(..)
+  , KeyConfig
+  , Binding(..)
+  , BindingState(..)
+
+  -- * Data
+  , allEvents
+
+  -- * Parsing and pretty-printing
+  , parseBinding
+  , parseBindingList
+  , ppBinding
+  , nonCharKeys
+  , eventToBinding
+
+  -- * Key event name resolution
+  , keyEventFromName
+  , keyEventName
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.Map.Strict as M
+import qualified Data.Text as T
+import qualified Graphics.Vty as Vty
+
+
+-- | This enum represents all the possible key events a user might
+--   want to use.
+data KeyEvent
+  = VtyRefreshEvent
+  | ShowHelpEvent
+  | EnterSelectModeEvent
+  | ReplyRecentEvent
+  | ToggleMessagePreviewEvent
+  | InvokeEditorEvent
+  | EnterFastSelectModeEvent
+  | QuitEvent
+  | NextChannelEvent
+  | PrevChannelEvent
+  | NextChannelEventAlternate
+  | PrevChannelEventAlternate
+  | NextUnreadChannelEvent
+  | NextUnreadUserOrChannelEvent
+  | LastChannelEvent
+  | EnterOpenURLModeEvent
+  | ClearUnreadEvent
+  | ToggleMultiLineEvent
+  | EnterFlaggedPostsEvent
+  | ToggleChannelListVisibleEvent
+  | ToggleExpandedChannelTopicsEvent
+  | ShowAttachmentListEvent
+
+  | EditorKillToBolEvent
+  | EditorKillToEolEvent
+  | EditorBolEvent
+  | EditorEolEvent
+  | EditorTransposeCharsEvent
+  | EditorDeleteCharacter
+  | EditorPrevCharEvent
+  | EditorNextCharEvent
+  | EditorPrevWordEvent
+  | EditorNextWordEvent
+  | EditorDeleteNextWordEvent
+  | EditorDeletePrevWordEvent
+  | EditorHomeEvent
+  | EditorEndEvent
+  | EditorYankEvent
+
+  | SelectNextTabEvent
+  | SelectPreviousTabEvent
+
+  -- generic cancel
+  | CancelEvent
+
+  -- channel-scroll-specific
+  | LoadMoreEvent
+  | OpenMessageURLEvent
+
+  -- scrolling events---maybe rebindable?
+  | ScrollUpEvent
+  | ScrollDownEvent
+  | ScrollLeftEvent
+  | ScrollRightEvent
+  | PageUpEvent
+  | PageDownEvent
+  | PageRightEvent
+  | PageLeftEvent
+  | ScrollTopEvent
+  | ScrollBottomEvent
+  | SelectOldestMessageEvent
+
+  -- select events---not the same as scrolling sometimes!
+  | SelectUpEvent
+  | SelectDownEvent
+
+  -- search select events---these need to not be valid editor inputs
+  -- (such as 'j' and 'k')
+  | SearchSelectUpEvent
+  | SearchSelectDownEvent
+
+  -- E.g. Pressing enter on an item in a list to do something with it
+  | ActivateListItemEvent
+
+  | ViewMessageEvent
+  | FillGapEvent
+  | FlagMessageEvent
+  | PinMessageEvent
+  | YankMessageEvent
+  | YankWholeMessageEvent
+  | DeleteMessageEvent
+  | EditMessageEvent
+  | ReplyMessageEvent
+  | ReactToMessageEvent
+
+  -- Attachments
+  | AttachmentListAddEvent
+  | AttachmentListDeleteEvent
+  | AttachmentOpenEvent
+
+  -- Form submission
+  | FormSubmitEvent
+    deriving (Eq, Show, Ord, Enum)
+
+allEvents :: [KeyEvent]
+allEvents =
+  [ QuitEvent
+  , VtyRefreshEvent
+  , ClearUnreadEvent
+
+  , ToggleMessagePreviewEvent
+  , InvokeEditorEvent
+  , ToggleMultiLineEvent
+  , CancelEvent
+  , ReplyRecentEvent
+  , SelectNextTabEvent
+  , SelectPreviousTabEvent
+
+  , EnterFastSelectModeEvent
+  , NextChannelEvent
+  , PrevChannelEvent
+  , NextChannelEventAlternate
+  , PrevChannelEventAlternate
+  , NextUnreadChannelEvent
+  , NextUnreadUserOrChannelEvent
+  , LastChannelEvent
+
+  , ShowAttachmentListEvent
+
+  , EditorKillToBolEvent
+  , EditorKillToEolEvent
+  , EditorBolEvent
+  , EditorEolEvent
+  , EditorTransposeCharsEvent
+  , EditorDeleteCharacter
+  , EditorPrevCharEvent
+  , EditorNextCharEvent
+  , EditorPrevWordEvent
+  , EditorNextWordEvent
+  , EditorDeleteNextWordEvent
+  , EditorDeletePrevWordEvent
+  , EditorHomeEvent
+  , EditorEndEvent
+  , EditorYankEvent
+
+  , EnterFlaggedPostsEvent
+  , ToggleChannelListVisibleEvent
+  , ToggleExpandedChannelTopicsEvent
+  , ShowHelpEvent
+  , EnterSelectModeEvent
+  , EnterOpenURLModeEvent
+
+  , LoadMoreEvent
+  , OpenMessageURLEvent
+
+  , ScrollUpEvent
+  , ScrollDownEvent
+  , ScrollLeftEvent
+  , ScrollRightEvent
+  , PageUpEvent
+  , PageDownEvent
+  , PageLeftEvent
+  , PageRightEvent
+  , ScrollTopEvent
+  , ScrollBottomEvent
+  , SelectOldestMessageEvent
+
+  , SelectUpEvent
+  , SelectDownEvent
+
+  , ActivateListItemEvent
+
+  , SearchSelectUpEvent
+  , SearchSelectDownEvent
+
+  , FlagMessageEvent
+  , PinMessageEvent
+  , ViewMessageEvent
+  , FillGapEvent
+  , YankMessageEvent
+  , YankWholeMessageEvent
+  , DeleteMessageEvent
+  , EditMessageEvent
+  , ReplyMessageEvent
+  , ReactToMessageEvent
+  , AttachmentListAddEvent
+  , AttachmentListDeleteEvent
+  , AttachmentOpenEvent
+  , FormSubmitEvent
+  ]
+
+eventToBinding :: Vty.Event -> Binding
+eventToBinding (Vty.EvKey k mods) = Binding mods k
+eventToBinding k = error $ "BUG: invalid keybinding " <> show k
+
+data Binding = Binding
+  { kbMods :: [Vty.Modifier]
+  , kbKey  :: Vty.Key
+  } deriving (Eq, Show, Ord)
+
+data BindingState =
+    BindingList [Binding]
+    | Unbound
+    deriving (Show, Eq, Ord)
+
+type KeyConfig = M.Map KeyEvent BindingState
+
+parseBinding :: Text -> Either String Binding
+parseBinding kb = go (T.splitOn "-" $ T.toLower kb) []
+  where go [k] mods = do
+          key <- pKey k
+          return Binding { kbMods = mods, kbKey = key }
+        go (k:ks) mods = do
+          m <- case k of
+            "s"       -> return Vty.MShift
+            "shift"   -> return Vty.MShift
+            "m"       -> return Vty.MMeta
+            "meta"    -> return Vty.MMeta
+            "a"       -> return Vty.MAlt
+            "alt"     -> return Vty.MAlt
+            "c"       -> return Vty.MCtrl
+            "ctrl"    -> return Vty.MCtrl
+            "control" -> return Vty.MCtrl
+            _         -> Left ("Unknown modifier prefix: " ++ show k)
+          go ks (m:mods)
+        go [] _ = Left "Empty keybinding not allowed"
+        pKey "esc"       = return Vty.KEsc
+        pKey "backspace" = return Vty.KBS
+        pKey "enter"     = return Vty.KEnter
+        pKey "left"      = return Vty.KLeft
+        pKey "right"     = return Vty.KRight
+        pKey "up"        = return Vty.KUp
+        pKey "down"      = return Vty.KDown
+        pKey "upleft"    = return Vty.KUpLeft
+        pKey "upright"   = return Vty.KUpRight
+        pKey "downleft"  = return Vty.KDownLeft
+        pKey "downright" = return Vty.KDownRight
+        pKey "center"    = return Vty.KCenter
+        pKey "backtab"   = return Vty.KBackTab
+        pKey "printscreen" = return Vty.KPrtScr
+        pKey "pause"     = return Vty.KPause
+        pKey "insert"    = return Vty.KIns
+        pKey "home"      = return Vty.KHome
+        pKey "pgup"      = return Vty.KPageUp
+        pKey "del"       = return Vty.KDel
+        pKey "end"       = return Vty.KEnd
+        pKey "pgdown"    = return Vty.KPageDown
+        pKey "begin"     = return Vty.KBegin
+        pKey "menu"      = return Vty.KMenu
+        pKey "space"     = return (Vty.KChar ' ')
+        pKey "tab"       = return (Vty.KChar '\t')
+        pKey t
+          | Just (c, "") <- T.uncons t =
+              return (Vty.KChar c)
+          | Just n <- T.stripPrefix "f" t =
+              case readMaybe (T.unpack n) of
+                  Nothing -> Left ("Unknown keybinding: " ++ show t)
+                  Just i -> return (Vty.KFun i)
+          | otherwise = Left ("Unknown keybinding: " ++ show t)
+
+ppBinding :: Binding -> Text
+ppBinding (Binding mods k) =
+    T.intercalate "-" $ (ppMod <$> mods) <> [ppKey k]
+
+ppKey :: Vty.Key -> Text
+ppKey (Vty.KChar c)   = ppChar c
+ppKey (Vty.KFun n)    = "F" <> (T.pack $ show n)
+ppKey Vty.KBackTab    = "BackTab"
+ppKey Vty.KEsc        = "Esc"
+ppKey Vty.KBS         = "Backspace"
+ppKey Vty.KEnter      = "Enter"
+ppKey Vty.KUp         = "Up"
+ppKey Vty.KDown       = "Down"
+ppKey Vty.KLeft       = "Left"
+ppKey Vty.KRight      = "Right"
+ppKey Vty.KHome       = "Home"
+ppKey Vty.KEnd        = "End"
+ppKey Vty.KPageUp     = "PgUp"
+ppKey Vty.KPageDown   = "PgDown"
+ppKey Vty.KDel        = "Del"
+ppKey Vty.KUpLeft     = "UpLeft"
+ppKey Vty.KUpRight    = "UpRight"
+ppKey Vty.KDownLeft   = "DownLeft"
+ppKey Vty.KDownRight  = "DownRight"
+ppKey Vty.KCenter     = "Center"
+ppKey Vty.KPrtScr     = "PrintScreen"
+ppKey Vty.KPause      = "Pause"
+ppKey Vty.KIns        = "Insert"
+ppKey Vty.KBegin      = "Begin"
+ppKey Vty.KMenu       = "Menu"
+
+nonCharKeys :: [Text]
+nonCharKeys = map ppKey
+  [ Vty.KBackTab, Vty.KEsc, Vty.KBS, Vty.KEnter, Vty.KUp, Vty.KDown
+  , Vty.KLeft, Vty.KRight, Vty.KHome, Vty.KEnd, Vty.KPageDown
+  , Vty.KPageUp, Vty.KDel, Vty.KUpLeft, Vty.KUpRight, Vty.KDownLeft
+  , Vty.KDownRight, Vty.KCenter, Vty.KPrtScr, Vty.KPause, Vty.KIns
+  , Vty.KBegin, Vty.KMenu
+  ]
+
+ppChar :: Char -> Text
+ppChar '\t' = "Tab"
+ppChar ' '  = "Space"
+ppChar c    = T.singleton c
+
+ppMod :: Vty.Modifier -> Text
+ppMod Vty.MMeta  = "M"
+ppMod Vty.MAlt   = "A"
+ppMod Vty.MCtrl  = "C"
+ppMod Vty.MShift = "S"
+
+parseBindingList :: Text -> Either String BindingState
+parseBindingList t =
+    if T.toLower t == "unbound"
+    then return Unbound
+    else BindingList <$> mapM (parseBinding . T.strip) (T.splitOn "," t)
+
+keyEventFromName :: Text -> Either String KeyEvent
+keyEventFromName t =
+    let mapping = M.fromList [ (keyEventName e, e) | e <- allEvents ]
+    in case M.lookup t mapping of
+        Just e -> return e
+        Nothing -> Left ("Unknown event: " ++ show t)
+
+keyEventName :: KeyEvent -> Text
+keyEventName ev = case ev of
+  QuitEvent                 -> "quit"
+  VtyRefreshEvent           -> "vty-refresh"
+  ClearUnreadEvent          -> "clear-unread"
+  CancelEvent               -> "cancel"
+
+  ToggleMessagePreviewEvent -> "toggle-message-preview"
+  InvokeEditorEvent         -> "invoke-editor"
+  ToggleMultiLineEvent      -> "toggle-multiline"
+  ReplyRecentEvent          -> "reply-recent"
+
+  EnterFastSelectModeEvent  -> "enter-fast-select"
+  NextChannelEvent          -> "focus-next-channel"
+  PrevChannelEvent          -> "focus-prev-channel"
+  NextChannelEventAlternate -> "focus-next-channel-alternate"
+  PrevChannelEventAlternate -> "focus-prev-channel-alternate"
+  NextUnreadChannelEvent    -> "focus-next-unread"
+  NextUnreadUserOrChannelEvent  -> "focus-next-unread-user-or-channel"
+  LastChannelEvent          -> "focus-last-channel"
+
+  SelectNextTabEvent        -> "select-next-tab"
+  SelectPreviousTabEvent    -> "select-previous-tab"
+
+  ShowAttachmentListEvent   -> "show-attachment-list"
+
+  EditorKillToBolEvent        -> "editor-kill-to-beginning-of-line"
+  EditorKillToEolEvent        -> "editor-kill-to-end-of-line"
+  EditorBolEvent              -> "editor-beginning-of-line"
+  EditorEolEvent              -> "editor-end-of-line"
+  EditorTransposeCharsEvent   -> "editor-transpose-chars"
+  EditorDeleteCharacter       -> "editor-delete-char"
+  EditorPrevCharEvent         -> "editor-prev-char"
+  EditorNextCharEvent         -> "editor-next-char"
+  EditorPrevWordEvent         -> "editor-prev-word"
+  EditorNextWordEvent         -> "editor-next-word"
+  EditorDeleteNextWordEvent   -> "editor-delete-next-word"
+  EditorDeletePrevWordEvent   -> "editor-delete-prev-word"
+  EditorHomeEvent             -> "editor-home"
+  EditorEndEvent              -> "editor-end"
+  EditorYankEvent             -> "editor-yank"
+
+  EnterFlaggedPostsEvent    -> "show-flagged-posts"
+  ToggleChannelListVisibleEvent -> "toggle-channel-list-visibility"
+  ToggleExpandedChannelTopicsEvent -> "toggle-expanded-channel-topics"
+  ShowHelpEvent             -> "show-help"
+  EnterSelectModeEvent      -> "select-mode"
+  EnterOpenURLModeEvent     -> "enter-url-open"
+
+  LoadMoreEvent             -> "load-more"
+  OpenMessageURLEvent       -> "open-message-url"
+
+  ScrollUpEvent     -> "scroll-up"
+  ScrollDownEvent   -> "scroll-down"
+  ScrollLeftEvent   -> "scroll-left"
+  ScrollRightEvent  -> "scroll-right"
+  PageUpEvent       -> "page-up"
+  PageDownEvent     -> "page-down"
+  PageLeftEvent     -> "page-left"
+  PageRightEvent    -> "page-right"
+  ScrollTopEvent    -> "scroll-top"
+  ScrollBottomEvent -> "scroll-bottom"
+  SelectOldestMessageEvent -> "select-oldest-message"
+
+  SelectUpEvent   -> "select-up"
+  SelectDownEvent -> "select-down"
+
+  SearchSelectUpEvent   -> "search-select-up"
+  SearchSelectDownEvent -> "search-select-down"
+
+  ActivateListItemEvent -> "activate-list-item"
+
+  FlagMessageEvent   -> "flag-message"
+  PinMessageEvent   -> "pin-message"
+  ViewMessageEvent   -> "view-message"
+  FillGapEvent       -> "fetch-for-gap"
+  YankMessageEvent   -> "yank-message"
+  YankWholeMessageEvent   -> "yank-whole-message"
+  DeleteMessageEvent -> "delete-message"
+  EditMessageEvent   -> "edit-message"
+  ReplyMessageEvent  -> "reply-message"
+  ReactToMessageEvent -> "react-to-message"
+
+  AttachmentListAddEvent    -> "add-to-attachment-list"
+  AttachmentListDeleteEvent -> "delete-from-attachment-list"
+  AttachmentOpenEvent       -> "open-attachment"
+
+  FormSubmitEvent -> "submit-form"
diff --git a/src/Matterhorn/Types/Messages.hs b/src/Matterhorn/Types/Messages.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Types/Messages.hs
@@ -0,0 +1,750 @@
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE TemplateHaskell #-}
+
+{-|
+
+The 'Message' is a single displayed event in a Channel.  All Messages
+have a date/time, and messages that represent posts to the channel
+have a (hash) ID, and displayable text, along with other attributes.
+
+All Messages are sorted chronologically.  There is no assumption that
+the server date/time is synchronized with the local date/time, so all
+of the Message ordering uses the server's date/time.
+
+The mattermost-api retrieves a 'Post' from the server, briefly encodes
+the useful portions of that as a 'ClientPost' object and then converts
+it to a 'Message' inserting this result it into the collection of
+Messages associated with a Channel.  The PostID of the message
+uniquely identifies that message and can be used to interact with the
+server for subsequent operations relative to that message's 'Post'.
+The date/time associated with these messages is generated by the
+server.
+
+There are also "messages" generated directly by the Matterhorn client
+which can be used to display additional, client-related information to
+the user. Examples of these client messages are: date boundaries, the
+"new messages" marker, errors from invoking the browser, etc.  These
+client-generated messages will have a date/time although it is locally
+generated (usually by relation to an associated Post).
+
+Most other Matterhorn operations primarily are concerned with
+user-posted messages (@case mMessageId of Just _@ or @case mType of CP
+_@), but others will include client-generated messages (@case mMessageId
+of Nothing@ or @case mType of C _@).
+
+--}
+
+module Matterhorn.Types.Messages
+  ( -- * Message and operations on a single Message
+    Message(..)
+  , isDeletable, isReplyable, isReactable, isEditable, isReplyTo, isGap, isFlaggable
+  , isPinnable, isEmote, isJoinLeave, isTransition, isNewMessagesTransition
+  , mText, mUser, mDate, mType, mPending, mDeleted, mPinned
+  , mAttachments, mInReplyToMsg, mMessageId, mReactions, mFlagged
+  , mOriginalPost, mChannelId, mMarkdownSource
+  , isBotMessage
+  , MessageType(..)
+  , MessageId(..)
+  , ThreadState(..)
+  , MentionedUser(..)
+  , isPostMessage
+  , messagePostId
+  , messageIdPostId
+  , UserRef(..)
+  , ReplyState(..)
+  , clientMessageToMessage
+  , clientPostToMessage
+  , clientPostReactionUserIds
+  , newMessageOfType
+    -- * Message Collections
+  , Messages
+  , ChronologicalMessages
+  , RetrogradeMessages
+  , MessageOps (..)
+  , noMessages
+  , messagesLength
+  , filterMessages
+  , reverseMessages
+  , unreverseMessages
+  , splitMessages
+  , splitDirSeqOn
+  , chronologicalMsgsWithThreadStates
+  , retrogradeMsgsWithThreadStates
+  , findMessage
+  , getRelMessageId
+  , messagesHead
+  , messagesDrop
+  , getNextMessage
+  , getPrevMessage
+  , getNextMessageId
+  , getPrevMessageId
+  , getNextPostId
+  , getPrevPostId
+  , getEarliestPostMsg
+  , getLatestPostMsg
+  , getEarliestSelectableMessage
+  , getLatestSelectableMessage
+  , findLatestUserMessage
+  -- * Operations on any Message type
+  , messagesAfter
+  , removeMatchesFromSubset
+  , withFirstMessage
+  , msgURLs
+
+  , LinkTarget(..)
+  , LinkChoice(LinkChoice, _linkTarget)
+  , linkUser
+  , linkTarget
+  , linkTime
+  , linkLabel
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Control.Monad
+import qualified Data.Foldable as F
+import           Data.Hashable ( Hashable )
+import qualified Data.Map.Strict as Map
+import           Data.Sequence as Seq
+import qualified Data.Set as S
+import           Data.Tuple
+import           Data.UUID ( UUID )
+import           GHC.Generics ( Generic )
+import           Lens.Micro.Platform ( makeLenses )
+
+import           Network.Mattermost.Types ( ChannelId, PostId, Post
+                                          , ServerTime, UserId, FileId
+                                          )
+
+import           Matterhorn.Types.DirectionalSeq
+import           Matterhorn.Types.Posts
+import           Matterhorn.Types.RichText ( RichTextBlock(..), Element(..)
+                                , ElementData(..), findUsernames, blockGetURLs
+                                , ElementStyle(..), URL(..), parseMarkdown
+                                , TeamURLName
+                                )
+
+
+-- | The state of a message's thread context.
+data ThreadState =
+    NoThread
+    -- ^ The message is not in a thread at all.
+    | InThreadShowParent
+    -- ^ The message is in a thread, and the thread's root message
+    -- (parent) should be displayed above this message.
+    | InThread
+    -- ^ The message is in a thread but the thread's root message should
+    -- not be displayed above this message.
+    deriving (Show, Eq)
+
+-- ----------------------------------------------------------------------
+-- * Messages
+
+data MessageId = MessagePostId PostId
+               | MessageUUID UUID
+               deriving (Eq, Read, Show, Generic, Hashable)
+
+messageIdPostId :: MessageId -> Maybe PostId
+messageIdPostId (MessagePostId p) = Just p
+messageIdPostId _ = Nothing
+
+-- | A 'Message' is any message we might want to render, either from
+--   Mattermost itself or from a client-internal source.
+data Message = Message
+  { _mText          :: Seq RichTextBlock
+  , _mMarkdownSource :: Text
+  , _mUser          :: UserRef
+  , _mDate          :: ServerTime
+  , _mType          :: MessageType
+  , _mPending       :: Bool
+  , _mDeleted       :: Bool
+  , _mAttachments   :: Seq Attachment
+  , _mInReplyToMsg  :: ReplyState
+  , _mMessageId     :: Maybe MessageId
+  , _mReactions     :: Map.Map Text (S.Set UserId)
+  , _mOriginalPost  :: Maybe Post
+  , _mFlagged       :: Bool
+  , _mPinned        :: Bool
+  , _mChannelId     :: Maybe ChannelId
+  } deriving (Show)
+
+isPostMessage :: Message -> Bool
+isPostMessage m =
+    isJust (_mMessageId m >>= messageIdPostId)
+
+messagePostId :: Message -> Maybe PostId
+messagePostId m = do
+    mId <- _mMessageId m
+    messageIdPostId mId
+
+isDeletable :: Message -> Bool
+isDeletable m =
+    isJust (messagePostId m) &&
+    case _mType m of
+      CP NormalPost -> True
+      CP Emote -> True
+      _ -> False
+
+isFlaggable :: Message -> Bool
+isFlaggable = isJust . messagePostId
+
+isPinnable :: Message -> Bool
+isPinnable = isJust . messagePostId
+
+isReplyable :: Message -> Bool
+isReplyable m =
+    isJust (messagePostId m) &&
+    case _mType m of
+      CP NormalPost -> True
+      CP Emote -> True
+      _ -> False
+
+isReactable :: Message -> Bool
+isReactable m =
+    isJust (messagePostId m) &&
+    case _mType m of
+      CP NormalPost -> True
+      CP Emote -> True
+      _ -> False
+
+isEditable :: Message -> Bool
+isEditable m =
+    isJust (messagePostId m) &&
+    case _mType m of
+      CP NormalPost -> True
+      CP Emote -> True
+      _ -> False
+
+isReplyTo :: PostId -> Message -> Bool
+isReplyTo expectedParentId m =
+    case _mInReplyToMsg m of
+        NotAReply                -> False
+        InReplyTo actualParentId -> actualParentId == expectedParentId
+
+isGap :: Message -> Bool
+isGap m = case _mType m of
+            C UnknownGapBefore -> True
+            C UnknownGapAfter -> True
+            _ -> False
+
+isTransition :: Message -> Bool
+isTransition m = case _mType m of
+                   C DateTransition -> True
+                   C NewMessagesTransition -> True
+                   _ -> False
+
+isNewMessagesTransition :: Message -> Bool
+isNewMessagesTransition m = case _mType m of
+    C NewMessagesTransition -> True
+    _ -> False
+
+isEmote :: Message -> Bool
+isEmote m = case _mType m of
+              CP Emote -> True
+              _ -> False
+
+isJoinLeave :: Message -> Bool
+isJoinLeave m = case _mType m of
+                  CP Join -> True
+                  CP Leave -> True
+                  _ -> False
+
+-- | A 'Message' is the representation we use for storage and
+--   rendering, so it must be able to represent either a
+--   post from Mattermost or an internal message. This represents
+--   the union of both kinds of post types.
+data MessageType = C ClientMessageType
+                 | CP ClientPostType
+                 deriving (Show)
+
+-- | There may be no user (usually an internal message), a reference to
+-- a user (by Id), or the server may have supplied a specific username
+-- (often associated with bots). The boolean flag indicates whether the
+-- user reference is for a message from a bot.
+data UserRef = NoUser | UserI Bool UserId | UserOverride Bool Text
+               deriving (Eq, Show, Ord)
+
+isBotMessage :: Message -> Bool
+isBotMessage m =
+    case _mUser m of
+        UserI bot _        -> bot
+        UserOverride bot _ -> bot
+        NoUser             -> False
+
+-- | The 'ReplyState' of a message represents whether a message
+--   is a reply, and if so, to what message
+data ReplyState =
+    NotAReply
+    | InReplyTo PostId
+    deriving (Show, Eq)
+
+data LinkTarget =
+    LinkURL URL
+    | LinkFileId FileId
+    | LinkPermalink TeamURLName PostId
+    deriving (Eq, Show, Ord)
+
+-- | This type represents links to things in the 'open links' view.
+data LinkChoice =
+    LinkChoice { _linkTime   :: ServerTime
+               , _linkUser   :: UserRef
+               , _linkLabel  :: Maybe (Seq Element)
+               , _linkTarget :: LinkTarget
+               } deriving (Eq, Show)
+
+makeLenses ''LinkChoice
+
+-- | Convert a 'ClientMessage' to a 'Message'.  A 'ClientMessage' is
+-- one that was generated by the Matterhorn client and which the
+-- server knows nothing about.  For example, an error message
+-- associated with passing a link to the local browser.
+clientMessageToMessage :: ClientMessage -> Message
+clientMessageToMessage cm = Message
+  { _mText          = parseMarkdown Nothing (cm^.cmText)
+  , _mMarkdownSource = cm^.cmText
+  , _mUser          = NoUser
+  , _mDate          = cm^.cmDate
+  , _mType          = C $ cm^.cmType
+  , _mPending       = False
+  , _mDeleted       = False
+  , _mAttachments   = Seq.empty
+  , _mInReplyToMsg  = NotAReply
+  , _mMessageId     = Nothing
+  , _mReactions     = Map.empty
+  , _mOriginalPost  = Nothing
+  , _mFlagged       = False
+  , _mPinned        = False
+  , _mChannelId     = Nothing
+  }
+
+
+data MentionedUser =
+    UsernameMention Text
+    | UserIdMention UserId
+    deriving (Eq, Show, Ord)
+
+clientPostReactionUserIds :: ClientPost -> S.Set UserId
+clientPostReactionUserIds cp =
+    S.unions $ F.toList $ cp^.cpReactions
+
+-- | Builds a message from a ClientPost and also returns the set of
+-- usernames mentioned in the text of the message.
+clientPostToMessage :: ClientPost -> (Message, S.Set MentionedUser)
+clientPostToMessage cp = (m, mentions)
+    where
+        mentions =
+            S.fromList $
+                (UsernameMention <$> (F.toList $ findUsernames $ cp^.cpText)) <>
+                (UserIdMention <$> (F.toList $ clientPostReactionUserIds cp))
+        m = Message { _mText = cp^.cpText
+                    , _mMarkdownSource = cp^.cpMarkdownSource
+                    , _mUser =
+                        case cp^.cpUserOverride of
+                            Just n | cp^.cpType == NormalPost -> UserOverride (cp^.cpFromWebhook) n
+                            _ -> maybe NoUser (UserI (cp^.cpFromWebhook)) $ cp^.cpUser
+                    , _mDate = cp^.cpDate
+                    , _mType = CP $ cp^.cpType
+                    , _mPending = cp^.cpPending
+                    , _mDeleted = cp^.cpDeleted
+                    , _mAttachments = cp^.cpAttachments
+                    , _mInReplyToMsg =
+                        case cp^.cpInReplyToPost of
+                            Nothing  -> NotAReply
+                            Just pId -> InReplyTo pId
+                    , _mMessageId = Just $ MessagePostId $ cp^.cpPostId
+                    , _mReactions = cp^.cpReactions
+                    , _mOriginalPost = Just $ cp^.cpOriginalPost
+                    , _mFlagged = False
+                    , _mPinned = cp^.cpPinned
+                    , _mChannelId = Just $ cp^.cpChannelId
+                    }
+
+
+newMessageOfType :: Text -> MessageType -> ServerTime -> Message
+newMessageOfType text typ d = Message
+  { _mText         = parseMarkdown Nothing text
+  , _mMarkdownSource = text
+  , _mUser         = NoUser
+  , _mDate         = d
+  , _mType         = typ
+  , _mPending      = False
+  , _mDeleted      = False
+  , _mAttachments  = Seq.empty
+  , _mInReplyToMsg = NotAReply
+  , _mMessageId    = Nothing
+  , _mReactions    = Map.empty
+  , _mOriginalPost = Nothing
+  , _mFlagged      = False
+  , _mPinned       = False
+  , _mChannelId    = Nothing
+  }
+
+-- ** 'Message' Lenses
+
+makeLenses ''Message
+
+-- ----------------------------------------------------------------------
+
+-- * Message Collections
+
+-- | A wrapper for an ordered, unique list of 'Message' values.
+--
+-- This type has (and promises) the following instances: Show,
+-- Functor, Monoid, Foldable, Traversable
+type ChronologicalMessages = DirectionalSeq Chronological Message
+type Messages = ChronologicalMessages
+
+-- | There are also cases where the list of 'Message' values are kept
+-- in reverse order (most recent -> oldest); these cases are
+-- represented by the `RetrogradeMessages` type.
+type RetrogradeMessages = DirectionalSeq Retrograde Message
+
+-- ** Common operations on Messages
+
+filterMessages :: SeqDirection seq
+               => (a -> Bool)
+               -> DirectionalSeq seq a
+               -> DirectionalSeq seq a
+filterMessages f = onDirectedSeq (Seq.filter f)
+
+class MessageOps a where
+    -- | addMessage inserts a date in proper chronological order, with
+    -- the following extra functionality:
+    --     * no duplication (by PostId)
+    --     * no duplication (adjacent UnknownGap entries)
+    addMessage :: Message -> a -> a
+
+instance MessageOps ChronologicalMessages where
+    addMessage m ml =
+        case viewr (dseq ml) of
+            EmptyR -> DSeq $ singleton m
+            _ :> l ->
+                case compare (m^.mDate) (l^.mDate) of
+                  GT -> DSeq $ dseq ml |> m
+                  EQ -> if m^.mMessageId == l^.mMessageId && isJust (m^.mMessageId)
+                        then ml
+                        else dirDateInsert m ml
+                  LT -> dirDateInsert m ml
+
+dirDateInsert :: Message -> ChronologicalMessages -> ChronologicalMessages
+dirDateInsert m = onDirectedSeq $ finalize . foldr insAfter initial
+   where initial = (Just m, mempty)
+         insAfter c (Nothing, l) = (Nothing, c <| l)
+         insAfter c (Just n, l) =
+             case compare (n^.mDate) (c^.mDate) of
+               GT -> (Nothing, c <| (n <| l))
+               EQ -> if n^.mMessageId == c^.mMessageId && isJust (c^.mMessageId)
+                     then (Nothing, c <| l)
+                     else (Just n, c <| l)
+               LT -> (Just n, c <| l)
+         finalize (Just n, l) = n <| l
+         finalize (_, l) = l
+
+noMessages :: Messages
+noMessages = DSeq mempty
+
+messagesLength :: DirectionalSeq seq a -> Int
+messagesLength (DSeq ms) = Seq.length ms
+
+-- | Reverse the order of the messages
+reverseMessages :: Messages -> RetrogradeMessages
+reverseMessages = DSeq . Seq.reverse . dseq
+
+-- | Unreverse the order of the messages
+unreverseMessages :: RetrogradeMessages -> Messages
+unreverseMessages = DSeq . Seq.reverse . dseq
+
+splitDirSeqOn :: SeqDirection d
+              => (a -> Bool)
+              -> DirectionalSeq d a
+              -> (Maybe a, (DirectionalSeq (ReverseDirection d) a,
+                            DirectionalSeq d a))
+splitDirSeqOn f msgs =
+    let (removed, remaining) = dirSeqBreakl f msgs
+        devomer = DSeq $ Seq.reverse $ dseq removed
+    in (withDirSeqHead id remaining, (devomer, onDirectedSeq (Seq.drop 1) remaining))
+
+-- ----------------------------------------------------------------------
+-- * Operations on Posted Messages
+
+-- | Searches for the specified MessageId and returns a tuple where the
+-- first element is the Message associated with the MessageId (if it
+-- exists), and the second element is another tuple: the first element
+-- of the second is all the messages from the beginning of the list to
+-- the message just before the MessageId message (or all messages if not
+-- found) *in reverse order*, and the second element of the second are
+-- all the messages that follow the found message (none if the message
+-- was never found) in *forward* order.
+splitMessages :: Maybe MessageId
+              -> DirectionalSeq Chronological (Message, ThreadState)
+              -> (Maybe (Message, ThreadState),
+                   ( DirectionalSeq Retrograde (Message, ThreadState),
+                     DirectionalSeq Chronological (Message, ThreadState)))
+splitMessages mid msgs = splitDirSeqOn (\(m, _) -> isJust mid && m^.mMessageId == mid) msgs
+
+-- | Given a message and its chronological predecessor, return
+-- the thread state of the specified message with respect to its
+-- predecessor.
+threadStateFor :: Message
+               -- ^ The message whose state is to be obtained.
+               -> Message
+               -- ^ The message's predecessor.
+               -> ThreadState
+threadStateFor msg prev = case msg^.mInReplyToMsg of
+    InReplyTo rootId ->
+        if | (prev^.mMessageId) == Just (MessagePostId rootId) ->
+               InThread
+           | prev^.mInReplyToMsg == msg^.mInReplyToMsg ->
+               InThread
+           | otherwise ->
+               InThreadShowParent
+    _ -> NoThread
+
+retrogradeMsgsWithThreadStates :: RetrogradeMessages -> DirectionalSeq Retrograde (Message, ThreadState)
+retrogradeMsgsWithThreadStates msgs = DSeq $ checkAdjacentMessages (dseq msgs)
+    where
+        getMessagePredecessor ms =
+                let visiblePredMsg m = not (isTransition m || m^.mDeleted) in
+                case Seq.viewl ms of
+                    prev Seq.:< rest ->
+                        if visiblePredMsg prev
+                        then Just prev
+                        else getMessagePredecessor rest
+                    Seq.EmptyL -> Nothing
+
+        checkAdjacentMessages s = case Seq.viewl s of
+            Seq.EmptyL -> mempty
+            m Seq.:< t ->
+                let new_m = case getMessagePredecessor t of
+                        Just prev -> (m, threadStateFor m prev)
+                        Nothing -> case m^.mInReplyToMsg of
+                            InReplyTo _ -> (m, InThreadShowParent)
+                            _           -> (m, NoThread)
+                in new_m Seq.<| checkAdjacentMessages t
+
+chronologicalMsgsWithThreadStates :: Messages -> DirectionalSeq Chronological (Message, ThreadState)
+chronologicalMsgsWithThreadStates msgs = DSeq $ checkAdjacentMessages (dseq msgs)
+    where
+        getMessagePredecessor ms =
+                let visiblePredMsg m = not (isTransition m || m^.mDeleted) in
+                case Seq.viewr ms of
+                    rest Seq.:> prev ->
+                        if visiblePredMsg prev
+                        then Just prev
+                        else getMessagePredecessor rest
+                    Seq.EmptyR -> Nothing
+
+        checkAdjacentMessages s = case Seq.viewr s of
+            Seq.EmptyR -> mempty
+            t Seq.:> m ->
+                let new_m = case getMessagePredecessor t of
+                        Just prev -> (m, threadStateFor m prev)
+                        Nothing -> case m^.mInReplyToMsg of
+                            InReplyTo _ -> (m, InThreadShowParent)
+                            _           -> (m, NoThread)
+                in checkAdjacentMessages t Seq.|> new_m
+
+-- | findMessage searches for a specific message as identified by the
+-- PostId.  The search starts from the most recent messages because
+-- that is the most likely place the message will occur.
+findMessage :: MessageId -> Messages -> Maybe Message
+findMessage mid msgs =
+    findIndexR (\m -> m^.mMessageId == Just mid) (dseq msgs)
+    >>= Just . Seq.index (dseq msgs)
+
+-- | Look forward for the first Message with an ID that follows the
+-- specified Id and return it.  If no input Id supplied, get the
+-- latest (most recent chronologically) Message in the input set.
+getNextMessage :: Maybe MessageId -> Messages -> Maybe Message
+getNextMessage = getRelMessageId
+
+-- | Look backward for the first Message with an ID that follows the
+-- specified MessageId and return it.  If no input MessageId supplied,
+-- get the latest (most recent chronologically) Message in the input
+-- set.
+getPrevMessage :: Maybe MessageId -> Messages -> Maybe Message
+getPrevMessage mId = getRelMessageId mId . reverseMessages
+
+messagesHead :: (SeqDirection seq) => DirectionalSeq seq a -> Maybe a
+messagesHead = withDirSeqHead id
+
+messagesDrop :: (SeqDirection seq) => Int -> DirectionalSeq seq a -> DirectionalSeq seq a
+messagesDrop i = onDirectedSeq (Seq.drop i)
+
+-- | Look forward for the first Message with an ID that follows the
+-- specified MessageId and return that found Message's ID; if no input
+-- MessageId is specified, return the latest (most recent
+-- chronologically) MessageId (if any) in the input set.
+getNextMessageId :: Maybe MessageId -> Messages -> Maybe MessageId
+getNextMessageId mId = _mMessageId <=< getNextMessage mId
+
+-- | Look backwards for the first Message with an ID that comes before
+-- the specified MessageId and return that found Message's ID; if no
+-- input MessageId is specified, return the latest (most recent
+-- chronologically) MessageId (if any) in the input set.
+getPrevMessageId :: Maybe MessageId -> Messages -> Maybe MessageId
+getPrevMessageId mId = _mMessageId <=< getPrevMessage mId
+
+-- | Look forward for the first Message with an ID that follows the
+-- specified PostId and return that found Message's PostID; if no
+-- input PostId is specified, return the latest (most recent
+-- chronologically) PostId (if any) in the input set.
+getNextPostId :: Maybe PostId -> Messages -> Maybe PostId
+getNextPostId pid = messagePostId <=< getNextMessage (MessagePostId <$> pid)
+
+-- | Look backwards for the first Post with an ID that comes before
+-- the specified PostId.
+getPrevPostId :: Maybe PostId -> Messages -> Maybe PostId
+getPrevPostId pid = messagePostId <=< getPrevMessage (MessagePostId <$> pid)
+
+
+getRelMessageId :: SeqDirection dir =>
+                   Maybe MessageId
+                -> DirectionalSeq dir Message
+                -> Maybe Message
+getRelMessageId mId =
+  let isMId = const ((==) mId . _mMessageId) <$> mId
+  in getRelMessage isMId
+
+-- | Internal worker function to return a different user message in
+-- relation to either the latest point or a specific message.
+getRelMessage :: SeqDirection dir =>
+                 Maybe (Message -> Bool)
+              -> DirectionalSeq dir Message
+              -> Maybe Message
+getRelMessage matcher msgs =
+  let after = case matcher of
+                Just matchFun -> case splitDirSeqOn matchFun msgs of
+                                   (_, (_, ms)) -> ms
+                Nothing -> msgs
+  in withDirSeqHead id $ filterMessages validSelectableMessage after
+
+-- | Find the most recent message that is a Post (as opposed to a
+-- local message) (if any).
+getLatestPostMsg :: Messages -> Maybe Message
+getLatestPostMsg msgs =
+    case viewr $ dropWhileR (not . validUserMessage) (dseq msgs) of
+      EmptyR -> Nothing
+      _ :> m -> Just m
+
+-- | Find the oldest message that is a message with an ID.
+getEarliestSelectableMessage :: Messages -> Maybe Message
+getEarliestSelectableMessage msgs =
+    case viewl $ dropWhileL (not . validSelectableMessage) (dseq msgs) of
+      EmptyL -> Nothing
+      m :< _ -> Just m
+
+-- | Find the most recent message that is a message with an ID.
+getLatestSelectableMessage :: Messages -> Maybe Message
+getLatestSelectableMessage msgs =
+    case viewr $ dropWhileR (not . validSelectableMessage) (dseq msgs) of
+      EmptyR -> Nothing
+      _ :> m -> Just m
+
+-- | Find the earliest message that is a Post (as opposed to a
+-- local message) (if any).
+getEarliestPostMsg :: Messages -> Maybe Message
+getEarliestPostMsg msgs =
+    case viewl $ dropWhileL (not . validUserMessage) (dseq msgs) of
+      EmptyL -> Nothing
+      m :< _ -> Just m
+
+-- | Find the most recent message that is a message posted by a user
+-- that matches the test (if any), skipping local client messages and
+-- any user event that is not a message (i.e. find a normal message or
+-- an emote).
+findLatestUserMessage :: (Message -> Bool) -> Messages -> Maybe Message
+findLatestUserMessage f ml =
+    case viewr $ dropWhileR (\m -> not (validUserMessage m && f m)) $ dseq ml of
+      EmptyR -> Nothing
+      _ :> m -> Just m
+
+validUserMessage :: Message -> Bool
+validUserMessage m =
+    not (m^.mDeleted) && case m^.mMessageId of
+        Just (MessagePostId _) -> True
+        _ -> False
+
+validSelectableMessage :: Message -> Bool
+validSelectableMessage m = (not $ m^.mDeleted) && (isJust $ m^.mMessageId)
+
+-- ----------------------------------------------------------------------
+-- * Operations on any Message type
+
+-- | Return all messages that were posted after the specified date/time.
+messagesAfter :: ServerTime -> Messages -> Messages
+messagesAfter viewTime = onDirectedSeq $ takeWhileR (\m -> m^.mDate > viewTime)
+
+-- | Removes any Messages (all types) for which the predicate is true
+-- from the specified subset of messages (identified by a starting and
+-- ending MessageId, inclusive) and returns the resulting list (from
+-- start to finish, irrespective of 'firstId' and 'lastId') and the
+-- list of removed items.
+--
+--    start       | end          |  operates-on              | (test) case
+--   --------------------------------------------------------|-------------
+--   Nothing      | Nothing      | entire list               |  C1
+--   Nothing      | Just found   | start --> found]          |  C2
+--   Nothing      | Just missing | nothing [suggest invalid] |  C3
+--   Just found   | Nothing      | [found --> end            |  C4
+--   Just found   | Just found   | [found --> found]         |  C5
+--   Just found   | Just missing | [found --> end            |  C6
+--   Just missing | Nothing      | nothing [suggest invalid] |  C7
+--   Just missing | Just found   | start --> found]          |  C8
+--   Just missing | Just missing | nothing [suggest invalid] |  C9
+--
+--  @removeMatchesFromSubset matchPred fromId toId msgs = (remaining, removed)@
+--
+removeMatchesFromSubset :: (Message -> Bool) -> Maybe MessageId -> Maybe MessageId
+                        -> Messages -> (Messages, Messages)
+removeMatchesFromSubset matching firstId lastId msgs =
+    let knownIds = fmap (^.mMessageId) msgs
+    in if isNothing firstId && isNothing lastId
+       then swap $ dirSeqPartition matching msgs
+       else if isJust firstId && firstId `elem` knownIds
+            then onDirSeqSubset
+                (\m -> m^.mMessageId == firstId)
+                (if isJust lastId then \m -> m^.mMessageId == lastId else const False)
+                (swap . dirSeqPartition matching) msgs
+            else if isJust lastId && lastId `elem` knownIds
+                 then onDirSeqSubset
+                     (const True)
+                     (\m -> m^.mMessageId == lastId)
+                     (swap . dirSeqPartition matching) msgs
+                 else (msgs, noMessages)
+
+-- | Performs an operation on the first Message, returning just the
+-- result of that operation, or Nothing if there were no messages.
+-- Note that the message is not necessarily a posted user message.
+withFirstMessage :: SeqDirection dir
+                 => (Message -> r)
+                 -> DirectionalSeq dir Message
+                 -> Maybe r
+withFirstMessage = withDirSeqHead
+
+msgURLs :: Message -> Seq LinkChoice
+msgURLs msg =
+  let uRef = msg^.mUser
+      mkTarget (Right url) = LinkURL url
+      mkTarget (Left (tName, pId)) = LinkPermalink tName pId
+      mkEntry (val, text) = LinkChoice (msg^.mDate) uRef text (mkTarget val)
+      msgUrls = mkEntry <$> (Seq.fromList $ mconcat $ blockGetURLs <$> (toList $ msg^.mText))
+      attachmentURLs = (\ a ->
+                          LinkChoice
+                            (msg^.mDate)
+                            uRef
+                            (Just $ attachmentLabel a)
+                            (LinkFileId $ a^.attachmentFileId))
+                       <$> (msg^.mAttachments)
+      attachmentLabel a =
+          Seq.fromList [ Element Normal $ EText "attachment"
+                       , Element Normal ESpace
+                       , Element Code $ EText $ a^.attachmentName
+                       ]
+  in msgUrls <> attachmentURLs
diff --git a/src/Matterhorn/Types/Posts.hs b/src/Matterhorn/Types/Posts.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Types/Posts.hs
@@ -0,0 +1,231 @@
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE TemplateHaskell #-}
+module Matterhorn.Types.Posts
+  ( ClientMessage
+  , newClientMessage
+  , cmDate
+  , cmType
+  , cmText
+
+  , ClientMessageType(..)
+
+  , Attachment
+  , mkAttachment
+  , attachmentName
+  , attachmentFileId
+  , attachmentURL
+
+  , ClientPostType(..)
+
+  , ClientPost
+  , toClientPost
+  , cpUserOverride
+  , cpMarkdownSource
+  , cpUser
+  , cpText
+  , cpType
+  , cpReactions
+  , cpPending
+  , cpOriginalPost
+  , cpFromWebhook
+  , cpInReplyToPost
+  , cpDate
+  , cpChannelId
+  , cpAttachments
+  , cpDeleted
+  , cpPostId
+  , cpPinned
+
+  , unEmote
+
+  , postIsLeave
+  , postIsJoin
+  , postIsTopicChange
+  , postIsEmote
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.Map.Strict as Map
+import qualified Data.Sequence as Seq
+import qualified Data.Text as T
+import qualified Data.Set as S
+import           Data.Time.Clock ( getCurrentTime )
+import           Lens.Micro.Platform ( makeLenses )
+
+import           Network.Mattermost.Lenses
+import           Network.Mattermost.Types
+
+import           Matterhorn.Types.Common
+import           Matterhorn.Types.RichText ( RichTextBlock(Blockquote), parseMarkdown
+                                           , TeamBaseURL
+                                           )
+
+
+-- * Client Messages
+
+-- | A 'ClientMessage' is a message given to us by our client,
+--   like help text or an error message.
+data ClientMessage = ClientMessage
+  { _cmText :: Text
+  , _cmDate :: ServerTime
+  , _cmType :: ClientMessageType
+  } deriving (Show)
+
+-- | Create a new 'ClientMessage' value.  This is a message generated
+-- by this Matterhorn client and not by (or visible to) the Server.
+-- These should be visible, but not necessarily integrated into any
+-- special position in the output stream (i.e., they should generally
+-- appear at the bottom of the messages display, but subsequent
+-- messages should follow them), so this is a special place where
+-- there is an assumed approximation of equality between local time
+-- and server time.
+newClientMessage :: (MonadIO m) => ClientMessageType -> Text -> m ClientMessage
+newClientMessage ty msg = do
+  now <- liftIO getCurrentTime
+  return (ClientMessage msg (ServerTime now) ty)
+
+-- | We format 'ClientMessage' values differently depending on
+--   their 'ClientMessageType'
+data ClientMessageType =
+    Informative
+    | Error
+    | DateTransition
+    | NewMessagesTransition
+    | UnknownGapBefore -- ^ a region where the server may have
+                       -- messages before the given timestamp that are
+                       -- not known locally by this client
+    | UnknownGapAfter  -- ^ a region where server may have messages
+                       -- after the given timestamp that are not known
+                       -- locally by this client
+    deriving (Show)
+
+-- ** 'ClientMessage' Lenses
+
+makeLenses ''ClientMessage
+
+-- * Mattermost Posts
+
+-- | A 'ClientPost' is a temporary internal representation of
+--   the Mattermost 'Post' type, with unnecessary information
+--   removed and some preprocessing done.
+data ClientPost = ClientPost
+  { _cpText          :: Seq RichTextBlock
+  , _cpMarkdownSource :: Text
+  , _cpUser          :: Maybe UserId
+  , _cpUserOverride  :: Maybe Text
+  , _cpDate          :: ServerTime
+  , _cpType          :: ClientPostType
+  , _cpPending       :: Bool
+  , _cpDeleted       :: Bool
+  , _cpAttachments   :: Seq Attachment
+  , _cpInReplyToPost :: Maybe PostId
+  , _cpPostId        :: PostId
+  , _cpChannelId     :: ChannelId
+  , _cpReactions     :: Map.Map Text (S.Set UserId)
+  , _cpOriginalPost  :: Post
+  , _cpFromWebhook   :: Bool
+  , _cpPinned        :: Bool
+  } deriving (Show)
+
+-- | An attachment has a very long URL associated, as well as
+--   an actual file URL
+data Attachment = Attachment
+  { _attachmentName   :: Text
+  , _attachmentURL    :: Text
+  , _attachmentFileId :: FileId
+  } deriving (Eq, Show)
+
+mkAttachment :: Text -> Text -> FileId -> Attachment
+mkAttachment = Attachment
+
+-- | A Mattermost 'Post' value can represent either a normal
+--   chat message or one of several special events.
+data ClientPostType =
+    NormalPost
+    | Emote
+    | Join
+    | Leave
+    | TopicChange
+    deriving (Eq, Show)
+
+-- ** Creating 'ClientPost' Values
+
+-- | Determine the internal 'PostType' based on a 'Post'
+postClientPostType :: Post -> ClientPostType
+postClientPostType cp =
+    if | postIsEmote cp       -> Emote
+       | postIsJoin  cp       -> Join
+       | postIsLeave cp       -> Leave
+       | postIsTopicChange cp -> TopicChange
+       | otherwise            -> NormalPost
+
+-- | Find out whether a 'Post' represents a topic change
+postIsTopicChange :: Post -> Bool
+postIsTopicChange p = postType p == PostTypeHeaderChange
+
+-- | Find out whether a 'Post' is from a @/me@ command
+postIsEmote :: Post -> Bool
+postIsEmote p =
+    and [ p^.postPropsL.postPropsOverrideIconUrlL == Just (""::Text)
+        , ("*" `T.isPrefixOf` (sanitizeUserText $ postMessage p))
+        , ("*" `T.isSuffixOf` (sanitizeUserText $ postMessage p))
+        ]
+
+-- | Find out whether a 'Post' is a user joining a channel
+postIsJoin :: Post -> Bool
+postIsJoin p =
+  p^.postTypeL == PostTypeJoinChannel
+
+-- | Find out whether a 'Post' is a user leaving a channel
+postIsLeave :: Post -> Bool
+postIsLeave p =
+  p^.postTypeL == PostTypeLeaveChannel
+
+-- | Undo the automatic formatting of posts generated by @/me@-commands
+unEmote :: ClientPostType -> Text -> Text
+unEmote Emote t = if "*" `T.isPrefixOf` t && "*" `T.isSuffixOf` t
+                  then T.init $ T.tail t
+                  else t
+unEmote _ t = t
+
+-- | Convert a Mattermost 'Post' to a 'ClientPost', passing in a
+--   'ParentId' if it has a known one.
+toClientPost :: TeamBaseURL -> Post -> Maybe PostId -> ClientPost
+toClientPost baseUrl p parentId =
+  let src = unEmote (postClientPostType p) $ sanitizeUserText $ postMessage p
+  in ClientPost { _cpText          = parseMarkdown (Just baseUrl) src <> getAttachmentText p
+                , _cpMarkdownSource = src
+                , _cpUser          = postUserId p
+                , _cpUserOverride  = p^.postPropsL.postPropsOverrideUsernameL
+                , _cpDate          = postCreateAt p
+                , _cpType          = postClientPostType p
+                , _cpPending       = False
+                , _cpDeleted       = False
+                , _cpPinned        = fromMaybe False $ postPinned p
+                , _cpAttachments   = Seq.empty
+                , _cpInReplyToPost = parentId
+                , _cpPostId        = p^.postIdL
+                , _cpChannelId     = p^.postChannelIdL
+                , _cpReactions     = Map.empty
+                , _cpOriginalPost  = p
+                , _cpFromWebhook   = fromMaybe False $ p^.postPropsL.postPropsFromWebhookL
+                }
+
+-- | Right now, instead of treating 'attachment' properties specially, we're
+--   just going to roll them directly into the message text
+getAttachmentText :: Post -> Seq RichTextBlock
+getAttachmentText p =
+  case p^.postPropsL.postPropsAttachmentsL of
+    Nothing -> Seq.empty
+    Just attachments ->
+      fmap (Blockquote . render) attachments
+  where render att = parseMarkdown Nothing (att^.ppaTextL) <>
+                     parseMarkdown Nothing (att^.ppaFallbackL)
+
+-- ** 'ClientPost' Lenses
+
+makeLenses ''Attachment
+makeLenses ''ClientPost
diff --git a/src/Matterhorn/Types/RichText.hs b/src/Matterhorn/Types/RichText.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Types/RichText.hs
@@ -0,0 +1,486 @@
+-- | This module provides a set of data types to represent message text.
+-- The types here are based loosely on the @cheapskate@ package's types
+-- but provide higher-level support for the kinds of things we find in
+-- Mattermost messages such as user and channel references.
+--
+-- To parse a Markdown document, use 'parseMarkdown'. To actually render
+-- text in this representation, see the module 'Draw.RichText'.
+module Matterhorn.Types.RichText
+  ( RichTextBlock(..)
+  , ListType(..)
+  , CodeBlockInfo(..)
+  , NumDecoration(..)
+  , Element(..)
+  , ElementData(..)
+  , ElementStyle(..)
+  , TeamBaseURL(..)
+  , TeamURLName(..)
+
+  , URL(..)
+  , unURL
+
+  , parseMarkdown
+  , setElementStyle
+
+  , findUsernames
+  , blockGetURLs
+  , findVerbatimChunk
+
+  -- Exposed for testing only:
+  , fromMarkdownBlocks
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Cheapskate as C
+import           Data.Char ( isAlphaNum, isAlpha )
+import qualified Data.Foldable as F
+import           Data.Monoid (First(..))
+import qualified Data.Set as S
+import qualified Data.Sequence as Seq
+import           Data.Sequence ( (<|), ViewL((:<)) )
+import qualified Data.Text as T
+
+import           Network.Mattermost.Types ( PostId(..), Id(..), ServerBaseURL(..) )
+
+import           Matterhorn.Constants ( userSigil, normalChannelSigil )
+
+-- | A team name found in a Mattermost post URL
+data TeamURLName = TeamURLName Text
+                 deriving (Eq, Show, Ord)
+
+-- | A server base URL with a team name.
+data TeamBaseURL = TeamBaseURL TeamURLName ServerBaseURL
+                 deriving (Eq, Show)
+
+-- | A block in a rich text document.
+data RichTextBlock =
+    Para (Seq Element)
+    -- ^ A paragraph.
+    | Header Int (Seq Element)
+    -- ^ A section header with specified depth and contents.
+    | Blockquote (Seq RichTextBlock)
+    -- ^ A blockquote.
+    | List Bool ListType (Seq (Seq RichTextBlock))
+    -- ^ An itemized list.
+    | CodeBlock CodeBlockInfo Text
+    -- ^ A code block.
+    | HTMLBlock Text
+    -- ^ A fragment of raw HTML.
+    | HRule
+    -- ^ A horizontal rule.
+    deriving (Show)
+
+-- | The type of itemized list items.
+data ListType =
+    Bullet Char
+    -- ^ Decorate the items with bullet using the specified character.
+    | Numbered NumDecoration Int
+    -- ^ Number the items starting at the specified number; use the
+    -- indicated decoration following the number.
+    deriving (Eq, Show, Ord)
+
+-- | Information about a code block.
+data CodeBlockInfo =
+    CodeBlockInfo { codeBlockLanguage :: Maybe Text
+                  -- ^ The language of the source code in the code
+                  -- block, if any. This is encoded in Markdown as a
+                  -- sequence of non-whitespace characters following the
+                  -- fenced code block opening backticks.
+                  , codeBlockInfo :: Maybe Text
+                  -- ^ Any text that comes after the language token.
+                  -- This text is separated from the language token by
+                  -- whitespace.
+                  }
+                  deriving (Eq, Show, Ord)
+
+-- | Ways to decorate numbered itemized list items. The decoration
+-- follows the list item number.
+data NumDecoration =
+    Paren
+    | Period
+    deriving (Eq, Show, Ord)
+
+-- | A single logical inline element in a rich text block.
+data Element =
+    Element { eStyle :: ElementStyle
+            -- ^ The element's visual style.
+            , eData :: ElementData
+            -- ^ The element's data.
+            }
+            deriving (Show, Eq, Ord)
+
+setElementStyle :: ElementStyle -> Element -> Element
+setElementStyle s e = e { eStyle = s }
+
+-- | A URL.
+newtype URL = URL Text
+            deriving (Eq, Show, Ord)
+
+unURL :: URL -> Text
+unURL (URL url) = url
+
+-- | The kinds of data that can appear in rich text elements.
+data ElementData =
+    EText Text
+    -- ^ A sequence of non-whitespace characters.
+    | ESpace
+    -- ^ A single space.
+    | ESoftBreak
+    -- ^ A soft line break.
+    | ELineBreak
+    -- ^ A hard line break.
+    | ERawHtml Text
+    -- ^ Raw HTML.
+    | EEditSentinel Bool
+    -- ^ A sentinel indicating that some text has been edited (used
+    -- to indicate that mattermost messages have been edited by their
+    -- authors). This has no parsable representation; it is only used
+    -- to annotate a message prior to rendering to add a visual editing
+    -- indicator. The boolean indicates whether the edit was "recent"
+    -- (True) or not (False).
+    | EUser Text
+    -- ^ A user reference. The text here includes only the username, not
+    -- the sigil.
+    | EChannel Text
+    -- ^ A channel reference. The text here includes only the channel
+    -- name, not the sigil.
+    | EHyperlink URL (Maybe (Seq Element))
+    -- ^ A hyperlink to the specified URL. Optionally provides an
+    -- element sequence indicating the URL's text label; if absent, the
+    -- label is understood to be the URL itself.
+    | EImage URL (Maybe (Seq Element))
+    -- ^ An image at the specified URL. Optionally provides an element
+    -- sequence indicating the image's "alt" text label; if absent, the
+    -- label is understood to be the URL itself.
+    | EEmoji Text
+    -- ^ An emoji reference. The text here includes only the text
+    -- portion, not the colons, e.g. "foo" instead of ":foo:".
+    | ENonBreaking (Seq Element)
+    -- ^ A sequence of elements that must never be separated during line
+    -- wrapping.
+    | EPermalink TeamURLName PostId (Maybe (Seq Element))
+    -- ^ A permalink to the specified team (name) and post ID with an
+    -- optional label.
+    deriving (Show, Eq, Ord)
+
+-- | Element visual styles.
+data ElementStyle =
+    Normal
+    -- ^ Normal text
+    | Emph
+    -- ^ Emphasized text
+    | Strikethrough
+    -- ^ Strikethrough text
+    | Strong
+    -- ^ Bold text
+    | Code
+    -- ^ Inline code segment or code block
+    | Hyperlink URL ElementStyle
+    -- ^ A terminal hyperlink to the specified URL, composed with
+    -- another element style
+    | Permalink
+    -- ^ A post permalink
+    deriving (Eq, Show, Ord)
+
+parseMarkdown :: Maybe TeamBaseURL -> T.Text -> Seq RichTextBlock
+parseMarkdown baseUrl t =
+    fromMarkdownBlocks baseUrl bs where C.Doc _ bs = C.markdown C.def t
+
+-- | Convert a sequence of markdown (Cheapskate) blocks into rich text
+-- blocks.
+fromMarkdownBlocks :: Maybe TeamBaseURL -> C.Blocks -> Seq RichTextBlock
+fromMarkdownBlocks baseUrl = fmap (fromMarkdownBlock baseUrl)
+
+-- | Convert a single markdown block into a single rich text block.
+fromMarkdownBlock :: Maybe TeamBaseURL -> C.Block -> RichTextBlock
+fromMarkdownBlock baseUrl (C.Para is) =
+    Para $ fromMarkdownInlines baseUrl is
+fromMarkdownBlock baseUrl (C.Header level is) =
+    Header level $ fromMarkdownInlines baseUrl is
+fromMarkdownBlock baseUrl (C.Blockquote bs) =
+    Blockquote $ fromMarkdownBlock baseUrl <$> bs
+fromMarkdownBlock baseUrl (C.List f ty bss) =
+    List f (fromMarkdownListType ty) $ fmap (fromMarkdownBlock baseUrl) <$> Seq.fromList bss
+fromMarkdownBlock _ (C.CodeBlock attr body) =
+    CodeBlock (fromMarkdownCodeAttr attr) body
+fromMarkdownBlock _ (C.HtmlBlock body) =
+    HTMLBlock body
+fromMarkdownBlock _ C.HRule =
+    HRule
+
+fromMarkdownCodeAttr :: C.CodeAttr -> CodeBlockInfo
+fromMarkdownCodeAttr (C.CodeAttr lang info) =
+    let strippedLang = T.strip lang
+        strippedInfo = T.strip info
+        maybeText t = if T.null t then Nothing else Just t
+    in CodeBlockInfo (maybeText strippedLang)
+                     (maybeText strippedInfo)
+
+fromMarkdownListType :: C.ListType -> ListType
+fromMarkdownListType (C.Bullet c) =
+    Bullet c
+fromMarkdownListType (C.Numbered wrap i) =
+    let dec = case wrap of
+                  C.PeriodFollowing -> Period
+                  C.ParenFollowing -> Paren
+    in Numbered dec i
+
+-- | Remove hyperlinks from an inline sequence. This should only be used
+-- for sequences that are themselves used as labels for hyperlinks; this
+-- prevents them from embedding their own hyperlinks, which is nonsense.
+--
+-- Any hyperlinks found in the sequence will be replaced with a text
+-- represent of their URL (if they have no label) or the label itself
+-- otherwise.
+removeHyperlinks :: Seq C.Inline -> Seq C.Inline
+removeHyperlinks is =
+    case Seq.viewl is of
+        h :< t ->
+            case h of
+                C.Link label theUrl _ ->
+                    if Seq.null label
+                    then C.Str theUrl <| removeHyperlinks t
+                    else removeHyperlinks label <> removeHyperlinks t
+                _ -> h <| removeHyperlinks t
+        Seq.EmptyL -> mempty
+
+-- | Convert a sequence of Markdown inline values to a sequence of
+-- Elements.
+--
+-- This conversion converts sequences of Markdown AST fragments into
+-- RichText elements. In particular, this function may determine that
+-- some sequences of Markdown text fragments belong together, such as
+-- the sequence @["@", "joe", "-", "user"]@, which should be treated as
+-- a single "@joe-user" token due to username character validation. When
+-- appropriate, this function does such consolidation when converting to
+-- RichText elements.
+--
+-- This function is also partially responsible for paving the way for
+-- line-wrapping later on when RichText is rendered. This means that,
+-- when possible, the elements produced by this function need to be as
+-- small as possible, without losing structure information. An example
+-- of this is Markdown inline code fragments, such as "`this is code`".
+-- This function will convert that one Markdown inline code fragment
+-- into a sequence of five RichText elements, each with a "Code" style
+-- assigned: @[EText "this", ESpace, EText "is", ESpace, EText "code"]@.
+-- This "flattening" of the original Markdown structure makes it much
+-- easier to do line-wrapping without losing style information because
+-- it is possible to gather up tokens that do not exceed line widths
+-- without losing style information. This is key to rendering the text
+-- with the right terminal attributes.
+--
+-- However, there are a couple of cases where such flattening does *not*
+-- happen: hyperlinks and images. In these cases we do not flatten the
+-- (arbitrary) text label structure of links and images because we need
+-- to be able to recover those labels to gather up URLs to show to the
+-- user in the URL list. So we leave the complete text structure of
+-- those labels behind in the 'EHyperlink' and 'EImage' constructors as
+-- sequences of Elements. This means that logic in 'Draw.RichText' that
+-- does line-wrapping will have to explicitly break up link and image
+-- labels across line breaks.
+fromMarkdownInlines :: Maybe TeamBaseURL -> Seq C.Inline -> Seq Element
+fromMarkdownInlines baseUrl inlines =
+    let go sty is = case Seq.viewl is of
+          C.Str "~" :< xs ->
+              case Seq.viewl xs of
+                  C.Str "~" :< xs2 ->
+                      case takeUntilStrikethroughEnd xs2 of
+                          Nothing -> Element sty (EText "~") <|
+                                     go sty xs
+                          Just (strikethroughInlines, rest) ->
+                              go Strikethrough strikethroughInlines <>
+                              go sty rest
+                  _ ->
+                      let (cFrags, rest) = Seq.spanl isNameFragment xs
+                          cn = T.concat (unsafeGetStr <$> F.toList cFrags)
+                      in if not (T.null cn)
+                         then Element sty (EChannel cn) <| go sty rest
+                         else Element sty (EText normalChannelSigil) <| go sty xs
+          C.Str ":" :< xs ->
+              let validEmojiFragment (C.Str f) =
+                      f `elem` ["_", "-"] || T.all isAlphaNum f
+                  validEmojiFragment _ = False
+                  (emojiFrags, rest) = Seq.spanl validEmojiFragment xs
+                  em = T.concat $ unsafeGetStr <$> F.toList emojiFrags
+              in case Seq.viewl rest of
+                  C.Str ":" :< rest2 ->
+                      Element Normal (EEmoji em) <| go sty rest2
+                  _ ->
+                      Element sty (EText ":") <| go sty xs
+          C.Str t :< xs | userSigil `T.isPrefixOf` t ->
+              let (uFrags, rest) = Seq.spanl isNameFragment xs
+                  t' = T.concat $ t : (unsafeGetStr <$> F.toList uFrags)
+                  u = T.drop 1 t'
+              in Element sty (EUser u) <| go sty rest
+          C.Str t :< xs ->
+              -- When we encounter a string node, we go ahead and
+              -- process the rest of the nodes in the sequence. If the
+              -- new sequence starts with *another* string node with
+              -- the same style, we merge them. We do this because
+              -- Cheapskate will parse things like punctuation as
+              -- individual string nodes, but we want to keep those
+              -- together with any text is adjacent to them to avoid
+              -- e.g. breaking up quotes from quoted text when doing
+              -- line-wrapping. It's important to make sure we only do
+              -- this for adjacent string (i.e. not whitespace) nodes
+              -- and that we make sure we only merge when they have the
+              -- same style.
+              let rest = go sty xs
+                  e = Element sty (EText t)
+              in case Seq.viewl rest of
+                  Element sty2 (EText t2) :< tail_ | sty2 == sty ->
+                      (Element sty (EText (t <> t2))) <| tail_
+                  _ ->
+                      e <| rest
+          C.Space :< xs ->
+              Element sty ESpace <| go sty xs
+          C.SoftBreak :< xs ->
+              Element sty ESoftBreak <| go sty xs
+          C.LineBreak :< xs ->
+              Element sty ELineBreak <| go sty xs
+          C.Link label theUrl _ :< xs ->
+              let mLabel = if Seq.null label
+                           then Nothing
+                           else case F.toList label of
+                               [C.Str u] | u == theUrl -> Nothing
+                               _ -> Just $ fromMarkdownInlines baseUrl $ removeHyperlinks label
+                  rest = go sty xs
+                  this = case flip getPermalink theUrl =<< baseUrl of
+                      Nothing ->
+                          let url = URL theUrl
+                          in Element (Hyperlink url sty) $ EHyperlink url mLabel
+                      Just (tName, pId) ->
+                          Element Permalink $ EPermalink tName pId mLabel
+              in this <| rest
+          C.Image altIs theUrl _ :< xs ->
+              let mLabel = if Seq.null altIs
+                           then Nothing
+                           else Just $ fromMarkdownInlines baseUrl altIs
+                  url = URL theUrl
+              in (Element (Hyperlink url sty) $ EImage url mLabel) <| go sty xs
+          C.RawHtml t :< xs ->
+              Element sty (ERawHtml t) <| go sty xs
+          C.Code t :< xs ->
+              -- We turn a single code string into individual Elements
+              -- so we can perform line-wrapping on the inline code
+              -- text.
+              let ts = [ Element Code frag
+                       | wd <- T.split (== ' ') t
+                       , frag <- case wd of
+                           "" -> [ESpace]
+                           _  -> [ESpace, EText wd]
+                       ]
+                  ts' = case ts of
+                    (Element _ ESpace:rs) -> rs
+                    _                     -> ts
+              in Seq.fromList ts' <> go sty xs
+          C.Emph as :< xs ->
+              go Emph as <> go sty xs
+          C.Strong as :< xs ->
+              go Strong as <> go sty xs
+          C.Entity t :< xs ->
+              Element sty (EText t) <| go sty xs
+          Seq.EmptyL -> mempty
+
+    in go Normal inlines
+
+takeUntilStrikethroughEnd :: Seq C.Inline -> Maybe (Seq C.Inline, Seq C.Inline)
+takeUntilStrikethroughEnd is =
+    let go pos s = case Seq.viewl s of
+            C.Str "~" :< rest ->
+                case Seq.viewl rest of
+                    C.Str "~" :< _ ->
+                        Just pos
+                    _ -> go (pos + 1) rest
+            _ :< rest -> go (pos + 1) rest
+            Seq.EmptyL -> Nothing
+    in do
+        pos <- go 0 is
+        let (h, t) = Seq.splitAt pos is
+        return (h, Seq.drop 2 t)
+
+-- | If the specified URL matches the active server base URL and team
+-- and refers to a post, extract the team name and post ID values and
+-- return them.
+getPermalink :: TeamBaseURL -> Text -> Maybe (TeamURLName, PostId)
+getPermalink (TeamBaseURL tName (ServerBaseURL baseUrl)) url =
+    let newBaseUrl = if "/" `T.isSuffixOf` baseUrl
+                     then baseUrl
+                     else baseUrl <> "/"
+    in if not $ newBaseUrl `T.isPrefixOf` url
+       then Nothing
+       else let rest = T.drop (T.length newBaseUrl) url
+                (tName', rawPIdStr) = T.breakOn "/pl/" rest
+                pIdStr = T.drop 4 rawPIdStr
+            in if tName == TeamURLName tName' && not (T.null pIdStr)
+               then Just (tName, PI $ Id pIdStr)
+               else Nothing
+
+
+unsafeGetStr :: C.Inline -> Text
+unsafeGetStr (C.Str t) = t
+unsafeGetStr _ = error "BUG: unsafeGetStr called on non-Str Inline"
+
+-- | Obtain all username references in a sequence of rich text blocks.
+findUsernames :: Seq RichTextBlock -> S.Set T.Text
+findUsernames = S.unions . F.toList . fmap blockFindUsernames
+
+blockFindUsernames :: RichTextBlock -> S.Set T.Text
+blockFindUsernames (Para is) =
+    elementFindUsernames $ F.toList is
+blockFindUsernames (Header _ is) =
+    elementFindUsernames $ F.toList is
+blockFindUsernames (Blockquote bs) =
+    findUsernames bs
+blockFindUsernames (List _ _ bs) =
+    S.unions $ F.toList $ findUsernames <$> bs
+blockFindUsernames _ =
+    mempty
+
+elementFindUsernames :: [Element] -> S.Set T.Text
+elementFindUsernames [] = mempty
+elementFindUsernames (e : es) =
+    case eData e of
+        EUser u -> S.insert u $ elementFindUsernames es
+        _ -> elementFindUsernames es
+
+-- | Obtain all URLs (and optional labels) in a rich text block.
+blockGetURLs :: RichTextBlock -> [(Either (TeamURLName, PostId) URL, Maybe (Seq Element))]
+blockGetURLs (Para is) =
+    catMaybes $ elementGetURL <$> toList is
+blockGetURLs (Header _ is) =
+    catMaybes $ elementGetURL <$> toList is
+blockGetURLs (Blockquote bs) =
+    mconcat $ blockGetURLs <$> toList bs
+blockGetURLs (List _ _ bss) =
+    mconcat $ mconcat $
+    (fmap blockGetURLs . F.toList) <$> F.toList bss
+blockGetURLs _ =
+    mempty
+
+elementGetURL :: Element -> Maybe (Either (TeamURLName, PostId) URL, Maybe (Seq Element))
+elementGetURL (Element _ (EHyperlink url label)) =
+    Just (Right url, label)
+elementGetURL (Element _ (EImage url label)) =
+    Just (Right url, label)
+elementGetURL (Element _ (EPermalink tName pId label)) =
+    Just (Left (tName, pId), label)
+elementGetURL _ =
+    Nothing
+
+-- | Find the first code block in a sequence of rich text blocks.
+findVerbatimChunk :: Seq RichTextBlock -> Maybe Text
+findVerbatimChunk = getFirst . F.foldMap go
+  where go (CodeBlock _ t) = First (Just t)
+        go _               = First Nothing
+
+isValidNameChar :: Char -> Bool
+isValidNameChar c = isAlpha c || c == '_' || c == '.' || c == '-'
+
+isNameFragment :: C.Inline -> Bool
+isNameFragment (C.Str t) =
+    not (T.null t) && isValidNameChar (T.head t)
+isNameFragment _ = False
diff --git a/src/Matterhorn/Types/Users.hs b/src/Matterhorn/Types/Users.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Types/Users.hs
@@ -0,0 +1,196 @@
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE DeriveFunctor #-}
+
+module Matterhorn.Types.Users
+  ( UserInfo(..)
+  , UserStatus(..)
+  , Users   -- constructor remains internal
+  -- * Lenses created for accessing UserInfo fields
+  , uiName, uiId, uiStatus, uiInTeam, uiNickName, uiFirstName, uiLastName, uiEmail
+  , uiDeleted
+  -- * Various operations on UserInfo
+  -- * Creating UserInfo objects
+  , userInfoFromUser
+  -- * Miscellaneous
+  , getUsernameSet
+  , trimUserSigil
+  , statusFromText
+  , findUserById
+  , findUserByUsername
+  , findUserByNickname
+  , noUsers, addUser, allUsers
+  , modifyUserById
+  , userDeleted
+  , TypingUsers
+  , noTypingUsers
+  , addTypingUser
+  , allTypingUsers
+  , expireTypingUsers
+  , getAllUserIds
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.HashMap.Strict as HM
+import qualified Data.Set as S
+import           Data.Semigroup ( Max(..) )
+import qualified Data.Text as T
+import           Lens.Micro.Platform ( (%~), makeLenses, ix )
+
+import           Network.Mattermost.Types ( UserId(..), User(..) )
+
+import           Matterhorn.Types.Common
+import           Matterhorn.Constants ( userSigil )
+
+-- * 'UserInfo' Values
+
+-- | A 'UserInfo' value represents everything we need to know at
+--   runtime about a user
+data UserInfo = UserInfo
+  { _uiName      :: Text
+  , _uiId        :: UserId
+  , _uiStatus    :: UserStatus
+  , _uiInTeam    :: Bool
+  , _uiNickName  :: Maybe Text
+  , _uiFirstName :: Text
+  , _uiLastName  :: Text
+  , _uiEmail     :: Text
+  , _uiDeleted   :: Bool
+  } deriving (Eq, Show)
+
+-- | Is this user deleted?
+userDeleted :: User -> Bool
+userDeleted u =
+    case userCreateAt u of
+        Nothing -> False
+        Just c -> userDeleteAt u > c
+
+-- | Create a 'UserInfo' value from a Mattermost 'User' value
+userInfoFromUser :: User -> Bool -> UserInfo
+userInfoFromUser up inTeam = UserInfo
+  { _uiName      = userUsername up
+  , _uiId        = userId up
+  , _uiStatus    = Offline
+  , _uiInTeam    = inTeam
+  , _uiNickName  =
+      let nick = sanitizeUserText $ userNickname up
+      in if T.null nick then Nothing else Just nick
+  , _uiFirstName = sanitizeUserText $ userFirstName up
+  , _uiLastName  = sanitizeUserText $ userLastName up
+  , _uiEmail     = sanitizeUserText $ userEmail up
+  , _uiDeleted   = userDeleted up
+  }
+
+-- | The 'UserStatus' value represents possible current status for
+--   a user
+data UserStatus
+  = Online
+  | Away
+  | Offline
+  | DoNotDisturb
+  | Other Text
+    deriving (Eq, Show)
+
+statusFromText :: Text -> UserStatus
+statusFromText t = case t of
+  "online"  -> Online
+  "offline" -> Offline
+  "away"    -> Away
+  "dnd"     -> DoNotDisturb
+  _         -> Other t
+
+-- ** 'UserInfo' lenses
+
+makeLenses ''UserInfo
+
+-- ** Manage the collection of all Users
+
+-- | Define a binary kinded type to allow derivation of functor.
+data AllMyUsers a =
+    AllUsers { _ofUsers :: HashMap UserId a
+             , _usernameSet :: S.Set Text
+             }
+             deriving Functor
+
+makeLenses ''AllMyUsers
+
+-- | Define the exported typename which universally binds the
+-- collection to the UserInfo type.
+type Users = AllMyUsers UserInfo
+
+getUsernameSet :: Users -> S.Set Text
+getUsernameSet = _usernameSet
+
+-- | Initial collection of Users with no members
+noUsers :: Users
+noUsers = AllUsers HM.empty mempty
+
+getAllUserIds :: Users -> [UserId]
+getAllUserIds = HM.keys . _ofUsers
+
+-- | Add a member to the existing collection of Users
+addUser :: UserInfo -> Users -> Users
+addUser userinfo u =
+    u & ofUsers %~ HM.insert (userinfo^.uiId) userinfo
+      & usernameSet %~ S.insert (userinfo^.uiName)
+
+-- | Get a list of all known users
+allUsers :: Users -> [UserInfo]
+allUsers = HM.elems . _ofUsers
+
+-- | Define the exported typename to represent the collection of users
+-- | who are currently typing. The values kept against the user id keys are the
+-- | latest timestamps of typing events from the server.
+type TypingUsers = AllMyUsers (Max UTCTime)
+
+-- | Initial collection of TypingUsers with no members
+noTypingUsers :: TypingUsers
+noTypingUsers = AllUsers HM.empty mempty
+
+-- | Add a member to the existing collection of TypingUsers
+addTypingUser :: UserId -> UTCTime -> TypingUsers -> TypingUsers
+addTypingUser uId ts = ofUsers %~ HM.insertWith (<>) uId (Max ts)
+
+-- | Get a list of all typing users
+allTypingUsers :: TypingUsers -> [UserId]
+allTypingUsers = HM.keys . _ofUsers
+
+-- | Remove all the expired users from the collection of TypingUsers.
+-- | Expiry is decided by the given timestamp.
+expireTypingUsers :: UTCTime -> TypingUsers -> TypingUsers
+expireTypingUsers expiryTimestamp =
+    ofUsers %~ HM.filter (\(Max ts') -> ts' >= expiryTimestamp)
+
+-- | Get the User information given the UserId
+findUserById :: UserId -> Users -> Maybe UserInfo
+findUserById uId = HM.lookup uId . _ofUsers
+
+-- | Get the User information given the user's name. This is an exact
+-- match on the username field. It will automatically trim a user sigil
+-- from the input.
+findUserByUsername :: Text -> Users -> Maybe (UserId, UserInfo)
+findUserByUsername name allusers =
+  case filter ((== trimUserSigil name) . _uiName . snd) $ HM.toList $ _ofUsers allusers of
+    (usr : []) -> Just usr
+    _ -> Nothing
+
+-- | Get the User information given the user's name. This is an exact
+-- match on the nickname field, not necessarily the presented name. It
+-- will automatically trim a user sigil from the input.
+findUserByNickname:: Text -> Users -> Maybe (UserId, UserInfo)
+findUserByNickname nick us =
+  case filter ((== (Just $ trimUserSigil nick)) . _uiNickName . snd) $ HM.toList $ _ofUsers us of
+    (pair : []) -> Just pair
+    _ -> Nothing
+
+trimUserSigil :: Text -> Text
+trimUserSigil n
+    | userSigil `T.isPrefixOf` n = T.tail n
+    | otherwise                  = n
+
+-- | Extract a specific user from the collection and perform an
+-- endomorphism operation on it, then put it back into the collection.
+modifyUserById :: UserId -> (UserInfo -> UserInfo) -> Users -> Users
+modifyUserById uId f = ofUsers.ix(uId) %~ f
diff --git a/src/Matterhorn/Util.hs b/src/Matterhorn/Util.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Util.hs
@@ -0,0 +1,25 @@
+module Matterhorn.Util
+  ( nubOn
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import qualified Data.Set as Set
+
+
+-- | The 'nubOn' function removes duplicate elements from a list. In
+-- particular, it keeps only the /last/ occurrence of each
+-- element. The equality of two elements in a call to @nub f@ is
+-- determined using @f x == f y@, and the resulting elements must have
+-- an 'Ord' instance in order to make this function more efficient.
+nubOn :: (Ord b) => (a -> b) -> [a] -> [a]
+nubOn f = snd . go Set.empty
+  where go before [] = (before, [])
+        go before (x:xs) =
+          let (before', xs') = go before xs
+              key = f x in
+          if key `Set.member` before'
+            then (before', xs')
+            else (Set.insert key before', x : xs')
diff --git a/src/Matterhorn/Windows/ViewMessage.hs b/src/Matterhorn/Windows/ViewMessage.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Windows/ViewMessage.hs
@@ -0,0 +1,218 @@
+module Matterhorn.Windows.ViewMessage
+  ( viewMessageWindowTemplate
+  , viewMessageKeybindings
+  , viewMessageKeyHandlers
+  , viewMessageReactionsKeybindings
+  , viewMessageReactionsKeyHandlers
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude
+
+import           Brick
+import           Brick.Widgets.Border
+
+import qualified Data.Set as S
+import qualified Data.Map as M
+import           Data.Maybe ( fromJust )
+import qualified Data.Text as T
+import qualified Data.Foldable as F
+import qualified Graphics.Vty as Vty
+import           Lens.Micro.Platform ( to )
+
+import           Matterhorn.Constants
+import           Matterhorn.Events.Keybindings
+import           Matterhorn.Themes
+import           Matterhorn.Types
+import           Matterhorn.Draw.RichText
+import           Matterhorn.Draw.Messages ( nameForUserRef )
+
+-- | The template for "View Message" windows triggered by message
+-- selection mode.
+viewMessageWindowTemplate :: TabbedWindowTemplate ViewMessageWindowTab
+viewMessageWindowTemplate =
+    TabbedWindowTemplate { twtEntries = [ messageEntry
+                                        , reactionsEntry
+                                        ]
+                         , twtTitle = const $ txt "View Message"
+                         }
+
+messageEntry :: TabbedWindowEntry ViewMessageWindowTab
+messageEntry =
+    TabbedWindowEntry { tweValue = VMTabMessage
+                      , tweRender = renderTab
+                      , tweHandleEvent = handleEvent
+                      , tweTitle = tabTitle
+                      , tweShowHandler = onShow
+                      }
+
+reactionsEntry :: TabbedWindowEntry ViewMessageWindowTab
+reactionsEntry =
+    TabbedWindowEntry { tweValue = VMTabReactions
+                      , tweRender = renderTab
+                      , tweHandleEvent = handleEvent
+                      , tweTitle = tabTitle
+                      , tweShowHandler = onShow
+                      }
+
+tabTitle :: ViewMessageWindowTab -> Bool -> T.Text
+tabTitle VMTabMessage _ = "Message"
+tabTitle VMTabReactions _ = "Reactions"
+
+-- When we show the tabs, we need to reset the viewport scroll position
+-- for viewports in that tab. This is because an older View Message
+-- window used the same handle for the viewport and we don't want that
+-- old state affecting this window. This also means that switching tabs
+-- in an existing window resets this state, too.
+onShow :: ViewMessageWindowTab -> MH ()
+onShow VMTabMessage = resetVp ViewMessageArea
+onShow VMTabReactions = resetVp ViewMessageArea
+
+resetVp :: Name -> MH ()
+resetVp n = do
+    let vs = viewportScroll n
+    mh $ do
+        vScrollToBeginning vs
+        hScrollToBeginning vs
+
+renderTab :: ViewMessageWindowTab -> ChatState -> Widget Name
+renderTab tab cs =
+    let latestMessage = case cs^.csViewedMessage of
+          Nothing -> error "BUG: no message to show, please report!"
+          Just (m, _) -> getLatestMessage cs m
+    in case tab of
+        VMTabMessage -> viewMessageBox cs latestMessage
+        VMTabReactions -> reactionsText cs latestMessage
+
+getLatestMessage :: ChatState -> Message -> Message
+getLatestMessage cs m =
+    case m^.mMessageId of
+        Nothing -> m
+        Just mId -> fromJust $ findMessage mId $ cs^.csCurrentChannel.ccContents.cdMessages
+
+handleEvent :: ViewMessageWindowTab -> Vty.Event -> MH ()
+handleEvent VMTabMessage =
+    void . handleKeyboardEvent viewMessageKeybindings (const $ return ())
+handleEvent VMTabReactions =
+    void . handleKeyboardEvent viewMessageReactionsKeybindings (const $ return ())
+
+reactionsText :: ChatState -> Message -> Widget Name
+reactionsText st m = viewport ViewMessageReactionsArea Vertical body
+    where
+        body = case null reacList of
+            True -> txt "This message has no reactions."
+            False -> vBox $ mkEntry <$> reacList
+        reacList = M.toList (m^.mReactions)
+        mkEntry (reactionName, userIdSet) =
+            let count = str $ "(" <> show (S.size userIdSet) <> ")"
+                name = withDefAttr emojiAttr $ txt $ ":" <> reactionName <> ":"
+                usernameList = usernameText userIdSet
+            in (name <+> (padLeft (Pad 1) count)) <=>
+               (padLeft (Pad 2) usernameList)
+
+        hs = getHighlightSet st
+
+        usernameText uids =
+            renderText' Nothing (myUsername st) hs $
+            T.intercalate ", " $
+            fmap (userSigil <>) $
+            catMaybes (lookupUsername <$> F.toList uids)
+
+        lookupUsername uid = usernameForUserId uid st
+
+viewMessageBox :: ChatState -> Message -> Widget Name
+viewMessageBox st msg =
+    let maybeWarn = if not (msg^.mDeleted) then id else warn
+        warn w = vBox [w, hBorder, deleteWarning]
+        deleteWarning = withDefAttr errorMessageAttr $
+                        txtWrap $ "Alert: this message has been deleted and " <>
+                                  "will no longer be accessible once this window " <>
+                                  "is closed."
+        mkBody vpWidth =
+            let hs = getHighlightSet st
+                parent = case msg^.mInReplyToMsg of
+                     NotAReply -> Nothing
+                     InReplyTo pId -> getMessageForPostId st pId
+                md = MessageData { mdEditThreshold     = Nothing
+                                 , mdShowOlderEdits    = False
+                                 , mdMessage           = msg
+                                 , mdUserName          = msg^.mUser.to (nameForUserRef st)
+                                 , mdParentMessage     = parent
+                                 , mdParentUserName    = parent >>= (^.mUser.to (nameForUserRef st))
+                                 , mdRenderReplyParent = True
+                                 , mdHighlightSet      = hs
+                                 , mdIndentBlocks      = True
+                                 , mdThreadState       = NoThread
+                                 , mdShowReactions     = False
+                                 , mdMessageWidthLimit = Just vpWidth
+                                 , mdMyUsername        = myUsername st
+                                 , mdWrapNonhighlightedCodeBlocks = False
+                                 }
+            in renderMessage md
+
+    in Widget Greedy Greedy $ do
+        ctx <- getContext
+        render $ maybeWarn $ viewport ViewMessageArea Both $ mkBody (ctx^.availWidthL)
+
+viewMessageKeybindings :: KeyConfig -> KeyHandlerMap
+viewMessageKeybindings = mkKeybindings viewMessageKeyHandlers
+
+viewMessageKeyHandlers :: [KeyEventHandler]
+viewMessageKeyHandlers =
+    let vs = viewportScroll ViewMessageArea
+    in [ mkKb PageUpEvent "Page up" $
+           mh $ vScrollBy vs (-1 * pageAmount)
+
+       , mkKb PageDownEvent "Page down" $
+           mh $ vScrollBy vs pageAmount
+
+       , mkKb PageLeftEvent "Page left" $
+           mh $ hScrollBy vs (-2 * pageAmount)
+
+       , mkKb PageRightEvent "Page right" $
+           mh $ hScrollBy vs (2 * pageAmount)
+
+       , mkKb ScrollUpEvent "Scroll up" $
+           mh $ vScrollBy vs (-1)
+
+       , mkKb ScrollDownEvent "Scroll down" $
+           mh $ vScrollBy vs 1
+
+       , mkKb ScrollLeftEvent "Scroll left" $
+           mh $ hScrollBy vs (-1)
+
+       , mkKb ScrollRightEvent "Scroll right" $
+           mh $ hScrollBy vs 1
+
+       , mkKb ScrollBottomEvent "Scroll to the end of the message" $
+           mh $ vScrollToEnd vs
+
+       , mkKb ScrollTopEvent "Scroll to the beginning of the message" $
+           mh $ vScrollToBeginning vs
+       ]
+
+viewMessageReactionsKeybindings :: KeyConfig -> KeyHandlerMap
+viewMessageReactionsKeybindings = mkKeybindings viewMessageReactionsKeyHandlers
+
+viewMessageReactionsKeyHandlers :: [KeyEventHandler]
+viewMessageReactionsKeyHandlers =
+    let vs = viewportScroll ViewMessageReactionsArea
+    in [ mkKb PageUpEvent "Page up" $
+           mh $ vScrollBy vs (-1 * pageAmount)
+
+       , mkKb PageDownEvent "Page down" $
+           mh $ vScrollBy vs pageAmount
+
+       , mkKb ScrollUpEvent "Scroll up" $
+           mh $ vScrollBy vs (-1)
+
+       , mkKb ScrollDownEvent "Scroll down" $
+           mh $ vScrollBy vs 1
+
+       , mkKb ScrollBottomEvent "Scroll to the end of the reactions list" $
+           mh $ vScrollToEnd vs
+
+       , mkKb ScrollTopEvent "Scroll to the beginning of the reactions list" $
+           mh $ vScrollToBeginning vs
+       ]
diff --git a/src/Matterhorn/Zipper.hs b/src/Matterhorn/Zipper.hs
new file mode 100644
--- /dev/null
+++ b/src/Matterhorn/Zipper.hs
@@ -0,0 +1,119 @@
+module Matterhorn.Zipper
+  ( Zipper
+  , fromList
+  , toList
+  , focus
+  , unsafeFocus
+  , left
+  , leftL
+  , right
+  , rightL
+  , findRight
+  , maybeFindRight
+  , updateList
+  , updateListBy
+  , filterZipper
+  , maybeMapZipper
+  , isEmpty
+  )
+where
+
+import           Prelude ()
+import           Matterhorn.Prelude hiding (toList)
+
+import           Data.Maybe ( fromJust )
+import qualified Data.Foldable as F
+import qualified Data.Sequence as Seq
+import qualified Data.CircularList as C
+import           Lens.Micro.Platform
+
+data Zipper a b =
+    Zipper { zRing :: C.CList b
+           , zTrees :: Seq.Seq (a, Seq.Seq b)
+           }
+
+instance F.Foldable (Zipper a) where
+    foldMap f = foldMap f .
+                F.toList .
+                mconcat .
+                F.toList .
+                fmap snd .
+                zTrees
+
+instance Functor (Zipper a) where
+    fmap f z =
+        Zipper { zRing = f <$> zRing z
+               , zTrees = zTrees z & mapped._2.mapped %~ f
+               }
+
+isEmpty :: Zipper a b -> Bool
+isEmpty = C.isEmpty . zRing
+
+-- Move the focus one element to the left
+left :: Zipper a b -> Zipper a b
+left z = z { zRing = C.rotL (zRing z) }
+
+-- A lens on the zipper moved to the left
+leftL :: Lens (Zipper a b) (Zipper a b) (Zipper a b) (Zipper a b)
+leftL = lens left (\ _ b -> right b)
+
+-- Move the focus one element to the right
+right :: Zipper a b -> Zipper a b
+right z = z { zRing = C.rotR (zRing z) }
+
+-- A lens on the zipper moved to the right
+rightL :: Lens (Zipper a b) (Zipper a b) (Zipper a b) (Zipper a b)
+rightL = lens right (\ _ b -> left b)
+
+-- Return the focus element
+focus :: Zipper a b -> Maybe b
+focus = C.focus . zRing
+
+unsafeFocus :: Zipper a b -> b
+unsafeFocus = fromJust . focus
+
+-- Turn a list into a wraparound zipper, focusing on the head
+fromList :: (Eq b) => [(a, [b])] -> Zipper a b
+fromList xs =
+    let ts = Seq.fromList $ xs & mapped._2 %~ Seq.fromList
+        tsList = F.toList $ mconcat $ F.toList $ snd <$> ts
+        maybeFocus = if null tsList
+                     then id
+                     else fromJust . C.rotateTo (tsList !! 0)
+    in Zipper { zRing = maybeFocus $ C.fromList tsList
+              , zTrees = ts
+              }
+
+toList :: Zipper a b -> [(a, [b])]
+toList z = F.toList $ zTrees z & mapped._2 %~ F.toList
+
+-- Shift the focus until a given element is found, or return the
+-- same zipper if none applies
+findRight :: (b -> Bool) -> Zipper a b -> Zipper a b
+findRight f z = fromMaybe z $ maybeFindRight f z
+
+-- Shift the focus until a given element is found, or return
+-- Nothing if none applies
+maybeFindRight :: (b -> Bool) -> Zipper a b -> Maybe (Zipper a b)
+maybeFindRight f z = do
+    newRing <- C.findRotateTo f (zRing z)
+    return z { zRing = newRing }
+
+updateList :: (Eq b) => [(a, [b])] -> Zipper a b -> Zipper a b
+updateList newList oldZip = updateListBy (\old b -> old == Just b) newList oldZip
+
+updateListBy :: (Eq b) => (Maybe b -> b -> Bool) -> [(a, [b])] -> Zipper a b -> Zipper a b
+updateListBy f newList oldZip = findRight (f (focus oldZip)) $ fromList newList
+
+maybeMapZipper :: (Eq c) => (b -> Maybe c) -> Zipper a b -> Zipper a c
+maybeMapZipper f z =
+    let oldTrees = zTrees z
+        newTrees = F.toList $ oldTrees & mapped._2 %~ (catMaybes . F.toList . fmap f)
+    in fromList newTrees
+
+filterZipper :: (Eq b) => (b -> Bool) -> Zipper a b -> Zipper a b
+filterZipper f oldZip = maintainFocus newZip
+  where maintainFocus = findRight ((== focus oldZip) . Just)
+        newZip = Zipper { zTrees = zTrees oldZip & mapped._2 %~ Seq.filter f
+                        , zRing = C.filterR f (zRing oldZip)
+                        }
diff --git a/src/Options.hs b/src/Options.hs
deleted file mode 100644
--- a/src/Options.hs
+++ /dev/null
@@ -1,156 +0,0 @@
-{-# LANGUAGE TemplateHaskell #-}
-
-module Options where
-
-import Prelude ()
-import Prelude.MH
-
-import Config
-import Data.Char ( toLower )
-import Data.Foldable (traverse_)
-import Data.Tuple ( swap )
-import Data.Version ( showVersion )
-import Development.GitRev
-import Network.Mattermost.Version ( mmApiVersion )
-import Paths_matterhorn ( version )
-import System.Console.GetOpt
-import System.Environment ( getArgs )
-import System.Exit ( exitFailure, exitSuccess )
-import System.IO ( hPutStrLn, stderr )
-
-
-data Behaviour
-  = Normal
-  | ShowVersion
-  | ShowHelp
-  | CheckConfig
-    deriving (Eq, Show)
-
-data PrintFormat =
-    Markdown | Plain deriving (Eq, Show)
-
-data Options = Options
-  { optConfLocation     :: Maybe FilePath
-  , optLogLocation      :: Maybe FilePath
-  , optBehaviour        :: Behaviour
-  , optIgnoreConfig     :: Bool
-  , optPrintKeybindings :: Bool
-  , optPrintCommands    :: Bool
-  , optPrintFormat      :: PrintFormat
-  } deriving (Eq, Show)
-
-defaultOptions :: Options
-defaultOptions = Options
-  { optConfLocation     = Nothing
-  , optLogLocation      = Nothing
-  , optBehaviour        = Normal
-  , optIgnoreConfig     = False
-  , optPrintKeybindings = False
-  , optPrintCommands    = False
-  , optPrintFormat      = Plain
-  }
-
-optDescrs :: [OptDescr (Options -> Options)]
-optDescrs =
-  [ Option ['c'] ["config"]
-    (ReqArg (\ path c -> c { optConfLocation = Just path }) "PATH")
-    "Path to the configuration file"
-  , Option ['l'] ["logs"]
-    (ReqArg (\ path c -> c { optLogLocation = Just path }) "PATH")
-    "Debug log output path"
-  , Option ['v'] ["version"]
-    (NoArg (\ c -> c { optBehaviour = ShowVersion }))
-    "Print version information and exit"
-  , Option ['h'] ["help"]
-    (NoArg (\ c -> c { optBehaviour = ShowHelp }))
-    "Print help for command-line flags and exit"
-  , Option ['i'] ["ignore-config"]
-    (NoArg (\ c -> c { optIgnoreConfig = True }))
-    "Start with no configuration"
-  , Option ['k'] ["keybindings"]
-    (NoArg (\ c -> c { optPrintKeybindings = True }))
-    "Print keybindings effective for the current configuration"
-  , Option ['m'] ["commands"]
-    (NoArg (\ c -> c { optPrintCommands = True }))
-    "Print available commands"
-  , Option ['f'] ["format"]
-    (ReqArg handleFormat "FORMAT")
-    ("Print keybinding or command output in the specified format " <>
-     "(options: " <> formatChoicesStr <> ", default: " <>
-     formatStringFor (optPrintFormat defaultOptions) <> ")")
-  , Option [] ["check-config"]
-    (NoArg (\ c -> c { optBehaviour = CheckConfig }))
-    "Validate configuration file"
-  ]
-
-formatChoices :: [(String, PrintFormat)]
-formatChoices =
-    [ ("plain", Plain)
-    , ("markdown", Markdown)
-    ]
-
-formatStringFor :: PrintFormat -> String
-formatStringFor fmt =
-    case lookup fmt (swap <$> formatChoices) of
-        Nothing -> error $ "BUG: no format string for " <> show fmt
-        Just s -> s
-
-formatChoicesStr :: String
-formatChoicesStr = intercalate ", " $ fst <$> formatChoices
-
-handleFormat :: String -> Options -> Options
-handleFormat fmtStr c =
-    let fmt = case lookup (toLower <$> fmtStr) formatChoices of
-            Just f -> f
-            Nothing ->
-                error $ "Invalid format: " <> show fmtStr <> ", choices: " <>
-                        formatChoicesStr
-    in c { optPrintFormat = fmt }
-
-mhVersion :: String
-mhVersion
-  | $(gitHash) == ("UNKNOWN" :: String) = "matterhorn " ++ showVersion version
-  | otherwise = "matterhorn " ++ showVersion version ++ " (" ++
-                $(gitBranch) ++ "@" ++ take 7 $(gitHash) ++ ")"
-
-fullVersionString :: String
-fullVersionString = mhVersion ++ "\n using " ++ mmApiVersion
-
-usage :: IO ()
-usage = putStr (usageInfo "matterhorn" optDescrs)
-
-grabOptions :: IO Options
-grabOptions = do
-  args <- getArgs
-  case getOpt Permute optDescrs args of
-    (aps, [], []) -> do
-      let rs = foldr (.) id aps defaultOptions
-      case optBehaviour rs of
-        Normal -> return rs
-        ShowHelp -> usage >> exitSuccess
-        ShowVersion -> putStrLn fullVersionString >> exitSuccess
-        CheckConfig -> checkConfiguration (optConfLocation rs)
-    (_, _, errs) -> do
-      mapM_ putStr errs
-      usage
-      exitFailure
-
-checkConfiguration :: Maybe FilePath -> IO a
-checkConfiguration mb =
-  do res <- findConfig mb
-     let writeLn = hPutStrLn stderr
-         printLocation Nothing = "No configuration file"
-         printLocation (Just fp) = "Location: " ++ fp
-     case res of
-       Left e ->
-         do writeLn e
-            exitFailure
-       Right ([], config) ->
-         do writeLn "Configuration file valid"
-            writeLn (printLocation (configAbsPath config))
-            exitSuccess
-       Right (ws, config) ->
-         do writeLn "Configuration file generated warnings"
-            writeLn (printLocation (configAbsPath config))
-            traverse_ writeLn ws
-            exitFailure
diff --git a/src/Prelude/MH.hs b/src/Prelude/MH.hs
deleted file mode 100644
--- a/src/Prelude/MH.hs
+++ /dev/null
@@ -1,115 +0,0 @@
-{-# LANGUAGE NoImplicitPrelude #-}
-{-# LANGUAGE CPP #-}
-
-{-| This module is for internal re-exports of commonly-used
-  functions. This also lets us avoid churn between versions of GHC by
-  putting changed functions behind CPP in a single place.
--}
-
-{-
-  GHC version <--> base version (https://wiki.haskell.org/Base_package) includes:
-     8.0.1      4.9.0.0
-     8.2.1      4.10.0.0
-     8.4.1      4.11.0.0
-
-  GHC distributions include a set of core packages; overriding these
-  with new versions is unwise.  Core packages include (see
-  https://downloads.haskell.org/~ghc/X.Y.Z/docs/html/users_guide/X.Y.Z-notes.html#included-libraries
-  ):
-
-    base, Cabal, array, bytestring, containers, deepseq, directory,
-    filepath, mtl, parsec, process, text, time, unix
--}
-
-module Prelude.MH
-  ( module P
-#if !MIN_VERSION_base(4,9,0)
-  , (<>)
-#endif
-  , (<|>)
-  -- commonly-used functions from Maybe
-  , Maybe.isJust
-  , Maybe.isNothing
-  , Maybe.listToMaybe
-  , Maybe.maybeToList
-  , Maybe.fromMaybe
-  , Maybe.catMaybes
-
-  -- a non-partial Read function
-  , Read.readMaybe
-
-  -- commonly-used functions from Monad
-  , Monad.forM
-  , Monad.forM_
-  , Monad.filterM
-  , Monad.when
-  , Monad.unless
-  , Monad.void
-  , Monad.join
-  , Monad.forever
-  , Monad.foldM
-  , Monad.MonadIO(..)
-
-  -- commonly-used functions from List
-  , Foldable.toList
-  , List.find
-  , List.sort
-  , List.intercalate
-  , Exts.sortWith
-  , Exts.groupWith
-
-  -- common read-only lens operators
-  , (Lens.&)
-  , (Lens.^.)
-  , Lens.use
-
-  -- not available in all versions of GHC currently in use
-#if MIN_VERSION_base(4,10,0)
-  , Clock.nominalDay
-#else
-  , nominalDay
-#endif
-
-  -- various type aliases
-  , Text
-  , HashMap
-  , Seq
-  , Set
-  , Time.UTCTime
-  , Time.TimeZoneSeries
-  , Time.NominalDiffTime
-  )
-where
-
-
-#if !MIN_VERSION_base(4,11,0)
-import           Data.Semigroup ( (<>) )
-#endif
-
-import           Control.Applicative ( (<|>) )
-import qualified Control.Monad as Monad
-import qualified Control.Monad.IO.Class as Monad
-import qualified Data.Foldable as Foldable
-import qualified Data.List as List
-import qualified Data.Maybe as Maybe
-import qualified Data.Time as Time
-#if MIN_VERSION_base(4,10,0)
-import qualified Data.Time.Clock as Clock
-#endif
-import qualified Data.Time.LocalTime.TimeZone.Series as Time
-import qualified GHC.Exts as Exts
-import qualified Lens.Micro.Platform as Lens
-import           Prelude.Compat
-import qualified Prelude.Compat as P
-import qualified Text.Read as Read
-
--- these below we import only for type aliases
-import           Data.HashMap.Strict ( HashMap )
-import           Data.Sequence ( Seq )
-import           Data.Set ( Set )
-import           Data.Text ( Text )
-
-#if !MIN_VERSION_base(4,10,0)
-nominalDay :: Time.NominalDiffTime
-nominalDay = 86400
-#endif
diff --git a/src/Scripts.hs b/src/Scripts.hs
deleted file mode 100644
--- a/src/Scripts.hs
+++ /dev/null
@@ -1,81 +0,0 @@
-module Scripts
-  ( findAndRunScript
-  , listScripts
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Control.Concurrent ( takeMVar, newEmptyMVar )
-import qualified Control.Concurrent.STM as STM
-import qualified Data.Text as T
-import           System.Exit ( ExitCode(..) )
-
-import           Network.Mattermost.Types ( ChannelId )
-
-import           FilePaths ( Script(..), getAllScripts, locateScriptPath )
-import           State.Common
-import           State.Messages ( sendMessage )
-import           Types
-
-
-findAndRunScript :: ChannelId -> Text -> Text -> MH ()
-findAndRunScript cId scriptName input = do
-    fpMb <- liftIO $ locateScriptPath (T.unpack scriptName)
-    outputChan <- use (csResources.crSubprocessLog)
-    case fpMb of
-      ScriptPath scriptPath -> do
-        doAsyncWith Preempt $ runScript cId outputChan scriptPath input
-      NonexecScriptPath scriptPath -> do
-        let msg = ("The script `" <> T.pack scriptPath <> "` cannot be " <>
-             "executed. Try running\n" <>
-             "```\n" <>
-             "$ chmod u+x " <> T.pack scriptPath <> "\n" <>
-             "```\n" <>
-             "to correct this error. " <> scriptHelpAddendum)
-        mhError $ GenericError msg
-      ScriptNotFound -> do
-        mhError $ NoSuchScript scriptName
-
-runScript :: ChannelId -> STM.TChan ProgramOutput -> FilePath -> Text -> IO (Maybe (MH ()))
-runScript cId outputChan fp text = do
-  outputVar <- newEmptyMVar
-  runLoggedCommand True outputChan fp [] (Just $ T.unpack text) (Just outputVar)
-  po <- takeMVar outputVar
-  return $ case programExitCode po of
-    ExitSuccess -> do
-        case null $ programStderr po of
-            True -> Just $ do
-                mode <- use (csEditState.cedEditMode)
-                sendMessage cId mode (T.pack $ programStdout po) []
-            False -> Nothing
-    ExitFailure _ -> Nothing
-
-listScripts :: MH ()
-listScripts = do
-  (execs, nonexecs) <- liftIO getAllScripts
-  let scripts = ("Available scripts are:\n" <>
-                 mconcat [ "  - " <> T.pack cmd <> "\n"
-                         | cmd <- execs
-                         ])
-  postInfoMessage scripts
-  case nonexecs of
-    [] -> return ()
-    _  -> do
-      let errMsg = ("Some non-executable script files are also " <>
-                    "present. If you want to run these as scripts " <>
-                    "in Matterhorn, mark them executable with \n" <>
-                    "```\n" <>
-                    "$ chmod u+x [script path]\n" <>
-                    "```\n" <>
-                    "\n" <>
-                    mconcat [ "  - " <> T.pack cmd <> "\n"
-                            | cmd <- nonexecs
-                            ] <> "\n" <> scriptHelpAddendum)
-      mhError $ GenericError errMsg
-
-scriptHelpAddendum :: Text
-scriptHelpAddendum =
-  "For more help with scripts, run the command\n" <>
-  "```\n/help scripts\n```\n"
diff --git a/src/State/Async.hs b/src/State/Async.hs
deleted file mode 100644
--- a/src/State/Async.hs
+++ /dev/null
@@ -1,161 +0,0 @@
-module State.Async
-  ( AsyncPriority(..)
-  , doAsync
-  , doAsyncIO
-  , doAsyncWith
-  , doAsyncChannelMM
-  , doAsyncWithIO
-  , doAsyncMM
-  , tryMM
-  , endAsyncNOP
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Control.Concurrent.STM as STM
-import           Control.Exception ( try )
-
-import           Network.Mattermost.Types
-
-import           Types
-
-
--- | Try to run a computation, posting an informative error
---   message if it fails with a 'MattermostServerError'.
-tryMM :: IO a
-      -- ^ The action to try (usually a MM API call)
-      -> (a -> IO (Maybe (MH ())))
-      -- ^ What to do on success
-      -> IO (Maybe (MH ()))
-tryMM act onSuccess = do
-    result <- liftIO $ try act
-    case result of
-        Left e -> return $ Just $ mhError $ ServerError e
-        Right value -> liftIO $ onSuccess value
-
--- * Background Computation
-
--- $background_computation
---
--- The main context for Matterhorn is the EventM context provided by
--- the 'Brick' library.  This context is normally waiting for user
--- input (or terminal resizing, etc.) which gets turned into an
--- MHEvent and the 'onEvent' event handler is called to process that
--- event, after which the display is redrawn as necessary and brick
--- awaits the next input.
---
--- However, it is often convenient to communicate with the Mattermost
--- server in the background, so that large numbers of
--- synchronously-blocking events (e.g. on startup) or refreshes can
--- occur whenever needed and without negatively impacting the UI
--- updates or responsiveness.  This is handled by a 'forkIO' context
--- that waits on an STM channel for work to do, performs the work, and
--- then sends brick an MHEvent containing the completion or failure
--- information for that work.
---
--- The /doAsyncWith/ family of functions here facilitates that
--- asynchronous functionality.  This is typically used in the
--- following fashion:
---
--- > doSomething :: MH ()
--- > doSomething = do
--- >    got <- something
--- >    doAsyncWith Normal $ do
--- >       r <- mmFetchR ....
--- >       return $ do
--- >          csSomething.here %= processed r
---
--- The second argument is an IO monad operation (because 'forkIO' runs
--- in the IO Monad context), but it returns an MH monad operation.
--- The IO monad has access to the closure of 'doSomething' (e.g. the
--- 'got' value), but it should be aware that the state of the MH monad
--- may have been changed by the time the IO monad runs in the
--- background, so the closure is a snapshot of information at the time
--- the 'doAsyncWith' was called.
---
--- Similarly, the returned MH monad operation is *not* run in the
--- context of the 'forkIO' background, but it is instead passed via an
--- MHEvent back to the main brick thread, where it is executed in an
--- EventM handler's MH monad context.  This operation therefore has
--- access to the combined closure of the pre- 'doAsyncWith' code and
--- the closure of the IO operation.  It is important that the final MH
--- monad operation should *re-obtain* state information from the MH
--- monad instead of using or setting the state obtained prior to the
--- 'doAsyncWith' call.
-
--- | Priority setting for asynchronous work items. Preempt means that
--- the queued item will be the next work item begun (i.e. it goes to the
--- front of the queue); normal means it will go last in the queue.
-data AsyncPriority = Preempt | Normal
-
--- | Run a computation in the background, ignoring any results from it.
-doAsync :: AsyncPriority -> IO () -> MH ()
-doAsync prio act = doAsyncWith prio (act >> return Nothing)
-
--- | Run a computation in the background, returning a computation to be
--- called on the 'ChatState' value.
-doAsyncWith :: AsyncPriority -> IO (Maybe (MH ())) -> MH ()
-doAsyncWith prio act = do
-    let putChan = case prio of
-          Preempt -> STM.unGetTChan
-          Normal  -> STM.writeTChan
-    queue <- use (csResources.crRequestQueue)
-    liftIO $ STM.atomically $ putChan queue act
-
-doAsyncIO :: AsyncPriority -> ChatState -> IO () -> IO ()
-doAsyncIO prio st act =
-  doAsyncWithIO prio st (act >> return Nothing)
-
--- | Run a computation in the background, returning a computation to be
--- called on the 'ChatState' value.
-doAsyncWithIO :: AsyncPriority -> ChatState -> IO (Maybe (MH ())) -> IO ()
-doAsyncWithIO prio st act = do
-    let putChan = case prio of
-          Preempt -> STM.unGetTChan
-          Normal  -> STM.writeTChan
-    let queue = st^.csResources.crRequestQueue
-    STM.atomically $ putChan queue act
-
--- | Performs an asynchronous IO operation. On completion, the final
--- argument a completion function is executed in an MH () context in the
--- main (brick) thread.
-doAsyncMM :: AsyncPriority
-          -- ^ the priority for this async operation
-          -> (Session -> IO a)
-          -- ^ the async MM channel-based IO operation
-          -> (a -> Maybe (MH ()))
-          -- ^ function to process the results in brick event handling
-          -- context
-          -> MH ()
-doAsyncMM prio mmOp eventHandler = do
-  session <- getSession
-  doAsyncWith prio $ do
-    r <- mmOp session
-    return $ eventHandler r
-
--- | Helper type for a function to perform an asynchronous MM operation
--- on a channel and then invoke an MH completion event.
-type DoAsyncChannelMM a =
-    AsyncPriority
-    -- ^ the priority for this async operation
-    -> ChannelId
-    -- ^ The channel
-    -> (Session -> ChannelId -> IO a)
-    -- ^ the asynchronous Mattermost channel-based IO operation
-    -> (ChannelId -> a -> Maybe (MH ()))
-    -- ^ function to process the results in brick event handling context
-    -> MH ()
-
--- | Performs an asynchronous IO operation on a specific channel. On
--- completion, the final argument a completion function is executed in
--- an MH () context in the main (brick) thread.
-doAsyncChannelMM :: DoAsyncChannelMM a
-doAsyncChannelMM prio cId mmOp eventHandler =
-  doAsyncMM prio (\s -> mmOp s cId) (eventHandler cId)
-
--- | Use this convenience function if no operation needs to be
--- performed in the MH state after an async operation completes.
-endAsyncNOP :: ChannelId -> a -> Maybe (MH ())
-endAsyncNOP _ _ = Nothing
diff --git a/src/State/Attachments.hs b/src/State/Attachments.hs
deleted file mode 100644
--- a/src/State/Attachments.hs
+++ /dev/null
@@ -1,53 +0,0 @@
-module State.Attachments
-  ( showAttachmentList
-  , resetAttachmentList
-  , showAttachmentFileBrowser
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-import qualified Control.Exception as E
-import           Data.Either ( isRight )
-import           System.Directory ( doesDirectoryExist, getDirectoryContents )
-import           Data.Bool ( bool )
-
-import           Brick ( vScrollToBeginning, viewportScroll )
-import qualified Brick.Widgets.List as L
-import qualified Brick.Widgets.FileBrowser as FB
-import           Lens.Micro.Platform ( (.=) )
-
-import           Types
-
-validateAttachmentPath :: FilePath -> IO (Maybe FilePath)
-validateAttachmentPath path = bool Nothing (Just path) <$> do
-    ex <- doesDirectoryExist path
-    case ex of
-        False -> return False
-        True -> do
-            result :: Either E.SomeException [FilePath]
-                   <- E.try $ getDirectoryContents path
-            return $ isRight result
-
-defaultAttachmentsPath :: Config -> IO (Maybe FilePath)
-defaultAttachmentsPath = maybe (return Nothing) validateAttachmentPath . configDefaultAttachmentPath
-
-showAttachmentList :: MH ()
-showAttachmentList = do
-    lst <- use (csEditState.cedAttachmentList)
-    case length (L.listElements lst) of
-        0 -> showAttachmentFileBrowser
-        _ -> setMode ManageAttachments
-
-resetAttachmentList :: MH ()
-resetAttachmentList = do
-    csEditState.cedAttachmentList .= L.list AttachmentList mempty 1
-    mh $ vScrollToBeginning $ viewportScroll AttachmentList
-
-showAttachmentFileBrowser :: MH ()
-showAttachmentFileBrowser = do
-    config <- use (csResources.crConfiguration)
-    filePath <- liftIO $ defaultAttachmentsPath config
-    browser <- liftIO $ Just <$> FB.newFileBrowser FB.selectNonDirectories AttachmentFileBrowser filePath
-    csEditState.cedFileBrowser .= browser
-    setMode ManageAttachmentsBrowseFiles
diff --git a/src/State/Autocomplete.hs b/src/State/Autocomplete.hs
deleted file mode 100644
--- a/src/State/Autocomplete.hs
+++ /dev/null
@@ -1,263 +0,0 @@
-module State.Autocomplete
-  ( AutocompleteContext(..)
-  , checkForAutocompletion
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Main ( viewportScroll, vScrollToBeginning )
-import           Brick.Widgets.Edit ( editContentsL )
-import qualified Brick.Widgets.List as L
-import           Data.Char ( isSpace )
-import qualified Data.Foldable as F
-import qualified Data.HashMap.Strict as HM
-import           Data.List ( sortBy, partition )
-import qualified Data.Map as M
-import qualified Data.Sequence as Seq
-import qualified Data.Text as T
-import qualified Data.Text.Zipper as Z
-import qualified Data.Vector as V
-import           Lens.Micro.Platform ( (%=), (.=), (.~), _Just, preuse )
-import qualified Skylighting.Types as Sky
-
-import           Network.Mattermost.Types (userId, channelId)
-import qualified Network.Mattermost.Endpoints as MM
-
-import           Constants ( userSigil, normalChannelSigil )
-import {-# SOURCE #-} Command ( commandList, printArgSpec )
-import           State.Common
-import {-# SOURCE #-} State.Editing ( Direction(..), tabComplete )
-import           Types hiding ( newState )
-import           Emoji
-
-
-data AutocompleteContext =
-    AutocompleteContext { autocompleteManual :: Bool
-                        -- ^ Whether the autocompletion was manual
-                        -- (True) or automatic (False). The automatic
-                        -- case is the case where the autocomplete
-                        -- lookups and UI are triggered merely by
-                        -- entering some initial text (such as "@").
-                        -- The manual case is the case where the
-                        -- autocomplete lookups and UI are triggered
-                        -- explicitly by a user's TAB keypress.
-                        , autocompleteFirstMatch :: Bool
-                        -- ^ Once the results of the autocomplete lookup
-                        -- are available, this flag determines whether
-                        -- the user's input is replaced immediately
-                        -- with the first available match (True) or not
-                        -- (False).
-                        }
-
--- | Check for whether the currently-edited word in the message editor
--- should cause an autocompletion UI to appear. If so, initiate a server
--- query or local cache lookup to present the completion alternatives
--- for the word at the cursor.
-checkForAutocompletion :: AutocompleteContext -> MH ()
-checkForAutocompletion ctx = do
-    result <- getCompleterForInput ctx
-    case result of
-        Nothing -> resetAutocomplete
-        Just (ty, runUpdater, searchString) -> do
-            prevResult <- use (csEditState.cedAutocomplete)
-            -- We should update the completion state if EITHER:
-            --
-            -- 1) The type changed
-            --
-            -- or
-            --
-            -- 2) The search string changed but the type did NOT change
-            let shouldUpdate = ((maybe True ((/= searchString) . _acPreviousSearchString)
-                                 prevResult) &&
-                                (maybe True ((== ty) . _acType) prevResult)) ||
-                               (maybe False ((/= ty) . _acType) prevResult)
-            when shouldUpdate $ do
-                csEditState.cedAutocompletePending .= Just searchString
-                runUpdater ty ctx searchString
-
-getCompleterForInput :: AutocompleteContext
-                     -> MH (Maybe (AutocompletionType, AutocompletionType -> AutocompleteContext -> Text -> MH (), Text))
-getCompleterForInput ctx = do
-    z <- use (csEditState.cedEditor.editContentsL)
-
-    let col = snd $ Z.cursorPosition z
-        curLine = Z.currentLine z
-
-    return $ case wordAtColumn col curLine of
-        Just (startCol, w)
-            | userSigil `T.isPrefixOf` w ->
-                Just (ACUsers, doUserAutoCompletion, T.tail w)
-            | normalChannelSigil `T.isPrefixOf` w ->
-                Just (ACChannels, doChannelAutoCompletion, T.tail w)
-            | ":" `T.isPrefixOf` w && autocompleteManual ctx ->
-                Just (ACEmoji, doEmojiAutoCompletion, T.tail w)
-            | "```" `T.isPrefixOf` w ->
-                Just (ACCodeBlockLanguage, doSyntaxAutoCompletion, T.drop 3 w)
-            | "/" `T.isPrefixOf` w && startCol == 0 ->
-                Just (ACCommands, doCommandAutoCompletion, T.tail w)
-        _ -> Nothing
-
--- Completion implementations
-
-doEmojiAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()
-doEmojiAutoCompletion ty ctx searchString = do
-    session <- getSession
-    em <- use (csResources.crEmoji)
-    withCachedAutocompleteResults ctx ty searchString $
-        doAsyncWith Preempt $ do
-            results <- getMatchingEmoji session em searchString
-            let alts = EmojiCompletion <$> results
-            return $ Just $ setCompletionAlternatives ctx searchString alts ty
-
-doSyntaxAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()
-doSyntaxAutoCompletion ty ctx searchString = do
-    mapping <- use (csResources.crSyntaxMap)
-    let allNames = Sky.sShortname <$> M.elems mapping
-        (prefixed, notPrefixed) = partition isPrefixed $ filter match allNames
-        match = (((T.toLower searchString) `T.isInfixOf`) . T.toLower)
-        isPrefixed = (((T.toLower searchString) `T.isPrefixOf`) . T.toLower)
-        alts = SyntaxCompletion <$> (sort prefixed <> sort notPrefixed)
-    setCompletionAlternatives ctx searchString alts ty
-
-doCommandAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()
-doCommandAutoCompletion ty ctx searchString = do
-    let alts = mkAlt <$> sortBy compareCommands (filter matches commandList)
-        compareCommands a b =
-            let isAPrefix = searchString `T.isPrefixOf` cmdName a
-                isBPrefix = searchString `T.isPrefixOf` cmdName b
-            in if isAPrefix && isBPrefix
-               then compare (cmdName a) (cmdName b)
-               else if isAPrefix
-                    then LT
-                    else GT
-        lowerSearch = T.toLower searchString
-        matches c = lowerSearch `T.isInfixOf` (cmdName c) ||
-                    lowerSearch `T.isInfixOf` (T.toLower $ cmdDescr c)
-        mkAlt (Cmd name desc args _) =
-            CommandCompletion name (printArgSpec args) desc
-    setCompletionAlternatives ctx searchString alts ty
-
-doUserAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()
-doUserAutoCompletion ty ctx searchString = do
-    session <- getSession
-    myTid <- gets myTeamId
-    myUid <- gets myUserId
-    cId <- use csCurrentChannelId
-
-    withCachedAutocompleteResults ctx ty searchString $
-        doAsyncWith Preempt $ do
-            ac <- MM.mmAutocompleteUsers (Just myTid) (Just cId) searchString session
-
-            let active = Seq.filter (\u -> userId u /= myUid && (not $ userDeleted u))
-                alts = F.toList $
-                       ((\u -> UserCompletion u True) <$> (active $ MM.userAutocompleteUsers ac)) <>
-                       (maybe mempty (fmap (\u -> UserCompletion u False) . active) $
-                              MM.userAutocompleteOutOfChannel ac)
-
-                specials = [ MentionAll
-                           , MentionChannel
-                           ]
-                extras = [ SpecialMention m | m <- specials
-                         , (T.toLower searchString) `T.isPrefixOf` specialMentionName m
-                         ]
-
-            return $ Just $ setCompletionAlternatives ctx searchString (alts <> extras) ty
-
-doChannelAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()
-doChannelAutoCompletion ty ctx searchString = do
-    session <- getSession
-    tId <- gets myTeamId
-    cs <- use csChannels
-
-    withCachedAutocompleteResults ctx ty searchString $ do
-        doAsyncWith Preempt $ do
-            results <- MM.mmAutocompleteChannels tId searchString session
-            let alts = F.toList $ (ChannelCompletion True <$> inChannels) <>
-                                  (ChannelCompletion False <$> notInChannels)
-                (inChannels, notInChannels) = Seq.partition isMember results
-                isMember c = isJust $ findChannelById (channelId c) cs
-            return $ Just $ setCompletionAlternatives ctx searchString alts ty
-
--- Utility functions
-
--- | Attempt to re-use a cached autocomplete alternative list for
--- a given search string. If the cache contains no such entry (keyed
--- on search string), run the specified action, which is assumed to be
--- responsible for fetching the completion results from the server.
-withCachedAutocompleteResults :: AutocompleteContext
-                              -- ^ The autocomplete context
-                              -> AutocompletionType
-                              -- ^ The type of autocompletion we're
-                              -- doing
-                              -> Text
-                              -- ^ The search string to look for in the
-                              -- cache
-                              -> MH ()
-                              -- ^ The action to execute on a cache miss
-                              -> MH ()
-withCachedAutocompleteResults ctx ty searchString act = do
-    mCache <- preuse (csEditState.cedAutocomplete._Just.acCachedResponses)
-    mActiveTy <- preuse (csEditState.cedAutocomplete._Just.acType)
-
-    case Just ty == mActiveTy of
-        True ->
-            -- Does the cache have results for this search string? If
-            -- so, use them; otherwise invoke the specified action.
-            case HM.lookup searchString =<< mCache of
-                Just alts -> setCompletionAlternatives ctx searchString alts ty
-                Nothing -> act
-        False -> act
-
-setCompletionAlternatives :: AutocompleteContext
-                          -> Text
-                          -> [AutocompleteAlternative]
-                          -> AutocompletionType
-                          -> MH ()
-setCompletionAlternatives ctx searchString alts ty = do
-    let list = L.list CompletionList (V.fromList $ F.toList alts) 1
-        state = AutocompleteState { _acPreviousSearchString = searchString
-                                  , _acCompletionList =
-                                      list & L.listSelectedL .~ Nothing
-                                  , _acCachedResponses = HM.fromList [(searchString, alts)]
-                                  , _acType = ty
-                                  }
-
-    pending <- use (csEditState.cedAutocompletePending)
-    case pending of
-        Just val | val == searchString -> do
-
-            -- If there is already state, update it, but also cache the
-            -- search results.
-            csEditState.cedAutocomplete %= \prev ->
-                let newState = case prev of
-                        Nothing ->
-                            state
-                        Just oldState ->
-                            state & acCachedResponses .~
-                                HM.insert searchString alts (oldState^.acCachedResponses)
-                in Just newState
-
-            mh $ vScrollToBeginning $ viewportScroll CompletionList
-
-            when (autocompleteFirstMatch ctx) $
-                tabComplete Forwards
-        _ ->
-            -- Do not update the state if this result does not
-            -- correspond to the search string we used most recently.
-            -- This happens when the editor changes faster than the
-            -- async completion responses arrive from the server. If we
-            -- don't check this, we show completion results that are
-            -- wrong for the editor state.
-            return ()
-
-wordAtColumn :: Int -> Text -> Maybe (Int, Text)
-wordAtColumn i t =
-    let tokens = T.groupBy (\a b -> isSpace a == isSpace b) t
-        go _ j _ | j < 0 = Nothing
-        go col j ts = case ts of
-            [] -> Nothing
-            (w:rest) | j <= T.length w && not (isSpace $ T.head w) -> Just (col, w)
-                     | otherwise -> go (col + T.length w) (j - T.length w) rest
-    in go 0 i tokens
diff --git a/src/State/ChannelListOverlay.hs b/src/State/ChannelListOverlay.hs
deleted file mode 100644
--- a/src/State/ChannelListOverlay.hs
+++ /dev/null
@@ -1,82 +0,0 @@
-module State.ChannelListOverlay
-  ( enterChannelListOverlayMode
-
-  , channelListSelectDown
-  , channelListSelectUp
-  , channelListPageDown
-  , channelListPageUp
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Brick.Widgets.List as L
-import qualified Data.Vector as Vec
-import qualified Data.Sequence as Seq
-import           Data.Function ( on )
-import           Lens.Micro.Platform ( to )
-
-import           Network.Mattermost.Types
-import qualified Network.Mattermost.Endpoints as MM
-
-import           State.ListOverlay
-import           State.Channels
-import           Types
-
-
-enterChannelListOverlayMode :: MH ()
-enterChannelListOverlayMode = do
-    myTId <- gets myTeamId
-    myChannels <- use (csChannels.to (filteredChannelIds (const True)))
-    enterListOverlayMode csChannelListOverlay ChannelListOverlay
-        AllChannels enterHandler (fetchResults myTId myChannels)
-
-enterHandler :: Channel -> MH Bool
-enterHandler chan = do
-    joinChannel (getId chan)
-    return True
-
-fetchResults :: TeamId
-             -> [ChannelId]
-             -- ^ The channels to exclude from the results
-             -> ChannelSearchScope
-             -- ^ The scope to search
-             -> Session
-             -- ^ The connection session
-             -> Text
-             -- ^ The search string
-             -> IO (Vec.Vector Channel)
-fetchResults myTId exclude AllChannels session searchString = do
-    resultChans <- MM.mmSearchChannels myTId searchString session
-    let filteredChans = Seq.filter (\ c -> not (channelId c `elem` exclude)) resultChans
-        sortedChans = Vec.fromList $ toList $ Seq.sortBy (compare `on` channelName) filteredChans
-    return sortedChans
-
--- | Move the selection up in the channel list overlay by one channel.
-channelListSelectUp :: MH ()
-channelListSelectUp = channelListMove L.listMoveUp
-
--- | Move the selection down in the channel list overlay by one channel.
-channelListSelectDown :: MH ()
-channelListSelectDown = channelListMove L.listMoveDown
-
--- | Move the selection up in the channel list overlay by a page of channels
--- (channelListPageSize).
-channelListPageUp :: MH ()
-channelListPageUp = channelListMove (L.listMoveBy (-1 * channelListPageSize))
-
--- | Move the selection down in the channel list overlay by a page of channels
--- (channelListPageSize).
-channelListPageDown :: MH ()
-channelListPageDown = channelListMove (L.listMoveBy channelListPageSize)
-
--- | Transform the channel list results in some way, e.g. by moving the
--- cursor, and then check to see whether the modification warrants a
--- prefetch of more search results.
-channelListMove :: (L.List Name Channel -> L.List Name Channel) -> MH ()
-channelListMove = listOverlayMove csChannelListOverlay
-
--- | The number of channels in a "page" for cursor movement purposes.
-channelListPageSize :: Int
-channelListPageSize = 10
diff --git a/src/State/ChannelSelect.hs b/src/State/ChannelSelect.hs
deleted file mode 100644
--- a/src/State/ChannelSelect.hs
+++ /dev/null
@@ -1,155 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-module State.ChannelSelect
-  ( beginChannelSelect
-  , updateChannelSelectMatches
-  , channelSelectNext
-  , channelSelectPrevious
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Widgets.Edit ( getEditContents )
-import           Data.Char ( isUpper )
-import qualified Data.Text as T
-import           Lens.Micro.Platform
-
-import qualified Network.Mattermost.Types as MM
-
-import           Constants ( userSigil, normalChannelSigil )
-import           Types
-import qualified Zipper as Z
-
-beginChannelSelect :: MH ()
-beginChannelSelect = do
-    setMode ChannelSelect
-    csChannelSelectState .= emptyChannelSelectState
-    updateChannelSelectMatches
-
-    -- Preserve the current channel selection when initializing channel
-    -- selection mode
-    zipper <- use csFocus
-    let isCurrentFocus m = Just (matchEntry m) == Z.focus zipper
-    csChannelSelectState.channelSelectMatches %= Z.findRight isCurrentFocus
-
--- Select the next match in channel selection mode.
-channelSelectNext :: MH ()
-channelSelectNext = updateSelectedMatch Z.right
-
--- Select the previous match in channel selection mode.
-channelSelectPrevious :: MH ()
-channelSelectPrevious = updateSelectedMatch Z.left
-
-updateChannelSelectMatches :: MH ()
-updateChannelSelectMatches = do
-    st <- use id
-
-    input <- use (csChannelSelectState.channelSelectInput)
-    cconfig <- use csClientConfig
-    prefs <- use (csResources.crUserPreferences)
-
-    let pat = parseChannelSelectPattern $ T.concat $ getEditContents input
-        chanNameMatches e = case pat of
-            Nothing -> const Nothing
-            Just p -> applySelectPattern p e
-        patTy = case pat of
-            Nothing -> Nothing
-            Just CSPAny -> Nothing
-            Just (CSP ty _) -> Just ty
-
-    let chanMatches e chan =
-            if patTy == Just PrefixDMOnly
-            then Nothing
-            else if chan^.ccInfo.cdType /= MM.Group
-                 then chanNameMatches e $ chan^.ccInfo.cdDisplayName
-                 else Nothing
-        groupChanMatches e chan =
-            if patTy == Just PrefixNonDMOnly
-            then Nothing
-            else if chan^.ccInfo.cdType == MM.Group
-                 then chanNameMatches e $ chan^.ccInfo.cdDisplayName
-                 else Nothing
-        displayName uInfo = displayNameForUser uInfo cconfig prefs
-        userMatches e uInfo =
-            if patTy == Just PrefixNonDMOnly
-            then Nothing
-            else (chanNameMatches e . displayName) uInfo
-        matches e@(CLChannel cId) = findChannelById cId (st^.csChannels) >>= chanMatches e
-        matches e@(CLUserDM _ uId) = userById uId st >>= userMatches e
-        matches e@(CLGroupDM cId) = findChannelById cId (st^.csChannels) >>= groupChanMatches e
-
-        preserveFocus Nothing _ = False
-        preserveFocus (Just m) m2 = matchEntry m == matchEntry m2
-
-    csChannelSelectState.channelSelectMatches %= (Z.updateListBy preserveFocus $ Z.toList $ Z.maybeMapZipper matches (st^.csFocus))
-
-applySelectPattern :: ChannelSelectPattern -> ChannelListEntry -> Text -> Maybe ChannelSelectMatch
-applySelectPattern CSPAny entry chanName = do
-    return $ ChannelSelectMatch "" "" chanName chanName entry
-applySelectPattern (CSP ty pat) entry chanName = do
-    let applyType Infix | pat `T.isInfixOf` normalizedChanName =
-            case T.breakOn pat normalizedChanName of
-                (pre, _) ->
-                    return ( T.take (T.length pre) chanName
-                           , T.take (T.length pat) $ T.drop (T.length pre) chanName
-                           , T.drop (T.length pat + T.length pre) chanName
-                           )
-
-        applyType Prefix | pat `T.isPrefixOf` normalizedChanName = do
-            let (b, a) = T.splitAt (T.length pat) chanName
-            return ("", b, a)
-
-        applyType PrefixDMOnly | pat `T.isPrefixOf` normalizedChanName = do
-            let (b, a) = T.splitAt (T.length pat) chanName
-            return ("", b, a)
-
-        applyType PrefixNonDMOnly | pat `T.isPrefixOf` normalizedChanName = do
-            let (b, a) = T.splitAt (T.length pat) chanName
-            return ("", b, a)
-
-        applyType Suffix | pat `T.isSuffixOf` normalizedChanName = do
-            let (b, a) = T.splitAt (T.length chanName - T.length pat) chanName
-            return (b, a, "")
-
-        applyType Equal  | pat == normalizedChanName =
-            return ("", chanName, "")
-
-        applyType _ = Nothing
-
-        caseSensitive = T.any isUpper pat
-        normalizedChanName = if caseSensitive
-                             then chanName
-                             else T.toLower chanName
-
-    (pre, m, post) <- applyType ty
-    return $ ChannelSelectMatch pre m post chanName entry
-
-parseChannelSelectPattern :: Text -> Maybe ChannelSelectPattern
-parseChannelSelectPattern "" = return CSPAny
-parseChannelSelectPattern pat = do
-    let only = if | userSigil `T.isPrefixOf` pat -> Just $ CSP PrefixDMOnly $ T.tail pat
-                  | normalChannelSigil `T.isPrefixOf` pat -> Just $ CSP PrefixNonDMOnly $ T.tail pat
-                  | otherwise -> Nothing
-
-    (pat1, pfx) <- case "^" `T.isPrefixOf` pat of
-        True  -> return (T.tail pat, Just Prefix)
-        False -> return (pat, Nothing)
-
-    (pat2, sfx) <- case "$" `T.isSuffixOf` pat1 of
-        True  -> return (T.init pat1, Just Suffix)
-        False -> return (pat1, Nothing)
-
-    only <|> case (pfx, sfx) of
-        (Nothing, Nothing)         -> return $ CSP Infix  pat2
-        (Just Prefix, Nothing)     -> return $ CSP Prefix pat2
-        (Nothing, Just Suffix)     -> return $ CSP Suffix pat2
-        (Just Prefix, Just Suffix) -> return $ CSP Equal  pat2
-        tys                        -> error $ "BUG: invalid channel select case: " <> show tys
-
--- Update the channel selection mode match cursor. The argument function
--- determines how to navigate to the next item.
-updateSelectedMatch :: (Z.Zipper ChannelListGroup ChannelSelectMatch -> Z.Zipper ChannelListGroup ChannelSelectMatch)
-                    -> MH ()
-updateSelectedMatch nextItem =
-    csChannelSelectState.channelSelectMatches %= nextItem
diff --git a/src/State/Channels.hs b/src/State/Channels.hs
deleted file mode 100644
--- a/src/State/Channels.hs
+++ /dev/null
@@ -1,1082 +0,0 @@
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE MultiWayIf #-}
-module State.Channels
-  ( updateSidebar
-  , updateViewed
-  , updateViewedChan
-  , refreshChannel
-  , refreshChannelsAndUsers
-  , setFocus
-  , refreshChannelById
-  , applyPreferenceChange
-  , leaveChannel
-  , leaveCurrentChannel
-  , getNextUnreadChannel
-  , getNextUnreadUserOrChannel
-  , nextUnreadChannel
-  , nextUnreadUserOrChannel
-  , createOrFocusDMChannel
-  , clearChannelUnreadStatus
-  , prevChannel
-  , nextChannel
-  , recentChannel
-  , setReturnChannel
-  , resetReturnChannel
-  , hideDMChannel
-  , createGroupChannel
-  , showGroupChannelPref
-  , channelHistoryForward
-  , channelHistoryBackward
-  , handleNewChannel
-  , createOrdinaryChannel
-  , handleChannelInvite
-  , addUserByNameToCurrentChannel
-  , addUserToCurrentChannel
-  , removeUserFromCurrentChannel
-  , removeChannelFromState
-  , isRecentChannel
-  , isReturnChannel
-  , isCurrentChannel
-  , deleteCurrentChannel
-  , startLeaveCurrentChannel
-  , joinChannel
-  , joinChannelByName
-  , changeChannelByName
-  , setChannelTopic
-  , setChannelPurpose
-  , beginCurrentChannelDeleteConfirm
-  , toggleChannelListVisibility
-  , showChannelInSidebar
-  , updateChannelNotifyProps
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Main ( getVtyHandle, viewportScroll, vScrollToBeginning
-                            , invalidateCache, invalidateCacheEntry )
-import           Brick.Widgets.Edit ( applyEdit, getEditContents, editContentsL )
-import           Control.Concurrent.Async ( runConcurrently, Concurrently(..) )
-import           Control.Exception ( SomeException, try )
-import           Data.Char ( isAlphaNum )
-import qualified Data.HashMap.Strict as HM
-import qualified Data.Foldable as F
-import           Data.List ( nub )
-import           Data.Maybe ( fromJust )
-import qualified Data.Set as S
-import qualified Data.Sequence as Seq
-import qualified Data.Text as T
-import           Data.Text.Zipper ( textZipper, clearZipper, insertMany, gotoEOL )
-import           Data.Time.Clock ( getCurrentTime )
-import qualified Graphics.Vty as Vty
-import           Lens.Micro.Platform
-
-import qualified Network.Mattermost.Endpoints as MM
-import           Network.Mattermost.Lenses
-import           Network.Mattermost.Types
-
-import           Constants ( normalChannelSigil )
-import           InputHistory
-import           State.Common
-import {-# SOURCE #-} State.Messages ( fetchVisibleIfNeeded )
-import           State.Users
-import           State.Flagging
-import           Types
-import           Types.Common
-import           Zipper ( Zipper )
-import qualified Zipper as Z
-
-
-updateSidebar :: MH ()
-updateSidebar = do
-    -- Invalidate the cached sidebar rendering since we are about to
-    -- change the underlying state
-    mh $ invalidateCacheEntry ChannelSidebar
-
-    -- Get the currently-focused channel ID so we can compare after the
-    -- zipper is rebuilt
-    cconfig <- use csClientConfig
-    oldCid <- use csCurrentChannelId
-
-    -- Update the zipper
-    cs <- use csChannels
-    us <- getUsers
-    prefs <- use (csResources.crUserPreferences)
-    now <- liftIO getCurrentTime
-    config <- use (csResources.crConfiguration)
-
-    let zl = mkChannelZipperList now config cconfig prefs cs us
-    csFocus %= Z.updateList zl
-
-    -- Schedule the current sidebar for user status updates at the end
-    -- of this MH action.
-    newZ <- use csFocus
-    myId <- gets myUserId
-    scheduleUserStatusFetches $ myId : userIdsFromZipper newZ
-
-    -- Update the window title based on the unread status of the
-    -- channels.
-    let anyUnread = any channelListGroupHasUnread $ fst <$> zl
-        title = "matterhorn" <> if anyUnread then "(*)" else ""
-
-    vty <- mh getVtyHandle
-    liftIO $ Vty.setWindowTitle vty title
-
-    -- If the zipper rebuild caused the current channel to change, such
-    -- as when the previously-focused channel was removed, we need to
-    -- call fetchVisibleIfNeeded on the newly-focused channel to ensure
-    -- that it gets loaded.
-    newCid <- use csCurrentChannelId
-    when (newCid /= oldCid) $
-        fetchVisibleIfNeeded
-
-updateViewed :: Bool -> MH ()
-updateViewed updatePrev = do
-    csCurrentChannel.ccInfo.cdMentionCount .= 0
-    updateViewedChan updatePrev =<< use csCurrentChannelId
-
--- | When a new channel has been selected for viewing, this will
--- notify the server of the change, and also update the local channel
--- state to set the last-viewed time for the previous channel and
--- update the viewed time to now for the newly selected channel.
---
--- The boolean argument indicates whether the view time of the previous
--- channel (if any) should be updated, too. We typically want to do that
--- only on channel switching; when we just want to update the view time
--- of the specified channel, False should be provided.
-updateViewedChan :: Bool -> ChannelId -> MH ()
-updateViewedChan updatePrev cId = use csConnectionStatus >>= \case
-    Connected -> do
-        -- Only do this if we're connected to avoid triggering noisy
-        -- exceptions.
-        pId <- if updatePrev
-               then use csRecentChannel
-               else return Nothing
-        doAsyncChannelMM Preempt cId
-          (\s c -> MM.mmViewChannel UserMe c pId s)
-          (\c () -> Just $ setLastViewedFor pId c)
-    Disconnected ->
-        -- Cannot update server; make no local updates to avoid getting
-        -- out of sync with the server. Assumes that this is a temporary
-        -- break in connectivity and that after the connection is
-        -- restored, the user's normal activities will update state as
-        -- appropriate. If connectivity is permanently lost, managing
-        -- this state is irrelevant.
-        return ()
-
-toggleChannelListVisibility :: MH ()
-toggleChannelListVisibility = do
-    mh invalidateCache
-    csShowChannelList %= not
-
--- | If the current channel is a DM channel with a single user or a
--- group of users, hide it from the sidebar and adjust the server-side
--- preference to hide it persistently.
---
--- If the current channel is any other kind of channel, complain with a
--- usage error.
-hideDMChannel :: ChannelId -> MH ()
-hideDMChannel cId = do
-    me <- gets myUser
-    session <- getSession
-    withChannel cId $ \chan -> do
-        case chan^.ccInfo.cdType of
-            Direct -> do
-                let pref = showDirectChannelPref (me^.userIdL) uId False
-                    Just uId = chan^.ccInfo.cdDMUserId
-                csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing
-                doAsyncWith Preempt $ do
-                    MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session
-                    return Nothing
-            Group -> do
-                let pref = hideGroupChannelPref cId (me^.userIdL)
-                csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing
-                doAsyncWith Preempt $ do
-                    MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session
-                    return Nothing
-            _ -> do
-                mhError $ GenericError "Cannot hide this channel. Consider using /leave instead."
-
--- | Called on async completion when the currently viewed channel has
--- been updated (i.e., just switched to this channel) to update local
--- state.
-setLastViewedFor :: Maybe ChannelId -> ChannelId -> MH ()
-setLastViewedFor prevId cId = do
-    chan <- use (csChannels.to (findChannelById cId))
-    -- Update new channel's viewed time, creating the channel if needed
-    case chan of
-        Nothing ->
-            -- It's possible for us to get spurious WMChannelViewed
-            -- events from the server, e.g. for channels that have been
-            -- deleted. So here we ignore the request since it's hard to
-            -- detect it before this point.
-            return ()
-        Just _  ->
-          -- The server has been sent a viewed POST update, but there is
-          -- no local information on what timestamp the server actually
-          -- recorded. There are a couple of options for setting the
-          -- local value of the viewed time:
-          --
-          --   1. Attempting to locally construct a value, which would
-          --      involve scanning all (User) messages in the channel
-          --      to find the maximum of the created date, the modified
-          --      date, or the deleted date, and assuming that maximum
-          --      mostly matched the server's viewed time.
-          --
-          --   2. Issuing a channel metadata request to get the server's
-          --      new concept of the viewed time.
-          --
-          --   3. Having the "chan/viewed" POST that was just issued
-          --      return a value from the server. See
-          --      https://github.com/mattermost/platform/issues/6803.
-          --
-          -- Method 3 would be the best and most lightweight. Until that
-          -- is available, Method 2 will be used. The downside to Method
-          -- 2 is additional client-server messaging, and a delay in
-          -- updating the client data, but it's also immune to any new
-          -- or removed Message date fields, or anything else that would
-          -- contribute to the viewed/updated times on the server.
-          doAsyncChannelMM Preempt cId (\ s _ ->
-                                           (,) <$> MM.mmGetChannel cId s
-                                               <*> MM.mmGetChannelMember cId UserMe s)
-          (\pcid (cwd, member) -> Just $ csChannel(pcid).ccInfo %= channelInfoFromChannelWithData cwd member)
-
-    -- Update the old channel's previous viewed time (allows tracking of
-    -- new messages)
-    case prevId of
-      Nothing -> return ()
-      Just p -> clearChannelUnreadStatus p
-
--- | Refresh information about all channels and users. This is usually
--- triggered when a reconnect event for the WebSocket to the server
--- occurs.
-refreshChannelsAndUsers :: MH ()
-refreshChannelsAndUsers = do
-    session <- getSession
-    myTId <- gets myTeamId
-    me <- gets myUser
-    knownUsers <- gets allUserIds
-    doAsyncWith Preempt $ do
-      (chans, datas) <- runConcurrently $ (,)
-                       <$> Concurrently (MM.mmGetChannelsForUser UserMe myTId session)
-                       <*> Concurrently (MM.mmGetChannelMembersForUser UserMe myTId session)
-
-      -- Collect all user IDs associated with DM channels so we can
-      -- bulk-fetch their user records.
-      let dmUsers = catMaybes $ flip map (F.toList chans) $ \chan ->
-              case chan^.channelTypeL of
-                  Direct -> case userIdForDMChannel (userId me) (sanitizeUserText $ channelName chan) of
-                        Nothing -> Nothing
-                        Just otherUserId -> Just otherUserId
-                  _ -> Nothing
-          uIdsToFetch = nub $ userId me : knownUsers <> dmUsers
-
-          dataMap = HM.fromList $ toList $ (\d -> (channelMemberChannelId d, d)) <$> datas
-          mkPair chan = (chan, fromJust $ HM.lookup (channelId chan) dataMap)
-          chansWithData = mkPair <$> chans
-
-      return $ Just $
-          -- Fetch user data associated with DM channels
-          handleNewUsers (Seq.fromList uIdsToFetch) $ do
-              -- Then refresh all loaded channels
-              forM_ chansWithData $ uncurry (refreshChannel SidebarUpdateDeferred)
-              updateSidebar
-
--- | Refresh information about a specific channel.  The channel
--- metadata is refreshed, and if this is a loaded channel, the
--- scrollback is updated as well.
---
--- The sidebar update argument indicates whether this refresh should
--- also update the sidebar. Ordinarily you want this, so pass
--- SidebarUpdateImmediate unless you are very sure you know what you are
--- doing, i.e., you are very sure that a call to refreshChannel will
--- be followed immediately by a call to updateSidebar. We provide this
--- control so that channel refreshes can be batched and then a single
--- updateSidebar call can be used instead of the default behavior of
--- calling it once per refreshChannel call, which is the behavior if the
--- immediate setting is passed here.
-refreshChannel :: SidebarUpdate -> Channel -> ChannelMember -> MH ()
-refreshChannel upd chan member = do
-    let cId = getId chan
-    myTId <- gets myTeamId
-    let ourTeam = channelTeamId chan == Nothing ||
-                  Just myTId == channelTeamId chan
-
-    case not ourTeam of
-        True -> return ()
-        False -> do
-            -- If this channel is unknown, register it first.
-            mChan <- preuse (csChannel(cId))
-            when (isNothing mChan) $
-                handleNewChannel False upd chan member
-
-            updateChannelInfo cId chan member
-
-handleNewChannel :: Bool -> SidebarUpdate -> Channel -> ChannelMember -> MH ()
-handleNewChannel = handleNewChannel_ True
-
-handleNewChannel_ :: Bool
-                  -- ^ Whether to permit this call to recursively
-                  -- schedule itself for later if it can't locate
-                  -- a DM channel user record. This is to prevent
-                  -- uncontrolled recursion.
-                  -> Bool
-                  -- ^ Whether to switch to the new channel once it has
-                  -- been installed.
-                  -> SidebarUpdate
-                  -- ^ Whether to update the sidebar, in case the caller
-                  -- wants to batch these before updating it. Pass
-                  -- SidebarUpdateImmediate unless you know what
-                  -- you are doing, i.e., unless you intend to call
-                  -- updateSidebar yourself after calling this.
-                  -> Channel
-                  -- ^ The channel to install.
-                  -> ChannelMember
-                  -> MH ()
-handleNewChannel_ permitPostpone switch sbUpdate nc member = do
-    -- Only add the channel to the state if it isn't already known.
-    me <- gets myUser
-    mChan <- preuse (csChannel(getId nc))
-    case mChan of
-        Just _ -> when switch $ setFocus (getId nc)
-        Nothing -> do
-            -- Create a new ClientChannel structure
-            cChannel <- (ccInfo %~ channelInfoFromChannelWithData nc member) <$>
-                       makeClientChannel (me^.userIdL) nc
-
-            st <- use id
-
-            -- Add it to the message map, and to the name map so we
-            -- can look it up by name. The name we use for the channel
-            -- depends on its type:
-            let chType = nc^.channelTypeL
-
-            -- Get the channel name. If we couldn't, that means we have
-            -- async work to do before we can register this channel (in
-            -- which case abort because we got rescheduled).
-            register <- case chType of
-                Direct -> case userIdForDMChannel (myUserId st) (sanitizeUserText $ channelName nc) of
-                    Nothing -> return True
-                    Just otherUserId ->
-                        case userById otherUserId st of
-                            -- If we found a user ID in the channel
-                            -- name string but don't have that user's
-                            -- metadata, postpone adding this channel
-                            -- until we have fetched the metadata. This
-                            -- can happen when we have a channel record
-                            -- for a user that is no longer in the
-                            -- current team. To avoid recursion due to a
-                            -- problem, ensure that the rescheduled new
-                            -- channel handler is not permitted to try
-                            -- this again.
-                            --
-                            -- If we're already in a recursive attempt
-                            -- to register this channel and still
-                            -- couldn't find a username, just bail and
-                            -- use the synthetic name (this has the same
-                            -- problems as above).
-                            Nothing -> do
-                                case permitPostpone of
-                                    False -> return True
-                                    True -> do
-                                        mhLog LogAPI $ T.pack $ "handleNewChannel_: about to call handleNewUsers for " <> show otherUserId
-                                        handleNewUsers (Seq.singleton otherUserId) (return ())
-                                        doAsyncWith Normal $
-                                            return $ Just $ handleNewChannel_ False switch sbUpdate nc member
-                                        return False
-                            Just _ -> return True
-                _ -> return True
-
-            when register $ do
-                csChannels %= addChannel (getId nc) cChannel
-                when (sbUpdate == SidebarUpdateImmediate) $ do
-                    -- Note that we only check for whether we should
-                    -- switch to this channel when doing a sidebar
-                    -- update, since that's the only case where it's
-                    -- possible to do so.
-                    updateSidebar
-
-                    -- Finally, set our focus to the newly created
-                    -- channel if the caller requested a change of
-                    -- channel. Also consider the last join request
-                    -- state field in case this is an asynchronous
-                    -- channel addition triggered by a /join.
-                    pending1 <- checkPendingChannelChange (ChangeByChannelId $ getId nc)
-                    pending2 <- case cChannel^.ccInfo.cdDMUserId of
-                        Nothing -> return False
-                        Just uId -> checkPendingChannelChange (ChangeByUserId uId)
-
-                    when (switch || pending1 || pending2) $ setFocus (getId nc)
-
--- | Check to see whether the specified channel has been queued up to
--- be switched to.  Note that this condition is only cleared by the
--- actual setFocus switch to the channel because there may be multiple
--- operations that must complete before the channel is fully ready for
--- display/use.
-checkPendingChannelChange :: PendingChannelChange -> MH Bool
-checkPendingChannelChange change = (==) (Just change) <$> use csPendingChannelChange
-
--- | Update the indicated Channel entry with the new data retrieved from
--- the Mattermost server. Also update the channel name if it changed.
-updateChannelInfo :: ChannelId -> Channel -> ChannelMember -> MH ()
-updateChannelInfo cid new member = do
-    mh $ invalidateCacheEntry $ ChannelMessages cid
-    csChannel(cid).ccInfo %= channelInfoFromChannelWithData new member
-    updateSidebar
-
-setFocus :: ChannelId -> MH ()
-setFocus cId = do
-    showChannelInSidebar cId True
-    setFocusWith True (Z.findRight ((== cId) . channelListEntryChannelId)) (return ())
-
-showChannelInSidebar :: ChannelId -> Bool -> MH ()
-showChannelInSidebar cId setPending = do
-    mChan <- preuse $ csChannel cId
-    me <- gets myUser
-    prefs <- use (csResources.crUserPreferences)
-    session <- getSession
-
-    case mChan of
-        Nothing ->
-          -- The requested channel doesn't actually exist yet, so no
-          -- action can be taken.  It's likely that this is a
-          -- pendingChannel situation and not all of the operations to
-          -- locally define the channel have completed, in which case
-          -- this code will be re-entered later and the mChan will be
-          -- known.
-          return ()
-        Just ch -> do
-
-            -- Able to successfully switch to a known channel.  This
-            -- should clear any pending channel intention.  If the
-            -- intention was for this channel, then: done.  If the
-            -- intention was for a different channel, reaching this
-            -- point means that the pending is still outstanding but
-            -- that the user identified a new channel which *was*
-            -- displayable, and the UI should always prefer to SATISFY
-            -- the user's latest request over any pending/background
-            -- task.
-            csPendingChannelChange .= Nothing
-
-            now <- liftIO getCurrentTime
-            csChannel(cId).ccInfo.cdSidebarShowOverride .= Just now
-            updateSidebar
-
-            case ch^.ccInfo.cdType of
-                Direct -> do
-                    let Just uId = ch^.ccInfo.cdDMUserId
-                    case dmChannelShowPreference prefs uId of
-                        Just False -> do
-                            let pref = showDirectChannelPref (me^.userIdL) uId True
-                            when setPending $
-                                csPendingChannelChange .= Just (ChangeByChannelId $ ch^.ccInfo.cdChannelId)
-                            doAsyncWith Preempt $ do
-                                MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session
-                                return Nothing
-                        _ -> return ()
-
-                Group ->
-                    case groupChannelShowPreference prefs cId of
-                        Just False -> do
-                            let pref = showGroupChannelPref cId (me^.userIdL)
-                            when setPending $
-                                csPendingChannelChange .= Just (ChangeByChannelId $ ch^.ccInfo.cdChannelId)
-                            doAsyncWith Preempt $ do
-                                MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session
-                                return Nothing
-                        _ -> return ()
-
-                _ -> return ()
-
-setFocusWith :: Bool
-             -> (Zipper ChannelListGroup ChannelListEntry
-             -> Zipper ChannelListGroup ChannelListEntry)
-             -> MH ()
-             -> MH ()
-setFocusWith updatePrev f onNoChange = do
-    oldZipper <- use csFocus
-    let newZipper = f oldZipper
-        newFocus = Z.focus newZipper
-        oldFocus = Z.focus oldZipper
-
-    -- If we aren't changing anything, skip all the book-keeping because
-    -- we'll end up clobbering things like csRecentChannel.
-    if newFocus /= oldFocus
-       then do
-          mh $ invalidateCacheEntry ChannelSidebar
-          resetAutocomplete
-          preChangeChannelCommon
-          csFocus .= newZipper
-
-          now <- liftIO getCurrentTime
-          newCid <- use csCurrentChannelId
-          csChannel(newCid).ccInfo.cdSidebarShowOverride .= Just now
-
-          updateViewed updatePrev
-          postChangeChannelCommon
-       else onNoChange
-
-postChangeChannelCommon :: MH ()
-postChangeChannelCommon = do
-    resetEditorState
-    updateChannelListScroll
-    loadLastEdit
-    fetchVisibleIfNeeded
-
-loadLastEdit :: MH ()
-loadLastEdit = do
-    cId <- use csCurrentChannelId
-
-    oldEphemeral <- preuse (csChannel(cId).ccEditState)
-    case oldEphemeral of
-        Nothing -> return ()
-        Just e -> csEditState.cedEphemeral .= e
-
-    loadLastChannelInput
-
-loadLastChannelInput :: MH ()
-loadLastChannelInput = do
-    cId <- use csCurrentChannelId
-    inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)
-    case inputHistoryPos of
-        Just i -> loadHistoryEntryToEditor cId i
-        Nothing -> do
-            (lastEdit, lastEditMode) <- use (csEditState.cedEphemeral.eesLastInput)
-            csEditState.cedEditor %= (applyEdit $ insertMany lastEdit . clearZipper)
-            csEditState.cedEditMode .= lastEditMode
-
-updateChannelListScroll :: MH ()
-updateChannelListScroll = do
-    mh $ vScrollToBeginning (viewportScroll ChannelList)
-
-preChangeChannelCommon :: MH ()
-preChangeChannelCommon = do
-    cId <- use csCurrentChannelId
-    csRecentChannel .= Just cId
-    saveCurrentEdit
-
-resetEditorState :: MH ()
-resetEditorState = do
-    csEditState.cedEditMode .= NewPost
-    clearEditor
-
-clearEditor :: MH ()
-clearEditor = csEditState.cedEditor %= applyEdit clearZipper
-
-saveCurrentEdit :: MH ()
-saveCurrentEdit = do
-    saveCurrentChannelInput
-
-    oldEphemeral <- use (csEditState.cedEphemeral)
-    cId <- use csCurrentChannelId
-    csChannel(cId).ccEditState .= oldEphemeral
-
-saveCurrentChannelInput :: MH ()
-saveCurrentChannelInput = do
-    cmdLine <- use (csEditState.cedEditor)
-    mode <- use (csEditState.cedEditMode)
-
-    -- Only save the editor contents if the user is not navigating the
-    -- history.
-    inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)
-
-    when (isNothing inputHistoryPos) $
-        csEditState.cedEphemeral.eesLastInput .=
-           (T.intercalate "\n" $ getEditContents $ cmdLine, mode)
-
-hideGroupChannelPref :: ChannelId -> UserId -> Preference
-hideGroupChannelPref cId uId =
-    Preference { preferenceCategory = PreferenceCategoryGroupChannelShow
-               , preferenceValue = PreferenceValue "false"
-               , preferenceName = PreferenceName $ idString cId
-               , preferenceUserId = uId
-               }
-
-showGroupChannelPref :: ChannelId -> UserId -> Preference
-showGroupChannelPref cId uId =
-    Preference { preferenceCategory = PreferenceCategoryGroupChannelShow
-               , preferenceValue = PreferenceValue "true"
-               , preferenceName = PreferenceName $ idString cId
-               , preferenceUserId = uId
-               }
-
-showDirectChannelPref :: UserId -> UserId -> Bool -> Preference
-showDirectChannelPref myId otherId s =
-    Preference { preferenceCategory = PreferenceCategoryDirectChannelShow
-               , preferenceValue = if s then PreferenceValue "true"
-                                        else PreferenceValue "false"
-               , preferenceName = PreferenceName $ idString otherId
-               , preferenceUserId = myId
-               }
-
-applyPreferenceChange :: Preference -> MH ()
-applyPreferenceChange pref = do
-    -- always update our user preferences accordingly
-    csResources.crUserPreferences %= setUserPreferences (Seq.singleton pref)
-
-    -- Invalidate the entire rendering cache since many things depend on
-    -- user preferences
-    mh invalidateCache
-
-    if
-      | Just f <- preferenceToFlaggedPost pref -> do
-          updateMessageFlag (flaggedPostId f) (flaggedPostStatus f)
-
-      | Just d <- preferenceToDirectChannelShowStatus pref -> do
-          updateSidebar
-
-          cs <- use csChannels
-
-          -- We need to check on whether this preference was to show a
-          -- channel and, if so, whether it was the one we attempted to
-          -- switch to (thus triggering the preference change). If so,
-          -- we need to switch to it now.
-          let Just cId = getDmChannelFor (directChannelShowUserId d) cs
-          case directChannelShowValue d of
-              True -> do
-                  pending <- checkPendingChannelChange $ ChangeByChannelId cId
-                  when pending $ setFocus cId
-              False -> do
-                  csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing
-
-      | Just g <- preferenceToGroupChannelPreference pref -> do
-          updateSidebar
-
-          -- We need to check on whether this preference was to show a
-          -- channel and, if so, whether it was the one we attempted to
-          -- switch to (thus triggering the preference change). If so,
-          -- we need to switch to it now.
-          let cId = groupChannelId g
-          case groupChannelShow g of
-              True -> do
-                  pending <- checkPendingChannelChange $ ChangeByChannelId cId
-                  when pending $ setFocus cId
-              False -> do
-                  csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing
-
-      | otherwise -> return ()
-
-refreshChannelById :: ChannelId -> MH ()
-refreshChannelById cId = do
-    session <- getSession
-    doAsyncWith Preempt $ do
-        cwd <- MM.mmGetChannel cId session
-        member <- MM.mmGetChannelMember cId UserMe session
-        return $ Just $ do
-            refreshChannel SidebarUpdateImmediate cwd member
-
-removeChannelFromState :: ChannelId -> MH ()
-removeChannelFromState cId = do
-    withChannel cId $ \ chan -> do
-        when (chan^.ccInfo.cdType /= Direct) $ do
-            origFocus <- use csCurrentChannelId
-            when (origFocus == cId) nextChannelSkipPrevView
-            -- Update input history
-            csEditState.cedInputHistory         %= removeChannelHistory cId
-            -- Update msgMap
-            csChannels                          %= removeChannel cId
-            -- Remove from focus zipper
-            csFocus %= Z.filterZipper ((/= cId) . channelListEntryChannelId)
-            updateSidebar
-
-nextChannel :: MH ()
-nextChannel = do
-    resetReturnChannel
-    setFocusWith True Z.right (return ())
-
--- | This is almost never what you want; we use this when we delete a
--- channel and we don't want to update the deleted channel's view time.
-nextChannelSkipPrevView :: MH ()
-nextChannelSkipPrevView = setFocusWith False Z.right (return ())
-
-prevChannel :: MH ()
-prevChannel = do
-    resetReturnChannel
-    setFocusWith True Z.left (return ())
-
-recentChannel :: MH ()
-recentChannel = do
-  recent <- use csRecentChannel
-  case recent of
-    Nothing  -> return ()
-    Just cId -> do
-        ret <- use csReturnChannel
-        when (ret == Just cId) resetReturnChannel
-        setFocus cId
-
-resetReturnChannel :: MH ()
-resetReturnChannel = do
-  val <- use csReturnChannel
-  case val of
-      Nothing -> return ()
-      Just _ -> do
-          mh $ invalidateCacheEntry ChannelSidebar
-          csReturnChannel .= Nothing
-
-gotoReturnChannel :: MH ()
-gotoReturnChannel = do
-  ret <- use csReturnChannel
-  case ret of
-    Nothing  -> return ()
-    Just cId -> do
-        resetReturnChannel
-        setFocus cId
-
-setReturnChannel :: MH ()
-setReturnChannel = do
-  ret <- use csReturnChannel
-  case ret of
-    Nothing  -> do
-        cId <- use csCurrentChannelId
-        csReturnChannel .= Just cId
-        mh $ invalidateCacheEntry ChannelSidebar
-    Just _ -> return ()
-
-nextUnreadChannel :: MH ()
-nextUnreadChannel = do
-    st <- use id
-    setReturnChannel
-    setFocusWith True (getNextUnreadChannel st) gotoReturnChannel
-
-nextUnreadUserOrChannel :: MH ()
-nextUnreadUserOrChannel = do
-    st <- use id
-    setReturnChannel
-    setFocusWith True (getNextUnreadUserOrChannel st) gotoReturnChannel
-
-leaveChannel :: ChannelId -> MH ()
-leaveChannel cId = leaveChannelIfPossible cId False
-
-leaveChannelIfPossible :: ChannelId -> Bool -> MH ()
-leaveChannelIfPossible cId delete = do
-    st <- use id
-    me <- gets myUser
-    let isMe u = u^.userIdL == me^.userIdL
-
-    case st ^? csChannel(cId).ccInfo of
-        Nothing -> return ()
-        Just cInfo -> case canLeaveChannel cInfo of
-            False -> return ()
-            True ->
-                -- The server will reject an attempt to leave a private
-                -- channel if we're the only member. To check this, we
-                -- just ask for the first two members of the channel.
-                -- If there is only one, it must be us: hence the "all
-                -- isMe" check below. If there are two members, it
-                -- doesn't matter who they are, because we just know
-                -- that we aren't the only remaining member, so we can't
-                -- delete the channel.
-                doAsyncChannelMM Preempt cId
-                    (\s _ ->
-                      let query = MM.defaultUserQuery
-                           { MM.userQueryPage = Just 0
-                           , MM.userQueryPerPage = Just 2
-                           , MM.userQueryInChannel = Just cId
-                           }
-                      in toList <$> MM.mmGetUsers query s)
-                    (\_ members -> Just $ do
-                        -- If the channel is private:
-                        -- * leave it if we aren't the last member.
-                        -- * delete it if we are.
-                        --
-                        -- Otherwise:
-                        -- * leave (or delete) the channel as specified
-                        -- by the delete argument.
-                        let func = case cInfo^.cdType of
-                                Private -> case all isMe members of
-                                    True -> (\ s c -> MM.mmDeleteChannel c s)
-                                    False -> (\ s c -> MM.mmRemoveUserFromChannel c UserMe s)
-                                Group ->
-                                    \s _ ->
-                                        let pref = hideGroupChannelPref cId (me^.userIdL)
-                                        in MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) s
-                                _ -> if delete
-                                     then (\ s c -> MM.mmDeleteChannel c s)
-                                     else (\ s c -> MM.mmRemoveUserFromChannel c UserMe s)
-
-                        doAsyncChannelMM Preempt cId func endAsyncNOP
-                    )
-
-getNextUnreadChannel :: ChatState
-                     -> (Zipper a ChannelListEntry -> Zipper a ChannelListEntry)
-getNextUnreadChannel st =
-    -- The next channel with unread messages must also be a channel
-    -- other than the current one, since the zipper may be on a channel
-    -- that has unread messages and will stay that way until we leave
-    -- it- so we need to skip that channel when doing the zipper search
-    -- for the next candidate channel.
-    Z.findRight (\e ->
-                let cId = channelListEntryChannelId e
-                in hasUnread st cId && (cId /= st^.csCurrentChannelId))
-
-getNextUnreadUserOrChannel :: ChatState
-                           -> Zipper a ChannelListEntry
-                           -> Zipper a ChannelListEntry
-getNextUnreadUserOrChannel st z =
-    -- Find the next unread channel, prefering direct messages
-    let cur = st^.csCurrentChannelId
-        matches e = entryIsDMEntry e && isFresh (channelListEntryChannelId e)
-        isFresh c = hasUnread st c && (c /= cur)
-    in fromMaybe (Z.findRight (isFresh . channelListEntryChannelId) z)
-                 (Z.maybeFindRight matches z)
-
-leaveCurrentChannel :: MH ()
-leaveCurrentChannel = use csCurrentChannelId >>= leaveChannel
-
-createGroupChannel :: Text -> MH ()
-createGroupChannel usernameList = do
-    me <- gets myUser
-    session <- getSession
-    cs <- use csChannels
-
-    doAsyncWith Preempt $ do
-        let usernames = Seq.fromList $ fmap trimUserSigil $ T.words usernameList
-        results <- MM.mmGetUsersByUsernames usernames session
-
-        -- If we found all of the users mentioned, then create the group
-        -- channel.
-        case length results == length usernames of
-            True -> do
-                chan <- MM.mmCreateGroupMessageChannel (userId <$> results) session
-                return $ Just $ do
-                    case findChannelById (channelId chan) cs of
-                      Just _ ->
-                          -- If we already know about the channel ID,
-                          -- that means the channel already exists so
-                          -- we can just switch to it.
-                          setFocus (channelId chan)
-                      Nothing -> do
-                          csPendingChannelChange .= (Just $ ChangeByChannelId $ channelId chan)
-                          let pref = showGroupChannelPref (channelId chan) (me^.userIdL)
-                          doAsyncWith Normal $ do
-                            MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session
-                            return $ Just $ applyPreferenceChange pref
-            False -> do
-                let foundUsernames = userUsername <$> results
-                    missingUsernames = S.toList $
-                                       S.difference (S.fromList $ F.toList usernames)
-                                                    (S.fromList $ F.toList foundUsernames)
-                return $ Just $ do
-                    forM_ missingUsernames (mhError . NoSuchUser)
-
-channelHistoryForward :: MH ()
-channelHistoryForward = do
-    cId <- use csCurrentChannelId
-    inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)
-    case inputHistoryPos of
-        Just i
-          | i == 0 -> do
-            -- Transition out of history navigation
-            csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing
-            loadLastChannelInput
-          | otherwise -> do
-            let newI = i - 1
-            loadHistoryEntryToEditor cId newI
-            csEditState.cedEphemeral.eesInputHistoryPosition .= (Just newI)
-        _ -> return ()
-
-loadHistoryEntryToEditor :: ChannelId -> Int -> MH ()
-loadHistoryEntryToEditor cId idx = do
-    inputHistory <- use (csEditState.cedInputHistory)
-    case getHistoryEntry cId idx inputHistory of
-        Nothing -> return ()
-        Just entry -> do
-            let eLines = T.lines entry
-                mv = if length eLines == 1 then gotoEOL else id
-            csEditState.cedEditor.editContentsL .= (mv $ textZipper eLines Nothing)
-
-channelHistoryBackward :: MH ()
-channelHistoryBackward = do
-    cId <- use csCurrentChannelId
-    inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)
-    saveCurrentChannelInput
-
-    let newI = maybe 0 (+ 1) inputHistoryPos
-    loadHistoryEntryToEditor cId newI
-    csEditState.cedEphemeral.eesInputHistoryPosition .= (Just newI)
-
-createOrdinaryChannel :: Bool -> Text -> MH ()
-createOrdinaryChannel public name = do
-    session <- getSession
-    myTId <- gets myTeamId
-    doAsyncWith Preempt $ do
-        -- create a new chat channel
-        let slug = T.map (\ c -> if isAlphaNum c then c else '-') (T.toLower name)
-            minChannel = MinChannel
-              { minChannelName        = slug
-              , minChannelDisplayName = name
-              , minChannelPurpose     = Nothing
-              , minChannelHeader      = Nothing
-              , minChannelType        = if public then Ordinary else Private
-              , minChannelTeamId      = myTId
-              }
-        tryMM (do c <- MM.mmCreateChannel minChannel session
-                  chan <- MM.mmGetChannel (getId c) session
-                  member <- MM.mmGetChannelMember (getId c) UserMe session
-                  return (chan, member)
-              )
-              (return . Just . uncurry (handleNewChannel True SidebarUpdateImmediate))
-
--- | When we are added to a channel not locally known about, we need
--- to fetch the channel info for that channel.
-handleChannelInvite :: ChannelId -> MH ()
-handleChannelInvite cId = do
-    session <- getSession
-    doAsyncWith Normal $ do
-        member <- MM.mmGetChannelMember cId UserMe session
-        tryMM (MM.mmGetChannel cId session)
-              (\cwd -> return $ Just $ do
-                  pending <- checkPendingChannelChange $ ChangeByChannelId cId
-                  handleNewChannel pending SidebarUpdateImmediate cwd member)
-
-addUserByNameToCurrentChannel :: Text -> MH ()
-addUserByNameToCurrentChannel uname =
-    withFetchedUser (UserFetchByUsername uname) addUserToCurrentChannel
-
-addUserToCurrentChannel :: UserInfo -> MH ()
-addUserToCurrentChannel u = do
-    cId <- use csCurrentChannelId
-    session <- getSession
-    let channelMember = MinChannelMember (u^.uiId) cId
-    doAsyncWith Normal $ do
-        tryMM (void $ MM.mmAddUser cId channelMember session)
-              (const $ return Nothing)
-
-removeUserFromCurrentChannel :: Text -> MH ()
-removeUserFromCurrentChannel uname =
-    withFetchedUser (UserFetchByUsername uname) $ \u -> do
-        cId <- use csCurrentChannelId
-        session <- getSession
-        doAsyncWith Normal $ do
-            tryMM (void $ MM.mmRemoveUserFromChannel cId (UserById $ u^.uiId) session)
-                  (const $ return Nothing)
-
-startLeaveCurrentChannel :: MH ()
-startLeaveCurrentChannel = do
-    cInfo <- use (csCurrentChannel.ccInfo)
-    case cInfo^.cdType of
-        Direct -> hideDMChannel (cInfo^.cdChannelId)
-        Group -> hideDMChannel (cInfo^.cdChannelId)
-        _ -> setMode LeaveChannelConfirm
-
-deleteCurrentChannel :: MH ()
-deleteCurrentChannel = do
-    setMode Main
-    cId <- use csCurrentChannelId
-    leaveChannelIfPossible cId True
-
-isCurrentChannel :: ChatState -> ChannelId -> Bool
-isCurrentChannel st cId = st^.csCurrentChannelId == cId
-
-isRecentChannel :: ChatState -> ChannelId -> Bool
-isRecentChannel st cId = st^.csRecentChannel == Just cId
-
-isReturnChannel :: ChatState -> ChannelId -> Bool
-isReturnChannel st cId = st^.csReturnChannel == Just cId
-
-joinChannelByName :: Text -> MH ()
-joinChannelByName rawName = do
-    session <- getSession
-    tId <- gets myTeamId
-    doAsyncWith Preempt $ do
-        result <- try $ MM.mmGetChannelByName tId (trimChannelSigil rawName) session
-        return $ Just $ case result of
-            Left (_::SomeException) -> mhError $ NoSuchChannel rawName
-            Right chan -> joinChannel $ getId chan
-
--- | If the user is not a member of the specified channel, submit a
--- request to join it. Otherwise switch to the channel.
-joinChannel :: ChannelId -> MH ()
-joinChannel chanId = do
-    setMode Main
-    mChan <- preuse (csChannel(chanId))
-    case mChan of
-        Just _ -> setFocus chanId
-        Nothing -> do
-            myId <- gets myUserId
-            let member = MinChannelMember myId chanId
-            csPendingChannelChange .= (Just $ ChangeByChannelId chanId)
-            doAsyncChannelMM Preempt chanId (\ s c -> MM.mmAddUser c member s) endAsyncNOP
-
-createOrFocusDMChannel :: UserInfo -> Maybe (ChannelId -> MH ()) -> MH ()
-createOrFocusDMChannel user successAct = do
-    cs <- use csChannels
-    case getDmChannelFor (user^.uiId) cs of
-        Just cId -> do
-            setFocus cId
-            case successAct of
-                Nothing -> return ()
-                Just act -> act cId
-        Nothing -> do
-            -- We have a user of that name but no channel. Time to make one!
-            myId <- gets myUserId
-            session <- getSession
-            csPendingChannelChange .= (Just $ ChangeByUserId $ user^.uiId)
-            doAsyncWith Normal $ do
-                -- create a new channel
-                chan <- MM.mmCreateDirectMessageChannel (user^.uiId, myId) session
-                return $ successAct <*> pure (channelId chan)
-
--- | This switches to the named channel or creates it if it is a missing
--- but valid user channel.
-changeChannelByName :: Text -> MH ()
-changeChannelByName name = do
-    mCId <- gets (channelIdByChannelName name)
-    mDMCId <- gets (channelIdByUsername name)
-
-    withFetchedUserMaybe (UserFetchByUsername name) $ \foundUser -> do
-        let err = mhError $ AmbiguousName name
-        case (mCId, mDMCId) of
-          (Nothing, Nothing) ->
-              case foundUser of
-                  -- We know about the user but there isn't already a DM
-                  -- channel, so create one.
-                  Just user -> createOrFocusDMChannel user Nothing
-                  -- There were no matches of any kind.
-                  Nothing -> mhError $ NoSuchChannel name
-          (Just cId, Nothing)
-              -- We matched a channel and there was an explicit sigil, so we
-              -- don't care about the username match.
-              | normalChannelSigil `T.isPrefixOf` name -> setFocus cId
-              -- We matched both a channel and a user, even though there is
-              -- no DM channel.
-              | Just _ <- foundUser -> err
-              -- We matched a channel only.
-              | otherwise -> setFocus cId
-          (Nothing, Just cId) ->
-              -- We matched a DM channel only.
-              setFocus cId
-          (Just _, Just _) ->
-              -- We matched both a channel and a DM channel.
-              err
-
-setChannelTopic :: Text -> MH ()
-setChannelTopic msg = do
-    cId <- use csCurrentChannelId
-    let patch = defaultChannelPatch { channelPatchHeader = Just msg }
-    doAsyncChannelMM Preempt cId
-        (\s _ -> MM.mmPatchChannel cId patch s)
-        (\_ _ -> Nothing)
-
-setChannelPurpose :: Text -> MH ()
-setChannelPurpose msg = do
-    cId <- use csCurrentChannelId
-    let patch = defaultChannelPatch { channelPatchPurpose = Just msg }
-    doAsyncChannelMM Preempt cId
-        (\s _ -> MM.mmPatchChannel cId patch s)
-        (\_ _ -> Nothing)
-
-beginCurrentChannelDeleteConfirm :: MH ()
-beginCurrentChannelDeleteConfirm = do
-    cId <- use csCurrentChannelId
-    withChannel cId $ \chan -> do
-        let chType = chan^.ccInfo.cdType
-        if chType /= Direct
-            then setMode DeleteChannelConfirm
-            else mhError $ GenericError "Direct message channels cannot be deleted."
-
-updateChannelNotifyProps :: ChannelId -> ChannelNotifyProps -> MH ()
-updateChannelNotifyProps cId notifyProps = do
-    mh $ invalidateCacheEntry ChannelSidebar
-    csChannel(cId).ccInfo.cdNotifyProps .= notifyProps
diff --git a/src/State/Common.hs b/src/State/Common.hs
deleted file mode 100644
--- a/src/State/Common.hs
+++ /dev/null
@@ -1,383 +0,0 @@
-module State.Common
-  (
-  -- * System interface
-    openURL
-  , runLoggedCommand
-
-  -- * Posts
-  , installMessagesFromPosts
-  , updatePostMap
-
-  -- * Utilities
-  , postInfoMessage
-  , postErrorMessageIO
-  , postErrorMessage'
-  , addEmoteFormatting
-  , removeEmoteFormatting
-
-  , fetchMentionedUsers
-  , doPendingUserFetches
-  , doPendingUserStatusFetches
-
-  , module State.Async
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Main ( invalidateCacheEntry )
-import           Control.Concurrent ( MVar, putMVar, forkIO )
-import           Control.Concurrent.Async ( concurrently )
-import qualified Control.Concurrent.STM as STM
-import           Control.Exception ( SomeException, try )
-import qualified Data.ByteString as BS
-import qualified Data.HashMap.Strict as HM
-import qualified Data.Sequence as Seq
-import qualified Data.Set as Set
-import qualified Data.Text as T
-import           Lens.Micro.Platform ( (.=), (%=), (%~), (.~) )
-import           System.Directory ( createDirectoryIfMissing )
-import           System.Environment.XDG.BaseDir ( getUserCacheDir )
-import           System.Exit ( ExitCode(..) )
-import           System.FilePath
-import           System.IO ( hGetContents, hFlush, hPutStrLn )
-import           System.Process ( proc, std_in, std_out, std_err, StdStream(..)
-                                , createProcess, waitForProcess )
-
-import           Network.Mattermost.Endpoints
-import           Network.Mattermost.Lenses
-import           Network.Mattermost.Types
-
-import           FilePaths ( xdgName )
-import           State.Async
-import           Types
-import           Types.Common
-import           Types.RichText ( unURL )
-
-
--- * Client Messages
-
--- | Given a collection of posts from the server, save the posts in the
--- global post map. Also convert the posts to Matterhorn's Message type
--- and return them along with the set of all usernames mentioned in the
--- text of the resulting messages.
---
--- This also sets the mFlagged field of each message based on whether
--- its post ID is a flagged post according to crFlaggedPosts at the time
--- of this call.
-installMessagesFromPosts :: Posts -> MH Messages
-installMessagesFromPosts postCollection = do
-  flags <- use (csResources.crFlaggedPosts)
-
-  -- Add all posts in this collection to the global post cache
-  updatePostMap postCollection
-
-  -- Build the ordered list of posts. Note that postsOrder lists the
-  -- posts most recent first, but we want most recent last.
-  let postsInOrder = findPost <$> (Seq.reverse $ postsOrder postCollection)
-      mkClientPost p = toClientPost p (postId <$> parent p)
-      clientPosts = mkClientPost <$> postsInOrder
-
-      addNext cp (msgs, us) =
-          let (msg, mUsernames) = clientPostToMessage cp
-          in (addMessage (maybeFlag flags msg) msgs, Set.union us mUsernames)
-      (ms, mentions) = foldr addNext (noMessages, mempty) clientPosts
-
-  fetchMentionedUsers mentions
-  return ms
-    where
-        maybeFlag flagSet msg
-          | Just (MessagePostId pId) <- msg^.mMessageId, pId `Set.member` flagSet
-            = msg & mFlagged .~ True
-          | otherwise = msg
-        parent x = do
-            parentId <- x^.postRootIdL
-            HM.lookup parentId (postCollection^.postsPostsL)
-        findPost pId = case HM.lookup pId (postsPosts postCollection) of
-            Nothing -> error $ "BUG: could not find post for post ID " <> show pId
-            Just post -> post
-
--- Add all posts in this collection to the global post cache
-updatePostMap :: Posts -> MH ()
-updatePostMap postCollection = do
-  -- Build a map from post ID to Matterhorn message, then add the new
-  -- messages to the global post map. We use the "postsPosts" field for
-  -- this because that might contain more messages than the "postsOrder"
-  -- list, since the former can contain other messages in threads that
-  -- the server sent us, even if those messages are not part of the
-  -- ordered post listing of "postsOrder."
-  let postMap = HM.fromList
-          [ ( pId
-            , fst $ clientPostToMessage (toClientPost x Nothing)
-            )
-          | (pId, x) <- HM.toList (postCollection^.postsPostsL)
-          ]
-  csPostMap %= HM.union postMap
-
--- | Add a 'ClientMessage' to the current channel's message list
-addClientMessage :: ClientMessage -> MH ()
-addClientMessage msg = do
-  cid <- use csCurrentChannelId
-  uuid <- generateUUID
-  let addCMsg = ccContents.cdMessages %~
-          (addMessage $ clientMessageToMessage msg & mMessageId .~ Just (MessageUUID uuid))
-  csChannels %= modifyChannelById cid addCMsg
-  mh $ invalidateCacheEntry $ ChannelMessages cid
-  mh $ invalidateCacheEntry ChannelSidebar
-
-  let msgTy = case msg^.cmType of
-        Error -> LogError
-        _     -> LogGeneral
-
-  mhLog msgTy $ T.pack $ show msg
-
--- | Add a new 'ClientMessage' representing an error message to
---   the current channel's message list
-postInfoMessage :: Text -> MH ()
-postInfoMessage info =
-    addClientMessage =<< newClientMessage Informative (sanitizeUserText' info)
-
--- | Add a new 'ClientMessage' representing an error message to
---   the current channel's message list
-postErrorMessage' :: Text -> MH ()
-postErrorMessage' err =
-    addClientMessage =<< newClientMessage Error (sanitizeUserText' err)
-
-postErrorMessageIO :: Text -> ChatState -> IO ChatState
-postErrorMessageIO err st = do
-  msg <- newClientMessage Error err
-  uuid <- generateUUID_IO
-  let cId = st ^. csCurrentChannelId
-      addEMsg = ccContents.cdMessages %~
-          (addMessage $ clientMessageToMessage msg & mMessageId .~ Just (MessageUUID uuid))
-  return $ st & csChannels %~ modifyChannelById cId addEMsg
-
-openURL :: OpenInBrowser -> MH Bool
-openURL thing = do
-    cfg <- use (csResources.crConfiguration)
-    case configURLOpenCommand cfg of
-        Nothing ->
-            return False
-        Just urlOpenCommand -> do
-            session <- getSession
-
-            -- Is the URL referring to an attachment?
-            let act = case thing of
-                    OpenLinkChoice link ->
-                        case link^.linkFileId of
-                            Nothing -> prepareLink link
-                            Just fId -> prepareAttachment fId session
-                    OpenLocalFile path ->
-                        return [path]
-
-            -- Is the URL-opening command interactive? If so, pause
-            -- Matterhorn and run the opener interactively. Otherwise
-            -- run the opener asynchronously and continue running
-            -- Matterhorn interactively.
-            case configURLOpenCommandInteractive cfg of
-                False -> do
-                    outputChan <- use (csResources.crSubprocessLog)
-                    doAsyncWith Preempt $ do
-                        args <- act
-                        runLoggedCommand False outputChan (T.unpack urlOpenCommand)
-                                         args Nothing Nothing
-                        return Nothing
-                True -> do
-                    -- If there isn't a new message cutoff showing in
-                    -- the current channel, set one. This way, while the
-                    -- user is gone using their interactive URL opener,
-                    -- when they return, any messages that arrive in the
-                    -- current channel will be displayed as new.
-                    curChan <- use csCurrentChannel
-                    let msgs = curChan^.ccContents.cdMessages
-                    case findLatestUserMessage isEditable msgs of
-                        Nothing -> return ()
-                        Just m ->
-                            case m^.mOriginalPost of
-                                Nothing -> return ()
-                                Just p ->
-                                    case curChan^.ccInfo.cdNewMessageIndicator of
-                                        Hide ->
-                                            csCurrentChannel.ccInfo.cdNewMessageIndicator .= (NewPostsAfterServerTime (p^.postCreateAtL))
-                                        _ -> return ()
-                    -- No need to add a gap here: the websocket
-                    -- disconnect/reconnect events will automatically
-                    -- handle management of messages delivered while
-                    -- suspended.
-
-                    mhSuspendAndResume $ \st -> do
-                        args <- act
-                        result <- runInteractiveCommand (T.unpack urlOpenCommand) args
-
-                        let waitForKeypress = do
-                                putStrLn "Press any key to return to Matterhorn."
-                                void getChar
-
-                        case result of
-                            Right ExitSuccess -> return ()
-                            Left err -> do
-                                putStrLn $ "URL opener subprocess " <> (show urlOpenCommand) <>
-                                           " could not be run: " <> err
-                                waitForKeypress
-                            Right (ExitFailure code) -> do
-                                putStrLn $ "URL opener subprocess " <> (show urlOpenCommand) <>
-                                           " exited with non-zero status " <> show code
-                                waitForKeypress
-
-                        return $ setMode' Main st
-
-            return True
-
-runInteractiveCommand :: String
-                      -> [String]
-                      -> IO (Either String ExitCode)
-runInteractiveCommand cmd args = do
-    let opener = (proc cmd args) { std_in = Inherit
-                                 , std_out = Inherit
-                                 , std_err = Inherit
-                                 }
-    result <- try $ createProcess opener
-    case result of
-        Left (e::SomeException) -> return $ Left $ show e
-        Right (_, _, _, ph) -> do
-            ec <- waitForProcess ph
-            return $ Right ec
-
-runLoggedCommand :: Bool
-                 -- ^ Whether stdout output is expected for this program
-                 -> STM.TChan ProgramOutput
-                 -- ^ The output channel to send the output to
-                 -> String
-                 -- ^ The program name
-                 -> [String]
-                 -- ^ Arguments
-                 -> Maybe String
-                 -- ^ The stdin to send, if any
-                 -> Maybe (MVar ProgramOutput)
-                 -- ^ Where to put the program output when it is ready
-                 -> IO ()
-runLoggedCommand stdoutOkay outputChan cmd args mInput mOutputVar = void $ forkIO $ do
-    let stdIn = maybe NoStream (const CreatePipe) mInput
-        opener = (proc cmd args) { std_in = stdIn
-                                 , std_out = CreatePipe
-                                 , std_err = CreatePipe
-                                 }
-    result <- try $ createProcess opener
-    case result of
-        Left (e::SomeException) -> do
-            let po = ProgramOutput cmd args "" stdoutOkay (show e) (ExitFailure 1)
-            STM.atomically $ STM.writeTChan outputChan po
-            maybe (return ()) (flip putMVar po) mOutputVar
-        Right (stdinResult, Just outh, Just errh, ph) -> do
-            case stdinResult of
-                Just inh -> do
-                    let Just input = mInput
-                    hPutStrLn inh input
-                    hFlush inh
-                Nothing -> return ()
-
-            ec <- waitForProcess ph
-            outResult <- hGetContents outh
-            errResult <- hGetContents errh
-            let po = ProgramOutput cmd args outResult stdoutOkay errResult ec
-            STM.atomically $ STM.writeTChan outputChan po
-            maybe (return ()) (flip putMVar po) mOutputVar
-        Right _ ->
-            error $ "BUG: createProcess returned unexpected result, report this at " <>
-                    "https://github.com/matterhorn-chat/matterhorn"
-
-prepareLink :: LinkChoice -> IO [String]
-prepareLink link = return [T.unpack $ unURL $ link^.linkURL]
-
-prepareAttachment :: FileId -> Session -> IO [String]
-prepareAttachment fId sess = do
-    -- The link is for an attachment, so fetch it and then
-    -- open the local copy.
-
-    (info, contents) <- concurrently (mmGetMetadataForFile fId sess) (mmGetFile fId sess)
-    cacheDir <- getUserCacheDir xdgName
-
-    let dir   = cacheDir </> "files" </> T.unpack (idString fId)
-        fname = dir </> T.unpack (fileInfoName info)
-
-    createDirectoryIfMissing True dir
-    BS.writeFile fname contents
-    return [fname]
-
-removeEmoteFormatting :: T.Text -> T.Text
-removeEmoteFormatting t
-    | "*" `T.isPrefixOf` t &&
-      "*" `T.isSuffixOf` t = T.init $ T.drop 1 t
-    | otherwise = t
-
-addEmoteFormatting :: T.Text -> T.Text
-addEmoteFormatting t = "*" <> t <> "*"
-
-fetchMentionedUsers :: Set.Set MentionedUser -> MH ()
-fetchMentionedUsers ms
-    | Set.null ms = return ()
-    | otherwise = do
-        let convertMention (UsernameMention u) = UserFetchByUsername u
-            convertMention (UserIdMention i) = UserFetchById i
-        scheduleUserFetches $ convertMention <$> Set.toList ms
-
-doPendingUserStatusFetches :: MH ()
-doPendingUserStatusFetches = do
-    mz <- getScheduledUserStatusFetches
-    case mz of
-        Nothing -> return ()
-        Just z -> do
-            statusChan <- use (csResources.crStatusUpdateChan)
-            liftIO $ STM.atomically $ STM.writeTChan statusChan z
-
-doPendingUserFetches :: MH ()
-doPendingUserFetches = do
-    fs <- getScheduledUserFetches
-
-    let getUsername (UserFetchByUsername u) = Just u
-        getUsername _ = Nothing
-
-        getUserId (UserFetchById i) = Just i
-        getUserId _ = Nothing
-
-    fetchUsers (catMaybes $ getUsername <$> fs) (catMaybes $ getUserId <$> fs)
-
--- | Given a list of usernames, ensure that we have a user record for
--- each one in the state, either by confirming that a local record
--- exists or by issuing a request for user records.
-fetchUsers :: [Text] -> [UserId] -> MH ()
-fetchUsers rawUsernames uids = do
-    st <- use id
-    session <- getSession
-    let usernames = trimUserSigil <$> rawUsernames
-        missingUsernames = filter isMissing usernames
-        isMissing n = and [ not $ T.null n
-                          , not $ isSpecialMention n
-                          , isNothing $ userByUsername n st
-                          ]
-        missingIds = filter (\i -> isNothing $ userById i st) uids
-
-    when (not $ null missingUsernames) $ do
-        mhLog LogGeneral $ T.pack $ "fetchUsers: getting " <> show missingUsernames
-
-    when (not $ null missingIds) $ do
-        mhLog LogGeneral $ T.pack $ "fetchUsers: getting " <> show missingIds
-
-    when ((not $ null missingUsernames) || (not $ null missingIds)) $ do
-        doAsyncWith Normal $ do
-            act1 <- case null missingUsernames of
-                True -> return $ return ()
-                False -> do
-                    results <- mmGetUsersByUsernames (Seq.fromList missingUsernames) session
-                    return $ do
-                        forM_ results (\u -> addNewUser $ userInfoFromUser u True)
-
-            act2 <- case null missingIds of
-                True -> return $ return ()
-                False -> do
-                    results <- mmGetUsersByIds (Seq.fromList missingIds) session
-                    return $ do
-                        forM_ results (\u -> addNewUser $ userInfoFromUser u True)
-
-            return $ Just $ act1 >> act2
diff --git a/src/State/Editing.hs b/src/State/Editing.hs
deleted file mode 100644
--- a/src/State/Editing.hs
+++ /dev/null
@@ -1,524 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE RankNTypes #-}
-module State.Editing
-  ( requestSpellCheck
-  , editingKeybindings
-  , editingKeyHandlers
-  , messageEditingKeybindings
-  , toggleMultilineEditing
-  , invokeExternalEditor
-  , handlePaste
-  , handleInputSubmission
-  , getEditorContent
-  , handleEditingInput
-  , cancelAutocompleteOrReplyOrEdit
-  , replyToLatestMessage
-  , Direction(..)
-  , tabComplete
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Main ( invalidateCache, invalidateCacheEntry )
-import           Brick.Widgets.Edit ( Editor, applyEdit , handleEditorEvent
-                                    , getEditContents, editContentsL )
-import qualified Brick.Widgets.List as L
-import qualified Codec.Binary.UTF8.Generic as UTF8
-import           Control.Arrow
-import qualified Control.Concurrent.STM as STM
-import qualified Data.ByteString as BS
-import           Data.Char ( isSpace )
-import qualified Data.Foldable as F
-import qualified Data.Map as M
-import qualified Data.Set as S
-import qualified Data.Text as T
-import qualified Data.Text.Encoding as T
-import qualified Data.Text.Zipper as Z
-import qualified Data.Text.Zipper.Generic.Words as Z
-import           Data.Time ( getCurrentTime )
-import           Graphics.Vty ( Event(..), Key(..) )
-import           Lens.Micro.Platform ( Lens', (%=), (.=), (.~), to, _Just )
-import qualified System.Environment as Sys
-import qualified System.Exit as Sys
-import qualified System.IO as Sys
-import qualified System.IO.Temp as Sys
-import qualified System.Process as Sys
-import           Text.Aspell ( AspellResponse(..), mistakeWord, askAspell )
-
-import           Network.Mattermost.Types ( Post(..), ChannelId )
-
-import           Config
-import {-# SOURCE #-} Command ( dispatchCommand )
-import           InputHistory
-import           Events.Keybindings
-import           State.Common
-import           State.Autocomplete
-import           State.Attachments
-import           State.Messages
-import           Types hiding ( newState )
-import           Types.Common ( sanitizeUserText' )
-
-
-startMultilineEditing :: MH ()
-startMultilineEditing = do
-    mh invalidateCache
-    csEditState.cedEphemeral.eesMultiline .= True
-
-toggleMultilineEditing :: MH ()
-toggleMultilineEditing = do
-    mh invalidateCache
-    csEditState.cedEphemeral.eesMultiline %= not
-
-    -- If multiline is now disabled and there is more than one line in
-    -- the editor, that means we're showing the multiline message status
-    -- (see Draw.Main.renderUserCommandBox.commandBox) so we want to be
-    -- sure no autocomplete UI is present in case the cursor was left on
-    -- a word that would otherwise show completion alternatives.
-    multiline <- use (csEditState.cedEphemeral.eesMultiline)
-    numLines <- use (csEditState.cedEditor.to getEditContents.to length)
-    when (not multiline && numLines > 1) resetAutocomplete
-
-invokeExternalEditor :: MH ()
-invokeExternalEditor = do
-    -- If EDITOR is in the environment, write the current message to a
-    -- temp file, invoke EDITOR on it, read the result, remove the temp
-    -- file, and update the program state.
-    --
-    -- If EDITOR is not present, fall back to 'vi'.
-    mEnv <- liftIO $ Sys.lookupEnv "EDITOR"
-    let editorProgram = maybe "vi" id mEnv
-
-    mhSuspendAndResume $ \ st -> do
-      Sys.withSystemTempFile "matterhorn_editor.md" $ \tmpFileName tmpFileHandle -> do
-        -- Write the current message to the temp file
-        Sys.hPutStr tmpFileHandle $ T.unpack $ T.intercalate "\n" $
-            getEditContents $ st^.csEditState.cedEditor
-        Sys.hClose tmpFileHandle
-
-        -- Run the editor
-        status <- Sys.system (editorProgram <> " " <> tmpFileName)
-
-        -- On editor exit, if exited with zero status, read temp file.
-        -- If non-zero status, skip temp file read.
-        case status of
-            Sys.ExitSuccess -> do
-                tmpBytes <- BS.readFile tmpFileName
-                case T.decodeUtf8' tmpBytes of
-                    Left _ -> do
-                        postErrorMessageIO "Failed to decode file contents as UTF-8" st
-                    Right t -> do
-                        let tmpLines = T.lines $ sanitizeUserText' t
-                        return $ st & csEditState.cedEditor.editContentsL .~ (Z.textZipper tmpLines Nothing)
-            Sys.ExitFailure _ -> return st
-
-handlePaste :: BS.ByteString -> MH ()
-handlePaste bytes = do
-  let pasteStr = T.pack (UTF8.toString bytes)
-  csEditState.cedEditor %= applyEdit (Z.insertMany (sanitizeUserText' pasteStr))
-  contents <- use (csEditState.cedEditor.to getEditContents)
-  case length contents > 1 of
-      True -> startMultilineEditing
-      False -> return ()
-
-editingPermitted :: ChatState -> Bool
-editingPermitted st =
-    (length (getEditContents $ st^.csEditState.cedEditor) == 1) ||
-    st^.csEditState.cedEphemeral.eesMultiline
-
-messageEditingKeybindings :: KeyConfig -> KeyHandlerMap
-messageEditingKeybindings kc =
-    let KeyHandlerMap m = editingKeybindings (csEditState.cedEditor) kc
-    in KeyHandlerMap $ M.map withUserTypingAction m
-
-withUserTypingAction :: KeyHandler -> KeyHandler
-withUserTypingAction kh =
-    kh { khHandler = newH }
-    where
-        oldH = khHandler kh
-        newH = oldH { kehHandler = newKEH }
-        oldKEH = kehHandler oldH
-        newKEH = oldKEH { ehAction = ehAction oldKEH >> sendUserTypingAction }
-
-editingKeybindings :: Lens' ChatState (Editor T.Text Name) -> KeyConfig -> KeyHandlerMap
-editingKeybindings editor = mkKeybindings $ editingKeyHandlers editor
-
-editingKeyHandlers :: Lens' ChatState (Editor T.Text Name) -> [KeyEventHandler]
-editingKeyHandlers editor =
-  [ mkKb EditorTransposeCharsEvent
-    "Transpose the final two characters"
-    (editor %= applyEdit Z.transposeChars)
-  , mkKb EditorBolEvent
-    "Go to the start of the current line"
-    (editor %= applyEdit Z.gotoBOL)
-  , mkKb EditorEolEvent
-    "Go to the end of the current line"
-    (editor %= applyEdit Z.gotoEOL)
-  , mkKb EditorDeleteCharacter
-    "Delete the character at the cursor"
-    (editor %= applyEdit Z.deleteChar)
-  , mkKb EditorKillToBolEvent
-    "Delete from the cursor to the start of the current line"
-    (editor %= applyEdit Z.killToBOL)
-  , mkKb EditorKillToEolEvent
-    "Kill the line to the right of the current position and copy it" $ do
-      z <- use (editor.editContentsL)
-      let restOfLine = Z.currentLine (Z.killToBOL z)
-      csEditState.cedYankBuffer .= restOfLine
-      editor %= applyEdit Z.killToEOL
-  , mkKb EditorNextCharEvent
-    "Move one character to the right"
-    (editor %= applyEdit Z.moveRight)
-  , mkKb EditorPrevCharEvent
-    "Move one character to the left"
-    (editor %= applyEdit Z.moveLeft)
-  , mkKb EditorNextWordEvent
-    "Move one word to the right"
-    (editor %= applyEdit Z.moveWordRight)
-  , mkKb EditorPrevWordEvent
-    "Move one word to the left"
-    (editor %= applyEdit Z.moveWordLeft)
-  , mkKb EditorDeletePrevWordEvent
-    "Delete the word to the left of the cursor" $ do
-    editor %= applyEdit Z.deletePrevWord
-  , mkKb EditorDeleteNextWordEvent
-    "Delete the word to the right of the cursor" $ do
-    editor %= applyEdit Z.deleteWord
-  , mkKb EditorHomeEvent
-    "Move the cursor to the beginning of the input" $ do
-    editor %= applyEdit gotoHome
-  , mkKb EditorEndEvent
-    "Move the cursor to the end of the input" $ do
-    editor %= applyEdit gotoEnd
-  , mkKb EditorYankEvent
-    "Paste the current buffer contents at the cursor" $ do
-      buf <- use (csEditState.cedYankBuffer)
-      editor %= applyEdit (Z.insertMany buf)
-  ]
-
-getEditorContent :: MH Text
-getEditorContent = do
-    cmdLine <- use (csEditState.cedEditor)
-    let (line:rest) = getEditContents cmdLine
-    return $ T.intercalate "\n" $ line : rest
-
--- | Handle an input submission in the message editor.
---
--- This handles the specified input text as if it were user input for
--- the specified channel. This means that if the specified input text
--- contains a command ("/...") then it is executed as normal. Otherwise
--- the text is sent as a message to the specified channel.
---
--- However, this function assumes that the message editor is the
--- *source* of the text, so it also takes care of clearing the editor,
--- resetting the edit mode, updating the input history for the specified
--- channel, etc.
-handleInputSubmission :: ChannelId -> Text -> MH ()
-handleInputSubmission cId content = do
-    -- We clean up before dispatching the command or sending the message
-    -- since otherwise the command could change the state and then doing
-    -- cleanup afterwards could clean up the wrong things.
-    csEditState.cedEditor %= applyEdit Z.clearZipper
-    csEditState.cedInputHistory %= addHistoryEntry content cId
-    csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing
-
-    case T.uncons content of
-      Just ('/', cmd) ->
-          dispatchCommand cmd
-      _ -> do
-          attachments <- use (csEditState.cedAttachmentList.L.listElementsL)
-          mode <- use (csEditState.cedEditMode)
-          sendMessage cId mode content $ F.toList attachments
-
-    -- Reset the autocomplete UI
-    resetAutocomplete
-
-    -- Empty the attachment list
-    resetAttachmentList
-
-    -- Reset the edit mode *after* handling the input so that the input
-    -- handler can tell whether we're editing, replying, etc.
-    csEditState.cedEditMode .= NewPost
-
-closingPunctuationMarks :: String
-closingPunctuationMarks = ".,'\";:)]!?"
-
-isSmartClosingPunctuation :: Event -> Bool
-isSmartClosingPunctuation (EvKey (KChar c) []) = c `elem` closingPunctuationMarks
-isSmartClosingPunctuation _ = False
-
-handleEditingInput :: Event -> MH ()
-handleEditingInput e = do
-    -- Only handle input events to the editor if we permit editing:
-    -- if multiline mode is off, or if there is only one line of text
-    -- in the editor. This means we skip input this catch-all handler
-    -- if we're *not* in multiline mode *and* there are multiple lines,
-    -- i.e., we are showing the user the status message about the
-    -- current editor state and editing is not permitted.
-
-    -- Record the input line count before handling the editing event
-    -- so we can tell whether the editing event changes the line count
-    -- later.
-    beforeLineCount <- use (csEditState.cedEditor.to getEditContents.to length)
-
-    smartBacktick <- use (csResources.crConfiguration.to configSmartBacktick)
-    let smartChars = "*`_"
-    st <- use id
-    csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing
-
-    smartEditing <- use (csResources.crConfiguration.to configSmartEditing)
-    justCompleted <- use (csEditState.cedJustCompleted)
-
-    conf <- use (csResources.crConfiguration)
-    let keyMap = editingKeybindings (csEditState.cedEditor) (configUserKeys conf)
-    case lookupKeybinding e keyMap of
-      Just kb | editingPermitted st -> (ehAction $ kehHandler $ khHandler kb)
-      _ -> do
-        case e of
-          -- Not editing; backspace here means cancel multi-line message
-          -- composition
-          EvKey KBS [] | (not $ editingPermitted st) ->
-            csEditState.cedEditor %= applyEdit Z.clearZipper
-
-          -- Backspace in editing mode with smart pair insertion means
-          -- smart pair removal when possible
-          EvKey KBS [] | editingPermitted st && smartBacktick ->
-              let backspace = csEditState.cedEditor %= applyEdit Z.deletePrevChar
-              in case cursorAtOneOf smartChars (st^.csEditState.cedEditor) of
-                  Nothing -> backspace
-                  Just ch ->
-                      -- Smart char removal:
-                      if | (cursorAtChar ch $ applyEdit Z.moveLeft $ st^.csEditState.cedEditor) &&
-                           (cursorIsAtEnd $ applyEdit Z.moveRight $ st^.csEditState.cedEditor) ->
-                             csEditState.cedEditor %= applyEdit (Z.deleteChar >>> Z.deletePrevChar)
-                         | otherwise -> backspace
-
-          EvKey (KChar ch) []
-            | editingPermitted st && smartBacktick && ch `elem` smartChars ->
-              -- Smart char insertion:
-              let doInsertChar = do
-                    csEditState.cedEditor %= applyEdit (Z.insertChar ch)
-                    sendUserTypingAction
-                  curLine = Z.currentLine $ st^.csEditState.cedEditor.editContentsL
-              -- First case: if the cursor is at the end of the current
-              -- line and it contains "``" and the user entered a third
-              -- "`", enable multi-line mode since they're likely typing
-              -- a code block.
-              in if | (cursorIsAtEnd $ st^.csEditState.cedEditor) &&
-                         curLine == "``" &&
-                         ch == '`' -> do
-                        csEditState.cedEditor %= applyEdit (Z.insertMany (T.singleton ch))
-                        csEditState.cedEphemeral.eesMultiline .= True
-                    -- Second case: user entered some smart character
-                    -- (don't care which) on an empty line or at the end
-                    -- of the line after whitespace, so enter a pair of
-                    -- the smart chars and put the cursor between them.
-                    | (editorEmpty $ st^.csEditState.cedEditor) ||
-                         ((cursorAtChar ' ' (applyEdit Z.moveLeft $ st^.csEditState.cedEditor)) &&
-                          (cursorIsAtEnd $ st^.csEditState.cedEditor)) ->
-                        csEditState.cedEditor %= applyEdit (Z.insertMany (T.pack $ ch:ch:[]) >>> Z.moveLeft)
-                    -- Third case: the cursor is already on a smart
-                    -- character and that character is the last one
-                    -- on the line, so instead of inserting a new
-                    -- character, just move past it.
-                    | (cursorAtChar ch $ st^.csEditState.cedEditor) &&
-                      (cursorIsAtEnd $ applyEdit Z.moveRight $ st^.csEditState.cedEditor) ->
-                        csEditState.cedEditor %= applyEdit Z.moveRight
-                    -- Fall-through case: just insert one of the chars
-                    -- without doing anything smart.
-                    | otherwise -> doInsertChar
-            | editingPermitted st -> do
-
-              -- If the most recent editing event was a tab completion,
-              -- there is a trailing space that we want to remove if the
-              -- next input character is punctuation.
-              when (smartEditing && justCompleted && isSmartClosingPunctuation e) $
-                  csEditState.cedEditor %= applyEdit Z.deletePrevChar
-
-              csEditState.cedEditor %= applyEdit (Z.insertMany (sanitizeUserText' $ T.singleton ch))
-              sendUserTypingAction
-          _ | editingPermitted st -> do
-              mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent e
-              sendUserTypingAction
-            | otherwise -> return ()
-
-    let ctx = AutocompleteContext { autocompleteManual = False
-                                  , autocompleteFirstMatch = False
-                                  }
-    checkForAutocompletion ctx
-    liftIO $ resetSpellCheckTimer $ st^.csEditState
-
-    -- If the preview is enabled and multi-line editing is enabled and
-    -- the line count changed, we need to invalidate the rendering cache
-    -- entry for the channel messages because we want to redraw them to
-    -- fit in the space just changed by the size of the preview area.
-    afterLineCount <- use (csEditState.cedEditor.to getEditContents.to length)
-    isMultiline <- use (csEditState.cedEphemeral.eesMultiline)
-    isPreviewing <- use csShowMessagePreview
-    when (beforeLineCount /= afterLineCount && isMultiline && isPreviewing) $ do
-        cId <- use csCurrentChannelId
-        mh $ invalidateCacheEntry $ ChannelMessages cId
-
-    -- Reset the recent autocompletion flag to stop smart punctuation
-    -- handling.
-    when justCompleted $
-        csEditState.cedJustCompleted .= False
-
--- | Send the user_typing action to the server asynchronously, over the
--- connected websocket. If the websocket is not connected, drop the
--- action silently.
-sendUserTypingAction :: MH ()
-sendUserTypingAction = do
-  st <- use id
-  when (configShowTypingIndicator (st^.csResources.crConfiguration)) $
-    case st^.csConnectionStatus of
-      Connected -> do
-        let pId = case st^.csEditState.cedEditMode of
-                    Replying _ post -> Just $ postId post
-                    _               -> Nothing
-        liftIO $ do
-          now <- getCurrentTime
-          let action = UserTyping now (st^.csCurrentChannelId) pId
-          STM.atomically $ STM.writeTChan (st^.csResources.crWebsocketActionChan) action
-      Disconnected -> return ()
-
--- Kick off an async request to the spell checker for the current editor
--- contents.
-requestSpellCheck :: MH ()
-requestSpellCheck = do
-    st <- use id
-    case st^.csEditState.cedSpellChecker of
-        Nothing -> return ()
-        Just (checker, _) -> do
-            -- Get the editor contents.
-            contents <- getEditContents <$> use (csEditState.cedEditor)
-            doAsyncWith Preempt $ do
-                -- For each line in the editor, submit an aspell request.
-                let query = concat <$> mapM (askAspell checker) contents
-                    postMistakes :: [AspellResponse] -> MH ()
-                    postMistakes responses = do
-                        let getMistakes AllCorrect = []
-                            getMistakes (Mistakes ms) = mistakeWord <$> ms
-                            allMistakes = S.fromList $ concat $ getMistakes <$> responses
-                        csEditState.cedMisspellings .= allMistakes
-
-                tryMM query (return . Just . postMistakes)
-
-editorEmpty :: Editor Text a -> Bool
-editorEmpty e = cursorIsAtEnd e &&
-                cursorIsAtBeginning e
-
-cursorIsAtEnd :: Editor Text a -> Bool
-cursorIsAtEnd e =
-    let col = snd $ Z.cursorPosition z
-        curLine = Z.currentLine z
-        z = e^.editContentsL
-    in col == T.length curLine
-
-cursorIsAtBeginning :: Editor Text a -> Bool
-cursorIsAtBeginning e =
-    let col = snd $ Z.cursorPosition z
-        z = e^.editContentsL
-    in col == 0
-
-cursorAtOneOf :: [Char] -> Editor Text a -> Maybe Char
-cursorAtOneOf [] _ = Nothing
-cursorAtOneOf (c:cs) e =
-    if cursorAtChar c e
-    then Just c
-    else cursorAtOneOf cs e
-
-cursorAtChar :: Char -> Editor Text a -> Bool
-cursorAtChar ch e =
-    let col = snd $ Z.cursorPosition z
-        curLine = Z.currentLine z
-        z = e^.editContentsL
-    in (T.singleton ch) `T.isPrefixOf` T.drop col curLine
-
-gotoHome :: Z.TextZipper Text -> Z.TextZipper Text
-gotoHome = Z.moveCursor (0, 0)
-
-gotoEnd :: Z.TextZipper Text -> Z.TextZipper Text
-gotoEnd z =
-    let zLines = Z.getText z
-        numLines = length zLines
-        lastLineLength = T.length $ last zLines
-    in if numLines > 0
-       then Z.moveCursor (numLines - 1, lastLineLength) z
-       else z
-
-cancelAutocompleteOrReplyOrEdit :: MH ()
-cancelAutocompleteOrReplyOrEdit = do
-    cId <- use csCurrentChannelId
-    mh $ invalidateCacheEntry $ ChannelMessages cId
-    ac <- use (csEditState.cedAutocomplete)
-    case ac of
-        Just _ -> do
-            resetAutocomplete
-        Nothing -> do
-            mode <- use (csEditState.cedEditMode)
-            case mode of
-                NewPost -> return ()
-                _ -> do
-                    csEditState.cedEditMode .= NewPost
-                    csEditState.cedEditor %= applyEdit Z.clearZipper
-                    resetAttachmentList
-
-replyToLatestMessage :: MH ()
-replyToLatestMessage = do
-  msgs <- use (csCurrentChannel . ccContents . cdMessages)
-  case findLatestUserMessage isReplyable msgs of
-    Just msg | isReplyable msg ->
-        do let Just p = msg^.mOriginalPost
-           setMode Main
-           cId <- use csCurrentChannelId
-           mh $ invalidateCacheEntry $ ChannelMessages cId
-           csEditState.cedEditMode .= Replying msg p
-    _ -> return ()
-
-data Direction = Forwards | Backwards
-
-tabComplete :: Direction -> MH ()
-tabComplete dir = do
-    let transform list =
-            let len = list^.L.listElementsL.to length
-            in case dir of
-                Forwards ->
-                    if (L.listSelected list == Just (len - 1)) ||
-                       (L.listSelected list == Nothing && len > 0)
-                    then L.listMoveTo 0 list
-                    else L.listMoveBy 1 list
-                Backwards ->
-                    if (L.listSelected list == Just 0) ||
-                       (L.listSelected list == Nothing && len > 0)
-                    then L.listMoveTo (len - 1) list
-                    else L.listMoveBy (-1) list
-    csEditState.cedAutocomplete._Just.acCompletionList %= transform
-
-    mac <- use (csEditState.cedAutocomplete)
-    case mac of
-        Nothing -> do
-            let ctx = AutocompleteContext { autocompleteManual = True
-                                          , autocompleteFirstMatch = True
-                                          }
-            checkForAutocompletion ctx
-        Just ac -> do
-            case ac^.acCompletionList.to L.listSelectedElement of
-                Nothing -> return ()
-                Just (_, alternative) -> do
-                    let replacement = autocompleteAlternativeReplacement alternative
-                        maybeEndOfWord z =
-                            if maybe True isSpace (Z.currentChar z)
-                            then z
-                            else Z.moveWordRight z
-                    csEditState.cedEditor %=
-                        applyEdit (Z.insertChar ' ' . Z.insertMany replacement . Z.deletePrevWord .
-                                   maybeEndOfWord)
-                    csEditState.cedJustCompleted .= True
-
-                    -- If there was only one completion alternative,
-                    -- hide the autocomplete listing now that we've
-                    -- completed the only completion.
-                    when (ac^.acCompletionList.to L.listElements.to length == 1) resetAutocomplete
diff --git a/src/State/Editing.hs-boot b/src/State/Editing.hs-boot
deleted file mode 100644
--- a/src/State/Editing.hs-boot
+++ /dev/null
@@ -1,10 +0,0 @@
-module State.Editing
-  ( Direction(..)
-  , tabComplete
-  )
-where
-
-import Types ( MH )
-
-data Direction = Forwards | Backwards
-tabComplete :: Direction -> MH ()
diff --git a/src/State/Flagging.hs b/src/State/Flagging.hs
deleted file mode 100644
--- a/src/State/Flagging.hs
+++ /dev/null
@@ -1,66 +0,0 @@
-module State.Flagging
-  ( loadFlaggedMessages
-  , updateMessageFlag
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Data.Function ( on )
-import qualified Data.Set as Set
-import           Lens.Micro.Platform
-
-import           Network.Mattermost.Types
-
-import           State.Common
-import           Types
-
-
-loadFlaggedMessages :: Seq FlaggedPost -> ChatState -> IO ()
-loadFlaggedMessages prefs st = doAsyncWithIO Normal st $ do
-  return $ Just $ do
-      sequence_ [ updateMessageFlag (flaggedPostId fp) True
-                | fp <- toList prefs
-                , flaggedPostStatus fp
-                ]
-
-
--- | Update the UI to reflect the flagged/unflagged state of a
--- message. This __does not__ talk to the Mattermost server, but
--- rather is the function we call when the Mattermost server notifies
--- us of flagged or unflagged messages.
-updateMessageFlag :: PostId -> Bool -> MH ()
-updateMessageFlag pId f = do
-  if f
-    then csResources.crFlaggedPosts %= Set.insert pId
-    else csResources.crFlaggedPosts %= Set.delete pId
-  msgMb <- use (csPostMap.at(pId))
-  case msgMb of
-    Just msg
-      | Just cId <- msg^.mChannelId -> do
-      let isTargetMessage m = m^.mMessageId == Just (MessagePostId pId)
-      csChannel(cId).ccContents.cdMessages.traversed.filtered isTargetMessage.mFlagged .= f
-      csPostMap.ix(pId).mFlagged .= f
-      -- We also want to update the post overlay if this happens while
-      -- we're we're observing it
-      mode <- gets appMode
-      case mode of
-        PostListOverlay PostListFlagged
-          | f ->
-              csPostListOverlay.postListPosts %=
-                addMessage (msg & mFlagged .~ True)
-          -- deleting here is tricky, because it means that we need to
-          -- move the focus somewhere: we'll try moving it _up_ unless
-          -- we can't, in which case we'll try moving it down.
-          | otherwise -> do
-              selId <- use (csPostListOverlay.postListSelected)
-              posts <- use (csPostListOverlay.postListPosts)
-              let nextId = case getNextPostId selId posts of
-                    Nothing -> getPrevPostId selId posts
-                    Just x  -> Just x
-              csPostListOverlay.postListSelected .= nextId
-              csPostListOverlay.postListPosts %=
-                filterMessages (((/=) `on` _mMessageId) msg)
-        _ -> return ()
-    _ -> return ()
diff --git a/src/State/Help.hs b/src/State/Help.hs
deleted file mode 100644
--- a/src/State/Help.hs
+++ /dev/null
@@ -1,21 +0,0 @@
-module State.Help
-  ( showHelpScreen
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Main ( viewportScroll, vScrollToBeginning )
-
-import           Types
-
-
-showHelpScreen :: HelpTopic -> MH ()
-showHelpScreen topic = do
-    curMode <- gets appMode
-    case curMode of
-        ShowHelp {} -> return ()
-        _ -> do
-            mh $ vScrollToBeginning (viewportScroll HelpViewport)
-            setMode $ ShowHelp topic curMode
diff --git a/src/State/ListOverlay.hs b/src/State/ListOverlay.hs
deleted file mode 100644
--- a/src/State/ListOverlay.hs
+++ /dev/null
@@ -1,144 +0,0 @@
-{-# LANGUAGE RankNTypes #-}
-module State.ListOverlay
-  ( listOverlayActivateCurrent
-  , listOverlaySearchString
-  , listOverlayMove
-  , exitListOverlay
-  , enterListOverlayMode
-  , resetListOverlaySearch
-  , onEventListOverlay
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Brick.Widgets.List as L
-import qualified Brick.Widgets.Edit as E
-import qualified Data.Text.Zipper as Z
-import qualified Data.Vector as Vec
-import           Lens.Micro.Platform ( Lens', (%=), (.=) )
-import           Network.Mattermost.Types ( Session )
-import qualified Graphics.Vty as Vty
-
-import           Types
-import           State.Common
-import           State.Editing ( editingKeybindings )
-import           Events.Keybindings ( KeyConfig, KeyHandlerMap, handleKeyboardEvent )
-
-
--- | Activate the specified list overlay's selected item by invoking the
--- overlay's configured enter keypress handler function.
-listOverlayActivateCurrent :: Lens' ChatState (ListOverlayState a b) -> MH ()
-listOverlayActivateCurrent which = do
-  mItem <- L.listSelectedElement <$> use (which.listOverlaySearchResults)
-  case mItem of
-      Nothing -> return ()
-      Just (_, val) -> do
-          handler <- use (which.listOverlayEnterHandler)
-          activated <- handler val
-          if activated
-             then setMode Main
-             else return ()
-
--- | Get the current search string for the specified overlay.
-listOverlaySearchString :: Lens' ChatState (ListOverlayState a b) -> MH Text
-listOverlaySearchString which =
-    (head . E.getEditContents) <$> use (which.listOverlaySearchInput)
-
--- | Move the list cursor in the specified overlay.
-listOverlayMove :: Lens' ChatState (ListOverlayState a b)
-                -- ^ Which overlay
-                -> (L.List Name a -> L.List Name a)
-                -- ^ How to transform the list in the overlay
-                -> MH ()
-listOverlayMove which how = which.listOverlaySearchResults %= how
-
--- | Clear the state of the specified list overlay and return to the
--- Main mode.
-exitListOverlay :: Lens' ChatState (ListOverlayState a b)
-                -- ^ Which overlay to reset
-                -> MH ()
-exitListOverlay which = do
-    newList <- use (which.listOverlayNewList)
-    which.listOverlaySearchResults .= newList mempty
-    which.listOverlayEnterHandler .= (const $ return False)
-    setMode Main
-
--- | Initialize a list overlay with the specified arguments and switch
--- to the specified mode.
-enterListOverlayMode :: (Lens' ChatState (ListOverlayState a b))
-                     -- ^ Which overlay to initialize
-                     -> Mode
-                     -- ^ The mode to change to
-                     -> b
-                     -- ^ The overlay's initial search scope
-                     -> (a -> MH Bool)
-                     -- ^ The overlay's enter keypress handler
-                     -> (b -> Session -> Text -> IO (Vec.Vector a))
-                     -- ^ The overlay's results fetcher function
-                     -> MH ()
-enterListOverlayMode which mode scope enterHandler fetcher = do
-    which.listOverlaySearchScope .= scope
-    which.listOverlaySearchInput.E.editContentsL %= Z.clearZipper
-    which.listOverlayEnterHandler .= enterHandler
-    which.listOverlayFetchResults .= fetcher
-    which.listOverlaySearching .= False
-    newList <- use (which.listOverlayNewList)
-    which.listOverlaySearchResults .= newList mempty
-    setMode mode
-    resetListOverlaySearch which
-
--- | Reset the overlay's search by initiating a new search request for
--- the string that is currently in the overlay's editor. This does
--- nothing if a search for this overlay is already in progress.
-resetListOverlaySearch :: Lens' ChatState (ListOverlayState a b) -> MH ()
-resetListOverlaySearch which = do
-    searchPending <- use (which.listOverlaySearching)
-
-    when (not searchPending) $ do
-        searchString <- listOverlaySearchString which
-        which.listOverlaySearching .= True
-        newList <- use (which.listOverlayNewList)
-        session <- getSession
-        scope <- use (which.listOverlaySearchScope)
-        fetcher <- use (which.listOverlayFetchResults)
-        doAsyncWith Preempt $ do
-            results <- fetcher scope session searchString
-            return $ Just $ do
-                which.listOverlaySearchResults .= newList results
-                which.listOverlaySearching .= False
-
-                -- Now that the results are available, check to see if the
-                -- search string changed since this request was submitted.
-                -- If so, issue another search.
-                afterSearchString <- listOverlaySearchString which
-                when (searchString /= afterSearchString) $ resetListOverlaySearch which
-
--- | Generically handle an event for the list overlay state targeted
--- by the specified lens. Automatically dispatches new searches in the
--- overlay's editor if the editor contents change.
-onEventListOverlay :: Lens' ChatState (ListOverlayState a b)
-                   -- ^ Which overlay to dispatch to?
-                   -> (KeyConfig -> KeyHandlerMap)
-                   -- ^ The keybinding builder
-                   -> Vty.Event
-                   -- ^ The event
-                   -> MH Bool
-onEventListOverlay which keybindings =
-    handleKeyboardEvent keybindings $ \e -> do
-        -- Get the editor content before the event.
-        before <- listOverlaySearchString which
-
-        -- First find a matching keybinding in the keybinding list.
-        handled <- handleKeyboardEvent (editingKeybindings (which.listOverlaySearchInput)) (const $ return ()) e
-
-        -- If we didn't find a matching binding, just handle the event
-        -- as a normal editor input event.
-        when (not handled) $
-            mhHandleEventLensed (which.listOverlaySearchInput) E.handleEditorEvent e
-
-        -- Get the editor content after the event. If the string changed,
-        -- start a new search.
-        after <- listOverlaySearchString which
-        when (before /= after) $ resetListOverlaySearch which
diff --git a/src/State/Logging.hs b/src/State/Logging.hs
deleted file mode 100644
--- a/src/State/Logging.hs
+++ /dev/null
@@ -1,33 +0,0 @@
-module State.Logging
-  ( startLogging
-  , stopLogging
-  , logSnapshot
-  , getLogDestination
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Types
-
-
-startLogging :: FilePath -> MH ()
-startLogging path = do
-    mgr <- use (csResources.crLogManager)
-    liftIO $ startLoggingToFile mgr path
-
-stopLogging :: MH ()
-stopLogging = do
-    mgr <- use (csResources.crLogManager)
-    liftIO $ stopLoggingToFile mgr
-
-logSnapshot :: FilePath -> MH ()
-logSnapshot path = do
-    mgr <- use (csResources.crLogManager)
-    liftIO $ requestLogSnapshot mgr path
-
-getLogDestination :: MH ()
-getLogDestination = do
-    mgr <- use (csResources.crLogManager)
-    liftIO $ requestLogDestination mgr
diff --git a/src/State/MessageSelect.hs b/src/State/MessageSelect.hs
deleted file mode 100644
--- a/src/State/MessageSelect.hs
+++ /dev/null
@@ -1,294 +0,0 @@
-module State.MessageSelect
-  (
-  -- * Message selection mode
-    beginMessageSelect
-  , flagSelectedMessage
-  , pinSelectedMessage
-  , viewSelectedMessage
-  , fillSelectedGap
-  , yankSelectedMessageVerbatim
-  , yankSelectedMessage
-  , openSelectedMessageURLs
-  , beginConfirmDeleteSelectedMessage
-  , messageSelectUp
-  , messageSelectUpBy
-  , messageSelectDown
-  , messageSelectDownBy
-  , messageSelectFirst
-  , messageSelectLast
-  , deleteSelectedMessage
-  , beginReplyCompose
-  , beginEditMessage
-  , flagMessage
-  , getSelectedMessage
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Widgets.Edit ( applyEdit )
-import           Data.Text.Zipper ( clearZipper, insertMany )
-import           Lens.Micro.Platform
-
-import qualified Network.Mattermost.Endpoints as MM
-import           Network.Mattermost.Types
-
-import           Clipboard ( copyToClipboard )
-import           State.Common
-import           State.Messages
-import           Types
-import           Types.RichText ( findVerbatimChunk )
-import           Types.Common
-import           Windows.ViewMessage
-
-
--- | In these modes, we allow access to the selected message state.
-messageSelectCompatibleModes :: [Mode]
-messageSelectCompatibleModes =
-    [ MessageSelect
-    , MessageSelectDeleteConfirm
-    , ReactionEmojiListOverlay
-    ]
-
-getSelectedMessage :: ChatState -> Maybe Message
-getSelectedMessage st
-    | not (appMode st `elem` messageSelectCompatibleModes) = Nothing
-    | otherwise = do
-        selMsgId <- selectMessageId $ st^.csMessageSelect
-        let chanMsgs = st ^. csCurrentChannel . ccContents . cdMessages
-        findMessage selMsgId chanMsgs
-
-beginMessageSelect :: MH ()
-beginMessageSelect = do
-    -- Get the number of messages in the current channel and set the
-    -- currently selected message index to be the most recently received
-    -- message that corresponds to a Post (i.e. exclude informative
-    -- messages).
-    --
-    -- If we can't find one at all, we ignore the mode switch request
-    -- and just return.
-    chanMsgs <- use(csCurrentChannel . ccContents . cdMessages)
-    let recentMsg = getLatestSelectableMessage chanMsgs
-
-    when (isJust recentMsg) $ do
-        setMode MessageSelect
-        csMessageSelect .= MessageSelectState (recentMsg >>= _mMessageId)
-
--- | Tell the server that the message we currently have selected
--- should have its flagged state toggled.
-flagSelectedMessage :: MH ()
-flagSelectedMessage = do
-  selected <- use (to getSelectedMessage)
-  case selected of
-    Just msg
-      | isFlaggable msg, Just pId <- messagePostId msg ->
-        flagMessage pId (not (msg^.mFlagged))
-    _        -> return ()
-
--- | Tell the server that the message we currently have selected
--- should have its pinned state toggled.
-pinSelectedMessage :: MH ()
-pinSelectedMessage = do
-  selected <- use (to getSelectedMessage)
-  case selected of
-    Just msg
-      | isPinnable msg, Just pId <- messagePostId msg ->
-        pinMessage pId (not (msg^.mPinned))
-    _ -> return ()
-
-viewSelectedMessage :: MH ()
-viewSelectedMessage = do
-  selected <- use (to getSelectedMessage)
-  case selected of
-    Just msg
-      | not (isGap msg) -> viewMessage msg
-    _        -> return ()
-
-fillSelectedGap :: MH ()
-fillSelectedGap = do
-  selected <- use (to getSelectedMessage)
-  case selected of
-    Just msg
-      | isGap msg -> do cId <- use csCurrentChannelId
-                        asyncFetchMessagesForGap cId msg
-    _        -> return ()
-
-viewMessage :: Message -> MH ()
-viewMessage m = do
-    let w = tabbedWindow VMTabMessage viewMessageWindowTemplate Main (78, 25)
-    csViewedMessage .= Just (m, w)
-    runTabShowHandlerFor (twValue w) w
-    setMode ViewMessage
-
-yankSelectedMessageVerbatim :: MH ()
-yankSelectedMessageVerbatim = do
-    selectedMessage <- use (to getSelectedMessage)
-    case selectedMessage of
-        Nothing -> return ()
-        Just m -> do
-            setMode Main
-            case findVerbatimChunk (m^.mText) of
-                Just txt -> copyToClipboard txt
-                Nothing  -> return ()
-
-yankSelectedMessage :: MH ()
-yankSelectedMessage = do
-    selectedMessage <- use (to getSelectedMessage)
-    case selectedMessage of
-        Nothing -> return ()
-        Just m -> do
-            setMode Main
-            copyToClipboard $ m^.mMarkdownSource
-
-openSelectedMessageURLs :: MH ()
-openSelectedMessageURLs = whenMode MessageSelect $ do
-    mCurMsg <- use (to getSelectedMessage)
-    curMsg <- case mCurMsg of
-        Nothing -> error "BUG: openSelectedMessageURLs: no selected message available"
-        Just m -> return m
-
-    remainInSelectMode <- use (csResources.crConfiguration.to configMessageSelectAfterURLOpen)
-
-    let urls = msgURLs curMsg
-    when (not (null urls)) $ do
-        openedAll <- and <$> mapM (openURL . OpenLinkChoice) urls
-        case openedAll of
-            True -> when (not remainInSelectMode) $ setMode Main
-            False ->
-                mhError $ ConfigOptionMissing "urlOpenCommand"
-
-beginConfirmDeleteSelectedMessage :: MH ()
-beginConfirmDeleteSelectedMessage = do
-    st <- use id
-    selected <- use (to getSelectedMessage)
-    case selected of
-        Just msg | isDeletable msg && isMine st msg ->
-            setMode MessageSelectDeleteConfirm
-        _ -> return ()
-
-messageSelectUp :: MH ()
-messageSelectUp = do
-    mode <- gets appMode
-    selected <- use (csMessageSelect.to selectMessageId)
-    case selected of
-        Just _ | mode == MessageSelect -> do
-            chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)
-            let nextMsgId = getPrevMessageId selected chanMsgs
-            csMessageSelect .= MessageSelectState (nextMsgId <|> selected)
-        _ -> return ()
-
-messageSelectDown :: MH ()
-messageSelectDown = do
-    selected <- use (csMessageSelect.to selectMessageId)
-    case selected of
-        Just _ -> whenMode MessageSelect $ do
-            chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)
-            let nextMsgId = getNextMessageId selected chanMsgs
-            csMessageSelect .= MessageSelectState (nextMsgId <|> selected)
-        _ -> return ()
-
-messageSelectDownBy :: Int -> MH ()
-messageSelectDownBy amt
-    | amt <= 0 = return ()
-    | otherwise =
-        messageSelectDown >> messageSelectDownBy (amt - 1)
-
-messageSelectUpBy :: Int -> MH ()
-messageSelectUpBy amt
-    | amt <= 0 = return ()
-    | otherwise =
-      messageSelectUp >> messageSelectUpBy (amt - 1)
-
-messageSelectFirst :: MH ()
-messageSelectFirst = do
-    selected <- use (csMessageSelect.to selectMessageId)
-    case selected of
-        Just _ -> whenMode MessageSelect $ do
-            chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)
-            case getEarliestSelectableMessage chanMsgs of
-              Just firstMsg ->
-                csMessageSelect .= MessageSelectState (firstMsg^.mMessageId <|> selected)
-              Nothing -> mhLog LogError "No first message found from current message?!"
-        _ -> return ()
-
-messageSelectLast :: MH ()
-messageSelectLast = do
-    selected <- use (csMessageSelect.to selectMessageId)
-    case selected of
-        Just _ -> whenMode MessageSelect $ do
-            chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)
-            case getLatestSelectableMessage chanMsgs of
-              Just lastSelMsg ->
-                csMessageSelect .= MessageSelectState (lastSelMsg^.mMessageId <|> selected)
-              Nothing -> mhLog LogError "No last message found from current message?!"
-        _ -> return ()
-
-deleteSelectedMessage :: MH ()
-deleteSelectedMessage = do
-    selectedMessage <- use (to getSelectedMessage)
-    st <- use id
-    cId <- use csCurrentChannelId
-    case selectedMessage of
-        Just msg | isMine st msg && isDeletable msg ->
-            case msg^.mOriginalPost of
-              Just p ->
-                  doAsyncChannelMM Preempt cId
-                      (\s _ -> MM.mmDeletePost (postId p) s)
-                      (\_ _ -> Just $ do
-                          csEditState.cedEditMode .= NewPost
-                          setMode Main)
-              Nothing -> return ()
-        _ -> return ()
-
-beginReplyCompose :: MH ()
-beginReplyCompose = do
-    selected <- use (to getSelectedMessage)
-    case selected of
-        Just msg | isReplyable msg -> do
-            let Just p = msg^.mOriginalPost
-            setMode Main
-            csEditState.cedEditMode .= Replying msg p
-        _ -> return ()
-
-beginEditMessage :: MH ()
-beginEditMessage = do
-    selected <- use (to getSelectedMessage)
-    st <- use id
-    case selected of
-        Just msg | isMine st msg && isEditable msg -> do
-            let Just p = msg^.mOriginalPost
-            setMode Main
-            csEditState.cedEditMode .= Editing p (msg^.mType)
-            -- If the post that we're editing is an emote, we need
-            -- to strip the formatting because that's only there to
-            -- indicate that the post is an emote. This is annoying and
-            -- can go away one day when there is an actual post type
-            -- value of "emote" that we can look at. Note that the
-            -- removed formatting needs to be reinstated just prior to
-            -- issuing the API call to update the post.
-            let sanitized = sanitizeUserText $ postMessage p
-            let toEdit = if isEmote msg
-                         then removeEmoteFormatting sanitized
-                         else sanitized
-            csEditState.cedEditor %= applyEdit (insertMany toEdit . clearZipper)
-        _ -> return ()
-
--- | Tell the server that we have flagged or unflagged a message.
-flagMessage :: PostId -> Bool -> MH ()
-flagMessage pId f = do
-  session <- getSession
-  myId <- gets myUserId
-  doAsyncWith Normal $ do
-    let doFlag = if f then MM.mmFlagPost else MM.mmUnflagPost
-    doFlag myId pId session
-    return Nothing
-
--- | Tell the server that we have pinned or unpinned a message.
-pinMessage :: PostId -> Bool -> MH ()
-pinMessage pId f = do
-  session <- getSession
-  doAsyncWith Normal $ do
-    let doPin = if f then MM.mmPinPostToChannel else MM.mmUnpinPostToChannel
-    void $ doPin pId session
-    return Nothing
diff --git a/src/State/Messages.hs b/src/State/Messages.hs
deleted file mode 100644
--- a/src/State/Messages.hs
+++ /dev/null
@@ -1,871 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-module State.Messages
-  ( PostToAdd(..)
-  , addDisconnectGaps
-  , lastMsg
-  , sendMessage
-  , editMessage
-  , deleteMessage
-  , addNewPostedMessage
-  , addObtainedMessages
-  , asyncFetchMoreMessages
-  , asyncFetchMessagesForGap
-  , asyncFetchMessagesSurrounding
-  , fetchVisibleIfNeeded
-  , disconnectChannels
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Main ( getVtyHandle, invalidateCacheEntry )
-import qualified Brick.Widgets.FileBrowser as FB
-import qualified Data.Foldable as F
-import qualified Data.HashMap.Strict as HM
-import qualified Data.Set as Set
-import qualified Data.Sequence as Seq
-import qualified Data.Text as T
-import           Graphics.Vty ( outputIface )
-import           Graphics.Vty.Output.Interface ( ringTerminalBell )
-import           Lens.Micro.Platform ( Traversal', (.=), (%=), (%~), (.~), (^?)
-                                     , to, at, traversed, filtered, ix, _1 )
-
-import           Network.Mattermost
-import qualified Network.Mattermost.Endpoints as MM
-import           Network.Mattermost.Lenses
-import           Network.Mattermost.Types
-
-import           Constants
-import           State.Channels
-import           State.Common
-import           State.Reactions
-import           State.Users
-import           TimeUtils
-import           Types
-import           Types.Common ( sanitizeUserText )
-import           Types.DirectionalSeq ( DirectionalSeq, SeqDirection )
-
-
--- ----------------------------------------------------------------------
--- Message gaps
-
-
--- | Called to add an UnknownGap to the end of the Messages collection
--- for all channels when the client has become disconnected from the
--- server.  This gaps will later be removed by successful fetching
--- overlaps if the connection is re-established.  Note that the
--- disconnect is re-iterated periodically via a re-connect timer
--- attempt, so do not duplicate gaps.  Also clear any flags
--- representing a pending exchange with the server (which will now
--- never complete).
-addDisconnectGaps :: MH ()
-addDisconnectGaps = mapM_ onEach . filteredChannelIds (const True) =<< use csChannels
-    where onEach c = do addEndGap c
-                        clearPendingFlags c
-                        mh $ invalidateCacheEntry (ChannelMessages c)
-
--- | Websocket was disconnected, so all channels may now miss some
--- messages
-disconnectChannels :: MH ()
-disconnectChannels = addDisconnectGaps
-
-clearPendingFlags :: ChannelId -> MH ()
-clearPendingFlags c = csChannel(c).ccContents.cdFetchPending .= False
-
-addEndGap :: ChannelId -> MH ()
-addEndGap cId = withChannel cId $ \chan ->
-    let lastmsg_ = chan^.ccContents.cdMessages.to reverseMessages.to lastMsg
-        lastIsGap = maybe False isGap lastmsg_
-        gapMsg = newGapMessage timeJustAfterLast
-        timeJustAfterLast = maybe t0 (justAfter . _mDate) lastmsg_
-        t0 = ServerTime $ originTime  -- use any time for a channel with no messages yet
-        newGapMessage = newMessageOfType
-                        (T.pack "Disconnected. Will refresh when connected.")
-                        (C UnknownGapAfter)
-    in unless lastIsGap
-           (csChannels %= modifyChannelById cId (ccContents.cdMessages %~ addMessage gapMsg))
-
-lastMsg :: RetrogradeMessages -> Maybe Message
-lastMsg = withFirstMessage id
-
--- | Send a message and attachments to the specified channel.
-sendMessage :: ChannelId -> EditMode -> Text -> [AttachmentData] -> MH ()
-sendMessage chanId mode msg attachments =
-    when (not $ shouldSkipMessage msg) $ do
-        status <- use csConnectionStatus
-        case status of
-            Disconnected -> do
-                let m = T.concat [ "Cannot send messages while disconnected. Enable logging to "
-                                 , "get disconnection information. If Matterhorn's reconnection "
-                                 , "attempts are failing, use `/reconnect` to attempt to "
-                                 , "reconnect manually."
-                                 ]
-                mhError $ GenericError m
-            Connected -> do
-                session <- getSession
-                doAsync Preempt $ do
-                    -- Upload attachments
-                    fileInfos <- forM attachments $ \a -> do
-                        MM.mmUploadFile chanId (FB.fileInfoFilename $ attachmentDataFileInfo a)
-                            (attachmentDataBytes a) session
-
-                    let fileIds = Seq.fromList $
-                                  fmap fileInfoId $
-                                  concat $
-                                  (F.toList . MM.uploadResponseFileInfos) <$> fileInfos
-
-                    case mode of
-                        NewPost -> do
-                            let pendingPost = (rawPost msg chanId) { rawPostFileIds = fileIds }
-                            void $ MM.mmCreatePost pendingPost session
-                        Replying _ p -> do
-                            let pendingPost = (rawPost msg chanId) { rawPostRootId = postRootId p <|> (Just $ postId p)
-                                                                   , rawPostFileIds = fileIds
-                                                                   }
-                            void $ MM.mmCreatePost pendingPost session
-                        Editing p ty -> do
-                            let body = case ty of
-                                         CP Emote -> addEmoteFormatting msg
-                                         _ -> msg
-                                update = (postUpdateBody body) { postUpdateFileIds = if null fileIds
-                                                                                     then Nothing
-                                                                                     else Just fileIds
-                                                               }
-                            void $ MM.mmPatchPost (postId p) update session
-
-shouldSkipMessage :: Text -> Bool
-shouldSkipMessage "" = True
-shouldSkipMessage s = T.all (`elem` (" \t"::String)) s
-
-editMessage :: Post -> MH ()
-editMessage new = do
-    myId <- gets myUserId
-    let isEditedMessage m = m^.mMessageId == Just (MessagePostId $ new^.postIdL)
-        (msg, mentionedUsers) = clientPostToMessage (toClientPost new (new^.postRootIdL))
-        chan = csChannel (new^.postChannelIdL)
-    chan . ccContents . cdMessages . traversed . filtered isEditedMessage .= msg
-    mh $ invalidateCacheEntry (ChannelMessages $ new^.postChannelIdL)
-
-    fetchMentionedUsers mentionedUsers
-
-    when (postUserId new /= Just myId) $
-        chan %= adjustEditedThreshold new
-
-    csPostMap.ix(postId new) .= msg
-    asyncFetchReactionsForPost (postChannelId new) new
-    asyncFetchAttachments new
-
-deleteMessage :: Post -> MH ()
-deleteMessage new = do
-    let isDeletedMessage m = m^.mMessageId == Just (MessagePostId $ new^.postIdL) ||
-                             isReplyTo (new^.postIdL) m
-        chan :: Traversal' ChatState ClientChannel
-        chan = csChannel (new^.postChannelIdL)
-    chan.ccContents.cdMessages.traversed.filtered isDeletedMessage %= (& mDeleted .~ True)
-    chan %= adjustUpdated new
-    mh $ invalidateCacheEntry (ChannelMessages $ new^.postChannelIdL)
-
-addNewPostedMessage :: PostToAdd -> MH ()
-addNewPostedMessage p =
-    addMessageToState True True p >>= postProcessMessageAdd
-
--- | Adds the set of Posts to the indicated channel.  The Posts must
--- all be for the specified Channel.  The reqCnt argument indicates
--- how many posts were requested, which will determine whether a gap
--- message is added to either end of the posts list or not.
---
--- The addTrailingGap is only True when fetching the very latest messages
--- for the channel, and will suppress the generation of a Gap message
--- following the added block of messages.
-addObtainedMessages :: ChannelId -> Int -> Bool -> Posts -> MH PostProcessMessageAdd
-addObtainedMessages cId reqCnt addTrailingGap posts =
-  if null $ posts^.postsOrderL
-  then do when addTrailingGap $
-            -- Fetched at the end of the channel, but nothing was
-            -- available.  This is common if this is a new channel
-            -- with no messages in it.  Need to remove any gaps that
-            -- exist at the end of the channel.
-            csChannels %= modifyChannelById cId
-              (ccContents.cdMessages %~
-               \msgs -> let startPoint = join $ _mMessageId <$> getLatestPostMsg msgs
-                        in fst $ removeMatchesFromSubset isGap startPoint Nothing msgs)
-          return NoAction
-  else
-    -- Adding a block of server-provided messages, which are known to
-    -- be contiguous.  Locally this may overlap with some UnknownGap
-    -- messages, which can therefore be removed.  Alternatively the
-    -- new block may be discontiguous with the local blocks, in which
-    -- case the new block should be surrounded by UnknownGaps.
-    withChannelOrDefault cId NoAction $ \chan -> do
-        let pIdList = toList (posts^.postsOrderL)
-            -- the first and list PostId in the batch to be added
-            earliestPId = last pIdList
-            latestPId = head pIdList
-            earliestDate = postCreateAt $ (posts^.postsPostsL) HM.! earliestPId
-            latestDate = postCreateAt $ (posts^.postsPostsL) HM.! latestPId
-
-            localMessages = chan^.ccContents . cdMessages
-
-            -- Get a list of the duplicated message PostIds between
-            -- the messages already in the channel and the new posts
-            -- to be added.
-
-            match = snd $ removeMatchesFromSubset
-                          (\m -> maybe False (\p -> p `elem` pIdList) (messagePostId m))
-                          (Just (MessagePostId earliestPId))
-                          (Just (MessagePostId latestPId))
-                          localMessages
-
-            accum m l =
-                case messagePostId m of
-                    Just pId -> pId : l
-                    Nothing -> l
-            dupPIds = foldr accum [] match
-
-            -- If there were any matches, then there was overlap of
-            -- the new messages with existing messages.
-
-            -- Don't re-add matching messages (avoid overhead like
-            -- re-checking/re-fetching related post information, and
-            -- do not signal action needed for notifications), and
-            -- remove any gaps in the overlapping region.
-
-            newGapMessage d isOlder =
-              -- newGapMessage is a helper for generating a gap
-              -- message
-              do uuid <- generateUUID
-                 let txt = "Load " <>
-                           (if isOlder then "older" else "newer") <>
-                           " messages" <>
-                           (if isOlder then "  ↥↥↥" else "  ↧↧↧")
-                     ty = if isOlder
-                          then C UnknownGapBefore
-                          else C UnknownGapAfter
-                 return (newMessageOfType txt ty d
-                         & mMessageId .~ Just (MessageUUID uuid))
-
-            -- If this batch contains the latest known messages, do
-            -- not add a following gap.  A gap at this point is added
-            -- by a websocket disconnect, and any fetches thereafter
-            -- are assumed to be the most current information (until
-            -- another disconnect), so no gap is needed.
-            -- Additionally, the presence of a gap at the end for a
-            -- connected client causes a fetch of messages at this
-            -- location, so adding the gap here would cause an
-            -- infinite update loop.
-
-            addingAtEnd = maybe True (latestDate >=) $
-                          (^.mDate) <$> getLatestPostMsg localMessages
-
-            addingAtStart = maybe True (earliestDate <=) $
-                            (^.mDate) <$> getEarliestPostMsg localMessages
-            removeStart = if addingAtStart && noMoreBefore
-                          then Nothing
-                          else Just (MessagePostId earliestPId)
-            removeEnd = if addTrailingGap || (addingAtEnd && noMoreAfter)
-                        then Nothing
-                        else Just (MessagePostId latestPId)
-
-            noMoreBefore = reqCnt < 0 && length pIdList < (-reqCnt)
-            noMoreAfter = addTrailingGap || reqCnt > 0 && length pIdList < reqCnt
-
-            reAddGapBefore = earliestPId `elem` dupPIds || noMoreBefore
-            -- addingAtEnd used to be in reAddGapAfter but does not
-            -- seem to be needed.  I may have missed a specific use
-            -- case/scenario, so I've left it commented out here for
-            -- debug assistance.
-            reAddGapAfter = latestPId `elem` dupPIds || {- addingAtEnd || -} noMoreAfter
-
-        -- The post map returned by the server will *already* have
-        -- all thread messages for each post that is part of a
-        -- thread. By calling installMessagesFromPosts here, we go ahead
-        -- and populate the csPostMap with those posts so that below, in
-        -- addMessageToState, we notice that we already know about reply
-        -- parent messages and can avoid fetching them. This converts
-        -- the posts to Messages and stores those and also returns
-        -- them, but we don't need them here. We just want the post
-        -- map update. This also gathers up the set of all mentioned
-        -- usernames in the text of the messages which we need to use to
-        -- submit a single batch request for user metadata so we don't
-        -- submit one request per mention.
-        void $ installMessagesFromPosts posts
-
-        -- Add all the new *unique* posts into the existing channel
-        -- corpus, generating needed fetches of data associated with
-        -- the post, and determining an notification action to be
-        -- taken (if any).
-        action <- foldr andProcessWith NoAction <$>
-          mapM (addMessageToState False False . OldPost)
-                   [ (posts^.postsPostsL) HM.! p
-                   | p <- toList (posts^.postsOrderL)
-                   , not (p `elem` dupPIds)
-                   ]
-
-        -- The channel messages now include all the fetched messages.
-        -- Things to do at this point are:
-        --
-        --   1. Remove any duplicates just added, as well as any gaps
-        --   2. Add new gaps (if needed) at either end of the added
-        --      messages.
-        --   3. Update the "current selection" if it was on a removed message.
-        --
-        -- Do this with the updated copy of the channel's messages.
-
-        withChannelOrDefault cId () $ \updchan -> do
-          let updMsgs = updchan ^. ccContents . cdMessages
-
-          -- Remove any gaps in the added region.  If there was an
-          -- active message selection and it is one of the removed
-          -- gaps, reset the selection to the beginning or end of the
-          -- added region (if there are any added selectable messages,
-          -- otherwise just the end if the message list in it's
-          -- entirety, or no selection at all).
-
-          let (resultMessages, removedMessages) =
-                removeMatchesFromSubset isGap removeStart removeEnd updMsgs
-          csChannels %= modifyChannelById cId
-            (ccContents.cdMessages .~ resultMessages)
-
-          -- Determine if the current selected message was one of the
-          -- removed messages.
-
-          selMsgId <- use (csMessageSelect.to selectMessageId)
-          let rmvdSel = do
-                i <- selMsgId -- :: Maybe MessageId
-                findMessage i removedMessages
-              rmvdSelType = _mType <$> rmvdSel
-
-          case rmvdSel of
-            Nothing -> return ()
-            Just rm ->
-              if isGap rm
-              then return ()  -- handled during gap insertion below
-              else do
-                -- Replaced a selected message that wasn't a gap.
-                -- This is unlikely, but may occur if the previously
-                -- selected message was just deleted by another user
-                -- and is in the fetched region.  The choices here are
-                -- to move the selection, or cancel the selection.
-                -- Both will be unpleasant surprises for the user, but
-                -- cancelling the selection is probably the better
-                -- choice than allowing the user to perform select
-                -- actions on a message that isn't the one they just
-                -- selected.
-                setMode Main
-                csMessageSelect .= MessageSelectState Nothing
-
-          -- Add a gap at each end of the newly fetched data, unless:
-          --   1. there is an overlap
-          --   2. there is no more in the indicated direction
-          --      a. indicated by adding messages later than any currently
-          --         held messages (see note above re 'addingAtEnd').
-          --      b. the amount returned was less than the amount requested
-
-          if reAddGapBefore
-            then
-              -- No more gaps.  If the selected gap was removed, move
-              -- select to first (earliest) message)
-              case rmvdSelType of
-                Just (C UnknownGapBefore) ->
-                  csMessageSelect .= MessageSelectState (pure $ MessagePostId earliestPId)
-                _ -> return ()
-            else do
-              -- add a gap at the start of the newly fetched block and
-              -- make that the active selection if this fetch removed
-              -- the previously selected gap in this direction.
-              gapMsg <- newGapMessage (justBefore earliestDate) True
-              csChannels %= modifyChannelById cId
-                (ccContents.cdMessages %~ addMessage gapMsg)
-              -- Move selection from old gap to new gap
-              case rmvdSelType of
-                Just (C UnknownGapBefore) -> do
-                  csMessageSelect .= MessageSelectState (gapMsg^.mMessageId)
-                _ -> return ()
-
-          if reAddGapAfter
-            then
-              -- No more gaps.  If the selected gap was removed, move
-              -- select to last (latest) message.
-              case rmvdSelType of
-                Just (C UnknownGapAfter) ->
-                  csMessageSelect .= MessageSelectState (pure $ MessagePostId latestPId)
-                _ -> return ()
-            else do
-              -- add a gap at the end of the newly fetched block and
-              -- make that the active selection if this fetch removed
-              -- the previously selected gap in this direction.
-              gapMsg <- newGapMessage (justAfter latestDate) False
-              csChannels %= modifyChannelById cId
-                (ccContents.cdMessages %~ addMessage gapMsg)
-              -- Move selection from old gap to new gap
-              case rmvdSelType of
-                Just (C UnknownGapAfter) ->
-                  csMessageSelect .= MessageSelectState (gapMsg^.mMessageId)
-                _ -> return ()
-
-        -- Now initiate fetches for use information for any
-        -- as-yet-unknown users related to this new set of messages
-
-        let users = foldr (\post s -> maybe s (flip Set.insert s) (postUserId post))
-                          Set.empty (posts^.postsPostsL)
-            addUnknownUsers inputUserIds = do
-                knownUserIds <- Set.fromList <$> gets allUserIds
-                let unknownUsers = Set.difference inputUserIds knownUserIds
-                if Set.null unknownUsers
-                   then return ()
-                   else handleNewUsers (Seq.fromList $ toList unknownUsers) (return ())
-
-        addUnknownUsers users
-
-        -- Return the aggregated user notification action needed
-        -- relative to the set of added messages.
-
-        return action
-
--- | Adds a possibly new message to the associated channel contents.
--- Returns an indicator of whether the user should be potentially
--- notified of a change (a new message not posted by this user, a
--- mention of the user, etc.).  This operation has no effect on any
--- existing UnknownGap entries and should be called when those are
--- irrelevant.
---
--- The first boolean argument ('doFetchMentionedUsers') indicates
--- whether this function should schedule a fetch for any mentioned
--- users in the message. This is provided so that callers can batch
--- this operation if a large collection of messages is being added
--- together, in which case we don't want this function to schedule a
--- single request per message (worst case). If you're calling this as
--- part of scrollback processing, you should pass False. Otherwise if
--- you're adding only a single message, you should pass True.
---
--- The second boolean argument ('fetchAuthor') is similar to the first
--- boolean argument but it refers to the author of the message instead
--- of any user mentions within the message body.
---
--- The third argument ('newPostData') indicates whether this message
--- is being added as part of a fetch of old messages (e.g. scrollback)
--- or if ti is a new message and affects things like whether
--- notifications are generated and if the "New Messages" marker gets
--- updated.
-addMessageToState :: Bool -> Bool -> PostToAdd -> MH PostProcessMessageAdd
-addMessageToState doFetchMentionedUsers fetchAuthor newPostData = do
-    let (new, wasMentioned) = case newPostData of
-          -- A post from scrollback history has no mention data, and
-          -- that's okay: we only need to track mentions to tell the
-          -- user that recent posts contained mentions.
-          OldPost p      -> (p, False)
-          RecentPost p m -> (p, m)
-
-    st <- use id
-    case st ^? csChannel(postChannelId new) of
-        Nothing -> do
-            session <- getSession
-            doAsyncWith Preempt $ do
-                nc <- MM.mmGetChannel (postChannelId new) session
-                member <- MM.mmGetChannelMember (postChannelId new) UserMe session
-
-                let chType = nc^.channelTypeL
-                    pref = showGroupChannelPref (postChannelId new) (myUserId st)
-
-                -- If the channel has been archived, we don't want to
-                -- post this message or add the channel to the state.
-                case channelDeleted nc of
-                    True -> return Nothing
-                    False -> return $ Just $ do
-                        -- If the incoming message is for a group
-                        -- channel we don't know about, that's because
-                        -- it was previously hidden by the user. We need
-                        -- to show it, and to do that we need to update
-                        -- the server-side preference. (That, in turn,
-                        -- triggers a channel refresh.)
-                        if chType == Group
-                            then applyPreferenceChange pref
-                            else refreshChannel SidebarUpdateImmediate nc member
-
-                        addMessageToState doFetchMentionedUsers fetchAuthor newPostData >>=
-                            postProcessMessageAdd
-
-            return NoAction
-        Just _ -> do
-            let cp = toClientPost new (new^.postRootIdL)
-                fromMe = (cp^.cpUser == (Just $ myUserId st)) &&
-                         (isNothing $ cp^.cpUserOverride)
-                userPrefs = st^.csResources.crUserPreferences
-                isJoinOrLeave = case cp^.cpType of
-                  Join  -> True
-                  Leave -> True
-                  _     -> False
-                ignoredJoinLeaveMessage =
-                  not (userPrefs^.userPrefShowJoinLeave) && isJoinOrLeave
-                cId = postChannelId new
-
-                doAddMessage = do
-                    -- Do we have the user data for the post author?
-                    case cp^.cpUser of
-                        Nothing -> return ()
-                        Just authorId -> when fetchAuthor $ do
-                            authorResult <- gets (userById authorId)
-                            when (isNothing authorResult) $
-                                handleNewUsers (Seq.singleton authorId) (return ())
-
-                    currCId <- use csCurrentChannelId
-                    flags <- use (csResources.crFlaggedPosts)
-                    let (msg', mentionedUsers) = clientPostToMessage cp
-                                 & _1.mFlagged .~ ((cp^.cpPostId) `Set.member` flags)
-
-                    when doFetchMentionedUsers $
-                        fetchMentionedUsers mentionedUsers
-
-                    csPostMap.at(postId new) .= Just msg'
-                    mh $ invalidateCacheEntry (ChannelMessages cId)
-                    csChannels %= modifyChannelById cId
-                      ((ccContents.cdMessages %~ addMessage msg') .
-                       (if not ignoredJoinLeaveMessage then adjustUpdated new else id) .
-                       (\c -> if currCId == cId
-                              then c
-                              else case newPostData of
-                                     OldPost _ -> c
-                                     RecentPost _ _ ->
-                                       updateNewMessageIndicator new c) .
-                       (\c -> if wasMentioned
-                              then c & ccInfo.cdMentionCount %~ succ
-                              else c)
-                      )
-                    asyncFetchReactionsForPost cId new
-                    asyncFetchAttachments new
-                    postedChanMessage
-
-                doHandleAddedMessage = do
-                    -- If the message is in reply to another message,
-                    -- try to find it in the scrollback for the post's
-                    -- channel. If the message isn't there, fetch it. If
-                    -- we have to fetch it, don't post this message to the
-                    -- channel until we have fetched the parent.
-                    case cp^.cpInReplyToPost of
-                        Just parentId ->
-                            case getMessageForPostId st parentId of
-                                Nothing -> do
-                                    doAsyncChannelMM Preempt cId
-                                        (\s _ -> MM.mmGetThread parentId s)
-                                        (\_ p -> Just $ updatePostMap p)
-                                _ -> return ()
-                        _ -> return ()
-
-                    doAddMessage
-
-                postedChanMessage =
-                  withChannelOrDefault (postChannelId new) NoAction $ \chan -> do
-                      currCId <- use csCurrentChannelId
-
-                      let notifyPref = notifyPreference (myUser st) chan
-                          curChannelAction = if postChannelId new == currCId
-                                             then UpdateServerViewed
-                                             else NoAction
-                          originUserAction =
-                            if | fromMe                            -> NoAction
-                               | ignoredJoinLeaveMessage           -> NoAction
-                               | notifyPref == NotifyOptionAll     -> NotifyUser [newPostData]
-                               | notifyPref == NotifyOptionMention
-                                   && wasMentioned                 -> NotifyUser [newPostData]
-                               | otherwise                         -> NoAction
-
-                      return $ curChannelAction `andProcessWith` originUserAction
-
-            doHandleAddedMessage
-
--- | PostProcessMessageAdd is an internal value that informs the main
--- code whether the user should be notified (e.g., ring the bell) or
--- the server should be updated (e.g., that the channel has been
--- viewed).  This is a monoid so that it can be folded over when there
--- are multiple inbound posts to be processed.
-data PostProcessMessageAdd = NoAction
-                           | NotifyUser [PostToAdd]
-                           | UpdateServerViewed
-                           | NotifyUserAndServer [PostToAdd]
-
-andProcessWith
-  :: PostProcessMessageAdd -> PostProcessMessageAdd -> PostProcessMessageAdd
-andProcessWith NoAction x                                        = x
-andProcessWith x NoAction                                        = x
-andProcessWith (NotifyUserAndServer p) UpdateServerViewed        = NotifyUserAndServer p
-andProcessWith (NotifyUserAndServer p1) (NotifyUser p2)          = NotifyUserAndServer (p1 <> p2)
-andProcessWith (NotifyUserAndServer p1) (NotifyUserAndServer p2) = NotifyUserAndServer (p1 <> p2)
-andProcessWith (NotifyUser p1) (NotifyUserAndServer p2)          = NotifyUser (p1 <> p2)
-andProcessWith (NotifyUser p1) (NotifyUser p2)                   = NotifyUser (p1 <> p2)
-andProcessWith (NotifyUser p) UpdateServerViewed                 = NotifyUserAndServer p
-andProcessWith UpdateServerViewed UpdateServerViewed             = UpdateServerViewed
-andProcessWith UpdateServerViewed (NotifyUserAndServer p)        = NotifyUserAndServer p
-andProcessWith UpdateServerViewed (NotifyUser p)                 = NotifyUserAndServer p
-
--- | postProcessMessageAdd performs the actual actions indicated by
--- the corresponding input value.
-postProcessMessageAdd :: PostProcessMessageAdd -> MH ()
-postProcessMessageAdd ppma = postOp ppma
-    where
-        postOp NoAction                = return ()
-        postOp UpdateServerViewed      = updateViewed False
-        postOp (NotifyUser p)          = maybeRingBell >> mapM_ maybeNotify p
-        postOp (NotifyUserAndServer p) = updateViewed False >> maybeRingBell >> mapM_ maybeNotify p
-
-maybeNotify :: PostToAdd -> MH ()
-maybeNotify (OldPost _) = do
-    return ()
-maybeNotify (RecentPost post mentioned) = runNotifyCommand post mentioned
-
-maybeRingBell :: MH ()
-maybeRingBell = do
-    doBell <- use (csResources.crConfiguration.to configActivityBell)
-    when doBell $ do
-        vty <- mh getVtyHandle
-        liftIO $ ringTerminalBell $ outputIface vty
-
--- | When we add posts to the application state, we either get them
--- from the server during scrollback fetches (here called 'OldPost') or
--- we get them from websocket events when they are posted in real time
--- (here called 'RecentPost').
-data PostToAdd =
-    OldPost Post
-    -- ^ A post from the server's history
-    | RecentPost Post Bool
-    -- ^ A message posted to the channel since the user connected, along
-    -- with a flag indicating whether the post triggered any of the
-    -- user's mentions. We need an extra flag because the server
-    -- determines whether the post has any mentions, and that data is
-    -- only available in websocket events (and then provided to this
-    -- constructor).
-
-runNotifyCommand :: Post -> Bool -> MH ()
-runNotifyCommand post mentioned = do
-    outputChan <- use (csResources.crSubprocessLog)
-    st <- use id
-    notifyCommand <- use (csResources.crConfiguration.to configActivityNotifyCommand)
-    case notifyCommand of
-        Nothing -> return ()
-        Just cmd ->
-            doAsyncWith Preempt $ do
-                let messageString = T.unpack $ sanitizeUserText $ postMessage post
-                    notified = if mentioned then "1" else "2"
-                    sender = T.unpack $ maybePostUsername st post
-                runLoggedCommand False outputChan (T.unpack cmd)
-                                 [notified, sender, messageString] Nothing Nothing
-                return Nothing
-
-maybePostUsername :: ChatState -> Post -> T.Text
-maybePostUsername st p =
-    fromMaybe T.empty $ do
-        uId <- postUserId p
-        usernameForUserId uId st
-
--- | Fetches additional message history for the current channel.  This
--- is generally called when in ChannelScroll mode, in which state the
--- output is cached and seen via a scrolling viewport; new messages
--- received in this mode are not normally shown, but this explicit
--- user-driven fetch should be displayed, so this also invalidates the
--- cache.
---
--- This function assumes it is being called to add "older" messages to
--- the message history (i.e. near the beginning of the known
--- messages).  It will normally try to overlap the fetch with the
--- known existing messages so that when the fetch results are
--- processed (which should be a contiguous set of messages as provided
--- by the server) there will be an overlap with existing messages; if
--- there is no overlap, then a special "gap" must be inserted in the
--- area between the existing messages and the newly fetched messages
--- to indicate that this client does not know if there are missing
--- messages there or not.
---
--- In order to achieve an overlap, this code attempts to get the
--- second oldest messages as the message ID to pass to the server as
--- the "older than" marker ('postQueryBefore'), so that the oldest
--- message here overlaps with the fetched results to ensure no gap
--- needs to be inserted.  However, there may already be a gap between
--- the oldest and second-oldest messages, so this code must actually
--- search for the first set of two *contiguous* messages it is aware
--- of to avoid adding additional gaps. (It's OK if gaps are added, but
--- the user must explicitly request a check for messages in order to
--- eliminate them, so it's better to avoid adding them in the first
--- place).  This code is nearly always used to extend the older
--- history of a channel that information has already been retrieved
--- from, so it's almost certain that there are at least two contiguous
--- messages to use as a starting point, but exceptions are new
--- channels and empty channels.
-asyncFetchMoreMessages :: MH ()
-asyncFetchMoreMessages = do
-    cId  <- use csCurrentChannelId
-    withChannel cId $ \chan ->
-        let offset = max 0 $ length (chan^.ccContents.cdMessages) - 2
-            page = offset `div` pageAmount
-            usefulMsgs = getTwoContiguousPosts Nothing (chan^.ccContents.cdMessages.to reverseMessages)
-            sndOldestId = (messagePostId . snd) =<< usefulMsgs
-            query = MM.defaultPostQuery
-                      { MM.postQueryPage = maybe (Just page) (const Nothing) sndOldestId
-                      , MM.postQueryPerPage = Just pageAmount
-                      , MM.postQueryBefore = sndOldestId
-                      }
-            addTrailingGap = MM.postQueryBefore query == Nothing &&
-                             MM.postQueryPage query == Just 0
-        in doAsyncChannelMM Preempt cId
-               (\s c -> MM.mmGetPostsForChannel c query s)
-               (\c p -> Just $ do
-                   pp <- addObtainedMessages c (-pageAmount) addTrailingGap p
-                   postProcessMessageAdd pp
-                   mh $ invalidateCacheEntry (ChannelMessages cId))
-
-
--- | Given a starting point and a direction to move from that point,
--- returns the closest two adjacent messages on that direction (as a
--- tuple of closest and next-closest), or Nothing if there are no
--- adjacent messages in the indicated direction.
-getTwoContiguousPosts :: SeqDirection dir =>
-                         Maybe Message
-                      -> DirectionalSeq dir Message
-                      -> Maybe (Message, Message)
-getTwoContiguousPosts startMsg msgs =
-  let go start =
-        do anchor <- getRelMessageId (_mMessageId =<< start) msgs
-           hinge <- getRelMessageId (anchor^.mMessageId) msgs
-           if isGap anchor || isGap hinge
-             then go $ Just anchor
-             else Just (anchor, hinge)
-  in go startMsg
-
-
-asyncFetchMessagesForGap :: ChannelId -> Message -> MH ()
-asyncFetchMessagesForGap cId gapMessage =
-  when (isGap gapMessage) $
-  withChannel cId $ \chan ->
-    let offset = max 0 $ length (chan^.ccContents.cdMessages) - 2
-        page = offset `div` pageAmount
-        chanMsgs = chan^.ccContents.cdMessages
-        fromMsg = Just gapMessage
-        fetchNewer = case gapMessage^.mType of
-                       C UnknownGapAfter -> True
-                       C UnknownGapBefore -> False
-                       _ -> error "fetch gap messages: unknown gap message type"
-        baseId = messagePostId . snd =<<
-                 case gapMessage^.mType of
-                    C UnknownGapAfter -> getTwoContiguousPosts fromMsg $
-                                         reverseMessages chanMsgs
-                    C UnknownGapBefore -> getTwoContiguousPosts fromMsg chanMsgs
-                    _ -> error "fetch gap messages: unknown gap message type"
-        query = MM.defaultPostQuery
-                { MM.postQueryPage = maybe (Just page) (const Nothing) baseId
-                , MM.postQueryPerPage = Just pageAmount
-                , MM.postQueryBefore = if fetchNewer then Nothing else baseId
-                , MM.postQueryAfter = if fetchNewer then baseId else Nothing
-                }
-        addTrailingGap = MM.postQueryBefore query == Nothing &&
-                         MM.postQueryPage query == Just 0
-    in doAsyncChannelMM Preempt cId
-       (\s c -> MM.mmGetPostsForChannel c query s)
-       (\c p -> Just $ do
-           void $ addObtainedMessages c (-pageAmount) addTrailingGap p
-           mh $ invalidateCacheEntry (ChannelMessages cId))
-
--- | Given a particular message ID, this fetches n messages before and
--- after immediately before and after the specified message in order
--- to establish some context for that message.  This is frequently
--- used as a background operation when looking at search or flag
--- results so that jumping to message select mode for one of those
--- messages will show a bit of context (and it also prevents showing
--- gap messages for adjacent targets).
---
--- The result will be adding at most 2n messages to the channel, with
--- the input post ID being somewhere in the middle of the added
--- messages.
---
--- Note that this fetch will add messages to the channel, but it
--- performs no notifications or updates of new-unread indicators
--- because it is assumed to be used for non-current (previously-seen)
--- messages in background mode.
-asyncFetchMessagesSurrounding :: ChannelId -> PostId -> MH ()
-asyncFetchMessagesSurrounding cId pId = do
-    let query = MM.defaultPostQuery
-          { MM.postQueryBefore = Just pId
-          , MM.postQueryPerPage = Just reqAmt
-          }
-        reqAmt = 5  -- both before and after
-    doAsyncChannelMM Preempt cId
-      -- first get some messages before the target, no overlap
-      (\s c -> MM.mmGetPostsForChannel c query s)
-      (\c p -> Just $ do
-          let last2ndId = secondToLastPostId p
-          void $ addObtainedMessages c (-reqAmt) False p
-          mh $ invalidateCacheEntry (ChannelMessages cId)
-          -- now start 2nd from end of this fetch to fetch some
-          -- messages forward, also overlapping with this fetch and
-          -- the original message ID to eliminate all gaps in this
-          -- surrounding set of messages.
-          let query' = MM.defaultPostQuery
-                       { MM.postQueryAfter = last2ndId
-                       , MM.postQueryPerPage = Just $ reqAmt + 2
-                       }
-          doAsyncChannelMM Preempt cId
-            (\s' c' -> MM.mmGetPostsForChannel c' query' s')
-            (\c' p' -> Just $ do
-                void $ addObtainedMessages c' (reqAmt + 2) False p'
-                mh $ invalidateCacheEntry (ChannelMessages cId)
-            )
-      )
-      where secondToLastPostId posts =
-              let pl = toList $ postsOrder posts
-              in if length pl > 1 then Just $ last $ init pl else Nothing
-
-
-fetchVisibleIfNeeded :: MH ()
-fetchVisibleIfNeeded = do
-    sts <- use csConnectionStatus
-    when (sts == Connected) $ do
-        cId <- use csCurrentChannelId
-        withChannel cId $ \chan ->
-            let msgs = chan^.ccContents.cdMessages.to reverseMessages
-                (numRemaining, gapInDisplayable, _, rel'pId, overlap) =
-                    foldl gapTrail (numScrollbackPosts, False, Nothing, Nothing, 2) msgs
-                gapTrail a@(_,  True, _, _, _) _ = a
-                gapTrail a@(0,     _, _, _, _) _ = a
-                gapTrail   (a, False, b, c, d) m | isGap m = (a, True, b, c, d)
-                gapTrail (remCnt, _, prev'pId, prev''pId, ovl) msg =
-                    (remCnt - 1, False, msg^.mMessageId <|> prev'pId, prev'pId <|> prev''pId,
-                     ovl + if not (isPostMessage msg) then 1 else 0)
-                numToReq = numRemaining + overlap
-                query = MM.defaultPostQuery
-                        { MM.postQueryPage    = Just 0
-                        , MM.postQueryPerPage = Just numToReq
-                        }
-                finalQuery = case rel'pId of
-                               Just (MessagePostId pid) -> query { MM.postQueryBefore = Just pid }
-                               _ -> query
-                op = \s c -> MM.mmGetPostsForChannel c finalQuery s
-                addTrailingGap = MM.postQueryBefore finalQuery == Nothing &&
-                                 MM.postQueryPage finalQuery == Just 0
-            in when ((not $ chan^.ccContents.cdFetchPending) && gapInDisplayable) $ do
-                      csChannel(cId).ccContents.cdFetchPending .= True
-                      doAsyncChannelMM Preempt cId op
-                          (\c p -> Just $ do
-                              addObtainedMessages c (-numToReq) addTrailingGap p >>= postProcessMessageAdd
-                              csChannel(c).ccContents.cdFetchPending .= False)
-
-asyncFetchAttachments :: Post -> MH ()
-asyncFetchAttachments p = do
-    let cId = p^.postChannelIdL
-        pId = p^.postIdL
-    session <- getSession
-    host <- use (csResources.crConn.cdHostnameL)
-    F.forM_ (p^.postFileIdsL) $ \fId -> doAsyncWith Normal $ do
-        info <- MM.mmGetMetadataForFile fId session
-        let scheme = "https://"
-            attUrl = scheme <> host <> urlForFile fId
-            attachment = mkAttachment (fileInfoName info) attUrl fId
-            addIfMissing a as =
-                if isNothing $ Seq.elemIndexL a as
-                then a Seq.<| as
-                else as
-            addAttachment m
-                | m^.mMessageId == Just (MessagePostId pId) =
-                    m & mAttachments %~ (addIfMissing attachment)
-                | otherwise =
-                    m
-        return $ Just $ do
-            csChannel(cId).ccContents.cdMessages.traversed %= addAttachment
-            mh $ invalidateCacheEntry $ ChannelMessages cId
diff --git a/src/State/Messages.hs-boot b/src/State/Messages.hs-boot
deleted file mode 100644
--- a/src/State/Messages.hs-boot
+++ /dev/null
@@ -1,11 +0,0 @@
-module State.Messages
-  ( fetchVisibleIfNeeded
-  )
-where
-
-import Types ( MH )
-
--- State.Channels needs this in setFocusWith, but that creates an
--- import cycle because several things in State.Messages need things in
--- State.Channels.
-fetchVisibleIfNeeded :: MH ()
diff --git a/src/State/NotifyPrefs.hs b/src/State/NotifyPrefs.hs
deleted file mode 100644
--- a/src/State/NotifyPrefs.hs
+++ /dev/null
@@ -1,87 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RankNTypes #-}
-module State.NotifyPrefs
-    ( enterEditNotifyPrefsMode
-    , exitEditNotifyPrefsMode
-    )
-where
-
-import Prelude ()
-import Prelude.MH
-
-import Types
-
-import Network.Mattermost.Types ( ChannelNotifyProps
-                                , User(..)
-                                , UserNotifyProps(..)
-                                , Type(..)
-                                , channelNotifyPropsMarkUnread
-                                , channelNotifyPropsIgnoreChannelMentions
-                                , WithDefault(..)
-                                , NotifyOption(..)
-                                )
-
-import Brick
-import Brick.Forms
-import Lens.Micro.Platform ( Lens', (.=), lens )
-
-muteLens :: Lens' ChannelNotifyProps Bool
-muteLens = lens (\props -> props^.channelNotifyPropsMarkUnreadL == IsValue NotifyOptionMention)
-           (\props muted -> props { channelNotifyPropsMarkUnread =
-                                          if muted
-                                          then IsValue NotifyOptionMention
-                                          else IsValue NotifyOptionAll
-                                  })
-
-channelMentionLens :: Lens' ChannelNotifyProps Bool
-channelMentionLens = lens (\props -> props^.channelNotifyPropsIgnoreChannelMentionsL == IsValue True)
-                     (\props ignoreChannelMentions ->
-                          props { channelNotifyPropsIgnoreChannelMentions = if ignoreChannelMentions
-                                                                            then IsValue True
-                                                                            else Default
-                                })
-
-notifyOptionName :: NotifyOption -> Text
-notifyOptionName NotifyOptionAll = "All activity"
-notifyOptionName NotifyOptionMention = "Mentions"
-notifyOptionName NotifyOptionNone = "Never"
-
-mkNotifyButtons :: ((WithDefault NotifyOption) -> Name)
-                -> Lens' ChannelNotifyProps (WithDefault NotifyOption)
-                -> NotifyOption
-                -> ChannelNotifyProps
-                -> FormFieldState ChannelNotifyProps e Name
-mkNotifyButtons mkName l globalDefault =
-    let optTuple opt = (IsValue opt, mkName $ IsValue opt, notifyOptionName opt)
-        defaultField = (Default, mkName Default, "Global default (" <> notifyOptionName globalDefault <> ")")
-        nonDefault = optTuple <$> [ NotifyOptionAll
-                                  , NotifyOptionMention
-                                  , NotifyOptionNone
-                                  ]
-    in radioField l (defaultField : nonDefault)
-
-notifyPrefsForm :: UserNotifyProps -> ChannelNotifyProps -> Form ChannelNotifyProps e Name
-notifyPrefsForm globalDefaults =
-    newForm [ checkboxField muteLens MuteToggleField "Mute channel"
-            , (padTop $ Pad 1) @@= checkboxField channelMentionLens ChannelMentionsField "Ignore channel mentions"
-            , radioStyle "Desktop notifications" @@=
-                mkNotifyButtons DesktopNotificationsField channelNotifyPropsDesktopL (userNotifyPropsDesktop globalDefaults)
-            , radioStyle "Push notifications" @@=
-                mkNotifyButtons PushNotificationsField channelNotifyPropsPushL (userNotifyPropsPush globalDefaults)
-            ]
-    where radioStyle label = (padTop $ Pad 1 ) . (str label <=>) . (padLeft $ Pad 1)
-
-enterEditNotifyPrefsMode :: MH ()
-enterEditNotifyPrefsMode = do
-    chanInfo <- use (csCurrentChannel.ccInfo)
-    case chanInfo^.cdType of
-      Direct -> mhError $ GenericError "Cannot open notification preferences for DM channel."
-      _ -> do
-        let props = chanInfo^.cdNotifyProps
-        user <- use csMe
-        csNotifyPrefs .= (Just (notifyPrefsForm (userNotifyProps user) props))
-        setMode EditNotifyPrefs
-
-exitEditNotifyPrefsMode :: MH ()
-exitEditNotifyPrefsMode = do
-    setMode Main
diff --git a/src/State/PostListOverlay.hs b/src/State/PostListOverlay.hs
deleted file mode 100644
--- a/src/State/PostListOverlay.hs
+++ /dev/null
@@ -1,160 +0,0 @@
-module State.PostListOverlay
-  ( enterFlaggedPostListMode
-  , enterPinnedPostListMode
-  , enterSearchResultPostListMode
-  , postListJumpToCurrent
-  , postListSelectUp
-  , postListSelectDown
-  , postListUnflagSelected
-  , exitPostListMode
-  )
-where
-
-import           GHC.Exts ( IsList(..) )
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Data.Foldable as F
-import qualified Data.Text as T
-import           Lens.Micro.Platform ( (.=) )
-import           Network.Mattermost.Endpoints
-import           Network.Mattermost.Types
-
-import           State.Channels
-import           State.Common
-import           State.MessageSelect
-import           State.Messages ( addObtainedMessages
-                                , asyncFetchMessagesSurrounding )
-import           Types
-import           Types.DirectionalSeq (emptyDirSeq)
-
-
--- | Create a PostListOverlay with the given content description and
--- with a specified list of messages.
-enterPostListMode ::  PostListContents -> Messages -> MH ()
-enterPostListMode contents msgs = do
-  csPostListOverlay.postListPosts .= msgs
-  let mlatest = getLatestPostMsg msgs
-      pId = mlatest >>= messagePostId
-      cId = mlatest >>= \m -> m^.mChannelId
-  csPostListOverlay.postListSelected .= pId
-  setMode $ PostListOverlay contents
-  case (pId, cId) of
-    (Just p, Just c) -> asyncFetchMessagesSurrounding c p
-    _ -> return ()
-
--- | Clear out the state of a PostListOverlay
-exitPostListMode :: MH ()
-exitPostListMode = do
-  csPostListOverlay.postListPosts .= emptyDirSeq
-  csPostListOverlay.postListSelected .= Nothing
-  setMode Main
-
-
-createPostList :: PostListContents -> (Session -> IO Posts) -> MH ()
-createPostList contentsType fetchOp = do
-  session <- getSession
-  doAsyncWith Preempt $ do
-    posts <- fetchOp session
-    return $ Just $ do
-      messages <- installMessagesFromPosts posts
-      -- n.b. do not use addNewPostedMessage because these messages
-      -- are not new, and so no notifications or channel highlighting
-      -- or other post-processing should be performed.
-      let plist = F.toList $ postsPosts posts
-          postsSpec p = Posts { postsPosts = fromList [(postId p, p)]
-                              , postsOrder = fromList [postId p]
-                              }
-      mapM_ (\p -> addObtainedMessages (postChannelId p) 0 False $ postsSpec p) plist
-      enterPostListMode contentsType messages
-
-
--- | Create a PostListOverlay with flagged messages from the server.
-enterFlaggedPostListMode :: MH ()
-enterFlaggedPostListMode = createPostList PostListFlagged $
-                           mmGetListOfFlaggedPosts UserMe defaultFlaggedPostsQuery
-
--- | Create a PostListOverlay with pinned messages from the server for
--- the current channel.
-enterPinnedPostListMode :: MH ()
-enterPinnedPostListMode = do
-    cId <- use csCurrentChannelId
-    createPostList (PostListPinned cId) $ mmGetChannelPinnedPosts cId
-
--- | Create a PostListOverlay with post search result messages from the
--- server.
-enterSearchResultPostListMode :: Text -> MH ()
-enterSearchResultPostListMode terms
-  | T.null (T.strip terms) = postInfoMessage "Search command requires at least one search term."
-  | otherwise = do
-      enterPostListMode (PostListSearch terms True) noMessages
-      tId <- gets myTeamId
-      createPostList (PostListSearch terms False) $
-        mmSearchForTeamPosts tId (SearchPosts terms False)
-
-
--- | Move the selection up in the PostListOverlay, which corresponds
--- to finding a chronologically /newer/ message.
-postListSelectUp :: MH ()
-postListSelectUp = do
-  selId <- use (csPostListOverlay.postListSelected)
-  posts <- use (csPostListOverlay.postListPosts)
-  let nextMsg = getNextMessage (MessagePostId <$> selId) posts
-  case nextMsg of
-    Nothing -> return ()
-    Just m -> do
-      let pId = m^.mMessageId >>= messageIdPostId
-      csPostListOverlay.postListSelected .= pId
-      case (m^.mChannelId, pId) of
-        (Just c, Just p) -> asyncFetchMessagesSurrounding c p
-        o -> mhLog LogError
-             (T.pack $ "postListSelectUp" <>
-              " unable to get channel or post ID: " <> show o)
-
--- | Move the selection down in the PostListOverlay, which corresponds
--- to finding a chronologically /old/ message.
-postListSelectDown :: MH ()
-postListSelectDown = do
-  selId <- use (csPostListOverlay.postListSelected)
-  posts <- use (csPostListOverlay.postListPosts)
-  let prevMsg = getPrevMessage (MessagePostId <$> selId) posts
-  case prevMsg of
-    Nothing -> return ()
-    Just m -> do
-      let pId = m^.mMessageId >>= messageIdPostId
-      csPostListOverlay.postListSelected .= pId
-      case (m^.mChannelId, pId) of
-        (Just c, Just p) -> asyncFetchMessagesSurrounding c p
-        o -> mhLog LogError
-             (T.pack $ "postListSelectDown" <>
-              " unable to get channel or post ID: " <> show o)
-
--- | Unflag the post currently selected in the PostListOverlay, if any
-postListUnflagSelected :: MH ()
-postListUnflagSelected = do
-  msgId <- use (csPostListOverlay.postListSelected)
-  case msgId of
-    Nothing  -> return ()
-    Just pId -> flagMessage pId False
-
-
--- | Jumps to the specified message in the message's main channel
--- display and changes to MessageSelectState.
-postListJumpToCurrent :: MH ()
-postListJumpToCurrent = do
-  msgId <- use (csPostListOverlay.postListSelected)
-  st <- use id
-  case msgId of
-    Nothing  -> return ()
-    Just pId ->
-      case getMessageForPostId st pId of
-        Just msg ->
-          case msg ^. mChannelId of
-            Just cId -> do
-              setFocus cId
-              setMode MessageSelect
-              csMessageSelect .= MessageSelectState (msg^.mMessageId)
-            Nothing ->
-              error "INTERNAL: selected Post ID not associated with a channel"
-        Nothing ->
-          error "INTERNAL: selected Post ID not added to global state!"
diff --git a/src/State/ReactionEmojiListOverlay.hs b/src/State/ReactionEmojiListOverlay.hs
deleted file mode 100644
--- a/src/State/ReactionEmojiListOverlay.hs
+++ /dev/null
@@ -1,125 +0,0 @@
-{-# LANGUAGE TupleSections #-}
-module State.ReactionEmojiListOverlay
-  ( enterReactionEmojiListOverlayMode
-
-  , reactionEmojiListSelectDown
-  , reactionEmojiListSelectUp
-  , reactionEmojiListPageDown
-  , reactionEmojiListPageUp
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Brick.Widgets.List as L
-import qualified Data.Vector as Vec
-import qualified Data.Text as T
-import qualified Data.Map as M
-import qualified Data.Set as Set
-import           Data.Function ( on )
-import           Data.List ( nubBy )
-import           Lens.Micro.Platform ( to )
-
-import           Network.Mattermost.Types
-import           Network.Mattermost.Endpoints ( mmPostReaction, mmDeleteReaction )
-
-import           Emoji
-import           State.ListOverlay
-import           State.MessageSelect
-import           State.Async
-import           Types
-
-
-enterReactionEmojiListOverlayMode :: MH ()
-enterReactionEmojiListOverlayMode = do
-    selectedMessage <- use (to getSelectedMessage)
-    case selectedMessage of
-        Nothing -> return ()
-        Just msg -> do
-            em <- use (csResources.crEmoji)
-            myId <- gets myUserId
-            enterListOverlayMode csReactionEmojiListOverlay ReactionEmojiListOverlay
-                () enterHandler (fetchResults myId msg em)
-
-enterHandler :: (Bool, T.Text) -> MH Bool
-enterHandler (mine, e) = do
-    session <- getSession
-    myId <- gets myUserId
-
-    selectedMessage <- use (to getSelectedMessage)
-    case selectedMessage of
-        Nothing -> return False
-        Just m -> do
-            case m^.mOriginalPost of
-                Nothing -> return False
-                Just p -> do
-                    case mine of
-                        False ->
-                            doAsyncWith Preempt $ do
-                                mmPostReaction (postId p) myId e session
-                                return Nothing
-                        True ->
-                            doAsyncWith Preempt $ do
-                                mmDeleteReaction (postId p) myId e session
-                                return Nothing
-                    return True
-
-fetchResults :: UserId
-             -- ^ My user ID, so we can see which reactions I haven't
-             -- posted
-             -> Message
-             -- ^ The selected message, so we can include its current
-             -- reactions in the list
-             -> EmojiCollection
-             -- ^ The emoji collection
-             -> ()
-             -- ^ The scope to search
-             -> Session
-             -- ^ The connection session
-             -> Text
-             -- ^ The search string
-             -> IO (Vec.Vector (Bool, T.Text))
-fetchResults myId msg em () session searchString = do
-    let currentReactions = [ (myId `Set.member` uIds, k)
-                           | (k, uIds) <- M.toList (msg^.mReactions)
-                           ]
-        matchingCurrentOtherReactions = [ (mine, r) | (mine, r) <- currentReactions
-                                        , matchesEmoji searchString r
-                                        , not mine
-                                        ]
-        matchingCurrentMyReactions = [ (mine, r) | (mine, r) <- currentReactions
-                                     , matchesEmoji searchString r
-                                     , mine
-                                     ]
-    serverMatches <- getMatchingEmoji session em searchString
-    return $ Vec.fromList $ nubBy ((==) `on` snd) $
-        matchingCurrentOtherReactions <> matchingCurrentMyReactions <> ((False,) <$> serverMatches)
-
--- | Move the selection up in the emoji list overlay by one emoji.
-reactionEmojiListSelectUp :: MH ()
-reactionEmojiListSelectUp = reactionEmojiListMove L.listMoveUp
-
--- | Move the selection down in the emoji list overlay by one emoji.
-reactionEmojiListSelectDown :: MH ()
-reactionEmojiListSelectDown = reactionEmojiListMove L.listMoveDown
-
--- | Move the selection up in the emoji list overlay by a page of emoji
--- (ReactionEmojiListPageSize).
-reactionEmojiListPageUp :: MH ()
-reactionEmojiListPageUp = reactionEmojiListMove (L.listMoveBy (-1 * reactionEmojiListPageSize))
-
--- | Move the selection down in the emoji list overlay by a page of emoji
--- (ReactionEmojiListPageSize).
-reactionEmojiListPageDown :: MH ()
-reactionEmojiListPageDown = reactionEmojiListMove (L.listMoveBy reactionEmojiListPageSize)
-
--- | Transform the emoji list results in some way, e.g. by moving the
--- cursor, and then check to see whether the modification warrants a
--- prefetch of more search results.
-reactionEmojiListMove :: (L.List Name (Bool, T.Text) -> L.List Name (Bool, T.Text)) -> MH ()
-reactionEmojiListMove = listOverlayMove csReactionEmojiListOverlay
-
--- | The number of emoji in a "page" for cursor movement purposes.
-reactionEmojiListPageSize :: Int
-reactionEmojiListPageSize = 10
diff --git a/src/State/Reactions.hs b/src/State/Reactions.hs
deleted file mode 100644
--- a/src/State/Reactions.hs
+++ /dev/null
@@ -1,52 +0,0 @@
-module State.Reactions
-  ( asyncFetchReactionsForPost
-  , addReactions
-  , removeReaction
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Main ( invalidateCacheEntry )
-import qualified Data.Map.Strict as Map
-import           Lens.Micro.Platform
-import qualified Data.Set as S
-
-import           Network.Mattermost.Endpoints
-import           Network.Mattermost.Lenses
-import           Network.Mattermost.Types
-
-import           State.Async
-import           State.Common ( fetchMentionedUsers )
-import           Types
-
-
-asyncFetchReactionsForPost :: ChannelId -> Post -> MH ()
-asyncFetchReactionsForPost cId p
-  | not (p^.postHasReactionsL) = return ()
-  | otherwise = doAsyncChannelMM Normal cId
-        (\s _ -> fmap toList (mmGetReactionsForPost (p^.postIdL) s))
-        (\_ rs -> Just $ addReactions cId rs)
-
-addReactions :: ChannelId -> [Reaction] -> MH ()
-addReactions cId rs = do
-    mh $ invalidateCacheEntry $ ChannelMessages cId
-    csChannel(cId).ccContents.cdMessages %= fmap upd
-    let mentions = S.fromList $ UserIdMention <$> reactionUserId <$> rs
-    fetchMentionedUsers mentions
-  where upd msg = msg & mReactions %~ insertAll (msg^.mMessageId)
-        insert mId r
-          | mId == Just (MessagePostId (r^.reactionPostIdL)) =
-              Map.insertWith S.union (r^.reactionEmojiNameL) (S.singleton $ r^.reactionUserIdL)
-          | otherwise = id
-        insertAll mId msg = foldr (insert mId) msg rs
-
-removeReaction :: Reaction -> ChannelId -> MH ()
-removeReaction r cId = do
-    mh $ invalidateCacheEntry $ ChannelMessages cId
-    csChannel(cId).ccContents.cdMessages %= fmap upd
-  where upd m | m^.mMessageId == Just (MessagePostId $ r^.reactionPostIdL) =
-                  m & mReactions %~ (Map.alter delReaction (r^.reactionEmojiNameL))
-              | otherwise = m
-        delReaction mUs = S.delete (r^.reactionUserIdL) <$> mUs
diff --git a/src/State/Setup.hs b/src/State/Setup.hs
deleted file mode 100644
--- a/src/State/Setup.hs
+++ /dev/null
@@ -1,281 +0,0 @@
-{-# LANGUAGE TypeFamilies #-}
-module State.Setup
-  ( setupState
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.BChan ( newBChan )
-import           Brick.Themes ( themeToAttrMap, loadCustomizations )
-import qualified Control.Concurrent.STM as STM
-import           Data.Maybe ( fromJust )
-import qualified Data.Sequence as Seq
-import qualified Data.Text as T
-import           Data.Time.Clock ( getCurrentTime )
-import qualified Graphics.Vty as Vty
-import           Lens.Micro.Platform ( (.~) )
-import           System.Exit ( exitFailure, exitSuccess )
-import           System.FilePath ( (</>), isRelative, dropFileName )
-
-import           Network.Mattermost.Endpoints
-import           Network.Mattermost.Types
-
-import           Config
-import           InputHistory
-import           LastRunState
-import           Login
-import           State.Flagging
-import           State.Messages ( fetchVisibleIfNeeded )
-import           State.Setup.Threads
-import           TeamSelect
-import           Themes
-import           TimeUtils ( lookupLocalTimeZone )
-import           Types
-import           Types.Common
-import           Emoji
-import           FilePaths ( userEmojiJsonPath, bundledEmojiJsonPath )
-import qualified Zipper as Z
-
-
-incompleteCredentials :: Config -> ConnectionInfo
-incompleteCredentials config = ConnectionInfo
-  { _ciHostname = fromMaybe "" (configHost config)
-  , _ciPort     = configPort config
-  , _ciUrlPath  = fromMaybe "" (configUrlPath config)
-  , _ciUsername = fromMaybe "" (configUser config)
-  , _ciPassword = case configPass config of
-                    Just (PasswordString s) -> s
-                    _                       -> ""
-  , _ciAccessToken = case configToken config of
-                       Just (TokenString s) -> s
-                       _                    -> ""
-  , _ciType     = configConnectionType config
-  }
-
-apiLogEventToLogMessage :: LogEvent -> IO LogMessage
-apiLogEventToLogMessage ev = do
-    now <- getCurrentTime
-    let msg = T.pack $ "Function: " <> logFunction ev <>
-                       ", event: " <> show (logEventType ev)
-    return $ LogMessage { logMessageCategory = LogAPI
-                        , logMessageText = msg
-                        , logMessageContext = Nothing
-                        , logMessageTimestamp = now
-                        }
-
-setupState :: IO Vty.Vty -> Maybe FilePath -> Config -> IO (ChatState, Vty.Vty)
-setupState mkVty mLogLocation config = do
-  initialVty <- mkVty
-
-  eventChan <- newBChan 2500
-  logMgr <- newLogManager eventChan (configLogMaxBufferSize config)
-
-  -- If we got an initial log location, start logging there.
-  case mLogLocation of
-      Nothing -> return ()
-      Just loc -> startLoggingToFile logMgr loc
-
-  let logApiEvent ev = apiLogEventToLogMessage ev >>= sendLogMessage logMgr
-      setLogger cd = cd `withLogger` logApiEvent
-
-  (mLastAttempt, loginVty) <- interactiveGetLoginSession initialVty mkVty
-                                                         setLogger
-                                                         logMgr
-                                                         (incompleteCredentials config)
-
-  let shutdown vty = do
-          Vty.shutdown vty
-          exitSuccess
-
-  (session, me, cd, mbTeam) <- case mLastAttempt of
-      Nothing ->
-          -- The user never attempted a connection and just chose to
-          -- quit.
-          shutdown loginVty
-      Just (AttemptFailed {}) ->
-          -- The user attempted a connection and failed, and then chose
-          -- to quit.
-          shutdown loginVty
-      Just (AttemptSucceeded _ cd sess user mbTeam) ->
-          -- The user attempted a connection and succeeded so continue
-          -- with setup.
-          return (sess, user, cd, mbTeam)
-
-  teams <- mmGetUsersTeams UserMe session
-  when (Seq.null teams) $ do
-      putStrLn "Error: your account is not a member of any teams"
-      exitFailure
-
-  (myTeam, teamSelVty) <- do
-      let foundTeam = do
-             tName <- mbTeam <|> configTeam config
-             let matchingTeam = listToMaybe $ filter matches $ toList teams
-                 matches t = (sanitizeUserText $ teamName t) == tName
-             matchingTeam
-
-      case foundTeam of
-          Just t -> return (t, loginVty)
-          Nothing -> do
-              (mTeam, vty) <- interactiveTeamSelection loginVty mkVty $ toList teams
-              case mTeam of
-                  Nothing -> shutdown vty
-                  Just team -> return (team, vty)
-
-  userStatusChan <- STM.newTChanIO
-  slc <- STM.newTChanIO
-  wac <- STM.newTChanIO
-
-  prefs <- mmGetUsersPreferences UserMe session
-  let userPrefs = setUserPreferences prefs defaultUserPreferences
-      themeName = case configTheme config of
-          Nothing -> internalThemeName defaultTheme
-          Just t -> t
-      baseTheme = internalTheme $ fromMaybe defaultTheme (lookupTheme themeName)
-
-  -- Did the configuration specify a theme customization file? If so,
-  -- load it and customize the theme.
-  custTheme <- case configThemeCustomizationFile config of
-      Nothing -> return baseTheme
-      Just path ->
-          -- If we have no configuration path (i.e. we used the default
-          -- config) then ignore theme customization.
-          let pathStr = T.unpack path
-          in if isRelative pathStr && isNothing (configAbsPath config)
-             then return baseTheme
-             else do
-                 let absPath = if isRelative pathStr
-                               then (dropFileName $ fromJust $ configAbsPath config) </> pathStr
-                               else pathStr
-                 result <- loadCustomizations absPath baseTheme
-                 case result of
-                     Left e -> do
-                         Vty.shutdown teamSelVty
-                         putStrLn $ "Error loading theme customization from " <> show absPath <> ": " <> e
-                         exitFailure
-                     Right t -> return t
-
-  requestChan <- STM.atomically STM.newTChan
-
-  emoji <- either (const emptyEmojiCollection) id <$> do
-      result1 <- loadEmoji =<< userEmojiJsonPath
-      case result1 of
-          Right e -> return $ Right e
-          Left _ -> loadEmoji =<< bundledEmojiJsonPath
-
-  let cr = ChatResources { _crSession             = session
-                         , _crWebsocketThreadId   = Nothing
-                         , _crConn                = cd
-                         , _crRequestQueue        = requestChan
-                         , _crEventQueue          = eventChan
-                         , _crSubprocessLog       = slc
-                         , _crWebsocketActionChan = wac
-                         , _crTheme               = themeToAttrMap custTheme
-                         , _crStatusUpdateChan    = userStatusChan
-                         , _crConfiguration       = config
-                         , _crFlaggedPosts        = mempty
-                         , _crUserPreferences     = userPrefs
-                         , _crSyntaxMap           = mempty
-                         , _crLogManager          = logMgr
-                         , _crEmoji               = emoji
-                         }
-
-  st <- initializeState cr myTeam me
-
-  return (st, teamSelVty)
-
-initializeState :: ChatResources -> Team -> User -> IO ChatState
-initializeState cr myTeam me = do
-  let session = getResourceSession cr
-      requestChan = cr^.crRequestQueue
-      myTId = getId myTeam
-
-  -- Create a predicate to find the last selected channel by reading the
-  -- run state file. If unable to read or decode or validate the file, this
-  -- predicate is just `isTownSquare`.
-  isLastSelectedChannel <- do
-    result <- readLastRunState $ teamId myTeam
-    case result of
-      Right lrs | isValidLastRunState cr me lrs -> return $ \c ->
-           channelId c == lrs^.lrsSelectedChannelId
-      _ -> return isTownSquare
-
-  -- Get all channels, but filter down to just the one we want to start
-  -- in. We get all, rather than requesting by name or ID, because
-  -- we don't know whether the server will give us a last-viewed preference.
-  -- We first try to find a channel matching with the last selected channel ID,
-  -- failing which we look for the Town Square channel by name.
-  userChans <- mmGetChannelsForUser UserMe myTId session
-  let lastSelectedChans = Seq.filter isLastSelectedChannel userChans
-      chans = if Seq.null lastSelectedChans
-                then Seq.filter isTownSquare userChans
-                else lastSelectedChans
-
-  -- Since the only channel we are dealing with is by construction the
-  -- last channel, we don't have to consider other cases here:
-  chanPairs <- forM (toList chans) $ \c -> do
-      cChannel <- makeClientChannel (userId me) c
-      return (getId c, cChannel)
-
-  tz <- lookupLocalTimeZone
-  hist <- do
-      result <- readHistory
-      case result of
-          Left _ -> return newHistory
-          Right h -> return h
-
-  --------------------------------------------------------------------
-  -- Start background worker threads:
-  --
-  -- * Syntax definition loader
-  startSyntaxMapLoaderThread (cr^.crConfiguration) (cr^.crEventQueue)
-
-  -- * Main async queue worker thread
-  startAsyncWorkerThread (cr^.crConfiguration) (cr^.crRequestQueue) (cr^.crEventQueue)
-
-  -- * User status thread
-  startUserStatusUpdateThread (cr^.crStatusUpdateChan) session requestChan
-
-  -- * Refresher for users who are typing currently
-  when (configShowTypingIndicator (cr^.crConfiguration)) $
-    startTypingUsersRefreshThread requestChan
-
-  -- * Timezone change monitor
-  startTimezoneMonitorThread tz requestChan
-
-  -- * Subprocess logger
-  startSubprocessLoggerThread (cr^.crSubprocessLog) requestChan
-
-  -- * Spell checker and spell check timer, if configured
-  spResult <- maybeStartSpellChecker (cr^.crConfiguration) (cr^.crEventQueue)
-
-  -- End thread startup ----------------------------------------------
-
-  now <- getCurrentTime
-  let chanIds = mkChannelZipperList now (cr^.crConfiguration) Nothing (cr^.crUserPreferences) clientChans noUsers
-      chanZip = Z.fromList chanIds
-      clientChans = foldr (uncurry addChannel) noChannels chanPairs
-      startupState =
-          StartupStateInfo { startupStateResources      = cr
-                           , startupStateChannelZipper  = chanZip
-                           , startupStateConnectedUser  = me
-                           , startupStateTeam           = myTeam
-                           , startupStateTimeZone       = tz
-                           , startupStateInitialHistory = hist
-                           , startupStateSpellChecker   = spResult
-                           }
-
-  initialState <- newState startupState
-  let st = initialState & csChannels .~ clientChans
-
-  loadFlaggedMessages (cr^.crUserPreferences.userPrefFlaggedPostList) st
-
-  -- Trigger an initial websocket refresh
-  writeBChan (cr^.crEventQueue) RefreshWebsocketEvent
-
-  -- Refresh initial channel(s)
-  writeBChan (cr^.crEventQueue) $ RespEvent $ do
-      fetchVisibleIfNeeded
-
-  return st
diff --git a/src/State/Setup/Threads.hs b/src/State/Setup/Threads.hs
deleted file mode 100644
--- a/src/State/Setup/Threads.hs
+++ /dev/null
@@ -1,372 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-module State.Setup.Threads
-  ( startUserStatusUpdateThread
-  , startTypingUsersRefreshThread
-  , startSubprocessLoggerThread
-  , startTimezoneMonitorThread
-  , maybeStartSpellChecker
-  , startAsyncWorkerThread
-  , startSyntaxMapLoaderThread
-  , module State.Setup.Threads.Logging
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.BChan ( BChan )
-import           Brick.Main ( invalidateCache )
-import           Control.Concurrent ( threadDelay, forkIO )
-import qualified Control.Concurrent.STM as STM
-import           Control.Concurrent.STM.Delay
-import           Control.Exception ( SomeException, try, fromException, catch )
-import           Data.List ( isInfixOf )
-import qualified Data.Map as M
-import qualified Data.Sequence as Seq
-import qualified Data.Text as T
-import           Data.Time ( getCurrentTime, addUTCTime )
-import           Lens.Micro.Platform ( (.=), (%=), (%~), mapped )
-import           Skylighting.Loader ( loadSyntaxesFromDir )
-import           System.Directory ( getTemporaryDirectory )
-import           System.Exit ( ExitCode(ExitSuccess) )
-import           System.IO ( hPutStrLn, hFlush )
-import           System.IO.Temp ( openTempFile )
-import           System.Timeout ( timeout )
-import           Text.Aspell ( Aspell, AspellOption(..), startAspell )
-
-import           Network.Mattermost.Exceptions ( RateLimitException
-                                               , rateLimitExceptionReset )
-import           Network.Mattermost.Endpoints
-import           Network.Mattermost.Types
-
-import           Constants
-import           State.Editing ( requestSpellCheck )
-import           State.Setup.Threads.Logging
-import           TimeUtils ( lookupLocalTimeZone )
-import           Types
-
-
-updateUserStatuses :: [UserId] -> Session -> IO (Maybe (MH ()))
-updateUserStatuses uIds session =
-    case null uIds of
-        False -> do
-            statuses <- mmGetUserStatusByIds (Seq.fromList uIds) session
-            return $ Just $ do
-                forM_ statuses $ \s ->
-                    setUserStatus (statusUserId s) (statusStatus s)
-        True -> return Nothing
-
-startUserStatusUpdateThread :: STM.TChan [UserId] -> Session -> RequestChan -> IO ()
-startUserStatusUpdateThread zipperChan session requestChan = void $ forkIO body
-  where
-      seconds = (* (1000 * 1000))
-      userRefreshInterval = 30
-      body = refresh []
-      refresh prev = do
-          result <- timeout (seconds userRefreshInterval) (STM.atomically $ STM.readTChan zipperChan)
-          let (uIds, update) = case result of
-                  Nothing -> (prev, True)
-                  Just ids -> (ids, ids /= prev)
-
-          when update $ do
-              STM.atomically $ STM.writeTChan requestChan $ do
-                  rs <- try $ updateUserStatuses uIds session
-                  case rs of
-                      Left (_ :: SomeException) -> return Nothing
-                      Right upd -> return upd
-
-          refresh uIds
-
--- This thread refreshes the map of typing users every second, forever,
--- to expire users who have stopped typing. Expiry time is 3 seconds.
-startTypingUsersRefreshThread :: RequestChan -> IO ()
-startTypingUsersRefreshThread requestChan = void $ forkIO $ forever refresh
-  where
-    seconds = (* (1000 * 1000))
-    refreshIntervalMicros = ceiling $ seconds $ userTypingExpiryInterval / 2
-    refresh = do
-      STM.atomically $ STM.writeTChan requestChan $ return $ Just $ do
-        now <- liftIO getCurrentTime
-        let expiry = addUTCTime (- userTypingExpiryInterval) now
-        let expireUsers c = c & ccInfo.cdTypingUsers %~ expireTypingUsers expiry
-        csChannels . mapped %= expireUsers
-
-      threadDelay refreshIntervalMicros
-
-startSubprocessLoggerThread :: STM.TChan ProgramOutput -> RequestChan -> IO ()
-startSubprocessLoggerThread logChan requestChan = do
-    let logMonitor mPair = do
-          ProgramOutput progName args out stdoutOkay err ec <-
-              STM.atomically $ STM.readTChan logChan
-
-          -- If either stdout or stderr is non-empty or there was an exit
-          -- failure, log it and notify the user.
-          let emptyOutput s = null s || s == "\n"
-
-          case ec == ExitSuccess && (emptyOutput out || stdoutOkay) && emptyOutput err of
-              -- the "good" case, no output and exit sucess
-              True -> logMonitor mPair
-              False -> do
-                  (logPath, logHandle) <- case mPair of
-                      Just p ->
-                          return p
-                      Nothing -> do
-                          tmp <- getTemporaryDirectory
-                          openTempFile tmp "matterhorn-subprocess.log"
-
-                  let unexpectedStdout = ec == ExitSuccess && (not $ emptyOutput out) && not stdoutOkay
-
-                  hPutStrLn logHandle $
-                      unlines [ "Program: " <> progName
-                              , "Arguments: " <> show args
-                              , "Exit code: " <> show ec
-                              , if unexpectedStdout
-                                then "Stdout (unexpected due to successful exit):"
-                                else "Stdout:"
-                              , out
-                              , "Stderr:"
-                              , err
-                              ]
-                  hFlush logHandle
-
-                  STM.atomically $ STM.writeTChan requestChan $ do
-                      return $ Just $ mhError $ ProgramExecutionFailed (T.pack progName) (T.pack logPath)
-
-                  logMonitor (Just (logPath, logHandle))
-
-    void $ forkIO $ logMonitor Nothing
-
-startTimezoneMonitorThread :: TimeZoneSeries -> RequestChan -> IO ()
-startTimezoneMonitorThread tz requestChan = do
-  -- Start the timezone monitor thread
-  let timezoneMonitorSleepInterval = minutes 5
-      minutes = (* (seconds 60))
-      seconds = (* (1000 * 1000))
-      timezoneMonitor prevTz = do
-        threadDelay timezoneMonitorSleepInterval
-
-        newTz <- lookupLocalTimeZone
-        when (newTz /= prevTz) $
-            STM.atomically $ STM.writeTChan requestChan $ do
-                return $ Just $ do
-                    timeZone .= newTz
-                    mh invalidateCache
-
-        timezoneMonitor newTz
-
-  void $ forkIO (timezoneMonitor tz)
-
-maybeStartSpellChecker :: Config -> BChan MHEvent -> IO (Maybe (Aspell, IO ()))
-maybeStartSpellChecker config eventQueue = do
-  case configEnableAspell config of
-      False -> return Nothing
-      True -> do
-          let aspellOpts = catMaybes [ UseDictionary <$> (configAspellDictionary config)
-                                     ]
-              spellCheckerTimeout = 500 * 1000 -- 500k us = 500ms
-          asResult <- either (const Nothing) Just <$> startAspell aspellOpts
-          case asResult of
-              Nothing -> return Nothing
-              Just as -> do
-                  resetSCChan <- startSpellCheckerThread eventQueue spellCheckerTimeout
-                  let resetSCTimer = STM.atomically $ STM.writeTChan resetSCChan ()
-                  return $ Just (as, resetSCTimer)
-
--- Start the background spell checker delay thread.
---
--- The purpose of this thread is to postpone the spell checker query
--- while the user is actively typing and only wait until they have
--- stopped typing before bothering with a query. This is to avoid spell
--- checker queries when the editor contents are changing rapidly.
--- Avoiding such queries reduces system load and redraw frequency.
---
--- We do this by starting a thread whose job is to wait for the event
--- loop to tell it to schedule a spell check. Spell checks are scheduled
--- by writing to the channel returned by this function. The scheduler
--- thread reads from that channel and then works with another worker
--- thread as follows:
---
--- A wakeup of the main spell checker thread causes it to determine
--- whether the worker thread is already waiting on a timer. When that
--- timer goes off, a spell check will be requested. If there is already
--- an active timer that has not yet expired, the timer's expiration is
--- extended. This is the case where typing is occurring and we want to
--- continue postponing the spell check. If there is not an active timer
--- or the active timer has expired, we create a new timer and send it to
--- the worker thread for waiting.
---
--- The worker thread works by reading a timer from its queue, waiting
--- until the timer expires, and then injecting an event into the main
--- event loop to request a spell check.
-startSpellCheckerThread :: BChan MHEvent
-                        -- ^ The main event loop's event channel.
-                        -> Int
-                        -- ^ The number of microseconds to wait before
-                        -- requesting a spell check.
-                        -> IO (STM.TChan ())
-startSpellCheckerThread eventChan spellCheckTimeout = do
-  delayWakeupChan <- STM.atomically STM.newTChan
-  delayWorkerChan <- STM.atomically STM.newTChan
-  delVar <- STM.atomically $ STM.newTVar Nothing
-
-  -- The delay worker actually waits on the delay to expire and then
-  -- requests a spell check.
-  void $ forkIO $ forever $ do
-    STM.atomically $ waitDelay =<< STM.readTChan delayWorkerChan
-    writeBChan eventChan (RespEvent requestSpellCheck)
-
-  -- The delay manager waits for requests to start a delay timer and
-  -- signals the worker to begin waiting.
-  void $ forkIO $ forever $ do
-    () <- STM.atomically $ STM.readTChan delayWakeupChan
-
-    oldDel <- STM.atomically $ STM.readTVar delVar
-    mNewDel <- case oldDel of
-        Nothing -> Just <$> newDelay spellCheckTimeout
-        Just del -> do
-            -- It's possible that between this check for expiration and
-            -- the updateDelay below, the timer will expire -- at which
-            -- point this will mean that we won't extend the timer as
-            -- originally desired. But that's alright, because future
-            -- keystrokes will trigger another timer anyway.
-            expired <- tryWaitDelayIO del
-            case expired of
-                True -> Just <$> newDelay spellCheckTimeout
-                False -> do
-                    updateDelay del spellCheckTimeout
-                    return Nothing
-
-    case mNewDel of
-        Nothing -> return ()
-        Just newDel -> STM.atomically $ do
-            STM.writeTVar delVar $ Just newDel
-            STM.writeTChan delayWorkerChan newDel
-
-  return delayWakeupChan
-
-startSyntaxMapLoaderThread :: Config -> BChan MHEvent -> IO ()
-startSyntaxMapLoaderThread config eventChan = void $ forkIO $ do
-    -- Iterate over the configured syntax directories, loading syntax
-    -- maps. Ensure that entries loaded in earlier directories in the
-    -- sequence take precedence over entries loaded later.
-    mMaps <- forM (configSyntaxDirs config) $ \dir -> do
-        result <- try $ loadSyntaxesFromDir dir
-        case result of
-            Left (_::SomeException) -> return Nothing
-            Right (Left _)          -> return Nothing
-            Right (Right m)         -> return $ Just m
-
-    let maps = catMaybes mMaps
-        finalMap = foldl M.union mempty maps
-
-    writeBChan eventChan $ RespEvent $ do
-        csResources.crSyntaxMap .= finalMap
-        mh invalidateCache
-
--------------------------------------------------------------------
--- Async worker thread
-
-startAsyncWorkerThread :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()
-startAsyncWorkerThread c r e = void $ forkIO $ asyncWorker c r e
-
-asyncWorker :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()
-asyncWorker c r e = forever $ doAsyncWork c r e
-
-doAsyncWork :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()
-doAsyncWork config requestChan eventChan = do
-    let rateLimitNotify sec = do
-            writeBChan eventChan $ RateLimitExceeded sec
-
-    startWork <- case configShowBackground config of
-        Disabled -> return $ return ()
-        Active -> do chk <- STM.atomically $ STM.tryPeekTChan requestChan
-                     case chk of
-                       Nothing -> do writeBChan eventChan BGIdle
-                                     return $ writeBChan eventChan $ BGBusy Nothing
-                       _ -> return $ return ()
-        ActiveCount -> do
-          chk <- STM.atomically $ do
-            chanCopy <- STM.cloneTChan requestChan
-            let cntMsgs = do m <- STM.tryReadTChan chanCopy
-                             case m of
-                               Nothing -> return 0
-                               Just _ -> (1 +) <$> cntMsgs
-            cntMsgs
-          case chk of
-            0 -> do writeBChan eventChan BGIdle
-                    return (writeBChan eventChan $ BGBusy (Just 1))
-            _ -> do writeBChan eventChan $ BGBusy (Just chk)
-                    return $ return ()
-
-    req <- STM.atomically $ STM.readTChan requestChan
-    startWork
-    -- Run the IO action with up to one additional attempt if it makes
-    -- rate-limited API requests.
-    res <- try $ rateLimitRetry rateLimitNotify req
-    case res of
-      Left e -> do
-          when (not $ shouldIgnore e) $ do
-              case fromException e of
-                  Just (_::RateLimitException) ->
-                      writeBChan eventChan RequestDropped
-                  Nothing -> do
-                      let err = case fromException e of
-                            Nothing -> AsyncErrEvent e
-                            Just mmErr -> ServerError mmErr
-                      writeBChan eventChan $ IEvent $ DisplayError err
-      Right upd ->
-          case upd of
-              -- The IO action triggered a rate limit error but could
-              -- not be retried due to rate limiting information being
-              -- missing.
-              Nothing -> writeBChan eventChan RateLimitSettingsMissing
-
-              -- The IO action was run successfully but returned no
-              -- state transformation.
-              Just Nothing -> return ()
-
-              -- The IO action was run successfully and returned a state
-              -- transformation.
-              Just (Just action) -> writeBChan eventChan (RespEvent action)
-
--- | Run an IO action. If the action raises a RateLimitException, invoke
--- the provided rate limit exception handler with the rate limit window
--- size (time in seconds until rate limit resets). Then block until the
--- rate limit resets and attempt to run the action one more time.
---
--- If the rate limit exception does not contain a rate limit reset
--- interval, return Nothing. Otherwise return IO action's result.
-rateLimitRetry :: (Int -> IO ()) -> IO a -> IO (Maybe a)
-rateLimitRetry rateLimitNotify act = do
-    let retry e = do
-            case rateLimitExceptionReset e of
-                -- The rate limit exception contains no metadata so we
-                -- cannot retry the action.
-                Nothing -> return Nothing
-
-                -- The rate limit exception contains the size of the
-                -- rate limit reset interval, so block until that has
-                -- passed and retry the action (only) one more time.
-                Just sec -> do
-                    let adjusted = sec + 1
-                    rateLimitNotify adjusted
-                    threadDelay $ adjusted * 1000000
-                    Just <$> act
-
-    (Just <$> act) `catch` retry
-
--- Filter for exceptions that we don't want to report to the user,
--- probably because they are not actionable and/or contain no useful
--- information.
---
--- E.g.
--- https://github.com/matterhorn-chat/matterhorn/issues/391
-shouldIgnore :: SomeException -> Bool
-shouldIgnore e =
-    let eStr = show e
-    in or [ "getAddrInfo" `isInfixOf` eStr
-          , "Network.Socket.recvBuf" `isInfixOf` eStr
-          , "Network.Socket.sendBuf" `isInfixOf` eStr
-          , "resource vanished" `isInfixOf` eStr
-          , "timeout" `isInfixOf` eStr
-          ]
diff --git a/src/State/Setup/Threads/Logging.hs b/src/State/Setup/Threads/Logging.hs
deleted file mode 100644
--- a/src/State/Setup/Threads/Logging.hs
+++ /dev/null
@@ -1,317 +0,0 @@
-{-# LANGUAGE RecordWildCards #-}
--- | This module implements the logging thread. This thread can
--- optionally write logged messages to an output file. When the thread
--- is created, it is initially not writing to any output file and
--- must be told to do so by issuing a LogCommand using the LogManager
--- interface.
---
--- The logging thread has an internal bounded log message buffer. Logged
--- messages always get written to the buffer (potentially evicting old
--- messages to maintain the size bound). If the thread is also writing
--- to a file, such messages also get written to the file. When the
--- thread begins logging to a file, the entire buffer is written to the
--- file so that a historical snapshot of log activity can be saved in
--- cases where logging is turned on at runtime only once a problematic
--- behavior is observed.
-module State.Setup.Threads.Logging
-  ( newLogManager
-  , shutdownLogManager
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.BChan ( BChan )
-import           Control.Concurrent.Async ( Async, async, wait )
-import qualified Control.Concurrent.STM as STM
-import           Control.Exception ( SomeException, try )
-import           Control.Monad.State.Strict
-import qualified Data.Sequence as Seq
-import qualified Data.Text as T
-import           Data.Time ( getCurrentTime )
-import           System.IO ( Handle, IOMode(AppendMode), hPutStr, hPutStrLn
-                           , hFlush, openFile, hClose )
-
-import           Types
-
-
--- | Used to remember the last logging output point for various log output targets.
-newtype LogMemory = LogMemory { logMem :: [(FilePath, LogMessage)] }
-
-blankLogMemory :: LogMemory
-blankLogMemory = LogMemory []
-
--- | Adds or updates the last log message output to this destination.
--- This is used for the case when logging is re-enabled to that
--- destination to avoid repeating logging output.
---
--- The number of log memories is limited by forgetting the oldest ones
--- if over a threshold.  This handles a pathological case where the
--- user has redirected output to a very large number of logfiles.
--- This is very unlikely to happen (a script or bad paste buffer
--- maybe?) but this provides defensive behavior to prevent
--- uncontrolled memory consumption in this case.  The limit here is
--- arbitrary, but it should suffice and once it's reached the
--- degradation case is simply the potential for duplicated messages
--- when returning to logfiles that haven't been recently logged to.
-rememberOutputPoint :: FilePath -> LogMessage -> LogMemory -> LogMemory
-rememberOutputPoint logPath logMsg oldLogMem =
-  LogMemory $
-  take 50 $ -- upper limit on number of logpath+message memories retained
-  (logPath, logMsg) : filter ((/=) logPath . fst) (logMem oldLogMem)
-
--- | Returns the last LogMessage logged to the specified output log
--- file path if it was previously logged to.
-memoryOfOutputPath :: FilePath -> LogMemory -> Maybe LogMessage
-memoryOfOutputPath p = lookup p . logMem
-
--- | The state of the logging thread.
-data LogThreadState =
-    LogThreadState { logThreadDestination :: Maybe (FilePath, Handle)
-                   -- ^ The logging thread's active logging destination.
-                   -- Nothing means log messages are not being written
-                   -- anywhere except the internal buffer.
-                   , logThreadEventChan :: BChan MHEvent
-                   -- ^ The application event channel that we'll use to
-                   -- notify of logging events.
-                   , logThreadCommandChan :: STM.TChan LogCommand
-                   -- ^ The channel on which the logging thread will
-                   -- wait for logging commands.
-                   , logThreadMessageBuffer :: Seq.Seq LogMessage
-                   -- ^ The internal bounded log message buffer.
-                   , logThreadMaxBufferSize :: Int
-                   -- ^ The size bound of the logThreadMessageBuffer.
-                   , logPreviousStopPoint :: LogMemory
-                   -- ^ Previous logging stop points to avoid
-                   -- duplication if logging is re-enabled to the same
-                   -- output
-                   }
-
--- | Create a new log manager and start a logging thread for it.
-newLogManager :: BChan MHEvent -> Int -> IO LogManager
-newLogManager eventChan maxBufferSize = do
-    chan <- STM.newTChanIO
-    self <- startLoggingThread eventChan chan maxBufferSize
-    let mgr = LogManager { logManagerCommandChannel = chan
-                         , logManagerHandle = self
-                         }
-    return mgr
-
--- | Shuts down the log manager and blocks until shutdown is complete.
-shutdownLogManager :: LogManager -> IO ()
-shutdownLogManager mgr = do
-    STM.atomically $ STM.writeTChan (logManagerCommandChannel mgr) ShutdownLogging
-    wait $ logManagerHandle mgr
-
--- | The logging thread.
-startLoggingThread :: BChan MHEvent -> STM.TChan LogCommand -> Int -> IO (Async ())
-startLoggingThread eventChan logChan maxBufferSize = do
-    let initialState = LogThreadState { logThreadDestination = Nothing
-                                      , logThreadEventChan = eventChan
-                                      , logThreadCommandChan = logChan
-                                      , logThreadMessageBuffer = mempty
-                                      , logThreadMaxBufferSize = maxBufferSize
-                                      , logPreviousStopPoint = blankLogMemory
-                                      }
-    async $ void $ runStateT logThreadBody initialState
-
-logThreadBody :: StateT LogThreadState IO ()
-logThreadBody = do
-    cmd <- nextLogCommand
-    continue <- handleLogCommand cmd
-    when continue logThreadBody
-
--- | Get the next pending log thread command.
-nextLogCommand :: StateT LogThreadState IO LogCommand
-nextLogCommand = do
-    chan <- gets logThreadCommandChan
-    liftIO $ STM.atomically $ STM.readTChan chan
-
-putMarkerMessage :: String -> Handle -> IO ()
-putMarkerMessage msg h = do
-    now <- getCurrentTime
-    hPutStrLn h $ "[" <> show now <> "] " <> msg
-
--- | Emit a log stop marker to the file.
-putLogEndMarker :: Handle -> IO ()
-putLogEndMarker = putMarkerMessage "<<< Logging end >>>"
-
--- | Emit a log start marker to the file.
-putLogStartMarker :: Handle -> IO ()
-putLogStartMarker = putMarkerMessage "<<< Logging start >>>"
-
--- | Emit a log stop marker to the file and close it, then notify the
--- application that we have stopped logging.
-finishLog :: BChan MHEvent -> FilePath -> Handle -> StateT LogThreadState IO ()
-finishLog eventChan oldPath oldHandle = do
-    liftIO $ do
-        putLogEndMarker oldHandle
-        hClose oldHandle
-        writeBChan eventChan $ IEvent $ LoggingStopped oldPath
-    modify $ \s ->
-      let buf = logThreadMessageBuffer s
-          lastLm = Seq.index buf (Seq.length buf - 1) -- n.b. putLogEndMarker ensures buf not empty
-          stops = rememberOutputPoint oldPath lastLm $ logPreviousStopPoint s
-      in s { logThreadDestination = Nothing
-           , logPreviousStopPoint = stops
-           }
-
-stopLogOutput :: StateT LogThreadState IO ()
-stopLogOutput = do
-    oldDest <- gets logThreadDestination
-    case oldDest of
-        Nothing -> return ()
-        Just (oldPath, oldHandle) -> do
-            eventChan <- gets logThreadEventChan
-            finishLog eventChan oldPath oldHandle
-
--- | Handle a single logging command.
-handleLogCommand :: LogCommand -> StateT LogThreadState IO Bool
-handleLogCommand (LogSnapshot path) = do
-    -- LogSnapshot: write the current log message buffer to the
-    -- specified path. Ignore the request if it is for the path that we
-    -- are already logging to.
-    eventChan <- gets logThreadEventChan
-    dest <- gets logThreadDestination
-
-    let shouldWrite = case dest of
-          Nothing -> True
-          Just (curPath, _) -> curPath /= path
-
-    when shouldWrite $ do
-        result <- liftIO $ try $ openFile path AppendMode
-        case result of
-            Left (e::SomeException) -> do
-                liftIO $ writeBChan eventChan $ IEvent $ LogSnapshotFailed path (show e)
-            Right handle -> do
-                flushLogMessageBuffer path handle
-                liftIO $ hClose handle
-                liftIO $ writeBChan eventChan $ IEvent $ LogSnapshotSucceeded path
-
-    return True
-handleLogCommand GetLogDestination = do
-    -- GetLogDestination: the application asked us to provide the
-    -- current log destination.
-    dest <- gets logThreadDestination
-    eventChan <- gets logThreadEventChan
-    liftIO $ writeBChan eventChan $ IEvent $ LogDestination $ fst <$> dest
-    return True
-handleLogCommand ShutdownLogging = do
-    -- ShutdownLogging: if we were logging to a file, close it. Then
-    -- unlock the shutdown lock.
-    stopLogOutput
-    return False
-handleLogCommand StopLogging = do
-    -- StopLogging: if we were logging to a file, close it and notify
-    -- the application. Otherwise do nothing.
-    stopLogOutput
-    return True
-handleLogCommand (LogToFile newPath) = do
-    -- LogToFile: if we were logging to a file, close that file, notify
-    -- the application, then attempt to open the new file. If that
-    -- failed, notify the application of the error. If it succeeded,
-    -- start logging and notify the application.
-    eventChan <- gets logThreadEventChan
-    oldDest <- gets logThreadDestination
-
-    shouldChange <- case oldDest of
-        Nothing ->
-            return True
-        Just (oldPath, _) ->
-            return (oldPath /= newPath)
-
-    when shouldChange $ do
-        result <- liftIO $ try $ openFile newPath AppendMode
-        case result of
-            Left (e::SomeException) -> liftIO $ do
-                let msg = "Error in log thread: could not open " <> show newPath <>
-                          ": " <> show e
-                writeBChan eventChan $ IEvent $ LogStartFailed newPath msg
-            Right handle -> do
-                stopLogOutput
-
-                modify $ \s -> s { logThreadDestination = Just (newPath, handle) }
-                flushLogMessageBuffer newPath handle
-                liftIO $ putLogStartMarker handle
-                liftIO $ writeBChan eventChan $ IEvent $ LoggingStarted newPath
-
-    return True
-handleLogCommand (LogAMessage lm) = do
-    -- LogAMessage: log a single message. Write the message to the
-    -- bounded internal buffer (which may cause an older message to be
-    -- evicted). Then, if we are actively logging to a file, write the
-    -- message to that file and flush the output stream.
-    maxBufSize <- gets logThreadMaxBufferSize
-
-    let addMessageToBuffer s =
-            -- Ensure that newSeq is always at most maxBufSize elements
-            -- long.
-            let newSeq = s Seq.|> lm
-                toDrop = Seq.length s - maxBufSize
-            in Seq.drop toDrop newSeq
-
-    -- Append the message to the internal buffer, maintaining the bound
-    -- on the internal buffer size.
-    modify $ \s -> s { logThreadMessageBuffer = addMessageToBuffer (logThreadMessageBuffer s) }
-
-    -- If we have an active log destination, write the message to the
-    -- output file.
-    dest <- gets logThreadDestination
-    case dest of
-        Nothing -> return ()
-        Just (_, handle) -> liftIO $ do
-            hPutLogMessage handle lm
-            hFlush handle
-
-    return True
-
--- | Write a single log message to the output handle.
-hPutLogMessage :: Handle -> LogMessage -> IO ()
-hPutLogMessage handle (LogMessage {..}) = do
-    hPutStr handle $ "[" <> show logMessageTimestamp <> "] "
-    hPutStr handle $ "[" <> show logMessageCategory <> "] "
-    case logMessageContext of
-        Nothing -> hPutStr handle "[*] "
-        Just c  -> hPutStr handle $ "[" <> show c <> "] "
-    hPutStrLn handle $ T.unpack logMessageText
-
--- | Flush the contents of the internal log message buffer.
-flushLogMessageBuffer :: FilePath -> Handle -> StateT LogThreadState IO ()
-flushLogMessageBuffer pathOfHandle handle = do
-    buf <- gets logThreadMessageBuffer
-    when (not $ Seq.null buf) $ do
-      lastPoint <- memoryOfOutputPath pathOfHandle <$> gets logPreviousStopPoint
-      case lastPoint of
-        Nothing ->
-          -- never logged to this output point before, so dump the
-          -- current internal buffer of log messages to the beginning of
-          -- the output file.
-          dumpBuf buf
-        Just lm ->
-          -- There was previous logging to this file.  If the log buffer
-          -- contains the entirety of the log messages during the
-          -- logging disable, then just dump that portion; otherwise
-          -- dump the entire buffer and indicate there may be missing
-          -- entries before the buffer.
-          let unseen = Seq.takeWhileR (not . (==) lm) buf
-              firstM = Seq.index buf 0 -- above ensures that buf is not empty here
-          in do when (Seq.length buf == Seq.length unseen) $ liftIO $
-                  hPutStrLn handle $ mkMsg (logMessageTimestamp firstM)
-                                     "<<< Potentially missing log messages here... >>>"
-                dumpBuf unseen
-      where
-        mkMsg t m = "[" <> show t <> "] " <> m
-        dumpBuf buf = liftIO $ do
-          let firstLm = Seq.index buf 0
-              lastLm = Seq.index buf (Seq.length buf - 1)
-
-          hPutStrLn handle $ mkMsg (logMessageTimestamp firstLm)
-                             "<<< Log message buffer begin >>>"
-
-          forM_ buf (hPutLogMessage handle)
-
-          hPutStrLn handle $ mkMsg (logMessageTimestamp lastLm)
-                                   "<<< Log message buffer end >>>"
-
-          hFlush handle
diff --git a/src/State/ThemeListOverlay.hs b/src/State/ThemeListOverlay.hs
deleted file mode 100644
--- a/src/State/ThemeListOverlay.hs
+++ /dev/null
@@ -1,86 +0,0 @@
-module State.ThemeListOverlay
-  ( enterThemeListMode
-
-  , themeListSelectDown
-  , themeListSelectUp
-  , themeListPageDown
-  , themeListPageUp
-
-  , setTheme
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick ( invalidateCache )
-import           Brick.Themes ( themeToAttrMap )
-import qualified Brick.Widgets.List as L
-import qualified Data.Text as T
-import qualified Data.Vector as Vec
-import           Lens.Micro.Platform ( (.=) )
-
-import           Network.Mattermost.Types
-
-import           State.ListOverlay
-import           Themes
-import           Types
-
-
--- | Show the user list overlay with the given search scope, and issue a
--- request to gather the first search results.
-enterThemeListMode :: MH ()
-enterThemeListMode =
-    enterListOverlayMode csThemeListOverlay ThemeListOverlay () setInternalTheme getThemesMatching
-
--- | Move the selection up in the user list overlay by one user.
-themeListSelectUp :: MH ()
-themeListSelectUp = themeListMove L.listMoveUp
-
--- | Move the selection down in the user list overlay by one user.
-themeListSelectDown :: MH ()
-themeListSelectDown = themeListMove L.listMoveDown
-
--- | Move the selection up in the user list overlay by a page of users
--- (themeListPageSize).
-themeListPageUp :: MH ()
-themeListPageUp = themeListMove (L.listMoveBy (-1 * themeListPageSize))
-
--- | Move the selection down in the user list overlay by a page of users
--- (themeListPageSize).
-themeListPageDown :: MH ()
-themeListPageDown = themeListMove (L.listMoveBy themeListPageSize)
-
--- | Transform the user list results in some way, e.g. by moving the
--- cursor, and then check to see whether the modification warrants a
--- prefetch of more search results.
-themeListMove :: (L.List Name InternalTheme -> L.List Name InternalTheme) -> MH ()
-themeListMove = listOverlayMove csThemeListOverlay
-
--- | The number of users in a "page" for cursor movement purposes.
-themeListPageSize :: Int
-themeListPageSize = 10
-
-getThemesMatching :: ()
-                  -> Session
-                  -> Text
-                  -> IO (Vec.Vector InternalTheme)
-getThemesMatching _ _ searchString = do
-    let matching = filter matches internalThemes
-        search = T.toLower searchString
-        matches t = search `T.isInfixOf` T.toLower (internalThemeName t) ||
-                    search `T.isInfixOf` T.toLower (internalThemeDesc t)
-    return $ Vec.fromList matching
-
-setInternalTheme :: InternalTheme -> MH Bool
-setInternalTheme t = do
-    setTheme $ internalThemeName t
-    return False
-
-setTheme :: Text -> MH ()
-setTheme name =
-    case lookupTheme name of
-        Nothing -> enterThemeListMode
-        Just it -> do
-            mh invalidateCache
-            csResources.crTheme .= (themeToAttrMap $ internalTheme it)
diff --git a/src/State/UrlSelect.hs b/src/State/UrlSelect.hs
deleted file mode 100644
--- a/src/State/UrlSelect.hs
+++ /dev/null
@@ -1,48 +0,0 @@
-module State.UrlSelect
-  (
-  -- * URL selection mode
-    startUrlSelect
-  , stopUrlSelect
-  , openSelectedURL
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick.Widgets.List ( list, listMoveTo, listSelectedElement )
-import qualified Data.Vector as V
-import           Lens.Micro.Platform ( (.=), to )
-
-import           State.Common
-import           Types
-import           Util
-
-
-startUrlSelect :: MH ()
-startUrlSelect = do
-    urls <- use (csCurrentChannel.to findUrls.to V.fromList)
-    setMode UrlSelect
-    csUrlList .= (listMoveTo (length urls - 1) $ list UrlList urls 2)
-
-stopUrlSelect :: MH ()
-stopUrlSelect = setMode Main
-
-openSelectedURL :: MH ()
-openSelectedURL = whenMode UrlSelect $ do
-    selected <- use (csUrlList.to listSelectedElement)
-    case selected of
-        Nothing -> return ()
-        Just (_, link) -> do
-            opened <- openURL (OpenLinkChoice link)
-            when (not opened) $ do
-                mhError $ ConfigOptionMissing "urlOpenCommand"
-                setMode Main
-
-findUrls :: ClientChannel -> [LinkChoice]
-findUrls chan =
-    let msgs = chan^.ccContents.cdMessages
-    in removeDuplicates $ concat $ toList $ toList <$> msgURLs <$> msgs
-
-removeDuplicates :: [LinkChoice] -> [LinkChoice]
-removeDuplicates = nubOn (\ l -> (l^.linkURL, l^.linkUser))
diff --git a/src/State/UserListOverlay.hs b/src/State/UserListOverlay.hs
deleted file mode 100644
--- a/src/State/UserListOverlay.hs
+++ /dev/null
@@ -1,174 +0,0 @@
-module State.UserListOverlay
-  ( enterChannelMembersUserList
-  , enterChannelInviteUserList
-  , enterDMSearchUserList
-
-  , userListSelectDown
-  , userListSelectUp
-  , userListPageDown
-  , userListPageUp
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Brick.Widgets.List as L
-import qualified Data.HashMap.Strict as HM
-import qualified Data.Sequence as Seq
-import qualified Data.Text as T
-import qualified Data.Vector as Vec
-import           Lens.Micro.Platform ( (.~), (.=) )
-
-import qualified Network.Mattermost.Endpoints as MM
-import qualified Network.Mattermost.Types.Config as MM
-import           Network.Mattermost.Types
-
-import           State.Async ( doAsyncWith, AsyncPriority(Preempt) )
-import           State.Channels ( createOrFocusDMChannel, addUserToCurrentChannel )
-import           State.ListOverlay
-import           Types
-
-
--- | Show the user list overlay for searching/showing members of the
--- current channel.
-enterChannelMembersUserList :: MH ()
-enterChannelMembersUserList = do
-    cId <- use csCurrentChannelId
-    myId <- gets myUserId
-    myTId <- gets myTeamId
-    session <- getSession
-
-    doAsyncWith Preempt $ do
-        stats <- MM.mmGetChannelStatistics cId session
-        return $ Just $ do
-            enterUserListMode (ChannelMembers cId myTId) (Just $ channelStatsMemberCount stats)
-              (\u -> case u^.uiId /= myId of
-                True -> createOrFocusDMChannel u Nothing >> return True
-                False -> return False
-              )
-
--- | Show the user list overlay for showing users that are not members
--- of the current channel for the purpose of adding them to the
--- channel.
-enterChannelInviteUserList :: MH ()
-enterChannelInviteUserList = do
-    cId <- use csCurrentChannelId
-    myId <- gets myUserId
-    myTId <- gets myTeamId
-    enterUserListMode (ChannelNonMembers cId myTId) Nothing
-      (\u -> case u^.uiId /= myId of
-        True -> addUserToCurrentChannel u >> return True
-        False -> return False
-      )
-
--- | Show the user list overlay for showing all users for the purpose of
--- starting a direct message channel with another user.
-enterDMSearchUserList :: MH ()
-enterDMSearchUserList = do
-    myId <- gets myUserId
-    myTId <- gets myTeamId
-    config <- use csClientConfig
-    let restrictTeam = case MM.clientConfigRestrictDirectMessage <$> config of
-            Just MM.RestrictTeam -> Just myTId
-            _ -> Nothing
-    enterUserListMode (AllUsers restrictTeam) Nothing
-      (\u -> case u^.uiId /= myId of
-        True -> createOrFocusDMChannel u Nothing >> return True
-        False -> return False
-      )
-
--- | Show the user list overlay with the given search scope, and issue a
--- request to gather the first search results.
-enterUserListMode :: UserSearchScope -> Maybe Int -> (UserInfo -> MH Bool) -> MH ()
-enterUserListMode scope resultCount enterHandler = do
-    csUserListOverlay.listOverlayRecordCount .= resultCount
-    enterListOverlayMode csUserListOverlay UserListOverlay scope enterHandler getUserSearchResults
-
-userInfoFromPair :: User -> Text -> UserInfo
-userInfoFromPair u status =
-    userInfoFromUser u True & uiStatus .~ statusFromText status
-
--- | Move the selection up in the user list overlay by one user.
-userListSelectUp :: MH ()
-userListSelectUp = userListMove L.listMoveUp
-
--- | Move the selection down in the user list overlay by one user.
-userListSelectDown :: MH ()
-userListSelectDown = userListMove L.listMoveDown
-
--- | Move the selection up in the user list overlay by a page of users
--- (userListPageSize).
-userListPageUp :: MH ()
-userListPageUp = userListMove (L.listMoveBy (-1 * userListPageSize))
-
--- | Move the selection down in the user list overlay by a page of users
--- (userListPageSize).
-userListPageDown :: MH ()
-userListPageDown = userListMove (L.listMoveBy userListPageSize)
-
--- | Transform the user list results in some way, e.g. by moving the
--- cursor, and then check to see whether the modification warrants a
--- prefetch of more search results.
-userListMove :: (L.List Name UserInfo -> L.List Name UserInfo) -> MH ()
-userListMove = listOverlayMove csUserListOverlay
-
--- | The number of users in a "page" for cursor movement purposes.
-userListPageSize :: Int
-userListPageSize = 10
-
-getUserSearchResults :: UserSearchScope
-                     -- ^ The scope to search
-                     -> Session
-                     -- ^ The connection session
-                     -> Text
-                     -- ^ The search string
-                     -> IO (Vec.Vector UserInfo)
-getUserSearchResults scope s searchString = do
-    -- Unfortunately, we don't get pagination control when there is a
-    -- search string in effect. We'll get at most 100 results from a
-    -- search.
-    let query = UserSearch { userSearchTerm = if T.null searchString then " " else searchString
-                           -- Hack alert: Searching with the string " "
-                           -- above is a hack to use the search
-                           -- endpoint to get "all users" instead of
-                           -- those matching a particular non-empty
-                           -- non-whitespace string. This is because
-                           -- only the search endpoint provides a
-                           -- control to eliminate deleted users from
-                           -- the results. If we don't do this, and
-                           -- use the /users endpoint instead, we'll
-                           -- get deleted users in those results and
-                           -- then those deleted users will disappear
-                           -- from the results once the user enters a
-                           -- non-empty string string.
-                           , userSearchAllowInactive = False
-                           , userSearchWithoutTeam = False
-                           , userSearchInChannelId = case scope of
-                               ChannelMembers cId _ -> Just cId
-                               _                    -> Nothing
-                           , userSearchNotInTeamId = Nothing
-                           , userSearchNotInChannelId = case scope of
-                               ChannelNonMembers cId _ -> Just cId
-                               _                       -> Nothing
-                           , userSearchTeamId = case scope of
-                               AllUsers tId            -> tId
-                               ChannelMembers _ tId    -> Just tId
-                               ChannelNonMembers _ tId -> Just tId
-                           }
-    users <- MM.mmSearchUsers query s
-
-    let uList = toList users
-        uIds = userId <$> uList
-
-    -- Now fetch status info for the users we got.
-    case null uList of
-        False -> do
-            statuses <- MM.mmGetUserStatusByIds (Seq.fromList uIds) s
-            let statusMap = HM.fromList [ (statusUserId e, statusStatus e) | e <- toList statuses ]
-                usersWithStatus = [ userInfoFromPair u (fromMaybe "" $ HM.lookup (userId u) statusMap)
-                                  | u <- uList
-                                  ]
-
-            return $ Vec.fromList usersWithStatus
-        True -> return mempty
diff --git a/src/State/Users.hs b/src/State/Users.hs
deleted file mode 100644
--- a/src/State/Users.hs
+++ /dev/null
@@ -1,100 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-module State.Users
-  ( handleNewUsers
-  , handleTypingUser
-  , withFetchedUser
-  , withFetchedUserMaybe
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Data.Text as T
-import qualified Data.Foldable as F
-import qualified Data.Sequence as Seq
-import           Data.Time ( getCurrentTime )
-import           Lens.Micro.Platform
-
-import qualified Network.Mattermost.Endpoints as MM
-import           Network.Mattermost.Types
-
-import           Config
-import           Types
-
-import           State.Common
-
-
-handleNewUsers :: Seq UserId -> MH () -> MH ()
-handleNewUsers newUserIds after = do
-    doAsyncMM Preempt getUserInfo addNewUsers
-    where getUserInfo session =
-              do nUsers <- MM.mmGetUsersByIds newUserIds session
-                 let usrInfo u = userInfoFromUser u True
-                     usrList = toList nUsers
-                 return $ usrInfo <$> usrList
-
-          addNewUsers :: [UserInfo] -> Maybe (MH ())
-          addNewUsers is = Just $ mapM_ addNewUser is >> after
-
--- | Handle the typing events from the websocket to show the currently
--- typing users on UI
-handleTypingUser :: UserId -> ChannelId -> MH ()
-handleTypingUser uId cId = do
-    config <- use (csResources.crConfiguration)
-    when (configShowTypingIndicator config) $ do
-        withFetchedUser (UserFetchById uId) $ const $ do
-            ts <- liftIO getCurrentTime
-            csChannels %= modifyChannelById cId (addChannelTypingUser uId ts)
-
--- | Given a user fetching strategy, locate the user in the state or
--- fetch it from the server, and pass the result to the specified
--- action. Assumes a single match is the only expected/valid result.
-withFetchedUser :: UserFetch -> (UserInfo -> MH ()) -> MH ()
-withFetchedUser fetch handle =
-    withFetchedUserMaybe fetch $ \u -> do
-        case u of
-            Nothing -> postErrorMessage' "No such user"
-            Just user -> handle user
-
-withFetchedUserMaybe :: UserFetch -> (Maybe UserInfo -> MH ()) -> MH ()
-withFetchedUserMaybe fetch handle = do
-    st <- use id
-    session <- getSession
-
-    let localMatch = case fetch of
-            UserFetchById uId -> userById uId st
-            UserFetchByUsername uname -> userByUsername uname st
-            UserFetchByNickname nick -> userByNickname nick st
-
-    case localMatch of
-        Just user -> handle $ Just user
-        Nothing -> do
-            mhLog LogGeneral $ T.pack $ "withFetchedUserMaybe: getting " <> show fetch
-            doAsyncWith Normal $ do
-                results <- case fetch of
-                    UserFetchById uId ->
-                        MM.mmGetUsersByIds (Seq.singleton uId) session
-                    UserFetchByUsername uname ->
-                        MM.mmGetUsersByUsernames (Seq.singleton $ trimUserSigil uname) session
-                    UserFetchByNickname nick -> do
-                        let req = UserSearch { userSearchTerm = trimUserSigil nick
-                                             , userSearchAllowInactive = True
-                                             , userSearchWithoutTeam = True
-                                             , userSearchInChannelId = Nothing
-                                             , userSearchNotInTeamId = Nothing
-                                             , userSearchNotInChannelId = Nothing
-                                             , userSearchTeamId = Nothing
-                                             }
-                        MM.mmSearchUsers req session
-
-                return $ Just $ do
-                    infos <- forM (F.toList results) $ \u -> do
-                        let info = userInfoFromUser u True
-                        addNewUser info
-                        return info
-
-                    case infos of
-                        [match] -> handle $ Just match
-                        [] -> handle Nothing
-                        _ -> postErrorMessage' "Error: ambiguous user information"
diff --git a/src/TeamSelect.hs b/src/TeamSelect.hs
deleted file mode 100644
--- a/src/TeamSelect.hs
+++ /dev/null
@@ -1,90 +0,0 @@
-module TeamSelect
-  ( interactiveTeamSelection
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Widgets.Border
-import           Brick.Widgets.Center
-import           Brick.Widgets.List
-import qualified Data.Function as F
-import           Data.List ( sortBy )
-import qualified Data.Vector as V
-import           Graphics.Vty hiding (mkVty)
-import qualified Data.Text as T
-
-import           Network.Mattermost.Types
-
-import           Draw.RichText
-import           Types.Common
-
-
-data State =
-    State { appList :: List () Team
-          , appCancelled :: Bool
-          }
-
-interactiveTeamSelection :: Vty -> IO Vty -> [Team] -> IO (Maybe Team, Vty)
-interactiveTeamSelection vty mkVty teams = do
-    let state = State { appList = list () (V.fromList sortedTeams) 1
-                      , appCancelled = False
-                      }
-        sortedTeams = sortBy (compare `F.on` teamName) teams
-
-    (finalSt, finalVty) <- customMainWithVty vty mkVty Nothing app state
-
-    let result = if appCancelled finalSt
-                 then Nothing
-                 else snd <$> listSelectedElement (appList finalSt)
-
-    return (result, finalVty)
-
-app :: App State e ()
-app = App
-  { appDraw         = teamSelectDraw
-  , appChooseCursor = neverShowCursor
-  , appHandleEvent  = onEvent
-  , appStartEvent   = return
-  , appAttrMap      = const colorTheme
-  }
-
-colorTheme :: AttrMap
-colorTheme = attrMap defAttr
-  [ (listSelectedFocusedAttr, black `on` yellow)
-  ]
-
-teamSelectDraw :: State -> [Widget ()]
-teamSelectDraw st =
-    [ teamSelect st
-    ]
-
-teamSelect :: State -> Widget ()
-teamSelect st =
-    center $ hLimit 50 $ vLimit 15 $
-    vBox [ hCenter $ txt "Welcome to Mattermost. Please select a team:"
-         , txt " "
-         , border theList
-         , txt " "
-         , renderText "Press Enter to select a team and connect or Esc to exit."
-         ]
-    where
-    theList = renderList renderTeamItem True (appList st)
-
-renderTeamItem :: Bool -> Team -> Widget ()
-renderTeamItem _ t =
-    padRight Max $ txt $ (sanitizeUserText $ teamName t) <>
-        if not $ T.null (sanitizeUserText $ teamDisplayName t)
-        then " (" <> (sanitizeUserText $ teamDisplayName t) <> ")"
-        else ""
-
-onEvent :: State -> BrickEvent () e -> EventM () (Next State)
-onEvent st (VtyEvent (EvKey KEsc [])) = do
-    halt $ st { appCancelled = True }
-onEvent st (VtyEvent (EvKey KEnter [])) = halt st
-onEvent st (VtyEvent e) = do
-    list' <- handleListEvent e (appList st)
-    continue $ st { appList = list' }
-onEvent st _ = continue st
diff --git a/src/Themes.hs b/src/Themes.hs
deleted file mode 100644
--- a/src/Themes.hs
+++ /dev/null
@@ -1,772 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-module Themes
-  ( InternalTheme(..)
-
-  , defaultTheme
-  , internalThemes
-  , lookupTheme
-  , themeDocs
-
-  -- * Attribute names
-  , currentUserAttr
-  , timeAttr
-  , channelHeaderAttr
-  , channelListHeaderAttr
-  , currentChannelNameAttr
-  , unreadChannelAttr
-  , unreadGroupMarkerAttr
-  , mentionsChannelAttr
-  , urlAttr
-  , codeAttr
-  , emailAttr
-  , emojiAttr
-  , channelNameAttr
-  , clientMessageAttr
-  , clientHeaderAttr
-  , clientEmphAttr
-  , clientStrongAttr
-  , dateTransitionAttr
-  , pinnedMessageIndicatorAttr
-  , newMessageTransitionAttr
-  , gapMessageAttr
-  , errorMessageAttr
-  , helpAttr
-  , helpEmphAttr
-  , channelSelectPromptAttr
-  , channelSelectMatchAttr
-  , completionAlternativeListAttr
-  , completionAlternativeCurrentAttr
-  , dialogAttr
-  , dialogEmphAttr
-  , recentMarkerAttr
-  , replyParentAttr
-  , loadMoreAttr
-  , urlListSelectedAttr
-  , messageSelectAttr
-  , messageSelectStatusAttr
-  , misspellingAttr
-  , editedMarkingAttr
-  , editedRecentlyMarkingAttr
-  , tabSelectedAttr
-  , tabUnselectedAttr
-
-  -- * Username formatting
-  , colorUsername
-  , attrForUsername
-  , usernameColorHashBuckets
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Themes
-import           Brick.Widgets.List
-import qualified Brick.Widgets.FileBrowser as FB
-import           Brick.Widgets.Skylighting ( attrNameForTokenType
-                                           , attrMappingsForStyle
-                                           , highlightedCodeBlockAttr
-                                           )
-import           Brick.Forms ( focusedFormInputAttr )
-import           Data.Hashable ( hash )
-import qualified Data.Map as M
-import qualified Data.Text as T
-import           Graphics.Vty
-import qualified Skylighting.Styles as Sky
-import           Skylighting.Types ( TokenType(..) )
-
-import           Types ( InternalTheme(..), specialUserMentions )
-
-
-helpAttr :: AttrName
-helpAttr = "help"
-
-helpEmphAttr :: AttrName
-helpEmphAttr = "helpEmphasis"
-
-recentMarkerAttr :: AttrName
-recentMarkerAttr = "recentChannelMarker"
-
-replyParentAttr :: AttrName
-replyParentAttr = "replyParentPreview"
-
-pinnedMessageIndicatorAttr :: AttrName
-pinnedMessageIndicatorAttr = "pinnedMessageIndicator"
-
-loadMoreAttr :: AttrName
-loadMoreAttr = "loadMoreMessages"
-
-urlListSelectedAttr :: AttrName
-urlListSelectedAttr = "urlListCursor"
-
-messageSelectAttr :: AttrName
-messageSelectAttr = "messageSelectCursor"
-
-editedMarkingAttr :: AttrName
-editedMarkingAttr = "editedMarking"
-
-editedRecentlyMarkingAttr :: AttrName
-editedRecentlyMarkingAttr = "editedRecentlyMarking"
-
-dialogAttr :: AttrName
-dialogAttr = "dialog"
-
-dialogEmphAttr :: AttrName
-dialogEmphAttr = "dialogEmphasis"
-
-channelSelectMatchAttr :: AttrName
-channelSelectMatchAttr = "channelSelectMatch"
-
-channelSelectPromptAttr :: AttrName
-channelSelectPromptAttr = "channelSelectPrompt"
-
-completionAlternativeListAttr :: AttrName
-completionAlternativeListAttr = "tabCompletionAlternative"
-
-completionAlternativeCurrentAttr :: AttrName
-completionAlternativeCurrentAttr = "tabCompletionCursor"
-
-timeAttr :: AttrName
-timeAttr = "time"
-
-currentUserAttr :: AttrName
-currentUserAttr = "currentUser"
-
-channelHeaderAttr :: AttrName
-channelHeaderAttr = "channelHeader"
-
-channelListHeaderAttr :: AttrName
-channelListHeaderAttr = "channelListSectionHeader"
-
-currentChannelNameAttr :: AttrName
-currentChannelNameAttr = "currentChannelName"
-
-channelNameAttr :: AttrName
-channelNameAttr = "channelName"
-
-unreadChannelAttr :: AttrName
-unreadChannelAttr = "unreadChannel"
-
-unreadGroupMarkerAttr :: AttrName
-unreadGroupMarkerAttr = "unreadChannelGroupMarker"
-
-mentionsChannelAttr :: AttrName
-mentionsChannelAttr = "channelWithMentions"
-
-tabSelectedAttr :: AttrName
-tabSelectedAttr = "tabSelected"
-
-tabUnselectedAttr :: AttrName
-tabUnselectedAttr = "tabUnselected"
-
-dateTransitionAttr :: AttrName
-dateTransitionAttr = "dateTransition"
-
-newMessageTransitionAttr :: AttrName
-newMessageTransitionAttr = "newMessageTransition"
-
-urlAttr :: AttrName
-urlAttr = "url"
-
-codeAttr :: AttrName
-codeAttr = "codeBlock"
-
-emailAttr :: AttrName
-emailAttr = "email"
-
-emojiAttr :: AttrName
-emojiAttr = "emoji"
-
-clientMessageAttr :: AttrName
-clientMessageAttr = "clientMessage"
-
-clientHeaderAttr :: AttrName
-clientHeaderAttr = "markdownHeader"
-
-clientEmphAttr :: AttrName
-clientEmphAttr = "markdownEmph"
-
-clientStrongAttr :: AttrName
-clientStrongAttr = "markdownStrong"
-
-errorMessageAttr :: AttrName
-errorMessageAttr = "errorMessage"
-
-gapMessageAttr :: AttrName
-gapMessageAttr = "gapMessage"
-
-misspellingAttr :: AttrName
-misspellingAttr = "misspelling"
-
-messageSelectStatusAttr :: AttrName
-messageSelectStatusAttr = "messageSelectStatus"
-
-lookupTheme :: Text -> Maybe InternalTheme
-lookupTheme n = find ((== n) . internalThemeName) internalThemes
-
-internalThemes :: [InternalTheme]
-internalThemes = validateInternalTheme <$>
-    [ darkColorTheme
-    , darkColor256Theme
-    , lightColorTheme
-    , lightColor256Theme
-    ]
-
-validateInternalTheme :: InternalTheme -> InternalTheme
-validateInternalTheme it =
-    let un = undocumentedAttrNames (internalTheme it)
-    in if not $ null un
-       then error $ "Internal theme " <> show (T.unpack (internalThemeName it)) <>
-                    " references undocumented attribute names: " <> show un
-       else it
-
-undocumentedAttrNames :: Theme -> [AttrName]
-undocumentedAttrNames t =
-    let noDocs k = isNothing $ attrNameDescription themeDocs k
-    in filter noDocs (M.keys $ themeDefaultMapping t)
-
-defaultTheme :: InternalTheme
-defaultTheme = darkColorTheme
-
-lightColorTheme :: InternalTheme
-lightColorTheme = InternalTheme name theme desc
-    where
-        theme = newTheme def $ lightAttrs usernameColors16
-        name = "builtin:light"
-        def = black `on` white
-        desc = "A color theme for terminal windows with light background colors"
-
-lightColor256Theme :: InternalTheme
-lightColor256Theme = InternalTheme name theme desc
-    where
-        theme = newTheme def $ lightAttrs usernameColors256
-        name = "builtin:light256"
-        def = black `on` white
-        desc = "Like builtin:light, but with 256-color username colors"
-
-lightAttrs :: [Attr] -> [(AttrName, Attr)]
-lightAttrs usernameColors =
-    let sty = Sky.kate
-    in [ (timeAttr,                         fg black)
-       , (currentUserAttr,                  defAttr `withStyle` bold)
-       , (channelHeaderAttr,                fg black `withStyle` underline)
-       , (channelListHeaderAttr,            fg cyan)
-       , (currentChannelNameAttr,           black `on` yellow `withStyle` bold)
-       , (unreadChannelAttr,                black `on` cyan   `withStyle` bold)
-       , (unreadGroupMarkerAttr,            fg black `withStyle` bold)
-       , (mentionsChannelAttr,              black `on` red    `withStyle` bold)
-       , (urlAttr,                          fg brightYellow)
-       , (emailAttr,                        fg yellow)
-       , (codeAttr,                         fg magenta)
-       , (emojiAttr,                        fg yellow)
-       , (channelNameAttr,                  fg blue)
-       , (clientMessageAttr,                fg black)
-       , (clientEmphAttr,                   fg black `withStyle` bold)
-       , (clientStrongAttr,                 fg black `withStyle` bold `withStyle` underline)
-       , (clientHeaderAttr,                 fg red `withStyle` bold)
-       , (dateTransitionAttr,               fg green)
-       , (newMessageTransitionAttr,         black `on` yellow)
-       , (errorMessageAttr,                 fg red)
-       , (gapMessageAttr,                   fg red)
-       , (helpAttr,                         fg black)
-       , (pinnedMessageIndicatorAttr,       black `on` cyan)
-       , (helpEmphAttr,                     fg blue `withStyle` bold)
-       , (channelSelectMatchAttr,           black `on` magenta)
-       , (channelSelectPromptAttr,          fg black)
-       , (completionAlternativeListAttr,    white `on` blue)
-       , (completionAlternativeCurrentAttr, black `on` yellow)
-       , (dialogAttr,                       black `on` cyan)
-       , (dialogEmphAttr,                   fg white)
-       , (listSelectedFocusedAttr,          black `on` yellow)
-       , (recentMarkerAttr,                 fg black `withStyle` bold)
-       , (loadMoreAttr,                     black `on` cyan)
-       , (urlListSelectedAttr,              black `on` yellow)
-       , (messageSelectAttr,                black `on` yellow)
-       , (messageSelectStatusAttr,          fg black)
-       , (misspellingAttr,                  fg red `withStyle` underline)
-       , (editedMarkingAttr,                fg yellow)
-       , (editedRecentlyMarkingAttr,        black `on` yellow)
-       , (tabSelectedAttr,                  black `on` yellow)
-       , (focusedFormInputAttr,             black `on` yellow)
-       , (FB.fileBrowserCurrentDirectoryAttr, white `on` blue)
-       , (FB.fileBrowserSelectionInfoAttr,  white `on` blue)
-       , (FB.fileBrowserDirectoryAttr,      fg blue)
-       , (FB.fileBrowserBlockDeviceAttr,    fg magenta)
-       , (FB.fileBrowserCharacterDeviceAttr, fg green)
-       , (FB.fileBrowserNamedPipeAttr,      fg yellow)
-       , (FB.fileBrowserSymbolicLinkAttr,   fg cyan)
-       , (FB.fileBrowserUnixSocketAttr,     fg red)
-       ] <>
-       ((\(i, a) -> (usernameAttr i, a)) <$> zip [0..usernameColorHashBuckets-1] (cycle usernameColors)) <>
-       (filter skipBaseCodeblockAttr $ attrMappingsForStyle sty)
-
-darkAttrs :: [Attr] -> [(AttrName, Attr)]
-darkAttrs usernameColors =
-  let sty = Sky.espresso
-  in [ (timeAttr,                         fg white)
-     , (currentUserAttr,                  defAttr `withStyle` bold)
-     , (channelHeaderAttr,                fg white `withStyle` underline)
-     , (channelListHeaderAttr,            fg cyan)
-     , (currentChannelNameAttr,           black `on` yellow `withStyle` bold)
-     , (unreadChannelAttr,                black `on` cyan   `withStyle` bold)
-     , (unreadGroupMarkerAttr,            fg white `withStyle` bold)
-     , (mentionsChannelAttr,              black `on` brightMagenta `withStyle` bold)
-     , (urlAttr,                          fg yellow)
-     , (emailAttr,                        fg yellow)
-     , (codeAttr,                         fg magenta)
-     , (emojiAttr,                        fg yellow)
-     , (channelNameAttr,                  fg cyan)
-     , (pinnedMessageIndicatorAttr,       fg cyan `withStyle` bold)
-     , (clientMessageAttr,                fg white)
-     , (clientEmphAttr,                   fg white `withStyle` bold)
-     , (clientStrongAttr,                 fg white `withStyle` bold `withStyle` underline)
-     , (clientHeaderAttr,                 fg red `withStyle` bold)
-     , (dateTransitionAttr,               fg green)
-     , (newMessageTransitionAttr,         fg yellow `withStyle` bold)
-     , (errorMessageAttr,                 fg red)
-     , (gapMessageAttr,                   black `on` yellow)
-     , (helpAttr,                         fg white)
-     , (helpEmphAttr,                     fg cyan `withStyle` bold)
-     , (channelSelectMatchAttr,           black `on` magenta)
-     , (channelSelectPromptAttr,          fg white)
-     , (completionAlternativeListAttr,    white `on` blue)
-     , (completionAlternativeCurrentAttr, black `on` yellow)
-     , (dialogAttr,                       black `on` cyan)
-     , (dialogEmphAttr,                   fg white)
-     , (listSelectedFocusedAttr,          black `on` yellow)
-     , (recentMarkerAttr,                 fg yellow `withStyle` bold)
-     , (loadMoreAttr,                     black `on` cyan)
-     , (urlListSelectedAttr,              black `on` yellow)
-     , (messageSelectAttr,                black `on` yellow)
-     , (messageSelectStatusAttr,          fg white)
-     , (misspellingAttr,                  fg red `withStyle` underline)
-     , (editedMarkingAttr,                fg yellow)
-     , (editedRecentlyMarkingAttr,        black `on` yellow)
-     , (tabSelectedAttr,                  black `on` yellow)
-     , (focusedFormInputAttr,             black `on` yellow)
-     , (FB.fileBrowserCurrentDirectoryAttr, white `on` blue)
-     , (FB.fileBrowserSelectionInfoAttr,  white `on` blue)
-     , (FB.fileBrowserDirectoryAttr,      fg blue)
-     , (FB.fileBrowserBlockDeviceAttr,    fg magenta)
-     , (FB.fileBrowserCharacterDeviceAttr, fg green)
-     , (FB.fileBrowserNamedPipeAttr,      fg yellow)
-     , (FB.fileBrowserSymbolicLinkAttr,   fg cyan)
-     , (FB.fileBrowserUnixSocketAttr,     fg red)
-     ] <>
-     ((\(i, a) -> (usernameAttr i, a)) <$> zip [0..usernameColorHashBuckets-1] (cycle usernameColors)) <>
-     (filter skipBaseCodeblockAttr $ attrMappingsForStyle sty)
-
-skipBaseCodeblockAttr :: (AttrName, Attr) -> Bool
-skipBaseCodeblockAttr = ((/= highlightedCodeBlockAttr) . fst)
-
-darkColorTheme :: InternalTheme
-darkColorTheme = InternalTheme name theme desc
-    where
-        theme = newTheme def $ darkAttrs usernameColors16
-        name = "builtin:dark"
-        def = defAttr
-        desc = "A color theme for terminal windows with dark background colors"
-
-darkColor256Theme :: InternalTheme
-darkColor256Theme = InternalTheme name theme desc
-    where
-        theme = newTheme def $ darkAttrs usernameColors256
-        name = "builtin:dark256"
-        def = defAttr
-        desc = "Like builtin:dark, but with 256-color username colors"
-
-usernameAttr :: Int -> AttrName
-usernameAttr i = "username" <> (attrName $ show i)
-
--- | Render a string with a color chosen based on the text of a
--- username.
---
--- This function takes some display text and renders it using an
--- attribute based on the username associated with the text. If the
--- username associated with the text is equal to the username of
--- the user running Matterhorn, the display text is formatted with
--- 'currentAttr'. Otherwise it is formatted with an attribute chosen
--- by hashing the associated username and choosing from amongst the
--- username color hash buckets with 'usernameAttr'.
---
--- Usually the first argument to this function will be @myUsername st@,
--- where @st@ is a 'ChatState'.
---
--- The most common way to call this function is
---
--- @colorUsername (myUsername st) u u
---
--- The third argument is allowed to vary from the second since sometimes
--- we call this with the user's status sigil as the third argument.
-colorUsername :: Text
-              -- ^ The username for the user currently running
-              -- Matterhorn
-              -> Text
-              -- ^ The username associated with the text to render
-              -> Text
-              -- ^ The text to render
-              -> Widget a
-colorUsername current username display =
-    let aName = attrForUsername username
-        maybeWithCurrentAttr = if current == username
-                               then withAttr currentUserAttr
-                               else id
-    in withDefAttr aName $
-       maybeWithCurrentAttr $
-       txt (display)
-
--- | Return the attribute name to use for the specified username.
--- The input username is expected to be the username only (i.e. no
--- sigil).
---
--- If the input username is a special reserved username such as "all",
--- the @clientEmphAttr@ attribute name will be returned. Otherwise
--- a hash-bucket username attribute name will be returned based on
--- the hash value of the username and the number of hash buckets
--- (@usernameColorHashBuckets@).
-attrForUsername :: Text
-                -- ^ The username to get an attribute for
-                -> AttrName
-attrForUsername username =
-    let normalizedUsername = T.toLower username
-        aName = if normalizedUsername `elem` specialUserMentions
-                then clientEmphAttr
-                else usernameAttr h
-        h = hash normalizedUsername `mod` usernameColorHashBuckets
-    in aName
-
--- | The number of hash buckets to use when hashing usernames to choose
--- their colors.
-usernameColorHashBuckets :: Int
-usernameColorHashBuckets = 50
-
-usernameColors16 :: [Attr]
-usernameColors16 =
-    [ fg red
-    , fg green
-    , fg yellow
-    , fg blue
-    , fg magenta
-    , fg cyan
-    , fg brightRed
-    , fg brightGreen
-    , fg brightYellow
-    , fg brightBlue
-    , fg brightMagenta
-    , fg brightCyan
-    ]
-
-usernameColors256 :: [Attr]
-usernameColors256 = mkColor <$> username256ColorChoices
-    where
-        mkColor (r, g, b) = defAttr `withForeColor` rgbColor r g b
-
-username256ColorChoices :: [(Integer, Integer, Integer)]
-username256ColorChoices =
-    [ (255, 0, 86)
-    , (158, 0, 142)
-    , (14, 76, 161)
-    , (255, 229, 2)
-    , (149, 0, 58)
-    , (255, 147, 126)
-    , (164, 36, 0)
-    , (98, 14, 0)
-    , (0, 0, 255)
-    , (106, 130, 108)
-    , (0, 174, 126)
-    , (194, 140, 159)
-    , (0, 143, 156)
-    , (95, 173, 78)
-    , (255, 2, 157)
-    , (255, 116, 163)
-    , (152, 255, 82)
-    , (167, 87, 64)
-    , (254, 137, 0)
-    , (1, 208, 255)
-    , (187, 136, 0)
-    , (117, 68, 177)
-    , (165, 255, 210)
-    , (122, 71, 130)
-    , (0, 71, 84)
-    , (181, 0, 255)
-    , (144, 251, 146)
-    , (189, 211, 147)
-    , (229, 111, 254)
-    , (222, 255, 116)
-    , (0, 255, 120)
-    , (0, 155, 255)
-    , (0, 100, 1)
-    , (0, 118, 255)
-    , (133, 169, 0)
-    , (0, 185, 23)
-    , (120, 130, 49)
-    , (0, 255, 198)
-    , (255, 110, 65)
-    ]
-
--- Functions for dealing with Skylighting styles
-
-attrNameDescription :: ThemeDocumentation -> AttrName -> Maybe Text
-attrNameDescription td an = M.lookup an (themeDescriptions td)
-
-themeDocs :: ThemeDocumentation
-themeDocs = ThemeDocumentation $ M.fromList $
-    [ ( timeAttr
-      , "Timestamps on chat messages"
-      )
-    , ( channelHeaderAttr
-      , "Channel headers displayed above chat message lists"
-      )
-    , ( channelListHeaderAttr
-      , "The heading of the channel list sections"
-      )
-    , ( currentChannelNameAttr
-      , "The currently selected channel in the channel list"
-      )
-    , ( unreadChannelAttr
-      , "A channel in the channel list with unread messages"
-      )
-    , ( unreadGroupMarkerAttr
-      , "The channel group marker indicating unread messages"
-      )
-    , ( mentionsChannelAttr
-      , "A channel in the channel list with unread mentions"
-      )
-    , ( urlAttr
-      , "A URL in a chat message"
-      )
-    , ( codeAttr
-      , "A code block in a chat message with no language indication"
-      )
-    , ( emailAttr
-      , "An e-mail address in a chat message"
-      )
-    , ( emojiAttr
-      , "A text emoji indication in a chat message"
-      )
-    , ( channelNameAttr
-      , "A channel name in a chat message"
-      )
-    , ( clientMessageAttr
-      , "A Matterhorn diagnostic or informative message"
-      )
-    , ( clientHeaderAttr
-      , "Markdown heading"
-      )
-    , ( clientEmphAttr
-      , "Markdown 'emphasized' text"
-      )
-    , ( clientStrongAttr
-      , "Markdown 'strong' text"
-      )
-    , ( dateTransitionAttr
-      , "Date transition lines between chat messages on different days"
-      )
-    , ( pinnedMessageIndicatorAttr
-      , "The indicator for messages that have been pinned"
-      )
-    , ( newMessageTransitionAttr
-      , "The 'New Messages' line that appears above unread messages"
-      )
-    , ( tabSelectedAttr
-      , "Selected tabs in tabbed windows"
-      )
-    , ( tabUnselectedAttr
-      , "Unselected tabs in tabbed windows"
-      )
-    , ( errorMessageAttr
-      , "Matterhorn error messages"
-      )
-    , ( gapMessageAttr
-      , "Matterhorn message gap information"
-      )
-    , ( helpAttr
-      , "The help screen text"
-      )
-    , ( helpEmphAttr
-      , "The help screen's emphasized text"
-      )
-    , ( channelSelectPromptAttr
-      , "Channel selection: prompt"
-      )
-    , ( channelSelectMatchAttr
-      , "Channel selection: the portion of a channel name that matches"
-      )
-    , ( completionAlternativeListAttr
-      , "Tab completion alternatives"
-      )
-    , ( completionAlternativeCurrentAttr
-      , "The currently-selected tab completion alternative"
-      )
-    , ( dialogAttr
-      , "Dialog box text"
-      )
-    , ( dialogEmphAttr
-      , "Dialog box emphasized text"
-      )
-    , ( recentMarkerAttr
-      , "The marker indicating the channel last visited"
-      )
-    , ( replyParentAttr
-      , "The first line of parent messages appearing above reply messages"
-      )
-    , ( loadMoreAttr
-      , "The 'Load More' line that appears at the top of a chat message list"
-      )
-    , ( urlListSelectedAttr
-      , "URL list: the selected URL"
-      )
-    , ( messageSelectAttr
-      , "Message selection: the currently-selected message"
-      )
-    , ( messageSelectStatusAttr
-      , "Message selection: the message selection actions"
-      )
-    , ( misspellingAttr
-      , "A misspelled word in the chat message editor"
-      )
-    , ( editedMarkingAttr
-      , "The 'edited' marking that appears on edited messages"
-      )
-    , ( editedRecentlyMarkingAttr
-      , "The 'edited' marking that appears on newly-edited messages"
-      )
-    , ( highlightedCodeBlockAttr
-      , "The base attribute for syntax-highlighted code blocks"
-      )
-    , ( attrNameForTokenType KeywordTok
-      , "Syntax highlighting: Keyword"
-      )
-    , ( attrNameForTokenType DataTypeTok
-      , "Syntax highlighting: DataType"
-      )
-    , ( attrNameForTokenType DecValTok
-      , "Syntax highlighting: Declaration"
-      )
-    , ( attrNameForTokenType BaseNTok
-      , "Syntax highlighting: BaseN"
-      )
-    , ( attrNameForTokenType FloatTok
-      , "Syntax highlighting: Float"
-      )
-    , ( attrNameForTokenType ConstantTok
-      , "Syntax highlighting: Constant"
-      )
-    , ( attrNameForTokenType CharTok
-      , "Syntax highlighting: Char"
-      )
-    , ( attrNameForTokenType SpecialCharTok
-      , "Syntax highlighting: Special Char"
-      )
-    , ( attrNameForTokenType StringTok
-      , "Syntax highlighting: String"
-      )
-    , ( attrNameForTokenType VerbatimStringTok
-      , "Syntax highlighting: Verbatim String"
-      )
-    , ( attrNameForTokenType SpecialStringTok
-      , "Syntax highlighting: Special String"
-      )
-    , ( attrNameForTokenType ImportTok
-      , "Syntax highlighting: Import"
-      )
-    , ( attrNameForTokenType CommentTok
-      , "Syntax highlighting: Comment"
-      )
-    , ( attrNameForTokenType DocumentationTok
-      , "Syntax highlighting: Documentation"
-      )
-    , ( attrNameForTokenType AnnotationTok
-      , "Syntax highlighting: Annotation"
-      )
-    , ( attrNameForTokenType CommentVarTok
-      , "Syntax highlighting: Comment"
-      )
-    , ( attrNameForTokenType OtherTok
-      , "Syntax highlighting: Other"
-      )
-    , ( attrNameForTokenType FunctionTok
-      , "Syntax highlighting: Function"
-      )
-    , ( attrNameForTokenType VariableTok
-      , "Syntax highlighting: Variable"
-      )
-    , ( attrNameForTokenType ControlFlowTok
-      , "Syntax highlighting: Control Flow"
-      )
-    , ( attrNameForTokenType OperatorTok
-      , "Syntax highlighting: Operator"
-      )
-    , ( attrNameForTokenType BuiltInTok
-      , "Syntax highlighting: Built-In"
-      )
-    , ( attrNameForTokenType ExtensionTok
-      , "Syntax highlighting: Extension"
-      )
-    , ( attrNameForTokenType PreprocessorTok
-      , "Syntax highlighting: Preprocessor"
-      )
-    , ( attrNameForTokenType AttributeTok
-      , "Syntax highlighting: Attribute"
-      )
-    , ( attrNameForTokenType RegionMarkerTok
-      , "Syntax highlighting: Region Marker"
-      )
-    , ( attrNameForTokenType InformationTok
-      , "Syntax highlighting: Information"
-      )
-    , ( attrNameForTokenType WarningTok
-      , "Syntax highlighting: Warning"
-      )
-    , ( attrNameForTokenType AlertTok
-      , "Syntax highlighting: Alert"
-      )
-    , ( attrNameForTokenType ErrorTok
-      , "Syntax highlighting: Error"
-      )
-    , ( attrNameForTokenType NormalTok
-      , "Syntax highlighting: Normal text"
-      )
-    , ( listSelectedFocusedAttr
-      , "The selected channel"
-      )
-    , ( focusedFormInputAttr
-      , "A form input that has focus"
-      )
-    , ( FB.fileBrowserAttr
-      , "The base file browser attribute"
-      )
-    , ( FB.fileBrowserCurrentDirectoryAttr
-      , "The file browser current directory attribute"
-      )
-    , ( FB.fileBrowserSelectionInfoAttr
-      , "The file browser selection information attribute"
-      )
-    , ( FB.fileBrowserDirectoryAttr
-      , "Attribute for directories in the file browser"
-      )
-    , ( FB.fileBrowserBlockDeviceAttr
-      , "Attribute for block devices in the file browser"
-      )
-    , ( FB.fileBrowserRegularFileAttr
-      , "Attribute for regular files in the file browser"
-      )
-    , ( FB.fileBrowserCharacterDeviceAttr
-      , "Attribute for character devices in the file browser"
-      )
-    , ( FB.fileBrowserNamedPipeAttr
-      , "Attribute for named pipes in the file browser"
-      )
-    , ( FB.fileBrowserSymbolicLinkAttr
-      , "Attribute for symbolic links in the file browser"
-      )
-    , ( FB.fileBrowserUnixSocketAttr
-      , "Attribute for Unix sockets in the file browser"
-      )
-    , ( currentUserAttr
-      , "Attribute for the username of the user running Matterhorn"
-      )
-    ] <> [ (usernameAttr i, T.pack $ "Username color " <> show i)
-         | i <- [0..usernameColorHashBuckets-1]
-         ]
diff --git a/src/TimeUtils.hs b/src/TimeUtils.hs
deleted file mode 100644
--- a/src/TimeUtils.hs
+++ /dev/null
@@ -1,70 +0,0 @@
-module TimeUtils
-    ( lookupLocalTimeZone
-    , startOfDay
-    , justAfter, justBefore
-    , asLocalTime
-    , localTimeText
-    , originTime
-    )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Data.Text as T
-import           Data.Time.Clock ( UTCTime(..) )
-import           Data.Time.Format ( formatTime, defaultTimeLocale )
-import           Data.Time.LocalTime ( LocalTime(..), TimeOfDay(..) )
-import           Data.Time.LocalTime.TimeZone.Olson ( getTimeZoneSeriesFromOlsonFile )
-import           Data.Time.LocalTime.TimeZone.Series ( localTimeToUTC'
-                                                     , utcToLocalTime')
-
-import           Network.Mattermost.Types ( ServerTime(..) )
-
-
--- | Get the timezone series that should be used for converting UTC
--- times into local times with appropriate DST adjustments.
-lookupLocalTimeZone :: IO TimeZoneSeries
-lookupLocalTimeZone = getTimeZoneSeriesFromOlsonFile "/etc/localtime"
-
-
--- | Sometimes it is convenient to render a divider between messages;
--- the 'justAfter' function can be used to get a time that is after
--- the input time but by such a small increment that there is unlikely
--- to be anything between (or at) the result.  Adding the divider
--- using this timestamp value allows the general sorting based on
--- timestamps to operate normally (whereas a type-match for a
--- non-timestamp-entry in the sort operation would be considerably
--- more complex).
-justAfter :: ServerTime -> ServerTime
-justAfter = ServerTime . justAfterUTC . withServerTime
-    where justAfterUTC time = let UTCTime d t = time in UTCTime d (succ t)
-
--- | Obtain a time value that is just moments before the input time;
--- see the comment for the 'justAfter' function for more details.
-justBefore :: ServerTime -> ServerTime
-justBefore = ServerTime . justBeforeUTC . withServerTime
-    where justBeforeUTC time = let UTCTime d t = time in UTCTime d (pred t)
-
--- | The timestamp for the start of the day associated with the input
--- timestamp.  If timezone information is supplied, then the returned
--- value will correspond to when the day started in that timezone;
--- otherwise it is the start of the day in a timezone aligned with
--- UTC.
-startOfDay :: Maybe TimeZoneSeries -> UTCTime -> UTCTime
-startOfDay Nothing time = let UTCTime d _ = time in UTCTime d 0
-startOfDay (Just tz) time = let lt = utcToLocalTime' tz time
-                                ls = LocalTime (localDay lt) (TimeOfDay 0 0 0)
-                            in localTimeToUTC' tz ls
-
--- | Convert a UTC time value to a local time.
-asLocalTime :: TimeZoneSeries -> UTCTime -> LocalTime
-asLocalTime = utcToLocalTime'
-
--- | Local time in displayable format
-localTimeText :: Text -> LocalTime -> Text
-localTimeText fmt time = T.pack $ formatTime defaultTimeLocale (T.unpack fmt) time
-
--- | Provides a time value that can be used when there are no other times available
-originTime :: UTCTime
-originTime = UTCTime (toEnum 0) 0
diff --git a/src/Types.hs b/src/Types.hs
deleted file mode 100644
--- a/src/Types.hs
+++ /dev/null
@@ -1,2068 +0,0 @@
-{-# LANGUAGE GADTs #-}
-{-# LANGUAGE KindSignatures #-}
-{-# LANGUAGE TemplateHaskell #-}
-{-# LANGUAGE MultiWayIf #-}
-{-# LANGUAGE RankNTypes #-}
-{-# LANGUAGE MultiParamTypeClasses #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TupleSections #-}
-module Types
-  ( ConnectionStatus(..)
-  , HelpTopic(..)
-  , MessageSelectState(..)
-  , ProgramOutput(..)
-  , MHEvent(..)
-  , InternalEvent(..)
-  , Name(..)
-  , ChannelSelectMatch(..)
-  , StartupStateInfo(..)
-  , MHError(..)
-  , AttachmentData(..)
-  , CPUUsagePolicy(..)
-  , tabbedWindow
-  , getCurrentTabbedWindowEntry
-  , tabbedWindowNextTab
-  , tabbedWindowPreviousTab
-  , runTabShowHandlerFor
-  , TabbedWindow(..)
-  , TabbedWindowEntry(..)
-  , TabbedWindowTemplate(..)
-  , OpenInBrowser(..)
-  , ConnectionInfo(..)
-  , SidebarUpdate(..)
-  , PendingChannelChange(..)
-  , ViewMessageWindowTab(..)
-  , clearChannelUnreadStatus
-  , ChannelListEntry(..)
-  , channelListEntryChannelId
-  , channelListEntryUserId
-  , userIdsFromZipper
-  , entryIsDMEntry
-  , ciHostname
-  , ciPort
-  , ciUrlPath
-  , ciUsername
-  , ciPassword
-  , ciType
-  , ciAccessToken
-  , Config(..)
-  , HelpScreen(..)
-  , PasswordSource(..)
-  , TokenSource(..)
-  , MatchType(..)
-  , Mode(..)
-  , ChannelSelectPattern(..)
-  , PostListContents(..)
-  , AuthenticationException(..)
-  , BackgroundInfo(..)
-  , RequestChan
-  , UserFetch(..)
-  , writeBChan
-  , InternalTheme(..)
-
-  , attrNameToConfig
-
-  , mkChannelZipperList
-  , ChannelListGroup(..)
-  , channelListGroupHasUnread
-
-  , trimChannelSigil
-
-  , ChannelSelectState(..)
-  , channelSelectMatches
-  , channelSelectInput
-  , emptyChannelSelectState
-
-  , ChatState
-  , newState
-  , csResources
-  , csFocus
-  , csCurrentChannel
-  , csCurrentChannelId
-  , csUrlList
-  , csShowMessagePreview
-  , csShowChannelList
-  , csPostMap
-  , csRecentChannel
-  , csReturnChannel
-  , csThemeListOverlay
-  , csPostListOverlay
-  , csUserListOverlay
-  , csChannelListOverlay
-  , csReactionEmojiListOverlay
-  , csMyTeam
-  , csMessageSelect
-  , csConnectionStatus
-  , csWorkerIsBusy
-  , csChannel
-  , csChannels
-  , csChannelSelectState
-  , csEditState
-  , csClientConfig
-  , csPendingChannelChange
-  , csViewedMessage
-  , csNotifyPrefs
-  , csMe
-  , timeZone
-  , whenMode
-  , setMode
-  , setMode'
-  , appMode
-
-  , ChatEditState
-  , emptyEditState
-  , cedAttachmentList
-  , cedFileBrowser
-  , cedYankBuffer
-  , cedSpellChecker
-  , cedMisspellings
-  , cedEditMode
-  , cedEphemeral
-  , cedEditor
-  , cedInputHistory
-  , cedAutocomplete
-  , cedAutocompletePending
-  , cedJustCompleted
-
-  , AutocompleteState(..)
-  , acPreviousSearchString
-  , acCompletionList
-  , acCachedResponses
-  , acType
-
-  , AutocompletionType(..)
-
-  , AutocompleteAlternative(..)
-  , autocompleteAlternativeReplacement
-  , SpecialMention(..)
-  , specialMentionName
-  , isSpecialMention
-
-  , PostListOverlayState
-  , postListSelected
-  , postListPosts
-
-  , UserSearchScope(..)
-  , ChannelSearchScope(..)
-
-  , ListOverlayState
-  , listOverlaySearchResults
-  , listOverlaySearchInput
-  , listOverlaySearchScope
-  , listOverlaySearching
-  , listOverlayEnterHandler
-  , listOverlayNewList
-  , listOverlayFetchResults
-  , listOverlayRecordCount
-
-  , getUsers
-
-  , ChatResources(..)
-  , crUserPreferences
-  , crEventQueue
-  , crTheme
-  , crStatusUpdateChan
-  , crSubprocessLog
-  , crWebsocketActionChan
-  , crWebsocketThreadId
-  , crRequestQueue
-  , crFlaggedPosts
-  , crConn
-  , crConfiguration
-  , crSyntaxMap
-  , crLogManager
-  , crEmoji
-  , getSession
-  , getResourceSession
-
-  , specialUserMentions
-
-  , UserPreferences(UserPreferences)
-  , userPrefShowJoinLeave
-  , userPrefFlaggedPostList
-  , userPrefGroupChannelPrefs
-  , userPrefDirectChannelPrefs
-  , userPrefTeammateNameDisplayMode
-  , dmChannelShowPreference
-  , groupChannelShowPreference
-
-  , defaultUserPreferences
-  , setUserPreferences
-
-  , WebsocketAction(..)
-
-  , Cmd(..)
-  , commandName
-  , CmdArgs(..)
-
-  , MH
-  , runMHEvent
-  , scheduleUserFetches
-  , scheduleUserStatusFetches
-  , getScheduledUserFetches
-  , getScheduledUserStatusFetches
-  , mh
-  , generateUUID
-  , generateUUID_IO
-  , mhSuspendAndResume
-  , mhHandleEventLensed
-  , St.gets
-  , mhError
-
-  , mhLog
-  , mhGetIOLogger
-  , ioLogWithManager
-  , LogContext(..)
-  , withLogContext
-  , withLogContextChannelId
-  , getLogContext
-  , LogMessage(..)
-  , LogCommand(..)
-  , LogCategory(..)
-
-  , LogManager(..)
-  , startLoggingToFile
-  , stopLoggingToFile
-  , requestLogSnapshot
-  , requestLogDestination
-  , sendLogMessage
-
-  , requestQuit
-  , getMessageForPostId
-  , getParentMessage
-  , resetSpellCheckTimer
-  , withChannel
-  , withChannelOrDefault
-  , userList
-  , resetAutocomplete
-  , hasUnread
-  , hasUnread'
-  , isMine
-  , setUserStatus
-  , myUser
-  , myUsername
-  , myUserId
-  , myTeamId
-  , usernameForUserId
-  , userByUsername
-  , userByNickname
-  , channelIdByChannelName
-  , channelIdByUsername
-  , channelByName
-  , userById
-  , allUserIds
-  , addNewUser
-  , useNickname
-  , useNickname'
-  , displayNameForUserId
-  , displayNameForUser
-  , raiseInternalEvent
-  , getNewMessageCutoff
-  , getEditedMessageCutoff
-
-  , HighlightSet(..)
-  , UserSet
-  , ChannelSet
-  , getHighlightSet
-
-  , module Types.Channels
-  , module Types.Messages
-  , module Types.Posts
-  , module Types.Users
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Graphics.Vty as Vty
-import qualified Brick
-import           Brick ( EventM, Next, Widget )
-import           Brick.Themes ( Theme )
-import           Brick.Main ( invalidateCache, invalidateCacheEntry )
-import           Brick.AttrMap ( AttrMap )
-import qualified Brick.BChan as BCH
-import           Brick.Forms (Form)
-import           Brick.Widgets.Edit ( Editor, editor )
-import           Brick.Widgets.List ( List, list )
-import qualified Brick.Widgets.FileBrowser as FB
-import           Control.Concurrent ( ThreadId )
-import           Control.Concurrent.Async ( Async )
-import qualified Control.Concurrent.STM as STM
-import           Control.Exception ( SomeException )
-import qualified Control.Monad.Fail as MHF
-import qualified Control.Monad.State as St
-import qualified Control.Monad.Reader as R
-import qualified Data.Set as Set
-import qualified Data.ByteString as BS
-import qualified Data.Foldable as F
-import qualified Data.Kind as K
-import           Data.Ord ( comparing )
-import qualified Data.HashMap.Strict as HM
-import           Data.List ( sortBy, nub, elemIndex )
-import qualified Data.Sequence as Seq
-import qualified Data.Text as T
-import           Data.Time.Clock ( getCurrentTime, addUTCTime )
-import           Data.UUID ( UUID )
-import qualified Data.Vector as Vec
-import           Lens.Micro.Platform ( at, makeLenses, lens, (%~), (^?!), (.=)
-                                     , (%=), (^?), (.~)
-                                     , _Just, Traversal', preuse, to
-                                     , SimpleGetter
-                                     )
-import           Network.Connection ( HostNotResolved, HostCannotConnect )
-import           Skylighting.Types ( SyntaxMap )
-import           System.Exit ( ExitCode )
-import           System.Random ( randomIO )
-import           Text.Aspell ( Aspell )
-
-import           Network.Mattermost ( ConnectionData )
-import           Network.Mattermost.Exceptions
-import           Network.Mattermost.Lenses
-import           Network.Mattermost.Types
-import           Network.Mattermost.Types.Config
-import           Network.Mattermost.WebSocket ( WebsocketEvent, WebsocketActionResponse )
-
-import           Constants ( userSigil, normalChannelSigil )
-import           InputHistory
-import           Emoji
-import           Types.Common
-import           Types.Channels
-import           Types.DirectionalSeq ( emptyDirSeq )
-import           Types.KeyEvents
-import           Types.Messages
-import           Types.Posts
-import           Types.Users
-import           Zipper ( Zipper, toList, fromList, unsafeFocus )
-
-
--- * Configuration
-
--- | A user password is either given to us directly, or a command
--- which we execute to find the password.
-data PasswordSource =
-    PasswordString Text
-    | PasswordCommand Text
-    deriving (Eq, Read, Show)
-
--- | An access token source.
-data TokenSource =
-    TokenString Text
-    | TokenCommand Text
-    deriving (Eq, Read, Show)
-
--- | The type of channel list group headings. Booleans indicate whether
--- any channels in the group have unread activity.
-data ChannelListGroup =
-    ChannelGroupPublicChannels Bool
-    | ChannelGroupPrivateChannels Bool
-    | ChannelGroupDirectMessages Bool
-    deriving (Eq)
-
-channelListGroupHasUnread :: ChannelListGroup -> Bool
-channelListGroupHasUnread (ChannelGroupPublicChannels u) = u
-channelListGroupHasUnread (ChannelGroupPrivateChannels u) = u
-channelListGroupHasUnread (ChannelGroupDirectMessages u) = u
-
--- | The type of channel list entries.
-data ChannelListEntry =
-    CLChannel ChannelId
-    -- ^ A non-DM entry
-    | CLUserDM ChannelId UserId
-    -- ^ A single-user DM entry
-    | CLGroupDM ChannelId
-    -- ^ A multi-user DM entry
-    deriving (Eq, Show)
-
--- | This is how we represent the user's configuration. Most fields
--- correspond to configuration file settings (see Config.hs) but some
--- are for internal book-keeping purposes only.
-data Config =
-    Config { configUser :: Maybe Text
-           -- ^ The username to use when connecting.
-           , configHost :: Maybe Text
-           -- ^ The hostname to use when connecting.
-           , configTeam :: Maybe Text
-           -- ^ The team name to use when connecting.
-           , configPort :: Int
-           -- ^ The port to use when connecting.
-           , configUrlPath :: Maybe Text
-           -- ^ The server path to use when connecting.
-           , configPass :: Maybe PasswordSource
-           -- ^ The password source to use when connecting.
-           , configToken :: Maybe TokenSource
-           -- ^ The token source to use when connecting.
-           , configTimeFormat :: Maybe Text
-           -- ^ The format string for timestamps.
-           , configDateFormat :: Maybe Text
-           -- ^ The format string for dates.
-           , configTheme :: Maybe Text
-           -- ^ The name of the theme to use.
-           , configThemeCustomizationFile :: Maybe Text
-           -- ^ The path to the theme customization file, if any.
-           , configSmartBacktick :: Bool
-           -- ^ Whether to enable smart quoting characters.
-           , configSmartEditing :: Bool
-           -- ^ Whether to enable smart editing behaviors.
-           , configURLOpenCommand :: Maybe Text
-           -- ^ The command to use to open URLs.
-           , configURLOpenCommandInteractive :: Bool
-           -- ^ Whether the URL-opening command is interactive (i.e.
-           -- whether it should be given control of the terminal).
-           , configActivityNotifyCommand :: Maybe T.Text
-           -- ^ The command to run for activity notifications.
-           , configActivityBell :: Bool
-           -- ^ Whether to ring the terminal bell on activity.
-           , configShowBackground :: BackgroundInfo
-           -- ^ Whether to show async background worker thread info.
-           , configShowMessagePreview :: Bool
-           -- ^ Whether to show the message preview area.
-           , configShowChannelList :: Bool
-           -- ^ Whether to show the channel list.
-           , configEnableAspell :: Bool
-           -- ^ Whether to enable Aspell spell checking.
-           , configAspellDictionary :: Maybe Text
-           -- ^ A specific Aspell dictionary name to use.
-           , configUnsafeUseHTTP :: Bool
-           -- ^ Whether to permit an insecure HTTP connection.
-           , configValidateServerCertificate :: Bool
-           -- ^ Whether to validate TLS certificates.
-           , configChannelListWidth :: Int
-           -- ^ The width, in columns, of the channel list sidebar.
-           , configLogMaxBufferSize :: Int
-           -- ^ The maximum size, in log entries, of the internal log
-           -- message buffer.
-           , configShowOlderEdits :: Bool
-           -- ^ Whether to highlight the edit indicator on edits made
-           -- prior to the beginning of the current session.
-           , configShowTypingIndicator :: Bool
-           -- ^ Whether to show the typing indicator for other users,
-           -- and whether to send typing notifications to other users.
-           , configAbsPath :: Maybe FilePath
-           -- ^ A book-keeping field for the absolute path to the
-           -- configuration. (Not a user setting.)
-           , configUserKeys :: KeyConfig
-           -- ^ The user's keybinding configuration.
-           , configHyperlinkingMode :: Bool
-           -- ^ Whether to enable terminal hyperlinking mode.
-           , configSyntaxDirs :: [FilePath]
-           -- ^ The search path for syntax description XML files.
-           , configDirectChannelExpirationDays :: Int
-           -- ^ The number of days to show a user in the channel menu after a direct
-           -- message with them.
-           , configCpuUsagePolicy :: CPUUsagePolicy
-           -- ^ The CPU usage policy for the application.
-           , configDefaultAttachmentPath :: Maybe FilePath
-           -- ^ The default path for browsing attachments
-           , configMessageSelectAfterURLOpen :: Bool
-           -- ^ Whether to remain in message selection mode after
-           -- opening URL(s)
-           } deriving (Eq, Show)
-
--- | The policy for CPU usage.
---
--- The idea is that Matterhorn can benefit from using multiple CPUs,
--- but the exact number is application-determined. We expose this policy
--- setting to the user in the configuration.
-data CPUUsagePolicy =
-    SingleCPU
-    -- ^ Constrain the application to use one CPU.
-    | MultipleCPUs
-    -- ^ Permit the usage of multiple CPUs (the exact number is
-    -- determined by the application).
-    deriving (Eq, Show)
-
--- | The state of the UI diagnostic indicator for the async worker
--- thread.
-data BackgroundInfo =
-    Disabled
-    -- ^ Disable (do not show) the indicator.
-    | Active
-    -- ^ Show the indicator when the thread is working.
-    | ActiveCount
-    -- ^ Show the indicator when the thread is working, but include the
-    -- thread's work queue length.
-    deriving (Eq, Show)
-
-data UserPreferences =
-    UserPreferences { _userPrefShowJoinLeave     :: Bool
-                    , _userPrefFlaggedPostList   :: Seq FlaggedPost
-                    , _userPrefGroupChannelPrefs :: HashMap ChannelId Bool
-                    , _userPrefDirectChannelPrefs :: HashMap UserId Bool
-                    , _userPrefTeammateNameDisplayMode :: Maybe TeammateNameDisplayMode
-                    }
-
-hasUnread :: ChatState -> ChannelId -> Bool
-hasUnread st cId = fromMaybe False $
-    hasUnread' <$> findChannelById cId (_csChannels st)
-
-hasUnread' :: ClientChannel -> Bool
-hasUnread' chan = fromMaybe False $ do
-    let info = _ccInfo chan
-    lastViewTime <- _cdViewed info
-    return $ _cdMentionCount info > 0 ||
-             (not (isMuted chan) &&
-              (((_cdUpdated info) > lastViewTime) ||
-               (isJust $ _cdEditedMessageThreshold info)))
-
-mkChannelZipperList :: UTCTime
-                    -> Config
-                    -> Maybe ClientConfig
-                    -> UserPreferences
-                    -> ClientChannels
-                    -> Users
-                    -> [(ChannelListGroup, [ChannelListEntry])]
-mkChannelZipperList now config cconfig prefs cs us =
-    [ let (anyUnread, entries) = getChannelEntriesInOrder cs Ordinary
-      in (ChannelGroupPublicChannels anyUnread, entries)
-    , let (anyUnread, entries) = getChannelEntriesInOrder cs Private
-      in (ChannelGroupPrivateChannels anyUnread, entries)
-    , (ChannelGroupDirectMessages False, getDMChannelEntriesInOrder now config cconfig prefs us cs)
-    ]
-
-getChannelEntriesInOrder :: ClientChannels -> Type -> (Bool, [ChannelListEntry])
-getChannelEntriesInOrder cs ty =
-    let matches (_, info) = info^.ccInfo.cdType == ty
-        pairs = filteredChannels matches cs
-        anyUnread = or $ (hasUnread' . snd) <$> pairs
-        entries = fmap (CLChannel . fst) $
-                  sortBy (comparing ((^.ccInfo.cdDisplayName.to T.toLower) . snd)) pairs
-    in (anyUnread, entries)
-
-getDMChannelEntriesInOrder :: UTCTime
-                           -> Config
-                           -> Maybe ClientConfig
-                           -> UserPreferences
-                           -> Users
-                           -> ClientChannels
-                           -> [ChannelListEntry]
-getDMChannelEntriesInOrder now config cconfig prefs us cs =
-    let oneOnOneDmChans = getDMChannelEntries now config cconfig prefs us cs
-        groupChans = getGroupDMChannelEntries now config prefs cs
-        allDmChans = groupChans <> oneOnOneDmChans
-        sorter (u1, n1, _) (u2, n2, _) =
-            if u1 == u2
-            then compare n1 n2
-            else if u1 && not u2
-                 then LT
-                 else GT
-        sorted = sortBy sorter allDmChans
-        third (_, _, c) = c
-    in third <$> sorted
-
-useNickname' :: Maybe ClientConfig -> UserPreferences -> Bool
-useNickname' clientConfig prefs =
-    let serverSetting = case clientConfig^?_Just.to clientConfigTeammateNameDisplay of
-            Just TMNicknameOrFullname -> Just True
-            _                         -> Nothing
-        accountSetting = (== TMNicknameOrFullname) <$> (_userPrefTeammateNameDisplayMode prefs)
-        fallback = False
-    in fromMaybe fallback $ accountSetting <|> serverSetting
-
-displayNameForUser :: UserInfo -> Maybe ClientConfig -> UserPreferences -> Text
-displayNameForUser u clientConfig prefs
-    | useNickname' clientConfig prefs =
-        fromMaybe (u^.uiName) (u^.uiNickName)
-    | otherwise =
-        u^.uiName
-
-getGroupDMChannelEntries :: UTCTime
-                         -> Config
-                         -> UserPreferences
-                         -> ClientChannels
-                         -> [(Bool, T.Text, ChannelListEntry)]
-getGroupDMChannelEntries now config prefs cs =
-    let matches (_, info) = info^.ccInfo.cdType == Group &&
-                            groupChannelShouldAppear now config prefs info
-    in fmap (\(cId, ch) -> (hasUnread' ch, ch^.ccInfo.cdDisplayName, CLGroupDM cId)) $
-       filteredChannels matches cs
-
-getDMChannelEntries :: UTCTime
-                    -> Config
-                    -> Maybe ClientConfig
-                    -> UserPreferences
-                    -> Users
-                    -> ClientChannels
-                    -> [(Bool, T.Text, ChannelListEntry)]
-getDMChannelEntries now config cconfig prefs us cs =
-    let mapping = allDmChannelMappings cs
-        mappingWithUserInfo = catMaybes $ getInfo <$> mapping
-        getInfo (uId, cId) = do
-            c <- findChannelById cId cs
-            u <- findUserById uId us
-            case u^.uiDeleted of
-                True -> Nothing
-                False ->
-                    if dmChannelShouldAppear now config prefs c
-                    then return (hasUnread' c, displayNameForUser u cconfig prefs, CLUserDM cId uId)
-                    else Nothing
-    in mappingWithUserInfo
-
--- Always show a DM channel if it has unread activity.
---
--- If it has no unread activity and if the preferences explicitly say to
--- hide it, hide it.
---
--- Otherwise, only show it if at least one of the other conditions are
--- met (see 'or' below).
-dmChannelShouldAppear :: UTCTime -> Config -> UserPreferences -> ClientChannel -> Bool
-dmChannelShouldAppear now config prefs c =
-    let ndays = configDirectChannelExpirationDays config
-        localCutoff = addUTCTime (nominalDay * (-(fromIntegral ndays))) now
-        cutoff = ServerTime localCutoff
-        updated = c^.ccInfo.cdUpdated
-        Just uId = c^.ccInfo.cdDMUserId
-    in if hasUnread' c || maybe False (>= localCutoff) (c^.ccInfo.cdSidebarShowOverride)
-       then True
-       else case dmChannelShowPreference prefs uId of
-           Just False -> False
-           _ -> or [
-                   -- The channel was updated recently enough
-                     updated >= cutoff
-                   ]
-
-groupChannelShouldAppear :: UTCTime -> Config -> UserPreferences -> ClientChannel -> Bool
-groupChannelShouldAppear now config prefs c =
-    let ndays = configDirectChannelExpirationDays config
-        localCutoff = addUTCTime (nominalDay * (-(fromIntegral ndays))) now
-        cutoff = ServerTime localCutoff
-        updated = c^.ccInfo.cdUpdated
-    in if hasUnread' c || maybe False (>= localCutoff) (c^.ccInfo.cdSidebarShowOverride)
-       then True
-       else case groupChannelShowPreference prefs (c^.ccInfo.cdChannelId) of
-           Just False -> False
-           _ -> or [
-                   -- The channel was updated recently enough
-                     updated >= cutoff
-                   ]
-
-dmChannelShowPreference :: UserPreferences -> UserId -> Maybe Bool
-dmChannelShowPreference ps uId = HM.lookup uId (_userPrefDirectChannelPrefs ps)
-
-groupChannelShowPreference :: UserPreferences -> ChannelId -> Maybe Bool
-groupChannelShowPreference ps cId = HM.lookup cId (_userPrefGroupChannelPrefs ps)
-
--- * Internal Names and References
-
--- | This 'Name' type is the type used in 'brick' to identify various
--- parts of the interface.
-data Name =
-    ChannelMessages ChannelId
-    | MessageInput
-    | ChannelList
-    | HelpViewport
-    | HelpText
-    | ScriptHelpText
-    | ThemeHelpText
-    | SyntaxHighlightHelpText
-    | KeybindingHelpText
-    | ChannelSelectString
-    | CompletionAlternatives
-    | CompletionList
-    | JoinChannelList
-    | UrlList
-    | MessagePreviewViewport
-    | ThemeListSearchInput
-    | UserListSearchInput
-    | JoinChannelListSearchInput
-    | UserListSearchResults
-    | ThemeListSearchResults
-    | ViewMessageArea
-    | ViewMessageReactionsArea
-    | ChannelSidebar
-    | ChannelSelectInput
-    | AttachmentList
-    | AttachmentFileBrowser
-    | MessageReactionsArea
-    | ReactionEmojiList
-    | ReactionEmojiListInput
-    | TabbedWindowTabBar
-    | MuteToggleField
-    | ChannelMentionsField
-    | DesktopNotificationsField (WithDefault NotifyOption)
-    | PushNotificationsField (WithDefault NotifyOption)
-    deriving (Eq, Show, Ord)
-
--- | The sum type of exceptions we expect to encounter on authentication
--- failure. We encode them explicitly here so that we can print them in
--- a more user-friendly manner than just 'show'.
-data AuthenticationException =
-    ConnectError HostCannotConnect
-    | ResolveError HostNotResolved
-    | AuthIOError IOError
-    | LoginError LoginFailureException
-    | OtherAuthError SomeException
-    deriving (Show)
-
--- | Our 'ConnectionInfo' contains exactly as much information as is
--- necessary to start a connection with a Mattermost server. This is
--- built up during interactive authentication and then is used to log
--- in.
---
--- If the access token field is non-empty, that value is used and the
--- username and password values are ignored.
-data ConnectionInfo =
-    ConnectionInfo { _ciHostname :: Text
-                   , _ciPort     :: Int
-                   , _ciUrlPath  :: Text
-                   , _ciUsername :: Text
-                   , _ciPassword :: Text
-                   , _ciAccessToken :: Text
-                   , _ciType     :: ConnectionType
-                   }
-
--- | We want to continue referring to posts by their IDs, but we don't
--- want to have to synthesize new valid IDs for messages from the client
--- itself (like error messages or informative client responses). To that
--- end, a PostRef can be either a PostId or a newly-generated client ID.
-data PostRef
-    = MMId PostId
-    | CLId Int
-    deriving (Eq, Show)
-
--- ** Channel-matching types
-
--- | A match in channel selection mode.
-data ChannelSelectMatch =
-    ChannelSelectMatch { nameBefore :: Text
-                       -- ^ The content of the match before the user's
-                       -- matching input.
-                       , nameMatched :: Text
-                       -- ^ The potion of the name that matched the
-                       -- user's input.
-                       , nameAfter :: Text
-                       -- ^ The portion of the name that came after the
-                       -- user's matching input.
-                       , matchFull :: Text
-                       -- ^ The full string for this entry so it doesn't
-                       -- have to be reassembled from the parts above.
-                       , matchEntry :: ChannelListEntry
-                       -- ^ The original entry data corresponding to the
-                       -- text match.
-                       }
-                       deriving (Eq, Show)
-
-data ChannelSelectPattern = CSP MatchType Text
-                          | CSPAny
-                          deriving (Eq, Show)
-
-data MatchType =
-    Prefix
-    | Suffix
-    | Infix
-    | Equal
-    | PrefixDMOnly
-    | PrefixNonDMOnly
-    deriving (Eq, Show)
-
--- * Application State Values
-
-data ProgramOutput =
-    ProgramOutput { program :: FilePath
-                  , programArgs :: [String]
-                  , programStdout :: String
-                  , programStdoutExpected :: Bool
-                  , programStderr :: String
-                  , programExitCode :: ExitCode
-                  }
-
-defaultUserPreferences :: UserPreferences
-defaultUserPreferences =
-    UserPreferences { _userPrefShowJoinLeave     = True
-                    , _userPrefFlaggedPostList   = mempty
-                    , _userPrefGroupChannelPrefs = mempty
-                    , _userPrefDirectChannelPrefs = mempty
-                    , _userPrefTeammateNameDisplayMode = Nothing
-                    }
-
-setUserPreferences :: Seq Preference -> UserPreferences -> UserPreferences
-setUserPreferences = flip (F.foldr go)
-    where go p u
-            | Just fp <- preferenceToFlaggedPost p =
-              u { _userPrefFlaggedPostList =
-                  _userPrefFlaggedPostList u Seq.|> fp
-                }
-            | Just gp <- preferenceToDirectChannelShowStatus p =
-              u { _userPrefDirectChannelPrefs =
-                  HM.insert
-                    (directChannelShowUserId gp)
-                    (directChannelShowValue gp)
-                    (_userPrefDirectChannelPrefs u)
-                }
-            | Just gp <- preferenceToGroupChannelPreference p =
-              u { _userPrefGroupChannelPrefs =
-                  HM.insert
-                    (groupChannelId gp)
-                    (groupChannelShow gp)
-                    (_userPrefGroupChannelPrefs u)
-                }
-            | preferenceName p == PreferenceName "join_leave" =
-              u { _userPrefShowJoinLeave =
-                  preferenceValue p /= PreferenceValue "false" }
-            | preferenceCategory p == PreferenceCategoryDisplaySettings &&
-              preferenceName p == PreferenceName "name_format" =
-                  let PreferenceValue txt = preferenceValue p
-                  in u { _userPrefTeammateNameDisplayMode = Just $ teammateDisplayModeFromText txt }
-            | otherwise = u
-
--- | Log message tags.
-data LogCategory =
-    LogGeneral
-    | LogAPI
-    | LogWebsocket
-    | LogError
-    | LogUserMark
-    deriving (Eq, Show)
-
--- | A log message.
-data LogMessage =
-    LogMessage { logMessageText :: !Text
-               -- ^ The text of the log message.
-               , logMessageContext :: !(Maybe LogContext)
-               -- ^ The optional context information relevant to the log
-               -- message.
-               , logMessageCategory :: !LogCategory
-               -- ^ The category of the log message.
-               , logMessageTimestamp :: !UTCTime
-               -- ^ The timestamp of the log message.
-               }
-               deriving (Eq, Show)
-
--- | A logging thread command.
-data LogCommand =
-    LogToFile FilePath
-    -- ^ Start logging to the specified path.
-    | LogAMessage !LogMessage
-    -- ^ Log the specified message.
-    | StopLogging
-    -- ^ Stop any active logging.
-    | ShutdownLogging
-    -- ^ Shut down.
-    | GetLogDestination
-    -- ^ Ask the logging thread about its active logging destination.
-    | LogSnapshot FilePath
-    -- ^ Ask the logging thread to dump the current buffer to the
-    -- specified destination.
-    deriving (Show)
-
--- | A handle to the log manager thread.
-data LogManager =
-    LogManager { logManagerCommandChannel :: STM.TChan LogCommand
-               , logManagerHandle :: Async ()
-               }
-
-startLoggingToFile :: LogManager -> FilePath -> IO ()
-startLoggingToFile mgr loc = sendLogCommand mgr $ LogToFile loc
-
-stopLoggingToFile :: LogManager -> IO ()
-stopLoggingToFile mgr = sendLogCommand mgr StopLogging
-
-requestLogSnapshot :: LogManager -> FilePath -> IO ()
-requestLogSnapshot mgr path = sendLogCommand mgr $ LogSnapshot path
-
-requestLogDestination :: LogManager -> IO ()
-requestLogDestination mgr = sendLogCommand mgr GetLogDestination
-
-sendLogMessage :: LogManager -> LogMessage -> IO ()
-sendLogMessage mgr lm = sendLogCommand mgr $ LogAMessage lm
-
-sendLogCommand :: LogManager -> LogCommand -> IO ()
-sendLogCommand mgr c =
-    STM.atomically $ STM.writeTChan (logManagerCommandChannel mgr) c
-
--- | 'ChatResources' represents configuration and connection-related
--- information, as opposed to current model or view information.
--- Information that goes in the 'ChatResources' value should be limited
--- to information that we read or set up prior to setting up the bulk of
--- the application state.
-data ChatResources =
-    ChatResources { _crSession             :: Session
-                  , _crWebsocketThreadId   :: Maybe ThreadId
-                  , _crConn                :: ConnectionData
-                  , _crRequestQueue        :: RequestChan
-                  , _crEventQueue          :: BCH.BChan MHEvent
-                  , _crSubprocessLog       :: STM.TChan ProgramOutput
-                  , _crWebsocketActionChan :: STM.TChan WebsocketAction
-                  , _crTheme               :: AttrMap
-                  , _crStatusUpdateChan    :: STM.TChan [UserId]
-                  , _crConfiguration       :: Config
-                  , _crFlaggedPosts        :: Set PostId
-                  , _crUserPreferences     :: UserPreferences
-                  , _crSyntaxMap           :: SyntaxMap
-                  , _crLogManager          :: LogManager
-                  , _crEmoji               :: EmojiCollection
-                  }
-
--- | A "special" mention that does not map to a specific user, but is an
--- alias that the server uses to notify users.
-data SpecialMention =
-    MentionAll
-    -- ^ @all: notify everyone in the channel.
-    | MentionChannel
-    -- ^ @channel: notify everyone in the channel.
-
-data AutocompleteAlternative =
-    UserCompletion User Bool
-    -- ^ User, plus whether the user is in the channel that triggered
-    -- the autocomplete
-    | SpecialMention SpecialMention
-    -- ^ A special mention.
-    | ChannelCompletion Bool Channel
-    -- ^ Channel, plus whether the user is a member of the channel
-    | SyntaxCompletion Text
-    -- ^ Name of a skylighting syntax definition
-    | CommandCompletion Text Text Text
-    -- ^ Name of a slash command, argspec, and description
-    | EmojiCompletion Text
-    -- ^ The text of an emoji completion
-
-specialMentionName :: SpecialMention -> Text
-specialMentionName MentionChannel = "channel"
-specialMentionName MentionAll = "all"
-
-isSpecialMention :: T.Text -> Bool
-isSpecialMention n = isJust $ lookup (T.toLower $ trimUserSigil n) pairs
-    where
-        pairs = mkPair <$> mentions
-        mentions = [ MentionChannel
-                   , MentionAll
-                   ]
-        mkPair v = (specialMentionName v, v)
-
-autocompleteAlternativeReplacement :: AutocompleteAlternative -> Text
-autocompleteAlternativeReplacement (EmojiCompletion e) =
-    ":" <> e <> ":"
-autocompleteAlternativeReplacement (SpecialMention m) =
-    userSigil <> specialMentionName m
-autocompleteAlternativeReplacement (UserCompletion u _) =
-    userSigil <> userUsername u
-autocompleteAlternativeReplacement (ChannelCompletion _ c) =
-    normalChannelSigil <> (sanitizeUserText $ channelName c)
-autocompleteAlternativeReplacement (SyntaxCompletion t) =
-    "```" <> t
-autocompleteAlternativeReplacement (CommandCompletion t _ _) =
-    "/" <> t
-
--- | The type of data that the autocompletion logic supports. We use
--- this to track the kind of completion underway in case the type of
--- completion needs to change.
-data AutocompletionType =
-    ACUsers
-    | ACChannels
-    | ACCodeBlockLanguage
-    | ACEmoji
-    | ACCommands
-    deriving (Eq, Show)
-
-data AutocompleteState =
-    AutocompleteState { _acPreviousSearchString :: Text
-                      -- ^ The search string used for the
-                      -- currently-displayed autocomplete results, for
-                      -- use in deciding whether to issue another server
-                      -- query
-                      , _acCompletionList :: List Name AutocompleteAlternative
-                      -- ^ The list of alternatives that the user
-                      -- selects from
-                      , _acType :: AutocompletionType
-                      -- ^ The type of data that we're completing
-                      , _acCachedResponses :: HM.HashMap Text [AutocompleteAlternative]
-                      -- ^ A cache of alternative lists, keyed on search
-                      -- string, for use in avoiding server requests.
-                      -- The idea here is that users type quickly enough
-                      -- (and edit their input) that would normally lead
-                      -- to rapid consecutive requests, some for the
-                      -- same strings during editing, that we can avoid
-                      -- that by caching them here. Note that this cache
-                      -- gets destroyed whenever autocompletion is not
-                      -- on, so this cache does not live very long.
-                      }
-
--- | The 'ChatEditState' value contains the editor widget itself as well
--- as history and metadata we need for editing-related operations.
-data ChatEditState =
-    ChatEditState { _cedEditor :: Editor Text Name
-                  , _cedEditMode :: EditMode
-                  , _cedEphemeral :: EphemeralEditState
-                  , _cedInputHistory :: InputHistory
-                  -- ^ The map of per-channel input history for the
-                  -- application. We don't distribute the per-channel
-                  -- history into the per-channel states (like we do
-                  -- for other per-channel state) since keeping it
-                  -- under the InputHistory banner lets us use a nicer
-                  -- startup/shutdown disk file management API.
-                  , _cedYankBuffer :: Text
-                  , _cedSpellChecker :: Maybe (Aspell, IO ())
-                  , _cedMisspellings :: Set Text
-                  , _cedAutocomplete :: Maybe AutocompleteState
-                  -- ^ The autocomplete state. The autocompletion UI is
-                  -- showing only when this state is present.
-                  , _cedAutocompletePending :: Maybe Text
-                  -- ^ The search string associated with the latest
-                  -- in-flight autocompletion request. This is used to
-                  -- determine whether any (potentially late-arriving)
-                  -- API responses are for stale queries since the user
-                  -- can type more quickly than the server can get us
-                  -- the results, and we wouldn't want to show results
-                  -- associated with old editor states.
-                  , _cedAttachmentList :: List Name AttachmentData
-                  -- ^ The list of attachments to be uploaded with the
-                  -- post being edited.
-                  , _cedFileBrowser :: Maybe (FB.FileBrowser Name)
-                  -- ^ The browser for selecting attachment files.
-                  -- This is a Maybe because the instantiation of the
-                  -- FileBrowser causes it to read and ingest the
-                  -- target directory, so this action is deferred
-                  -- until the browser is needed.
-                  , _cedJustCompleted :: Bool
-                  -- A flag that indicates whether the most recent
-                  -- editing event was a tab-completion. This is used by
-                  -- the smart trailing space handling.
-                  }
-
--- | An attachment.
-data AttachmentData =
-    AttachmentData { attachmentDataFileInfo :: FB.FileInfo
-                   , attachmentDataBytes :: BS.ByteString
-                   }
-                   deriving (Eq, Show)
-
--- | We can initialize a new 'ChatEditState' value with just an edit
--- history, which we save locally.
-emptyEditState :: InputHistory -> Maybe (Aspell, IO ()) -> IO ChatEditState
-emptyEditState hist sp =
-    return ChatEditState { _cedEditor               = editor MessageInput Nothing ""
-                         , _cedEphemeral            = defaultEphemeralEditState
-                         , _cedInputHistory         = hist
-                         , _cedEditMode             = NewPost
-                         , _cedYankBuffer           = ""
-                         , _cedSpellChecker         = sp
-                         , _cedMisspellings         = mempty
-                         , _cedAutocomplete         = Nothing
-                         , _cedAutocompletePending  = Nothing
-                         , _cedAttachmentList       = list AttachmentList mempty 1
-                         , _cedFileBrowser          = Nothing
-                         , _cedJustCompleted        = False
-                         }
-
--- | A 'RequestChan' is a queue of operations we have to perform in the
--- background to avoid blocking on the main loop
-type RequestChan = STM.TChan (IO (Maybe (MH ())))
-
--- | The 'HelpScreen' type represents the set of possible 'Help'
--- dialogues we have to choose from.
-data HelpScreen =
-    MainHelp
-    | ScriptHelp
-    | ThemeHelp
-    | SyntaxHighlightHelp
-    | KeybindingHelp
-    deriving (Eq)
-
--- | Help topics
-data HelpTopic =
-    HelpTopic { helpTopicName         :: Text
-              , helpTopicDescription  :: Text
-              , helpTopicScreen       :: HelpScreen
-              , helpTopicViewportName :: Name
-              }
-              deriving (Eq)
-
--- | Mode type for the current contents of the post list overlay
-data PostListContents =
-    PostListFlagged
-    | PostListPinned ChannelId
-    | PostListSearch Text Bool -- for the query and search status
-    deriving (Eq)
-
--- | The 'Mode' represents the current dominant UI activity
-data Mode =
-    Main
-    | ShowHelp HelpTopic Mode
-    | ChannelSelect
-    | UrlSelect
-    | LeaveChannelConfirm
-    | DeleteChannelConfirm
-    | MessageSelect
-    | MessageSelectDeleteConfirm
-    | PostListOverlay PostListContents
-    | UserListOverlay
-    | ReactionEmojiListOverlay
-    | ChannelListOverlay
-    | ThemeListOverlay
-    | ViewMessage
-    | ManageAttachments
-    | ManageAttachmentsBrowseFiles
-    | EditNotifyPrefs
-    deriving (Eq)
-
--- | We're either connected or we're not.
-data ConnectionStatus = Connected | Disconnected deriving (Eq)
-
--- | An entry in a tabbed window corresponding to a tab and its content.
--- Parameterized over an abstract handle type ('a') for the tabs so we
--- can give each a unique handle.
-data TabbedWindowEntry a =
-    TabbedWindowEntry { tweValue :: a
-                      -- ^ The handle for this tab.
-                      , tweRender :: a -> ChatState -> Widget Name
-                      -- ^ The rendering function to use when this tab
-                      -- is selected.
-                      , tweHandleEvent :: a -> Vty.Event -> MH ()
-                      -- ^ The event-handling function to use when this
-                      -- tab is selected.
-                      , tweTitle :: a -> Bool -> T.Text
-                      -- ^ Title function for this tab, with a boolean
-                      -- indicating whether this is the current tab.
-                      , tweShowHandler :: a -> MH ()
-                      -- ^ A handler to be invoked when this tab is
-                      -- shown.
-                      }
-
--- | The definition of a tabbed window. Note that this does not track
--- the *state* of the window; it merely provides a collection of tab
--- window entries (see above). To track the state of a tabbed window,
--- use a TabbedWindow.
---
--- Parameterized over an abstract handle type ('a') for the tabs so we
--- can give each a unique handle.
-data TabbedWindowTemplate a =
-    TabbedWindowTemplate { twtEntries :: [TabbedWindowEntry a]
-                         -- ^ The entries in tabbed windows with this
-                         -- structure.
-                         , twtTitle :: a -> Widget Name
-                         -- ^ The title-rendering function for this kind
-                         -- of tabbed window.
-                         }
-
--- | An instantiated tab window. This is based on a template and tracks
--- the state of the tabbed window (current tab).
---
--- Parameterized over an abstract handle type ('a') for the tabs so we
--- can give each a unique handle.
-data TabbedWindow a =
-    TabbedWindow { twValue :: a
-                 -- ^ The handle of the currently-selected tab.
-                 , twReturnMode :: Mode
-                 -- ^ The mode to return to when the tab is closed.
-                 , twTemplate :: TabbedWindowTemplate a
-                 -- ^ The template to use as a basis for rendering the
-                 -- window and handling user input.
-                 , twWindowWidth :: Int
-                 , twWindowHeight :: Int
-                 -- ^ Window dimensions
-                 }
-
--- | Construct a new tabbed window from a template. This will raise an
--- exception if the initially-selected tab does not exist in the window
--- template, or if the window template has any duplicated tab handles.
---
--- Note that the caller is responsible for determining whether to call
--- the initially-selected tab's on-show handler.
-tabbedWindow :: (Show a, Eq a)
-             => a
-             -- ^ The handle corresponding to the tab that should be
-             -- selected initially.
-             -> TabbedWindowTemplate a
-             -- ^ The template for the window to construct.
-             -> Mode
-             -- ^ When the window is closed, return to this application
-             -- mode.
-             -> (Int, Int)
-             -- ^ The window dimensions (width, height).
-             -> TabbedWindow a
-tabbedWindow initialVal t retMode (width, height) =
-    let handles = tweValue <$> twtEntries t
-    in if | null handles ->
-              error "BUG: tabbed window template must provide at least one entry"
-          | length handles /= length (nub handles) ->
-              error "BUG: tabbed window should have one entry per handle"
-          | not (initialVal `elem` handles) ->
-              error $ "BUG: tabbed window handle " <>
-                      show initialVal <> " not present in template"
-          | otherwise ->
-              TabbedWindow { twTemplate = t
-                           , twValue = initialVal
-                           , twReturnMode = retMode
-                           , twWindowWidth = width
-                           , twWindowHeight = height
-                           }
-
--- | Get the currently-selected tab entry for a tabbed window. Raise
--- an exception if the window's selected tab handle is not found in its
--- template (which is a bug in the tabbed window infrastructure).
-getCurrentTabbedWindowEntry :: (Show a, Eq a)
-                            => TabbedWindow a
-                            -> TabbedWindowEntry a
-getCurrentTabbedWindowEntry w =
-    lookupTabbedWindowEntry (twValue w) w
-
--- | Run the on-show handler for the window tab entry with the specified
--- handle.
-runTabShowHandlerFor :: (Eq a, Show a) => a -> TabbedWindow a -> MH ()
-runTabShowHandlerFor handle w = do
-    let entry = lookupTabbedWindowEntry handle w
-    tweShowHandler entry handle
-
--- | Look up a tabbed window entry by handle. Raises an exception if no
--- such entry exists.
-lookupTabbedWindowEntry :: (Eq a, Show a)
-                        => a
-                        -> TabbedWindow a
-                        -> TabbedWindowEntry a
-lookupTabbedWindowEntry handle w =
-    let matchesVal e = tweValue e == handle
-    in case filter matchesVal (twtEntries $ twTemplate w) of
-        [e] -> e
-        _ -> error $ "BUG: tabbed window entry for " <> show (twValue w) <>
-                     " should have matched a single entry"
-
--- | Switch a tabbed window's selected tab to its next tab, cycling back
--- to the first tab if the last tab is the selected tab. This also
--- invokes the on-show handler for the newly-selected tab.
---
--- Note that this does nothing if the window has only one tab.
-tabbedWindowNextTab :: (Show a, Eq a)
-                    => TabbedWindow a
-                    -> MH (TabbedWindow a)
-tabbedWindowNextTab w | length (twtEntries $ twTemplate w) == 1 = return w
-tabbedWindowNextTab w = do
-    let curIdx = case elemIndex (tweValue curEntry) allHandles of
-            Nothing ->
-                error $ "BUG: tabbedWindowNextTab: could not find " <>
-                        "current handle in handle list"
-            Just i -> i
-        nextIdx = if curIdx == length allHandles - 1
-                  then 0
-                  else curIdx + 1
-        newHandle = allHandles !! nextIdx
-        allHandles = tweValue <$> twtEntries (twTemplate w)
-        curEntry = getCurrentTabbedWindowEntry w
-        newWin = w { twValue = newHandle }
-
-    runTabShowHandlerFor newHandle newWin
-    return newWin
-
--- | Switch a tabbed window's selected tab to its previous tab, cycling
--- to the last tab if the first tab is the selected tab. This also
--- invokes the on-show handler for the newly-selected tab.
---
--- Note that this does nothing if the window has only one tab.
-tabbedWindowPreviousTab :: (Show a, Eq a)
-                        => TabbedWindow a
-                        -> MH (TabbedWindow a)
-tabbedWindowPreviousTab w | length (twtEntries $ twTemplate w) == 1 = return w
-tabbedWindowPreviousTab w = do
-    let curIdx = case elemIndex (tweValue curEntry) allHandles of
-            Nothing ->
-                error $ "BUG: tabbedWindowPreviousTab: could not find " <>
-                        "current handle in handle list"
-            Just i -> i
-        nextIdx = if curIdx == 0
-                  then length allHandles - 1
-                  else curIdx - 1
-        newHandle = allHandles !! nextIdx
-        allHandles = tweValue <$> twtEntries (twTemplate w)
-        curEntry = getCurrentTabbedWindowEntry w
-        newWin = w { twValue = newHandle }
-
-    runTabShowHandlerFor newHandle newWin
-    return newWin
-
--- | This is the giant bundle of fields that represents the current
--- state of our application at any given time.
-data ChatState =
-    ChatState { _csResources :: ChatResources
-              -- ^ Global application-wide resources that don't change
-              -- much.
-              , _csFocus :: Zipper ChannelListGroup ChannelListEntry
-              -- ^ The channel sidebar zipper that tracks which channel
-              -- is selected.
-              , _csMe :: User
-              -- ^ The authenticated user.
-              , _csMyTeam :: Team
-              -- ^ The active team of the authenticated user.
-              , _csChannels :: ClientChannels
-              -- ^ The channels that we are showing, including their
-              -- message lists.
-              , _csPostMap :: HashMap PostId Message
-              -- ^ The map of post IDs to messages. This allows us to
-              -- access messages by ID without having to linearly scan
-              -- channel message lists.
-              , _csUsers :: Users
-              -- ^ All of the users we know about.
-              , _timeZone :: TimeZoneSeries
-              -- ^ The client time zone.
-              , _csEditState :: ChatEditState
-              -- ^ The state of the input box used for composing and
-              -- editing messages and commands.
-              , _csMode :: Mode
-              -- ^ The current application mode. This is used to
-              -- dispatch to different rendering and event handling
-              -- routines.
-              , _csShowMessagePreview :: Bool
-              -- ^ Whether to show the message preview area.
-              , _csShowChannelList :: Bool
-              -- ^ Whether to show the channel list.
-              , _csChannelSelectState :: ChannelSelectState
-              -- ^ The state of the user's input and selection for
-              -- channel selection mode.
-              , _csRecentChannel :: Maybe ChannelId
-              -- ^ The most recently-selected channel, if any.
-              , _csReturnChannel :: Maybe ChannelId
-              -- ^ The channel to return to after visiting one or more
-              -- unread channels.
-              , _csUrlList :: List Name LinkChoice
-              -- ^ The URL list used to show URLs drawn from messages in
-              -- a channel.
-              , _csConnectionStatus :: ConnectionStatus
-              -- ^ Our view of the connection status.
-              , _csWorkerIsBusy :: Maybe (Maybe Int)
-              -- ^ Whether the async worker thread is busy, and its
-              -- queue length if so.
-              , _csMessageSelect :: MessageSelectState
-              -- ^ The state of message selection mode.
-              , _csThemeListOverlay :: ListOverlayState InternalTheme ()
-              -- ^ The state of the theme list overlay.
-              , _csPostListOverlay :: PostListOverlayState
-              -- ^ The state of the post list overlay.
-              , _csUserListOverlay :: ListOverlayState UserInfo UserSearchScope
-              -- ^ The state of the user list overlay.
-              , _csChannelListOverlay :: ListOverlayState Channel ChannelSearchScope
-              -- ^ The state of the user list overlay.
-              , _csReactionEmojiListOverlay :: ListOverlayState (Bool, T.Text) ()
-              -- ^ The state of the reaction emoji list overlay.
-              , _csClientConfig :: Maybe ClientConfig
-              -- ^ The Mattermost client configuration, as we understand it.
-              , _csPendingChannelChange :: Maybe PendingChannelChange
-              -- ^ A pending channel change that we need to apply once
-              -- the channel in question is available. We set this up
-              -- when we need to change to a channel in the sidebar, but
-              -- it isn't even there yet because we haven't loaded its
-              -- metadata.
-              , _csViewedMessage :: Maybe (Message, TabbedWindow ViewMessageWindowTab)
-              -- ^ Set when the ViewMessage mode is active. The message
-              -- being viewed. Note that this stores a message, not
-              -- a message ID. That's because not all messages have
-              -- message IDs (e.g. client messages) and we still
-              -- want to support viewing of those messages. It's the
-              -- responsibility of code that uses this message to always
-              -- consult the chat state for the latest *version* of any
-              -- message with an ID here, to be sure that the latest
-              -- version is used (e.g. if it gets edited, etc.).
-              , _csNotifyPrefs :: Maybe (Form ChannelNotifyProps MHEvent Name)
-              -- ^ A form for editing the notification preferences for
-              -- the current channel. This is set when entering
-              -- EditNotifyPrefs mode and updated when the user
-              -- changes the form state.
-              }
-
--- | Handles for the View Message window's tabs.
-data ViewMessageWindowTab =
-    VMTabMessage
-    -- ^ The message tab.
-    | VMTabReactions
-    -- ^ The reactions tab.
-    deriving (Eq, Show)
-
-data PendingChannelChange =
-    ChangeByChannelId ChannelId
-    | ChangeByUserId UserId
-    deriving (Eq, Show)
-
--- | Startup state information that is constructed prior to building a
--- ChatState.
-data StartupStateInfo =
-    StartupStateInfo { startupStateResources      :: ChatResources
-                     , startupStateChannelZipper  :: Zipper ChannelListGroup ChannelListEntry
-                     , startupStateConnectedUser  :: User
-                     , startupStateTeam           :: Team
-                     , startupStateTimeZone       :: TimeZoneSeries
-                     , startupStateInitialHistory :: InputHistory
-                     , startupStateSpellChecker   :: Maybe (Aspell, IO ())
-                     }
-
-newState :: StartupStateInfo -> IO ChatState
-newState (StartupStateInfo {..}) = do
-    editState <- emptyEditState startupStateInitialHistory startupStateSpellChecker
-    return ChatState { _csResources                   = startupStateResources
-                     , _csFocus                       = startupStateChannelZipper
-                     , _csMe                          = startupStateConnectedUser
-                     , _csMyTeam                      = startupStateTeam
-                     , _csChannels                    = noChannels
-                     , _csPostMap                     = HM.empty
-                     , _csUsers                       = noUsers
-                     , _timeZone                      = startupStateTimeZone
-                     , _csEditState                   = editState
-                     , _csMode                        = Main
-                     , _csShowMessagePreview          = configShowMessagePreview $ _crConfiguration startupStateResources
-                     , _csShowChannelList             = configShowChannelList $ _crConfiguration startupStateResources
-                     , _csChannelSelectState          = emptyChannelSelectState
-                     , _csRecentChannel               = Nothing
-                     , _csReturnChannel               = Nothing
-                     , _csUrlList                     = list UrlList mempty 2
-                     , _csConnectionStatus            = Connected
-                     , _csWorkerIsBusy                = Nothing
-                     , _csMessageSelect               = MessageSelectState Nothing
-                     , _csThemeListOverlay            = nullThemeListOverlayState
-                     , _csPostListOverlay             = PostListOverlayState emptyDirSeq Nothing
-                     , _csUserListOverlay             = nullUserListOverlayState
-                     , _csChannelListOverlay          = nullChannelListOverlayState
-                     , _csReactionEmojiListOverlay    = nullEmojiListOverlayState
-                     , _csClientConfig                = Nothing
-                     , _csPendingChannelChange        = Nothing
-                     , _csViewedMessage               = Nothing
-                     , _csNotifyPrefs                 = Nothing
-                     }
-
-nullChannelListOverlayState :: ListOverlayState Channel ChannelSearchScope
-nullChannelListOverlayState =
-    let newList rs = list JoinChannelList rs 2
-    in ListOverlayState { _listOverlaySearchResults  = newList mempty
-                        , _listOverlaySearchInput    = editor JoinChannelListSearchInput (Just 1) ""
-                        , _listOverlaySearchScope    = AllChannels
-                        , _listOverlaySearching      = False
-                        , _listOverlayEnterHandler   = const $ return False
-                        , _listOverlayNewList        = newList
-                        , _listOverlayFetchResults   = const $ const $ const $ return mempty
-                        , _listOverlayRecordCount    = Nothing
-                        }
-
-nullThemeListOverlayState :: ListOverlayState InternalTheme ()
-nullThemeListOverlayState =
-    let newList rs = list ThemeListSearchResults rs 3
-    in ListOverlayState { _listOverlaySearchResults  = newList mempty
-                        , _listOverlaySearchInput    = editor ThemeListSearchInput (Just 1) ""
-                        , _listOverlaySearchScope    = ()
-                        , _listOverlaySearching      = False
-                        , _listOverlayEnterHandler   = const $ return False
-                        , _listOverlayNewList        = newList
-                        , _listOverlayFetchResults   = const $ const $ const $ return mempty
-                        , _listOverlayRecordCount    = Nothing
-                        }
-
-nullUserListOverlayState :: ListOverlayState UserInfo UserSearchScope
-nullUserListOverlayState =
-    let newList rs = list UserListSearchResults rs 1
-    in ListOverlayState { _listOverlaySearchResults  = newList mempty
-                        , _listOverlaySearchInput    = editor UserListSearchInput (Just 1) ""
-                        , _listOverlaySearchScope    = AllUsers Nothing
-                        , _listOverlaySearching      = False
-                        , _listOverlayEnterHandler   = const $ return False
-                        , _listOverlayNewList        = newList
-                        , _listOverlayFetchResults   = const $ const $ const $ return mempty
-                        , _listOverlayRecordCount    = Nothing
-                        }
-
-nullEmojiListOverlayState :: ListOverlayState (Bool, T.Text) ()
-nullEmojiListOverlayState =
-    let newList rs = list ReactionEmojiList rs 1
-    in ListOverlayState { _listOverlaySearchResults  = newList mempty
-                        , _listOverlaySearchInput    = editor ReactionEmojiListInput (Just 1) ""
-                        , _listOverlaySearchScope    = ()
-                        , _listOverlaySearching      = False
-                        , _listOverlayEnterHandler   = const $ return False
-                        , _listOverlayNewList        = newList
-                        , _listOverlayFetchResults   = const $ const $ const $ return mempty
-                        , _listOverlayRecordCount    = Nothing
-                        }
-
--- | The state of channel selection mode.
-data ChannelSelectState =
-    ChannelSelectState { _channelSelectInput :: Editor Text Name
-                       , _channelSelectMatches :: Zipper ChannelListGroup ChannelSelectMatch
-                       }
-
-emptyChannelSelectState :: ChannelSelectState
-emptyChannelSelectState =
-    ChannelSelectState { _channelSelectInput = editor ChannelSelectInput (Just 1) ""
-                       , _channelSelectMatches = fromList []
-                       }
-
--- | The state of message selection mode.
-data MessageSelectState =
-    MessageSelectState { selectMessageId :: Maybe MessageId
-                       }
-
--- | The state of the post list overlay.
-data PostListOverlayState =
-    PostListOverlayState { _postListPosts    :: Messages
-                         , _postListSelected :: Maybe PostId
-                         }
-
-data InternalTheme =
-    InternalTheme { internalThemeName :: Text
-                  , internalTheme :: Theme
-                  , internalThemeDesc :: Text
-                  }
-
--- | The state of the search result list overlay. Type 'a' is the type
--- of data in the list. Type 'b' is the search scope type.
-data ListOverlayState a b =
-    ListOverlayState { _listOverlaySearchResults :: List Name a
-                     -- ^ The list of search results currently shown in
-                     -- the overlay.
-                     , _listOverlaySearchInput :: Editor Text Name
-                     -- ^ The editor for the overlay's search input.
-                     , _listOverlaySearchScope :: b
-                     -- ^ The overlay's current search scope.
-                     , _listOverlaySearching :: Bool
-                     -- ^ Whether a search is in progress (i.e. whether
-                     -- we are currently awaiting a response from a
-                     -- search query to the server).
-                     , _listOverlayEnterHandler :: a -> MH Bool
-                     -- ^ The handler to invoke on the selected element
-                     -- when the user presses Enter.
-                     , _listOverlayNewList :: Vec.Vector a -> List Name a
-                     -- ^ The function to build a new brick List from a
-                     -- vector of search results.
-                     , _listOverlayFetchResults :: b -> Session -> Text -> IO (Vec.Vector a)
-                     -- ^ The function to call to issue a search query
-                     -- to the server.
-                     , _listOverlayRecordCount :: Maybe Int
-                     -- ^ The total number of available records, if known.
-                     }
-
--- | The scope for searching for users in a user list overlay.
-data UserSearchScope =
-    ChannelMembers ChannelId TeamId
-    | ChannelNonMembers ChannelId TeamId
-    | AllUsers (Maybe TeamId)
-
--- | The scope for searching for channels to join.
-data ChannelSearchScope =
-    AllChannels
-
--- | Actions that can be sent on the websocket to the server.
-data WebsocketAction =
-    UserTyping UTCTime ChannelId (Maybe PostId) -- ^ user typing in the input box
-    deriving (Read, Show, Eq, Ord)
-
--- * MH Monad
-
--- | Logging context information, in the event that metadata should
--- accompany a log message.
-data LogContext =
-    LogContext { logContextChannelId :: Maybe ChannelId
-               }
-               deriving (Eq, Show)
-
--- | A user fetching strategy.
-data UserFetch =
-    UserFetchById UserId
-    -- ^ Fetch the user with the specified ID.
-    | UserFetchByUsername Text
-    -- ^ Fetch the user with the specified username.
-    | UserFetchByNickname Text
-    -- ^ Fetch the user with the specified nickname.
-    deriving (Eq, Show)
-
-data MHState =
-    MHState { mhCurrentState :: ChatState
-            , mhNextAction :: ChatState -> EventM Name (Next ChatState)
-            , mhUsersToFetch :: [UserFetch]
-            , mhPendingStatusList :: Maybe [UserId]
-            }
-
--- | A value of type 'MH' @a@ represents a computation that can
--- manipulate the application state and also request that the
--- application quit
-newtype MH a =
-    MH { fromMH :: R.ReaderT (Maybe LogContext) (St.StateT MHState (EventM Name)) a }
-
--- | Use a modified logging context for the duration of the specified MH
--- action.
-withLogContext :: (Maybe LogContext -> Maybe LogContext) -> MH a -> MH a
-withLogContext modifyContext act =
-    MH $ R.withReaderT modifyContext (fromMH act)
-
-withLogContextChannelId :: ChannelId -> MH a -> MH a
-withLogContextChannelId cId act =
-    let f Nothing = Just $ LogContext (Just cId)
-        f (Just c) = Just $ c { logContextChannelId = Just cId }
-    in withLogContext f act
-
--- | Get the current logging context.
-getLogContext :: MH (Maybe LogContext)
-getLogContext = MH R.ask
-
--- | Log a message.
-mhLog :: LogCategory -> Text -> MH ()
-mhLog cat msg = do
-    logger <- mhGetIOLogger
-    liftIO $ logger cat msg
-
--- | Get a logger suitable for use in IO. The logger always logs using
--- the MH monad log context at the time of the call to mhGetIOLogger.
-mhGetIOLogger :: MH (LogCategory -> Text -> IO ())
-mhGetIOLogger = do
-    ctx <- getLogContext
-    mgr <- use (to (_crLogManager . _csResources))
-    return $ ioLogWithManager mgr ctx
-
-ioLogWithManager :: LogManager -> Maybe LogContext -> LogCategory -> Text -> IO ()
-ioLogWithManager mgr ctx cat msg = do
-    now <- getCurrentTime
-    let lm = LogMessage { logMessageText = msg
-                        , logMessageContext = ctx
-                        , logMessageCategory = cat
-                        , logMessageTimestamp = now
-                        }
-    sendLogMessage mgr lm
-
--- | Run an 'MM' computation, choosing whether to continue or halt based
--- on the resulting
-runMHEvent :: ChatState -> MH () -> EventM Name (Next ChatState)
-runMHEvent st (MH mote) = do
-  let mhSt = MHState { mhCurrentState = st
-                     , mhNextAction = Brick.continue
-                     , mhUsersToFetch = []
-                     , mhPendingStatusList = Nothing
-                     }
-  ((), st') <- St.runStateT (R.runReaderT mote Nothing) mhSt
-  (mhNextAction st') (mhCurrentState st')
-
-scheduleUserFetches :: [UserFetch] -> MH ()
-scheduleUserFetches fs = MH $ do
-    St.modify $ \s -> s { mhUsersToFetch = fs <> mhUsersToFetch s }
-
-scheduleUserStatusFetches :: [UserId] -> MH ()
-scheduleUserStatusFetches is = MH $ do
-    St.modify $ \s -> s { mhPendingStatusList = Just is }
-
-getScheduledUserFetches :: MH [UserFetch]
-getScheduledUserFetches = MH $ St.gets mhUsersToFetch
-
-getScheduledUserStatusFetches :: MH (Maybe [UserId])
-getScheduledUserStatusFetches = MH $ St.gets mhPendingStatusList
-
--- | lift a computation in 'EventM' into 'MH'
-mh :: EventM Name a -> MH a
-mh = MH . R.lift . St.lift
-
-generateUUID :: MH UUID
-generateUUID = liftIO generateUUID_IO
-
-generateUUID_IO :: IO UUID
-generateUUID_IO = randomIO
-
-mhHandleEventLensed :: Lens' ChatState b -> (e -> b -> EventM Name b) -> e -> MH ()
-mhHandleEventLensed ln f event = MH $ do
-    s <- St.get
-    let st = mhCurrentState s
-    n <- R.lift $ St.lift $ f event (st ^. ln)
-    St.put (s { mhCurrentState = st & ln .~ n })
-
-mhSuspendAndResume :: (ChatState -> IO ChatState) -> MH ()
-mhSuspendAndResume mote = MH $ do
-    s <- St.get
-    St.put $ s { mhNextAction = \ _ -> Brick.suspendAndResume (mote $ mhCurrentState s) }
-
--- | This will request that after this computation finishes the
--- application should exit
-requestQuit :: MH ()
-requestQuit = MH $ do
-    s <- St.get
-    St.put $ s { mhNextAction = Brick.halt }
-
-instance Functor MH where
-    fmap f (MH x) = MH (fmap f x)
-
-instance Applicative MH where
-    pure x = MH (pure x)
-    MH f <*> MH x = MH (f <*> x)
-
-instance MHF.MonadFail MH where
-    fail = MH . MHF.fail
-
-instance Monad MH where
-    return x = MH (return x)
-    MH x >>= f = MH (x >>= \ x' -> fromMH (f x'))
-
--- We want to pretend that the state is only the ChatState, rather
--- than the ChatState and the Brick continuation
-instance St.MonadState ChatState MH where
-    get = mhCurrentState `fmap` MH St.get
-    put st = MH $ do
-        s <- St.get
-        St.put $ s { mhCurrentState = st }
-
-instance St.MonadIO MH where
-    liftIO = MH . St.liftIO
-
--- | This represents events that we handle in the main application loop.
-data MHEvent =
-    WSEvent WebsocketEvent
-    -- ^ For events that arise from the websocket
-    | WSActionResponse WebsocketActionResponse
-    -- ^ For responses to websocket actions
-    | RespEvent (MH ())
-    -- ^ For the result values of async IO operations
-    | RefreshWebsocketEvent
-    -- ^ Tell our main loop to refresh the websocket connection
-    | WebsocketParseError String
-    -- ^ We failed to parse an incoming websocket event
-    | WebsocketDisconnect
-    -- ^ The websocket connection went down.
-    | WebsocketConnect
-    -- ^ The websocket connection came up.
-    | BGIdle
-    -- ^ background worker is idle
-    | BGBusy (Maybe Int)
-    -- ^ background worker is busy (with n requests)
-    | RateLimitExceeded Int
-    -- ^ A request initially failed due to a rate limit but will be
-    -- retried if possible. The argument is the number of seconds in
-    -- which the retry will be attempted.
-    | RateLimitSettingsMissing
-    -- ^ A request denied by a rate limit could not be retried because
-    -- the response contained no rate limit metadata
-    | RequestDropped
-    -- ^ A request was reattempted due to a rate limit and was rate
-    -- limited again
-    | IEvent InternalEvent
-    -- ^ MH-internal events
-
--- | Internal application events.
-data InternalEvent =
-    DisplayError MHError
-    -- ^ Some kind of application error occurred
-    | LoggingStarted FilePath
-    | LoggingStopped FilePath
-    | LogStartFailed FilePath String
-    | LogDestination (Maybe FilePath)
-    | LogSnapshotSucceeded FilePath
-    | LogSnapshotFailed FilePath String
-    -- ^ Logging events from the logging thread
-
--- | Application errors.
-data MHError =
-    GenericError T.Text
-    -- ^ A generic error message constructor
-    | NoSuchChannel T.Text
-    -- ^ The specified channel does not exist
-    | NoSuchUser T.Text
-    -- ^ The specified user does not exist
-    | AmbiguousName T.Text
-    -- ^ The specified name matches both a user and a channel
-    | ServerError MattermostError
-    -- ^ A Mattermost server error occurred
-    | ClipboardError T.Text
-    -- ^ A problem occurred trying to deal with yanking or the system
-    -- clipboard
-    | ConfigOptionMissing T.Text
-    -- ^ A missing config option is required to perform an operation
-    | ProgramExecutionFailed T.Text T.Text
-    -- ^ Args: program name, path to log file. A problem occurred when
-    -- running the program.
-    | NoSuchScript T.Text
-    -- ^ The specified script was not found
-    | NoSuchHelpTopic T.Text
-    -- ^ The specified help topic was not found
-    | AsyncErrEvent SomeException
-    -- ^ For errors that arise in the course of async IO operations
-    deriving (Show)
-
--- ** Application State Lenses
-
-makeLenses ''ChatResources
-makeLenses ''ChatState
-makeLenses ''ChatEditState
-makeLenses ''AutocompleteState
-makeLenses ''PostListOverlayState
-makeLenses ''ListOverlayState
-makeLenses ''ChannelSelectState
-makeLenses ''UserPreferences
-makeLenses ''ConnectionInfo
-
-getSession :: MH Session
-getSession = use (csResources.crSession)
-
-getResourceSession :: ChatResources -> Session
-getResourceSession = _crSession
-
-whenMode :: Mode -> MH () -> MH ()
-whenMode m act = do
-    curMode <- use csMode
-    when (curMode == m) act
-
-setMode :: Mode -> MH ()
-setMode m = do
-    csMode .= m
-    mh invalidateCache
-
-setMode' :: Mode -> ChatState -> ChatState
-setMode' m = csMode .~ m
-
-appMode :: ChatState -> Mode
-appMode = _csMode
-
-resetSpellCheckTimer :: ChatEditState -> IO ()
-resetSpellCheckTimer s =
-    case s^.cedSpellChecker of
-        Nothing -> return ()
-        Just (_, reset) -> reset
-
--- ** Utility Lenses
-csCurrentChannelId :: SimpleGetter ChatState ChannelId
-csCurrentChannelId = csFocus.to unsafeFocus.to channelListEntryChannelId
-
-channelListEntryChannelId :: ChannelListEntry -> ChannelId
-channelListEntryChannelId (CLChannel cId) = cId
-channelListEntryChannelId (CLUserDM cId _) = cId
-channelListEntryChannelId (CLGroupDM cId) = cId
-
-channelListEntryUserId :: ChannelListEntry -> Maybe UserId
-channelListEntryUserId (CLUserDM _ uId) = Just uId
-channelListEntryUserId _ = Nothing
-
-userIdsFromZipper :: Zipper ChannelListGroup ChannelListEntry -> [UserId]
-userIdsFromZipper z =
-    concat $ (catMaybes . fmap channelListEntryUserId . snd) <$> Zipper.toList z
-
-entryIsDMEntry :: ChannelListEntry -> Bool
-entryIsDMEntry (CLUserDM {}) = True
-entryIsDMEntry (CLGroupDM {}) = True
-entryIsDMEntry (CLChannel {}) = False
-
-csCurrentChannel :: Lens' ChatState ClientChannel
-csCurrentChannel =
-    lens (\ st -> findChannelById (st^.csCurrentChannelId) (st^.csChannels) ^?! _Just)
-         (\ st n -> st & csChannels %~ addChannel (st^.csCurrentChannelId) n)
-
-csChannel :: ChannelId -> Traversal' ChatState ClientChannel
-csChannel cId =
-    csChannels . channelByIdL cId
-
-withChannel :: ChannelId -> (ClientChannel -> MH ()) -> MH ()
-withChannel cId = withChannelOrDefault cId ()
-
-withChannelOrDefault :: ChannelId -> a -> (ClientChannel -> MH a) -> MH a
-withChannelOrDefault cId deflt mote = do
-    chan <- preuse (csChannel(cId))
-    case chan of
-        Nothing -> return deflt
-        Just c  -> mote c
-
--- ** 'ChatState' Helper Functions
-
-raiseInternalEvent :: InternalEvent -> MH ()
-raiseInternalEvent ev = do
-    queue <- use (csResources.crEventQueue)
-    writeBChan queue (IEvent ev)
-
-writeBChan :: (MonadIO m) => BCH.BChan MHEvent -> MHEvent -> m ()
-writeBChan chan e = do
-    written <- liftIO $ BCH.writeBChanNonBlocking chan e
-    when (not written) $
-        error $ "mhSendEvent: BChan full, please report this as a bug!"
-
--- | Log and raise an error.
-mhError :: MHError -> MH ()
-mhError err = do
-    mhLog LogError $ T.pack $ show err
-    raiseInternalEvent (DisplayError err)
-
-isMine :: ChatState -> Message -> Bool
-isMine st msg =
-    case msg^.mUser of
-        UserI _ uid -> uid == myUserId st
-        _ -> False
-
-getMessageForPostId :: ChatState -> PostId -> Maybe Message
-getMessageForPostId st pId = st^.csPostMap.at(pId)
-
-getParentMessage :: ChatState -> Message -> Maybe Message
-getParentMessage st msg
-    | InReplyTo pId <- msg^.mInReplyToMsg
-      = st^.csPostMap.at(pId)
-    | otherwise = Nothing
-
-setUserStatus :: UserId -> Text -> MH ()
-setUserStatus uId t = do
-    csUsers %= modifyUserById uId (uiStatus .~ statusFromText t)
-    mh $ invalidateCacheEntry ChannelSidebar
-
-usernameForUserId :: UserId -> ChatState -> Maybe Text
-usernameForUserId uId st = _uiName <$> findUserById uId (st^.csUsers)
-
-displayNameForUserId :: UserId -> ChatState -> Maybe Text
-displayNameForUserId uId st = do
-    u <- findUserById uId (st^.csUsers)
-    return $ displayNameForUser u (st^.csClientConfig) (st^.csResources.crUserPreferences)
-
--- | Note: this only searches users we have already loaded. Be
--- aware that if you think you need a user we haven't fetched, use
--- withFetchedUser!
-userIdForUsername :: Text -> ChatState -> Maybe UserId
-userIdForUsername name st =
-    fst <$> (findUserByUsername name $ st^.csUsers)
-
-channelIdByChannelName :: Text -> ChatState -> Maybe ChannelId
-channelIdByChannelName name st =
-    let matches (_, cc) = cc^.ccInfo.cdName == (trimChannelSigil name)
-    in listToMaybe $ fst <$> filteredChannels matches (st^.csChannels)
-
-channelIdByUsername :: Text -> ChatState -> Maybe ChannelId
-channelIdByUsername name st = do
-    uId <- userIdForUsername name st
-    getDmChannelFor uId (st^.csChannels)
-
-useNickname :: ChatState -> Bool
-useNickname st =
-    useNickname' (st^.csClientConfig) (st^.csResources.crUserPreferences)
-
-channelByName :: Text -> ChatState -> Maybe ClientChannel
-channelByName n st = do
-    cId <- channelIdByChannelName n st
-    findChannelById cId (st^.csChannels)
-
-trimChannelSigil :: Text -> Text
-trimChannelSigil n
-    | normalChannelSigil `T.isPrefixOf` n = T.tail n
-    | otherwise = n
-
-addNewUser :: UserInfo -> MH ()
-addNewUser u = do
-    csUsers %= addUser u
-    -- Invalidate the cache because channel message rendering may need
-    -- to get updated if this user authored posts in any channels.
-    mh invalidateCache
-
-data SidebarUpdate =
-    SidebarUpdateImmediate
-    | SidebarUpdateDeferred
-    deriving (Eq, Show)
-
-
-resetAutocomplete :: MH ()
-resetAutocomplete = do
-    csEditState.cedAutocomplete .= Nothing
-    csEditState.cedAutocompletePending .= Nothing
-
-
--- * Slash Commands
-
--- | The 'CmdArgs' type represents the arguments to a slash-command; the
--- type parameter represents the argument structure.
-data CmdArgs :: K.Type -> K.Type where
-    NoArg    :: CmdArgs ()
-    LineArg  :: Text -> CmdArgs Text
-    UserArg  :: CmdArgs rest -> CmdArgs (Text, rest)
-    ChannelArg :: CmdArgs rest -> CmdArgs (Text, rest)
-    TokenArg :: Text -> CmdArgs rest -> CmdArgs (Text, rest)
-
--- | A 'CmdExec' value represents the implementation of a command when
--- provided with its arguments
-type CmdExec a = a -> MH ()
-
--- | A 'Cmd' packages up a 'CmdArgs' specifier and the 'CmdExec'
--- implementation with a name and a description.
-data Cmd =
-    forall a. Cmd { cmdName    :: Text
-                  , cmdDescr   :: Text
-                  , cmdArgSpec :: CmdArgs a
-                  , cmdAction  :: CmdExec a
-                  }
-
--- | Helper function to extract the name out of a 'Cmd' value
-commandName :: Cmd -> Text
-commandName (Cmd name _ _ _ ) = name
-
--- *  Channel Updates and Notifications
-
-userList :: ChatState -> [UserInfo]
-userList st = filter showUser $ allUsers (st^.csUsers)
-    where showUser u = not (isSelf u) && (u^.uiInTeam)
-          isSelf u = (myUserId st) == (u^.uiId)
-
-allUserIds :: ChatState -> [UserId]
-allUserIds st = getAllUserIds $ st^.csUsers
-
--- BEWARE: you probably don't want this, but instead
--- State.Users.withFetchedUser, since this only looks up users in the
--- collection we have already loaded rather than all valid users on the
--- server.
-userById :: UserId -> ChatState -> Maybe UserInfo
-userById uId st = findUserById uId (st^.csUsers)
-
-myUserId :: ChatState -> UserId
-myUserId st = myUser st ^. userIdL
-
-myTeamId :: ChatState -> TeamId
-myTeamId st = st ^. csMyTeam . teamIdL
-
-myUser :: ChatState -> User
-myUser st = st^.csMe
-
-myUsername :: ChatState -> Text
-myUsername st = userUsername $ st^.csMe
-
--- BEWARE: you probably don't want this, but instead
--- State.Users.withFetchedUser, since this only looks up users in the
--- collection we have already loaded rather than all valid users on the
--- server.
-userByUsername :: Text -> ChatState -> Maybe UserInfo
-userByUsername name st = do
-    snd <$> (findUserByUsername name $ st^.csUsers)
-
--- BEWARE: you probably don't want this, but instead
--- State.Users.withFetchedUser, since this only looks up users in the
--- collection we have already loaded rather than all valid users on the
--- server.
-userByNickname :: Text -> ChatState -> Maybe UserInfo
-userByNickname name st =
-    snd <$> (findUserByNickname name $ st^.csUsers)
-
-getUsers :: MH Users
-getUsers = use csUsers
-
--- * HighlightSet
-
-type UserSet = Set Text
-type ChannelSet = Set Text
-
--- | The set of usernames, channel names, and language names used for
--- highlighting when rendering messages.
-data HighlightSet =
-    HighlightSet { hUserSet    :: Set Text
-                 , hChannelSet :: Set Text
-                 , hSyntaxMap  :: SyntaxMap
-                 }
-
-getHighlightSet :: ChatState -> HighlightSet
-getHighlightSet st =
-    HighlightSet { hUserSet = addSpecialUserMentions $ getUsernameSet $ st^.csUsers
-                 , hChannelSet = getChannelNameSet $ st^.csChannels
-                 , hSyntaxMap = st^.csResources.crSyntaxMap
-                 }
-
-attrNameToConfig :: Brick.AttrName -> Text
-attrNameToConfig = T.pack . intercalate "." . Brick.attrNameComponents
-
--- From: https://docs.mattermost.com/help/messaging/mentioning-teammates.html
-specialUserMentions :: [T.Text]
-specialUserMentions = ["all", "channel", "here"]
-
-addSpecialUserMentions :: Set Text -> Set Text
-addSpecialUserMentions s = foldr Set.insert s specialUserMentions
-
-getNewMessageCutoff :: ChannelId -> ChatState -> Maybe NewMessageIndicator
-getNewMessageCutoff cId st = do
-    cc <- st^?csChannel(cId)
-    return $ cc^.ccInfo.cdNewMessageIndicator
-
-getEditedMessageCutoff :: ChannelId -> ChatState -> Maybe ServerTime
-getEditedMessageCutoff cId st = do
-    cc <- st^?csChannel(cId)
-    cc^.ccInfo.cdEditedMessageThreshold
-
-clearChannelUnreadStatus :: ChannelId -> MH ()
-clearChannelUnreadStatus cId = do
-    mh $ invalidateCacheEntry (ChannelMessages cId)
-    csChannel(cId) %= (clearNewMessageIndicator .
-                       clearEditedThreshold)
-
-data OpenInBrowser =
-    OpenLinkChoice LinkChoice
-    | OpenLocalFile FilePath
-    deriving (Eq, Show)
diff --git a/src/Types/Channels.hs b/src/Types/Channels.hs
deleted file mode 100644
--- a/src/Types/Channels.hs
+++ /dev/null
@@ -1,420 +0,0 @@
-{-# LANGUAGE TemplateHaskell #-}
-{-# LANGUAGE DeriveFunctor #-}
-{-# LANGUAGE DeriveFoldable #-}
-{-# LANGUAGE DeriveTraversable #-}
-{-# LANGUAGE RankNTypes #-}
-
-module Types.Channels
-  ( ClientChannel(..)
-  , ChannelContents(..)
-  , ChannelInfo(..)
-  , ClientChannels -- constructor remains internal
-  , NewMessageIndicator(..)
-  , EphemeralEditState(..)
-  , EditMode(..)
-  , eesMultiline, eesInputHistoryPosition, eesLastInput
-  , defaultEphemeralEditState
-  -- * Lenses created for accessing ClientChannel fields
-  , ccContents, ccInfo, ccEditState
-  -- * Lenses created for accessing ChannelInfo fields
-  , cdViewed, cdNewMessageIndicator, cdEditedMessageThreshold, cdUpdated
-  , cdName, cdDisplayName, cdHeader, cdPurpose, cdType
-  , cdMentionCount, cdTypingUsers, cdDMUserId, cdChannelId
-  , cdSidebarShowOverride, cdNotifyProps
-  -- * Lenses created for accessing ChannelContents fields
-  , cdMessages, cdFetchPending
-  -- * Creating ClientChannel objects
-  , makeClientChannel
-  -- * Managing ClientChannel collections
-  , noChannels, addChannel, removeChannel, findChannelById, modifyChannelById
-  , channelByIdL, maybeChannelByIdL
-  , filteredChannelIds
-  , filteredChannels
-  -- * Creating ChannelInfo objects
-  , channelInfoFromChannelWithData
-  -- * Channel State management
-  , clearNewMessageIndicator
-  , clearEditedThreshold
-  , adjustUpdated
-  , adjustEditedThreshold
-  , updateNewMessageIndicator
-  , addChannelTypingUser
-  -- * Notification settings
-  , notifyPreference
-  , isMuted
-  , channelNotifyPropsMarkUnreadL
-  , channelNotifyPropsIgnoreChannelMentionsL
-  , channelNotifyPropsDesktopL
-  , channelNotifyPropsPushL
-  -- * Miscellaneous channel-related operations
-  , canLeaveChannel
-  , preferredChannelName
-  , isTownSquare
-  , channelDeleted
-  , getDmChannelFor
-  , allDmChannelMappings
-  , getChannelNameSet
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Data.HashMap.Strict as HM
-import qualified Data.Set as S
-import qualified Data.Text as T
-import           Lens.Micro.Platform ( (%~), (.~), Traversal', Lens'
-                                     , makeLenses, ix, at
-                                     , to, non )
-
-import           Network.Mattermost.Lenses hiding ( Lens' )
-import           Network.Mattermost.Types ( Channel(..), UserId, ChannelId
-                                          , ChannelMember(..)
-                                          , Type(..)
-                                          , Post
-                                          , User(userNotifyProps)
-                                          , ChannelNotifyProps
-                                          , NotifyOption(..)
-                                          , WithDefault(..)
-                                          , ServerTime
-                                          , emptyChannelNotifyProps
-                                          )
-
-import           Types.Messages ( Messages, noMessages, addMessage
-                                , clientMessageToMessage, Message, MessageType )
-import           Types.Posts ( ClientMessageType(UnknownGapBefore)
-                             , newClientMessage )
-import           Types.Users ( TypingUsers, noTypingUsers, addTypingUser )
-import           Types.Common
-
-
--- * Channel representations
-
--- | A 'ClientChannel' contains both the message
---   listing and the metadata about a channel
-data ClientChannel = ClientChannel
-  { _ccContents :: ChannelContents
-    -- ^ A list of 'Message's in the channel
-  , _ccInfo :: ChannelInfo
-    -- ^ The 'ChannelInfo' for the channel
-  , _ccEditState :: EphemeralEditState
-    -- ^ Editor state that we swap in and out as the current channel is
-    -- changed.
-  }
-
--- | The input state associated with the message editor.
-data EditMode =
-    NewPost
-    -- ^ The input is for a new post.
-    | Editing Post MessageType
-    -- ^ The input is ultimately to replace the body of an existing post
-    -- of the specified type.
-    | Replying Message Post
-    -- ^ The input is to be used as a new post in reply to the specified
-    -- post.
-    deriving (Show)
-
-data EphemeralEditState =
-    EphemeralEditState { _eesMultiline :: Bool
-                       -- ^ Whether the editor is in multiline mode
-                       , _eesInputHistoryPosition :: Maybe Int
-                       -- ^ The input history position, if any
-                       , _eesLastInput :: (T.Text, EditMode)
-                       -- ^ The input entered into the text editor last
-                       -- time the user was focused on the channel
-                       -- associated with this state.
-                       }
-
--- Get a channel's name, depending on its type
-preferredChannelName :: Channel -> Text
-preferredChannelName ch
-    | channelType ch == Group = sanitizeUserText $ channelDisplayName ch
-    | otherwise               = sanitizeUserText $ channelName ch
-
-data NewMessageIndicator =
-    Hide
-    | NewPostsAfterServerTime ServerTime
-    | NewPostsStartingAt ServerTime
-    deriving (Eq, Show)
-
-initialChannelInfo :: UserId -> Channel -> ChannelInfo
-initialChannelInfo myId chan =
-    let updated  = chan ^. channelLastPostAtL
-    in ChannelInfo { _cdChannelId              = chan^.channelIdL
-                   , _cdViewed                 = Nothing
-                   , _cdNewMessageIndicator    = Hide
-                   , _cdEditedMessageThreshold = Nothing
-                   , _cdMentionCount           = 0
-                   , _cdUpdated                = updated
-                   , _cdName                   = preferredChannelName chan
-                   , _cdDisplayName            = sanitizeUserText $ channelDisplayName chan
-                   , _cdHeader                 = sanitizeUserText $ chan^.channelHeaderL
-                   , _cdPurpose                = sanitizeUserText $ chan^.channelPurposeL
-                   , _cdType                   = chan^.channelTypeL
-                   , _cdNotifyProps            = emptyChannelNotifyProps
-                   , _cdTypingUsers            = noTypingUsers
-                   , _cdDMUserId               = if chan^.channelTypeL == Direct
-                                                 then userIdForDMChannel myId $
-                                                      sanitizeUserText $ channelName chan
-                                                 else Nothing
-                   , _cdSidebarShowOverride    = Nothing
-                   }
-
-channelInfoFromChannelWithData :: Channel -> ChannelMember -> ChannelInfo -> ChannelInfo
-channelInfoFromChannelWithData chan chanMember ci =
-    let viewed   = chanMember ^. to channelMemberLastViewedAt
-        updated  = chan ^. channelLastPostAtL
-    in ci { _cdViewed           = Just viewed
-          , _cdNewMessageIndicator = case _cdNewMessageIndicator ci of
-              Hide -> if updated > viewed then NewPostsAfterServerTime viewed else Hide
-              v -> v
-          , _cdUpdated          = updated
-          , _cdName             = preferredChannelName chan
-          , _cdDisplayName      = sanitizeUserText $ channelDisplayName chan
-          , _cdHeader           = (sanitizeUserText $ chan^.channelHeaderL)
-          , _cdPurpose          = (sanitizeUserText $ chan^.channelPurposeL)
-          , _cdType             = (chan^.channelTypeL)
-          , _cdMentionCount     = chanMember^.to channelMemberMentionCount
-          , _cdNotifyProps      = chanMember^.to channelMemberNotifyProps
-          }
-
--- | The 'ChannelContents' is a wrapper for a list of
---   'Message' values
-data ChannelContents = ChannelContents
-  { _cdMessages :: Messages
-  , _cdFetchPending :: Bool
-  }
-
--- | An initial empty 'ChannelContents' value.  This also contains an
--- UnknownGapBefore, which is a signal that causes actual content fetching.
--- The initial Gap's timestamp is the local client time, but
--- subsequent fetches will synchronize with the server (and eventually
--- eliminate this Gap as well).
-emptyChannelContents :: MonadIO m => m ChannelContents
-emptyChannelContents = do
-  gapMsg <- clientMessageToMessage <$> newClientMessage UnknownGapBefore "--Fetching messages--"
-  return $ ChannelContents { _cdMessages = addMessage gapMsg noMessages
-                           , _cdFetchPending = False
-                           }
-
-
-------------------------------------------------------------------------
-
--- | The 'ChannelInfo' record represents metadata
---   about a channel
-data ChannelInfo = ChannelInfo
-  { _cdChannelId        :: ChannelId
-    -- ^ The channel's ID
-  , _cdViewed           :: Maybe ServerTime
-    -- ^ The last time we looked at a channel
-  , _cdNewMessageIndicator :: NewMessageIndicator
-    -- ^ The state of the channel's new message indicator.
-  , _cdEditedMessageThreshold :: Maybe ServerTime
-    -- ^ The channel's edited message threshold.
-  , _cdMentionCount     :: Int
-    -- ^ The current number of unread mentions
-  , _cdUpdated          :: ServerTime
-    -- ^ The last time a message showed up in the channel
-  , _cdName             :: Text
-    -- ^ The name of the channel
-  , _cdDisplayName      :: Text
-    -- ^ The display name of the channel
-  , _cdHeader           :: Text
-    -- ^ The header text of a channel
-  , _cdPurpose          :: Text
-    -- ^ The stated purpose of the channel
-  , _cdType             :: Type
-    -- ^ The type of a channel: public, private, or DM
-  , _cdNotifyProps      :: ChannelNotifyProps
-    -- ^ The user's notification settings for this channel
-  , _cdTypingUsers      :: TypingUsers
-    -- ^ The users who are currently typing in this channel
-  , _cdDMUserId         :: Maybe UserId
-    -- ^ The user associated with this channel, if it is a DM channel
-  , _cdSidebarShowOverride :: Maybe UTCTime
-    -- ^ If set, show this channel in the sidebar regardless of other
-    -- considerations as long as the specified timestamp meets a cutoff.
-    -- Otherwise fall back to other application policy to determine
-    -- whether to show the channel.
-  }
-
--- ** Channel-related Lenses
-
-makeLenses ''ChannelContents
-makeLenses ''ChannelInfo
-makeLenses ''ClientChannel
-makeLenses ''EphemeralEditState
-
-isMuted :: ClientChannel -> Bool
-isMuted cc = cc^.ccInfo.cdNotifyProps.channelNotifyPropsMarkUnreadL ==
-             IsValue NotifyOptionMention
-
-notifyPreference :: User -> ClientChannel -> NotifyOption
-notifyPreference u cc =
-    if isMuted cc then NotifyOptionNone
-    else case cc^.ccInfo.cdNotifyProps.channelNotifyPropsDesktopL of
-             IsValue v -> v
-             Default   -> (userNotifyProps u)^.userNotifyPropsDesktopL
-
--- ** Miscellaneous channel operations
-
-makeClientChannel :: (MonadIO m) => UserId -> Channel -> m ClientChannel
-makeClientChannel myId nc = emptyChannelContents >>= \contents ->
-  return ClientChannel
-  { _ccContents = contents
-  , _ccInfo = initialChannelInfo myId nc
-  , _ccEditState = defaultEphemeralEditState
-  }
-
-defaultEphemeralEditState :: EphemeralEditState
-defaultEphemeralEditState =
-    EphemeralEditState { _eesMultiline = False
-                       , _eesInputHistoryPosition = Nothing
-                       , _eesLastInput = ("", NewPost)
-                       }
-
-canLeaveChannel :: ChannelInfo -> Bool
-canLeaveChannel cInfo = not $ cInfo^.cdType `elem` [Direct]
-
--- ** Manage the collection of all Channels
-
--- | Define a binary kinded type to allow derivation of functor.
-data AllMyChannels a =
-    AllChannels { _chanMap :: HashMap ChannelId a
-                , _userChannelMap :: HashMap UserId ChannelId
-                , _channelNameSet :: S.Set Text
-                }
-                deriving (Functor, Foldable, Traversable)
-
--- | Define the exported typename which universally binds the
--- collection to the ChannelInfo type.
-type ClientChannels = AllMyChannels ClientChannel
-
-makeLenses ''AllMyChannels
-
-getChannelNameSet :: ClientChannels -> S.Set Text
-getChannelNameSet = _channelNameSet
-
--- | Initial collection of Channels with no members
-noChannels :: ClientChannels
-noChannels = AllChannels HM.empty HM.empty mempty
-
--- | Add a channel to the existing collection.
-addChannel :: ChannelId -> ClientChannel -> ClientChannels -> ClientChannels
-addChannel cId cinfo =
-    (chanMap %~ HM.insert cId cinfo) .
-    (if cinfo^.ccInfo.cdType `notElem` [Direct, Group]
-     then channelNameSet %~ S.insert (cinfo^.ccInfo.cdName)
-     else id) .
-    (case cinfo^.ccInfo.cdDMUserId of
-         Nothing -> id
-         Just uId -> userChannelMap %~ HM.insert uId cId
-    )
-
--- | Remove a channel from the collection.
-removeChannel :: ChannelId -> ClientChannels -> ClientChannels
-removeChannel cId cs =
-    let mChan = findChannelById cId cs
-        removeChannelName = case mChan of
-            Nothing -> id
-            Just ch -> channelNameSet %~ S.delete (ch^.ccInfo.cdName)
-    in cs & chanMap %~ HM.delete cId
-          & removeChannelName
-          & userChannelMap %~ HM.filter (/= cId)
-
-getDmChannelFor :: UserId -> ClientChannels -> Maybe ChannelId
-getDmChannelFor uId cs = cs^.userChannelMap.at uId
-
-allDmChannelMappings :: ClientChannels -> [(UserId, ChannelId)]
-allDmChannelMappings = HM.toList . _userChannelMap
-
--- | Get the ChannelInfo information given the ChannelId
-findChannelById :: ChannelId -> ClientChannels -> Maybe ClientChannel
-findChannelById cId = HM.lookup cId . _chanMap
-
--- | Transform the specified channel in place with provided function.
-modifyChannelById :: ChannelId -> (ClientChannel -> ClientChannel)
-                  -> ClientChannels -> ClientChannels
-modifyChannelById cId f = chanMap.ix(cId) %~ f
-
--- | A 'Traversal' that will give us the 'ClientChannel' in a
--- 'ClientChannels' structure if it exists
-channelByIdL :: ChannelId -> Traversal' ClientChannels ClientChannel
-channelByIdL cId = chanMap . ix cId
-
--- | A 'Lens' that will give us the 'ClientChannel' in a
--- 'ClientChannels' wrapped in a 'Maybe'
-maybeChannelByIdL :: ChannelId -> Lens' ClientChannels (Maybe ClientChannel)
-maybeChannelByIdL cId = chanMap . at cId
-
--- | Apply a filter to each ClientChannel and return a list of the
--- ChannelId values for which the filter matched.
-filteredChannelIds :: (ClientChannel -> Bool) -> ClientChannels -> [ChannelId]
-filteredChannelIds f cc = fst <$> filter (f . snd) (HM.toList (cc^.chanMap))
-
--- | Filter the ClientChannel collection, keeping only those for which
--- the provided filter test function returns True.
-filteredChannels :: ((ChannelId, ClientChannel) -> Bool)
-                 -> ClientChannels -> [(ChannelId, ClientChannel)]
-filteredChannels f cc = filter f $ cc^.chanMap.to HM.toList
-
-------------------------------------------------------------------------
-
--- * Channel State management
-
-
--- | Add user to the list of users in this channel who are currently typing.
-addChannelTypingUser :: UserId -> UTCTime -> ClientChannel -> ClientChannel
-addChannelTypingUser uId ts = ccInfo.cdTypingUsers %~ (addTypingUser uId ts)
-
--- | Clear the new message indicator for the specified channel
-clearNewMessageIndicator :: ClientChannel -> ClientChannel
-clearNewMessageIndicator c = c & ccInfo.cdNewMessageIndicator .~ Hide
-
--- | Clear the edit threshold for the specified channel
-clearEditedThreshold :: ClientChannel -> ClientChannel
-clearEditedThreshold c = c & ccInfo.cdEditedMessageThreshold .~ Nothing
-
--- | Adjust updated time based on a message, ensuring that the updated
--- time does not move backward.
-adjustUpdated :: Post -> ClientChannel -> ClientChannel
-adjustUpdated m =
-    ccInfo.cdUpdated %~ max (maxPostTimestamp m)
-
-adjustEditedThreshold :: Post -> ClientChannel -> ClientChannel
-adjustEditedThreshold m c =
-    if m^.postUpdateAtL <= m^.postCreateAtL
-    then c
-    else c & ccInfo.cdEditedMessageThreshold %~ (\mt -> case mt of
-        Just t -> Just $ min (m^.postUpdateAtL) t
-        Nothing -> Just $ m^.postUpdateAtL
-        )
-
-maxPostTimestamp :: Post -> ServerTime
-maxPostTimestamp m = max (m^.postDeleteAtL . non (m^.postUpdateAtL)) (m^.postCreateAtL)
-
-updateNewMessageIndicator :: Post -> ClientChannel -> ClientChannel
-updateNewMessageIndicator m =
-    ccInfo.cdNewMessageIndicator %~
-        (\old ->
-          case old of
-              Hide ->
-                  NewPostsStartingAt $ m^.postCreateAtL
-              NewPostsStartingAt ts ->
-                  NewPostsStartingAt $ min (m^.postCreateAtL) ts
-              NewPostsAfterServerTime ts ->
-                  if m^.postCreateAtL <= ts
-                  then NewPostsStartingAt $ m^.postCreateAtL
-                  else NewPostsAfterServerTime ts
-              )
-
--- | Town Square is special in that its non-display name cannot be
--- changed and is a hard-coded constant server-side according to the
--- developers (as of 8/2/17). So this is a reliable way to check for
--- whether a channel is in fact that channel, even if the user has
--- changed its display name.
-isTownSquare :: Channel -> Bool
-isTownSquare c = (sanitizeUserText $ c^.channelNameL) == "town-square"
-
-channelDeleted :: Channel -> Bool
-channelDeleted c = c^.channelDeleteAtL > c^.channelCreateAtL
diff --git a/src/Types/Common.hs b/src/Types/Common.hs
deleted file mode 100644
--- a/src/Types/Common.hs
+++ /dev/null
@@ -1,41 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-module Types.Common
-  ( sanitizeUserText
-  , sanitizeUserText'
-  , userIdForDMChannel
-  )
-where
-
-import Prelude ()
-import Prelude.MH
-
-import qualified Data.Text as T
-
-import Network.Mattermost.Types ( UserText, unsafeUserText, UserId(..), Id(..) )
-
-sanitizeUserText :: UserText -> T.Text
-sanitizeUserText = sanitizeUserText' . unsafeUserText
-
-sanitizeUserText' :: T.Text -> T.Text
-sanitizeUserText' =
-    T.replace "\ESC" "<ESC>" .
-    T.replace "\t" " " .
-    T.filter (\c -> c >= ' ' || c == '\n')  -- remove non-printable
-
--- | Extract the corresponding other user from a direct channel name.
--- Returns Nothing if the string is not a direct channel name or if it
--- is but neither user ID in the name matches the current user's ID.
-userIdForDMChannel :: UserId
-                   -- ^ My user ID
-                   -> Text
-                   -- ^ The channel name
-                   -> Maybe UserId
-userIdForDMChannel me chanName =
-    -- Direct channel names are of the form "UID__UID" where one of the
-    -- UIDs is mine and the other is the other channel participant.
-    let vals = T.splitOn "__" chanName
-    in case vals of
-        [u1, u2] -> if | (UI $ Id u1) == me  -> Just $ UI $ Id u2
-                       | (UI $ Id u2) == me  -> Just $ UI $ Id u1
-                       | otherwise        -> Nothing
-        _ -> Nothing
diff --git a/src/Types/DirectionalSeq.hs b/src/Types/DirectionalSeq.hs
deleted file mode 100644
--- a/src/Types/DirectionalSeq.hs
+++ /dev/null
@@ -1,97 +0,0 @@
-{-# LANGUAGE DeriveFoldable #-}
-{-# LANGUAGE DeriveFunctor #-}
-{-# LANGUAGE DeriveTraversable #-}
-{-# LANGUAGE TypeFamilies #-}
-
-{- | These declarations allow the use of a DirectionalSeq, which is a
-   Seq that uses a phantom type to identify the ordering of the
-   elements in the sequence (Forward or Reverse).  The constructors
-   are not exported from this module so that a DirectionalSeq can only
-   be constructed by the functions in this module.
--}
-
-module Types.DirectionalSeq where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Data.Sequence as Seq
-
-
-data Chronological
-data Retrograde
-class SeqDirection a where
-  type ReverseDirection a
-instance SeqDirection Chronological
-  where type ReverseDirection Chronological = Retrograde
-instance SeqDirection Retrograde
-  where type ReverseDirection Retrograde = Chronological
-
-data SeqDirection dir => DirectionalSeq dir a =
-    DSeq { dseq :: Seq a }
-         deriving (Show, Functor, Foldable, Traversable)
-
-emptyDirSeq :: DirectionalSeq dir a
-emptyDirSeq = DSeq mempty
-
-appendDirSeq :: DirectionalSeq dir a -> DirectionalSeq dir a -> DirectionalSeq dir a
-appendDirSeq a b = DSeq $ mappend (dseq a) (dseq b)
-
-onDirectedSeq :: SeqDirection dir => (Seq a -> Seq b)
-              -> DirectionalSeq dir a -> DirectionalSeq dir b
-onDirectedSeq f = DSeq . f . dseq
-
--- | Uses a start-predicate and and end-predicate to
--- identify (the first matching) subset that is delineated by
--- start-predicate and end-predicate (inclusive).  It will then call
--- the passed operation function on the subset messages to get back a
--- (possibly modified) set of messages, along with an extracted value.
--- The 'onDirSeqSubset' function will replace the original subset of
--- messages with the set returned by the operation function and return
--- the resulting message list along with the extracted value.
-
-onDirSeqSubset :: SeqDirection dir =>
-                 (e -> Bool) -> (e -> Bool)
-               -> (DirectionalSeq dir e -> (DirectionalSeq dir e, a))
-               -> DirectionalSeq dir e
-               -> (DirectionalSeq dir e, a)
-onDirSeqSubset startPred endPred op entries =
-    let ml = dseq entries
-        (bl, ml1) = Seq.breakl startPred ml
-        (ml2, el) = Seq.breakl endPred ml1
-        -- move match from start of el to end of ml2
-        (ml2', el') = if not (Seq.null el)
-                      then (ml2 <> Seq.take 1 el, Seq.drop 1 el)
-                      else (ml2, el)
-        (ml3, rval) = op $ DSeq ml2'
-    in (DSeq bl `appendDirSeq` ml3 `appendDirSeq` DSeq el', rval)
-
--- | dirSeqBreakl splits the DirectionalSeq into a tuple where the
--- first element is the (possibly empty) DirectionalSeq of all
--- elements from the start for which the predicate returns false; the
--- second tuple element is the remainder of the list, starting with
--- the first element for which the predicate matched.
-dirSeqBreakl :: SeqDirection dir =>
-               (e -> Bool) -> DirectionalSeq dir e
-             -> (DirectionalSeq dir e, DirectionalSeq dir e)
-dirSeqBreakl isMatch entries =
-    let (removed, remaining) = Seq.breakl isMatch $ dseq entries
-    in (DSeq removed, DSeq remaining)
-
--- | dirSeqPartition splits the DirectionalSeq into a tuple of two
--- DirectionalSeq elements: the first contains all elements for which
--- the predicate is true and the second contains all elements for
--- which the predicate is false.
-dirSeqPartition :: SeqDirection dir =>
-                  (e -> Bool) -> DirectionalSeq dir e
-                -> (DirectionalSeq dir e, DirectionalSeq dir e)
-dirSeqPartition isMatch entries =
-    let (match, nomatch) = Seq.partition isMatch $ dseq entries
-    in (DSeq match, DSeq nomatch)
-
-
-withDirSeqHead :: SeqDirection dir => (e -> r) -> DirectionalSeq dir e -> Maybe r
-withDirSeqHead op entries =
-    case Seq.viewl (dseq entries) of
-      Seq.EmptyL -> Nothing
-      e Seq.:< _ -> Just $ op e
diff --git a/src/Types/KeyEvents.hs b/src/Types/KeyEvents.hs
deleted file mode 100644
--- a/src/Types/KeyEvents.hs
+++ /dev/null
@@ -1,434 +0,0 @@
-module Types.KeyEvents
-  (
-  -- * Types
-    KeyEvent(..)
-  , KeyConfig
-  , Binding(..)
-  , BindingState(..)
-
-  -- * Data
-  , allEvents
-
-  -- * Parsing and pretty-printing
-  , parseBinding
-  , parseBindingList
-  , ppBinding
-  , nonCharKeys
-  , eventToBinding
-
-  -- * Key event name resolution
-  , keyEventFromName
-  , keyEventName
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Data.Map.Strict as M
-import qualified Data.Text as T
-import qualified Graphics.Vty as Vty
-
-
--- | This enum represents all the possible key events a user might
---   want to use.
-data KeyEvent
-  = VtyRefreshEvent
-  | ShowHelpEvent
-  | EnterSelectModeEvent
-  | ReplyRecentEvent
-  | ToggleMessagePreviewEvent
-  | InvokeEditorEvent
-  | EnterFastSelectModeEvent
-  | QuitEvent
-  | NextChannelEvent
-  | PrevChannelEvent
-  | NextChannelEventAlternate
-  | PrevChannelEventAlternate
-  | NextUnreadChannelEvent
-  | NextUnreadUserOrChannelEvent
-  | LastChannelEvent
-  | EnterOpenURLModeEvent
-  | ClearUnreadEvent
-  | ToggleMultiLineEvent
-  | EnterFlaggedPostsEvent
-  | ToggleChannelListVisibleEvent
-  | ShowAttachmentListEvent
-
-  | EditorKillToBolEvent
-  | EditorKillToEolEvent
-  | EditorBolEvent
-  | EditorEolEvent
-  | EditorTransposeCharsEvent
-  | EditorDeleteCharacter
-  | EditorPrevCharEvent
-  | EditorNextCharEvent
-  | EditorPrevWordEvent
-  | EditorNextWordEvent
-  | EditorDeleteNextWordEvent
-  | EditorDeletePrevWordEvent
-  | EditorHomeEvent
-  | EditorEndEvent
-  | EditorYankEvent
-
-  | SelectNextTabEvent
-  | SelectPreviousTabEvent
-
-  -- generic cancel
-  | CancelEvent
-
-  -- channel-scroll-specific
-  | LoadMoreEvent
-  | OpenMessageURLEvent
-
-  -- scrolling events---maybe rebindable?
-  | ScrollUpEvent
-  | ScrollDownEvent
-  | ScrollLeftEvent
-  | ScrollRightEvent
-  | PageUpEvent
-  | PageDownEvent
-  | PageRightEvent
-  | PageLeftEvent
-  | ScrollTopEvent
-  | ScrollBottomEvent
-  | SelectOldestMessageEvent
-
-  -- select events---not the same as scrolling sometimes!
-  | SelectUpEvent
-  | SelectDownEvent
-
-  -- search select events---these need to not be valid editor inputs
-  -- (such as 'j' and 'k')
-  | SearchSelectUpEvent
-  | SearchSelectDownEvent
-
-  -- E.g. Pressing enter on an item in a list to do something with it
-  | ActivateListItemEvent
-
-  | ViewMessageEvent
-  | FillGapEvent
-  | FlagMessageEvent
-  | PinMessageEvent
-  | YankMessageEvent
-  | YankWholeMessageEvent
-  | DeleteMessageEvent
-  | EditMessageEvent
-  | ReplyMessageEvent
-  | ReactToMessageEvent
-
-  -- Attachments
-  | AttachmentListAddEvent
-  | AttachmentListDeleteEvent
-  | AttachmentOpenEvent
-
-  -- Form submission
-  | FormSubmitEvent
-    deriving (Eq, Show, Ord, Enum)
-
-allEvents :: [KeyEvent]
-allEvents =
-  [ QuitEvent
-  , VtyRefreshEvent
-  , ClearUnreadEvent
-
-  , ToggleMessagePreviewEvent
-  , InvokeEditorEvent
-  , ToggleMultiLineEvent
-  , CancelEvent
-  , ReplyRecentEvent
-  , SelectNextTabEvent
-  , SelectPreviousTabEvent
-
-  , EnterFastSelectModeEvent
-  , NextChannelEvent
-  , PrevChannelEvent
-  , NextChannelEventAlternate
-  , PrevChannelEventAlternate
-  , NextUnreadChannelEvent
-  , NextUnreadUserOrChannelEvent
-  , LastChannelEvent
-
-  , ShowAttachmentListEvent
-
-  , EditorKillToBolEvent
-  , EditorKillToEolEvent
-  , EditorBolEvent
-  , EditorEolEvent
-  , EditorTransposeCharsEvent
-  , EditorDeleteCharacter
-  , EditorPrevCharEvent
-  , EditorNextCharEvent
-  , EditorPrevWordEvent
-  , EditorNextWordEvent
-  , EditorDeleteNextWordEvent
-  , EditorDeletePrevWordEvent
-  , EditorHomeEvent
-  , EditorEndEvent
-  , EditorYankEvent
-
-  , EnterFlaggedPostsEvent
-  , ToggleChannelListVisibleEvent
-  , ShowHelpEvent
-  , EnterSelectModeEvent
-  , EnterOpenURLModeEvent
-
-  , LoadMoreEvent
-  , OpenMessageURLEvent
-
-  , ScrollUpEvent
-  , ScrollDownEvent
-  , ScrollLeftEvent
-  , ScrollRightEvent
-  , PageUpEvent
-  , PageDownEvent
-  , PageLeftEvent
-  , PageRightEvent
-  , ScrollTopEvent
-  , ScrollBottomEvent
-  , SelectOldestMessageEvent
-
-  , SelectUpEvent
-  , SelectDownEvent
-
-  , ActivateListItemEvent
-
-  , SearchSelectUpEvent
-  , SearchSelectDownEvent
-
-  , FlagMessageEvent
-  , PinMessageEvent
-  , ViewMessageEvent
-  , FillGapEvent
-  , YankMessageEvent
-  , YankWholeMessageEvent
-  , DeleteMessageEvent
-  , EditMessageEvent
-  , ReplyMessageEvent
-  , ReactToMessageEvent
-  , AttachmentListAddEvent
-  , AttachmentListDeleteEvent
-  , AttachmentOpenEvent
-  , FormSubmitEvent
-  ]
-
-eventToBinding :: Vty.Event -> Binding
-eventToBinding (Vty.EvKey k mods) = Binding mods k
-eventToBinding k = error $ "BUG: invalid keybinding " <> show k
-
-data Binding = Binding
-  { kbMods :: [Vty.Modifier]
-  , kbKey  :: Vty.Key
-  } deriving (Eq, Show, Ord)
-
-data BindingState =
-    BindingList [Binding]
-    | Unbound
-    deriving (Show, Eq, Ord)
-
-type KeyConfig = M.Map KeyEvent BindingState
-
-parseBinding :: Text -> Either String Binding
-parseBinding kb = go (T.splitOn "-" $ T.toLower kb) []
-  where go [k] mods = do
-          key <- pKey k
-          return Binding { kbMods = mods, kbKey = key }
-        go (k:ks) mods = do
-          m <- case k of
-            "s"       -> return Vty.MShift
-            "shift"   -> return Vty.MShift
-            "m"       -> return Vty.MMeta
-            "meta"    -> return Vty.MMeta
-            "a"       -> return Vty.MAlt
-            "alt"     -> return Vty.MAlt
-            "c"       -> return Vty.MCtrl
-            "ctrl"    -> return Vty.MCtrl
-            "control" -> return Vty.MCtrl
-            _         -> Left ("Unknown modifier prefix: " ++ show k)
-          go ks (m:mods)
-        go [] _ = Left "Empty keybinding not allowed"
-        pKey "esc"       = return Vty.KEsc
-        pKey "backspace" = return Vty.KBS
-        pKey "enter"     = return Vty.KEnter
-        pKey "left"      = return Vty.KLeft
-        pKey "right"     = return Vty.KRight
-        pKey "up"        = return Vty.KUp
-        pKey "down"      = return Vty.KDown
-        pKey "upleft"    = return Vty.KUpLeft
-        pKey "upright"   = return Vty.KUpRight
-        pKey "downleft"  = return Vty.KDownLeft
-        pKey "downright" = return Vty.KDownRight
-        pKey "center"    = return Vty.KCenter
-        pKey "backtab"   = return Vty.KBackTab
-        pKey "printscreen" = return Vty.KPrtScr
-        pKey "pause"     = return Vty.KPause
-        pKey "insert"    = return Vty.KIns
-        pKey "home"      = return Vty.KHome
-        pKey "pgup"      = return Vty.KPageUp
-        pKey "del"       = return Vty.KDel
-        pKey "end"       = return Vty.KEnd
-        pKey "pgdown"    = return Vty.KPageDown
-        pKey "begin"     = return Vty.KBegin
-        pKey "menu"      = return Vty.KMenu
-        pKey "space"     = return (Vty.KChar ' ')
-        pKey "tab"       = return (Vty.KChar '\t')
-        pKey t
-          | Just (c, "") <- T.uncons t =
-              return (Vty.KChar c)
-          | Just n <- T.stripPrefix "f" t =
-              case readMaybe (T.unpack n) of
-                  Nothing -> Left ("Unknown keybinding: " ++ show t)
-                  Just i -> return (Vty.KFun i)
-          | otherwise = Left ("Unknown keybinding: " ++ show t)
-
-ppBinding :: Binding -> Text
-ppBinding (Binding mods k) =
-    T.intercalate "-" $ (ppMod <$> mods) <> [ppKey k]
-
-ppKey :: Vty.Key -> Text
-ppKey (Vty.KChar c)   = ppChar c
-ppKey (Vty.KFun n)    = "F" <> (T.pack $ show n)
-ppKey Vty.KBackTab    = "BackTab"
-ppKey Vty.KEsc        = "Esc"
-ppKey Vty.KBS         = "Backspace"
-ppKey Vty.KEnter      = "Enter"
-ppKey Vty.KUp         = "Up"
-ppKey Vty.KDown       = "Down"
-ppKey Vty.KLeft       = "Left"
-ppKey Vty.KRight      = "Right"
-ppKey Vty.KHome       = "Home"
-ppKey Vty.KEnd        = "End"
-ppKey Vty.KPageUp     = "PgUp"
-ppKey Vty.KPageDown   = "PgDown"
-ppKey Vty.KDel        = "Del"
-ppKey Vty.KUpLeft     = "UpLeft"
-ppKey Vty.KUpRight    = "UpRight"
-ppKey Vty.KDownLeft   = "DownLeft"
-ppKey Vty.KDownRight  = "DownRight"
-ppKey Vty.KCenter     = "Center"
-ppKey Vty.KPrtScr     = "PrintScreen"
-ppKey Vty.KPause      = "Pause"
-ppKey Vty.KIns        = "Insert"
-ppKey Vty.KBegin      = "Begin"
-ppKey Vty.KMenu       = "Menu"
-
-nonCharKeys :: [Text]
-nonCharKeys = map ppKey
-  [ Vty.KBackTab, Vty.KEsc, Vty.KBS, Vty.KEnter, Vty.KUp, Vty.KDown
-  , Vty.KLeft, Vty.KRight, Vty.KHome, Vty.KEnd, Vty.KPageDown
-  , Vty.KPageUp, Vty.KDel, Vty.KUpLeft, Vty.KUpRight, Vty.KDownLeft
-  , Vty.KDownRight, Vty.KCenter, Vty.KPrtScr, Vty.KPause, Vty.KIns
-  , Vty.KBegin, Vty.KMenu
-  ]
-
-ppChar :: Char -> Text
-ppChar '\t' = "Tab"
-ppChar ' '  = "Space"
-ppChar c    = T.singleton c
-
-ppMod :: Vty.Modifier -> Text
-ppMod Vty.MMeta  = "M"
-ppMod Vty.MAlt   = "A"
-ppMod Vty.MCtrl  = "C"
-ppMod Vty.MShift = "S"
-
-parseBindingList :: Text -> Either String BindingState
-parseBindingList t =
-    if T.toLower t == "unbound"
-    then return Unbound
-    else BindingList <$> mapM (parseBinding . T.strip) (T.splitOn "," t)
-
-keyEventFromName :: Text -> Either String KeyEvent
-keyEventFromName t =
-    let mapping = M.fromList [ (keyEventName e, e) | e <- allEvents ]
-    in case M.lookup t mapping of
-        Just e -> return e
-        Nothing -> Left ("Unknown event: " ++ show t)
-
-keyEventName :: KeyEvent -> Text
-keyEventName ev = case ev of
-  QuitEvent                 -> "quit"
-  VtyRefreshEvent           -> "vty-refresh"
-  ClearUnreadEvent          -> "clear-unread"
-  CancelEvent               -> "cancel"
-
-  ToggleMessagePreviewEvent -> "toggle-message-preview"
-  InvokeEditorEvent         -> "invoke-editor"
-  ToggleMultiLineEvent      -> "toggle-multiline"
-  ReplyRecentEvent          -> "reply-recent"
-
-  EnterFastSelectModeEvent  -> "enter-fast-select"
-  NextChannelEvent          -> "focus-next-channel"
-  PrevChannelEvent          -> "focus-prev-channel"
-  NextChannelEventAlternate -> "focus-next-channel-alternate"
-  PrevChannelEventAlternate -> "focus-prev-channel-alternate"
-  NextUnreadChannelEvent    -> "focus-next-unread"
-  NextUnreadUserOrChannelEvent  -> "focus-next-unread-user-or-channel"
-  LastChannelEvent          -> "focus-last-channel"
-
-  SelectNextTabEvent        -> "select-next-tab"
-  SelectPreviousTabEvent    -> "select-previous-tab"
-
-  ShowAttachmentListEvent   -> "show-attachment-list"
-
-  EditorKillToBolEvent        -> "editor-kill-to-beginning-of-line"
-  EditorKillToEolEvent        -> "editor-kill-to-end-of-line"
-  EditorBolEvent              -> "editor-beginning-of-line"
-  EditorEolEvent              -> "editor-end-of-line"
-  EditorTransposeCharsEvent   -> "editor-transpose-chars"
-  EditorDeleteCharacter       -> "editor-delete-char"
-  EditorPrevCharEvent         -> "editor-prev-char"
-  EditorNextCharEvent         -> "editor-next-char"
-  EditorPrevWordEvent         -> "editor-prev-word"
-  EditorNextWordEvent         -> "editor-next-word"
-  EditorDeleteNextWordEvent   -> "editor-delete-next-word"
-  EditorDeletePrevWordEvent   -> "editor-delete-prev-word"
-  EditorHomeEvent             -> "editor-home"
-  EditorEndEvent              -> "editor-end"
-  EditorYankEvent             -> "editor-yank"
-
-  EnterFlaggedPostsEvent    -> "show-flagged-posts"
-  ToggleChannelListVisibleEvent -> "toggle-channel-list-visibility"
-  ShowHelpEvent             -> "show-help"
-  EnterSelectModeEvent      -> "select-mode"
-  EnterOpenURLModeEvent     -> "enter-url-open"
-
-  LoadMoreEvent             -> "load-more"
-  OpenMessageURLEvent       -> "open-message-url"
-
-  ScrollUpEvent     -> "scroll-up"
-  ScrollDownEvent   -> "scroll-down"
-  ScrollLeftEvent   -> "scroll-left"
-  ScrollRightEvent  -> "scroll-right"
-  PageUpEvent       -> "page-up"
-  PageDownEvent     -> "page-down"
-  PageLeftEvent     -> "page-left"
-  PageRightEvent    -> "page-right"
-  ScrollTopEvent    -> "scroll-top"
-  ScrollBottomEvent -> "scroll-bottom"
-  SelectOldestMessageEvent -> "select-oldest-message"
-
-  SelectUpEvent   -> "select-up"
-  SelectDownEvent -> "select-down"
-
-  SearchSelectUpEvent   -> "search-select-up"
-  SearchSelectDownEvent -> "search-select-down"
-
-  ActivateListItemEvent -> "activate-list-item"
-
-  FlagMessageEvent   -> "flag-message"
-  PinMessageEvent   -> "pin-message"
-  ViewMessageEvent   -> "view-message"
-  FillGapEvent       -> "fetch-for-gap"
-  YankMessageEvent   -> "yank-message"
-  YankWholeMessageEvent   -> "yank-whole-message"
-  DeleteMessageEvent -> "delete-message"
-  EditMessageEvent   -> "edit-message"
-  ReplyMessageEvent  -> "reply-message"
-  ReactToMessageEvent -> "react-to-message"
-
-  AttachmentListAddEvent    -> "add-to-attachment-list"
-  AttachmentListDeleteEvent -> "delete-from-attachment-list"
-  AttachmentOpenEvent       -> "open-attachment"
-
-  FormSubmitEvent -> "submit-form"
diff --git a/src/Types/Messages.hs b/src/Types/Messages.hs
deleted file mode 100644
--- a/src/Types/Messages.hs
+++ /dev/null
@@ -1,739 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-{-# LANGUAGE DeriveAnyClass #-}
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE FlexibleInstances #-}
-{-# LANGUAGE MultiParamTypeClasses #-}
-{-# LANGUAGE TemplateHaskell #-}
-
-{-|
-
-The 'Message' is a single displayed event in a Channel.  All Messages
-have a date/time, and messages that represent posts to the channel
-have a (hash) ID, and displayable text, along with other attributes.
-
-All Messages are sorted chronologically.  There is no assumption that
-the server date/time is synchronized with the local date/time, so all
-of the Message ordering uses the server's date/time.
-
-The mattermost-api retrieves a 'Post' from the server, briefly encodes
-the useful portions of that as a 'ClientPost' object and then converts
-it to a 'Message' inserting this result it into the collection of
-Messages associated with a Channel.  The PostID of the message
-uniquely identifies that message and can be used to interact with the
-server for subsequent operations relative to that message's 'Post'.
-The date/time associated with these messages is generated by the
-server.
-
-There are also "messages" generated directly by the Matterhorn client
-which can be used to display additional, client-related information to
-the user. Examples of these client messages are: date boundaries, the
-"new messages" marker, errors from invoking the browser, etc.  These
-client-generated messages will have a date/time although it is locally
-generated (usually by relation to an associated Post).
-
-Most other Matterhorn operations primarily are concerned with
-user-posted messages (@case mMessageId of Just _@ or @case mType of CP
-_@), but others will include client-generated messages (@case mMessageId
-of Nothing@ or @case mType of C _@).
-
---}
-
-module Types.Messages
-  ( -- * Message and operations on a single Message
-    Message(..)
-  , isDeletable, isReplyable, isReactable, isEditable, isReplyTo, isGap, isFlaggable
-  , isPinnable, isEmote, isJoinLeave, isTransition, isNewMessagesTransition
-  , mText, mUser, mDate, mType, mPending, mDeleted, mPinned
-  , mAttachments, mInReplyToMsg, mMessageId, mReactions, mFlagged
-  , mOriginalPost, mChannelId, mMarkdownSource
-  , isBotMessage
-  , MessageType(..)
-  , MessageId(..)
-  , ThreadState(..)
-  , MentionedUser(..)
-  , isPostMessage
-  , messagePostId
-  , messageIdPostId
-  , UserRef(..)
-  , ReplyState(..)
-  , clientMessageToMessage
-  , clientPostToMessage
-  , clientPostReactionUserIds
-  , newMessageOfType
-    -- * Message Collections
-  , Messages
-  , ChronologicalMessages
-  , RetrogradeMessages
-  , MessageOps (..)
-  , noMessages
-  , messagesLength
-  , filterMessages
-  , reverseMessages
-  , unreverseMessages
-  , splitMessages
-  , splitDirSeqOn
-  , chronologicalMsgsWithThreadStates
-  , retrogradeMsgsWithThreadStates
-  , findMessage
-  , getRelMessageId
-  , messagesHead
-  , messagesDrop
-  , getNextMessage
-  , getPrevMessage
-  , getNextMessageId
-  , getPrevMessageId
-  , getNextPostId
-  , getPrevPostId
-  , getEarliestPostMsg
-  , getLatestPostMsg
-  , getEarliestSelectableMessage
-  , getLatestSelectableMessage
-  , findLatestUserMessage
-  -- * Operations on any Message type
-  , messagesAfter
-  , removeMatchesFromSubset
-  , withFirstMessage
-  , msgURLs
-
-  , LinkChoice(LinkChoice)
-  , linkUser
-  , linkURL
-  , linkTime
-  , linkLabel
-  , linkFileId
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Control.Monad
-import qualified Data.Foldable as F
-import           Data.Hashable ( Hashable )
-import qualified Data.Map.Strict as Map
-import           Data.Sequence as Seq
-import qualified Data.Set as S
-import           Data.Tuple
-import           Data.UUID ( UUID )
-import           GHC.Generics ( Generic )
-import           Lens.Micro.Platform ( makeLenses )
-
-import           Network.Mattermost.Types ( ChannelId, PostId, Post
-                                          , ServerTime, UserId, FileId )
-
-import           Types.DirectionalSeq
-import           Types.Posts
-import           Types.RichText ( RichTextBlock(..), Element(..)
-                                , ElementData(..), findUsernames, blockGetURLs
-                                , ElementStyle(..), URL(..), parseMarkdown
-                                )
-
-
--- | The state of a message's thread context.
-data ThreadState =
-    NoThread
-    -- ^ The message is not in a thread at all.
-    | InThreadShowParent
-    -- ^ The message is in a thread, and the thread's root message
-    -- (parent) should be displayed above this message.
-    | InThread
-    -- ^ The message is in a thread but the thread's root message should
-    -- not be displayed above this message.
-    deriving (Show, Eq)
-
--- ----------------------------------------------------------------------
--- * Messages
-
-data MessageId = MessagePostId PostId
-               | MessageUUID UUID
-               deriving (Eq, Read, Show, Generic, Hashable)
-
-messageIdPostId :: MessageId -> Maybe PostId
-messageIdPostId (MessagePostId p) = Just p
-messageIdPostId _ = Nothing
-
--- | A 'Message' is any message we might want to render, either from
---   Mattermost itself or from a client-internal source.
-data Message = Message
-  { _mText          :: Seq RichTextBlock
-  , _mMarkdownSource :: Text
-  , _mUser          :: UserRef
-  , _mDate          :: ServerTime
-  , _mType          :: MessageType
-  , _mPending       :: Bool
-  , _mDeleted       :: Bool
-  , _mAttachments   :: Seq Attachment
-  , _mInReplyToMsg  :: ReplyState
-  , _mMessageId     :: Maybe MessageId
-  , _mReactions     :: Map.Map Text (S.Set UserId)
-  , _mOriginalPost  :: Maybe Post
-  , _mFlagged       :: Bool
-  , _mPinned        :: Bool
-  , _mChannelId     :: Maybe ChannelId
-  } deriving (Show)
-
-isPostMessage :: Message -> Bool
-isPostMessage m =
-    isJust (_mMessageId m >>= messageIdPostId)
-
-messagePostId :: Message -> Maybe PostId
-messagePostId m = do
-    mId <- _mMessageId m
-    messageIdPostId mId
-
-isDeletable :: Message -> Bool
-isDeletable m =
-    isJust (messagePostId m) &&
-    case _mType m of
-      CP NormalPost -> True
-      CP Emote -> True
-      _ -> False
-
-isFlaggable :: Message -> Bool
-isFlaggable = isJust . messagePostId
-
-isPinnable :: Message -> Bool
-isPinnable = isJust . messagePostId
-
-isReplyable :: Message -> Bool
-isReplyable m =
-    isJust (messagePostId m) &&
-    case _mType m of
-      CP NormalPost -> True
-      CP Emote -> True
-      _ -> False
-
-isReactable :: Message -> Bool
-isReactable m =
-    isJust (messagePostId m) &&
-    case _mType m of
-      CP NormalPost -> True
-      CP Emote -> True
-      _ -> False
-
-isEditable :: Message -> Bool
-isEditable m =
-    isJust (messagePostId m) &&
-    case _mType m of
-      CP NormalPost -> True
-      CP Emote -> True
-      _ -> False
-
-isReplyTo :: PostId -> Message -> Bool
-isReplyTo expectedParentId m =
-    case _mInReplyToMsg m of
-        NotAReply                -> False
-        InReplyTo actualParentId -> actualParentId == expectedParentId
-
-isGap :: Message -> Bool
-isGap m = case _mType m of
-            C UnknownGapBefore -> True
-            C UnknownGapAfter -> True
-            _ -> False
-
-isTransition :: Message -> Bool
-isTransition m = case _mType m of
-                   C DateTransition -> True
-                   C NewMessagesTransition -> True
-                   _ -> False
-
-isNewMessagesTransition :: Message -> Bool
-isNewMessagesTransition m = case _mType m of
-    C NewMessagesTransition -> True
-    _ -> False
-
-isEmote :: Message -> Bool
-isEmote m = case _mType m of
-              CP Emote -> True
-              _ -> False
-
-isJoinLeave :: Message -> Bool
-isJoinLeave m = case _mType m of
-                  CP Join -> True
-                  CP Leave -> True
-                  _ -> False
-
--- | A 'Message' is the representation we use for storage and
---   rendering, so it must be able to represent either a
---   post from Mattermost or an internal message. This represents
---   the union of both kinds of post types.
-data MessageType = C ClientMessageType
-                 | CP ClientPostType
-                 deriving (Show)
-
--- | There may be no user (usually an internal message), a reference to
--- a user (by Id), or the server may have supplied a specific username
--- (often associated with bots). The boolean flag indicates whether the
--- user reference is for a message from a bot.
-data UserRef = NoUser | UserI Bool UserId | UserOverride Bool Text
-               deriving (Eq, Show, Ord)
-
-isBotMessage :: Message -> Bool
-isBotMessage m =
-    case _mUser m of
-        UserI bot _        -> bot
-        UserOverride bot _ -> bot
-        NoUser             -> False
-
--- | The 'ReplyState' of a message represents whether a message
---   is a reply, and if so, to what message
-data ReplyState =
-    NotAReply
-    | InReplyTo PostId
-    deriving (Show, Eq)
-
--- | This type represents links to things in the 'open links' view.
-data LinkChoice =
-    LinkChoice { _linkTime   :: ServerTime
-               , _linkUser   :: UserRef
-               , _linkLabel  :: Maybe (Seq Element)
-               , _linkURL    :: URL
-               , _linkFileId :: Maybe FileId
-               } deriving (Eq, Show)
-
-makeLenses ''LinkChoice
-
--- | Convert a 'ClientMessage' to a 'Message'.  A 'ClientMessage' is
--- one that was generated by the Matterhorn client and which the
--- server knows nothing about.  For example, an error message
--- associated with passing a link to the local browser.
-clientMessageToMessage :: ClientMessage -> Message
-clientMessageToMessage cm = Message
-  { _mText          = parseMarkdown (cm^.cmText)
-  , _mMarkdownSource = cm^.cmText
-  , _mUser          = NoUser
-  , _mDate          = cm^.cmDate
-  , _mType          = C $ cm^.cmType
-  , _mPending       = False
-  , _mDeleted       = False
-  , _mAttachments   = Seq.empty
-  , _mInReplyToMsg  = NotAReply
-  , _mMessageId     = Nothing
-  , _mReactions     = Map.empty
-  , _mOriginalPost  = Nothing
-  , _mFlagged       = False
-  , _mPinned        = False
-  , _mChannelId     = Nothing
-  }
-
-
-data MentionedUser =
-    UsernameMention Text
-    | UserIdMention UserId
-    deriving (Eq, Show, Ord)
-
-clientPostReactionUserIds :: ClientPost -> S.Set UserId
-clientPostReactionUserIds cp =
-    S.unions $ F.toList $ cp^.cpReactions
-
--- | Builds a message from a ClientPost and also returns the set of
--- usernames mentioned in the text of the message.
-clientPostToMessage :: ClientPost -> (Message, S.Set MentionedUser)
-clientPostToMessage cp = (m, mentions)
-    where
-        mentions =
-            S.fromList $
-                (UsernameMention <$> (F.toList $ findUsernames $ cp^.cpText)) <>
-                (UserIdMention <$> (F.toList $ clientPostReactionUserIds cp))
-        m = Message { _mText = cp^.cpText
-                    , _mMarkdownSource = cp^.cpMarkdownSource
-                    , _mUser =
-                        case cp^.cpUserOverride of
-                            Just n | cp^.cpType == NormalPost -> UserOverride (cp^.cpFromWebhook) n
-                            _ -> maybe NoUser (UserI (cp^.cpFromWebhook)) $ cp^.cpUser
-                    , _mDate = cp^.cpDate
-                    , _mType = CP $ cp^.cpType
-                    , _mPending = cp^.cpPending
-                    , _mDeleted = cp^.cpDeleted
-                    , _mAttachments = cp^.cpAttachments
-                    , _mInReplyToMsg =
-                        case cp^.cpInReplyToPost of
-                            Nothing  -> NotAReply
-                            Just pId -> InReplyTo pId
-                    , _mMessageId = Just $ MessagePostId $ cp^.cpPostId
-                    , _mReactions = cp^.cpReactions
-                    , _mOriginalPost = Just $ cp^.cpOriginalPost
-                    , _mFlagged = False
-                    , _mPinned = cp^.cpPinned
-                    , _mChannelId = Just $ cp^.cpChannelId
-                    }
-
-
-newMessageOfType :: Text -> MessageType -> ServerTime -> Message
-newMessageOfType text typ d = Message
-  { _mText         = parseMarkdown text
-  , _mMarkdownSource = text
-  , _mUser         = NoUser
-  , _mDate         = d
-  , _mType         = typ
-  , _mPending      = False
-  , _mDeleted      = False
-  , _mAttachments  = Seq.empty
-  , _mInReplyToMsg = NotAReply
-  , _mMessageId    = Nothing
-  , _mReactions    = Map.empty
-  , _mOriginalPost = Nothing
-  , _mFlagged      = False
-  , _mPinned       = False
-  , _mChannelId    = Nothing
-  }
-
--- ** 'Message' Lenses
-
-makeLenses ''Message
-
--- ----------------------------------------------------------------------
-
--- * Message Collections
-
--- | A wrapper for an ordered, unique list of 'Message' values.
---
--- This type has (and promises) the following instances: Show,
--- Functor, Monoid, Foldable, Traversable
-type ChronologicalMessages = DirectionalSeq Chronological Message
-type Messages = ChronologicalMessages
-
--- | There are also cases where the list of 'Message' values are kept
--- in reverse order (most recent -> oldest); these cases are
--- represented by the `RetrogradeMessages` type.
-type RetrogradeMessages = DirectionalSeq Retrograde Message
-
--- ** Common operations on Messages
-
-filterMessages :: SeqDirection seq
-               => (a -> Bool)
-               -> DirectionalSeq seq a
-               -> DirectionalSeq seq a
-filterMessages f = onDirectedSeq (Seq.filter f)
-
-class MessageOps a where
-    -- | addMessage inserts a date in proper chronological order, with
-    -- the following extra functionality:
-    --     * no duplication (by PostId)
-    --     * no duplication (adjacent UnknownGap entries)
-    addMessage :: Message -> a -> a
-
-instance MessageOps ChronologicalMessages where
-    addMessage m ml =
-        case viewr (dseq ml) of
-            EmptyR -> DSeq $ singleton m
-            _ :> l ->
-                case compare (m^.mDate) (l^.mDate) of
-                  GT -> DSeq $ dseq ml |> m
-                  EQ -> if m^.mMessageId == l^.mMessageId && isJust (m^.mMessageId)
-                        then ml
-                        else dirDateInsert m ml
-                  LT -> dirDateInsert m ml
-
-dirDateInsert :: Message -> ChronologicalMessages -> ChronologicalMessages
-dirDateInsert m = onDirectedSeq $ finalize . foldr insAfter initial
-   where initial = (Just m, mempty)
-         insAfter c (Nothing, l) = (Nothing, c <| l)
-         insAfter c (Just n, l) =
-             case compare (n^.mDate) (c^.mDate) of
-               GT -> (Nothing, c <| (n <| l))
-               EQ -> if n^.mMessageId == c^.mMessageId && isJust (c^.mMessageId)
-                     then (Nothing, c <| l)
-                     else (Just n, c <| l)
-               LT -> (Just n, c <| l)
-         finalize (Just n, l) = n <| l
-         finalize (_, l) = l
-
-noMessages :: Messages
-noMessages = DSeq mempty
-
-messagesLength :: DirectionalSeq seq a -> Int
-messagesLength (DSeq ms) = Seq.length ms
-
--- | Reverse the order of the messages
-reverseMessages :: Messages -> RetrogradeMessages
-reverseMessages = DSeq . Seq.reverse . dseq
-
--- | Unreverse the order of the messages
-unreverseMessages :: RetrogradeMessages -> Messages
-unreverseMessages = DSeq . Seq.reverse . dseq
-
-splitDirSeqOn :: SeqDirection d
-              => (a -> Bool)
-              -> DirectionalSeq d a
-              -> (Maybe a, (DirectionalSeq (ReverseDirection d) a,
-                            DirectionalSeq d a))
-splitDirSeqOn f msgs =
-    let (removed, remaining) = dirSeqBreakl f msgs
-        devomer = DSeq $ Seq.reverse $ dseq removed
-    in (withDirSeqHead id remaining, (devomer, onDirectedSeq (Seq.drop 1) remaining))
-
--- ----------------------------------------------------------------------
--- * Operations on Posted Messages
-
--- | Searches for the specified MessageId and returns a tuple where the
--- first element is the Message associated with the MessageId (if it
--- exists), and the second element is another tuple: the first element
--- of the second is all the messages from the beginning of the list to
--- the message just before the MessageId message (or all messages if not
--- found) *in reverse order*, and the second element of the second are
--- all the messages that follow the found message (none if the message
--- was never found) in *forward* order.
-splitMessages :: Maybe MessageId
-              -> DirectionalSeq Chronological (Message, ThreadState)
-              -> (Maybe (Message, ThreadState),
-                   ( DirectionalSeq Retrograde (Message, ThreadState),
-                     DirectionalSeq Chronological (Message, ThreadState)))
-splitMessages mid msgs = splitDirSeqOn (\(m, _) -> isJust mid && m^.mMessageId == mid) msgs
-
--- | Given a message and its chronological predecessor, return
--- the thread state of the specified message with respect to its
--- predecessor.
-threadStateFor :: Message
-               -- ^ The message whose state is to be obtained.
-               -> Message
-               -- ^ The message's predecessor.
-               -> ThreadState
-threadStateFor msg prev = case msg^.mInReplyToMsg of
-    InReplyTo rootId ->
-        if | (prev^.mMessageId) == Just (MessagePostId rootId) ->
-               InThread
-           | prev^.mInReplyToMsg == msg^.mInReplyToMsg ->
-               InThread
-           | otherwise ->
-               InThreadShowParent
-    _ -> NoThread
-
-retrogradeMsgsWithThreadStates :: RetrogradeMessages -> DirectionalSeq Retrograde (Message, ThreadState)
-retrogradeMsgsWithThreadStates msgs = DSeq $ checkAdjacentMessages (dseq msgs)
-    where
-        getMessagePredecessor ms =
-                let visiblePredMsg m = not (isTransition m || m^.mDeleted) in
-                case Seq.viewl ms of
-                    prev Seq.:< rest ->
-                        if visiblePredMsg prev
-                        then Just prev
-                        else getMessagePredecessor rest
-                    Seq.EmptyL -> Nothing
-
-        checkAdjacentMessages s = case Seq.viewl s of
-            Seq.EmptyL -> mempty
-            m Seq.:< t ->
-                let new_m = case getMessagePredecessor t of
-                        Just prev -> (m, threadStateFor m prev)
-                        Nothing -> case m^.mInReplyToMsg of
-                            InReplyTo _ -> (m, InThreadShowParent)
-                            _           -> (m, NoThread)
-                in new_m Seq.<| checkAdjacentMessages t
-
-chronologicalMsgsWithThreadStates :: Messages -> DirectionalSeq Chronological (Message, ThreadState)
-chronologicalMsgsWithThreadStates msgs = DSeq $ checkAdjacentMessages (dseq msgs)
-    where
-        getMessagePredecessor ms =
-                let visiblePredMsg m = not (isTransition m || m^.mDeleted) in
-                case Seq.viewr ms of
-                    rest Seq.:> prev ->
-                        if visiblePredMsg prev
-                        then Just prev
-                        else getMessagePredecessor rest
-                    Seq.EmptyR -> Nothing
-
-        checkAdjacentMessages s = case Seq.viewr s of
-            Seq.EmptyR -> mempty
-            t Seq.:> m ->
-                let new_m = case getMessagePredecessor t of
-                        Just prev -> (m, threadStateFor m prev)
-                        Nothing -> case m^.mInReplyToMsg of
-                            InReplyTo _ -> (m, InThreadShowParent)
-                            _           -> (m, NoThread)
-                in checkAdjacentMessages t Seq.|> new_m
-
--- | findMessage searches for a specific message as identified by the
--- PostId.  The search starts from the most recent messages because
--- that is the most likely place the message will occur.
-findMessage :: MessageId -> Messages -> Maybe Message
-findMessage mid msgs =
-    findIndexR (\m -> m^.mMessageId == Just mid) (dseq msgs)
-    >>= Just . Seq.index (dseq msgs)
-
--- | Look forward for the first Message with an ID that follows the
--- specified Id and return it.  If no input Id supplied, get the
--- latest (most recent chronologically) Message in the input set.
-getNextMessage :: Maybe MessageId -> Messages -> Maybe Message
-getNextMessage = getRelMessageId
-
--- | Look backward for the first Message with an ID that follows the
--- specified MessageId and return it.  If no input MessageId supplied,
--- get the latest (most recent chronologically) Message in the input
--- set.
-getPrevMessage :: Maybe MessageId -> Messages -> Maybe Message
-getPrevMessage mId = getRelMessageId mId . reverseMessages
-
-messagesHead :: (SeqDirection seq) => DirectionalSeq seq a -> Maybe a
-messagesHead = withDirSeqHead id
-
-messagesDrop :: (SeqDirection seq) => Int -> DirectionalSeq seq a -> DirectionalSeq seq a
-messagesDrop i = onDirectedSeq (Seq.drop i)
-
--- | Look forward for the first Message with an ID that follows the
--- specified MessageId and return that found Message's ID; if no input
--- MessageId is specified, return the latest (most recent
--- chronologically) MessageId (if any) in the input set.
-getNextMessageId :: Maybe MessageId -> Messages -> Maybe MessageId
-getNextMessageId mId = _mMessageId <=< getNextMessage mId
-
--- | Look backwards for the first Message with an ID that comes before
--- the specified MessageId and return that found Message's ID; if no
--- input MessageId is specified, return the latest (most recent
--- chronologically) MessageId (if any) in the input set.
-getPrevMessageId :: Maybe MessageId -> Messages -> Maybe MessageId
-getPrevMessageId mId = _mMessageId <=< getPrevMessage mId
-
--- | Look forward for the first Message with an ID that follows the
--- specified PostId and return that found Message's PostID; if no
--- input PostId is specified, return the latest (most recent
--- chronologically) PostId (if any) in the input set.
-getNextPostId :: Maybe PostId -> Messages -> Maybe PostId
-getNextPostId pid = messagePostId <=< getNextMessage (MessagePostId <$> pid)
-
--- | Look backwards for the first Post with an ID that comes before
--- the specified PostId.
-getPrevPostId :: Maybe PostId -> Messages -> Maybe PostId
-getPrevPostId pid = messagePostId <=< getPrevMessage (MessagePostId <$> pid)
-
-
-getRelMessageId :: SeqDirection dir =>
-                   Maybe MessageId
-                -> DirectionalSeq dir Message
-                -> Maybe Message
-getRelMessageId mId =
-  let isMId = const ((==) mId . _mMessageId) <$> mId
-  in getRelMessage isMId
-
--- | Internal worker function to return a different user message in
--- relation to either the latest point or a specific message.
-getRelMessage :: SeqDirection dir =>
-                 Maybe (Message -> Bool)
-              -> DirectionalSeq dir Message
-              -> Maybe Message
-getRelMessage matcher msgs =
-  let after = case matcher of
-                Just matchFun -> case splitDirSeqOn matchFun msgs of
-                                   (_, (_, ms)) -> ms
-                Nothing -> msgs
-  in withDirSeqHead id $ filterMessages validSelectableMessage after
-
--- | Find the most recent message that is a Post (as opposed to a
--- local message) (if any).
-getLatestPostMsg :: Messages -> Maybe Message
-getLatestPostMsg msgs =
-    case viewr $ dropWhileR (not . validUserMessage) (dseq msgs) of
-      EmptyR -> Nothing
-      _ :> m -> Just m
-
--- | Find the oldest message that is a message with an ID.
-getEarliestSelectableMessage :: Messages -> Maybe Message
-getEarliestSelectableMessage msgs =
-    case viewl $ dropWhileL (not . validSelectableMessage) (dseq msgs) of
-      EmptyL -> Nothing
-      m :< _ -> Just m
-
--- | Find the most recent message that is a message with an ID.
-getLatestSelectableMessage :: Messages -> Maybe Message
-getLatestSelectableMessage msgs =
-    case viewr $ dropWhileR (not . validSelectableMessage) (dseq msgs) of
-      EmptyR -> Nothing
-      _ :> m -> Just m
-
--- | Find the earliest message that is a Post (as opposed to a
--- local message) (if any).
-getEarliestPostMsg :: Messages -> Maybe Message
-getEarliestPostMsg msgs =
-    case viewl $ dropWhileL (not . validUserMessage) (dseq msgs) of
-      EmptyL -> Nothing
-      m :< _ -> Just m
-
--- | Find the most recent message that is a message posted by a user
--- that matches the test (if any), skipping local client messages and
--- any user event that is not a message (i.e. find a normal message or
--- an emote).
-findLatestUserMessage :: (Message -> Bool) -> Messages -> Maybe Message
-findLatestUserMessage f ml =
-    case viewr $ dropWhileR (\m -> not (validUserMessage m && f m)) $ dseq ml of
-      EmptyR -> Nothing
-      _ :> m -> Just m
-
-validUserMessage :: Message -> Bool
-validUserMessage m =
-    not (m^.mDeleted) && case m^.mMessageId of
-        Just (MessagePostId _) -> True
-        _ -> False
-
-validSelectableMessage :: Message -> Bool
-validSelectableMessage m = (not $ m^.mDeleted) && (isJust $ m^.mMessageId)
-
--- ----------------------------------------------------------------------
--- * Operations on any Message type
-
--- | Return all messages that were posted after the specified date/time.
-messagesAfter :: ServerTime -> Messages -> Messages
-messagesAfter viewTime = onDirectedSeq $ takeWhileR (\m -> m^.mDate > viewTime)
-
--- | Removes any Messages (all types) for which the predicate is true
--- from the specified subset of messages (identified by a starting and
--- ending MessageId, inclusive) and returns the resulting list (from
--- start to finish, irrespective of 'firstId' and 'lastId') and the
--- list of removed items.
---
---    start       | end          |  operates-on              | (test) case
---   --------------------------------------------------------|-------------
---   Nothing      | Nothing      | entire list               |  C1
---   Nothing      | Just found   | start --> found]          |  C2
---   Nothing      | Just missing | nothing [suggest invalid] |  C3
---   Just found   | Nothing      | [found --> end            |  C4
---   Just found   | Just found   | [found --> found]         |  C5
---   Just found   | Just missing | [found --> end            |  C6
---   Just missing | Nothing      | nothing [suggest invalid] |  C7
---   Just missing | Just found   | start --> found]          |  C8
---   Just missing | Just missing | nothing [suggest invalid] |  C9
---
---  @removeMatchesFromSubset matchPred fromId toId msgs = (remaining, removed)@
---
-removeMatchesFromSubset :: (Message -> Bool) -> Maybe MessageId -> Maybe MessageId
-                        -> Messages -> (Messages, Messages)
-removeMatchesFromSubset matching firstId lastId msgs =
-    let knownIds = fmap (^.mMessageId) msgs
-    in if isNothing firstId && isNothing lastId
-       then swap $ dirSeqPartition matching msgs
-       else if isJust firstId && firstId `elem` knownIds
-            then onDirSeqSubset
-                (\m -> m^.mMessageId == firstId)
-                (if isJust lastId then \m -> m^.mMessageId == lastId else const False)
-                (swap . dirSeqPartition matching) msgs
-            else if isJust lastId && lastId `elem` knownIds
-                 then onDirSeqSubset
-                     (const True)
-                     (\m -> m^.mMessageId == lastId)
-                     (swap . dirSeqPartition matching) msgs
-                 else (msgs, noMessages)
-
--- | Performs an operation on the first Message, returning just the
--- result of that operation, or Nothing if there were no messages.
--- Note that the message is not necessarily a posted user message.
-withFirstMessage :: SeqDirection dir => (Message -> r) -> DirectionalSeq dir Message -> Maybe r
-withFirstMessage = withDirSeqHead
-
-msgURLs :: Message -> Seq LinkChoice
-msgURLs msg =
-  let uRef = msg^.mUser
-      msgUrls = (\ (url, text) -> LinkChoice (msg^.mDate) uRef text url Nothing) <$>
-                  (Seq.fromList $ mconcat $ blockGetURLs <$> (toList $ msg^.mText))
-      attachmentURLs = (\ a ->
-                          LinkChoice
-                            (msg^.mDate)
-                            uRef
-                            (Just $ attachmentLabel a)
-                            (URL $ a^.attachmentURL)
-                            (Just (a^.attachmentFileId)))
-                       <$> (msg^.mAttachments)
-      attachmentLabel a =
-          Seq.fromList [ Element Normal $ EText "attachment"
-                       , Element Normal ESpace
-                       , Element Code $ EText $ a^.attachmentName
-                       ]
-  in msgUrls <> attachmentURLs
diff --git a/src/Types/Posts.hs b/src/Types/Posts.hs
deleted file mode 100644
--- a/src/Types/Posts.hs
+++ /dev/null
@@ -1,228 +0,0 @@
-{-# LANGUAGE MultiWayIf #-}
-{-# LANGUAGE TemplateHaskell #-}
-module Types.Posts
-  ( ClientMessage
-  , newClientMessage
-  , cmDate
-  , cmType
-  , cmText
-
-  , ClientMessageType(..)
-
-  , Attachment
-  , mkAttachment
-  , attachmentName
-  , attachmentFileId
-  , attachmentURL
-
-  , ClientPostType(..)
-
-  , ClientPost
-  , toClientPost
-  , cpUserOverride
-  , cpMarkdownSource
-  , cpUser
-  , cpText
-  , cpType
-  , cpReactions
-  , cpPending
-  , cpOriginalPost
-  , cpFromWebhook
-  , cpInReplyToPost
-  , cpDate
-  , cpChannelId
-  , cpAttachments
-  , cpDeleted
-  , cpPostId
-  , cpPinned
-
-  , unEmote
-
-  , postIsLeave
-  , postIsJoin
-  , postIsTopicChange
-  , postIsEmote
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Data.Map.Strict as Map
-import qualified Data.Sequence as Seq
-import qualified Data.Text as T
-import qualified Data.Set as S
-import           Data.Time.Clock ( getCurrentTime )
-import           Lens.Micro.Platform ( makeLenses )
-
-import           Network.Mattermost.Lenses
-import           Network.Mattermost.Types
-
-import           Types.Common
-import           Types.RichText ( RichTextBlock(Blockquote), parseMarkdown )
-
-
--- * Client Messages
-
--- | A 'ClientMessage' is a message given to us by our client,
---   like help text or an error message.
-data ClientMessage = ClientMessage
-  { _cmText :: Text
-  , _cmDate :: ServerTime
-  , _cmType :: ClientMessageType
-  } deriving (Show)
-
--- | Create a new 'ClientMessage' value.  This is a message generated
--- by this Matterhorn client and not by (or visible to) the Server.
--- These should be visible, but not necessarily integrated into any
--- special position in the output stream (i.e., they should generally
--- appear at the bottom of the messages display, but subsequent
--- messages should follow them), so this is a special place where
--- there is an assumed approximation of equality between local time
--- and server time.
-newClientMessage :: (MonadIO m) => ClientMessageType -> Text -> m ClientMessage
-newClientMessage ty msg = do
-  now <- liftIO getCurrentTime
-  return (ClientMessage msg (ServerTime now) ty)
-
--- | We format 'ClientMessage' values differently depending on
---   their 'ClientMessageType'
-data ClientMessageType =
-    Informative
-    | Error
-    | DateTransition
-    | NewMessagesTransition
-    | UnknownGapBefore -- ^ a region where the server may have
-                       -- messages before the given timestamp that are
-                       -- not known locally by this client
-    | UnknownGapAfter  -- ^ a region where server may have messages
-                       -- after the given timestamp that are not known
-                       -- locally by this client
-    deriving (Show)
-
--- ** 'ClientMessage' Lenses
-
-makeLenses ''ClientMessage
-
--- * Mattermost Posts
-
--- | A 'ClientPost' is a temporary internal representation of
---   the Mattermost 'Post' type, with unnecessary information
---   removed and some preprocessing done.
-data ClientPost = ClientPost
-  { _cpText          :: Seq RichTextBlock
-  , _cpMarkdownSource :: Text
-  , _cpUser          :: Maybe UserId
-  , _cpUserOverride  :: Maybe Text
-  , _cpDate          :: ServerTime
-  , _cpType          :: ClientPostType
-  , _cpPending       :: Bool
-  , _cpDeleted       :: Bool
-  , _cpAttachments   :: Seq Attachment
-  , _cpInReplyToPost :: Maybe PostId
-  , _cpPostId        :: PostId
-  , _cpChannelId     :: ChannelId
-  , _cpReactions     :: Map.Map Text (S.Set UserId)
-  , _cpOriginalPost  :: Post
-  , _cpFromWebhook   :: Bool
-  , _cpPinned        :: Bool
-  } deriving (Show)
-
--- | An attachment has a very long URL associated, as well as
---   an actual file URL
-data Attachment = Attachment
-  { _attachmentName   :: Text
-  , _attachmentURL    :: Text
-  , _attachmentFileId :: FileId
-  } deriving (Eq, Show)
-
-mkAttachment :: Text -> Text -> FileId -> Attachment
-mkAttachment = Attachment
-
--- | A Mattermost 'Post' value can represent either a normal
---   chat message or one of several special events.
-data ClientPostType =
-    NormalPost
-    | Emote
-    | Join
-    | Leave
-    | TopicChange
-    deriving (Eq, Show)
-
--- ** Creating 'ClientPost' Values
-
--- | Determine the internal 'PostType' based on a 'Post'
-postClientPostType :: Post -> ClientPostType
-postClientPostType cp =
-    if | postIsEmote cp       -> Emote
-       | postIsJoin  cp       -> Join
-       | postIsLeave cp       -> Leave
-       | postIsTopicChange cp -> TopicChange
-       | otherwise            -> NormalPost
-
--- | Find out whether a 'Post' represents a topic change
-postIsTopicChange :: Post -> Bool
-postIsTopicChange p = postType p == PostTypeHeaderChange
-
--- | Find out whether a 'Post' is from a @/me@ command
-postIsEmote :: Post -> Bool
-postIsEmote p =
-    and [ p^.postPropsL.postPropsOverrideIconUrlL == Just (""::Text)
-        , ("*" `T.isPrefixOf` (sanitizeUserText $ postMessage p))
-        , ("*" `T.isSuffixOf` (sanitizeUserText $ postMessage p))
-        ]
-
--- | Find out whether a 'Post' is a user joining a channel
-postIsJoin :: Post -> Bool
-postIsJoin p =
-  p^.postTypeL == PostTypeJoinChannel
-
--- | Find out whether a 'Post' is a user leaving a channel
-postIsLeave :: Post -> Bool
-postIsLeave p =
-  p^.postTypeL == PostTypeLeaveChannel
-
--- | Undo the automatic formatting of posts generated by @/me@-commands
-unEmote :: ClientPostType -> Text -> Text
-unEmote Emote t = if "*" `T.isPrefixOf` t && "*" `T.isSuffixOf` t
-                  then T.init $ T.tail t
-                  else t
-unEmote _ t = t
-
--- | Convert a Mattermost 'Post' to a 'ClientPost', passing in a
---   'ParentId' if it has a known one.
-toClientPost :: Post -> Maybe PostId -> ClientPost
-toClientPost p parentId =
-  let src = unEmote (postClientPostType p) $ sanitizeUserText $ postMessage p
-  in ClientPost { _cpText          = parseMarkdown src <> getAttachmentText p
-                , _cpMarkdownSource = src
-                , _cpUser          = postUserId p
-                , _cpUserOverride  = p^.postPropsL.postPropsOverrideUsernameL
-                , _cpDate          = postCreateAt p
-                , _cpType          = postClientPostType p
-                , _cpPending       = False
-                , _cpDeleted       = False
-                , _cpPinned        = fromMaybe False $ postPinned p
-                , _cpAttachments   = Seq.empty
-                , _cpInReplyToPost = parentId
-                , _cpPostId        = p^.postIdL
-                , _cpChannelId     = p^.postChannelIdL
-                , _cpReactions     = Map.empty
-                , _cpOriginalPost  = p
-                , _cpFromWebhook   = fromMaybe False $ p^.postPropsL.postPropsFromWebhookL
-                }
-
--- | Right now, instead of treating 'attachment' properties specially, we're
---   just going to roll them directly into the message text
-getAttachmentText :: Post -> Seq RichTextBlock
-getAttachmentText p =
-  case p^.postPropsL.postPropsAttachmentsL of
-    Nothing -> Seq.empty
-    Just attachments ->
-      fmap (Blockquote . render) attachments
-  where render att = parseMarkdown (att^.ppaTextL) <> parseMarkdown (att^.ppaFallbackL)
-
--- ** 'ClientPost' Lenses
-
-makeLenses ''Attachment
-makeLenses ''ClientPost
diff --git a/src/Types/RichText.hs b/src/Types/RichText.hs
deleted file mode 100644
--- a/src/Types/RichText.hs
+++ /dev/null
@@ -1,409 +0,0 @@
--- | This module provides a set of data types to represent message text.
--- The types here are based loosely on the @cheapskate@ package's types
--- but provide higher-level support for the kinds of things we find in
--- Mattermost messages such as user and channel references.
---
--- To parse a Markdown document, use 'parseMarkdown'. To actually render
--- text in this representation, see the module 'Draw.RichText'.
-module Types.RichText
-  ( RichTextBlock(..)
-  , ListType(..)
-  , CodeBlockInfo(..)
-  , NumDecoration(..)
-  , Element(..)
-  , ElementData(..)
-  , ElementStyle(..)
-
-  , URL(..)
-  , unURL
-
-  , parseMarkdown
-  , setElementStyle
-
-  , findUsernames
-  , blockGetURLs
-  , findVerbatimChunk
-
-  -- Exposed for testing only:
-  , fromMarkdownBlocks
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Cheapskate as C
-import           Data.Char ( isAlphaNum, isAlpha )
-import qualified Data.Foldable as F
-import           Data.Monoid (First(..))
-import qualified Data.Set as S
-import qualified Data.Sequence as Seq
-import           Data.Sequence ( (<|), ViewL((:<)) )
-import qualified Data.Text as T
-
-import           Constants ( userSigil, normalChannelSigil )
-
--- | A block in a rich text document.
-data RichTextBlock =
-    Para (Seq Element)
-    -- ^ A paragraph.
-    | Header Int (Seq Element)
-    -- ^ A section header with specified depth and contents.
-    | Blockquote (Seq RichTextBlock)
-    -- ^ A blockquote.
-    | List Bool ListType (Seq (Seq RichTextBlock))
-    -- ^ An itemized list.
-    | CodeBlock CodeBlockInfo Text
-    -- ^ A code block.
-    | HTMLBlock Text
-    -- ^ A fragment of raw HTML.
-    | HRule
-    -- ^ A horizontal rule.
-    deriving (Show)
-
--- | The type of itemized list items.
-data ListType =
-    Bullet Char
-    -- ^ Decorate the items with bullet using the specified character.
-    | Numbered NumDecoration Int
-    -- ^ Number the items starting at the specified number; use the
-    -- indicated decoration following the number.
-    deriving (Eq, Show)
-
--- | Information about a code block.
-data CodeBlockInfo =
-    CodeBlockInfo { codeBlockLanguage :: Maybe Text
-                  -- ^ The language of the source code in the code
-                  -- block, if any. This is encoded in Markdown as a
-                  -- sequence of non-whitespace characters following the
-                  -- fenced code block opening backticks.
-                  , codeBlockInfo :: Maybe Text
-                  -- ^ Any text that comes after the language token.
-                  -- This text is separated from the language token by
-                  -- whitespace.
-                  }
-                  deriving (Eq, Show)
-
--- | Ways to decorate numbered itemized list items. The decoration
--- follows the list item number.
-data NumDecoration =
-    Paren
-    | Period
-    deriving (Eq, Show)
-
--- | A single logical inline element in a rich text block.
-data Element =
-    Element { eStyle :: ElementStyle
-            -- ^ The element's visual style.
-            , eData :: ElementData
-            -- ^ The element's data.
-            }
-            deriving (Show, Eq)
-
-setElementStyle :: ElementStyle -> Element -> Element
-setElementStyle s e = e { eStyle = s }
-
--- | A URL.
-newtype URL = URL Text
-            deriving (Eq, Show, Ord)
-
-unURL :: URL -> Text
-unURL (URL url) = url
-
--- | The kinds of data that can appear in rich text elements.
-data ElementData =
-    EText Text
-    -- ^ A sequence of non-whitespace characters.
-    | ESpace
-    -- ^ A single space.
-    | ESoftBreak
-    -- ^ A soft line break.
-    | ELineBreak
-    -- ^ A hard line break.
-    | ERawHtml Text
-    -- ^ Raw HTML.
-    | EEditSentinel Bool
-    -- ^ A sentinel indicating that some text has been edited (used
-    -- to indicate that mattermost messages have been edited by their
-    -- authors). This has no parsable representation; it is only used
-    -- to annotate a message prior to rendering to add a visual editing
-    -- indicator. The boolean indicates whether the edit was "recent"
-    -- (True) or not (False).
-    | EUser Text
-    -- ^ A user reference. The text here includes only the username, not
-    -- the sigil.
-    | EChannel Text
-    -- ^ A channel reference. The text here includes only the channel
-    -- name, not the sigil.
-    | EHyperlink URL (Maybe (Seq Element))
-    -- ^ A hyperlink to the specified URL. Optionally provides an
-    -- element sequence indicating the URL's text label; if absent, the
-    -- label is understood to be the URL itself.
-    | EImage URL (Maybe (Seq Element))
-    -- ^ An image at the specified URL. Optionally provides an element
-    -- sequence indicating the image's "alt" text label; if absent, the
-    -- label is understood to be the URL itself.
-    | EEmoji Text
-    -- ^ An emoji reference. The text here includes only the text
-    -- portion, not the colons, e.g. "foo" instead of ":foo:".
-    deriving (Show, Eq)
-
--- | Element visual styles.
-data ElementStyle =
-    Normal
-    -- ^ Normal text
-    | Emph
-    -- ^ Emphasized text
-    | Strong
-    -- ^ Bold text
-    | Code
-    -- ^ Inline code segment or code block
-    | Hyperlink URL ElementStyle
-    -- ^ A terminal hyperlink to the specified URL, composed with
-    -- another element style
-    deriving (Eq, Show)
-
-parseMarkdown :: T.Text -> Seq RichTextBlock
-parseMarkdown t = fromMarkdownBlocks bs where C.Doc _ bs = C.markdown C.def t
-
--- | Convert a sequence of markdown (Cheapskate) blocks into rich text
--- blocks.
-fromMarkdownBlocks :: C.Blocks -> Seq RichTextBlock
-fromMarkdownBlocks = fmap fromMarkdownBlock
-
--- | Convert a single markdown block into a single rich text block.
-fromMarkdownBlock :: C.Block -> RichTextBlock
-fromMarkdownBlock (C.Para is) =
-    Para $ fromMarkdownInlines is
-fromMarkdownBlock (C.Header level is) =
-    Header level $ fromMarkdownInlines is
-fromMarkdownBlock (C.Blockquote bs) =
-    Blockquote $ fromMarkdownBlock <$> bs
-fromMarkdownBlock (C.List f ty bss) =
-    List f (fromMarkdownListType ty) $ fmap fromMarkdownBlock <$> Seq.fromList bss
-fromMarkdownBlock (C.CodeBlock attr body) =
-    CodeBlock (fromMarkdownCodeAttr attr) body
-fromMarkdownBlock (C.HtmlBlock body) =
-    HTMLBlock body
-fromMarkdownBlock C.HRule =
-    HRule
-
-fromMarkdownCodeAttr :: C.CodeAttr -> CodeBlockInfo
-fromMarkdownCodeAttr (C.CodeAttr lang info) =
-    let strippedLang = T.strip lang
-        strippedInfo = T.strip info
-        maybeText t = if T.null t then Nothing else Just t
-    in CodeBlockInfo (maybeText strippedLang)
-                     (maybeText strippedInfo)
-
-fromMarkdownListType :: C.ListType -> ListType
-fromMarkdownListType (C.Bullet c) =
-    Bullet c
-fromMarkdownListType (C.Numbered wrap i) =
-    let dec = case wrap of
-                  C.PeriodFollowing -> Period
-                  C.ParenFollowing -> Paren
-    in Numbered dec i
-
--- | Remove hyperlinks from an inline sequence. This should only be used
--- for sequences that are themselves used as labels for hyperlinks; this
--- prevents them from embedding their own hyperlinks, which is nonsense.
---
--- Any hyperlinks found in the sequence will be replaced with a text
--- represent of their URL (if they have no label) or the label itself
--- otherwise.
-removeHyperlinks :: Seq C.Inline -> Seq C.Inline
-removeHyperlinks is =
-    case Seq.viewl is of
-        h :< t ->
-            case h of
-                C.Link label theUrl _ ->
-                    if Seq.null label
-                    then C.Str theUrl <| removeHyperlinks t
-                    else removeHyperlinks label <> removeHyperlinks t
-                _ -> h <| removeHyperlinks t
-        Seq.EmptyL -> mempty
-
--- | Convert a sequence of Markdown inline values to a sequence of
--- Elements.
---
--- This conversion converts sequences of Markdown AST fragments into
--- RichText elements. In particular, this function may determine that
--- some sequences of Markdown text fragments belong together, such as
--- the sequence @["@", "joe", "-", "user"]@, which should be treated as
--- a single "@joe-user" token due to username character validation. When
--- appropriate, this function does such consolidation when converting to
--- RichText elements.
---
--- This function is also partially responsible for paving the way for
--- line-wrapping later on when RichText is rendered. This means that,
--- when possible, the elements produced by this function need to be as
--- small as possible, without losing structure information. An example
--- of this is Markdown inline code fragments, such as "`this is code`".
--- This function will convert that one Markdown inline code fragment
--- into a sequence of five RichText elements, each with a "Code" style
--- assigned: @[EText "this", ESpace, EText "is", ESpace, EText "code"]@.
--- This "flattening" of the original Markdown structure makes it much
--- easier to do line-wrapping without losing style information because
--- it is possible to gather up tokens that do not exceed line widths
--- without losing style information. This is key to rendering the text
--- with the right terminal attributes.
---
--- However, there are a couple of cases where such flattening does *not*
--- happen: hyperlinks and images. In these cases we do not flatten the
--- (arbitrary) text label structure of links and images because we need
--- to be able to recover those labels to gather up URLs to show to the
--- user in the URL list. So we leave the complete text structure of
--- those labels behind in the 'EHyperlink' and 'EImage' constructors as
--- sequences of Elements. This means that logic in 'Draw.RichText' that
--- does line-wrapping will have to explicitly break up link and image
--- labels across line breaks.
-fromMarkdownInlines :: Seq C.Inline -> Seq Element
-fromMarkdownInlines inlines =
-    let go sty is = case Seq.viewl is of
-          C.Str ":" :< xs ->
-              let validEmojiFragment (C.Str f) =
-                      f `elem` ["_", "-"] || T.all isAlphaNum f
-                  validEmojiFragment _ = False
-                  (emojiFrags, rest) = Seq.spanl validEmojiFragment xs
-                  em = T.concat $ unsafeGetStr <$> F.toList emojiFrags
-              in case Seq.viewl rest of
-                  C.Str ":" :< rest2 ->
-                      Element Normal (EEmoji em) <| go sty rest2
-                  _ ->
-                      Element sty (EText ":") <| go sty xs
-          C.Str t :< xs | userSigil `T.isPrefixOf` t ->
-              let (uFrags, rest) = Seq.spanl isNameFragment xs
-                  t' = T.concat $ t : (unsafeGetStr <$> F.toList uFrags)
-                  u = T.drop 1 t'
-              in Element sty (EUser u) <| go sty rest
-          C.Str t :< xs | normalChannelSigil `T.isPrefixOf` t ->
-              let (cFrags, rest) = Seq.spanl isNameFragment xs
-                  cn = T.drop 1 $ T.concat $ t : (unsafeGetStr <$> F.toList cFrags)
-              in Element sty (EChannel cn) <| go sty rest
-          C.Str t :< xs ->
-              -- When we encounter a string node, we go ahead and
-              -- process the rest of the nodes in the sequence. If the
-              -- new sequence starts with *another* string node with
-              -- the same style, we merge them. We do this because
-              -- Cheapskate will parse things like punctuation as
-              -- individual string nodes, but we want to keep those
-              -- together with any text is adjacent to them to avoid
-              -- e.g. breaking up quotes from quoted text when doing
-              -- line-wrapping. It's important to make sure we only do
-              -- this for adjacent string (i.e. not whitespace) nodes
-              -- and that we make sure we only merge when they have the
-              -- same style.
-              let rest = go sty xs
-                  e = Element sty (EText t)
-              in case Seq.viewl rest of
-                  Element sty2 (EText t2) :< tail_ | sty2 == sty ->
-                      (Element sty (EText (t <> t2))) <| tail_
-                  _ ->
-                      e <| rest
-          C.Space :< xs ->
-              Element sty ESpace <| go sty xs
-          C.SoftBreak :< xs ->
-              Element sty ESoftBreak <| go sty xs
-          C.LineBreak :< xs ->
-              Element sty ELineBreak <| go sty xs
-          C.Link label theUrl _ :< xs ->
-              let mLabel = if Seq.null label
-                           then Nothing
-                           else Just $ fromMarkdownInlines $ removeHyperlinks label
-                  url = URL theUrl
-              in (Element (Hyperlink url sty) $ EHyperlink url mLabel) <| go sty xs
-          C.Image altIs theUrl _ :< xs ->
-              let mLabel = if Seq.null altIs
-                           then Nothing
-                           else Just $ fromMarkdownInlines altIs
-                  url = URL theUrl
-              in (Element (Hyperlink url sty) $ EImage url mLabel) <| go sty xs
-          C.RawHtml t :< xs ->
-              Element sty (ERawHtml t) <| go sty xs
-          C.Code t :< xs ->
-              -- We turn a single code string into individual Elements
-              -- so we can perform line-wrapping on the inline code
-              -- text.
-              let ts = [ Element Code frag
-                       | wd <- T.split (== ' ') t
-                       , frag <- case wd of
-                           "" -> [ESpace]
-                           _  -> [ESpace, EText wd]
-                       ]
-                  ts' = case ts of
-                    (Element _ ESpace:rs) -> rs
-                    _                     -> ts
-              in Seq.fromList ts' <> go sty xs
-          C.Emph as :< xs ->
-              go Emph as <> go sty xs
-          C.Strong as :< xs ->
-              go Strong as <> go sty xs
-          C.Entity t :< xs ->
-              Element sty (EText t) <| go sty xs
-          Seq.EmptyL -> mempty
-
-    in go Normal inlines
-
-unsafeGetStr :: C.Inline -> Text
-unsafeGetStr (C.Str t) = t
-unsafeGetStr _ = error "BUG: unsafeGetStr called on non-Str Inline"
-
--- | Obtain all username references in a sequence of rich text blocks.
-findUsernames :: Seq RichTextBlock -> S.Set T.Text
-findUsernames = S.unions . F.toList . fmap blockFindUsernames
-
-blockFindUsernames :: RichTextBlock -> S.Set T.Text
-blockFindUsernames (Para is) =
-    elementFindUsernames $ F.toList is
-blockFindUsernames (Header _ is) =
-    elementFindUsernames $ F.toList is
-blockFindUsernames (Blockquote bs) =
-    findUsernames bs
-blockFindUsernames (List _ _ bs) =
-    S.unions $ F.toList $ findUsernames <$> bs
-blockFindUsernames _ =
-    mempty
-
-elementFindUsernames :: [Element] -> S.Set T.Text
-elementFindUsernames [] = mempty
-elementFindUsernames (e : es) =
-    case eData e of
-        EUser u -> S.insert u $ elementFindUsernames es
-        _ -> elementFindUsernames es
-
--- | Obtain all URLs (and optional labels) in a rich text block.
-blockGetURLs :: RichTextBlock -> [(URL, Maybe (Seq Element))]
-blockGetURLs (Para is) =
-    catMaybes $ elementGetURL <$> toList is
-blockGetURLs (Header _ is) =
-    catMaybes $ elementGetURL <$> toList is
-blockGetURLs (Blockquote bs) =
-    mconcat $ blockGetURLs <$> toList bs
-blockGetURLs (List _ _ bss) =
-    mconcat $ mconcat $
-    (fmap blockGetURLs . F.toList) <$> F.toList bss
-blockGetURLs _ =
-    mempty
-
-elementGetURL :: Element -> Maybe (URL, Maybe (Seq Element))
-elementGetURL (Element _ (EHyperlink url label)) =
-    Just (url, label)
-elementGetURL (Element _ (EImage url label)) =
-    Just (url, label)
-elementGetURL _ =
-    Nothing
-
--- | Find the first code block in a sequence of rich text blocks.
-findVerbatimChunk :: Seq RichTextBlock -> Maybe Text
-findVerbatimChunk = getFirst . F.foldMap go
-  where go (CodeBlock _ t) = First (Just t)
-        go _               = First Nothing
-
-isValidNameChar :: Char -> Bool
-isValidNameChar c = isAlpha c || c == '_' || c == '.' || c == '-'
-
-isNameFragment :: C.Inline -> Bool
-isNameFragment (C.Str t) =
-    not (T.null t) && isValidNameChar (T.head t)
-isNameFragment _ = False
diff --git a/src/Types/Users.hs b/src/Types/Users.hs
deleted file mode 100644
--- a/src/Types/Users.hs
+++ /dev/null
@@ -1,196 +0,0 @@
-{-# LANGUAGE TemplateHaskell #-}
-{-# LANGUAGE DeriveFunctor #-}
-
-module Types.Users
-  ( UserInfo(..)
-  , UserStatus(..)
-  , Users   -- constructor remains internal
-  -- * Lenses created for accessing UserInfo fields
-  , uiName, uiId, uiStatus, uiInTeam, uiNickName, uiFirstName, uiLastName, uiEmail
-  , uiDeleted
-  -- * Various operations on UserInfo
-  -- * Creating UserInfo objects
-  , userInfoFromUser
-  -- * Miscellaneous
-  , getUsernameSet
-  , trimUserSigil
-  , statusFromText
-  , findUserById
-  , findUserByUsername
-  , findUserByNickname
-  , noUsers, addUser, allUsers
-  , modifyUserById
-  , userDeleted
-  , TypingUsers
-  , noTypingUsers
-  , addTypingUser
-  , allTypingUsers
-  , expireTypingUsers
-  , getAllUserIds
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Data.HashMap.Strict as HM
-import qualified Data.Set as S
-import           Data.Semigroup ( Max(..) )
-import qualified Data.Text as T
-import           Lens.Micro.Platform ( (%~), makeLenses, ix )
-
-import           Network.Mattermost.Types ( UserId(..), User(..) )
-
-import           Types.Common
-import           Constants ( userSigil )
-
--- * 'UserInfo' Values
-
--- | A 'UserInfo' value represents everything we need to know at
---   runtime about a user
-data UserInfo = UserInfo
-  { _uiName      :: Text
-  , _uiId        :: UserId
-  , _uiStatus    :: UserStatus
-  , _uiInTeam    :: Bool
-  , _uiNickName  :: Maybe Text
-  , _uiFirstName :: Text
-  , _uiLastName  :: Text
-  , _uiEmail     :: Text
-  , _uiDeleted   :: Bool
-  } deriving (Eq, Show)
-
--- | Is this user deleted?
-userDeleted :: User -> Bool
-userDeleted u =
-    case userCreateAt u of
-        Nothing -> False
-        Just c -> userDeleteAt u > c
-
--- | Create a 'UserInfo' value from a Mattermost 'User' value
-userInfoFromUser :: User -> Bool -> UserInfo
-userInfoFromUser up inTeam = UserInfo
-  { _uiName      = userUsername up
-  , _uiId        = userId up
-  , _uiStatus    = Offline
-  , _uiInTeam    = inTeam
-  , _uiNickName  =
-      let nick = sanitizeUserText $ userNickname up
-      in if T.null nick then Nothing else Just nick
-  , _uiFirstName = sanitizeUserText $ userFirstName up
-  , _uiLastName  = sanitizeUserText $ userLastName up
-  , _uiEmail     = sanitizeUserText $ userEmail up
-  , _uiDeleted   = userDeleted up
-  }
-
--- | The 'UserStatus' value represents possible current status for
---   a user
-data UserStatus
-  = Online
-  | Away
-  | Offline
-  | DoNotDisturb
-  | Other Text
-    deriving (Eq, Show)
-
-statusFromText :: Text -> UserStatus
-statusFromText t = case t of
-  "online"  -> Online
-  "offline" -> Offline
-  "away"    -> Away
-  "dnd"     -> DoNotDisturb
-  _         -> Other t
-
--- ** 'UserInfo' lenses
-
-makeLenses ''UserInfo
-
--- ** Manage the collection of all Users
-
--- | Define a binary kinded type to allow derivation of functor.
-data AllMyUsers a =
-    AllUsers { _ofUsers :: HashMap UserId a
-             , _usernameSet :: S.Set Text
-             }
-             deriving Functor
-
-makeLenses ''AllMyUsers
-
--- | Define the exported typename which universally binds the
--- collection to the UserInfo type.
-type Users = AllMyUsers UserInfo
-
-getUsernameSet :: Users -> S.Set Text
-getUsernameSet = _usernameSet
-
--- | Initial collection of Users with no members
-noUsers :: Users
-noUsers = AllUsers HM.empty mempty
-
-getAllUserIds :: Users -> [UserId]
-getAllUserIds = HM.keys . _ofUsers
-
--- | Add a member to the existing collection of Users
-addUser :: UserInfo -> Users -> Users
-addUser userinfo u =
-    u & ofUsers %~ HM.insert (userinfo^.uiId) userinfo
-      & usernameSet %~ S.insert (userinfo^.uiName)
-
--- | Get a list of all known users
-allUsers :: Users -> [UserInfo]
-allUsers = HM.elems . _ofUsers
-
--- | Define the exported typename to represent the collection of users
--- | who are currently typing. The values kept against the user id keys are the
--- | latest timestamps of typing events from the server.
-type TypingUsers = AllMyUsers (Max UTCTime)
-
--- | Initial collection of TypingUsers with no members
-noTypingUsers :: TypingUsers
-noTypingUsers = AllUsers HM.empty mempty
-
--- | Add a member to the existing collection of TypingUsers
-addTypingUser :: UserId -> UTCTime -> TypingUsers -> TypingUsers
-addTypingUser uId ts = ofUsers %~ HM.insertWith (<>) uId (Max ts)
-
--- | Get a list of all typing users
-allTypingUsers :: TypingUsers -> [UserId]
-allTypingUsers = HM.keys . _ofUsers
-
--- | Remove all the expired users from the collection of TypingUsers.
--- | Expiry is decided by the given timestamp.
-expireTypingUsers :: UTCTime -> TypingUsers -> TypingUsers
-expireTypingUsers expiryTimestamp =
-    ofUsers %~ HM.filter (\(Max ts') -> ts' >= expiryTimestamp)
-
--- | Get the User information given the UserId
-findUserById :: UserId -> Users -> Maybe UserInfo
-findUserById uId = HM.lookup uId . _ofUsers
-
--- | Get the User information given the user's name. This is an exact
--- match on the username field. It will automatically trim a user sigil
--- from the input.
-findUserByUsername :: Text -> Users -> Maybe (UserId, UserInfo)
-findUserByUsername name allusers =
-  case filter ((== trimUserSigil name) . _uiName . snd) $ HM.toList $ _ofUsers allusers of
-    (usr : []) -> Just usr
-    _ -> Nothing
-
--- | Get the User information given the user's name. This is an exact
--- match on the nickname field, not necessarily the presented name. It
--- will automatically trim a user sigil from the input.
-findUserByNickname:: Text -> Users -> Maybe (UserId, UserInfo)
-findUserByNickname nick us =
-  case filter ((== (Just $ trimUserSigil nick)) . _uiNickName . snd) $ HM.toList $ _ofUsers us of
-    (pair : []) -> Just pair
-    _ -> Nothing
-
-trimUserSigil :: Text -> Text
-trimUserSigil n
-    | userSigil `T.isPrefixOf` n = T.tail n
-    | otherwise                  = n
-
--- | Extract a specific user from the collection and perform an
--- endomorphism operation on it, then put it back into the collection.
-modifyUserById :: UserId -> (UserInfo -> UserInfo) -> Users -> Users
-modifyUserById uId f = ofUsers.ix(uId) %~ f
diff --git a/src/Util.hs b/src/Util.hs
deleted file mode 100644
--- a/src/Util.hs
+++ /dev/null
@@ -1,25 +0,0 @@
-module Util
-  ( nubOn
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import qualified Data.Set as Set
-
-
--- | The 'nubOn' function removes duplicate elements from a list. In
--- particular, it keeps only the /last/ occurrence of each
--- element. The equality of two elements in a call to @nub f@ is
--- determined using @f x == f y@, and the resulting elements must have
--- an 'Ord' instance in order to make this function more efficient.
-nubOn :: (Ord b) => (a -> b) -> [a] -> [a]
-nubOn f = snd . go Set.empty
-  where go before [] = (before, [])
-        go before (x:xs) =
-          let (before', xs') = go before xs
-              key = f x in
-          if key `Set.member` before'
-            then (before', xs')
-            else (Set.insert key before', x : xs')
diff --git a/src/Windows/ViewMessage.hs b/src/Windows/ViewMessage.hs
deleted file mode 100644
--- a/src/Windows/ViewMessage.hs
+++ /dev/null
@@ -1,217 +0,0 @@
-module Windows.ViewMessage
-  ( viewMessageWindowTemplate
-  , viewMessageKeybindings
-  , viewMessageKeyHandlers
-  , viewMessageReactionsKeybindings
-  , viewMessageReactionsKeyHandlers
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH
-
-import           Brick
-import           Brick.Widgets.Border
-
-import qualified Data.Set as S
-import qualified Data.Map as M
-import           Data.Maybe ( fromJust )
-import qualified Data.Text as T
-import qualified Data.Foldable as F
-import qualified Graphics.Vty as Vty
-import           Lens.Micro.Platform ( to )
-
-import           Constants
-import           Events.Keybindings
-import           Themes
-import           Types
-import           Draw.RichText
-import           Draw.Messages ( nameForUserRef )
-
--- | The template for "View Message" windows triggered by message
--- selection mode.
-viewMessageWindowTemplate :: TabbedWindowTemplate ViewMessageWindowTab
-viewMessageWindowTemplate =
-    TabbedWindowTemplate { twtEntries = [ messageEntry
-                                        , reactionsEntry
-                                        ]
-                         , twtTitle = const $ txt "View Message"
-                         }
-
-messageEntry :: TabbedWindowEntry ViewMessageWindowTab
-messageEntry =
-    TabbedWindowEntry { tweValue = VMTabMessage
-                      , tweRender = renderTab
-                      , tweHandleEvent = handleEvent
-                      , tweTitle = tabTitle
-                      , tweShowHandler = onShow
-                      }
-
-reactionsEntry :: TabbedWindowEntry ViewMessageWindowTab
-reactionsEntry =
-    TabbedWindowEntry { tweValue = VMTabReactions
-                      , tweRender = renderTab
-                      , tweHandleEvent = handleEvent
-                      , tweTitle = tabTitle
-                      , tweShowHandler = onShow
-                      }
-
-tabTitle :: ViewMessageWindowTab -> Bool -> T.Text
-tabTitle VMTabMessage _ = "Message"
-tabTitle VMTabReactions _ = "Reactions"
-
--- When we show the tabs, we need to reset the viewport scroll position
--- for viewports in that tab. This is because an older View Message
--- window used the same handle for the viewport and we don't want that
--- old state affecting this window. This also means that switching tabs
--- in an existing window resets this state, too.
-onShow :: ViewMessageWindowTab -> MH ()
-onShow VMTabMessage = resetVp ViewMessageArea
-onShow VMTabReactions = resetVp ViewMessageArea
-
-resetVp :: Name -> MH ()
-resetVp n = do
-    let vs = viewportScroll n
-    mh $ do
-        vScrollToBeginning vs
-        hScrollToBeginning vs
-
-renderTab :: ViewMessageWindowTab -> ChatState -> Widget Name
-renderTab tab cs =
-    let latestMessage = case cs^.csViewedMessage of
-          Nothing -> error "BUG: no message to show, please report!"
-          Just (m, _) -> getLatestMessage cs m
-    in case tab of
-        VMTabMessage -> viewMessageBox cs latestMessage
-        VMTabReactions -> reactionsText cs latestMessage
-
-getLatestMessage :: ChatState -> Message -> Message
-getLatestMessage cs m =
-    case m^.mMessageId of
-        Nothing -> m
-        Just mId -> fromJust $ findMessage mId $ cs^.csCurrentChannel.ccContents.cdMessages
-
-handleEvent :: ViewMessageWindowTab -> Vty.Event -> MH ()
-handleEvent VMTabMessage =
-    void . handleKeyboardEvent viewMessageKeybindings (const $ return ())
-handleEvent VMTabReactions =
-    void . handleKeyboardEvent viewMessageReactionsKeybindings (const $ return ())
-
-reactionsText :: ChatState -> Message -> Widget Name
-reactionsText st m = viewport ViewMessageReactionsArea Vertical body
-    where
-        body = case null reacList of
-            True -> txt "This message has no reactions."
-            False -> vBox $ mkEntry <$> reacList
-        reacList = M.toList (m^.mReactions)
-        mkEntry (reactionName, userIdSet) =
-            let count = str $ "(" <> show (S.size userIdSet) <> ")"
-                name = withDefAttr emojiAttr $ txt $ ":" <> reactionName <> ":"
-                usernameList = usernameText userIdSet
-            in (name <+> (padLeft (Pad 1) count)) <=>
-               (padLeft (Pad 2) usernameList)
-
-        hs = getHighlightSet st
-
-        usernameText uids =
-            renderText' (myUsername st) hs $
-            T.intercalate ", " $
-            fmap (userSigil <>) $
-            catMaybes (lookupUsername <$> F.toList uids)
-
-        lookupUsername uid = usernameForUserId uid st
-
-viewMessageBox :: ChatState -> Message -> Widget Name
-viewMessageBox st msg =
-    let maybeWarn = if not (msg^.mDeleted) then id else warn
-        warn w = vBox [w, hBorder, deleteWarning]
-        deleteWarning = withDefAttr errorMessageAttr $
-                        txtWrap $ "Alert: this message has been deleted and " <>
-                                  "will no longer be accessible once this window " <>
-                                  "is closed."
-        mkBody vpWidth =
-            let hs = getHighlightSet st
-                parent = case msg^.mInReplyToMsg of
-                     NotAReply -> Nothing
-                     InReplyTo pId -> getMessageForPostId st pId
-                md = MessageData { mdEditThreshold     = Nothing
-                                 , mdShowOlderEdits    = False
-                                 , mdMessage           = msg
-                                 , mdUserName          = msg^.mUser.to (nameForUserRef st)
-                                 , mdParentMessage     = parent
-                                 , mdParentUserName    = parent >>= (^.mUser.to (nameForUserRef st))
-                                 , mdRenderReplyParent = True
-                                 , mdHighlightSet      = hs
-                                 , mdIndentBlocks      = True
-                                 , mdThreadState       = NoThread
-                                 , mdShowReactions     = False
-                                 , mdMessageWidthLimit = Just vpWidth
-                                 , mdMyUsername        = myUsername st
-                                 }
-            in renderMessage md
-
-    in Widget Greedy Greedy $ do
-        ctx <- getContext
-        render $ maybeWarn $ viewport ViewMessageArea Both $ mkBody (ctx^.availWidthL)
-
-viewMessageKeybindings :: KeyConfig -> KeyHandlerMap
-viewMessageKeybindings = mkKeybindings viewMessageKeyHandlers
-
-viewMessageKeyHandlers :: [KeyEventHandler]
-viewMessageKeyHandlers =
-    let vs = viewportScroll ViewMessageArea
-    in [ mkKb PageUpEvent "Page up" $
-           mh $ vScrollBy vs (-1 * pageAmount)
-
-       , mkKb PageDownEvent "Page down" $
-           mh $ vScrollBy vs pageAmount
-
-       , mkKb PageLeftEvent "Page left" $
-           mh $ hScrollBy vs (-2 * pageAmount)
-
-       , mkKb PageRightEvent "Page right" $
-           mh $ hScrollBy vs (2 * pageAmount)
-
-       , mkKb ScrollUpEvent "Scroll up" $
-           mh $ vScrollBy vs (-1)
-
-       , mkKb ScrollDownEvent "Scroll down" $
-           mh $ vScrollBy vs 1
-
-       , mkKb ScrollLeftEvent "Scroll left" $
-           mh $ hScrollBy vs (-1)
-
-       , mkKb ScrollRightEvent "Scroll right" $
-           mh $ hScrollBy vs 1
-
-       , mkKb ScrollBottomEvent "Scroll to the end of the message" $
-           mh $ vScrollToEnd vs
-
-       , mkKb ScrollTopEvent "Scroll to the beginning of the message" $
-           mh $ vScrollToBeginning vs
-       ]
-
-viewMessageReactionsKeybindings :: KeyConfig -> KeyHandlerMap
-viewMessageReactionsKeybindings = mkKeybindings viewMessageReactionsKeyHandlers
-
-viewMessageReactionsKeyHandlers :: [KeyEventHandler]
-viewMessageReactionsKeyHandlers =
-    let vs = viewportScroll ViewMessageReactionsArea
-    in [ mkKb PageUpEvent "Page up" $
-           mh $ vScrollBy vs (-1 * pageAmount)
-
-       , mkKb PageDownEvent "Page down" $
-           mh $ vScrollBy vs pageAmount
-
-       , mkKb ScrollUpEvent "Scroll up" $
-           mh $ vScrollBy vs (-1)
-
-       , mkKb ScrollDownEvent "Scroll down" $
-           mh $ vScrollBy vs 1
-
-       , mkKb ScrollBottomEvent "Scroll to the end of the reactions list" $
-           mh $ vScrollToEnd vs
-
-       , mkKb ScrollTopEvent "Scroll to the beginning of the reactions list" $
-           mh $ vScrollToBeginning vs
-       ]
diff --git a/src/Zipper.hs b/src/Zipper.hs
deleted file mode 100644
--- a/src/Zipper.hs
+++ /dev/null
@@ -1,119 +0,0 @@
-module Zipper
-  ( Zipper
-  , fromList
-  , toList
-  , focus
-  , unsafeFocus
-  , left
-  , leftL
-  , right
-  , rightL
-  , findRight
-  , maybeFindRight
-  , updateList
-  , updateListBy
-  , filterZipper
-  , maybeMapZipper
-  , isEmpty
-  )
-where
-
-import           Prelude ()
-import           Prelude.MH hiding (toList)
-
-import           Data.Maybe ( fromJust )
-import qualified Data.Foldable as F
-import qualified Data.Sequence as Seq
-import qualified Data.CircularList as C
-import           Lens.Micro.Platform
-
-data Zipper a b =
-    Zipper { zRing :: C.CList b
-           , zTrees :: Seq.Seq (a, Seq.Seq b)
-           }
-
-instance F.Foldable (Zipper a) where
-    foldMap f = foldMap f .
-                F.toList .
-                mconcat .
-                F.toList .
-                fmap snd .
-                zTrees
-
-instance Functor (Zipper a) where
-    fmap f z =
-        Zipper { zRing = f <$> zRing z
-               , zTrees = zTrees z & mapped._2.mapped %~ f
-               }
-
-isEmpty :: Zipper a b -> Bool
-isEmpty = C.isEmpty . zRing
-
--- Move the focus one element to the left
-left :: Zipper a b -> Zipper a b
-left z = z { zRing = C.rotL (zRing z) }
-
--- A lens on the zipper moved to the left
-leftL :: Lens (Zipper a b) (Zipper a b) (Zipper a b) (Zipper a b)
-leftL = lens left (\ _ b -> right b)
-
--- Move the focus one element to the right
-right :: Zipper a b -> Zipper a b
-right z = z { zRing = C.rotR (zRing z) }
-
--- A lens on the zipper moved to the right
-rightL :: Lens (Zipper a b) (Zipper a b) (Zipper a b) (Zipper a b)
-rightL = lens right (\ _ b -> left b)
-
--- Return the focus element
-focus :: Zipper a b -> Maybe b
-focus = C.focus . zRing
-
-unsafeFocus :: Zipper a b -> b
-unsafeFocus = fromJust . focus
-
--- Turn a list into a wraparound zipper, focusing on the head
-fromList :: (Eq b) => [(a, [b])] -> Zipper a b
-fromList xs =
-    let ts = Seq.fromList $ xs & mapped._2 %~ Seq.fromList
-        tsList = F.toList $ mconcat $ F.toList $ snd <$> ts
-        maybeFocus = if null tsList
-                     then id
-                     else fromJust . C.rotateTo (tsList !! 0)
-    in Zipper { zRing = maybeFocus $ C.fromList tsList
-              , zTrees = ts
-              }
-
-toList :: Zipper a b -> [(a, [b])]
-toList z = F.toList $ zTrees z & mapped._2 %~ F.toList
-
--- Shift the focus until a given element is found, or return the
--- same zipper if none applies
-findRight :: (b -> Bool) -> Zipper a b -> Zipper a b
-findRight f z = fromMaybe z $ maybeFindRight f z
-
--- Shift the focus until a given element is found, or return
--- Nothing if none applies
-maybeFindRight :: (b -> Bool) -> Zipper a b -> Maybe (Zipper a b)
-maybeFindRight f z = do
-    newRing <- C.findRotateTo f (zRing z)
-    return z { zRing = newRing }
-
-updateList :: (Eq b) => [(a, [b])] -> Zipper a b -> Zipper a b
-updateList newList oldZip = updateListBy (\old b -> old == Just b) newList oldZip
-
-updateListBy :: (Eq b) => (Maybe b -> b -> Bool) -> [(a, [b])] -> Zipper a b -> Zipper a b
-updateListBy f newList oldZip = findRight (f (focus oldZip)) $ fromList newList
-
-maybeMapZipper :: (Eq c) => (b -> Maybe c) -> Zipper a b -> Zipper a c
-maybeMapZipper f z =
-    let oldTrees = zTrees z
-        newTrees = F.toList $ oldTrees & mapped._2 %~ (catMaybes . F.toList . fmap f)
-    in fromList newTrees
-
-filterZipper :: (Eq b) => (b -> Bool) -> Zipper a b -> Zipper a b
-filterZipper f oldZip = maintainFocus newZip
-  where maintainFocus = findRight ((== focus oldZip) . Just)
-        newZip = Zipper { zTrees = zTrees oldZip & mapped._2 %~ Seq.filter f
-                        , zRing = C.filterR f (zRing oldZip)
-                        }
diff --git a/test/Message_QCA.hs b/test/Message_QCA.hs
--- a/test/Message_QCA.hs
+++ b/test/Message_QCA.hs
@@ -13,10 +13,11 @@
 import Network.Mattermost.QuickCheck
 import Network.Mattermost.Types
 import Test.Tasty.QuickCheck
-import Types.Messages
-import Types.Posts
-import Types.RichText
 
+import Matterhorn.Types.Messages
+import Matterhorn.Types.Posts
+import Matterhorn.Types.RichText
+
 genMap :: Ord key => Gen key -> Gen value -> Gen (Map key value)
 genMap gk gv = let kv = (,) <$> gk <*> gv in fromList <$> listOf kv
 
@@ -31,7 +32,7 @@
 
 genMessage :: Gen Message
 genMessage = Message
-             <$> (fromMarkdownBlocks <$> genBlocks)
+             <$> (fromMarkdownBlocks Nothing <$> genBlocks)
              <*> genText
              <*> genUserRef
              <*> genTime
@@ -59,7 +60,7 @@
 genMessage__DeletedPost :: Gen Message__DeletedPost
 genMessage__DeletedPost = Message__DeletedPost
                           <$> (Message
-                              <$> (fromMarkdownBlocks <$> genBlocks)
+                              <$> (fromMarkdownBlocks Nothing <$> genBlocks)
                               <*> genText
                               <*> genUserRef
                               <*> genTime
@@ -81,7 +82,7 @@
 genMessage__Posted :: Gen Message__Posted
 genMessage__Posted = Message__Posted
                      <$> (Message
-                         <$> (fromMarkdownBlocks <$> genBlocks)
+                         <$> (fromMarkdownBlocks Nothing <$> genBlocks)
                          <*> genText
                          <*> genUserRef
                          <*> genTime
diff --git a/test/test_messages.hs b/test/test_messages.hs
--- a/test/test_messages.hs
+++ b/test/test_messages.hs
@@ -14,18 +14,22 @@
 import           Data.Time.Calendar (Day(..))
 import           Data.Time.Clock (UTCTime(..), getCurrentTime
                                  , secondsToDiffTime)
-import           Message_QCA
-import           Network.Mattermost.Types
 import           System.Exit
-import           Test.QuickCheck.Checkers
+
 import           Test.Tasty
 import           Test.Tasty.HUnit
 import           Test.Tasty.QuickCheck
-import           TimeUtils
-import           Types.DirectionalSeq
-import           Types.Messages
-import           Types.Posts
-import           Prelude.MH
+import           Test.QuickCheck.Checkers
+
+import           Network.Mattermost.Types
+
+import           Message_QCA
+
+import           Matterhorn.Types.DirectionalSeq
+import           Matterhorn.Types.Messages
+import           Matterhorn.Types.Posts
+import           Matterhorn.Prelude
+import           Matterhorn.TimeUtils
 
 main :: IO ()
 main = defaultMain tests `catch` (\e -> do
