SimpleX-Chat/src/Simplex/Chat/Terminal/Input.hs

397 lines
18 KiB
Haskell
Raw Normal View History

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
2021-04-08 20:20:06 +01:00
module Simplex.Chat.Terminal.Input where
2021-04-08 20:20:06 +01:00
import Control.Applicative (optional, (<|>))
import Control.Concurrent (forkFinally, forkIO, killThread, mkWeakThreadId, threadDelay)
import Control.Monad
import Control.Monad.Except
import Control.Monad.Reader
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Bifunctor (second)
import qualified Data.ByteString.Char8 as B
import Data.Char (isAlpha, isAlphaNum, isAscii)
import Data.Either (fromRight)
import Data.List (dropWhileEnd, foldl', sort)
import Data.Maybe (isJust, isNothing)
import Data.Text (Text)
import qualified Data.Text as T
2022-02-04 12:41:43 +00:00
import Data.Text.Encoding (encodeUtf8)
import Database.SQLite.Simple (Only (..))
import qualified Database.SQLite.Simple as SQL
import Database.SQLite.Simple.QQ (sql)
import GHC.Weak (deRefWeak)
import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Messages
2023-06-17 11:03:22 +01:00
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Styled
import Simplex.Chat.Terminal.Output
import Simplex.Chat.Types (User (..))
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore, withTransaction)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import Simplex.Messaging.Util (catchAll_, safeDecodeUtf8, whenM)
import System.Exit (exitSuccess)
import System.Terminal hiding (insertChars)
import UnliftIO.STM
2021-04-08 20:20:06 +01:00
getKey :: MonadTerminal m => m (Key, Modifiers)
getKey =
flush >> awaitEvent >>= \case
Left Interrupt -> liftIO exitSuccess
Right (KeyEvent key ms) -> pure (key, ms)
_ -> getKey
runInputLoop :: ChatTerminal -> ChatController -> IO ()
runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do
s <- atomically . readTBQueue $ inputQ cc
rh <- readTVarIO $ currentRemoteHost cc
let bs = encodeUtf8 $ T.pack s
cmd = parseChatCommand bs
rh' = if either (const False) allowRemoteCommand cmd then rh else Nothing
unless (isMessage cmd) $ echo s
r <- runReaderT (execChatCommand rh' bs) cc
processResp s cmd rh r
printRespToTerminal ct cc False rh r
startLiveMessage cmd r
where
echo s = printToTerminal ct [plain s]
processResp s cmd rh = \case
CRActiveUser u -> case rh of
Nothing -> setActive ct ""
Just rhId -> updateRemoteUser ct u rhId
CRChatItems u chatName_ _ -> whenCurrUser cc u $ mapM_ (setActive ct . chatActiveTo) chatName_
CRNewChatItem u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo
CRChatItemUpdated u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo
CRChatItemDeleted u (AChatItem _ _ cInfo _) _ _ _ -> whenCurrUser cc u $ setActiveChat ct cInfo
CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c
CRGroupDeletedUser u g -> whenCurrUser cc u $ unsetActiveGroup ct g
CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g
CRChatCmdError _ _ -> when (isMessage cmd) $ echo s
CRChatError _ _ -> when (isMessage cmd) $ echo s
CRCmdOk _ -> case cmd of
Right APIDeleteUser {} -> setActive ct ""
_ -> pure ()
_ -> pure ()
isMessage = \case
Right SendMessage {} -> True
Right SendLiveMessage {} -> True
Right SendFile {} -> True
Right SendMessageQuote {} -> True
Right ForwardMessage {} -> True
Right ForwardLocalMessage {} -> True
Right SendGroupMessageQuote {} -> True
Right ForwardGroupMessage {} -> True
Right SendMessageBroadcast {} -> True
_ -> False
startLiveMessage :: Either a ChatCommand -> ChatResponse -> IO ()
startLiveMessage (Right (SendLiveMessage chatName msg)) (CRNewChatItem _ (AChatItem cType SMDSnd _ ChatItem {meta = CIMeta {itemId}})) = do
whenM (isNothing <$> readTVarIO liveMessageState) $ do
let s = T.unpack msg
int = case cType of SCTGroup -> 5000000; _ -> 3000000 :: Int
liveThreadId <- mkWeakThreadId =<< runLiveMessage int `forkFinally` const (atomically $ writeTVar liveMessageState Nothing)
promptThreadId <- mkWeakThreadId =<< forkIO blinkLivePrompt
atomically $ do
let lm = LiveMessage {chatName, chatItemId = itemId, livePrompt = True, sentMsg = s, typedMsg = s, liveThreadId, promptThreadId}
writeTVar liveMessageState (Just lm)
modifyTVar termState $ \ts -> ts {inputString = s, inputPosition = length s, inputPrompt = liveInputPrompt lm}
where
liveInputPrompt LiveMessage {chatName = n, livePrompt} =
"> " <> chatNameStr n <> " [" <> (if livePrompt then "LIVE" else " ") <> "] "
runLiveMessage :: Int -> IO ()
runLiveMessage int = do
threadDelay int
TerminalState {inputString = s} <- readTVarIO termState
readTVarIO liveMessageState
>>= mapM_ (\lm -> updateLiveMessage s lm >> runLiveMessage int)
blinkLivePrompt = readTVarIO liveMessageState >>= mapM_ updateLivePrompt
where
updateLivePrompt lm = do
atomically $ updatePrompt lm
updateInputView ct
threadDelay 1000000
blinkLivePrompt
updatePrompt lm = do
writeTVar liveMessageState $ Just lm {livePrompt = not $ livePrompt lm}
modifyTVar termState (\ts -> ts {inputPrompt = liveInputPrompt lm})
liveMessageToSend t LiveMessage {sentMsg, typedMsg} =
let s = if t /= typedMsg then truncateToWords t else t
in if s /= sentMsg then Just s else Nothing
updateLiveMessage typedMsg lm = case liveMessageToSend typedMsg lm of
Just sentMsg ->
sendUpdatedLiveMessage cc sentMsg lm True >>= \case
CRChatItemUpdated {} -> setLiveMessage lm {sentMsg, typedMsg}
_ -> do
-- TODO print error
setLiveMessage lm {typedMsg}
_ -> setLiveMessage lm {typedMsg}
setLiveMessage :: LiveMessage -> IO ()
setLiveMessage = atomically . writeTVar liveMessageState . Just
truncateToWords = fst . foldl' acc ("", "")
where
acc (s, w) c
| isAlphaNum c = (s, c : w)
| otherwise = (s <> reverse (c : w), "")
startLiveMessage _ _ = pure ()
sendUpdatedLiveMessage :: ChatController -> String -> LiveMessage -> Bool -> IO ChatResponse
sendUpdatedLiveMessage cc sentMsg LiveMessage {chatName, chatItemId} live = do
let cmd = UpdateLiveMessage chatName chatItemId live $ T.pack sentMsg
either (CRChatCmdError Nothing) id <$> runExceptT (processChatCommand cmd) `runReaderT` cc
runTerminalInput :: ChatTerminal -> ChatController -> IO ()
runTerminalInput ct cc = withChatTerm ct $ do
updateInput ct
receiveFromTTY cc ct
2021-04-08 20:20:06 +01:00
receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m ()
receiveFromTTY cc@ChatController {inputQ, currentUser, currentRemoteHost, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} =
forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct)
where
processKey :: (Key, Modifiers) -> IO ()
processKey key = case key of
(EnterKey, ms)
| ms == mempty -> submit False
| ms == altKey -> submit True
| otherwise -> pure ()
(CharKey c, ms)
| (c == 'l' || c == 'L') && ms == ctrlKey -> submit True
| otherwise -> update key
_ -> update key
submit live = do
ts <- readTVarIO termState
isLive <- isJust <$> readTVarIO liveMessageState
when (inputString ts /= "" || isLive) $
atomically (submitInput live ts) >>= mapM_ (uncurry endLiveMessage)
update key = do
chatPrefix <- readTVarIO activeTo
live <- isJust <$> readTVarIO liveMessageState
ts <- readTVarIO termState
user_ <- readTVarIO currentUser
ts' <- updateTermState user_ chatStore chatPrefix live (width termSize) key ts
atomically $ writeTVar termState $! ts'
endLiveMessage :: String -> LiveMessage -> IO ()
endLiveMessage sentMsg lm = do
kill liveThreadId
kill promptThreadId
atomically $ writeTVar liveMessageState Nothing
r <- sendUpdatedLiveMessage cc sentMsg lm False
rh <- readTVarIO currentRemoteHost -- XXX: should be inherited from live message state
printRespToTerminal ct cc False rh r
where
kill sel = deRefWeak (sel lm) >>= mapM_ killThread
submitInput :: Bool -> TerminalState -> STM (Maybe (String, LiveMessage))
submitInput live ts = do
let s = inputString ts
lm_ <- readTVar liveMessageState
case lm_ of
Just LiveMessage {chatName}
| live -> do
2023-11-26 18:16:37 +00:00
writeTVar termState ts' {previousInput}
writeTBQueue inputQ $ "/live " <> chatNameStr chatName
| otherwise ->
2023-11-26 18:16:37 +00:00
writeTVar termState ts' {inputPrompt = "> ", previousInput}
where
previousInput = chatNameStr chatName <> " " <> s
_
| live -> when (isSend s) $ do
2023-11-26 18:16:37 +00:00
writeTVar termState ts' {previousInput = s}
writeTBQueue inputQ $ "/live " <> s
| otherwise -> do
2023-11-26 18:16:37 +00:00
writeTVar termState ts' {inputPrompt = "> ", previousInput = s}
writeTBQueue inputQ s
pure $ (s,) <$> lm_
where
isSend s = length s > 1 && (head s == '@' || head s == '#')
ts' = ts {inputString = "", inputPosition = 0, autoComplete = mkAutoComplete}
2021-04-08 20:20:06 +01:00
data AutoComplete
= ACContact Text
| ACContactRequest Text
| ACMember Text Text
| ACGroup Text
| ACCommand Text
| ACNone
updateTermState :: Maybe User -> SQLiteStore -> String -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> IO TerminalState
updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p, autoComplete = acp} = case key of
CharKey c
| ms == mempty || ms == shiftKey -> pure $ insertChars $ charsWithContact [c]
| ms == altKey && c == 'b' -> pure $ setPosition prevWordPos
| ms == altKey && c == 'f' -> pure $ setPosition nextWordPos
| otherwise -> pure ts
TabKey -> do
(pfx, vs) <- autoCompleteVariants user_
let sv = acShowVariants acp
sv'
| not (acTabPressed acp) = if null pfx || sv /= SVNone then SVSome else SVNone
| sv == SVNone = SVSome
| sv == SVSome && length vs > 4 = SVAll
| otherwise = SVNone
acp' = acp {acVariants = vs, acInputString = s, acShowVariants = sv', acTabPressed = True}
pure $ (insertChars pfx) {autoComplete = acp'}
BackspaceKey -> pure backDeleteChar
DeleteKey -> pure deleteChar
HomeKey -> pure $ setPosition 0
EndKey -> pure $ setPosition $ length s
ArrowKey d -> pure $ case d of
Leftwards -> setPosition leftPos
Rightwards -> setPosition rightPos
Upwards
| ms == mempty && null s -> let s' = upArrowCmd $ previousInput ts in ts' (s', length s')
| ms == mempty -> let p' = p - tw in if p' > 0 then setPosition p' else ts
| otherwise -> ts
Downwards
| ms == mempty -> let p' = p + tw in if p' <= length s then setPosition p' else ts
| otherwise -> ts
_ -> pure ts
2021-04-08 20:20:06 +01:00
where
autoCompleteVariants Nothing = pure ("", [charsWithContact " "])
autoCompleteVariants (Just User {userId, userContactId}) =
getAutoCompleteChars $ fromRight ACNone $ A.parseOnly autoCompleteP $ encodeUtf8 $ T.pack s
where
autoCompleteP =
A.choice
[ ACContact <$> (contactPfx *> displayName <* A.endOfInput),
ACContactRequest <$> (contactReqPfx *> displayName <* A.endOfInput),
ACMember <$> (groupMemberPfx *> displayName) <* A.space <* optional (A.char '@') <*> displayName <* A.endOfInput,
ACGroup <$> (groupPfx *> displayName <* A.endOfInput),
ACCommand . safeDecodeUtf8 <$> ((<>) <$> ("/" *> alphaP) <*> (B.cons <$> A.space <*> alphaP <|> "")) <* A.endOfInput
]
displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ') <|> "")
refChar c = c > ' ' && c /= '#' && c /= '@'
alphaP = A.takeWhile $ \c -> isAscii c && isAlpha c
contactPfx =
A.choice $
ops '@' [">>", ">", "!", "\\"]
<> cmd '@' ["t", "tail", "?", "search", "set voice", "set delete", "set disappear"]
<> cmd_ '@' ["i ", "info ", "f ", "file ", "clear", "d ", "delete ", "code ", "verify "]
<> ["@"]
contactReqPfx = A.choice $ cmd_ '@' ["ac", "accept", "rc", "reject"]
groupPfx =
A.choice $
ops '#' [">", "!", "\\\\", "\\"]
<> cmd '#' ["t", "tail", "?", "search", "i", "info", "f", "file", "clear", "d", "delete", "code", "verify", "set voice", "set delete", "set disappear", "set direct"]
<> cmd_ '#' ["a", "add", "j", "join", "rm", "remove", "l", "leave", "ms", "members", "mr", "member role"]
<> ["#"]
groupMemberPfx =
A.choice $
ops '#' [">", "\\\\"]
<> cmd '#' ["i", "info", "code", "verify"]
<> cmd_ '#' ["rm", "remove", "l", "leave", "mr", "member role"]
ops c = map (<* (optional A.space <* A.char c))
cmd c = map $ \t -> A.char '/' *> t <* A.space <* A.char c
cmd_ c = map $ \t -> A.char '/' *> t <* A.space <* optional (A.char c)
getAutoCompleteChars = \case
ACContact pfx -> common pfx <$> getContactSfxs pfx
ACContactRequest pfx -> common pfx <$> getNameSfxs "contact_requests" pfx
ACGroup pfx -> common pfx <$> getNameSfxs "groups" pfx
ACMember gName pfx -> common pfx <$> getMemberNameSfxs gName pfx
ACCommand pfx -> pure $ second (map ('/' :)) $ common pfx $ hasPfx pfx commands
ACNone -> pure ("", [charsWithContact ""])
where
getMemberNameSfxs gName pfx =
getNameSfxs_
pfx
(userId, userContactId, gName, pfx <> "%")
[sql|
SELECT m.local_display_name
FROM group_members m
JOIN groups g USING (group_id)
WHERE g.user_id = ?
AND (m.contact_id IS NULL OR m.contact_id != ?)
AND g.local_display_name = ?
AND m.local_display_name LIKE ?
|]
getContactSfxs pfx =
getNameSfxs_
pfx
(userId, pfx <> "%")
"SELECT local_display_name FROM contacts WHERE is_user = 0 AND user_id = ? AND local_display_name LIKE ?"
getNameSfxs table pfx =
getNameSfxs_ pfx (userId, pfx <> "%") $
"SELECT local_display_name FROM " <> table <> " WHERE user_id = ? AND local_display_name LIKE ?"
getNameSfxs_ :: SQL.ToRow p => Text -> p -> SQL.Query -> IO [String]
getNameSfxs_ pfx ps q =
withTransaction st (\db -> hasPfx pfx . map fromOnly <$> DB.query db q ps) `catchAll_` pure []
commands =
["connect", "search", "tail", "info", "clear", "delete", "code", "verify"]
<> ["file", "freceive", "fcancel", "fstatus", "fforward", "image", "image_forward"]
<> ["address", "delete_address", "show_address", "auto_accept", "accept @", "reject @"]
<> ["group", "groups", "members #", "member role #", "add #", "join #", "remove #", "leave #"]
<> ["create link #", "set link role #", "delete link #", "show link #"]
<> ["set voice", "set delete", "set direct #", "set disappear", "mute", "unmute"]
<> ["create user", "profile", "users", "user", "mute user", "unmute user", "hide user", "unhide user", "delete user"]
<> ["chats", "contacts", "help", "markdown", "quit", "db export", "db encrypt", "db decrypt", "db key"]
hasPfx pfx = map T.unpack . sort . filter (pfx `T.isPrefixOf`)
common pfx xs = (commonPrefix $ map (drop $ T.length pfx) xs, xs)
commonPrefix = \case
x : xs -> foldl go x xs
_ -> ""
where
go (c : cs) (c' : cs')
| c == c' = c : go cs cs'
| otherwise = ""
go _ _ = ""
charsWithContact cs
| live = cs
2023-05-24 16:14:41 +04:00
| null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" && cs /= "+" && cs /= "-" =
2023-11-26 18:16:37 +00:00
chatPrefix <> cs
| (s == ">" || s == "\\" || s == "!") && cs == " " =
2023-11-26 18:16:37 +00:00
cs <> chatPrefix
| otherwise = cs
2021-04-08 20:20:06 +01:00
insertChars = ts' . if p >= length s then append else insert
append cs = let s' = s <> cs in (s', length s')
insert cs = let (b, a) = splitAt p s in (b <> cs <> a, p + length cs)
backDeleteChar
| p == 0 || null s = ts
| p >= length s = ts' (init s, length s - 1)
| otherwise = let (b, a) = splitAt p s in ts' (init b <> a, p - 1)
deleteChar
| p >= length s || null s = ts
| p == 0 = ts' (tail s, 0)
| otherwise = let (b, a) = splitAt p s in ts' (b <> tail a, p)
leftPos
| ms == mempty = max 0 (p - 1)
| ms == shiftKey = 0
| ms == ctrlKey = prevWordPos
| ms == altKey = prevWordPos
| otherwise = p
rightPos
| ms == mempty = min (length s) (p + 1)
| ms == shiftKey = length s
| ms == ctrlKey = nextWordPos
| ms == altKey = nextWordPos
| otherwise = p
upArrowCmd inp = case parseChatCommand . encodeUtf8 $ T.pack inp of
Left _ -> inp
Right cmd -> case cmd of
SendMessage {} -> "! " <> inp
SendMessageQuote {contactName, message} -> T.unpack $ "! @" <> contactName <> " " <> message
SendGroupMessageQuote {groupName, message} -> T.unpack $ "! #" <> groupName <> " " <> message
_ -> inp
2021-04-08 20:20:06 +01:00
setPosition p' = ts' (s, p')
prevWordPos
| p == 0 || null s = p
| otherwise =
2023-11-26 18:16:37 +00:00
let before = take p s
beforeWord = dropWhileEnd (/= ' ') $ dropWhileEnd (== ' ') before
in max 0 $ p - length before + length beforeWord
2021-04-08 20:20:06 +01:00
nextWordPos
| p >= length s || null s = p
| otherwise =
2023-11-26 18:16:37 +00:00
let after = drop p s
afterWord = dropWhile (/= ' ') $ dropWhile (== ' ') after
in min (length s) $ p + length after - length afterWord
ts' (s', p') = ts {inputString = s', inputPosition = p', autoComplete = acp {acTabPressed = False}}