packages feed

matterhorn-50200.18.0: src/Matterhorn/State/Editing.hs

{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE RankNTypes #-}
module Matterhorn.State.Editing
  ( requestSpellCheck
  , editingKeybindings
  , editingKeyHandlers
  , toggleMultilineEditing
  , invokeExternalEditor
  , handlePaste
  , handleInputSubmission
  , getEditorContent
  , handleEditingInput
  , cancelAutocompleteOrReplyOrEdit
  , replyToLatestMessage
  , Direction(..)
  , tabComplete
  )
where

import           Prelude ()
import           Matterhorn.Prelude

import           Brick ( BrickEvent(VtyEvent) )
import           Brick.Keybindings
import           Brick.Main ( invalidateCache )
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           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 ( Traversal', 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 ( Aspell, AspellResponse(..), mistakeWord, askAspell )

import           Network.Mattermost.Types ( Post(..) )

import           Matterhorn.Config
import {-# SOURCE #-} Matterhorn.Command ( dispatchCommand )
import           Matterhorn.InputHistory
import           Matterhorn.State.Common
import           Matterhorn.State.Autocomplete
import {-# SOURCE #-} Matterhorn.State.Messages
import {-# SOURCE #-} Matterhorn.State.ThreadWindow
import           Matterhorn.Types hiding ( newState )
import           Matterhorn.Types.Common ( sanitizeUserText' )


startMultilineEditing :: Lens' ChatState (EditState Name) -> MH ()
startMultilineEditing which = do
    mh invalidateCache
    which.esEphemeral.eesMultiline .= True

toggleMultilineEditing :: Lens' ChatState (EditState Name) -> MH ()
toggleMultilineEditing which = do
    mh invalidateCache
    which.esEphemeral.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 (which.esEphemeral.eesMultiline)
    numLines <- use (which.esEditor.to getEditContents.to length)
    when (not multiline && numLines > 1) (resetAutocomplete which)

invokeExternalEditor :: Lens' ChatState (EditState Name) -> MH ()
invokeExternalEditor which = 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^.which.esEditor
        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 & which.esEditor.editContentsL .~ (Z.textZipper tmpLines Nothing)
            Sys.ExitFailure _ -> return st

handlePaste :: Lens' ChatState (EditState Name) -> BS.ByteString -> MH ()
handlePaste which bytes = do
  let pasteStr = T.pack (UTF8.toString bytes)
  which.esEditor %= applyEdit (Z.insertMany (sanitizeUserText' pasteStr))
  contents <- use (which.esEditor.to getEditContents)
  case length contents > 1 of
      True -> startMultilineEditing which
      False -> return ()

editingPermitted :: ChatState -> Lens' ChatState (EditState Name) -> Bool
editingPermitted st which =
    (length (getEditContents $ st^.which.esEditor) == 1) ||
    st^.which.esEphemeral.eesMultiline

editingKeybindings :: Lens' ChatState (Editor T.Text Name) -> KeyConfig KeyEvent -> KeyDispatcher KeyEvent MH
editingKeybindings editor kc = unsafeKeyDispatcher kc $ editingKeyHandlers editor

editingKeyHandlers :: Lens' ChatState (Editor T.Text Name) -> [MHKeyEventHandler]
editingKeyHandlers editor =
  [ onEvent EditorTransposeCharsEvent
    "Transpose the final two characters"
    (editor %= applyEdit Z.transposeChars)
  , onEvent EditorBolEvent
    "Go to the start of the current line"
    (editor %= applyEdit Z.gotoBOL)
  , onEvent EditorEolEvent
    "Go to the end of the current line"
    (editor %= applyEdit Z.gotoEOL)
  , onEvent EditorDeleteCharacter
    "Delete the character at the cursor"
    (editor %= applyEdit Z.deleteChar)
  , onEvent EditorKillToBolEvent
    "Delete from the cursor to the start of the current line"
    (editor %= applyEdit Z.killToBOL)
  , onEvent EditorNextCharEvent
    "Move one character to the right"
    (editor %= applyEdit Z.moveRight)
  , onEvent EditorPrevCharEvent
    "Move one character to the left"
    (editor %= applyEdit Z.moveLeft)
  , onEvent EditorNextWordEvent
    "Move one word to the right"
    (editor %= applyEdit Z.moveWordRight)
  , onEvent EditorPrevWordEvent
    "Move one word to the left"
    (editor %= applyEdit Z.moveWordLeft)
  , onEvent EditorDeletePrevWordEvent
    "Delete the word to the left of the cursor" $ do
    editor %= applyEdit Z.deletePrevWord
  , onEvent EditorDeleteNextWordEvent
    "Delete the word to the right of the cursor" $ do
    editor %= applyEdit Z.deleteWord
  , onEvent EditorHomeEvent
    "Move the cursor to the beginning of the input" $ do
    editor %= applyEdit gotoHome
  , onEvent EditorEndEvent
    "Move the cursor to the end of the input" $ do
    editor %= applyEdit gotoEnd
  , onEvent 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)
      csGlobalEditState.gedYankBuffer .= restOfLine
      editor %= applyEdit Z.killToEOL
  , onEvent EditorYankEvent
    "Paste the current buffer contents at the cursor" $ do
        buf <- use (csGlobalEditState.gedYankBuffer)
        editor %= applyEdit (Z.insertMany buf)
  ]

getEditorContent :: Lens' ChatState (EditState Name) -> MH Text
getEditorContent which = do
    cmdLine <- use (which.esEditor)
    let (line, rest) = case getEditContents cmdLine of
            (a:as) -> (a, as)
            _ -> error "BUG: getEditorContent: got empty edit contents"
    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 :: Lens' ChatState (EditState Name)
                      -> Text
                      -> MH ()
handleInputSubmission editWhich content = do
    cId <- use (editWhich.esChannelId)

    -- 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.
    editWhich.esEditor %= applyEdit Z.clearZipper
    editWhich.esEphemeral.eesInputHistoryPosition .= Nothing

    csInputHistory %= addHistoryEntry content cId

    case T.uncons content of
      Just ('/', cmd) -> do
          tId <- do
              mTid <- use (editWhich.esTeamId)
              curTid <- fromJust <$> use csCurrentTeamId
              return $ fromMaybe curTid mTid
          dispatchCommand tId cmd
      _ -> do
          attachments <- use (editWhich.esAttachmentList.L.listElementsL)
          mode <- use (editWhich.esEditMode)
          sendMessage cId mode content $ F.toList attachments

          -- Empty the attachment list only if a mesage is
          -- actually sent, since it's possible to /attach a
          -- file before actually sending the message
          resetAttachmentList editWhich

    -- Reset the autocomplete UI
    resetAutocomplete editWhich

    -- Reset the edit mode *after* handling the input so that the input
    -- handler can tell whether we're editing, replying, etc.
    resetEditMode <- use (editWhich.esResetEditMode)
    editWhich.esEditMode .= resetEditMode

closingPunctuationMarks :: String
closingPunctuationMarks = ".,'\";:)]!?"

isSmartClosingPunctuation :: Event -> Bool
isSmartClosingPunctuation (EvKey (KChar c) []) = c `elem` closingPunctuationMarks
isSmartClosingPunctuation _ = False

handleEditingInput :: Lens' ChatState (EditState Name)
                   -> Event
                   -> MH ()
handleEditingInput which e = do
    cId <- use (which.esChannelId)

    -- 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 (which.esEditor.to getEditContents.to length)

    smartBacktick <- use (csResources.crConfiguration.configSmartBacktickL)
    let smartChars = "*`_"
    st <- use id
    which.esEphemeral.eesInputHistoryPosition .= Nothing

    smartEditing <- use (csResources.crConfiguration.configSmartEditingL)
    justCompleted <- use (which.esJustCompleted)

    conf <- use (csResources.crConfiguration)
    let keyMap = editingKeybindings (which.esEditor) (configUserKeys conf)
    case e of
        EvKey k mods -> do
            case lookupVtyEvent k mods keyMap of
              Just kb | editingPermitted st which -> (handlerAction $ kehHandler $ khHandler kb)
              _ -> do
                case (k, mods) of
                  -- Not editing; backspace here means cancel multi-line message
                  -- composition
                  (KBS, []) | (not $ editingPermitted st which) -> do
                    which.esEditor %= applyEdit Z.clearZipper
                    mh invalidateCache

                  -- Backspace in editing mode with smart pair insertion means
                  -- smart pair removal when possible
                  (KBS, []) | editingPermitted st which && smartBacktick ->
                      let backspace = which.esEditor %= applyEdit Z.deletePrevChar
                      in case cursorAtOneOf smartChars (st^.which.esEditor) of
                          Nothing -> backspace
                          Just ch ->
                              -- Smart char removal:
                              if | (cursorAtChar ch $ applyEdit Z.moveLeft $ st^.which.esEditor) &&
                                   (cursorIsAtEnd $ applyEdit Z.moveRight $ st^.which.esEditor) ->
                                     which.esEditor %= applyEdit (Z.deleteChar >>> Z.deletePrevChar)
                                 | otherwise -> backspace

                  (KChar ch, [])
                    | editingPermitted st which && smartBacktick && ch `elem` smartChars ->
                      -- Smart char insertion:
                      let doInsertChar = do
                            which.esEditor %= applyEdit (Z.insertChar ch)
                            sendUserTypingAction which
                          curLine = Z.currentLine $ st^.which.esEditor.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^.which.esEditor) &&
                                 curLine == "``" &&
                                 ch == '`' -> do
                                which.esEditor %= applyEdit (Z.insertMany (T.singleton ch))
                                which.esEphemeral.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^.which.esEditor) ||
                                 ((cursorAtChar ' ' (applyEdit Z.moveLeft $ st^.which.esEditor)) &&
                                  (cursorIsAtEnd $ st^.which.esEditor)) ->
                                which.esEditor %= 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^.which.esEditor) &&
                              (cursorIsAtEnd $ applyEdit Z.moveRight $ st^.which.esEditor) ->
                                which.esEditor %= applyEdit Z.moveRight
                            -- Fall-through case: just insert one of the chars
                            -- without doing anything smart.
                            | otherwise -> doInsertChar
                    | editingPermitted st which -> 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) $
                          which.esEditor %= applyEdit Z.deletePrevChar

                      which.esEditor %= applyEdit (Z.insertMany (sanitizeUserText' $ T.singleton ch))
                      sendUserTypingAction which
                  _ | editingPermitted st which -> do
                      mhZoom (which.esEditor) handleEditorEvent (VtyEvent e)
                      sendUserTypingAction which
                    | otherwise -> return ()
        _ -> return ()

    let ctx = AutocompleteContext { autocompleteManual = False
                                  , autocompleteFirstMatch = False
                                  }
    checkForAutocompletion which ctx

    -- Reset the spell check timer for this editor
    mReset <- use (which.esSpellCheckTimerReset)
    case mReset of
        Nothing -> return ()
        Just reset -> liftIO reset

    -- 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 (which.esEditor.to getEditContents.to length)
    isMultiline <- use (which.esEphemeral.eesMultiline)
    isPreviewing <- use (csResources.crConfiguration.configShowMessagePreviewL)
    when (beforeLineCount /= afterLineCount && isMultiline && isPreviewing) $ do
        invalidateChannelRenderingCache cId

    -- Reset the recent autocompletion flag to stop smart punctuation
    -- handling.
    when justCompleted $
        which.esJustCompleted .= 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 :: Lens' ChatState (EditState Name)
                     -> MH ()
sendUserTypingAction which = do
    st <- use id
    when (configSendTypingNotifications (st^.csResources.crConfiguration)) $
      case st^.csConnectionStatus of
        Connected -> do
          let pId = case st^.which.esEditMode of
                      Replying _ post -> Just $ postId post
                      _               -> Nothing
          cId <- use (which.esChannelId)
          liftIO $ do
            now <- getCurrentTime
            let action = UserTyping now cId 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 :: Aspell -> MessageInterfaceTarget -> MH ()
requestSpellCheck checker target = do
    -- Get the editor contents.
    mContents <- case target of
        MITeamThread tId -> do
            mTi <- use (maybeThreadInterface(tId))
            case mTi of
                Nothing -> return Nothing
                Just ti -> return $ Just $ getEditContents $ ti^.miEditor.esEditor
        MIChannel cId -> do
            mMi <- preuse (maybeChannelMessageInterface(cId))
            case mMi of
                Nothing -> return Nothing
                Just mi -> return $ Just $ getEditContents $ mi^.miEditor.esEditor

    case mContents of
        Nothing -> return ()
        Just contents ->
            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

                        case target of
                            MITeamThread tId ->
                                maybeThreadInterface(tId)._Just.miEditor.esMisspellings .= allMistakes
                            MIChannel cId ->
                                maybeChannelMessageInterface(cId).miEditor.esMisspellings .= 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

-- Cancels the following states in this order, as appropriate based on
-- context:
-- * Autocomplete UI display
-- * Reply
-- * Edit
-- * Close current team's thread window if open
cancelAutocompleteOrReplyOrEdit :: Lens' ChatState (EditState Name) -> MH ()
cancelAutocompleteOrReplyOrEdit which = do
    cId <- use (which.esChannelId)
    invalidateChannelRenderingCache cId
    ac <- use (which.esAutocomplete)
    case ac of
        Just _ -> do
            resetAutocomplete which
        Nothing -> do
            resetEditMode <- use (which.esResetEditMode)

            let resetEditor = do
                    which.esEditMode .= resetEditMode
                    which.esEditor %= applyEdit Z.clearZipper
                    resetAttachmentList which

            curEditMode <- use (which.esEditMode)
            case curEditMode of
                NewPost -> return ()
                Editing {} -> resetEditor
                Replying {} -> do
                    prevMode <- use (which.esEditMode)
                    resetEditor
                    newMode <- use (which.esEditMode)
                    when (newMode == prevMode) $
                        withCurrentTeam $ \tId -> do
                            ti <- use (csTeam(tId).tsThreadInterface)
                            foc <- use (csTeam(tId).tsMessageInterfaceFocus)
                            when (isJust ti && foc == FocusThread) $
                                closeThreadWindow tId

replyToLatestMessage :: Lens' ChatState (MessageInterface n i) -> MH ()
replyToLatestMessage which = do
    msgs <- use (which.miMessages)
    cId <- use (which.miChannelId)
    case findLatestUserMessage isReplyable msgs of
      Just msg | isReplyable msg ->
          do rootMsg <- getReplyRootMessage msg
             invalidateChannelRenderingCache cId
             which.miEditor.esEditMode .= Replying rootMsg (fromJust $ rootMsg^.mOriginalPost)
      _ -> return ()

data Direction = Forwards | Backwards

tabComplete :: Traversal' ChatState (EditState Name) -> Direction -> MH ()
tabComplete which 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

    which.esAutocomplete._Just.acCompletionList %= transform

    mac <- join <$> preuse (which.esAutocomplete)
    case mac of
        Nothing -> do
            let ctx = AutocompleteContext { autocompleteManual = True
                                          , autocompleteFirstMatch = True
                                          }
            checkForAutocompletion which 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
                    which.esEditor %=
                        applyEdit (Z.insertChar ' ' . Z.insertMany replacement . Z.deletePrevWord .
                                   maybeEndOfWord)
                    which.esJustCompleted .= 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 which)

resetAttachmentList :: Lens' ChatState (EditState Name) -> MH ()
resetAttachmentList which = do
    which.esAttachmentList %= L.listClear