send files to groups (#97)

* add sender/recipient info to file types

* send file to group (WIP)

* send file to group, test

* show file status when sending file to group

* notification when cancelled sending to group, remove chunks when file complete or canceleld
This commit is contained in:
Evgeny Poberezkin 2021-09-05 14:08:29 +01:00 committed by GitHub
parent 4bbdcc1d06
commit 28103825fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 351 additions and 160 deletions

View File

@ -51,7 +51,7 @@ import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Parsers (parseAll)
import qualified Simplex.Messaging.Protocol as SMP import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.Util (bshow, raceAny_) import Simplex.Messaging.Util (bshow, raceAny_, tryError)
import System.Exit (exitFailure, exitSuccess) import System.Exit (exitFailure, exitSuccess)
import System.FilePath (combine, splitExtensions, takeFileName) import System.FilePath (combine, splitExtensions, takeFileName)
import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout)
@ -163,6 +163,8 @@ inputSubscriber = do
case cmd of case cmd of
SendMessage c msg -> showSentMessage c msg SendMessage c msg -> showSentMessage c msg
SendGroupMessage g msg -> showSentGroupMessage g msg SendGroupMessage g msg -> showSentGroupMessage g msg
SendFile c f -> showSentFileInvitation c f
SendGroupFile g f -> showSentGroupFileInvitation g f
_ -> printToView [plain s] _ -> printToView [plain s]
user <- readTVarIO =<< asks currentUser user <- readTVarIO =<< asks currentUser
withAgentLock a . withLock l . void . runExceptT $ withAgentLock a . withLock l . void . runExceptT $
@ -264,33 +266,47 @@ processChatCommand user@User {userId, profile} = \case
sendGroupMessage members msgEvent sendGroupMessage members msgEvent
setActive $ ActiveG gName setActive $ ActiveG gName
SendFile cName f -> do SendFile cName f -> do
unlessM (doesFileExist f) . chatError $ CEFileNotFound f (fileSize, chSize) <- checkSndFile f
contact@Contact {contactId} <- withStore $ \st -> getContact st userId cName contact <- withStore $ \st -> getContact st userId cName
(agentConnId, fileQInfo) <- withAgent createConnection (agentConnId, fileQInfo) <- withAgent createConnection
fileSize <- getFileSize f
let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileQInfo} let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileQInfo}
chSize <- asks $ fileChunkSize . config SndFileTransfer {fileId} <- withStore $ \st ->
ft <- withStore $ \st -> createSndFileTransfer st userId contactId f fileInv agentConnId chSize createSndFileTransfer st userId contact f fileInv agentConnId chSize
sendDirectMessage (contactConnId contact) $ XFile fileInv sendDirectMessage (contactConnId contact) $ XFile fileInv
showSentFileInvitation cName ft showSentFileInfo fileId
setActive $ ActiveC cName setActive $ ActiveC cName
SendGroupFile _gName _file -> pure () SendGroupFile gName f -> do
(fileSize, chSize) <- checkSndFile f
group@Group {members, membership} <- withStore $ \st -> getGroup st user gName
unless (memberActive membership) $ chatError CEGroupMemberUserRemoved
let fileName = takeFileName f
ms <- forM (filter memberActive members) $ \m -> do
(connId, fileQInfo) <- withAgent createConnection
pure (m, connId, FileInvitation {fileName, fileSize, fileQInfo})
fileId <- withStore $ \st -> createSndGroupFileTransfer st userId group ms f fileSize chSize
forM_ ms $ \(m, _, fileInv) ->
traverse (`sendDirectMessage` XFile fileInv) $ memberConnId m
showSentFileInfo fileId
setActive $ ActiveG gName
ReceiveFile fileId filePath_ -> do ReceiveFile fileId filePath_ -> do
RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileQInfo}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileQInfo}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId
unless (fileStatus == RFSNew) . chatError $ CEFileAlreadyReceiving fileName unless (fileStatus == RFSNew) . chatError $ CEFileAlreadyReceiving fileName
agentConnId <- withAgent $ \a -> joinConnection a fileQInfo . directMessage $ XFileAcpt fileName tryError (withAgent $ \a -> joinConnection a fileQInfo . directMessage $ XFileAcpt fileName) >>= \case
filePath <- getRcvFilePath fileId filePath_ fileName Right agentConnId -> do
withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath filePath <- getRcvFilePath fileId filePath_ fileName
-- TODO include file sender in the message withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath
showRcvFileAccepted fileId filePath showRcvFileAccepted ft filePath
Left (ChatErrorAgent (SMP SMP.AUTH)) -> showRcvFileSndCancelled ft
Left (ChatErrorAgent (CONN DUPLICATE)) -> showRcvFileSndCancelled ft
Left e -> throwError e
CancelFile fileId -> CancelFile fileId ->
withStore (\st -> getFileTransfer st userId fileId) >>= \case withStore (\st -> getFileTransfer st userId fileId) >>= \case
FTSnd fts -> do FTSnd fts -> do
mapM_ cancelSndFileTransfer fts forM_ fts $ \ft -> cancelSndFileTransfer ft
showSndFileCancelled fileId showSndGroupFileCancelled fts
FTRcv ft -> do FTRcv ft -> do
cancelRcvFileTransfer ft cancelRcvFileTransfer ft
showRcvFileCancelled fileId showRcvFileCancelled ft
FileStatus fileId -> FileStatus fileId ->
withStore (\st -> getFileTransferProgress st userId fileId) >>= showFileTransferStatus withStore (\st -> getFileTransferProgress st userId fileId) >>= showFileTransferStatus
UpdateProfile p -> unless (p == profile) $ do UpdateProfile p -> unless (p == profile) $ do
@ -306,6 +322,10 @@ processChatCommand user@User {userId, profile} = \case
contactMember Contact {contactId} = contactMember Contact {contactId} =
find $ \GroupMember {memberContactId = cId, memberStatus = s} -> find $ \GroupMember {memberContactId = cId, memberStatus = s} ->
cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft
checkSndFile :: FilePath -> m (Integer, Integer)
checkSndFile f = do
unlessM (doesFileExist f) . chatError $ CEFileNotFound f
(,) <$> getFileSize f <*> asks (fileChunkSize . config)
getRcvFilePath :: Int64 -> Maybe FilePath -> String -> m FilePath getRcvFilePath :: Int64 -> Maybe FilePath -> String -> m FilePath
getRcvFilePath fileId filePath fileName = case filePath of getRcvFilePath fileId filePath fileName = case filePath of
Nothing -> do Nothing -> do
@ -449,7 +469,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do
ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage msgBody ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage msgBody
case chatMsgEvent of case chatMsgEvent of
XMsgNew (MsgContent MTText [] body) -> newTextMessage c meta $ find (isSimplexContentType XCText) body XMsgNew (MsgContent MTText [] body) -> newTextMessage c meta $ find (isSimplexContentType XCText) body
XFile fInv -> processFileInvitation ct fInv XFile fInv -> processFileInvitation ct meta fInv
XInfo p -> xInfo ct p XInfo p -> xInfo ct p
XGrpInv gInv -> processGroupInvitation ct gInv XGrpInv gInv -> processGroupInvitation ct gInv
XInfoProbe probe -> xInfoProbe ct probe XInfoProbe probe -> xInfoProbe ct probe
@ -567,6 +587,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do
case chatMsgEvent of case chatMsgEvent of
XMsgNew (MsgContent MTText [] body) -> XMsgNew (MsgContent MTText [] body) ->
newGroupTextMessage gName m meta $ find (isSimplexContentType XCText) body newGroupTextMessage gName m meta $ find (isSimplexContentType XCText) body
XFile fInv -> processGroupFileInvitation gName m meta fInv
XGrpMemNew memInfo -> xGrpMemNew gName m memInfo XGrpMemNew memInfo -> xGrpMemNew gName m memInfo
XGrpMemIntro memInfo -> xGrpMemIntro gName m memInfo XGrpMemIntro memInfo -> xGrpMemIntro gName m memInfo
XGrpMemInv memId introInv -> xGrpMemInv gName m memId introInv XGrpMemInv memId introInv -> xGrpMemInv gName m memId introInv
@ -591,7 +612,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do
_ -> messageError "REQ from file connection must have x.file.acpt" _ -> messageError "REQ from file connection must have x.file.acpt"
CON -> do CON -> do
withStore $ \st -> updateSndFileStatus st ft FSConnected withStore $ \st -> updateSndFileStatus st ft FSConnected
showSndFileStart fileId showSndFileStart ft
sendFileChunk ft sendFileChunk ft
SENT msgId -> do SENT msgId -> do
withStore $ \st -> updateSndFileChunkSent st ft msgId withStore $ \st -> updateSndFileChunkSent st ft msgId
@ -599,7 +620,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do
MERR _ err -> do MERR _ err -> do
cancelSndFileTransfer ft cancelSndFileTransfer ft
case err of case err of
SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ showSndFileRcvCancelled fileId SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ showSndFileRcvCancelled ft
_ -> chatError $ CEFileSend fileId err _ -> chatError $ CEFileSend fileId err
MSG meta _ -> MSG meta _ ->
withAckMessage agentConnId meta $ pure () withAckMessage agentConnId meta $ pure ()
@ -610,12 +631,12 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do
case agentMsg of case agentMsg of
CON -> do CON -> do
withStore $ \st -> updateRcvFileStatus st ft FSConnected withStore $ \st -> updateRcvFileStatus st ft FSConnected
showRcvFileStart fileId showRcvFileStart ft
MSG meta@MsgMeta {recipient = (msgId, _), integrity} msgBody -> withAckMessage agentConnId meta $ do MSG meta@MsgMeta {recipient = (msgId, _), integrity} msgBody -> withAckMessage agentConnId meta $ do
parseFileChunk msgBody >>= \case parseFileChunk msgBody >>= \case
(0, _) -> do (0, _) -> do
cancelRcvFileTransfer ft cancelRcvFileTransfer ft
showRcvFileSndCancelled fileId showRcvFileSndCancelled ft
(chunkNo, chunk) -> do (chunkNo, chunk) -> do
case integrity of case integrity of
MsgOk -> pure () MsgOk -> pure ()
@ -632,8 +653,10 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do
then badRcvFileChunk ft "incorrect chunk size" then badRcvFileChunk ft "incorrect chunk size"
else do else do
appendFileChunk ft chunkNo chunk appendFileChunk ft chunkNo chunk
withStore $ \st -> updateRcvFileStatus st ft FSComplete withStore $ \st -> do
showRcvFileComplete fileId updateRcvFileStatus st ft FSComplete
deleteRcvFileChunks st ft
showRcvFileComplete ft
closeFileHandle fileId rcvFiles closeFileHandle fileId rcvFiles
withAgent (`deleteConnection` agentConnId) withAgent (`deleteConnection` agentConnId)
RcvChunkDuplicate -> pure () RcvChunkDuplicate -> pure ()
@ -681,7 +704,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do
newTextMessage c meta = \case newTextMessage c meta = \case
Just MsgContentBody {contentData = bs} -> do Just MsgContentBody {contentData = bs} -> do
let text = safeDecodeUtf8 bs let text = safeDecodeUtf8 bs
showReceivedMessage c (snd $ broker meta) text (integrity meta) showReceivedMessage c (snd $ broker meta) (msgPlain text) (integrity meta)
showToast (c <> "> ") text showToast (c <> "> ") text
setActive $ ActiveC c setActive $ ActiveC c
_ -> messageError "x.msg.new: no expected message body" _ -> messageError "x.msg.new: no expected message body"
@ -690,19 +713,26 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do
newGroupTextMessage gName GroupMember {localDisplayName = c} meta = \case newGroupTextMessage gName GroupMember {localDisplayName = c} meta = \case
Just MsgContentBody {contentData = bs} -> do Just MsgContentBody {contentData = bs} -> do
let text = safeDecodeUtf8 bs let text = safeDecodeUtf8 bs
showReceivedGroupMessage gName c (snd $ broker meta) text (integrity meta) showReceivedGroupMessage gName c (snd $ broker meta) (msgPlain text) (integrity meta)
showToast ("#" <> gName <> " " <> c <> "> ") text showToast ("#" <> gName <> " " <> c <> "> ") text
setActive $ ActiveG gName setActive $ ActiveG gName
_ -> messageError "x.msg.new: no expected message body" _ -> messageError "x.msg.new: no expected message body"
processFileInvitation :: Contact -> FileInvitation -> m () processFileInvitation :: Contact -> MsgMeta -> FileInvitation -> m ()
processFileInvitation Contact {contactId, localDisplayName = c} fInv = do processFileInvitation contact@Contact {localDisplayName = c} meta fInv = do
-- TODO chunk size has to be sent as part of invitation -- TODO chunk size has to be sent as part of invitation
chSize <- asks $ fileChunkSize . config chSize <- asks $ fileChunkSize . config
ft <- withStore $ \st -> createRcvFileTransfer st userId contactId fInv chSize ft <- withStore $ \st -> createRcvFileTransfer st userId contact fInv chSize
showReceivedFileInvitation c ft showReceivedMessage c (snd $ broker meta) (receivedFileInvitation ft) (integrity meta)
setActive $ ActiveC c setActive $ ActiveC c
processGroupFileInvitation :: GroupName -> GroupMember -> MsgMeta -> FileInvitation -> m ()
processGroupFileInvitation gName m@GroupMember {localDisplayName = c} meta fInv = do
chSize <- asks $ fileChunkSize . config
ft <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize
showReceivedGroupMessage gName c (snd $ broker meta) (receivedFileInvitation ft) (integrity meta)
setActive $ ActiveG gName
processGroupInvitation :: Contact -> GroupInvitation -> m () processGroupInvitation :: Contact -> GroupInvitation -> m ()
processGroupInvitation ct@Contact {localDisplayName} inv@(GroupInvitation (fromMemId, fromRole) (memId, memRole) _ _) = do processGroupInvitation ct@Contact {localDisplayName} inv@(GroupInvitation (fromMemId, fromRole) (memId, memRole) _ _) = do
when (fromRole < GRAdmin || fromRole < memRole) $ chatError (CEGroupContactRole localDisplayName) when (fromRole < GRAdmin || fromRole < memRole) $ chatError (CEGroupContactRole localDisplayName)
@ -852,8 +882,10 @@ sendFileChunk ft@SndFileTransfer {fileId, fileStatus, agentConnId} =
withStore (`createSndFileChunk` ft) >>= \case withStore (`createSndFileChunk` ft) >>= \case
Just chunkNo -> sendFileChunkNo ft chunkNo Just chunkNo -> sendFileChunkNo ft chunkNo
Nothing -> do Nothing -> do
withStore $ \st -> updateSndFileStatus st ft FSComplete withStore $ \st -> do
showSndFileComplete fileId updateSndFileStatus st ft FSComplete
deleteSndFileChunks st ft
showSndFileComplete ft
closeFileHandle fileId sndFiles closeFileHandle fileId sndFiles
withAgent (`deleteConnection` agentConnId) withAgent (`deleteConnection` agentConnId)
@ -915,7 +947,9 @@ isFileActive fileId files = do
cancelRcvFileTransfer :: ChatMonad m => RcvFileTransfer -> m () cancelRcvFileTransfer :: ChatMonad m => RcvFileTransfer -> m ()
cancelRcvFileTransfer ft@RcvFileTransfer {fileId, fileStatus} = do cancelRcvFileTransfer ft@RcvFileTransfer {fileId, fileStatus} = do
closeFileHandle fileId rcvFiles closeFileHandle fileId rcvFiles
withStore $ \st -> updateRcvFileStatus st ft FSCancelled withStore $ \st -> do
updateRcvFileStatus st ft FSCancelled
deleteRcvFileChunks st ft
case fileStatus of case fileStatus of
RFSAccepted RcvFileInfo {agentConnId} -> withAgent (`suspendConnection` agentConnId) RFSAccepted RcvFileInfo {agentConnId} -> withAgent (`suspendConnection` agentConnId)
RFSConnected RcvFileInfo {agentConnId} -> withAgent (`suspendConnection` agentConnId) RFSConnected RcvFileInfo {agentConnId} -> withAgent (`suspendConnection` agentConnId)
@ -924,9 +958,11 @@ cancelRcvFileTransfer ft@RcvFileTransfer {fileId, fileStatus} = do
cancelSndFileTransfer :: ChatMonad m => SndFileTransfer -> m () cancelSndFileTransfer :: ChatMonad m => SndFileTransfer -> m ()
cancelSndFileTransfer ft@SndFileTransfer {agentConnId, fileStatus} = cancelSndFileTransfer ft@SndFileTransfer {agentConnId, fileStatus} =
unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do
withStore $ \st -> updateSndFileStatus st ft FSCancelled withStore $ \st -> do
updateSndFileStatus st ft FSCancelled
deleteSndFileChunks st ft
withAgent $ \a -> do withAgent $ \a -> do
void $ sendMessage a agentConnId "0 " void (sendMessage a agentConnId "0 ") `catchError` \_ -> pure ()
suspendConnection a agentConnId suspendConnection a agentConnId
closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> m () closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> m ()

View File

@ -63,21 +63,26 @@ module Simplex.Chat.Store
matchSentProbe, matchSentProbe,
mergeContactRecords, mergeContactRecords,
createSndFileTransfer, createSndFileTransfer,
createSndGroupFileTransfer,
updateSndFileStatus, updateSndFileStatus,
createSndFileChunk, createSndFileChunk,
updateSndFileChunkMsg, updateSndFileChunkMsg,
updateSndFileChunkSent, updateSndFileChunkSent,
deleteSndFileChunks,
createRcvFileTransfer, createRcvFileTransfer,
createRcvGroupFileTransfer,
getRcvFileTransfer, getRcvFileTransfer,
acceptRcvFileTransfer, acceptRcvFileTransfer,
updateRcvFileStatus, updateRcvFileStatus,
createRcvFileChunk, createRcvFileChunk,
updatedRcvFileChunkStored, updatedRcvFileChunkStored,
deleteRcvFileChunks,
getFileTransfer, getFileTransfer,
getFileTransferProgress, getFileTransferProgress,
) )
where where
import Control.Applicative ((<|>))
import Control.Concurrent.STM (stateTVar) import Control.Concurrent.STM (stateTVar)
import Control.Exception (Exception) import Control.Exception (Exception)
import qualified Control.Exception as E import qualified Control.Exception as E
@ -107,7 +112,7 @@ import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Util (bshow, liftIOEither, (<$$>)) import Simplex.Messaging.Util (bshow, liftIOEither, (<$$>))
import System.FilePath (takeBaseName, takeExtension) import System.FilePath (takeBaseName, takeExtension, takeFileName)
import UnliftIO.STM import UnliftIO.STM
-- | The list of migrations in ascending order by date -- | The list of migrations in ascending order by date
@ -635,7 +640,7 @@ getConnectionChatDirection st User {userId, userContactId} agentConnId =
[sql| [sql|
SELECT SELECT
g.local_display_name, g.local_display_name,
m.group_member_id, m.member_id, m.member_role, m.member_category, m.member_status, m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name
FROM group_members m FROM group_members m
JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id
@ -655,14 +660,19 @@ getConnectionChatDirection st User {userId, userContactId} agentConnId =
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path SELECT s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path, cs.local_display_name, m.local_display_name
FROM snd_files s FROM snd_files s
JOIN files f USING (file_id) JOIN files f USING (file_id)
LEFT JOIN contacts cs USING (contact_id)
LEFT JOIN group_members m USING (group_member_id)
WHERE f.user_id = ? AND f.file_id = ? AND s.connection_id = ? WHERE f.user_id = ? AND f.file_id = ? AND s.connection_id = ?
|] |]
(userId, fileId, connId) (userId, fileId, connId)
sndFileTransfer_ :: Int64 -> Int64 -> [(FileStatus, String, Integer, Integer, FilePath)] -> Either StoreError SndFileTransfer sndFileTransfer_ :: Int64 -> Int64 -> [(FileStatus, String, Integer, Integer, FilePath, Maybe ContactName, Maybe ContactName)] -> Either StoreError SndFileTransfer
sndFileTransfer_ fileId connId [(fileStatus, fileName, fileSize, chunkSize, filePath)] = Right SndFileTransfer {..} sndFileTransfer_ fileId connId [(fileStatus, fileName, fileSize, chunkSize, filePath, contactName_, memberName_)] =
case contactName_ <|> memberName_ of
Just recipientDisplayName -> Right SndFileTransfer {..}
Nothing -> Left $ SESndFileInvalid fileId
sndFileTransfer_ fileId _ _ = Left $ SESndFileNotFound fileId sndFileTransfer_ fileId _ _ = Left $ SESndFileNotFound fileId
updateConnectionStatus :: MonadUnliftIO m => SQLiteStore -> Connection -> ConnStatus -> m () updateConnectionStatus :: MonadUnliftIO m => SQLiteStore -> Connection -> ConnStatus -> m ()
@ -738,7 +748,7 @@ getGroup_ db User {userId, userContactId} localDisplayName = do
db db
[sql| [sql|
SELECT SELECT
m.group_member_id, m.member_id, m.member_role, m.member_category, m.member_status, m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.created_at c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.created_at
@ -790,13 +800,14 @@ getGroupInvitation st user localDisplayName =
findFromContact (IBContact contactId) = find ((== Just contactId) . memberContactId) findFromContact (IBContact contactId) = find ((== Just contactId) . memberContactId)
findFromContact _ = const Nothing findFromContact _ = const Nothing
type GroupMemberRow = (Int64, ByteString, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text) type GroupMemberRow = (Int64, Int64, ByteString, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text)
toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember :: Int64 -> GroupMemberRow -> GroupMember
toGroupMember userContactId (groupMemberId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName) = toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName) =
let memberProfile = Profile {displayName, fullName} let memberProfile = Profile {displayName, fullName}
invitedBy = toInvitedBy userContactId invitedById invitedBy = toInvitedBy userContactId invitedById
in GroupMember {groupMemberId, memberId, memberRole, memberCategory, memberStatus, invitedBy, localDisplayName, memberProfile, memberContactId, activeConn = Nothing} activeConn = Nothing
in GroupMember {..}
createContactGroupMember :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> Int64 -> Contact -> GroupMemberRole -> ConnId -> m GroupMember createContactGroupMember :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> Int64 -> Contact -> GroupMemberRole -> ConnId -> m GroupMember
createContactGroupMember st gVar user groupId contact memberRole agentConnId = createContactGroupMember st gVar user groupId contact memberRole agentConnId =
@ -861,6 +872,7 @@ createNewMember_
memProfileId memProfileId
} = do } = do
let invitedById = fromInvitedBy userContactId invitedBy let invitedById = fromInvitedBy userContactId invitedBy
activeConn = Nothing
DB.execute DB.execute
db db
[sql| [sql|
@ -870,19 +882,7 @@ createNewMember_
|] |]
(groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memProfileId, memberContactId) (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memProfileId, memberContactId)
groupMemberId <- insertedRowId db groupMemberId <- insertedRowId db
pure $ pure GroupMember {..}
GroupMember
{ groupMemberId,
memberId,
memberRole,
memberStatus,
memberCategory,
invitedBy,
memberProfile,
localDisplayName,
memberContactId,
activeConn = Nothing
}
deleteGroupMemberConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> m () deleteGroupMemberConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> m ()
deleteGroupMemberConnection st userId m = deleteGroupMemberConnection st userId m =
@ -982,11 +982,11 @@ getIntroduction_ db reMember toMember = ExceptT $ do
toIntro _ = Left SEIntroNotFound toIntro _ = Left SEIntroNotFound
createIntroReMember :: StoreMonad m => SQLiteStore -> User -> Group -> GroupMember -> MemberInfo -> ConnId -> ConnId -> m GroupMember createIntroReMember :: StoreMonad m => SQLiteStore -> User -> Group -> GroupMember -> MemberInfo -> ConnId -> ConnId -> m GroupMember
createIntroReMember st user@User {userId} group _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) groupAgentConnId directAgentConnId = createIntroReMember st user@User {userId} group@Group {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) groupAgentConnId directAgentConnId =
liftIOEither . withTransaction st $ \db -> runExceptT $ do liftIOEither . withTransaction st $ \db -> runExceptT $ do
let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn
Connection {connId = directConnId} <- liftIO $ createConnection_ db userId directAgentConnId memberContactId cLevel Connection {connId = directConnId} <- liftIO $ createConnection_ db userId directAgentConnId memberContactId cLevel
(localDisplayName, contactId, memProfileId) <- ExceptT $ createContact_ db userId directConnId memberProfile (Just $ groupId group) (localDisplayName, contactId, memProfileId) <- ExceptT $ createContact_ db userId directConnId memberProfile (Just groupId)
liftIO $ do liftIO $ do
let newMember = let newMember =
NewGroupMember NewGroupMember
@ -1059,7 +1059,8 @@ createContactMember_ db User {userId, userContactId} groupId userOrContact (memb
let memberProfile = profile' userOrContact let memberProfile = profile' userOrContact
memberContactId = Just $ contactId' userOrContact memberContactId = Just $ contactId' userOrContact
localDisplayName = localDisplayName' userOrContact localDisplayName = localDisplayName' userOrContact
pure GroupMember {groupMemberId, memberId, memberRole, memberCategory, memberStatus, invitedBy, localDisplayName, memberProfile, memberContactId, activeConn = Nothing} activeConn = Nothing
pure GroupMember {..}
where where
insertMember_ = insertMember_ =
DB.executeNamed DB.executeNamed
@ -1094,7 +1095,7 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} =
[sql| [sql|
SELECT SELECT
g.local_display_name, g.local_display_name,
m.group_member_id, m.member_id, m.member_role, m.member_category, m.member_status, m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.created_at c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.created_at
@ -1148,8 +1149,8 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} =
in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup} in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup}
toContact _ = Nothing toContact _ = Nothing
createSndFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> FilePath -> FileInvitation -> ConnId -> Integer -> m SndFileTransfer createSndFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> ConnId -> Integer -> m SndFileTransfer
createSndFileTransfer st userId contactId filePath FileInvitation {fileName, fileSize} agentConnId chunkSize = createSndFileTransfer st userId Contact {contactId, localDisplayName = recipientDisplayName} filePath FileInvitation {fileName, fileSize} agentConnId chunkSize =
liftIO . withTransaction st $ \db -> do liftIO . withTransaction st $ \db -> do
DB.execute db "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size) VALUES (?, ?, ?, ?, ?, ?)" (userId, contactId, fileName, filePath, fileSize, chunkSize) DB.execute db "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size) VALUES (?, ?, ?, ?, ?, ?)" (userId, contactId, fileName, filePath, fileSize, chunkSize)
fileId <- insertedRowId db fileId <- insertedRowId db
@ -1158,6 +1159,17 @@ createSndFileTransfer st userId contactId filePath FileInvitation {fileName, fil
DB.execute db "INSERT INTO snd_files (file_id, file_status, connection_id) VALUES (?, ?, ?)" (fileId, fileStatus, connId) DB.execute db "INSERT INTO snd_files (file_id, file_status, connection_id) VALUES (?, ?, ?)" (fileId, fileStatus, connId)
pure SndFileTransfer {..} pure SndFileTransfer {..}
createSndGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Group -> [(GroupMember, ConnId, FileInvitation)] -> FilePath -> Integer -> Integer -> m Int64
createSndGroupFileTransfer st userId Group {groupId} ms filePath fileSize chunkSize =
liftIO . withTransaction st $ \db -> do
let fileName = takeFileName filePath
DB.execute db "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size) VALUES (?, ?, ?, ?, ?, ?)" (userId, groupId, fileName, filePath, fileSize, chunkSize)
fileId <- insertedRowId db
forM_ ms $ \(GroupMember {groupMemberId}, agentConnId, _) -> do
Connection {connId} <- createSndFileConnection_ db userId fileId agentConnId
DB.execute db "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id) VALUES (?, ?, ?, ?)" (fileId, FSNew, connId, groupMemberId)
pure fileId
createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection
createSndFileConnection_ db userId fileId agentConnId = do createSndFileConnection_ db userId fileId agentConnId = do
createdAt <- getCurrentTime createdAt <- getCurrentTime
@ -1218,13 +1230,26 @@ updateSndFileChunkSent st SndFileTransfer {fileId, connId} msgId =
|] |]
(fileId, connId, msgId) (fileId, connId, msgId)
createRcvFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> FileInvitation -> Integer -> m RcvFileTransfer deleteSndFileChunks :: MonadUnliftIO m => SQLiteStore -> SndFileTransfer -> m ()
createRcvFileTransfer st userId contactId f@FileInvitation {fileName, fileSize, fileQInfo} chunkSize = deleteSndFileChunks st SndFileTransfer {fileId, connId} =
liftIO . withTransaction st $ \db ->
DB.execute db "DELETE FROM snd_file_chunks WHERE file_id = ? AND connection_id = ?" (fileId, connId)
createRcvFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FileInvitation -> Integer -> m RcvFileTransfer
createRcvFileTransfer st userId Contact {contactId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileQInfo} chunkSize =
liftIO . withTransaction st $ \db -> do liftIO . withTransaction st $ \db -> do
DB.execute db "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size) VALUES (?, ?, ?, ?, ?)" (userId, contactId, fileName, fileSize, chunkSize) DB.execute db "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size) VALUES (?, ?, ?, ?, ?)" (userId, contactId, fileName, fileSize, chunkSize)
fileId <- insertedRowId db fileId <- insertedRowId db
DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info) VALUES (?, ?, ?)" (fileId, FSNew, fileQInfo) DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info) VALUES (?, ?, ?)" (fileId, FSNew, fileQInfo)
pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, chunkSize} pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize}
createRcvGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> FileInvitation -> Integer -> m RcvFileTransfer
createRcvGroupFileTransfer st userId GroupMember {groupId, groupMemberId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileQInfo} chunkSize =
liftIO . withTransaction st $ \db -> do
DB.execute db "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size) VALUES (?, ?, ?, ?, ?)" (userId, groupId, fileName, fileSize, chunkSize)
fileId <- insertedRowId db
DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, group_member_id) VALUES (?, ?, ?, ?)" (fileId, FSNew, fileQInfo, groupMemberId)
pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize}
getRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m RcvFileTransfer getRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m RcvFileTransfer
getRcvFileTransfer st userId fileId = getRcvFileTransfer st userId fileId =
@ -1238,28 +1263,34 @@ getRcvFileTransfer_ db userId fileId =
db db
[sql| [sql|
SELECT r.file_status, r.file_queue_info, f.file_name, SELECT r.file_status, r.file_queue_info, f.file_name,
f.file_size, f.chunk_size, f.file_path, c.connection_id, c.agent_conn_id f.file_size, f.chunk_size, cs.local_display_name, m.local_display_name,
f.file_path, c.connection_id, c.agent_conn_id
FROM rcv_files r FROM rcv_files r
JOIN files f USING (file_id) JOIN files f USING (file_id)
LEFT JOIN connections c ON r.file_id = c.rcv_file_id LEFT JOIN connections c ON r.file_id = c.rcv_file_id
LEFT JOIN contacts cs USING (contact_id)
LEFT JOIN group_members m USING (group_member_id)
WHERE f.user_id = ? AND f.file_id = ? WHERE f.user_id = ? AND f.file_id = ?
|] |]
(userId, fileId) (userId, fileId)
where where
rcvFileTransfer :: rcvFileTransfer ::
[(FileStatus, SMPQueueInfo, String, Integer, Integer, Maybe FilePath, Maybe Int64, Maybe ConnId)] -> [(FileStatus, SMPQueueInfo, String, Integer, Integer, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe ConnId)] ->
Either StoreError RcvFileTransfer Either StoreError RcvFileTransfer
rcvFileTransfer [(fileStatus', fileQInfo, fileName, fileSize, chunkSize, filePath_, connId_, agentConnId_)] = rcvFileTransfer [(fileStatus', fileQInfo, fileName, fileSize, chunkSize, contactName_, memberName_, filePath_, connId_, agentConnId_)] =
let fileInv = FileInvitation {fileName, fileSize, fileQInfo} let fileInv = FileInvitation {fileName, fileSize, fileQInfo}
fileInfo = (filePath_, connId_, agentConnId_) fileInfo = (filePath_, connId_, agentConnId_)
in case fileStatus' of in case contactName_ <|> memberName_ of
FSNew -> Right RcvFileTransfer {fileId, fileInvitation = fileInv, fileStatus = RFSNew, chunkSize} Nothing -> Left $ SERcvFileInvalid fileId
FSAccepted -> ft fileInv RFSAccepted fileInfo Just name ->
FSConnected -> ft fileInv RFSConnected fileInfo case fileStatus' of
FSComplete -> ft fileInv RFSComplete fileInfo FSNew -> Right RcvFileTransfer {fileId, fileInvitation = fileInv, fileStatus = RFSNew, senderDisplayName = name, chunkSize}
FSCancelled -> ft fileInv RFSCancelled fileInfo FSAccepted -> ft name fileInv RFSAccepted fileInfo
FSConnected -> ft name fileInv RFSConnected fileInfo
FSComplete -> ft name fileInv RFSComplete fileInfo
FSCancelled -> ft name fileInv RFSCancelled fileInfo
where where
ft fileInvitation rfs = \case ft senderDisplayName fileInvitation rfs = \case
(Just filePath, Just connId, Just agentConnId) -> (Just filePath, Just connId, Just agentConnId) ->
let fileStatus = rfs RcvFileInfo {filePath, connId, agentConnId} let fileStatus = rfs RcvFileInfo {filePath, connId, agentConnId}
in Right RcvFileTransfer {..} in Right RcvFileTransfer {..}
@ -1315,6 +1346,11 @@ updatedRcvFileChunkStored st RcvFileTransfer {fileId} chunkNo =
|] |]
(fileId, chunkNo) (fileId, chunkNo)
deleteRcvFileChunks :: MonadUnliftIO m => SQLiteStore -> RcvFileTransfer -> m ()
deleteRcvFileChunks st RcvFileTransfer {fileId} =
liftIO . withTransaction st $ \db ->
DB.execute db "DELETE FROM rcv_file_chunks WHERE file_id = ?" (Only fileId)
getFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m FileTransfer getFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m FileTransfer
getFileTransfer st userId fileId = getFileTransfer st userId fileId =
liftIOEither . withTransaction st $ \db -> liftIOEither . withTransaction st $ \db ->
@ -1354,18 +1390,24 @@ getSndFileTransfers_ db userId fileId =
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path, s.connection_id, c.agent_conn_id SELECT s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path, s.connection_id, c.agent_conn_id,
cs.local_display_name, m.local_display_name
FROM snd_files s FROM snd_files s
JOIN files f USING (file_id) JOIN files f USING (file_id)
JOIN connections c USING (connection_id) JOIN connections c USING (connection_id)
LEFT JOIN contacts cs USING (contact_id)
LEFT JOIN group_members m USING (group_member_id)
WHERE f.user_id = ? AND f.file_id = ? WHERE f.user_id = ? AND f.file_id = ?
|] |]
(userId, fileId) (userId, fileId)
where where
sndFileTransfers :: [(FileStatus, String, Integer, Integer, FilePath, Int64, ConnId)] -> Either StoreError [SndFileTransfer] sndFileTransfers :: [(FileStatus, String, Integer, Integer, FilePath, Int64, ConnId, Maybe ContactName, Maybe ContactName)] -> Either StoreError [SndFileTransfer]
sndFileTransfers [] = Left $ SESndFileNotFound fileId sndFileTransfers [] = Left $ SESndFileNotFound fileId
sndFileTransfers fts = Right $ map sndFileTransfer fts sndFileTransfers fts = mapM sndFileTransfer fts
sndFileTransfer (fileStatus, fileName, fileSize, chunkSize, filePath, connId, agentConnId) = SndFileTransfer {..} sndFileTransfer (fileStatus, fileName, fileSize, chunkSize, filePath, connId, agentConnId, contactName_, memberName_) =
case contactName_ <|> memberName_ of
Just recipientDisplayName -> Right SndFileTransfer {..}
Nothing -> Left $ SESndFileInvalid fileId
-- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- | Saves unique local display name based on passed displayName, suffixed with _N if required.
-- This function should be called inside transaction. -- This function should be called inside transaction.
@ -1432,6 +1474,7 @@ data StoreError
| SEGroupAlreadyJoined | SEGroupAlreadyJoined
| SEGroupInvitationNotFound | SEGroupInvitationNotFound
| SESndFileNotFound Int64 | SESndFileNotFound Int64
| SESndFileInvalid Int64
| SERcvFileNotFound Int64 | SERcvFileNotFound Int64
| SEFileNotFound Int64 | SEFileNotFound Int64
| SERcvFileInvalid Int64 | SERcvFileInvalid Int64

View File

@ -123,6 +123,7 @@ data ReceivedGroupInvitation = ReceivedGroupInvitation
data GroupMember = GroupMember data GroupMember = GroupMember
{ groupMemberId :: Int64, { groupMemberId :: Int64,
groupId :: Int64,
memberId :: MemberId, memberId :: MemberId,
memberRole :: GroupMemberRole, memberRole :: GroupMemberRole,
memberCategory :: GroupMemberCategory, memberCategory :: GroupMemberCategory,
@ -305,6 +306,7 @@ data SndFileTransfer = SndFileTransfer
filePath :: String, filePath :: String,
fileSize :: Integer, fileSize :: Integer,
chunkSize :: Integer, chunkSize :: Integer,
recipientDisplayName :: ContactName,
connId :: Int64, connId :: Int64,
agentConnId :: ConnId, agentConnId :: ConnId,
fileStatus :: FileStatus fileStatus :: FileStatus
@ -322,6 +324,7 @@ data RcvFileTransfer = RcvFileTransfer
{ fileId :: Int64, { fileId :: Int64,
fileInvitation :: FileInvitation, fileInvitation :: FileInvitation,
fileStatus :: RcvFileStatus, fileStatus :: RcvFileStatus,
senderDisplayName :: ContactName,
chunkSize :: Integer chunkSize :: Integer
} }
deriving (Eq, Show) deriving (Eq, Show)
@ -343,7 +346,7 @@ data RcvFileInfo = RcvFileInfo
data FileTransfer = FTSnd [SndFileTransfer] | FTRcv RcvFileTransfer data FileTransfer = FTSnd [SndFileTransfer] | FTRcv RcvFileTransfer
data FileStatus = FSNew | FSAccepted | FSConnected | FSComplete | FSCancelled deriving (Eq, Show) data FileStatus = FSNew | FSAccepted | FSConnected | FSComplete | FSCancelled deriving (Eq, Ord, Show)
instance FromField FileStatus where fromField = fromTextField_ fileStatusT instance FromField FileStatus where fromField = fromTextField_ fileStatusT

View File

@ -25,11 +25,14 @@ module Simplex.Chat.View
showSentMessage, showSentMessage,
showSentGroupMessage, showSentGroupMessage,
showSentFileInvitation, showSentFileInvitation,
showSentGroupFileInvitation,
showSentFileInfo,
showSndFileStart, showSndFileStart,
showSndFileComplete, showSndFileComplete,
showSndFileCancelled, showSndFileCancelled,
showSndGroupFileCancelled,
showSndFileRcvCancelled, showSndFileRcvCancelled,
showReceivedFileInvitation, receivedFileInvitation,
showRcvFileAccepted, showRcvFileAccepted,
showRcvFileStart, showRcvFileStart,
showRcvFileComplete, showRcvFileComplete,
@ -58,6 +61,7 @@ module Simplex.Chat.View
showContactUpdated, showContactUpdated,
showMessageError, showMessageError,
safeDecodeUtf8, safeDecodeUtf8,
msgPlain,
) )
where where
@ -65,7 +69,9 @@ import Control.Monad.IO.Unlift
import Control.Monad.Reader import Control.Monad.Reader
import Data.ByteString.Char8 (ByteString) import Data.ByteString.Char8 (ByteString)
import Data.Composition ((.:), (.:.)) import Data.Composition ((.:), (.:.))
import Data.Function (on)
import Data.Int (Int64) import Data.Int (Int64)
import Data.List (groupBy, intersperse, sortOn)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Time.Clock (DiffTime, UTCTime) import Data.Time.Clock (DiffTime, UTCTime)
@ -123,13 +129,13 @@ showGroupRemoved = printToView . groupRemoved
showMemberSubError :: ChatReader m => GroupName -> ContactName -> ChatError -> m () showMemberSubError :: ChatReader m => GroupName -> ContactName -> ChatError -> m ()
showMemberSubError = printToView .:. memberSubError showMemberSubError = printToView .:. memberSubError
showReceivedMessage :: ChatReader m => ContactName -> UTCTime -> Text -> MsgIntegrity -> m () showReceivedMessage :: ChatReader m => ContactName -> UTCTime -> [StyledString] -> MsgIntegrity -> m ()
showReceivedMessage = showReceivedMessage_ . ttyFromContact showReceivedMessage = showReceivedMessage_ . ttyFromContact
showReceivedGroupMessage :: ChatReader m => GroupName -> ContactName -> UTCTime -> Text -> MsgIntegrity -> m () showReceivedGroupMessage :: ChatReader m => GroupName -> ContactName -> UTCTime -> [StyledString] -> MsgIntegrity -> m ()
showReceivedGroupMessage = showReceivedMessage_ .: ttyFromGroup showReceivedGroupMessage = showReceivedMessage_ .: ttyFromGroup
showReceivedMessage_ :: ChatReader m => StyledString -> UTCTime -> Text -> MsgIntegrity -> m () showReceivedMessage_ :: ChatReader m => StyledString -> UTCTime -> [StyledString] -> MsgIntegrity -> m ()
showReceivedMessage_ from utcTime msg mOk = printToView =<< liftIO (receivedMessage from utcTime msg mOk) showReceivedMessage_ from utcTime msg mOk = printToView =<< liftIO (receivedMessage from utcTime msg mOk)
showSentMessage :: ChatReader m => ContactName -> ByteString -> m () showSentMessage :: ChatReader m => ContactName -> ByteString -> m ()
@ -141,37 +147,46 @@ showSentGroupMessage = showSentMessage_ . ttyToGroup
showSentMessage_ :: ChatReader m => StyledString -> ByteString -> m () showSentMessage_ :: ChatReader m => StyledString -> ByteString -> m ()
showSentMessage_ to msg = printToView =<< liftIO (sentMessage to msg) showSentMessage_ to msg = printToView =<< liftIO (sentMessage to msg)
showSentFileInvitation :: ChatReader m => ContactName -> SndFileTransfer -> m () showSentFileInvitation :: ChatReader m => ContactName -> FilePath -> m ()
showSentFileInvitation = printToView .: sentFileInvitation showSentFileInvitation = showSentFileInvitation_ . ttyToContact
showSndFileStart :: ChatReader m => Int64 -> m () showSentGroupFileInvitation :: ChatReader m => GroupName -> FilePath -> m ()
showSentGroupFileInvitation = showSentFileInvitation_ . ttyToGroup
showSentFileInvitation_ :: ChatReader m => StyledString -> FilePath -> m ()
showSentFileInvitation_ to filePath = printToView =<< liftIO (sentFileInvitation to filePath)
showSentFileInfo :: ChatReader m => Int64 -> m ()
showSentFileInfo = printToView . sentFileInfo
showSndFileStart :: ChatReader m => SndFileTransfer -> m ()
showSndFileStart = printToView . sndFileStart showSndFileStart = printToView . sndFileStart
showSndFileComplete :: ChatReader m => Int64 -> m () showSndFileComplete :: ChatReader m => SndFileTransfer -> m ()
showSndFileComplete = printToView . sndFileComplete showSndFileComplete = printToView . sndFileComplete
showSndFileCancelled :: ChatReader m => Int64 -> m () showSndFileCancelled :: ChatReader m => SndFileTransfer -> m ()
showSndFileCancelled = printToView . sndFileCancelled showSndFileCancelled = printToView . sndFileCancelled
showSndFileRcvCancelled :: ChatReader m => Int64 -> m () showSndGroupFileCancelled :: ChatReader m => [SndFileTransfer] -> m ()
showSndGroupFileCancelled = printToView . sndGroupFileCancelled
showSndFileRcvCancelled :: ChatReader m => SndFileTransfer -> m ()
showSndFileRcvCancelled = printToView . sndFileRcvCancelled showSndFileRcvCancelled = printToView . sndFileRcvCancelled
showReceivedFileInvitation :: ChatReader m => ContactName -> RcvFileTransfer -> m () showRcvFileAccepted :: ChatReader m => RcvFileTransfer -> FilePath -> m ()
showReceivedFileInvitation = printToView .: receivedFileInvitation
showRcvFileAccepted :: ChatReader m => Int64 -> FilePath -> m ()
showRcvFileAccepted = printToView .: rcvFileAccepted showRcvFileAccepted = printToView .: rcvFileAccepted
showRcvFileStart :: ChatReader m => Int64 -> m () showRcvFileStart :: ChatReader m => RcvFileTransfer -> m ()
showRcvFileStart = printToView . rcvFileStart showRcvFileStart = printToView . rcvFileStart
showRcvFileComplete :: ChatReader m => Int64 -> m () showRcvFileComplete :: ChatReader m => RcvFileTransfer -> m ()
showRcvFileComplete = printToView . rcvFileComplete showRcvFileComplete = printToView . rcvFileComplete
showRcvFileCancelled :: ChatReader m => Int64 -> m () showRcvFileCancelled :: ChatReader m => RcvFileTransfer -> m ()
showRcvFileCancelled = printToView . rcvFileCancelled showRcvFileCancelled = printToView . rcvFileCancelled
showRcvFileSndCancelled :: ChatReader m => Int64 -> m () showRcvFileSndCancelled :: ChatReader m => RcvFileTransfer -> m ()
showRcvFileSndCancelled = printToView . rcvFileSndCancelled showRcvFileSndCancelled = printToView . rcvFileSndCancelled
showFileTransferStatus :: ChatReader m => (FileTransfer, [Integer]) -> m () showFileTransferStatus :: ChatReader m => (FileTransfer, [Integer]) -> m ()
@ -409,10 +424,10 @@ contactUpdated
messageError :: Text -> Text -> [StyledString] messageError :: Text -> Text -> [StyledString]
messageError prefix err = [plain prefix <> ": " <> plain err] messageError prefix err = [plain prefix <> ": " <> plain err]
receivedMessage :: StyledString -> UTCTime -> Text -> MsgIntegrity -> IO [StyledString] receivedMessage :: StyledString -> UTCTime -> [StyledString] -> MsgIntegrity -> IO [StyledString]
receivedMessage from utcTime msg mOk = do receivedMessage from utcTime msg mOk = do
t <- formatUTCTime <$> getCurrentTimeZone <*> getZonedTime t <- formatUTCTime <$> getCurrentTimeZone <*> getZonedTime
pure $ prependFirst (t <> " " <> from) (msgPlain msg) ++ showIntegrity mOk pure $ prependFirst (t <> " " <> from) msg ++ showIntegrity mOk
where where
formatUTCTime :: TimeZone -> ZonedTime -> StyledString formatUTCTime :: TimeZone -> ZonedTime -> StyledString
formatUTCTime localTz currentTime = formatUTCTime localTz currentTime =
@ -436,9 +451,15 @@ receivedMessage from utcTime msg mOk = do
msgError s = [styled (Colored Red) s] msgError s = [styled (Colored Red) s]
sentMessage :: StyledString -> ByteString -> IO [StyledString] sentMessage :: StyledString -> ByteString -> IO [StyledString]
sentMessage to msg = do sentMessage to msg = sendWithTime_ to . msgPlain $ safeDecodeUtf8 msg
sentFileInvitation :: StyledString -> FilePath -> IO [StyledString]
sentFileInvitation to f = sendWithTime_ ("/f " <> to) [ttyFilePath f]
sendWithTime_ :: StyledString -> [StyledString] -> IO [StyledString]
sendWithTime_ to styledMsg = do
time <- formatTime defaultTimeLocale "%H:%M" <$> getZonedTime time <- formatTime defaultTimeLocale "%H:%M" <$> getZonedTime
pure $ prependFirst (styleTime time <> " " <> to) (msgPlain $ safeDecodeUtf8 msg) pure $ prependFirst (styleTime time <> " " <> to) styledMsg
prependFirst :: StyledString -> [StyledString] -> [StyledString] prependFirst :: StyledString -> [StyledString] -> [StyledString]
prependFirst s [] = [s] prependFirst s [] = [s]
@ -447,27 +468,39 @@ prependFirst s (s' : ss) = (s <> s') : ss
msgPlain :: Text -> [StyledString] msgPlain :: Text -> [StyledString]
msgPlain = map styleMarkdownText . T.lines msgPlain = map styleMarkdownText . T.lines
sentFileInvitation :: ContactName -> SndFileTransfer -> [StyledString] sentFileInfo :: Int64 -> [StyledString]
sentFileInvitation cName SndFileTransfer {fileId, fileName} = sentFileInfo fileId =
[ "offered to send the file " <> plain fileName <> " to " <> ttyContact cName, ["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"]
"use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"
]
sndFileStart :: Int64 -> [StyledString] sndFileStart :: SndFileTransfer -> [StyledString]
sndFileStart fileId = ["started sending the file " <> sShow fileId] sndFileStart = sendingFile_ "started"
sndFileComplete :: Int64 -> [StyledString] sndFileComplete :: SndFileTransfer -> [StyledString]
sndFileComplete fileId = ["completed sending the file " <> sShow fileId] sndFileComplete = sendingFile_ "completed"
sndFileCancelled :: Int64 -> [StyledString] sndFileCancelled :: SndFileTransfer -> [StyledString]
sndFileCancelled fileId = ["cancelled sending the file " <> sShow fileId] sndFileCancelled = sendingFile_ "cancelled"
sndFileRcvCancelled :: Int64 -> [StyledString] sndGroupFileCancelled :: [SndFileTransfer] -> [StyledString]
sndFileRcvCancelled fileId = ["recipient cancelled receiving the file " <> sShow fileId] sndGroupFileCancelled fts =
case filter (\SndFileTransfer {fileStatus = s} -> s /= FSCancelled && s /= FSComplete) fts of
[] -> ["sending file can't be cancelled"]
ts@(ft : _) -> ["cancelled sending " <> sndFile ft <> " to " <> listMembers ts]
receivedFileInvitation :: ContactName -> RcvFileTransfer -> [StyledString] sendingFile_ :: StyledString -> SndFileTransfer -> [StyledString]
receivedFileInvitation c RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} = sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} =
[ ttyContact c <> " wants to send you the file " <> plain fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)", [status <> " sending " <> sndFile ft <> " to " <> ttyContact c]
sndFileRcvCancelled :: SndFileTransfer -> [StyledString]
sndFileRcvCancelled ft@SndFileTransfer {recipientDisplayName = c} =
[ttyContact c <> " cancelled receiving " <> sndFile ft]
sndFile :: SndFileTransfer -> StyledString
sndFile SndFileTransfer {fileId, fileName} = fileTransfer fileId fileName
receivedFileInvitation :: RcvFileTransfer -> [StyledString]
receivedFileInvitation RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} =
[ "sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)",
"use " <> highlight ("/fr " <> show fileId <> " [<dir>/ | <path>]") <> " to receive it" "use " <> highlight ("/fr " <> show fileId <> " [<dir>/ | <path>]") <> " to receive it"
] ]
@ -483,41 +516,71 @@ humanReadableSize size
mB = kB * 1024 mB = kB * 1024
gB = mB * 1024 gB = mB * 1024
rcvFileAccepted :: Int64 -> FilePath -> [StyledString] rcvFileAccepted :: RcvFileTransfer -> FilePath -> [StyledString]
rcvFileAccepted fileId filePath = ["saving file " <> sShow fileId <> " to " <> plain filePath] rcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath =
["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath]
rcvFileStart :: Int64 -> [StyledString] rcvFileStart :: RcvFileTransfer -> [StyledString]
rcvFileStart fileId = ["started receiving the file " <> sShow fileId] rcvFileStart = receivingFile_ "started"
rcvFileComplete :: Int64 -> [StyledString] rcvFileComplete :: RcvFileTransfer -> [StyledString]
rcvFileComplete fileId = ["completed receiving the file " <> sShow fileId] rcvFileComplete = receivingFile_ "completed"
rcvFileCancelled :: Int64 -> [StyledString] rcvFileCancelled :: RcvFileTransfer -> [StyledString]
rcvFileCancelled fileId = ["cancelled receiving the file " <> sShow fileId] rcvFileCancelled = receivingFile_ "cancelled"
rcvFileSndCancelled :: Int64 -> [StyledString] receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString]
rcvFileSndCancelled fileId = ["sender cancelled sending the file " <> sShow fileId] receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} =
[status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c]
rcvFileSndCancelled :: RcvFileTransfer -> [StyledString]
rcvFileSndCancelled ft@RcvFileTransfer {senderDisplayName = c} =
[ttyContact c <> " cancelled sending " <> rcvFile ft]
rcvFile :: RcvFileTransfer -> StyledString
rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = fileTransfer fileId fileName
fileTransfer :: Int64 -> String -> StyledString
fileTransfer fileId fileName = "file " <> sShow fileId <> " (" <> ttyFilePath fileName <> ")"
fileTransferStatus :: (FileTransfer, [Integer]) -> [StyledString] fileTransferStatus :: (FileTransfer, [Integer]) -> [StyledString]
fileTransferStatus (FTSnd [SndFileTransfer {fileStatus, fileSize, chunkSize}], chunksNum) = fileTransferStatus (FTSnd [ft@SndFileTransfer {fileStatus, fileSize, chunkSize}], chunksNum) =
["sent file transfer " <> sndStatus] ["sending " <> sndFile ft <> " " <> sndStatus]
where where
sndStatus = case fileStatus of sndStatus = case fileStatus of
FSNew -> "is not accepted yet" FSNew -> "not accepted yet"
FSAccepted -> "just started" FSAccepted -> "just started"
FSConnected -> "progress: " <> fileProgress chunksNum chunkSize fileSize FSConnected -> "progress " <> fileProgress chunksNum chunkSize fileSize
FSComplete -> "is complete" FSComplete -> "complete"
FSCancelled -> "is cancelled" FSCancelled -> "cancelled"
fileTransferStatus (FTSnd _fts, _chunks) = [] -- TODO group transfer fileTransferStatus (FTSnd [], _) = ["no file transfers (empty group)"]
fileTransferStatus (FTRcv RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileSize}, fileStatus, chunkSize}, chunksNum) = fileTransferStatus (FTSnd fts@(ft : _), chunksNum) =
["received file transfer " <> rcvStatus] case concatMap membersTransferStatus $ groupBy ((==) `on` fs) $ sortOn fs fts of
[membersStatus] -> ["sending " <> sndFile ft <> " " <> membersStatus]
membersStatuses -> ("sending " <> sndFile ft <> ": ") : map (" " <>) membersStatuses
where
fs = fileStatus :: SndFileTransfer -> FileStatus
membersTransferStatus [] = []
membersTransferStatus ts@(SndFileTransfer {fileStatus, fileSize, chunkSize} : _) = [sndStatus <> ": " <> listMembers ts]
where
sndStatus = case fileStatus of
FSNew -> "not accepted"
FSAccepted -> "just started"
FSConnected -> "in progress (" <> sShow (sum chunksNum * chunkSize * 100 `div` (toInteger (length chunksNum) * fileSize)) <> "%)"
FSComplete -> "complete"
FSCancelled -> "cancelled"
fileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileSize}, fileStatus, chunkSize}, chunksNum) =
["receiving " <> rcvFile ft <> " " <> rcvStatus]
where where
rcvStatus = case fileStatus of rcvStatus = case fileStatus of
RFSNew -> "is not accepted yet, use " <> highlight ("/fr " <> show fileId) <> " to receive file" RFSNew -> "not accepted yet, use " <> highlight ("/fr " <> show fileId) <> " to receive file"
RFSAccepted _ -> "just started" RFSAccepted _ -> "just started"
RFSConnected _ -> "progress: " <> fileProgress chunksNum chunkSize fileSize RFSConnected _ -> "progress " <> fileProgress chunksNum chunkSize fileSize
RFSComplete RcvFileInfo {filePath} -> "is complete, path: " <> plain filePath RFSComplete RcvFileInfo {filePath} -> "complete, path: " <> plain filePath
RFSCancelled RcvFileInfo {filePath} -> "is cancelled, received part path: " <> plain filePath RFSCancelled RcvFileInfo {filePath} -> "cancelled, received part path: " <> plain filePath
listMembers :: [SndFileTransfer] -> StyledString
listMembers = mconcat . intersperse ", " . map (ttyContact . recipientDisplayName)
fileProgress :: [Integer] -> Integer -> Integer -> StyledString fileProgress :: [Integer] -> Integer -> Integer -> StyledString
fileProgress chunksNum chunkSize fileSize = fileProgress chunksNum chunkSize fileSize =
@ -606,6 +669,9 @@ ttyFromGroup g c = styled (Colored Yellow) $ "#" <> g <> " " <> c <> "> "
ttyToGroup :: GroupName -> StyledString ttyToGroup :: GroupName -> StyledString
ttyToGroup g = styled (Colored Cyan) $ "#" <> g <> " " ttyToGroup g = styled (Colored Cyan) $ "#" <> g <> " "
ttyFilePath :: FilePath -> StyledString
ttyFilePath = plain
optFullName :: ContactName -> Text -> StyledString optFullName :: ContactName -> Text -> StyledString
optFullName localDisplayName fullName optFullName localDisplayName fullName
| T.null fullName || localDisplayName == fullName = "" | T.null fullName || localDisplayName == fullName = ""

View File

@ -43,7 +43,7 @@ extra-deps:
# - simplexmq-0.3.1@sha256:f247aaff3c16c5d3974a4ab4d5882ab50ac78073110997c0bceb05a74d10a325,6688 # - simplexmq-0.3.1@sha256:f247aaff3c16c5d3974a4ab4d5882ab50ac78073110997c0bceb05a74d10a325,6688
# - ../simplexmq # - ../simplexmq
- github: simplex-chat/simplexmq - github: simplex-chat/simplexmq
commit: 2ac903a2dd37c11a8612b19cd132cf43fe771fe4 commit: e07bedac0e59346f076d71635ce6cd25de67ca7e
# #
# extra-deps: [] # extra-deps: []

View File

@ -44,6 +44,7 @@ chatTests = do
it "send and receive file" testFileTransfer it "send and receive file" testFileTransfer
it "sender cancelled file transfer" testFileSndCancel it "sender cancelled file transfer" testFileSndCancel
it "recipient cancelled file transfer" testFileRcvCancel it "recipient cancelled file transfer" testFileRcvCancel
it "send and receive file to group" testGroupFileTransfer
testAddContact :: IO () testAddContact :: IO ()
testAddContact = testAddContact =
@ -410,10 +411,10 @@ testFileTransfer =
concurrentlyN_ concurrentlyN_
[ do [ do
bob #> "@alice receiving here..." bob #> "@alice receiving here..."
bob <## "completed receiving the file 1", bob <## "completed receiving file 1 (test.jpg) from alice",
do do
alice <# "bob> receiving here..." alice <# "bob> receiving here..."
alice <## "completed sending the file 1" alice <## "completed sending file 1 (test.jpg) to bob"
] ]
src <- B.readFile "./tests/fixtures/test.jpg" src <- B.readFile "./tests/fixtures/test.jpg"
dest <- B.readFile "./tests/tmp/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg"
@ -428,13 +429,13 @@ testFileSndCancel =
alice ##> "/fc 1" alice ##> "/fc 1"
concurrentlyN_ concurrentlyN_
[ do [ do
alice <## "cancelled sending the file 1" alice <## "cancelled sending file 1 (test.jpg) to bob"
alice ##> "/fs 1" alice ##> "/fs 1"
alice <## "sent file transfer is cancelled", alice <## "sending file 1 (test.jpg) cancelled",
do do
bob <## "sender cancelled sending the file 1" bob <## "alice cancelled sending file 1 (test.jpg)"
bob ##> "/fs 1" bob ##> "/fs 1"
bob <## "received file transfer is cancelled, received part path: ./tests/tmp/test.jpg" bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg"
] ]
checkPartialTransfer checkPartialTransfer
@ -445,35 +446,77 @@ testFileRcvCancel =
connectUsers alice bob connectUsers alice bob
startFileTransfer alice bob startFileTransfer alice bob
bob ##> "/fs 1" bob ##> "/fs 1"
getTermLine bob >>= (`shouldStartWith` "received file transfer progress:") getTermLine bob >>= (`shouldStartWith` "receiving file 1 (test.jpg) progress")
waitFileExists "./tests/tmp/test.jpg" waitFileExists "./tests/tmp/test.jpg"
bob ##> "/fc 1" bob ##> "/fc 1"
concurrentlyN_ concurrentlyN_
[ do [ do
bob <## "cancelled receiving the file 1" bob <## "cancelled receiving file 1 (test.jpg) from alice"
bob ##> "/fs 1" bob ##> "/fs 1"
bob <## "received file transfer is cancelled, received part path: ./tests/tmp/test.jpg", bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg",
do do
alice <## "recipient cancelled receiving the file 1" alice <## "bob cancelled receiving file 1 (test.jpg)"
alice ##> "/fs 1" alice ##> "/fs 1"
alice <## "sent file transfer is cancelled" alice <## "sending file 1 (test.jpg) cancelled"
] ]
checkPartialTransfer checkPartialTransfer
where where
waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f
testGroupFileTransfer :: IO ()
testGroupFileTransfer =
testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
createGroup3 "team" alice bob cath
alice #> "/f #team ./tests/fixtures/test.jpg"
alice <## "use /fc 1 to cancel sending"
concurrentlyN_
[ do
bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
bob <## "use /fr 1 [<dir>/ | <path>] to receive it",
do
cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
cath <## "use /fr 1 [<dir>/ | <path>] to receive it"
]
alice ##> "/fs 1"
getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg) not accepted")
bob ##> "/fr 1 ./tests/tmp/"
bob <## "saving file 1 from alice to ./tests/tmp/test.jpg"
concurrentlyN_
[ do
alice <## "started sending file 1 (test.jpg) to bob"
alice <## "completed sending file 1 (test.jpg) to bob"
alice ##> "/fs 1"
alice <## "sending file 1 (test.jpg):"
alice <### [" complete: bob", " not accepted: cath"],
do
bob <## "started receiving file 1 (test.jpg) from alice"
bob <## "completed receiving file 1 (test.jpg) from alice"
]
cath ##> "/fr 1 ./tests/tmp/"
cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg"
concurrentlyN_
[ do
alice <## "started sending file 1 (test.jpg) to cath"
alice <## "completed sending file 1 (test.jpg) to cath"
alice ##> "/fs 1"
getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg) complete"),
do
cath <## "started receiving file 1 (test.jpg) from alice"
cath <## "completed receiving file 1 (test.jpg) from alice"
]
startFileTransfer :: TestCC -> TestCC -> IO () startFileTransfer :: TestCC -> TestCC -> IO ()
startFileTransfer alice bob = do startFileTransfer alice bob = do
alice ##> "/f bob ./tests/fixtures/test.jpg" alice #> "/f @bob ./tests/fixtures/test.jpg"
alice <## "offered to send the file test.jpg to bob"
alice <## "use /fc 1 to cancel sending" alice <## "use /fc 1 to cancel sending"
bob <## "alice wants to send you the file test.jpg (136.5 KiB / 139737 bytes)" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
bob <## "use /fr 1 [<dir>/ | <path>] to receive it" bob <## "use /fr 1 [<dir>/ | <path>] to receive it"
bob ##> "/fr 1 ./tests/tmp" bob ##> "/fr 1 ./tests/tmp"
bob <## "saving file 1 to ./tests/tmp/test.jpg" bob <## "saving file 1 from alice to ./tests/tmp/test.jpg"
concurrently_ concurrently_
(bob <## "started receiving the file 1") (bob <## "started receiving file 1 (test.jpg) from alice")
(alice <## "started sending the file 1") (alice <## "started sending file 1 (test.jpg) to bob")
checkPartialTransfer :: IO () checkPartialTransfer :: IO ()
checkPartialTransfer = do checkPartialTransfer = do