SimpleX-Chat/src/Simplex/Chat/Terminal/Input.hs
Evgeny Poberezkin a5db36469d
core: api to forward messages (#3968)
* wip

* wip

* test

* mute

* tests

* simplify (only bool flag)

* re-encrypt file

* tests

* more tests (wip)

* fix relative paths, refactor

* more tests

* more locks

* fix, tests

* more tests

* rework (revert from bool to ids)

* update schema

* more tests

* add to info

* ForwardedMsg container

* Revert "ForwardedMsg container"

This reverts commit bb57f12151.

* parser

* more tests

* rework api

* more locks

* test

* move

* remove from

* view

* prohibit editing

* item info view

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-04-09 16:02:59 +04:00

396 lines
18 KiB
Haskell

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
module Simplex.Chat.Terminal.Input where
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
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
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
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
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
writeTVar termState ts' {previousInput}
writeTBQueue inputQ $ "/live " <> chatNameStr chatName
| otherwise ->
writeTVar termState ts' {inputPrompt = "> ", previousInput}
where
previousInput = chatNameStr chatName <> " " <> s
_
| live -> when (isSend s) $ do
writeTVar termState ts' {previousInput = s}
writeTBQueue inputQ $ "/live " <> s
| otherwise -> do
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}
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
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
| null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" && cs /= "+" && cs /= "-" =
chatPrefix <> cs
| (s == ">" || s == "\\" || s == "!") && cs == " " =
cs <> chatPrefix
| otherwise = cs
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
setPosition p' = ts' (s, p')
prevWordPos
| p == 0 || null s = p
| otherwise =
let before = take p s
beforeWord = dropWhileEnd (/= ' ') $ dropWhileEnd (== ' ') before
in max 0 $ p - length before + length beforeWord
nextWordPos
| p >= length s || null s = p
| otherwise =
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}}