matterhorn-40000.1.0: src/State/Common.hs
module State.Common where
import Prelude ()
import Prelude.Compat
import qualified Control.Concurrent.STM as STM
import Control.Exception (try)
import Control.Monad.IO.Class (liftIO)
import qualified Data.Foldable as F
import qualified Data.HashMap.Strict as HM
import qualified Data.Map.Strict as Map
import Data.Maybe (isNothing)
import Data.Monoid ((<>))
import qualified Data.Sequence as Seq
import qualified Data.Set as Set
import qualified Data.Text as T
import Lens.Micro.Platform
import System.Hclip (setClipboard, ClipboardException(..))
import Network.Mattermost
import Network.Mattermost.Lenses
import Network.Mattermost.Exceptions
import Types
import Types.Channels
import Types.Posts
import Types.Messages
-- * Mattermost API
-- | Try to run a computation, posting an informative error
-- message if it fails with a 'MattermostServerError'.
tryMM :: IO a
-- ^ The action to try (usually a MM API call)
-> (a -> IO (MH ()))
-- ^ What to do on success
-> IO (MH ())
tryMM act onSuccess = do
result <- liftIO $ try act
case result of
Left (MattermostServerError msg) -> return $ postErrorMessage msg
Right value -> liftIO $ onSuccess value
-- * Background Computation
-- $background_computation
--
-- The main context for Matterhorn is the EventM context provided by
-- the 'Brick' library. This context is normally waiting for user
-- input (or terminal resizing, etc.) which gets turned into an
-- MHEvent and the 'onEvent' event handler is called to process that
-- event, after which the display is redrawn as necessary and brick
-- awaits the next input.
--
-- However, it is often convenient to communicate with the Mattermost
-- server in the background, so that large numbers of
-- synchronously-blocking events (e.g. on startup) or refreshes can
-- occur whenever needed and without negatively impacting the UI
-- updates or responsiveness. This is handled by a 'forkIO' context
-- that waits on an STM channel for work to do, performs the work, and
-- then sends brick an MHEvent containing the completion or failure
-- information for that work.
--
-- The /doAsyncWith/ family of functions here facilitates that
-- asynchronous functionality. This is typically used in the
-- following fashion:
--
-- > doSomething :: MH ()
-- > doSomething = do
-- > got <- something
-- > doAsyncWith Normal $ do
-- > r <- mmFetchR ....
-- > return $ do
-- > csSomething.here %= processed r
--
-- The second argument is an IO monad operation (because 'forkIO' runs
-- in the IO Monad context), but it returns an MH monad operation.
-- The IO monad has access to the closure of 'doSomething' (e.g. the
-- 'got' value), but it should be aware that the state of the MH monad
-- may have been changed by the time the IO monad runs in the
-- background, so the closure is a snapshot of information at the time
-- the 'doAsyncWith' was called.
--
-- Similarly, the returned MH monad operation is *not* run in the
-- context of the 'forkIO' background, but it is instead passed via an
-- MHEvent back to the main brick thread, where it is executed in an
-- EventM handler's MH monad context. This operation therefore has
-- access to the combined closure of the pre- 'doAsyncWith' code and
-- the closure of the IO operation. It is important that the final MH
-- monad operation should *re-obtain* state information from the MH
-- monad instead of using or setting the state obtained prior to the
-- 'doAsyncWith' call.
-- | Priority setting for asynchronous work items. Preempt means that
-- the queued item will be the next work item begun (i.e. it goes to the
-- front of the queue); normal means it will go last in the queue.
data AsyncPriority = Preempt | Normal
-- | Run a computation in the background, ignoring any results from it.
doAsync :: AsyncPriority -> IO () -> MH ()
doAsync prio act = doAsyncWith prio (act >> return (return ()))
-- | Run a computation in the background, returning a computation to be
-- called on the 'ChatState' value.
doAsyncWith :: AsyncPriority -> IO (MH ()) -> MH ()
doAsyncWith prio act = do
let putChan = case prio of
Preempt -> STM.unGetTChan
Normal -> STM.writeTChan
queue <- use (csResources.crRequestQueue)
liftIO $ STM.atomically $ putChan queue act
doAsyncIO :: AsyncPriority -> ChatState -> IO () -> IO ()
doAsyncIO prio st act =
doAsyncWithIO prio st (act >> return (return ()))
-- | Run a computation in the background, returning a computation to be
-- called on the 'ChatState' value.
doAsyncWithIO :: AsyncPriority -> ChatState -> IO (MH ()) -> IO ()
doAsyncWithIO prio st act = do
let putChan = case prio of
Preempt -> STM.unGetTChan
Normal -> STM.writeTChan
let queue = st^.csResources.crRequestQueue
STM.atomically $ putChan queue act
-- | Performs an asynchronous IO operation. On completion, the final
-- argument a completion function is executed in an MH () context in the
-- main (brick) thread.
doAsyncMM :: AsyncPriority
-- ^ the priority for this async operation
-> (Session -> TeamId -> IO a)
-- ^ the async MM channel-based IO operation
-> (a -> MH ())
-- ^ function to process the results in brick event handling
-- context
-> MH ()
doAsyncMM prio mmOp eventHandler = do
session <- use (csResources.crSession)
myTeamId <- use (csMyTeam.teamIdL)
doAsyncWith prio $ do
r <- mmOp session myTeamId
return $ eventHandler r
-- | Helper type for a function to perform an asynchronous MM operation
-- on a channel and then invoke an MH completion event.
type DoAsyncChannelMM a =
AsyncPriority
-- ^ the priority for this async operation
-> ChannelId
-- ^ The channel
-> (Session -> TeamId -> ChannelId -> IO a)
-- ^ the asynchronous Mattermost channel-based IO operation
-> (ChannelId -> a -> MH ())
-- ^ function to process the results in brick event handling context
-> MH ()
-- | Performs an asynchronous IO operation on a specific channel. On
-- completion, the final argument a completion function is executed in
-- an MH () context in the main (brick) thread.
doAsyncChannelMM :: DoAsyncChannelMM a
doAsyncChannelMM prio cId mmOp eventHandler =
doAsyncMM prio (\s t -> mmOp s t cId) (eventHandler cId)
-- | Prefix function for calling doAsyncChannelMM that will set the
-- channel state to "pending" until the async operation completes. If
-- the channel state is already in the pending state when this
-- function is called, no operations are performed (i.e., this request
-- is treated as a duplicate).
asPending :: DoAsyncChannelMM a -> DoAsyncChannelMM a
asPending asyncOp prio cId mmOp eventHandler = do
withChannel cId $ \chan ->
let origState = chan^.ccInfo.cdCurrentState
(pendState, setDone) = pendingChannelState origState
in if pendState == origState
then return () -- this operation already pending; do not duplicate
else do
csChannel(cId).ccInfo.cdCurrentState .= pendState
asyncOp prio cId mmOp $ \_ r ->
do csChannel(cId).ccInfo.cdCurrentState %= setDone
eventHandler cId r
-- | Use this convenience function if no operation needs to be
-- performed in the MH state after an async operation completes.
endAsyncNOP :: ChannelId -> a -> MH ()
endAsyncNOP _ _ = return ()
-- * Client Messages
-- | Create 'ChannelContents' from a 'Posts' value
fromPosts :: Posts -> MH ChannelContents
fromPosts ps = do
msgs <- messagesFromPosts ps
F.forM_ (ps^.postsPostsL) $
asyncFetchAttachments
return (ChannelContents msgs)
messagesFromPosts :: Posts -> MH Messages
messagesFromPosts p = do
st <- use id
flags <- use (csResources.crFlaggedPosts)
csPostMap %= HM.union (postMap st)
st' <- use id
let msgs = postsToMessages (maybeFlag flags . clientPostToMessage st') (clientPost <$> ps)
postsToMessages f = foldr (addMessage . f) noMessages
return msgs
where
postMap :: ChatState -> HM.HashMap PostId Message
postMap st = HM.fromList
[ ( pId
, clientPostToMessage st (toClientPost x Nothing)
)
| (pId, x) <- HM.toList (p^.postsPostsL)
]
maybeFlag flagSet msg
| Just pId <- msg^.mPostId, pId `Set.member` flagSet
= msg & mFlagged .~ True
| otherwise = msg
-- n.b. postsOrder is most recent first
ps = findPost <$> (Seq.reverse $ postsOrder p)
clientPost :: Post -> ClientPost
clientPost x = toClientPost x (postId <$> parent x)
parent x = do
parentId <- x^.postParentIdL
HM.lookup parentId (p^.postsPostsL)
findPost pId = case HM.lookup pId (postsPosts p) of
Nothing -> error $ "BUG: could not find post for post ID " <> show pId
Just post -> post
asyncFetchAttachments :: Post -> MH ()
asyncFetchAttachments p = do
let cId = (p^.postChannelIdL)
pId = (p^.postIdL)
session <- use (csResources.crSession)
host <- use (csResources.crConn.cdHostnameL)
F.forM_ (p^.postFileIdsL) $ \fId -> doAsyncWith Normal $ do
info <- mmGetFileInfo session fId
let scheme = "https://"
attUrl = scheme <> host <> urlForFile fId
attachment = mkAttachment (fileInfoName info) attUrl fId
addIfMissing a as =
if isNothing $ Seq.elemIndexL a as
then a Seq.<| as
else as
addAttachment m
| m^.mPostId == Just pId =
m & mAttachments %~ (addIfMissing attachment)
| otherwise = m
return $
csChannel(cId).ccContents.cdMessages.traversed %= addAttachment
-- | Add a 'ClientMessage' to the current channel's message list
addClientMessage :: ClientMessage -> MH ()
addClientMessage msg = do
cid <- use csCurrentChannelId
let addCMsg = ccContents.cdMessages %~ (addMessage $ clientMessageToMessage msg)
csChannels %= modifyChannelById cid addCMsg
-- | Add a new 'ClientMessage' representing an error message to
-- the current channel's message list
postInfoMessage :: T.Text -> MH ()
postInfoMessage err = do
msg <- newClientMessage Informative err
doAsyncWith Normal (return $ addClientMessage msg)
-- | Add a new 'ClientMessage' representing an error message to
-- the current channel's message list
postErrorMessage :: T.Text -> MH ()
postErrorMessage err = do
msg <- newClientMessage Error err
doAsyncWith Normal (return $ addClientMessage msg)
postErrorMessageIO :: T.Text -> ChatState -> IO ChatState
postErrorMessageIO err st = do
msg <- newClientMessage Error err
let cId = st ^. csCurrentChannelId
addEMsg = ccContents.cdMessages %~ (addMessage $ clientMessageToMessage msg)
return $ st & csChannels %~ modifyChannelById cId addEMsg
numScrollbackPosts :: Int
numScrollbackPosts = 100
asyncFetchReactionsForPost :: ChannelId -> Post -> MH ()
asyncFetchReactionsForPost cId p
| not (p^.postHasReactionsL) = return ()
| otherwise = doAsyncChannelMM Normal cId
(\s t c -> mmGetReactionsForPost s t c (p^.postIdL))
addReactions
addReactions :: ChannelId -> [Reaction] -> MH ()
addReactions cId rs = csChannel(cId).ccContents.cdMessages %= fmap upd
where upd msg = msg & mReactions %~ insertAll (msg^.mPostId)
insert pId r
| pId == Just (r^.reactionPostIdL) = Map.insertWith (+) (r^.reactionEmojiNameL) 1
| otherwise = id
insertAll pId msg = foldr (insert pId) msg rs
removeReaction :: Reaction -> ChannelId -> MH ()
removeReaction r cId = csChannel(cId).ccContents.cdMessages %= fmap upd
where upd m | m^.mPostId == Just (r^.reactionPostIdL) =
m & mReactions %~ (Map.insertWith (+) (r^.reactionEmojiNameL) (-1))
| otherwise = m
copyToClipboard :: T.Text -> MH ()
copyToClipboard txt = do
result <- liftIO (try (setClipboard (T.unpack txt)))
case result of
Left e -> do
let errMsg = case e of
UnsupportedOS _ ->
"Matterhorn does not support yanking on this operating system."
NoTextualData ->
"Textual data is required to set the clipboard."
MissingCommands cmds ->
"Could not set clipboard due to missing one of the " <>
"required program(s): " <> (T.pack $ show cmds)
postErrorMessage errMsg
Right () ->
return ()
loadAllUsers :: Session -> IO (HM.HashMap UserId User)
loadAllUsers session = go HM.empty 0
where go users n = do
newUsers <- mmGetUsers session (n * 50) 50
if HM.null newUsers
then return users
else go (newUsers <> users) (n+1)