content: {-# LANGUAGE GADTs #-}
  {-# LANGUAGE KindSignatures #-}
  {-# LANGUAGE TemplateHaskell #-}
  {-# LANGUAGE MultiWayIf #-}
  {-# LANGUAGE RankNTypes #-}
  {-# LANGUAGE MultiParamTypeClasses #-}
  {-# LANGUAGE RecordWildCards #-}
  module Matterhorn.Types
    ( ConnectionStatus(..)
    , HelpTopic(..)
    , ProgramOutput(..)
    , MHEvent(..)
    , InternalEvent(..)
    , StartupStateInfo(..)
    , MHError(..)
    , CPUUsagePolicy(..)
    , SemEq(..)
    , Work(..)
    , handleEventWith
    , getServerBaseUrl
    , serverBaseUrl
    , ConnectionInfo(..)
    , SidebarUpdate(..)
    , PendingChannelChange(..)
    , ViewMessageWindowTab(..)
    , clearChannelUnreadStatus
    , ChannelListSorting(..)
    , TeamListSorting(..)
    , ThreadOrientation(..)
    , ChannelListOrientation(..)
    , channelListEntryUserId
    , userIdsFromZipper
    , entryIsDMEntry
    , ciHostname
    , ciPort
    , ciUrlPath
    , ciUsername
    , ciOTPToken
    , ciPassword
    , ciType
    , ciAccessToken
    , ChannelTopicDialogState(..)
    , channelTopicDialogEditor
    , channelTopicDialogFocus
  
    , CharWidths
    , newCharWidths
    , buildWidthMap
  
    , resultToWidget
  
    , MHKeyEventHandler
    , mhHandleKeyboardEvent
  
    , Config(..)
    , configUserL
    , configHostL
    , configTeamL
    , configPortL
    , configUrlPathL
    , configPassL
    , configTokenL
    , configOTPTokenL
    , configTimeFormatL
    , configDateFormatL
    , configThemeL
    , configThemeCustomizationFileL
    , configSmartBacktickL
    , configSmartEditingL
    , configURLOpenCommandL
    , configURLOpenCommandInteractiveL
    , configActivityNotifyCommandL
    , configActivityNotifyVersionL
    , configActivityBellL
    , configTruncateVerbatimBlocksL
    , configChannelListSortingL
    , configTeamListSortingL
    , configShowMessageTimestampsL
    , configShowBackgroundL
    , configShowMessagePreviewL
    , configShowChannelListL
    , configShowExpandedChannelTopicsL
    , configEnableAspellL
    , configAspellDictionaryL
    , configUnsafeUseHTTPL
    , configValidateServerCertificateL
    , configChannelListWidthL
    , configLogMaxBufferSizeL
    , configShowOlderEditsL
    , configShowTypingIndicatorL
    , configSendTypingNotificationsL
    , configAbsPathL
    , configUserKeysL
    , configHyperlinkingModeL
    , configSyntaxDirsL
    , configDirectChannelExpirationDaysL
    , configCpuUsagePolicyL
    , configDefaultAttachmentPathL
    , configChannelListOrientationL
    , configThreadOrientationL
    , configMouseModeL
    , configShowLastOpenThreadL
    , configChannelSelectCaseInsensitiveL
    , configCharacterWidthsL
  
    , unsafeKeyDispatcher
    , bindingConflictMessage
  
    , ChannelListWidth(..)
    , NotificationVersion(..)
    , PasswordSource(..)
    , TokenSource(..)
    , OTPTokenSource(..)
    , MatchType(..)
    , Mode(..)
    , ChannelSelectPattern(..)
    , PostListContents(..)
    , AuthenticationException(..)
    , BackgroundInfo(..)
    , RequestChan
    , UserFetch(..)
    , writeBChan
    , InternalTheme(..)
  
    , attrNameToConfig
  
    , matchesTeam
    , teamUnreadCount
    , teamZipperIds
    , mkChannelZipperList
    , ChannelListGroup(..)
    , nonDMChannelListGroupUnread
  
    , ThreadInterface
    , ChannelMessageInterface
  
    , threadInterface
    , unsafeThreadInterface
    , maybeThreadInterface
    , threadInterfaceEmpty
    , threadInterfaceDeleteWhere
    , modifyThreadMessages
    , modifyEachThreadMessage
  
    , trimChannelSigil
  
    , ChannelSelectState(..)
    , channelSelectMatches
    , channelSelectInput
    , emptyChannelSelectState
  
    , TeamState(..)
    , tsFocus
    , tsPendingChannelChange
    , tsRecentChannel
    , tsReturnChannel
    , tsTeam
    , tsChannelSelectState
    , tsViewedMessage
    , tsPostListWindow
    , tsUserListWindow
    , tsChannelListWindow
    , tsNotifyPrefs
    , tsChannelTopicDialog
    , tsReactionEmojiListWindow
    , tsThemeListWindow
    , tsChannelListSorting
    , tsThreadInterface
    , tsMessageInterfaceFocus
  
    , teamMode
    , teamModes
    , getTeamMode
  
    , MessageInterfaceFocus(..)
    , messageInterfaceFocusNext
    , messageInterfaceFocusPrev
  
    , channelEditor
    , channelMessageSelect
  
    , ChatState
    , newState
  
    , withCurrentChannel
    , withCurrentChannel'
    , withCurrentTeam
    , forEachTeam
  
    , csTeamZipper
    , csTeams
    , csTeam
    , csChannelListOrientation
    , csResources
    , csLastMouseDownEvent
    , csGlobalEditState
    , csVerbatimTruncateSetting
    , csCurrentChannelId
    , csCurrentTeamId
    , csPostMap
    , csUsers
    , csHiddenChannelGroups
    , csConnectionStatus
    , csWorkerIsBusy
    , csChannel
    , csChannelMessages
    , csChannelMessageInterface
    , maybeChannelMessageInterface
    , csChannels
    , csClientConfig
    , csInputHistory
    , csMe
    , timeZone
    , whenMode
    , pushMode
    , pushMode'
    , popMode
    , replaceMode
  
    , GlobalEditState(..)
    , emptyGlobalEditState
    , gedYankBuffer
  
    , PostListWindowState(..)
    , postListSelected
    , postListPosts
  
    , UserSearchScope(..)
    , ChannelSearchScope(..)
  
    , ListWindowState(..)
    , listWindowSearchResults
    , listWindowSearchInput
    , listWindowSearchScope
    , listWindowSearching
    , listWindowEnterHandler
    , listWindowNewList
    , listWindowFetchResults
    , listWindowRecordCount
  
    , getUsers
  
    , ChatResources(..)
    , crUserPreferences
    , crEventQueue
    , crTheme
    , crThemeOriginal
    , crStatusUpdateChan
    , crSubprocessLog
    , crWebsocketActionChan
    , crWebsocketThreadId
    , crRequestQueue
    , crFlaggedPosts
    , crConn
    , crConfiguration
    , crSyntaxMap
    , crLogManager
    , crSpellChecker
    , crWindowSize
    , crEmoji
    , getSession
    , getResourceSession
  
    , specialUserMentions
  
    , applyTeamOrder
    , refreshTeamZipper
  
    , UserPreferences(UserPreferences)
    , userPrefShowJoinLeave
    , userPrefFlaggedPostList
    , userPrefGroupChannelPrefs
    , userPrefDirectChannelPrefs
    , userPrefTeammateNameDisplayMode
    , userPrefTeamOrder
    , userPrefFavoriteChannelPrefs
    , dmChannelShowPreference
    , groupChannelShowPreference
    , favoriteChannelPreference
  
    , defaultUserPreferences
    , setUserPreferences
  
    , WebsocketAction(..)
  
    , Cmd(..)
    , commandName
    , CmdArgs(..)
  
    , MH
    , runMHEvent
    , scheduleUserFetches
    , scheduleUserStatusFetches
    , getScheduledUserFetches
    , getScheduledUserStatusFetches
    , mh
    , generateUUID
    , generateUUID_IO
    , mhSuspendAndResume
    , mhZoom
    , mhZoom'
    , mhContinueWithoutRedraw
    , St.gets
    , mhError
  
    , mhLog
    , mhGetIOLogger
    , ioLogWithManager
    , LogContext(..)
    , withLogContext
    , withLogContextChannelId
    , getLogContext
    , LogMessage(..)
    , LogCommand(..)
    , LogCategory(..)
  
    , LogManager(..)
    , startLoggingToFile
    , stopLoggingToFile
    , requestLogSnapshot
    , requestLogDestination
    , sendLogMessage
  
    , requestQuit
    , getMessageForPostId
    , getParentMessage
    , getReplyRootMessage
    , withChannel
    , withChannelOrDefault
    , userList
    , resetAutocomplete
    , isMine
    , setUserStatus
    , myUser
    , myUsername
    , myUserId
    , usernameForUserId
    , userByUsername
    , userByNickname
    , channelIdByChannelName
    , channelIdByUsername
    , userById
    , allUserIds
    , addNewUser
    , useNickname
    , useNickname'
    , displayNameForUserId
    , displayNameForUser
    , raiseInternalEvent
    , getNewMessageCutoff
    , getEditedMessageCutoff
  
    , HighlightSet(..)
    , getHighlightSet
    , emptyHSet
  
    , moveLeft
    , moveRight
  
    , module Matterhorn.Types.Core
    , module Matterhorn.Types.Channels
    , module Matterhorn.Types.EditState
    , module Matterhorn.Types.Messages
    , module Matterhorn.Types.MessageInterface
    , module Matterhorn.Types.TabbedWindow
    , module Matterhorn.Types.Posts
    , module Matterhorn.Types.Users
    )
  where
  
  import           Prelude ()
  import           Matterhorn.Prelude
  
  import           GHC.Stack ( HasCallStack )
  
  import qualified Brick
  import           Brick ( EventM, Widget(..), Size(..), Result )
  import           Brick.Keybindings
  import           Brick.Focus ( 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 )
  import           Control.Concurrent ( ThreadId )
  import           Control.Concurrent.Async ( Async )
  import qualified Control.Concurrent.STM as STM
  import           Control.Exception ( SomeException )
  import qualified Control.Monad.State as St
  import qualified Control.Monad.Reader as R
  import qualified Data.Set as Set
  import qualified Data.Foldable as F
  import           Data.Function ( on )
  import qualified Data.Kind as K
  import           Data.Maybe ( fromJust )
  import           Data.Ord ( comparing, Down(..) )
  import qualified Data.HashMap.Strict as HM
  import           Data.List ( sortBy, elemIndex, partition )
  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 qualified Graphics.Vty as Vty
  import qualified Graphics.Vty.UnicodeWidthTable.Types as Vty
  import           Lens.Micro.Platform ( at, makeLenses, lens, (^?!), (.=)
                                       , (%=), (%~), (.~), _Just, Traversal', to
                                       , SimpleGetter, filtered, traversed, singular
                                       , zoom
                                       )
  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 ( normalChannelSigil )
  import           Matterhorn.InputHistory
  import           Matterhorn.Emoji
  import           Matterhorn.Types.Common
  import           Matterhorn.Types.Core
  import           Matterhorn.Types.Channels
  import           Matterhorn.Types.EditState
  import           Matterhorn.Types.Messages
  import           Matterhorn.Types.MessageInterface
  import           Matterhorn.Types.NonemptyStack
  import           Matterhorn.Types.Posts
  import           Matterhorn.Types.RichText ( TeamBaseURL(..), TeamURLName(..) )
  import           Matterhorn.Types.TabbedWindow
  import           Matterhorn.Types.Users
  import qualified Matterhorn.Zipper as Z
  
  
  -- * Configuration
  
  -- | A notification version for the external notifier
  data NotificationVersion =
      NotifyV1
      | NotifyV2
      | NotifyV3
      deriving (Eq, Read, Show)
  
  -- | 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)
  
  -- | An OTP token source.
  data OTPTokenSource =
      OTPTokenString Text
      | OTPTokenCommand 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 =
      ChannelListGroup { channelListGroupLabel :: ChannelListGroupLabel
                       , channelListGroupUnread :: Int
                       , channelListGroupCollapsed :: Bool
                       , channelListGroupEntries :: Int
                       }
                       deriving (Eq, Show)
  
  nonDMChannelListGroupUnread :: ChannelListGroup -> Int
  nonDMChannelListGroupUnread g =
      case channelListGroupLabel g of
          ChannelGroupDirectMessages -> 0
          _ -> channelListGroupUnread g
  
  data ChannelListSorting =
      ChannelListSortDefault
      | ChannelListSortUnreadFirst
      deriving (Eq, Show, Ord)
  
  data TeamListSorting =
      TeamListSortDefault
      | TeamListSortUnreadFirst
      deriving (Eq, Show, Ord)
  
  data ChannelListWidth =
      ChannelListWidthFixed Int
      -- ^ A fixed width in columns.
      | ChannelListWidthAuto
      -- ^ Automatically determine a reasonable width based on the window
      -- dimensions.
      deriving (Eq, Show, Ord)
  
  newtype CharWidths = CharWidths [(Char, Int)]
                     deriving (Eq, Show)
  
  newCharWidths :: [(Char, Int)] -> CharWidths
  newCharWidths = CharWidths
  
  buildWidthMap :: CharWidths -> Vty.UnicodeWidthTable
  buildWidthMap (CharWidths pairs) =
      Vty.UnicodeWidthTable (mkRange <$> pairs)
      where
          mkRange (ch, w) =
              Vty.WidthTableRange { Vty.rangeStart = toEnum $ fromEnum ch
                                  , Vty.rangeSize = 1
                                  , Vty.rangeColumns = toEnum w
                                  }
  
  -- | 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.
             , configOTPToken :: Maybe OTPTokenSource
             -- ^ The OTP 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.
             , configActivityNotifyVersion :: NotificationVersion
             -- ^ The activity notifier version.
             , configActivityBell :: Bool
             -- ^ Whether to ring the terminal bell on activity.
             , configTruncateVerbatimBlocks :: Maybe Int
             -- ^ Whether to truncate verbatim (and code) blocks past a
             -- reasonable number of lines.
             , 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 :: ChannelListWidth
             -- ^ 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.
             , configChannelListSorting :: ChannelListSorting
             -- ^ How to sort channels in each channel list group
             , configTeamListSorting :: TeamListSorting
             -- ^ How to sort teams in the team list
             , configShowTypingIndicator :: Bool
             -- ^ Whether to show the typing indicator when other users
             -- are typing
             , configSendTypingNotifications :: Bool
             -- 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 KeyEvent
             -- ^ 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.
             , configThreadOrientation :: ThreadOrientation
             -- ^ The orientation of the thread window relative to the
             -- main channel message window.
             , configMouseMode :: Bool
             -- ^ Whether to enable mouse support in matterhorn
             , configShowLastOpenThread :: Bool
             -- ^ Whether to re-open a thread that was open the last time
             -- Matterhorn quit
             , configChannelSelectCaseInsensitive :: Bool
             -- ^ Whether channel selection input is always matched
             -- case-insensitively
             , configCharacterWidths :: Maybe CharWidths
             -- ^ Map of Unicode characters to widths to configure Vty
             } 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
                      , _userPrefFavoriteChannelPrefs :: HashMap ChannelId Bool
                      , _userPrefTeammateNameDisplayMode :: Maybe TeammateNameDisplayMode
                      , _userPrefTeamOrder :: Maybe [TeamId]
                      }
  
  hasUnread :: ClientChannel -> Type -> Bool
  hasUnread chan ty =
      let info = _ccInfo chan
          hasMentions = countMentions && _cdMentionCount info > 0
          hasNewMessages = _cdTotalMessageCount info > _cdViewedMessageCount info
          hasEditThreshold = isJust $ _cdEditedMessageThreshold info
          countMentions = case ty of
              Direct -> False
              Group -> False
              _ -> True
      in hasMentions ||
         (not (isMuted chan) && (hasNewMessages || hasEditThreshold))
  
  mkChannelZipperList :: ChannelListSorting
                      -> UTCTime
                      -> Config
                      -> TeamId
                      -> Maybe ClientConfig
                      -> UserPreferences
                      -> HM.HashMap TeamId (Set ChannelListGroupLabel)
                      -> ClientChannels
                      -> Users
                      -> [(ChannelListGroup, [ChannelListEntry])]
  mkChannelZipperList sorting now config tId cconfig prefs hidden cs us =
      let (privFavs, privEntries) = partitionFavorites $ getChannelEntriesByType tId prefs cs Private
          (normFavs, normEntries) = partitionFavorites $ getChannelEntriesByType tId prefs cs Ordinary
          (dmFavs,   dmEntries)   = partitionFavorites $ getDMChannelEntries now config cconfig prefs us cs
          favEntries              = privFavs <> normFavs <> dmFavs
          isHidden label =
              case HM.lookup tId hidden of
                  Nothing -> False
                  Just s -> Set.member label s
          mkGroup (ty, entries, sortEntries) =
              let unread = length $ filter channelListEntryUnread entries
                  coll = isHidden ty
              in ( ChannelListGroup ty unread coll (length entries)
                 , if coll then mempty else sortEntries entries
                 )
      in mkGroup <$>
         [ (ChannelGroupFavoriteChannels, favEntries, sortChannelListEntries sorting)
         , (ChannelGroupPublicChannels, normEntries, sortChannelListEntries sorting)
         , (ChannelGroupPrivateChannels, privEntries, sortChannelListEntries sorting)
         , (ChannelGroupDirectMessages, dmEntries, sortDMChannelListEntries)
         ]
  
  sortChannelListEntries :: ChannelListSorting -> [ChannelListEntry] -> [ChannelListEntry]
  sortChannelListEntries ChannelListSortDefault =
      sortBy (comparing (\c -> (channelListEntryMuted c, channelListEntrySortValue c)))
  sortChannelListEntries ChannelListSortUnreadFirst =
      sortBy (comparing (not . channelListEntryUnread)) .
      sortChannelListEntries ChannelListSortDefault
  
  sortDMChannelListEntries :: [ChannelListEntry] -> [ChannelListEntry]
  sortDMChannelListEntries = sortBy compareDMChannelListEntries
  
  partitionFavorites :: [ChannelListEntry] -> ([ChannelListEntry], [ChannelListEntry])
  partitionFavorites = partition channelListEntryFavorite
  
  getChannelEntriesByType :: TeamId -> UserPreferences -> ClientChannels -> Type -> [ChannelListEntry]
  getChannelEntriesByType tId prefs cs ty =
      let matches (_, info) = info^.ccInfo.cdType == ty &&
                              info^.ccInfo.cdTeamId == Just tId
          pairs = filteredChannels matches cs
          entries = mkEntry <$> pairs
          mkEntry (cId, ch) = ChannelListEntry { channelListEntryChannelId = cId
                                               , channelListEntryType = CLChannel
                                               , channelListEntryMuted = isMuted ch
                                               , channelListEntryUnread = hasUnread ch ty
                                               , channelListEntrySortValue = ch^.ccInfo.cdDisplayName.to T.toLower
                                               , channelListEntryFavorite = isFavorite prefs cId
                                               }
      in entries
  
  getDMChannelEntries :: UTCTime
                      -> Config
                      -> Maybe ClientConfig
                      -> UserPreferences
                      -> Users
                      -> ClientChannels
                      -> [ChannelListEntry]
  getDMChannelEntries now config cconfig prefs us cs =
      let oneOnOneDmChans = getSingleDMChannelEntries now config cconfig prefs us cs
          groupChans = getGroupDMChannelEntries now config prefs cs
      in groupChans <> oneOnOneDmChans
  
  compareDMChannelListEntries :: ChannelListEntry -> ChannelListEntry -> Ordering
  compareDMChannelListEntries e1 e2 =
      let u1 = channelListEntryUnread e1
          u2 = channelListEntryUnread e2
          n1 = channelListEntrySortValue e1
          n2 = channelListEntrySortValue e2
      in if u1 == u2
         then compare n1 n2
         else if u1 && not u2
              then LT
              else GT
  
  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
                           -> [ChannelListEntry]
  getGroupDMChannelEntries now config prefs cs =
      let matches (_, info) = info^.ccInfo.cdType == Group &&
                              info^.ccInfo.cdTeamId == Nothing &&
                              groupChannelShouldAppear now config prefs info
      in fmap (\(cId, ch) -> ChannelListEntry { channelListEntryChannelId = cId
                                              , channelListEntryType = CLGroupDM
                                              , channelListEntryMuted = isMuted ch
                                              , channelListEntryUnread = hasUnread ch Group
                                              , channelListEntrySortValue = ch^.ccInfo.cdDisplayName
                                              , channelListEntryFavorite = isFavorite prefs cId
                                              }) $
         filteredChannels matches cs
  
  getSingleDMChannelEntries :: UTCTime
                            -> Config
                            -> Maybe ClientConfig
                            -> UserPreferences
                            -> Users
                            -> ClientChannels
                            -> [ChannelListEntry]
  getSingleDMChannelEntries 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 (ChannelListEntry { channelListEntryChannelId = cId
                                                    , channelListEntryType = CLUserDM uId
                                                    , channelListEntryMuted = isMuted c
                                                    , channelListEntryUnread = hasUnread c Direct
                                                    , channelListEntrySortValue = displayNameForUser u cconfig prefs
                                                    , channelListEntryFavorite = isFavorite prefs cId
                                                    })
                      else Nothing
      in mappingWithUserInfo
  
  -- | Return whether the specified channel has been marked as a favorite
  -- channel.
  isFavorite :: UserPreferences -> ChannelId -> Bool
  isFavorite prefs cId = favoriteChannelPreference prefs cId == Just True
  
  -- Always show a DM channel if it has unread activity or has been marked
  -- as a favorite.
  --
  -- 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
          uId = fromJust $ c^.ccInfo.cdDMUserId
          cId = c^.ccInfo.cdChannelId
      in if isFavorite prefs cId
         then True
         else (if hasUnread c Direct || 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
                              ])
  
  -- Always show a group DM channel if it has unread activity or has been
  -- marked as a favorite.
  --
  -- 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).
  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
          cId = c^.ccInfo.cdChannelId
      in if isFavorite prefs cId
         then True
         else (if hasUnread c Group || maybe False (>= localCutoff) (c^.ccInfo.cdSidebarShowOverride)
               then True
               else case groupChannelShowPreference prefs cId 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)
  
  favoriteChannelPreference :: UserPreferences -> ChannelId -> Maybe Bool
  favoriteChannelPreference ps cId = HM.lookup cId (_userPrefFavoriteChannelPrefs ps)
  
  -- | Types that provide a "semantically equal" operation. Two values may
  -- be semantically equal even if they are not equal according to Eq if,
  -- for example, they are equal on the basis of some fields that are more
  -- pertinent than others.
  class (Show a, Eq a, Ord a) => SemEq a where
      semeq :: a -> a -> Bool
  
  instance SemEq Name where
      semeq (ClickableURL mId1 r1 _ t1) (ClickableURL mId2 r2 _ t2) = mId1 == mId2 && t1 == t2 && r1 == r2
      semeq (ClickableUsername mId1 r1 _ n) (ClickableUsername mId2 r2 _ n2) = mId1 == mId2 && n == n2 && r1 == r2
      semeq a b = a == b
  
  instance SemEq a => SemEq (Maybe a) where
      semeq Nothing Nothing = True
      semeq (Just a) (Just b) = a `semeq` b
      semeq _ _ = False
  
  -- | 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
      | MattermostServerError MattermostError
      | 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
                     , _ciOTPToken :: Maybe 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
  
  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
                      , _userPrefFavoriteChannelPrefs = mempty
                      , _userPrefTeammateNameDisplayMode = Nothing
                      , _userPrefTeamOrder = 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)
                  }
              | Just fp <- preferenceToFavoriteChannelPreference p =
                u { _userPrefFavoriteChannelPrefs =
                    HM.insert
                      (favoriteChannelId fp)
                      (favoriteChannelShow fp)
                      (_userPrefFavoriteChannelPrefs u)
                  }
              | Just tIds <- preferenceToTeamOrder p =
                u { _userPrefTeamOrder = Just tIds
                  }
              | 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
      | LogAsyncWork
      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
                    , _crThemeOriginal       :: Theme
                    , _crStatusUpdateChan    :: STM.TChan [UserId]
                    , _crConfiguration       :: Config
                    , _crFlaggedPosts        :: Set PostId
                    , _crUserPreferences     :: UserPreferences
                    , _crSyntaxMap           :: SyntaxMap
                    , _crLogManager          :: LogManager
                    , _crEmoji               :: EmojiCollection
                    , _crSpellChecker        :: Maybe Aspell
                    , _crWindowSize          :: (Int, Int)
                    }
  
  -- | The 'GlobalEditState' value contains state not specific to any
  -- single editor.
  data GlobalEditState =
      GlobalEditState { _gedYankBuffer :: Text
                      }
  
  emptyGlobalEditState :: GlobalEditState
  emptyGlobalEditState =
      GlobalEditState { _gedYankBuffer   = ""
                      }
  
  -- | 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 Work))
  
  -- | Help topics
  data HelpTopic =
      HelpTopic { helpTopicName         :: Text
                , helpTopicDescription  :: Text
                , helpTopicScreen       :: HelpScreen
                }
                deriving (Eq, Show)
  
  -- | Mode type for the current contents of the post list window
  data PostListContents =
      PostListFlagged
      | PostListPinned ChannelId
      | PostListSearch Text Bool -- for the query and search status
      deriving (Eq, Show)
  
  -- | The 'Mode' represents the current dominant UI activity
  data Mode =
      Main
      | ShowHelp HelpTopic
      | ChannelSelect
      | LeaveChannelConfirm
      | DeleteChannelConfirm
      | MessageSelectDeleteConfirm MessageInterfaceTarget
      | PostListWindow PostListContents
      | UserListWindow
      | ReactionEmojiListWindow
      | ChannelListWindow
      | ThemeListWindow
      | ViewMessage
      | EditNotifyPrefs
      | ChannelTopicWindow
      deriving (Eq, Show)
  
  -- | We're either connected or we're not.
  data ConnectionStatus = Connected | Disconnected deriving (Eq)
  
  type ThreadInterface = MessageInterface Name PostId
  type ChannelMessageInterface = MessageInterface Name ()
  
  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)
  
  data ThreadOrientation =
      ThreadBelow
      -- ^ Show the thread below the channel message area.
      | ThreadAbove
      -- ^ Show the thread above the channel message area.
      | ThreadLeft
      -- ^ Show the thread to the left of the channel message area.
      | ThreadRight
      -- ^ Show the thread to the right of the channel message area.
      deriving (Eq, Show)
  
  -- | This type 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.
                , _csLastMouseDownEvent :: Maybe (Brick.BrickEvent Name MHEvent)
                -- ^ The most recent mouse click event we got. We reset
                -- this on mouse up so we can ignore clicks whenever this
                -- is already set.
                , _csVerbatimTruncateSetting :: Maybe Int
                -- ^ The current verbatim block truncation setting. This
                -- is used to toggle truncation behavior and is updated
                -- from the configTruncateVerbatimBlocks Config field.
                , _csTeams :: HashMap TeamId TeamState
                -- ^ The state for each team that we are in.
                , _csTeamZipper :: Z.Zipper () TeamId
                -- ^ The list of teams we can cycle through.
                , _csChannelListOrientation :: ChannelListOrientation
                -- ^ The orientation of the channel list.
                , _csMe :: User
                -- ^ The authenticated user.
                , _csChannels :: ClientChannels
                -- ^ The channels that we are showing, including their
                -- message lists.
                , _csHiddenChannelGroups :: HM.HashMap TeamId (Set ChannelListGroupLabel)
                -- ^ The set of channel list groups that are currently
                -- collapsed in the sidebar.
                , _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.
                , _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.
                , _csClientConfig :: Maybe ClientConfig
                -- ^ The Mattermost client configuration, as we understand it.
                , _csInputHistory :: 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.
                , _csGlobalEditState :: GlobalEditState
                -- ^ Bits of global state common to all editors.
                }
  
  -- | All application state specific to a team, along with state specific
  -- to our user interface's presentation of that team. We include the
  -- UI state relevant to the team so that we can easily switch which
  -- team the UI is presenting without having to reinitialize the UI from
  -- the new team. This allows the user to be engaged in just about any
  -- application activity while viewing a team, switch to another team,
  -- and return to the original team and resume what they were doing, all
  -- without us doing any work.
  data TeamState =
      TeamState { _tsFocus :: Z.Zipper ChannelListGroup ChannelListEntry
                -- ^ The channel sidebar zipper that tracks which channel
                -- is selected.
                , _tsTeam :: Team
                -- ^ The team data.
                , _tsRecentChannel :: Maybe ChannelId
                -- ^ The most recently-selected channel, if any.
                , _tsReturnChannel :: Maybe ChannelId
                -- ^ The channel to return to after visiting one or more
                -- unread channels.
                , _tsModeStack :: NonemptyStack Mode
                -- ^ The current application mode stack when viewing this
                -- team. This is used to dispatch to different rendering
                -- and event handling routines. The current mode is always
                -- in at the top of the stack.
                , _tsChannelSelectState :: ChannelSelectState
                -- ^ The state of the user's input and selection for
                -- channel selection mode.
                , _tsPendingChannelChange :: 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.
                , _tsViewedMessage :: Maybe (Message, TabbedWindow ChatState MH Name 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.).
                , _tsPostListWindow :: PostListWindowState
                -- ^ The state of the post list window.
                , _tsUserListWindow :: ListWindowState UserInfo UserSearchScope
                -- ^ The state of the user list window.
                , _tsChannelListWindow :: ListWindowState Channel ChannelSearchScope
                -- ^ The state of the user list window.
                , _tsNotifyPrefs :: 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.
                , _tsChannelTopicDialog :: ChannelTopicDialogState
                -- ^ The state for the interactive channel topic editor
                -- window.
                , _tsReactionEmojiListWindow :: ListWindowState (Bool, T.Text) ()
                -- ^ The state of the reaction emoji list window.
                , _tsThemeListWindow :: ListWindowState InternalTheme ()
                -- ^ The state of the theme list window.
                , _tsChannelListSorting :: ChannelListSorting
                -- ^ How to sort channels in this team's channel list
                -- groups
                , _tsThreadInterface :: Maybe ThreadInterface
                -- ^ The thread interface for this team for participating
                -- in a single thread
                , _tsMessageInterfaceFocus :: MessageInterfaceFocus
                -- ^ Which message interface is focused for editing input
                }
  
  data MessageInterfaceFocus =
      FocusThread
      | FocusCurrentChannel
      deriving (Eq, Show)
  
  messageInterfaceFocusList :: [MessageInterfaceFocus]
  messageInterfaceFocusList =
      [ FocusCurrentChannel
      , FocusThread
      ]
  
  messageInterfaceFocusNext :: TeamState -> TeamState
  messageInterfaceFocusNext = messageInterfaceFocusWith messageInterfaceFocusList
  
  messageInterfaceFocusPrev :: TeamState -> TeamState
  messageInterfaceFocusPrev = messageInterfaceFocusWith (reverse messageInterfaceFocusList)
  
  messageInterfaceFocusWith :: [MessageInterfaceFocus] -> TeamState -> TeamState
  messageInterfaceFocusWith lst ts =
      let next = fromJust $ cycleElemAfter cur lst
          cur = _tsMessageInterfaceFocus ts
          noThread = isNothing $ _tsThreadInterface ts
          newFocus = if next == FocusThread && noThread
                     then fromJust $ cycleElemAfter FocusThread lst
                     else next
      in ts { _tsMessageInterfaceFocus = newFocus }
  
  cycleElemAfter :: (Eq a) => a -> [a] -> Maybe a
  cycleElemAfter e es =
      if e `notElem` es
      then Nothing
      else Just $ head $ drop 1 $ dropWhile (/= e) $ cycle es
  
  -- | Handles for the View Message window's tabs.
  data ViewMessageWindowTab =
      VMTabMessage
      -- ^ The message tab.
      | VMTabReactions
      -- ^ The reactions tab.
      | VMTabAuthorInfo
      -- ^ The author info tab.
      deriving (Eq, Show)
  
  data PendingChannelChange =
      ChangeByChannelId TeamId ChannelId (Maybe (MH ()))
      | ChangeByUserId UserId
  
  -- | Startup state information that is constructed prior to building a
  -- ChatState.
  data StartupStateInfo =
      StartupStateInfo { startupStateResources      :: ChatResources
                       , startupStateConnectedUser  :: User
                       , startupStateTeams          :: HM.HashMap TeamId TeamState
                       , startupStateTimeZone       :: TimeZoneSeries
                       , startupStateInitialHistory :: InputHistory
                       , startupStateInitialTeam    :: TeamId
                       }
  
  -- | 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)
                              }
  
  sortTeamsAlpha :: [Team] -> [Team]
  sortTeamsAlpha =
      sortBy (compare `on` (T.strip . sanitizeUserText . teamName))
  
  matchesTeam :: T.Text -> Team -> Bool
  matchesTeam tName t =
      let normalizeUserText = normalize . sanitizeUserText
          normalize = T.strip . T.toLower
          urlName = normalizeUserText $ teamName t
          displayName = normalizeUserText $ teamDisplayName t
      in normalize tName `elem` [displayName, urlName]
  
  mkTeamZipperFromIds :: [TeamId] -> Z.Zipper () TeamId
  mkTeamZipperFromIds tIds = Z.fromList [((), tIds)]
  
  teamZipperIds :: Z.Zipper () TeamId -> [TeamId]
  teamZipperIds = concat . fmap snd . Z.toList
  
  -- | The state of channel selection mode.
  data ChannelSelectState =
      ChannelSelectState { _channelSelectInput :: Editor Text Name
                         , _channelSelectMatches :: Z.Zipper ChannelListGroup ChannelSelectMatch
                         }
  
  emptyChannelSelectState :: TeamId -> ChannelSelectState
  emptyChannelSelectState tId =
      ChannelSelectState { _channelSelectInput = editor (ChannelSelectInput tId) (Just 1) ""
                         , _channelSelectMatches = Z.fromList []
                         }
  
  -- | The state of the post list window.
  data PostListWindowState =
      PostListWindowState { _postListPosts    :: Messages
                          , _postListSelected :: Maybe PostId
                          }
  
  data InternalTheme =
      InternalTheme { internalThemeName :: Text
                    , internalTheme :: Theme
                    , internalThemeDesc :: Text
                    }
  
  -- | The state of the search result list window. Type 'a' is the type
  -- of data in the list. Type 'b' is the search scope type.
  data ListWindowState a b =
      ListWindowState { _listWindowSearchResults :: List Name a
                       -- ^ The list of search results currently shown in
                       -- the window.
                       , _listWindowSearchInput :: Editor Text Name
                       -- ^ The editor for the window's search input.
                       , _listWindowSearchScope :: b
                       -- ^ The window's current search scope.
                       , _listWindowSearching :: Bool
                       -- ^ Whether a search is in progress (i.e. whether
                       -- we are currently awaiting a response from a
                       -- search query to the server).
                       , _listWindowEnterHandler :: a -> MH Bool
                       -- ^ The handler to invoke on the selected element
                       -- when the user presses Enter.
                       , _listWindowNewList :: Vec.Vector a -> List Name a
                       -- ^ The function to build a new brick List from a
                       -- vector of search results.
                       , _listWindowFetchResults :: b -> Session -> Text -> IO (Vec.Vector a)
                       -- ^ The function to call to issue a search query
                       -- to the server.
                       , _listWindowRecordCount :: Maybe Int
                       -- ^ The total number of available records, if known.
                       }
  
  -- | The scope for searching for users in a user list window.
  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 { 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 ChatState)) 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 'MH' computation in 'EventM'.
  runMHEvent :: MH () -> EventM Name ChatState ()
  runMHEvent (MH mote) = do
    let mhSt = MHState { mhUsersToFetch = []
                       , mhPendingStatusList = Nothing
                       }
    void $ St.runStateT (R.runReaderT mote Nothing) mhSt
  
  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 ChatState a -> MH a
  mh = MH . R.lift . St.lift
  
  generateUUID :: MH UUID
  generateUUID = liftIO generateUUID_IO
  
  generateUUID_IO :: IO UUID
  generateUUID_IO = randomIO
  
  mhZoom :: Lens' ChatState b -> (e -> EventM Name b ()) -> e -> MH ()
  mhZoom ln f event = MH $ R.lift $ St.lift $ zoom ln (f event)
  
  mhZoom' :: Lens' ChatState b -> (EventM Name b ()) -> MH ()
  mhZoom' ln f = MH $ R.lift $ St.lift $ zoom ln f
  
  mhSuspendAndResume :: (ChatState -> IO ChatState) -> MH ()
  mhSuspendAndResume act = MH $ R.lift $ St.lift $ do
      st <- St.get
      Brick.suspendAndResume (act st)
  
  mhContinueWithoutRedraw :: MH ()
  mhContinueWithoutRedraw = MH $ R.lift $ St.lift $ Brick.continueWithoutRedraw
  
  -- | This will request that after this computation finishes the
  -- application should exit
  requestQuit :: MH ()
  requestQuit = MH $ R.lift $ St.lift $ 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 Monad MH where
      return = pure
      MH x >>= f = MH (x >>= \ x' -> fromMH (f x'))
  
  instance St.MonadState ChatState MH where
      get = MH $ R.lift $ St.lift St.get
      put = MH . R.lift . St.lift . St.put
  
  instance St.MonadIO MH where
      liftIO = MH . St.liftIO
  
  data Work = Work String (MH ())
  
  -- | 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 Work
      -- ^ For the result values of async 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
      | AttachmentException SomeException
      -- ^ IO operations for attaching a file threw an exception
      | BadAttachmentPath T.Text
      -- ^ The specified file is either a directory or doesn't exist
      | AsyncErrEvent SomeException
      -- ^ For errors that arise in the course of async IO operations
      deriving (Show)
  
  -- ** Application State Lenses
  
  makeLenses ''ChatResources
  makeLenses ''ChatState
  makeLenses ''TeamState
  makeLenses ''GlobalEditState
  makeLenses ''PostListWindowState
  makeLenses ''ListWindowState
  makeLenses ''ChannelSelectState
  makeLenses ''UserPreferences
  makeLenses ''ConnectionInfo
  makeLenses ''ChannelTopicDialogState
  Brick.suffixLenses ''Config
  
  -- | Given a list of event handlers and an event, try to handle the
  -- event with the handlers in the specified order. If a handler returns
  -- False (indicating it did not handle the event), try the next handler
  -- until either a handler returns True or all handlers are tried.
  -- Returns True if any handler handled the event or False otherwise.
  handleEventWith :: [Vty.Event -> MH Bool] -> Vty.Event -> MH Bool
  handleEventWith [] _ =
      return False
  handleEventWith (handler:rest) e = do
      handled <- handler e
      if handled
         then return True
         else handleEventWith rest e
  
  applyTeamOrderPref :: Config -> Maybe [TeamId] -> ChatState -> ChatState
  applyTeamOrderPref cfg mPrefTIds st =
      let teams = _csTeams st
          ourTids = HM.keys teams
          tIds = case mPrefTIds of
              Nothing -> []
              Just prefTIds -> filter (`elem` ourTids) prefTIds
          curTId = st^.csCurrentTeamId
          unmentioned = filter (not . wasMentioned) $ HM.elems teams
          wasMentioned ts = (teamId $ _tsTeam ts) `elem` tIds
          zipperTidsBeforeConfigSort = tIds <> (teamId <$> sortTeamsAlpha (_tsTeam <$> unmentioned))
          zipperTids = applyTeamSorting st (cfg^.configTeamListSortingL) zipperTidsBeforeConfigSort
      in st { _csTeamZipper = (Z.findRight ((== curTId) . Just) $ mkTeamZipperFromIds zipperTids)
            }
  
  applyTeamSorting :: ChatState -> TeamListSorting -> [TeamId] -> [TeamId]
  applyTeamSorting _ TeamListSortDefault tIds = tIds
  applyTeamSorting st TeamListSortUnreadFirst tIds =
      let withCount tId = (tId, teamUnreadCount tId st)
          withCounts = withCount <$> tIds
          unreadFirst = sortBy (comparing (Down . (> 0) . snd)) withCounts
      in fst <$> unreadFirst
  
  refreshTeamZipper :: MH ()
  refreshTeamZipper = do
      config <- use (csResources.crConfiguration)
      tidOrder <- use (csResources.crUserPreferences.userPrefTeamOrder)
      St.modify (applyTeamOrderPref config tidOrder)
  
  applyTeamOrder :: [TeamId] -> MH ()
  applyTeamOrder tIds = do
      config <- use (csResources.crConfiguration)
      St.modify (applyTeamOrderPref config $ Just tIds)
  
  newState :: StartupStateInfo -> ChatState
  newState (StartupStateInfo {..}) =
      let config = _crConfiguration startupStateResources
      in applyTeamOrderPref config (_userPrefTeamOrder $ _crUserPreferences startupStateResources) $
         ChatState { _csResources                   = startupStateResources
                   , _csLastMouseDownEvent          = Nothing
                   , _csGlobalEditState             = emptyGlobalEditState
                   , _csVerbatimTruncateSetting     = configTruncateVerbatimBlocks config
                   , _csTeamZipper                  = Z.findRight (== startupStateInitialTeam) $
                                                      mkTeamZipperFromIds $ teamId <$> (_tsTeam <$> snd <$> HM.toList startupStateTeams)
                   , _csTeams                       = startupStateTeams
                   , _csChannelListOrientation      = configChannelListOrientation config
                   , _csMe                          = startupStateConnectedUser
                   , _csChannels                    = noChannels
                   , _csPostMap                     = HM.empty
                   , _csUsers                       = noUsers
                   , _timeZone                      = startupStateTimeZone
                   , _csConnectionStatus            = Connected
                   , _csWorkerIsBusy                = Nothing
                   , _csClientConfig                = Nothing
                   , _csInputHistory                = startupStateInitialHistory
                   , _csHiddenChannelGroups         = mempty
                   }
  
  getServerBaseUrl :: TeamId -> MH TeamBaseURL
  getServerBaseUrl tId = do
      st <- use id
      return $ serverBaseUrl st tId
  
  serverBaseUrl :: ChatState -> TeamId -> TeamBaseURL
  serverBaseUrl st tId =
      let baseUrl = connectionDataURL $ _crConn $ _csResources st
          tName = teamName $ st^.csTeam(tId).tsTeam
      in TeamBaseURL (TeamURLName $ sanitizeUserText tName) baseUrl
  
  getSession :: MH Session
  getSession = use (csResources.crSession)
  
  getResourceSession :: ChatResources -> Session
  getResourceSession = _crSession
  
  whenMode :: TeamId -> Mode -> MH () -> MH ()
  whenMode tId m act = do
      curMode <- top <$> use (csTeam(tId).tsModeStack)
      when (curMode == m) act
  
  pushMode :: TeamId -> Mode -> MH ()
  pushMode tId m = do
      St.modify (pushMode' tId m)
      mh invalidateCache
  
  replaceMode :: TeamId -> Mode -> MH ()
  replaceMode tId m = popMode tId >> pushMode tId m
  
  popMode :: TeamId -> MH ()
  popMode tId = do
      s <- use (csTeam(tId).tsModeStack)
      let (s', topVal) = pop s
      case topVal of
          Nothing -> return ()
          Just _ -> do
              csTeam(tId).tsModeStack .= s'
              mh invalidateCache
  
  pushMode' :: TeamId -> Mode -> ChatState -> ChatState
  pushMode' tId m st =
      let s = st^.csTeam(tId).tsModeStack
      in if top s == m
         then st
         else st & csTeam(tId).tsModeStack %~ (push m)
  
  -- ** Utility Lenses
  csCurrentChannelId :: TeamId -> SimpleGetter ChatState (Maybe ChannelId)
  csCurrentChannelId tId =
      csTeam(tId).tsFocus.to Z.focus.to (fmap channelListEntryChannelId)
  
  teamUnreadCount :: TeamId -> ChatState -> Int
  teamUnreadCount tId st =
      sum $ fmap (nonDMChannelListGroupUnread . fst) $
            Z.toList $
            st^.csTeam(tId).tsFocus
  
  withCurrentTeam :: (TeamId -> MH ()) -> MH ()
  withCurrentTeam f = do
      mtId <- use csCurrentTeamId
      case mtId of
          Nothing -> return ()
          Just tId -> f tId
  
  forEachTeam :: (TeamId -> MH ()) -> MH ()
  forEachTeam f = do
      ts <- use csTeams
      mapM_ f (HM.keys ts)
  
  withCurrentChannel :: TeamId -> (ChannelId -> ClientChannel -> MH ()) -> MH ()
  withCurrentChannel tId f = do
      mcId <- use $ csCurrentChannelId tId
      case mcId of
          Nothing -> return ()
          Just cId -> do
              mChan <- preuse $ csChannel cId
              case mChan of
                  Just ch -> f cId ch
                  _ -> return ()
  
  withCurrentChannel' :: TeamId -> (ChannelId -> ClientChannel -> MH (Maybe a)) -> MH (Maybe a)
  withCurrentChannel' tId f = do
      mcId <- use $ csCurrentChannelId tId
      case mcId of
          Nothing -> return Nothing
          Just cId -> do
              mChan <- preuse $ csChannel cId
              case mChan of
                  Just ch -> f cId ch
                  _ -> return Nothing
  
  csCurrentTeamId :: SimpleGetter ChatState (Maybe TeamId)
  csCurrentTeamId = csTeamZipper.to Z.focus
  
  csChannelMessageInterface :: ChannelId -> Lens' ChatState ChannelMessageInterface
  csChannelMessageInterface cId =
      csChannels.maybeChannelByIdL cId.singular _Just.ccMessageInterface
  
  maybeChannelMessageInterface :: ChannelId -> Traversal' ChatState ChannelMessageInterface
  maybeChannelMessageInterface cId =
      csChannels.maybeChannelByIdL cId._Just.ccMessageInterface
  
  channelEditor :: ChannelId -> Lens' ChatState (EditState Name)
  channelEditor cId =
      csChannels.maybeChannelByIdL cId.singular _Just.ccMessageInterface.miEditor
  
  channelMessageSelect :: ChannelId -> Lens' ChatState MessageSelectState
  channelMessageSelect cId =
      csChannels.maybeChannelByIdL cId.singular _Just.ccMessageInterface.miMessageSelect
  
  csTeam :: TeamId -> Lens' ChatState TeamState
  csTeam tId =
      lens (\ st -> st ^. csTeams . at tId ^?! _Just)
           (\ st t -> st & csTeams . at tId .~ Just t)
  
  teamMode :: TeamState -> Mode
  teamMode = top . _tsModeStack
  
  teamModes :: TeamState -> [Mode]
  teamModes = stackToList . _tsModeStack
  
  getTeamMode :: TeamId -> MH Mode
  getTeamMode tId = teamMode <$> use (csTeam(tId))
  
  channelListEntryUserId :: ChannelListEntry -> Maybe UserId
  channelListEntryUserId e =
      case channelListEntryType e of
          CLUserDM uId -> Just uId
          _ -> Nothing
  
  userIdsFromZipper :: Z.Zipper ChannelListGroup ChannelListEntry -> [UserId]
  userIdsFromZipper z =
      concat $ (catMaybes . fmap channelListEntryUserId . snd) <$> Z.toList z
  
  entryIsDMEntry :: ChannelListEntry -> Bool
  entryIsDMEntry e =
      case channelListEntryType e of
          CLUserDM {} -> True
          CLGroupDM {} -> True
          CLChannel {} -> False
  
  csChannel :: ChannelId -> Traversal' ChatState ClientChannel
  csChannel cId =
      csChannels . channelByIdL cId
  
  csChannelMessages :: ChannelId -> Traversal' ChatState Messages
  csChannelMessages cId =
      csChannelMessageInterface(cId).miMessages
  
  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
  
  type MHKeyEventHandler = KeyEventHandler KeyEvent MH
  
  mhHandleKeyboardEvent :: (KeyConfig KeyEvent -> KeyDispatcher KeyEvent MH)
                        -- ^ The function to build a key handler map from
                        -- a key configuration.
                        -> Vty.Event
                        -- ^ The event to handle.
                        -> MH Bool
  mhHandleKeyboardEvent mkDispatcher (Vty.EvKey k mods) = do
      config <- use (csResources.crConfiguration)
      handleKey (mkDispatcher $ configUserKeys config) k mods
  mhHandleKeyboardEvent _ _ =
      return False
  
  -- | Create a key dispatcher, but convert errors about conflict bindings
  -- into a runtime exception with 'error'. Where we use this, it's safe
  -- to use because we do a startup check for keybinding conflicts in
  -- most application modes, which means that by the time we get around
  -- to calling this function, the modes have already been checked and
  -- have been found free of conflicts. However, there could be situations
  -- in the future where we can't detect collisions at startup due to
  -- dynamically built handler lists. In those cases, this would cause
  -- the program to crash with a detailed error about the conflicting key
  -- binding.
  unsafeKeyDispatcher :: (Ord k) => KeyConfig k -> [KeyEventHandler k m] -> KeyDispatcher k m
  unsafeKeyDispatcher cfg hs =
      case keyDispatcher cfg hs of
          Right d -> d
          Left conflicts ->
              error $ T.unpack $ "Error: conflicting key bindings:\n" <>
                                 bindingConflictMessage cfg conflicts
  
  bindingConflictMessage :: (Ord k) => KeyConfig k -> [(Binding, [KeyHandler k m])] -> T.Text
  bindingConflictMessage cfg conflicts = msg
      where
          msg = T.intercalate "\n" sections
          sections = mkSection <$> conflicts
          mkSection (key, handlers) =
              let handlerLines = handlerLine <$> handlers
                  handlerLine h =
                      let desc = handlerDescription $ kehHandler $ khHandler h
                          trigger = case kehEventTrigger $ khHandler h of
                              ByEvent e -> "event '" <> fromJust (keyEventName (keyConfigEvents cfg) e) <> "'"
                              ByKey b -> "fixed key '" <> ppBinding b <> "'"
                      in "  '" <> desc <> "', triggered by " <> trigger
              in T.intercalate "\n" $ [ "Conflicting key binding: " <> ppBinding key
                                      , "Handlers:"
                                      ] <> handlerLines
  
  -- ** '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 = void $ liftIO $ BCH.writeBChanNonBlocking chan e
  
  -- | 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)
      cs <- use csChannels
      forM_ (allTeamIds cs) $ \tId ->
          mh $ invalidateCacheEntry $ ChannelSidebar tId
  
  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 :: TeamId -> Text -> ChatState -> Maybe ChannelId
  channelIdByChannelName tId name st =
      let matches (_, cc) = cc^.ccInfo.cdName == (trimChannelSigil name) &&
                            cc^.ccInfo.cdTeamId == (Just tId)
      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)
  
  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 :: Traversal' ChatState (EditState n) -> MH ()
  resetAutocomplete which = do
      which.esAutocomplete .= Nothing
      which.esAutocompletePending .= 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
  
  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
  
  -- | 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
                   }
  
  emptyHSet :: HighlightSet
  emptyHSet = HighlightSet Set.empty Set.empty mempty
  
  getHighlightSet :: ChatState -> TeamId -> HighlightSet
  getHighlightSet st tId =
      HighlightSet { hUserSet = addSpecialUserMentions $ getUsernameSet $ st^.csUsers
                   , hChannelSet = getChannelNameSet tId $ 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 (MessageInterfaceMessages $ MessageInput cId)
      csChannel(cId) %= (clearNewMessageIndicator .
                         clearEditedThreshold)
  
  moveLeft :: (Eq a) => a -> [a] -> [a]
  moveLeft v as =
      case elemIndex v as of
          Nothing -> as
          Just 0 -> as
          Just i ->
              let (h, t) = splitAt i as
              in init h <> [v, last h] <> tail t
  
  moveRight :: (Eq a) => a -> [a] -> [a]
  moveRight v as =
      case elemIndex v as of
          Nothing -> as
          Just i
              | i == length as - 1 -> as
              | otherwise ->
                  let (h, t) = splitAt i as
                  in h <> [head (tail t), v] <> (tail (tail t))
  
  resultToWidget :: Result n -> Widget n
  resultToWidget = Widget Fixed Fixed . return
  
  threadInterface :: (HasCallStack) => TeamId -> Traversal' ChatState ThreadInterface
  threadInterface tId = maybeThreadInterface(tId)._Just
  
  -- An unsafe lens to get the specified team's thread interface. Assumes
  -- the interface is present; if not, this crashes. Intended for places
  -- where you know the interface will be present due to other state and
  -- don't want to deal with Maybe.
  unsafeThreadInterface :: (HasCallStack) => TeamId -> Lens' ChatState ThreadInterface
  unsafeThreadInterface tId = maybeThreadInterface(tId).singular _Just
  
  -- A safe version of unsafeThreadInterface.
  maybeThreadInterface :: TeamId -> Lens' ChatState (Maybe ThreadInterface)
  maybeThreadInterface tId = csTeam(tId).tsThreadInterface
  
  threadInterfaceEmpty :: TeamId -> MH Bool
  threadInterfaceEmpty tId = do
      mLen <- preuse (maybeThreadInterface(tId)._Just.miMessages.to messagesLength)
      case mLen of
          Nothing -> return True
          Just len -> return $ len == 0
  
  withThreadInterface :: TeamId -> ChannelId -> MH () -> MH ()
  withThreadInterface tId cId act = do
      mCid <- preuse (maybeThreadInterface(tId)._Just.miChannelId)
      case mCid of
          Just i | i == cId -> act
          _ -> return ()
  
  threadInterfaceDeleteWhere :: TeamId -> ChannelId -> (Message -> Bool) -> MH ()
  threadInterfaceDeleteWhere tId cId f =
      withThreadInterface tId cId $ do
          maybeThreadInterface(tId)._Just.miMessages.traversed.filtered f %=
              (& mDeleted .~ True)
  
  modifyThreadMessages :: TeamId -> ChannelId -> (Messages -> Messages) -> MH ()
  modifyThreadMessages tId cId f = do
      withThreadInterface tId cId $ do
          maybeThreadInterface(tId)._Just.miMessages %= f
  
  modifyEachThreadMessage :: TeamId -> ChannelId -> (Message -> Message) -> MH ()
  modifyEachThreadMessage tId cId f = do
      withThreadInterface tId cId $ do
          maybeThreadInterface(tId)._Just.miMessages.traversed %= f
  