matterhorn-50200.17.0: src/Matterhorn/State/Common.hs
module Matterhorn.State.Common
(
-- * System interface
openFilePath
, openWithOpener
, runLoggedCommand
, fetchFile
, fetchFileAtPath
-- * Posts
, installMessagesFromPosts
, updatePostMap
-- * Utilities
, postInfoMessage
, postErrorMessageIO
, postErrorMessage'
, addEmoteFormatting
, removeEmoteFormatting
, toggleMouseMode
, fetchMentionedUsers
, doPendingUserFetches
, doPendingUserStatusFetches
, setThreadOrientationByName
-- Cache management
, invalidateChannelRenderingCache
, invalidateMessageRenderingCacheByPostId
, module Matterhorn.State.Async
)
where
import Prelude ()
import Matterhorn.Prelude
import Brick.Main ( invalidateCacheEntry, invalidateCache, getVtyHandle )
import Control.Concurrent ( MVar, putMVar, forkIO )
import qualified Control.Concurrent.STM as STM
import Control.Exception ( SomeException, try )
import qualified Data.ByteString as BS
import qualified Data.HashMap.Strict as HM
import qualified Data.Sequence as Seq
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Graphics.Vty as Vty
import Lens.Micro.Platform ( (.=), (%=), (%~), (.~) )
import System.Directory ( createDirectoryIfMissing )
import System.Environment.XDG.BaseDir ( getUserCacheDir )
import System.Exit ( ExitCode(..) )
import System.FilePath
import System.IO ( hGetContents, hFlush, hPutStrLn )
import System.Process ( proc, std_in, std_out, std_err, StdStream(..)
, createProcess, waitForProcess )
import Network.Mattermost.Endpoints
import Network.Mattermost.Lenses
import Network.Mattermost.Types
import Matterhorn.FilePaths ( xdgName )
import Matterhorn.State.Async
import Matterhorn.Types
import Matterhorn.Types.Common
-- * Client Messages
-- | Given a collection of posts from the server, save the posts in the
-- global post map. Also convert the posts to Matterhorn's Message type
-- and return them along with the set of all usernames mentioned in the
-- text of the resulting messages.
--
-- This also sets the mFlagged field of each message based on whether
-- its post ID is a flagged post according to crFlaggedPosts at the time
-- of this call.
installMessagesFromPosts :: Maybe TeamId -> Posts -> MH Messages
installMessagesFromPosts mTId postCollection = do
flags <- use (csResources.crFlaggedPosts)
-- Add all posts in this collection to the global post cache
updatePostMap mTId postCollection
mBaseUrl <- case mTId of
Nothing -> return Nothing
Just tId -> Just <$> getServerBaseUrl tId
-- Build the ordered list of posts. Note that postsOrder lists the
-- posts most recent first, but we want most recent last.
let postsInOrder = findPost <$> (Seq.reverse $ postsOrder postCollection)
mkClientPost p = toClientPost mBaseUrl p (postId <$> parent p)
clientPosts = mkClientPost <$> postsInOrder
addNext cp (msgs, us) =
let (msg, mUsernames) = clientPostToMessage cp
in (addMessage (maybeFlag flags msg) msgs, Set.union us mUsernames)
(ms, mentions) = foldr addNext (noMessages, mempty) clientPosts
fetchMentionedUsers mentions
return ms
where
maybeFlag flagSet msg
| Just (MessagePostId pId) <- msg^.mMessageId, pId `Set.member` flagSet
= msg & mFlagged .~ True
| otherwise = msg
parent x = do
parentId <- x^.postRootIdL
HM.lookup parentId (postCollection^.postsPostsL)
findPost pId = case HM.lookup pId (postsPosts postCollection) of
Nothing -> error $ "BUG: could not find post for post ID " <> show pId
Just post -> post
-- Add all posts in this collection to the global post cache
updatePostMap :: Maybe TeamId -> Posts -> MH ()
updatePostMap mTId postCollection = do
-- Build a map from post ID to Matterhorn message, then add the new
-- messages to the global post map. We use the "postsPosts" field for
-- this because that might contain more messages than the "postsOrder"
-- list, since the former can contain other messages in threads that
-- the server sent us, even if those messages are not part of the
-- ordered post listing of "postsOrder."
mBaseUrl <- case mTId of
Nothing -> return Nothing
Just tId -> Just <$> getServerBaseUrl tId
let postMap = HM.fromList
[ ( pId
, fst $ clientPostToMessage (toClientPost mBaseUrl x Nothing)
)
| (pId, x) <- HM.toList (postCollection^.postsPostsL)
]
csPostMap %= HM.union postMap
-- | Add a 'ClientMessage' to the current channel's message list
addClientMessage :: ClientMessage -> MH ()
addClientMessage msg = do
withCurrentTeam $ \tId -> do
withCurrentChannel tId $ \cid _ -> do
uuid <- generateUUID
let addCMsg = ccMessageInterface.miMessages %~
(addMessage $ clientMessageToMessage msg & mMessageId .~ Just (MessageUUID uuid))
csChannels %= modifyChannelById cid addCMsg
invalidateChannelRenderingCache cid
mh $ invalidateCacheEntry $ ChannelSidebar tId
let msgTy = case msg^.cmType of
Error -> LogError
_ -> LogGeneral
mhLog msgTy $ T.pack $ show msg
-- | Add a new 'ClientMessage' representing an error message to
-- the current channel's message list
postInfoMessage :: Text -> MH ()
postInfoMessage info =
addClientMessage =<< newClientMessage Informative (sanitizeUserText' info)
-- | Add a new 'ClientMessage' representing an error message to
-- the current channel's message list
postErrorMessage' :: Text -> MH ()
postErrorMessage' err =
addClientMessage =<< newClientMessage Error (sanitizeUserText' err)
postErrorMessageIO :: Text -> ChatState -> IO ChatState
postErrorMessageIO err st = do
case st^.csCurrentTeamId of
Nothing -> return st
Just tId -> do
case st^.csCurrentChannelId(tId) of
Nothing -> return st
Just cId -> do
msg <- newClientMessage Error err
uuid <- generateUUID_IO
let addEMsg = ccMessageInterface.miMessages %~
(addMessage $ clientMessageToMessage msg & mMessageId .~ Just (MessageUUID uuid))
return $ st & csChannels %~ modifyChannelById cId addEMsg
openFilePath :: FilePath -> MH ()
openFilePath path = openWithOpener (return $ Right path)
openWithOpener :: MH (Either MHError String) -> MH ()
openWithOpener getTarget = do
cfg <- use (csResources.crConfiguration)
case configURLOpenCommand cfg of
Nothing ->
mhError $ ConfigOptionMissing "urlOpenCommand"
Just urlOpenCommand -> do
targetResult <- getTarget
case targetResult of
Left e -> do
mhError e
Right target -> do
-- Is the URL-opening command interactive? If
-- so, pause Matterhorn and run the opener
-- interactively. Otherwise run the opener
-- asynchronously and continue running Matterhorn
-- interactively.
case configURLOpenCommandInteractive cfg of
False -> do
outputChan <- use (csResources.crSubprocessLog)
doAsyncWith Preempt $ do
runLoggedCommand outputChan (T.unpack urlOpenCommand)
[target] Nothing Nothing
return Nothing
True -> do
-- If there isn't a new message cutoff
-- showing in the current channel, set one.
-- This way, while the user is gone using
-- their interactive URL opener, when they
-- return, any messages that arrive in the
-- current channel will be displayed as new.
withCurrentTeam $ \tId -> do
withCurrentChannel tId $ \cId curChan -> do
let msgs = curChan^.ccMessageInterface.miMessages
case findLatestUserMessage isEditable msgs of
Nothing -> return ()
Just m ->
case m^.mOriginalPost of
Nothing -> return ()
Just p ->
case curChan^.ccInfo.cdNewMessageIndicator of
Hide -> csChannel(cId).ccInfo.cdNewMessageIndicator .= (NewPostsAfterServerTime (p^.postCreateAtL))
_ -> return ()
-- No need to add a gap here: the websocket
-- disconnect/reconnect events will automatically
-- handle management of messages delivered while
-- suspended.
mhSuspendAndResume $ \st -> do
result <- runInteractiveCommand (T.unpack urlOpenCommand) [target]
let waitForKeypress = do
putStrLn "Press any key to return to Matterhorn."
void getChar
case result of
Right ExitSuccess -> return ()
Left err -> do
putStrLn $ "URL opener subprocess " <> (show urlOpenCommand) <>
" could not be run: " <> err
waitForKeypress
Right (ExitFailure code) -> do
putStrLn $ "URL opener subprocess " <> (show urlOpenCommand) <>
" exited with non-zero status " <> show code
waitForKeypress
return $ case st^.csCurrentTeamId of
Nothing -> st
Just tId -> pushMode' tId Main st
runInteractiveCommand :: String
-> [String]
-> IO (Either String ExitCode)
runInteractiveCommand cmd args = do
let opener = (proc cmd args) { std_in = Inherit
, std_out = Inherit
, std_err = Inherit
}
result <- try $ createProcess opener
case result of
Left (e::SomeException) -> return $ Left $ show e
Right (_, _, _, ph) -> do
ec <- waitForProcess ph
return $ Right ec
runLoggedCommand :: STM.TChan ProgramOutput
-- ^ The output channel to send the output to
-> String
-- ^ The program name
-> [String]
-- ^ Arguments
-> Maybe String
-- ^ The stdin to send, if any
-> Maybe (MVar ProgramOutput)
-- ^ Where to put the program output when it is ready
-> IO ()
runLoggedCommand outputChan cmd args mInput mOutputVar = void $ forkIO $ do
let stdIn = maybe NoStream (const CreatePipe) mInput
opener = (proc cmd args) { std_in = stdIn
, std_out = CreatePipe
, std_err = CreatePipe
}
result <- try $ createProcess opener
case result of
Left (e::SomeException) -> do
let po = ProgramOutput cmd args "" (show e) (ExitFailure 1)
STM.atomically $ STM.writeTChan outputChan po
maybe (return ()) (flip putMVar po) mOutputVar
Right (stdinResult, Just outh, Just errh, ph) -> do
case stdinResult of
Just inh -> do
case mInput of
Just input -> do
hPutStrLn inh input
hFlush inh
Nothing -> return ()
Nothing -> return ()
ec <- waitForProcess ph
outResult <- hGetContents outh
errResult <- hGetContents errh
let po = ProgramOutput cmd args outResult errResult ec
STM.atomically $ STM.writeTChan outputChan po
maybe (return ()) (flip putMVar po) mOutputVar
Right _ ->
error $ "BUG: createProcess returned unexpected result, report this at " <>
"https://github.com/matterhorn-chat/matterhorn"
-- | Given a file ID and server session, fetch the file into a temporary
-- location and return its path. The caller is responsible for deleting
-- the file.
fetchFile :: FileId -> Session -> IO String
fetchFile fId sess = do
-- The link is for an attachment, so fetch it and then
-- open the local copy.
info <- mmGetMetadataForFile fId sess
cacheDir <- getUserCacheDir xdgName
let dir = cacheDir </> "files" </> T.unpack (idString fId)
filename = T.unpack (fileInfoName info)
fullPath = dir </> filename
fetchFileAtPath fId sess fullPath
return fullPath
-- | Given a file ID and server session, fetch the file and save it to
-- the specified destination path. The destination path must refer to
-- the path to the file itself, not its parent directory. This function
-- will create only the parent directory in the specified path; it will
-- not create all path entries recursively. If the file already exists,
-- this function will overwrite the file.
--
-- The caller is responsible for catching all exceptions.
fetchFileAtPath :: FileId -> Session -> FilePath -> IO ()
fetchFileAtPath fId sess fullPath = do
contents <- mmGetFile fId sess
let dir = takeDirectory fullPath
createDirectoryIfMissing True dir
BS.writeFile fullPath contents
removeEmoteFormatting :: T.Text -> T.Text
removeEmoteFormatting t
| "*" `T.isPrefixOf` t &&
"*" `T.isSuffixOf` t = T.init $ T.drop 1 t
| otherwise = t
addEmoteFormatting :: T.Text -> T.Text
addEmoteFormatting t = "*" <> t <> "*"
fetchMentionedUsers :: Set.Set MentionedUser -> MH ()
fetchMentionedUsers ms
| Set.null ms = return ()
| otherwise = do
let convertMention (UsernameMention u) = UserFetchByUsername u
convertMention (UserIdMention i) = UserFetchById i
scheduleUserFetches $ convertMention <$> Set.toList ms
doPendingUserStatusFetches :: MH ()
doPendingUserStatusFetches = do
mz <- getScheduledUserStatusFetches
case mz of
Nothing -> return ()
Just z -> do
statusChan <- use (csResources.crStatusUpdateChan)
liftIO $ STM.atomically $ STM.writeTChan statusChan z
doPendingUserFetches :: MH ()
doPendingUserFetches = do
fs <- getScheduledUserFetches
let getUsername (UserFetchByUsername u) = Just u
getUsername _ = Nothing
getUserId (UserFetchById i) = Just i
getUserId _ = Nothing
fetchUsers (catMaybes $ getUsername <$> fs) (catMaybes $ getUserId <$> fs)
-- | Given a list of usernames, ensure that we have a user record for
-- each one in the state, either by confirming that a local record
-- exists or by issuing a request for user records.
fetchUsers :: [Text] -> [UserId] -> MH ()
fetchUsers rawUsernames uids = do
st <- use id
session <- getSession
let usernames = trimUserSigil <$> rawUsernames
missingUsernames = filter isMissing usernames
isMissing n = and [ not $ T.null n
, not $ isSpecialMention n
, isNothing $ userByUsername n st
]
missingIds = filter (\i -> isNothing $ userById i st) uids
when (not $ null missingUsernames) $ do
mhLog LogGeneral $ T.pack $ "fetchUsers: getting " <> show missingUsernames
when (not $ null missingIds) $ do
mhLog LogGeneral $ T.pack $ "fetchUsers: getting " <> show missingIds
when ((not $ null missingUsernames) || (not $ null missingIds)) $ do
doAsyncWith Normal $ do
act1 <- case null missingUsernames of
True -> return $ return ()
False -> do
results <- mmGetUsersByUsernames (Seq.fromList missingUsernames) session
return $ do
forM_ results (\u -> addNewUser $ userInfoFromUser u True)
act2 <- case null missingIds of
True -> return $ return ()
False -> do
results <- mmGetUsersByIds (Seq.fromList missingIds) session
return $ do
forM_ results (\u -> addNewUser $ userInfoFromUser u True)
return $ Just $ act1 >> act2
invalidateChannelRenderingCache :: ChannelId -> MH ()
invalidateChannelRenderingCache cId = do
mh $ invalidateCacheEntry $ MessageInterfaceMessages $ MessageInput cId
mh $ invalidateCacheEntry $ MessageInterfaceMessages $ ThreadMessageInput cId
invalidateMessageRenderingCacheByPostId :: PostId -> MH ()
invalidateMessageRenderingCacheByPostId pId = do
mh $ invalidateCacheEntry $ RenderedMessage $ MessagePostId pId
setThreadOrientationByName :: T.Text -> MH ()
setThreadOrientationByName o = do
let o' = T.strip $ T.toLower o
new <- case o' of
"above" -> return $ Just ThreadAbove
"below" -> return $ Just ThreadBelow
"left" -> return $ Just ThreadLeft
"right" -> return $ Just ThreadRight
_ -> do
postErrorMessage' $ T.pack $ "Invalid orientation: " <> show o
return Nothing
case new of
Nothing -> return ()
Just n -> do
csResources.crConfiguration.configThreadOrientationL .= n
postInfoMessage $ "Thread orientation set to " <> o'
mh invalidateCache
toggleMouseMode :: MH ()
toggleMouseMode = do
vty <- mh getVtyHandle
csResources.crConfiguration.configMouseModeL %= not
newMode <- use (csResources.crConfiguration.configMouseModeL)
liftIO $ Vty.setMode (Vty.outputIface vty) Vty.Mouse newMode
postInfoMessage $ if newMode
then "Mouse input is now enabled."
else "Mouse input is now disabled."