core: incognito connections (#926)

This commit is contained in:
JRoberts 2022-08-18 11:35:31 +04:00 committed by GitHub
parent 404b7093b7
commit 5e67654249
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 4408 additions and 460 deletions

View file

@ -1,7 +1,6 @@
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-} {-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
@ -40,7 +39,7 @@ mySquaringBot _user cc = do
race_ (forever $ void getLine) . forever $ do race_ (forever $ void getLine) . forever $ do
(_, resp) <- atomically . readTBQueue $ outputQ cc (_, resp) <- atomically . readTBQueue $ outputQ cc
case resp of case resp of
CRContactConnected contact -> do CRContactConnected contact _ -> do
contactConnected contact contactConnected contact
void . sendMsg contact $ "Hello! I am a simple squaring bot - if you send me a number, I will calculate its square" void . sendMsg contact $ "Hello! I am a simple squaring bot - if you send me a number, I will calculate its square"
CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do

View file

@ -0,0 +1,51 @@
# Incognito connections
## Problems
Allow users to connect with incognito profile using the same user account without exposing main profile - either with randomly generated per connection profile / no profile, or custom profile created by user per connection. The latter option involves designing complex UI for creating profile on connection and seems to detract from UX, so we consider first two options.
## Proposal
Add incognito mode determining whether newly created connections are incognito, and a switch on connection pages affecting current connection.
Add API to turn incognito mode on/off - it is saved as part of ChatController state to allow terminal users to set it. We can save preference on mobile and set it on chat start. We can also persist it to database to carry across terminal sessions, but it seems unnecessary.
Parameterize `AddContact`, `Connect` and `ConnectSimplex` API - create connection as incognito based on incognito mode and parameter, parameter is given preference.
### Option 1 - random profile
Add nullable field `custom_user_profile_id` to `connections` table - `incognitoProfile: Maybe Profile` in `Connection` type; when connection is created as incognito on API call, random profile is created and saved to `profiles` table.
Incognito profile is created only with a display name, it can be:
- Some prefix followed by sequence of random character/digits
- Passphrase-like (2-4 random words)
- A name from a dictionary(ies)?
- One of above chosen randomly.
We could generate other parts of profile (picture?) but it's not necessary for MVP.
When user initiates connection as incognito, incognito profile is sent as part of XInfo upon receiving CONF from joining user.
### Option 2 - no profile
Add `incognito` flag to `connections` table - `incognito: Bool` in `Connection` type.
Instead of XInfo both in `Connect` API and on receiving CONF when initiating, send a message that doesn't contain profile, e.g. XOk.
When saving connection profile (`saveConnInfo`) or processing contact request on receiving XOk / other message, contact generates a random profile for the user to distinguish from other connections. He is also able to mark this connection as incognito.
### Considerations
- Don't broadcast user profile updates to contacts with whom the user has established incognito connections.
- Add indication on chat info page that connection was established as incognito, show profile name so the user knows how the contact sees him.
- While profile names generated in option 1 may be distinguishable as incognito depending on generator, technically the fact that connection was established as incognito is not explicitly leaked, which is clear with option 2.
- We could offer same random profile generator on creating profiles, which would blend users with such profile as permanent and users who chose to connect with incognito profile to an observer (i.e. the fact that user chooses to be incognito for this specific connection is no longer leaked, just that he chose to be incognito generally).
- There's a use case for custom incognito profile created by user for a given connection in case user wants to hide the fact of incognito connection (leaked by distinguishable pattern in profile name or lack of profile), but it may better be solved by multi-profile.
- Send incognito profile when accepting contact requests in incognito mode? Parameterize API and give option in dialog?
### Groups
- If host used user's incognito connection when inviting, save same field marking group as incognito in `groups` table?
- Use incognito profile in XGrpMemInfo
- Allow host to create group in incognito profile - all connections with members are created as incognito?

View file

@ -31,6 +31,7 @@ dependencies:
- network >= 3.1.2.7 && < 3.2 - network >= 3.1.2.7 && < 3.2
- optparse-applicative >= 0.15 && < 0.17 - optparse-applicative >= 0.15 && < 0.17
- process == 1.6.* - process == 1.6.*
- random >= 1.1 && < 1.3
- simple-logger == 0.1.* - simple-logger == 0.1.*
- simplexmq >= 3.0 - simplexmq >= 3.0
- socks == 0.6.* - socks == 0.6.*

View file

@ -43,8 +43,10 @@ library
Simplex.Chat.Migrations.M20220702_calls Simplex.Chat.Migrations.M20220702_calls
Simplex.Chat.Migrations.M20220715_groups_chat_item_id Simplex.Chat.Migrations.M20220715_groups_chat_item_id
Simplex.Chat.Migrations.M20220811_chat_items_indices Simplex.Chat.Migrations.M20220811_chat_items_indices
Simplex.Chat.Migrations.M20220812_incognito_profiles
Simplex.Chat.Mobile Simplex.Chat.Mobile
Simplex.Chat.Options Simplex.Chat.Options
Simplex.Chat.ProfileGenerator
Simplex.Chat.Protocol Simplex.Chat.Protocol
Simplex.Chat.Store Simplex.Chat.Store
Simplex.Chat.Styled Simplex.Chat.Styled
@ -80,6 +82,7 @@ library
, network >=3.1.2.7 && <3.2 , network >=3.1.2.7 && <3.2
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplexmq >=3.0 , simplexmq >=3.0
, socks ==0.6.* , socks ==0.6.*
@ -120,6 +123,7 @@ executable simplex-bot
, network >=3.1.2.7 && <3.2 , network >=3.1.2.7 && <3.2
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplex-chat , simplex-chat
, simplexmq >=3.0 , simplexmq >=3.0
@ -161,6 +165,7 @@ executable simplex-bot-advanced
, network >=3.1.2.7 && <3.2 , network >=3.1.2.7 && <3.2
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplex-chat , simplex-chat
, simplexmq >=3.0 , simplexmq >=3.0
@ -203,6 +208,7 @@ executable simplex-chat
, network ==3.1.* , network ==3.1.*
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplex-chat , simplex-chat
, simplexmq >=3.0 , simplexmq >=3.0
@ -254,6 +260,7 @@ test-suite simplex-chat-test
, network ==3.1.* , network ==3.1.*
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplex-chat , simplex-chat
, simplexmq >=3.0 , simplexmq >=3.0

View file

@ -31,13 +31,12 @@ import Data.Either (fromRight)
import Data.Fixed (div') import Data.Fixed (div')
import Data.Functor (($>)) import Data.Functor (($>))
import Data.Int (Int64) import Data.Int (Int64)
import Data.List (find, isSuffixOf, sortBy, sortOn) import Data.List (find, isSuffixOf, sortOn)
import Data.List.NonEmpty (NonEmpty, nonEmpty) import Data.List.NonEmpty (NonEmpty, nonEmpty)
import qualified Data.List.NonEmpty as L import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map) import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe) import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)
import Data.Ord (comparing)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds)
@ -51,6 +50,7 @@ import Simplex.Chat.Controller
import Simplex.Chat.Markdown import Simplex.Chat.Markdown
import Simplex.Chat.Messages import Simplex.Chat.Messages
import Simplex.Chat.Options import Simplex.Chat.Options
import Simplex.Chat.ProfileGenerator (generateRandomProfile)
import Simplex.Chat.Protocol import Simplex.Chat.Protocol
import Simplex.Chat.Store import Simplex.Chat.Store
import Simplex.Chat.Types import Simplex.Chat.Types
@ -143,8 +143,9 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, de
rcvFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty
currentCalls <- atomically TM.empty currentCalls <- atomically TM.empty
filesFolder <- newTVarIO Nothing filesFolder <- newTVarIO Nothing
incognitoMode <- newTVarIO False
chatStoreChanged <- newTVarIO False chatStoreChanged <- newTVarIO False
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder} pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder}
where where
resolveServers :: InitialAgentServers -> IO InitialAgentServers resolveServers :: InitialAgentServers -> IO InitialAgentServers
resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of
@ -233,6 +234,10 @@ processChatCommand = \case
ff <- asks filesFolder ff <- asks filesFolder
atomically . writeTVar ff $ Just filesFolder' atomically . writeTVar ff $ Just filesFolder'
pure CRCmdOk pure CRCmdOk
SetIncognito onOff -> do
incognito <- asks incognitoMode
atomically . writeTVar incognito $ onOff
pure CRCmdOk
APIExportArchive cfg -> checkChatStopped $ exportArchive cfg $> CRCmdOk APIExportArchive cfg -> checkChatStopped $ exportArchive cfg $> CRCmdOk
APIImportArchive cfg -> checkChatStopped $ importArchive cfg >> setStoreChanged $> CRCmdOk APIImportArchive cfg -> checkChatStopped $ importArchive cfg >> setStoreChanged $> CRCmdOk
APIDeleteStorage -> checkChatStopped $ deleteStorage >> setStoreChanged $> CRCmdOk APIDeleteStorage -> checkChatStopped $ deleteStorage >> setStoreChanged $> CRCmdOk
@ -254,16 +259,14 @@ processChatCommand = \case
pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci
where where
setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd)) setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd))
setupSndFileTransfer ct = case file_ of setupSndFileTransfer ct = forM file_ $ \file -> do
Nothing -> pure Nothing (fileSize, chSize) <- checkSndFile file
Just file -> do (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation)
(fileSize, chSize) <- checkSndFile file let fileName = takeFileName file
(agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq}
let fileName = takeFileName file fileId <- withStore' $ \db -> createSndFileTransfer db userId ct file fileInvitation agentConnId chSize
fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq} let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored}
fileId <- withStore' $ \db -> createSndFileTransfer db userId ct file fileInvitation agentConnId chSize pure (fileInvitation, ciFile)
let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored}
pure $ Just (fileInvitation, ciFile)
prepareMsg :: Maybe FileInvitation -> m (MsgContainer, Maybe (CIQuote 'CTDirect)) prepareMsg :: Maybe FileInvitation -> m (MsgContainer, Maybe (CIQuote 'CTDirect))
prepareMsg fileInvitation_ = case quotedItemId_ of prepareMsg fileInvitation_ = case quotedItemId_ of
Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing)
@ -291,15 +294,13 @@ processChatCommand = \case
pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci
where where
setupSndFileTransfer :: GroupInfo -> m (Maybe (FileInvitation, CIFile 'MDSnd)) setupSndFileTransfer :: GroupInfo -> m (Maybe (FileInvitation, CIFile 'MDSnd))
setupSndFileTransfer gInfo = case file_ of setupSndFileTransfer gInfo = forM file_ $ \file -> do
Nothing -> pure Nothing (fileSize, chSize) <- checkSndFile file
Just file -> do let fileName = takeFileName file
(fileSize, chSize) <- checkSndFile file fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing}
let fileName = takeFileName file fileId <- withStore' $ \db -> createSndGroupFileTransfer db userId gInfo file fileInvitation chSize
fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored}
fileId <- withStore' $ \db -> createSndGroupFileTransfer db userId gInfo file fileInvitation chSize pure (fileInvitation, ciFile)
let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored}
pure $ Just (fileInvitation, ciFile)
prepareMsg :: Maybe FileInvitation -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup)) prepareMsg :: Maybe FileInvitation -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup))
prepareMsg fileInvitation_ membership = case quotedItemId_ of prepareMsg fileInvitation_ membership = case quotedItemId_ of
Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing)
@ -597,11 +598,17 @@ processChatCommand = \case
APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk
APIGetNetworkConfig -> CRNetworkConfig <$> withUser' (\_ -> withAgent getNetworkConfig) APIGetNetworkConfig -> CRNetworkConfig <$> withUser' (\_ -> withAgent getNetworkConfig)
APIContactInfo contactId -> withUser $ \User {userId} -> do APIContactInfo contactId -> withUser $ \User {userId} -> do
ct <- withStore $ \db -> getContact db userId contactId -- [incognito] print user's incognito profile for this contact
CRContactInfo ct <$> withAgent (`getConnectionServers` contactConnId ct) ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db userId contactId
APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
(g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId connectionStats <- withAgent (`getConnectionServers` contactConnId ct)
CRGroupMemberInfo g m <$> mapM (withAgent . flip getConnectionServers) (memberConnId m) pure $ CRContactInfo ct connectionStats incognitoProfile
APIGroupMemberInfo gId gMemberId -> withUser $ \user@User {userId} -> do
-- [incognito] print group member main profile
(g, m@GroupMember {memberContactProfileId}) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
mainProfile <- if memberIncognito m then Just <$> withStore (\db -> getProfileById db userId memberContactProfileId) else pure Nothing
connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m)
pure $ CRGroupMemberInfo g m connectionStats mainProfile
ContactInfo cName -> withUser $ \User {userId} -> do ContactInfo cName -> withUser $ \User {userId} -> do
contactId <- withStore $ \db -> getContactIdByName db userId cName contactId <- withStore $ \db -> getContactIdByName db userId cName
processChatCommand $ APIContactInfo contactId processChatCommand $ APIContactInfo contactId
@ -611,20 +618,29 @@ processChatCommand = \case
ChatHelp section -> pure $ CRChatHelp section ChatHelp section -> pure $ CRChatHelp section
Welcome -> withUser $ pure . CRWelcome Welcome -> withUser $ pure . CRWelcome
AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do
-- [incognito] generate profile for connection
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
(connId, cReq) <- withAgent (`createConnection` SCMInvitation) (connId, cReq) <- withAgent (`createConnection` SCMInvitation)
conn <- withStore' $ \db -> createDirectConnection db userId connId ConnNew conn <- withStore' $ \db -> createDirectConnection db userId connId ConnNew incognitoProfile
toView $ CRNewContactConnection conn toView $ CRNewContactConnection conn
pure $ CRInvitation cReq pure $ CRInvitation cReq
Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do
connId <- withAgent $ \a -> joinConnection a cReq . directMessage $ XInfo profile -- [incognito] generate profile to send
conn <- withStore' $ \db -> createDirectConnection db userId connId ConnJoined incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
let profileToSend = fromMaybe (fromLocalProfile profile) incognitoProfile
connId <- withAgent $ \a -> joinConnection a cReq . directMessage $ XInfo profileToSend
conn <- withStore' $ \db -> createDirectConnection db userId connId ConnJoined incognitoProfile
toView $ CRNewContactConnection conn toView $ CRNewContactConnection conn
pure CRSentConfirmation pure CRSentConfirmation
Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} -> Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} ->
connectViaContact userId cReq profile -- [incognito] generate profile to send
connectViaContact userId cReq $ fromLocalProfile profile
Connect Nothing -> throwChatError CEInvalidConnReq Connect Nothing -> throwChatError CEInvalidConnReq
ConnectSimplex -> withUser $ \User {userId, profile} -> ConnectSimplex -> withUser $ \User {userId, profile} ->
connectViaContact userId adminContactReq profile -- [incognito] generate profile to send
connectViaContact userId adminContactReq $ fromLocalProfile profile
DeleteContact cName -> withUser $ \User {userId} -> do DeleteContact cName -> withUser $ \User {userId} -> do
contactId <- withStore $ \db -> getContactIdByName db userId cName contactId <- withStore $ \db -> getContactIdByName db userId cName
processChatCommand $ APIDeleteChat (ChatRef CTDirect contactId) processChatCommand $ APIDeleteChat (ChatRef CTDirect contactId)
@ -686,29 +702,37 @@ processChatCommand = \case
processChatCommand $ APIUpdateChatItem chatRef editedItemId mc processChatCommand $ APIUpdateChatItem chatRef editedItemId mc
NewGroup gProfile -> withUser $ \user -> do NewGroup gProfile -> withUser $ \user -> do
gVar <- asks idsDrg gVar <- asks idsDrg
CRGroupCreated <$> withStore (\db -> createNewGroup db gVar user gProfile) -- [incognito] create membership with incognito profile
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile incognitoProfile)
pure $ CRGroupCreated groupInfo incognitoProfile
APIAddMember groupId contactId memRole -> withUser $ \user@User {userId} -> withChatLock $ do APIAddMember groupId contactId memRole -> withUser $ \user@User {userId} -> withChatLock $ do
-- TODO for large groups: no need to load all members to determine if contact is a member -- TODO for large groups: no need to load all members to determine if contact is a member
(group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db userId contactId (group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db userId contactId
-- [incognito] forbid to invite contact to whom user is connected as incognito if user's membership is not incognito
let Group gInfo@GroupInfo {localDisplayName, groupProfile, membership} members = group let Group gInfo@GroupInfo {localDisplayName, groupProfile, membership} members = group
GroupMember {memberRole = userRole, memberId = userMemberId} = membership GroupMember {memberRole = userRole, memberId = userMemberId} = membership
Contact {localDisplayName = cName} = contact Contact {localDisplayName = cName} = contact
when (contactConnIncognito contact && not (memberIncognito membership)) $ throwChatError CEGroupNotIncognitoCantInvite
when (userRole < GRAdmin || userRole < memRole) $ throwChatError CEGroupUserRole when (userRole < GRAdmin || userRole < memRole) $ throwChatError CEGroupUserRole
when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined gInfo) when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined gInfo)
unless (memberActive membership) $ throwChatError CEGroupMemberNotActive unless (memberActive membership) $ throwChatError CEGroupMemberNotActive
let sendInvitation member@GroupMember {groupMemberId, memberId} cReq = do let sendInvitation member@GroupMember {groupMemberId, memberId} cReq = do
let groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile -- [incognito] if membership is incognito, send its incognito profile in GroupInvitation
let incognitoProfile = if memberIncognito membership then Just (fromLocalProfile $ memberProfile membership) else Nothing
groupInv = GroupInvitation (MemberIdRole userMemberId userRole) incognitoProfile (MemberIdRole memberId memRole) cReq groupProfile
msg <- sendDirectContactMessage contact $ XGrpInv groupInv msg <- sendDirectContactMessage contact $ XGrpInv groupInv
let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending, invitedIncognito = Just $ memberIncognito membership}) memRole
ci <- saveSndChatItem user (CDDirectSnd contact) msg content Nothing Nothing ci <- saveSndChatItem user (CDDirectSnd contact) msg content Nothing Nothing
toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci
setActive $ ActiveG localDisplayName setActive $ ActiveG localDisplayName
pure $ CRSentGroupInvitation gInfo contact member pure $ CRSentGroupInvitation gInfo contact member incognitoProfile
case contactMember contact members of case contactMember contact members of
Nothing -> do Nothing -> do
gVar <- asks idsDrg gVar <- asks idsDrg
(agentConnId, cReq) <- withAgent (`createConnection` SCMInvitation) (agentConnId, cReq) <- withAgent (`createConnection` SCMInvitation)
member <- withStore $ \db -> createContactMember db gVar user groupId contact memRole agentConnId cReq member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq
sendInvitation member cReq sendInvitation member cReq
Just member@GroupMember {groupMemberId, memberStatus} Just member@GroupMember {groupMemberId, memberStatus}
| memberStatus == GSMemInvited -> | memberStatus == GSMemInvited ->
@ -719,13 +743,30 @@ processChatCommand = \case
APIJoinGroup groupId -> withUser $ \user@User {userId} -> do APIJoinGroup groupId -> withUser $ \user@User {userId} -> do
ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId
withChatLock . procCmd $ do withChatLock . procCmd $ do
agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership :: GroupMember) -- [incognito] if (incognito mode is enabled OR direct connection with host is incognito) [AND membership is not incognito] update membership to use incognito profile
incognito <- readTVarIO =<< asks incognitoMode
hostConnIncognito <- case (incognito, memberContactId fromMember) of
(True, _) -> pure True -- we don't need to check whether connection with host is incognito if incognito mode is on
(_, Just hostContactId) -> do
hostContact <- withStore $ \db -> getContact db userId hostContactId
pure $ contactConnIncognito hostContact
_ -> pure False
g'@GroupInfo {membership = membership'} <-
if (incognito || hostConnIncognito) && not (memberIncognito membership)
then do
incognitoProfile <- liftIO generateRandomProfile
membership' <- withStore $ \db -> createMemberIncognitoProfile db userId membership (Just incognitoProfile)
pure g {membership = membership'}
else pure g
-- [incognito] if membership is incognito, send its incognito profile in XGrpAcpt
let incognitoProfile = if memberIncognito membership' then Just (fromLocalProfile $ memberProfile membership') else Nothing
agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage $ XGrpAcpt (memberId (membership' :: GroupMember)) incognitoProfile
withStore' $ \db -> do withStore' $ \db -> do
createMemberConnection db userId fromMember agentConnId createMemberConnection db userId fromMember agentConnId
updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId fromMember GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted updateGroupMemberStatus db userId membership' GSMemAccepted
updateCIGroupInvitationStatus user updateCIGroupInvitationStatus user
pure $ CRUserAcceptedGroupSent g {membership = membership {memberStatus = GSMemAccepted}} pure $ CRUserAcceptedGroupSent g' {membership = membership' {memberStatus = GSMemAccepted}}
where where
updateCIGroupInvitationStatus user@User {userId} = do updateCIGroupInvitationStatus user@User {userId} = do
AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db user groupId AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db user groupId
@ -750,7 +791,7 @@ processChatCommand = \case
withStore' $ \db -> deleteGroupMember db user m withStore' $ \db -> deleteGroupMember db user m
_ -> do _ -> do
msg <- sendGroupMessage gInfo members $ XGrpMemDel mId msg <- sendGroupMessage gInfo members $ XGrpMemDel mId
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId memberProfile) Nothing Nothing ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) Nothing Nothing
toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci
deleteMemberConnection m deleteMemberConnection m
withStore' $ \db -> updateGroupMemberStatus db userId m GSMemRemoved withStore' $ \db -> updateGroupMemberStatus db userId m GSMemRemoved
@ -861,12 +902,12 @@ processChatCommand = \case
pure $ CRRcvFileCancelled ftr pure $ CRRcvFileCancelled ftr
FileStatus fileId -> FileStatus fileId ->
CRFileTransferStatus <$> withUser (\user -> withStore $ \db -> getFileTransferProgress db user fileId) CRFileTransferStatus <$> withUser (\user -> withStore $ \db -> getFileTransferProgress db user fileId)
ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile (fromLocalProfile profile)
UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do
let p = (profile :: Profile) {displayName = displayName, fullName = fullName} let p = (fromLocalProfile profile :: Profile) {displayName = displayName, fullName = fullName}
updateProfile user p updateProfile user p
UpdateProfileImage image -> withUser $ \user@User {profile} -> do UpdateProfileImage image -> withUser $ \user@User {profile} -> do
let p = (profile :: Profile) {image} let p = (fromLocalProfile profile :: Profile) {image}
updateProfile user p updateProfile user p
QuitChat -> liftIO exitSuccess QuitChat -> liftIO exitSuccess
ShowVersion -> pure $ CRVersionInfo versionNumber ShowVersion -> pure $ CRVersionInfo versionNumber
@ -910,10 +951,18 @@ processChatCommand = \case
(_, xContactId_) -> procCmd $ do (_, xContactId_) -> procCmd $ do
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
xContactId <- maybe randomXContactId pure xContactId_ xContactId <- maybe randomXContactId pure xContactId_
connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId) -- [incognito] generate profile to send
conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId -- if user makes a contact request using main profile, then turns on incognito mode and repeats the request,
-- an incognito profile will be sent even though the address holder will have user's main profile received as well;
-- we ignore this edge case as we already allow profile updates on repeat contact requests;
-- alternatively we can re-send the main profile even if incognito mode is enabled
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
let profileToSend = fromMaybe profile incognitoProfile
connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profileToSend $ Just xContactId)
conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile
toView $ CRNewContactConnection conn toView $ CRNewContactConnection conn
pure CRSentInvitation pure $ CRSentInvitation incognitoProfile
contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember :: Contact -> [GroupMember] -> Maybe GroupMember
contactMember Contact {contactId} = contactMember Contact {contactId} =
find $ \GroupMember {memberContactId = cId, memberStatus = s} -> find $ \GroupMember {memberContactId = cId, memberStatus = s} ->
@ -924,17 +973,20 @@ processChatCommand = \case
unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f
(,) <$> getFileSize fsFilePath <*> asks (fileChunkSize . config) (,) <$> getFileSize fsFilePath <*> asks (fileChunkSize . config)
updateProfile :: User -> Profile -> m ChatResponse updateProfile :: User -> Profile -> m ChatResponse
updateProfile user@User {profile = p} p'@Profile {displayName} updateProfile user@User {profile = p@LocalProfile {profileId}} p'@Profile {displayName}
| p' == p = pure CRUserProfileNoChange | p' == fromLocalProfile p = pure CRUserProfileNoChange
| otherwise = do | otherwise = do
withStore $ \db -> updateUserProfile db user p' withStore $ \db -> updateUserProfile db user p'
let user' = (user :: User) {localDisplayName = displayName, profile = p'} let user' = (user :: User) {localDisplayName = displayName, profile = toLocalProfile profileId p'}
asks currentUser >>= atomically . (`writeTVar` Just user') asks currentUser >>= atomically . (`writeTVar` Just user')
contacts <- filter isReady <$> withStore' (`getUserContacts` user) -- [incognito] filter out contacts with whom user has incognito connections
contacts <-
filter (\ct -> isReady ct && not (contactConnIncognito ct))
<$> withStore' (`getUserContacts` user)
withChatLock . procCmd $ do withChatLock . procCmd $ do
forM_ contacts $ \ct -> forM_ contacts $ \ct ->
void (sendDirectContactMessage ct $ XInfo p') `catchError` (toView . CRChatError) void (sendDirectContactMessage ct $ XInfo p') `catchError` (toView . CRChatError)
pure $ CRUserProfileUpdated p p' pure $ CRUserProfileUpdated (fromLocalProfile p) p'
isReady :: Contact -> Bool isReady :: Contact -> Bool
isReady ct = isReady ct =
let s = connStatus $ activeConn (ct :: Contact) let s = connStatus $ activeConn (ct :: Contact)
@ -1117,8 +1169,12 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F
acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> m Contact acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> m Contact
acceptContactRequest User {userId, profile} UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} = do acceptContactRequest User {userId, profile} UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} = do
connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile -- [incognito] generate profile to send, create connection with incognito profile
withStore' $ \db -> createAcceptedContact db userId connId cName profileId p userContactLinkId xContactId incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
let profileToSend = fromMaybe (fromLocalProfile profile) incognitoProfile
connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profileToSend
withStore' $ \db -> createAcceptedContact db userId connId cName profileId p userContactLinkId xContactId incognitoProfile
agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m () agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
agentSubscriber = do agentSubscriber = do
@ -1192,7 +1248,7 @@ subscribeUserConnections agentBatchSubscribe user = do
groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m () groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m ()
groupSubsToView rs gs ms ce = do groupSubsToView rs gs ms ce = do
mapM_ groupSub $ mapM_ groupSub $
sortBy (comparing $ \(Group GroupInfo {localDisplayName = g} _) -> g) gs sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs
toView . CRMemberSubSummary $ map (uncurry MemberSubStatus) mRs toView . CRMemberSubSummary $ map (uncurry MemberSubStatus) mRs
where where
mRs = resultsFor rs ms mRs = resultsFor rs ms
@ -1293,11 +1349,14 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
_ -> Nothing _ -> Nothing
processDirectMessage :: ACommand 'Agent -> Connection -> Maybe Contact -> m () processDirectMessage :: ACommand 'Agent -> Connection -> Maybe Contact -> m ()
processDirectMessage agentMsg conn@Connection {connId, viaUserContactLink} = \case processDirectMessage agentMsg conn@Connection {connId, viaUserContactLink, customUserProfileId} = \case
Nothing -> case agentMsg of Nothing -> case agentMsg of
CONF confId _ connInfo -> do CONF confId _ connInfo -> do
-- [incognito] send saved profile
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
let profileToSend = fromMaybe (fromLocalProfile profile) incognitoProfile
saveConnInfo conn connInfo saveConnInfo conn connInfo
allowAgentConnection conn confId $ XInfo profile allowAgentConnection conn confId $ XInfo profileToSend
INFO connInfo -> INFO connInfo ->
saveConnInfo conn connInfo saveConnInfo conn connInfo
MSG meta _msgFlags msgBody -> do MSG meta _msgFlags msgBody -> do
@ -1358,7 +1417,9 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
CON -> CON ->
withStore' (\db -> getViaGroupMember db user ct) >>= \case withStore' (\db -> getViaGroupMember db user ct) >>= \case
Nothing -> do Nothing -> do
toView $ CRContactConnected ct -- [incognito] print incognito profile used for this contact
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
toView $ CRContactConnected ct incognitoProfile
setActive $ ActiveC c setActive $ ActiveC c
showToast (c <> "> ") "connected" showToast (c <> "> ") "connected"
forM_ viaUserContactLink $ \userContactLinkId -> do forM_ viaUserContactLink $ \userContactLinkId -> do
@ -1386,25 +1447,26 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
-- TODO print errors -- TODO print errors
MERR msgId err -> do MERR msgId err -> do
chatItemId_ <- withStore' $ \db -> getChatItemIdByAgentMsgId db connId msgId chatItemId_ <- withStore' $ \db -> getChatItemIdByAgentMsgId db connId msgId
case chatItemId_ of forM_ chatItemId_ $ \chatItemId -> do
Nothing -> pure () chatItem <- withStore $ \db -> updateDirectChatItemStatus db userId contactId chatItemId (agentErrToItemStatus err)
Just chatItemId -> do toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
chatItem <- withStore $ \db -> updateDirectChatItemStatus db userId contactId chatItemId (agentErrToItemStatus err)
toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
ERR err -> toView . CRChatError $ ChatErrorAgent err ERR err -> toView . CRChatError $ ChatErrorAgent err
-- TODO add debugging output -- TODO add debugging output
_ -> pure () _ -> pure ()
processGroupMessage :: ACommand 'Agent -> Connection -> GroupInfo -> GroupMember -> m () processGroupMessage :: ACommand 'Agent -> Connection -> GroupInfo -> GroupMember -> m ()
processGroupMessage agentMsg conn gInfo@GroupInfo {groupId, localDisplayName = gName, membership} m = case agentMsg of processGroupMessage agentMsg conn gInfo@GroupInfo {groupId, localDisplayName = gName, membership} m@GroupMember {memberContactProfileId} = case agentMsg of
CONF confId _ connInfo -> do CONF confId _ connInfo -> do
ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo
case memberCategory m of case memberCategory m of
GCInviteeMember -> GCInviteeMember ->
case chatMsgEvent of case chatMsgEvent of
XGrpAcpt memId XGrpAcpt memId incognitoProfile
| sameMemberId memId m -> do | sameMemberId memId m -> do
withStore' $ \db -> updateGroupMemberStatus db userId m GSMemAccepted -- [incognito] update member profile to incognito profile
withStore $ \db -> do
liftIO $ updateGroupMemberStatus db userId m GSMemAccepted
void $ createMemberIncognitoProfile db userId m incognitoProfile
allowAgentConnection conn confId XOk allowAgentConnection conn confId XOk
| otherwise -> messageError "x.grp.acpt: memberId is different from expected" | otherwise -> messageError "x.grp.acpt: memberId is different from expected"
_ -> messageError "CONF from invited member must have x.grp.acpt" _ -> messageError "CONF from invited member must have x.grp.acpt"
@ -1413,7 +1475,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
XGrpMemInfo memId _memProfile XGrpMemInfo memId _memProfile
| sameMemberId memId m -> do | sameMemberId memId m -> do
-- TODO update member profile -- TODO update member profile
allowAgentConnection conn confId $ XGrpMemInfo (memberId (membership :: GroupMember)) profile -- [incognito] send membership incognito profile
allowAgentConnection conn confId $ XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership)
| otherwise -> messageError "x.grp.mem.info: memberId is different from expected" | otherwise -> messageError "x.grp.mem.info: memberId is different from expected"
_ -> messageError "CONF from member must have x.grp.mem.info" _ -> messageError "CONF from member must have x.grp.mem.info"
INFO connInfo -> do INFO connInfo -> do
@ -1436,13 +1499,17 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
sendPendingGroupMessages m conn sendPendingGroupMessages m conn
case memberCategory m of case memberCategory m of
GCHostMember -> do GCHostMember -> do
memberConnectedChatItem gInfo m -- [incognito] chat item & event with indication that host connected incognito
toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} mainProfile <- if memberIncognito m then Just <$> withStore (\db -> getProfileById db userId memberContactProfileId) else pure Nothing
memberConnectedChatItem gInfo m mainProfile
toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} (memberIncognito membership)
setActive $ ActiveG gName setActive $ ActiveG gName
showToast ("#" <> gName) "you are connected to group" showToast ("#" <> gName) "you are connected to group"
GCInviteeMember -> do GCInviteeMember -> do
memberConnectedChatItem gInfo m -- [incognito] chat item & event with indication that invitee connected incognito
toView $ CRJoinedGroupMember gInfo m {memberStatus = GSMemConnected} mainProfile <- if memberIncognito m then Just <$> withStore (\db -> getProfileById db userId memberContactProfileId) else pure Nothing
memberConnectedChatItem gInfo m mainProfile
toView $ CRJoinedGroupMember gInfo m {memberStatus = GSMemConnected} mainProfile
setActive $ ActiveG gName setActive $ ActiveG gName
showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected" showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected"
intros <- withStore' $ \db -> createIntroductions db members m intros <- withStore' $ \db -> createIntroductions db members m
@ -1629,10 +1696,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
cancelRcvFileTransfer user ft cancelRcvFileTransfer user ft
throwChatError $ CEFileRcvChunk err throwChatError $ CEFileRcvChunk err
memberConnectedChatItem :: GroupInfo -> GroupMember -> m () memberConnectedChatItem :: GroupInfo -> GroupMember -> Maybe Profile -> m ()
memberConnectedChatItem gInfo m = do memberConnectedChatItem gInfo m mainProfile_ = do
createdAt <- liftIO getCurrentTime createdAt <- liftIO getCurrentTime
let content = CIRcvGroupEvent RGEMemberConnected let content = CIRcvGroupEvent $ case mainProfile_ of
Just mainProfile -> RGEMemberConnected $ Just mainProfile
_ -> RGEMemberConnected Nothing
cd = CDGroupRcv gInfo m cd = CDGroupRcv gInfo m
-- first ts should be broker ts but we don't have it for CON -- first ts should be broker ts but we don't have it for CON
ciId <- withStore' $ \db -> createNewChatItemNoMsg db user cd content createdAt createdAt ciId <- withStore' $ \db -> createNewChatItemNoMsg db user cd content createdAt createdAt
@ -1641,7 +1710,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
notifyMemberConnected :: GroupInfo -> GroupMember -> m () notifyMemberConnected :: GroupInfo -> GroupMember -> m ()
notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do
memberConnectedChatItem gInfo m memberConnectedChatItem gInfo m Nothing
toView $ CRConnectedToGroupMember gInfo m toView $ CRConnectedToGroupMember gInfo m
let g = groupName' gInfo let g = groupName' gInfo
setActive $ ActiveG g setActive $ ActiveG g
@ -1679,13 +1748,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
setActive $ ActiveC c setActive $ ActiveC c
processFileInvitation :: Maybe FileInvitation -> (FileInvitation -> Integer -> m RcvFileTransfer) -> m (Maybe (CIFile 'MDRcv)) processFileInvitation :: Maybe FileInvitation -> (FileInvitation -> Integer -> m RcvFileTransfer) -> m (Maybe (CIFile 'MDRcv))
processFileInvitation fileInvitation_ createRcvFileTransferF = case fileInvitation_ of processFileInvitation fileInvitation_ createRcvFileTransferF =
Nothing -> pure Nothing forM fileInvitation_ $ \fileInvitation@FileInvitation {fileName, fileSize} -> do
Just fileInvitation@FileInvitation {fileName, fileSize} -> do
chSize <- asks $ fileChunkSize . config chSize <- asks $ fileChunkSize . config
RcvFileTransfer {fileId} <- createRcvFileTransferF fileInvitation chSize RcvFileTransfer {fileId} <- createRcvFileTransferF fileInvitation chSize
let ciFile = CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} let ciFile = CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation}
pure $ Just ciFile pure ciFile
messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m () messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m ()
messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta = do messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta = do
@ -1830,16 +1898,19 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci
processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m ()
processGroupInvitation ct@Contact {localDisplayName = c} inv@(GroupInvitation (MemberIdRole fromMemId fromRole) (MemberIdRole memId memRole) _ _) msg msgMeta = do processGroupInvitation ct@Contact {localDisplayName = c} inv@GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), fromMemberProfile, invitedMember = (MemberIdRole memId memRole)} msg msgMeta = do
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c)
when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId
gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = GroupMember {groupMemberId}} <- withStore $ \db -> createGroupInvitation db user ct inv -- [incognito] if received group invitation has host's incognito profile, create membership with new incognito profile; incognito mode is checked when joining group
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole let invitedIncognito = isJust fromMemberProfile
incognitoProfile <- if invitedIncognito then Just <$> liftIO generateRandomProfile else pure Nothing
gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = GroupMember {groupMemberId}} <- withStore $ \db -> createGroupInvitation db user ct inv incognitoProfile
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending, invitedIncognito = Just invitedIncognito}) memRole
ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content Nothing ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content Nothing
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
toView $ CRReceivedGroupInvitation gInfo ct memRole toView $ CRReceivedGroupInvitation gInfo ct memRole fromMemberProfile
showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group" showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group"
checkIntegrityCreateItem :: forall c. ChatTypeI c => ChatDirection c 'MDRcv -> MsgMeta -> m () checkIntegrityCreateItem :: forall c. ChatTypeI c => ChatDirection c 'MDRcv -> MsgMeta -> m ()
@ -1857,23 +1928,27 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
toView $ CRNewChatItem $ AChatItem (chatTypeI @c) SMDRcv (toChatInfo cd) ci toView $ CRNewChatItem $ AChatItem (chatTypeI @c) SMDRcv (toChatInfo cd) ci
xInfo :: Contact -> Profile -> m () xInfo :: Contact -> Profile -> m ()
xInfo c@Contact {profile = p} p' = unless (p == p') $ do xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do
c' <- withStore $ \db -> updateContactProfile db userId c p' c' <- withStore $ \db -> updateContactProfile db userId c p'
toView $ CRContactUpdated c c' toView $ CRContactUpdated c c'
xInfoProbe :: Contact -> Probe -> m () xInfoProbe :: Contact -> Probe -> m ()
xInfoProbe c2 probe = do xInfoProbe c2 probe =
r <- withStore' $ \db -> matchReceivedProbe db userId c2 probe -- [incognito] unless connected incognito
forM_ r $ \c1 -> probeMatch c1 c2 probe unless (contactConnIncognito c2) $ do
r <- withStore' $ \db -> matchReceivedProbe db userId c2 probe
forM_ r $ \c1 -> probeMatch c1 c2 probe
xInfoProbeCheck :: Contact -> ProbeHash -> m () xInfoProbeCheck :: Contact -> ProbeHash -> m ()
xInfoProbeCheck c1 probeHash = do xInfoProbeCheck c1 probeHash =
r <- withStore' $ \db -> matchReceivedProbeHash db userId c1 probeHash -- [incognito] unless connected incognito
forM_ r . uncurry $ probeMatch c1 unless (contactConnIncognito c1) $ do
r <- withStore' $ \db -> matchReceivedProbeHash db userId c1 probeHash
forM_ r . uncurry $ probeMatch c1
probeMatch :: Contact -> Contact -> Probe -> m () probeMatch :: Contact -> Contact -> Probe -> m ()
probeMatch c1@Contact {profile = p1} c2@Contact {profile = p2} probe = probeMatch c1@Contact {profile = p1} c2@Contact {profile = p2} probe =
when (p1 == p2) $ do when (fromLocalProfile p1 == fromLocalProfile p2) $ do
void . sendDirectContactMessage c1 $ XInfoProbeOk probe void . sendDirectContactMessage c1 $ XInfoProbeOk probe
mergeContacts c1 c2 mergeContacts c1 c2
@ -2009,7 +2084,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
toView $ CRJoinedGroupMemberConnecting gInfo m newMember toView $ CRJoinedGroupMemberConnecting gInfo m newMember
xGrpMemIntro :: Connection -> GroupInfo -> GroupMember -> MemberInfo -> m () xGrpMemIntro :: Connection -> GroupInfo -> GroupMember -> MemberInfo -> m ()
xGrpMemIntro conn gInfo@GroupInfo {groupId} m memInfo@(MemberInfo memId _ _) = do xGrpMemIntro conn gInfo@GroupInfo {groupId, membership} m memInfo@(MemberInfo memId _ _) = do
case memberCategory m of case memberCategory m of
GCHostMember -> do GCHostMember -> do
members <- withStore' $ \db -> getGroupMembers db user gInfo members <- withStore' $ \db -> getGroupMembers db user gInfo
@ -2018,7 +2093,9 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
else do else do
(groupConnId, groupConnReq) <- withAgent (`createConnection` SCMInvitation) (groupConnId, groupConnReq) <- withAgent (`createConnection` SCMInvitation)
(directConnId, directConnReq) <- withAgent (`createConnection` SCMInvitation) (directConnId, directConnReq) <- withAgent (`createConnection` SCMInvitation)
newMember <- withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnId directConnId -- [incognito] direct connection with member has to be established using same incognito profile
customUserProfileId <- if memberIncognito membership then Just <$> withStore (\db -> getGroupMemberProfileId db userId membership) else pure Nothing
newMember <- withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnId directConnId customUserProfileId
let msg = XGrpMemInv memId IntroInvitation {groupConnReq, directConnReq} let msg = XGrpMemInv memId IntroInvitation {groupConnReq, directConnReq}
void $ sendDirectMessage conn msg (GroupId groupId) void $ sendDirectMessage conn msg (GroupId groupId)
withStore' $ \db -> updateGroupMemberStatus db userId newMember GSMemIntroInvited withStore' $ \db -> updateGroupMemberStatus db userId newMember GSMemIntroInvited
@ -2047,10 +2124,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
Nothing -> withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced Nothing -> withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced
Just m' -> pure m' Just m' -> pure m'
withStore' $ \db -> saveMemberInvitation db toMember introInv withStore' $ \db -> saveMemberInvitation db toMember introInv
let msg = XGrpMemInfo (memberId (membership :: GroupMember)) profile -- [incognito] send membership incognito profile, create direct connection as incognito
let msg = XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership)
groupConnId <- withAgent $ \a -> joinConnection a groupConnReq $ directMessage msg groupConnId <- withAgent $ \a -> joinConnection a groupConnReq $ directMessage msg
directConnId <- withAgent $ \a -> joinConnection a directConnReq $ directMessage msg directConnId <- withAgent $ \a -> joinConnection a directConnReq $ directMessage msg
withStore' $ \db -> createIntroToMemberContact db userId m toMember groupConnId directConnId customUserProfileId <- if memberIncognito membership then Just <$> withStore (\db -> getGroupMemberProfileId db userId membership) else pure Nothing
withStore' $ \db -> createIntroToMemberContact db userId m toMember groupConnId directConnId customUserProfileId
xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> MsgMeta -> m () xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> MsgMeta -> m ()
xGrpMemDel gInfo@GroupInfo {membership} m memId msg msgMeta = do xGrpMemDel gInfo@GroupInfo {membership} m memId msg msgMeta = do
@ -2071,7 +2150,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
else do else do
deleteMemberConnection member deleteMemberConnection member
withStore' $ \db -> updateGroupMemberStatus db userId member GSMemRemoved withStore' $ \db -> updateGroupMemberStatus db userId member GSMemRemoved
ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent $ RGEMemberDeleted groupMemberId memberProfile) Nothing ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile)) Nothing
groupMsgToView gInfo m ci msgMeta groupMsgToView gInfo m ci msgMeta
toView $ CRDeletedMember gInfo m member {memberStatus = GSMemRemoved} toView $ CRDeletedMember gInfo m member {memberStatus = GSMemRemoved}
@ -2388,7 +2467,7 @@ getCreateActiveUser st = do
withTransaction st (`setActiveUser` userId user) withTransaction st (`setActiveUser` userId user)
pure user pure user
userStr :: User -> String userStr :: User -> String
userStr User {localDisplayName, profile = Profile {fullName}} = userStr User {localDisplayName, profile = LocalProfile {fullName}} =
T.unpack $ localDisplayName <> if T.null fullName || localDisplayName == fullName then "" else " (" <> fullName <> ")" T.unpack $ localDisplayName <> if T.null fullName || localDisplayName == fullName then "" else " (" <> fullName <> ")"
getContactName :: IO ContactName getContactName :: IO ContactName
getContactName = do getContactName = do
@ -2556,6 +2635,7 @@ chatCommandP =
"/profile_image" $> UpdateProfileImage Nothing, "/profile_image" $> UpdateProfileImage Nothing,
("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> userNames), ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> userNames),
("/profile" <|> "/p") $> ShowProfile, ("/profile" <|> "/p") $> ShowProfile,
"/incognito " *> (SetIncognito <$> onOffP),
("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/quit" <|> "/q" <|> "/exit") $> QuitChat,
("/version" <|> "/v") $> ShowVersion ("/version" <|> "/v") $> ShowVersion
] ]

View file

@ -24,7 +24,7 @@ chatBotRepl welcome answer _user cc = do
race_ (forever $ void getLine) . forever $ do race_ (forever $ void getLine) . forever $ do
(_, resp) <- atomically . readTBQueue $ outputQ cc (_, resp) <- atomically . readTBQueue $ outputQ cc
case resp of case resp of
CRContactConnected contact -> do CRContactConnected contact _ -> do
contactConnected contact contactConnected contact
void $ sendMsg contact welcome void $ sendMsg contact welcome
CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do
@ -40,7 +40,7 @@ initializeBotAddress cc = do
sendChatCmd cc "/show_address" >>= \case sendChatCmd cc "/show_address" >>= \case
CRUserContactLink uri _ _ -> showBotAddress uri CRUserContactLink uri _ _ -> showBotAddress uri
CRChatCmdError (ChatErrorStore SEUserContactLinkNotFound) -> do CRChatCmdError (ChatErrorStore SEUserContactLinkNotFound) -> do
putStrLn $ "No bot address, creating..." putStrLn "No bot address, creating..."
sendChatCmd cc "/address" >>= \case sendChatCmd cc "/address" >>= \case
CRUserContactLinkCreated uri -> showBotAddress uri CRUserContactLinkCreated uri -> showBotAddress uri
_ -> putStrLn "can't create bot address" >> exitFailure _ -> putStrLn "can't create bot address" >> exitFailure

View file

@ -88,7 +88,8 @@ data ChatController = ChatController
rcvFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle),
currentCalls :: TMap ContactId Call, currentCalls :: TMap ContactId Call,
config :: ChatConfig, config :: ChatConfig,
filesFolder :: TVar (Maybe FilePath) -- path to files folder for mobile apps filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps,
incognitoMode :: TVar Bool
} }
data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSMessages | HSSettings data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSMessages | HSSettings
@ -107,6 +108,7 @@ data ChatCommand
| APISuspendChat {suspendTimeout :: Int} | APISuspendChat {suspendTimeout :: Int}
| ResubscribeAllConnections | ResubscribeAllConnections
| SetFilesFolder FilePath | SetFilesFolder FilePath
| SetIncognito Bool
| APIExportArchive ArchiveConfig | APIExportArchive ArchiveConfig
| APIImportArchive ArchiveConfig | APIImportArchive ArchiveConfig
| APIDeleteStorage | APIDeleteStorage
@ -210,8 +212,8 @@ data ChatResponse
| CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList}
| CRUserSMPServers {smpServers :: [SMPServer]} | CRUserSMPServers {smpServers :: [SMPServer]}
| CRNetworkConfig {networkConfig :: NetworkConfig} | CRNetworkConfig {networkConfig :: NetworkConfig}
| CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats} | CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile}
| CRGroupMemberInfo {groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} | CRGroupMemberInfo {groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats, mainProfile :: Maybe Profile}
| CRNewChatItem {chatItem :: AChatItem} | CRNewChatItem {chatItem :: AChatItem}
| CRChatItemStatusUpdated {chatItem :: AChatItem} | CRChatItemStatusUpdated {chatItem :: AChatItem}
| CRChatItemUpdated {chatItem :: AChatItem} | CRChatItemUpdated {chatItem :: AChatItem}
@ -223,7 +225,7 @@ data ChatResponse
| CRCmdOk | CRCmdOk
| CRChatHelp {helpSection :: HelpSection} | CRChatHelp {helpSection :: HelpSection}
| CRWelcome {user :: User} | CRWelcome {user :: User}
| CRGroupCreated {groupInfo :: GroupInfo} | CRGroupCreated {groupInfo :: GroupInfo, customUserProfile :: Maybe Profile}
| CRGroupMembers {group :: Group} | CRGroupMembers {group :: Group}
| CRContactsList {contacts :: [Contact]} | CRContactsList {contacts :: [Contact]}
| CRUserContactLink {connReqContact :: ConnReqContact, autoAccept :: Bool, autoReply :: Maybe MsgContent} | CRUserContactLink {connReqContact :: ConnReqContact, autoAccept :: Bool, autoReply :: Maybe MsgContent}
@ -232,14 +234,14 @@ data ChatResponse
| CRUserAcceptedGroupSent {groupInfo :: GroupInfo} | CRUserAcceptedGroupSent {groupInfo :: GroupInfo}
| CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember} | CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember}
| CRGroupsList {groups :: [GroupInfo]} | CRGroupsList {groups :: [GroupInfo]}
| CRSentGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} | CRSentGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember, sentCustomProfile :: Maybe Profile}
| CRFileTransferStatus (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus | CRFileTransferStatus (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus
| CRUserProfile {profile :: Profile} | CRUserProfile {profile :: Profile}
| CRUserProfileNoChange | CRUserProfileNoChange
| CRVersionInfo {version :: String} | CRVersionInfo {version :: String}
| CRInvitation {connReqInvitation :: ConnReqInvitation} | CRInvitation {connReqInvitation :: ConnReqInvitation}
| CRSentConfirmation | CRSentConfirmation
| CRSentInvitation | CRSentInvitation {customUserProfile :: Maybe Profile}
| CRContactUpdated {fromContact :: Contact, toContact :: Contact} | CRContactUpdated {fromContact :: Contact, toContact :: Contact}
| CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact}
| CRContactDeleted {contact :: Contact} | CRContactDeleted {contact :: Contact}
@ -265,7 +267,7 @@ data ChatResponse
| CRSndGroupFileCancelled {chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} | CRSndGroupFileCancelled {chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]}
| CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile}
| CRContactConnecting {contact :: Contact} | CRContactConnecting {contact :: Contact}
| CRContactConnected {contact :: Contact} | CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile}
| CRContactAnotherClient {contact :: Contact} | CRContactAnotherClient {contact :: Contact}
| CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} | CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]}
| CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} | CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]}
@ -274,9 +276,9 @@ data ChatResponse
| CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost}
| CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost}
| CRGroupInvitation {groupInfo :: GroupInfo} | CRGroupInvitation {groupInfo :: GroupInfo}
| CRReceivedGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole} | CRReceivedGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole, receivedCustomProfile :: Maybe Profile}
| CRUserJoinedGroup {groupInfo :: GroupInfo, hostMember :: GroupMember} | CRUserJoinedGroup {groupInfo :: GroupInfo, hostMember :: GroupMember, usedCustomProfile :: Bool}
| CRJoinedGroupMember {groupInfo :: GroupInfo, member :: GroupMember} | CRJoinedGroupMember {groupInfo :: GroupInfo, member :: GroupMember, mainProfile :: Maybe Profile}
| CRJoinedGroupMemberConnecting {groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} | CRJoinedGroupMemberConnecting {groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember}
| CRConnectedToGroupMember {groupInfo :: GroupInfo, member :: GroupMember} | CRConnectedToGroupMember {groupInfo :: GroupInfo, member :: GroupMember}
| CRDeletedMember {groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} | CRDeletedMember {groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember}
@ -383,6 +385,7 @@ data ChatErrorType
| CEContactNotReady {contact :: Contact} | CEContactNotReady {contact :: Contact}
| CEContactGroups {contact :: Contact, groupNames :: [GroupName]} | CEContactGroups {contact :: Contact, groupNames :: [GroupName]}
| CEGroupUserRole | CEGroupUserRole
| CEGroupNotIncognitoCantInvite
| CEGroupContactRole {contactName :: ContactName} | CEGroupContactRole {contactName :: ContactName}
| CEGroupDuplicateMember {contactName :: ContactName} | CEGroupDuplicateMember {contactName :: ContactName}
| CEGroupDuplicateMemberId | CEGroupDuplicateMemberId

View file

@ -18,7 +18,7 @@ import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Simplex.Chat.Markdown import Simplex.Chat.Markdown
import Simplex.Chat.Styled import Simplex.Chat.Styled
import Simplex.Chat.Types (Profile (..), User (..)) import Simplex.Chat.Types (User (..), LocalProfile (..))
import System.Console.ANSI.Types import System.Console.ANSI.Types
highlight :: Text -> Markdown highlight :: Text -> Markdown
@ -55,7 +55,7 @@ chatWelcome user =
"Type " <> highlight "/help" <> " for usage info, " <> highlight "/welcome" <> " to show this message" "Type " <> highlight "/help" <> " for usage info, " <> highlight "/welcome" <> " to show this message"
] ]
where where
User {profile = Profile {displayName, fullName}} = user User {profile = LocalProfile {displayName, fullName}} = user
userName = if T.null fullName then displayName else fullName userName = if T.null fullName then displayName else fullName
chatHelpInfo :: [StyledString] chatHelpInfo :: [StyledString]

View file

@ -501,22 +501,24 @@ ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayN
rcvGroupEventToText :: RcvGroupEvent -> Text rcvGroupEventToText :: RcvGroupEvent -> Text
rcvGroupEventToText = \case rcvGroupEventToText = \case
RGEMemberAdded _ p -> "added " <> memberProfileToText p RGEMemberAdded _ p -> "added " <> profileToText p
RGEMemberConnected -> "connected" RGEMemberConnected contactMainProfile -> case contactMainProfile of
Just p -> profileToText p <> " connected incognito"
Nothing -> "connected"
RGEMemberLeft -> "left" RGEMemberLeft -> "left"
RGEMemberDeleted _ p -> "removed " <> memberProfileToText p RGEMemberDeleted _ p -> "removed " <> profileToText p
RGEUserDeleted -> "removed you" RGEUserDeleted -> "removed you"
RGEGroupDeleted -> "deleted group" RGEGroupDeleted -> "deleted group"
RGEGroupUpdated _ -> "group profile updated" RGEGroupUpdated _ -> "group profile updated"
sndGroupEventToText :: SndGroupEvent -> Text sndGroupEventToText :: SndGroupEvent -> Text
sndGroupEventToText = \case sndGroupEventToText = \case
SGEMemberDeleted _ p -> "removed " <> memberProfileToText p SGEMemberDeleted _ p -> "removed " <> profileToText p
SGEUserLeft -> "left" SGEUserLeft -> "left"
SGEGroupUpdated _ -> "group profile updated" SGEGroupUpdated _ -> "group profile updated"
memberProfileToText :: Profile -> Text profileToText :: Profile -> Text
memberProfileToText Profile {displayName, fullName} = displayName <> optionalFullName displayName fullName profileToText Profile {displayName, fullName} = displayName <> optionalFullName displayName fullName
-- This type is used both in API and in DB, so we use different JSON encodings for the database and for the API -- This type is used both in API and in DB, so we use different JSON encodings for the database and for the API
data CIContent (d :: MsgDirection) where data CIContent (d :: MsgDirection) where
@ -536,7 +538,7 @@ deriving instance Show (CIContent d)
data RcvGroupEvent data RcvGroupEvent
= RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting = RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting
| RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember | RGEMemberConnected {contactMainProfile :: Maybe Profile} -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember
| RGEMemberLeft -- CRLeftMember | RGEMemberLeft -- CRLeftMember
| RGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRDeletedMember | RGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRDeletedMember
| RGEUserDeleted -- CRDeletedMemberUser | RGEUserDeleted -- CRDeletedMemberUser
@ -569,13 +571,14 @@ data CIGroupInvitation = CIGroupInvitation
groupMemberId :: GroupMemberId, groupMemberId :: GroupMemberId,
localDisplayName :: GroupName, localDisplayName :: GroupName,
groupProfile :: GroupProfile, groupProfile :: GroupProfile,
status :: CIGroupInvitationStatus status :: CIGroupInvitationStatus,
invitedIncognito :: Maybe Bool
} }
deriving (Eq, Show, Generic, FromJSON) deriving (Eq, Show, Generic, FromJSON)
instance ToJSON CIGroupInvitation where instance ToJSON CIGroupInvitation where
toJSON = J.genericToJSON J.defaultOptions toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data CIGroupInvitationStatus data CIGroupInvitationStatus
= CIGISPending = CIGISPending

View file

@ -0,0 +1,16 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20220812_incognito_profiles where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20220812_incognito_profiles :: Query
m20220812_incognito_profiles =
[sql|
ALTER TABLE connections ADD COLUMN custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; -- only set for direct connections
ALTER TABLE group_members ADD COLUMN member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; -- member profile id if incognito profile was saved for member (used for hosts and invitees in incognito groups)
ALTER TABLE contact_profiles ADD COLUMN incognito INTEGER; -- 1 for incognito
|]

View file

@ -13,7 +13,8 @@ CREATE TABLE contact_profiles(
created_at TEXT CHECK(created_at NOT NULL), created_at TEXT CHECK(created_at NOT NULL),
updated_at TEXT CHECK(updated_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL),
image TEXT, image TEXT,
user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE,
incognito INTEGER
); );
CREATE INDEX contact_profiles_index ON contact_profiles( CREATE INDEX contact_profiles_index ON contact_profiles(
display_name, display_name,
@ -145,6 +146,7 @@ CREATE TABLE group_members(
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE,
created_at TEXT CHECK(created_at NOT NULL), created_at TEXT CHECK(created_at NOT NULL),
updated_at TEXT CHECK(updated_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL),
member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL,
FOREIGN KEY(user_id, local_display_name) FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name)
ON DELETE CASCADE ON DELETE CASCADE
@ -236,6 +238,7 @@ CREATE TABLE connections(
xcontact_id BLOB, xcontact_id BLOB,
via_user_contact_link INTEGER DEFAULT NULL via_user_contact_link INTEGER DEFAULT NULL
REFERENCES user_contact_links(user_contact_link_id) ON DELETE SET NULL, REFERENCES user_contact_links(user_contact_link_id) ON DELETE SET NULL,
custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL,
FOREIGN KEY(snd_file_id, connection_id) FOREIGN KEY(snd_file_id, connection_id)
REFERENCES snd_files(file_id, connection_id) REFERENCES snd_files(file_id, connection_id)
ON DELETE CASCADE ON DELETE CASCADE

File diff suppressed because it is too large Load diff

View file

@ -124,7 +124,7 @@ data ChatMsgEvent
| XInfo Profile | XInfo Profile
| XContact Profile (Maybe XContactId) | XContact Profile (Maybe XContactId)
| XGrpInv GroupInvitation | XGrpInv GroupInvitation
| XGrpAcpt MemberId | XGrpAcpt MemberId (Maybe Profile)
| XGrpMemNew MemberInfo | XGrpMemNew MemberInfo
| XGrpMemIntro MemberInfo | XGrpMemIntro MemberInfo
| XGrpMemInv MemberId IntroInvitation | XGrpMemInv MemberId IntroInvitation
@ -413,7 +413,7 @@ toCMEventTag = \case
XInfo _ -> XInfo_ XInfo _ -> XInfo_
XContact _ _ -> XContact_ XContact _ _ -> XContact_
XGrpInv _ -> XGrpInv_ XGrpInv _ -> XGrpInv_
XGrpAcpt _ -> XGrpAcpt_ XGrpAcpt _ _ -> XGrpAcpt_
XGrpMemNew _ -> XGrpMemNew_ XGrpMemNew _ -> XGrpMemNew_
XGrpMemIntro _ -> XGrpMemIntro_ XGrpMemIntro _ -> XGrpMemIntro_
XGrpMemInv _ _ -> XGrpMemInv_ XGrpMemInv _ _ -> XGrpMemInv_
@ -479,7 +479,7 @@ appToChatMessage AppMessage {msgId, event, params} = do
XInfo_ -> XInfo <$> p "profile" XInfo_ -> XInfo <$> p "profile"
XContact_ -> XContact <$> p "profile" <*> opt "contactReqId" XContact_ -> XContact <$> p "profile" <*> opt "contactReqId"
XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpInv_ -> XGrpInv <$> p "groupInvitation"
XGrpAcpt_ -> XGrpAcpt <$> p "memberId" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" <*> opt "memberProfile"
XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo"
XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo"
XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro"
@ -521,7 +521,7 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p
XInfo profile -> o ["profile" .= profile] XInfo profile -> o ["profile" .= profile]
XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile] XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile]
XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
XGrpAcpt memId -> o ["memberId" .= memId] XGrpAcpt memId profile -> o $ ("memberProfile" .=? profile) ["memberId" .= memId]
XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo]
XGrpMemIntro memInfo -> o ["memberInfo" .= memInfo] XGrpMemIntro memInfo -> o ["memberInfo" .= memInfo]
XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro]

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString) import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Char8 as B
import Data.Int (Int64) import Data.Int (Int64)
import Data.Maybe (isJust)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Time.Clock (UTCTime) import Data.Time.Clock (UTCTime)
@ -39,7 +40,7 @@ import Simplex.Messaging.Util ((<$?>))
class IsContact a where class IsContact a where
contactId' :: a -> ContactId contactId' :: a -> ContactId
profile' :: a -> Profile profile' :: a -> LocalProfile
localDisplayName' :: a -> ContactName localDisplayName' :: a -> ContactName
instance IsContact User where instance IsContact User where
@ -56,7 +57,7 @@ data User = User
{ userId :: UserId, { userId :: UserId,
userContactId :: ContactId, userContactId :: ContactId,
localDisplayName :: ContactName, localDisplayName :: ContactName,
profile :: Profile, profile :: LocalProfile,
activeUser :: Bool activeUser :: Bool
} }
deriving (Show, Generic, FromJSON) deriving (Show, Generic, FromJSON)
@ -67,10 +68,12 @@ type UserId = ContactId
type ContactId = Int64 type ContactId = Int64
type ProfileId = Int64
data Contact = Contact data Contact = Contact
{ contactId :: ContactId, { contactId :: ContactId,
localDisplayName :: ContactName, localDisplayName :: ContactName,
profile :: Profile, profile :: LocalProfile,
activeConn :: Connection, activeConn :: Connection,
viaGroup :: Maybe Int64, viaGroup :: Maybe Int64,
createdAt :: UTCTime, createdAt :: UTCTime,
@ -88,6 +91,9 @@ contactConn = activeConn
contactConnId :: Contact -> ConnId contactConnId :: Contact -> ConnId
contactConnId Contact {activeConn} = aConnId activeConn contactConnId Contact {activeConn} = aConnId activeConn
contactConnIncognito :: Contact -> Bool
contactConnIncognito Contact {activeConn = Connection {customUserProfileId}} = isJust customUserProfileId
data ContactRef = ContactRef data ContactRef = ContactRef
{ contactId :: ContactId, { contactId :: ContactId,
localDisplayName :: ContactName localDisplayName :: ContactName
@ -198,6 +204,7 @@ data Profile = Profile
{ displayName :: ContactName, { displayName :: ContactName,
fullName :: Text, fullName :: Text,
image :: Maybe ImageData image :: Maybe ImageData
-- incognito field should not be read as is into this data type to prevent sending it as part of profile to contacts
} }
deriving (Eq, Show, Generic, FromJSON) deriving (Eq, Show, Generic, FromJSON)
@ -205,6 +212,29 @@ instance ToJSON Profile where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data LocalProfile = LocalProfile
{ profileId :: ProfileId,
displayName :: ContactName,
fullName :: Text,
image :: Maybe ImageData
}
deriving (Eq, Show, Generic, FromJSON)
instance ToJSON LocalProfile where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
localProfileId :: LocalProfile -> ProfileId
localProfileId = profileId
toLocalProfile :: ProfileId -> Profile -> LocalProfile
toLocalProfile profileId Profile {displayName, fullName, image} =
LocalProfile {profileId, displayName, fullName, image}
fromLocalProfile :: LocalProfile -> Profile
fromLocalProfile LocalProfile {displayName, fullName, image} =
Profile {displayName, fullName, image}
data GroupProfile = GroupProfile data GroupProfile = GroupProfile
{ displayName :: GroupName, { displayName :: GroupName,
fullName :: Text, fullName :: Text,
@ -232,13 +262,16 @@ instance FromField ImageData where fromField = fmap ImageData . fromField
data GroupInvitation = GroupInvitation data GroupInvitation = GroupInvitation
{ fromMember :: MemberIdRole, { fromMember :: MemberIdRole,
fromMemberProfile :: Maybe Profile,
invitedMember :: MemberIdRole, invitedMember :: MemberIdRole,
connRequest :: ConnReqInvitation, connRequest :: ConnReqInvitation,
groupProfile :: GroupProfile groupProfile :: GroupProfile
} }
deriving (Eq, Show, Generic, FromJSON) deriving (Eq, Show, Generic, FromJSON)
instance ToJSON GroupInvitation where toEncoding = J.genericToEncoding J.defaultOptions instance ToJSON GroupInvitation where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data MemberIdRole = MemberIdRole data MemberIdRole = MemberIdRole
{ memberId :: MemberId, { memberId :: MemberId,
@ -267,7 +300,7 @@ instance ToJSON MemberInfo where toEncoding = J.genericToEncoding J.defaultOptio
memberInfo :: GroupMember -> MemberInfo memberInfo :: GroupMember -> MemberInfo
memberInfo GroupMember {memberId, memberRole, memberProfile} = memberInfo GroupMember {memberId, memberRole, memberProfile} =
MemberInfo memberId memberRole memberProfile MemberInfo memberId memberRole (fromLocalProfile memberProfile)
data ReceivedGroupInvitation = ReceivedGroupInvitation data ReceivedGroupInvitation = ReceivedGroupInvitation
{ fromMember :: GroupMember, { fromMember :: GroupMember,
@ -278,6 +311,8 @@ data ReceivedGroupInvitation = ReceivedGroupInvitation
type GroupMemberId = Int64 type GroupMemberId = Int64
-- memberProfile's profileId is COALESCE(member_profile_id, contact_profile_id), member_profile_id is non null
-- if incognito profile was saved for member (used for hosts and invitees in incognito groups)
data GroupMember = GroupMember data GroupMember = GroupMember
{ groupMemberId :: GroupMemberId, { groupMemberId :: GroupMemberId,
groupId :: GroupId, groupId :: GroupId,
@ -287,8 +322,9 @@ data GroupMember = GroupMember
memberStatus :: GroupMemberStatus, memberStatus :: GroupMemberStatus,
invitedBy :: InvitedBy, invitedBy :: InvitedBy,
localDisplayName :: ContactName, localDisplayName :: ContactName,
memberProfile :: Profile, memberProfile :: LocalProfile,
memberContactId :: Maybe Int64, memberContactId :: Maybe ContactId,
memberContactProfileId :: ProfileId,
activeConn :: Maybe Connection activeConn :: Maybe Connection
} }
deriving (Eq, Show, Generic) deriving (Eq, Show, Generic)
@ -306,6 +342,9 @@ memberConnId GroupMember {activeConn} = aConnId <$> activeConn
groupMemberId' :: GroupMember -> GroupMemberId groupMemberId' :: GroupMember -> GroupMemberId
groupMemberId' GroupMember {groupMemberId} = groupMemberId groupMemberId' GroupMember {groupMemberId} = groupMemberId
memberIncognito :: GroupMember -> Bool
memberIncognito GroupMember {memberProfile, memberContactProfileId} = localProfileId memberProfile /= memberContactProfileId
data NewGroupMember = NewGroupMember data NewGroupMember = NewGroupMember
{ memInfo :: MemberInfo, { memInfo :: MemberInfo,
memCategory :: GroupMemberCategory, memCategory :: GroupMemberCategory,
@ -695,6 +734,7 @@ data Connection = Connection
connLevel :: Int, connLevel :: Int,
viaContact :: Maybe Int64, -- group member contact ID, if not direct connection viaContact :: Maybe Int64, -- group member contact ID, if not direct connection
viaUserContactLink :: Maybe Int64, -- user contact link ID, if connected via "user address" viaUserContactLink :: Maybe Int64, -- user contact link ID, if connected via "user address"
customUserProfileId :: Maybe Int64,
connType :: ConnType, connType :: ConnType,
connStatus :: ConnStatus, connStatus :: ConnStatus,
entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID
@ -705,6 +745,9 @@ data Connection = Connection
aConnId :: Connection -> ConnId aConnId :: Connection -> ConnId
aConnId Connection {agentConnId = AgentConnId cId} = cId aConnId Connection {agentConnId = AgentConnId cId} = cId
connCustomUserProfileId :: Connection -> Maybe Int64
connCustomUserProfileId Connection {customUserProfileId} = customUserProfileId
instance ToJSON Connection where instance ToJSON Connection where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
@ -715,6 +758,7 @@ data PendingContactConnection = PendingContactConnection
pccConnStatus :: ConnStatus, pccConnStatus :: ConnStatus,
viaContactUri :: Bool, viaContactUri :: Bool,
viaUserContactLink :: Maybe Int64, viaUserContactLink :: Maybe Int64,
customUserProfileId :: Maybe Int64,
createdAt :: UTCTime, createdAt :: UTCTime,
updatedAt :: UTCTime updatedAt :: UTCTime
} }

View file

@ -54,7 +54,7 @@ serializeChatResponse = unlines . map unStyle . responseToView False
responseToView :: Bool -> ChatResponse -> [StyledString] responseToView :: Bool -> ChatResponse -> [StyledString]
responseToView testView = \case responseToView testView = \case
CRActiveUser User {profile} -> viewUserProfile profile CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile
CRChatStarted -> ["chat started"] CRChatStarted -> ["chat started"]
CRChatRunning -> ["chat is running"] CRChatRunning -> ["chat is running"]
CRChatStopped -> ["chat stopped"] CRChatStopped -> ["chat stopped"]
@ -64,8 +64,8 @@ responseToView testView = \case
CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft]
CRUserSMPServers smpServers -> viewSMPServers smpServers testView CRUserSMPServers smpServers -> viewSMPServers smpServers testView
CRNetworkConfig cfg -> viewNetworkConfig cfg CRNetworkConfig cfg -> viewNetworkConfig cfg
CRContactInfo ct cStats -> viewContactInfo ct cStats CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile
CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats CRGroupMemberInfo g m cStats mainProfile -> viewGroupMemberInfo g m cStats mainProfile
CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item False CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item False
CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True) chatItems CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True) chatItems
CRChatItemStatusUpdated _ -> [] CRChatItemStatusUpdated _ -> []
@ -89,10 +89,10 @@ responseToView testView = \case
CRUserContactLink cReqUri autoAccept autoReply -> connReqContact_ "Your chat address:" cReqUri <> autoAcceptStatus_ autoAccept autoReply CRUserContactLink cReqUri autoAccept autoReply -> connReqContact_ "Your chat address:" cReqUri <> autoAcceptStatus_ autoAccept autoReply
CRUserContactLinkUpdated _ autoAccept autoReply -> autoAcceptStatus_ autoAccept autoReply CRUserContactLinkUpdated _ autoAccept autoReply -> autoAcceptStatus_ autoAccept autoReply
CRContactRequestRejected UserContactRequest {localDisplayName = c} -> [ttyContact c <> ": contact request rejected"] CRContactRequestRejected UserContactRequest {localDisplayName = c} -> [ttyContact c <> ": contact request rejected"]
CRGroupCreated g -> viewGroupCreated g CRGroupCreated g customUserProfile -> viewGroupCreated g customUserProfile testView
CRGroupMembers g -> viewGroupMembers g CRGroupMembers g -> viewGroupMembers g
CRGroupsList gs -> viewGroupsList gs CRGroupsList gs -> viewGroupsList gs
CRSentGroupInvitation g c _ -> ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] CRSentGroupInvitation g c _ sentCustomProfile -> viewSentGroupInvitation g c sentCustomProfile
CRFileTransferStatus ftStatus -> viewFileTransferStatus ftStatus CRFileTransferStatus ftStatus -> viewFileTransferStatus ftStatus
CRUserProfile p -> viewUserProfile p CRUserProfile p -> viewUserProfile p
CRUserProfileNoChange -> ["user profile did not change"] CRUserProfileNoChange -> ["user profile did not change"]
@ -100,7 +100,7 @@ responseToView testView = \case
CRChatCmdError e -> viewChatError e CRChatCmdError e -> viewChatError e
CRInvitation cReq -> viewConnReqInvitation cReq CRInvitation cReq -> viewConnReqInvitation cReq
CRSentConfirmation -> ["confirmation sent!"] CRSentConfirmation -> ["confirmation sent!"]
CRSentInvitation -> ["connection request sent!"] CRSentInvitation customUserProfile -> viewSentInvitation customUserProfile testView
CRContactDeleted c -> [ttyContact' c <> ": contact is deleted"] CRContactDeleted c -> [ttyContact' c <> ": contact is deleted"]
CRChatCleared chatInfo -> viewChatCleared chatInfo CRChatCleared chatInfo -> viewChatCleared chatInfo
CRAcceptingContactRequest c -> [ttyFullContact c <> ": accepting contact request..."] CRAcceptingContactRequest c -> [ttyFullContact c <> ": accepting contact request..."]
@ -129,7 +129,7 @@ responseToView testView = \case
CRSndFileRcvCancelled _ ft@SndFileTransfer {recipientDisplayName = c} -> CRSndFileRcvCancelled _ ft@SndFileTransfer {recipientDisplayName = c} ->
[ttyContact c <> " cancelled receiving " <> sndFile ft] [ttyContact c <> " cancelled receiving " <> sndFile ft]
CRContactConnecting _ -> [] CRContactConnecting _ -> []
CRContactConnected ct -> [ttyFullContact ct <> ": contact is connected"] CRContactConnected ct userCustomProfile -> viewContactConnected ct userCustomProfile testView
CRContactAnotherClient c -> [ttyContact' c <> ": contact is connected to another client"] CRContactAnotherClient c -> [ttyContact' c <> ": contact is connected to another client"]
CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"]
CRContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] CRContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"]
@ -138,13 +138,13 @@ responseToView testView = \case
[sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors"
where where
(errors, subscribed) = partition (isJust . contactError) summary (errors, subscribed) = partition (isJust . contactError) summary
CRGroupInvitation GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership} ->
[groupInvitation' ldn fullName $ memberIncognito membership]
CRReceivedGroupInvitation g c role receivedCustomProfile -> viewReceivedGroupInvitation g c role receivedCustomProfile
CRUserJoinedGroup g _ usedCustomProfile -> viewUserJoinedGroup g usedCustomProfile testView
CRJoinedGroupMember g m mainProfile -> viewJoinedGroupMember g m mainProfile
CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h]
CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h]
CRGroupInvitation GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} ->
[groupInvitation' ldn fullName]
CRReceivedGroupInvitation g c role -> viewReceivedGroupInvitation g c role
CRUserJoinedGroup g _ -> [ttyGroup' g <> ": you joined the group"]
CRJoinedGroupMember g m -> [ttyGroup' g <> ": " <> ttyMember m <> " joined the group "]
CRJoinedGroupMemberConnecting g host m -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] CRJoinedGroupMemberConnecting g host m -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"]
CRConnectedToGroupMember g m -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] CRConnectedToGroupMember g m -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"]
CRDeletedMemberUser g by -> [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g CRDeletedMemberUser g by -> [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g
@ -156,7 +156,7 @@ responseToView testView = \case
CRGroupUpdated g g' m -> viewGroupUpdated g g' m CRGroupUpdated g g' m -> viewGroupUpdated g g' m
CRMemberSubError g m e -> [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] CRMemberSubError g m e -> [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e]
CRMemberSubSummary summary -> viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" CRMemberSubSummary summary -> viewErrorsSummary (filter (isJust . memberError) summary) " group member errors"
CRGroupSubscribed g -> [ttyFullGroup g <> ": connected to server(s)"] CRGroupSubscribed g -> viewGroupSubscribed g
CRPendingSubSummary _ -> [] CRPendingSubSummary _ -> []
CRSndFileSubError SndFileTransfer {fileId, fileName} e -> CRSndFileSubError SndFileTransfer {fileId, fileName} e ->
["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
@ -208,6 +208,12 @@ responseToView testView = \case
contactList :: [ContactRef] -> String contactList :: [ContactRef] -> String
contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs
viewGroupSubscribed :: GroupInfo -> [StyledString]
viewGroupSubscribed g@GroupInfo {membership} =
[incognito <> ttyFullGroup g <> ": connected to server(s)"]
where
incognito = if memberIncognito membership then incognitoPrefix else ""
showSMPServer :: SMPServer -> String showSMPServer :: SMPServer -> String
showSMPServer = B.unpack . strEncode . host showSMPServer = B.unpack . strEncode . host
@ -363,6 +369,12 @@ viewConnReqInvitation cReq =
"and ask them to connect: " <> highlight' "/c <invitation_link_above>" "and ask them to connect: " <> highlight' "/c <invitation_link_above>"
] ]
viewSentGroupInvitation :: GroupInfo -> Contact -> Maybe Profile -> [StyledString]
viewSentGroupInvitation g c sentCustomProfile =
if isJust sentCustomProfile
then ["invitation to join the group " <> ttyGroup' g <> " incognito sent to " <> ttyContact' c]
else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c]
viewChatCleared :: AChatInfo -> [StyledString] viewChatCleared :: AChatInfo -> [StyledString]
viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of
DirectChat ct -> [ttyContact' ct <> ": all messages are removed locally ONLY"] DirectChat ct -> [ttyContact' ct <> ": all messages are removed locally ONLY"]
@ -372,7 +384,8 @@ viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of
viewContactsList :: [Contact] -> [StyledString] viewContactsList :: [Contact] -> [StyledString]
viewContactsList = viewContactsList =
let ldn = T.toLower . (localDisplayName :: Contact -> ContactName) let ldn = T.toLower . (localDisplayName :: Contact -> ContactName)
in map ttyFullContact . sortOn ldn incognito ct = if contactConnIncognito ct then incognitoPrefix else ""
in map (\ct -> incognito ct <> ttyFullContact ct) . sortOn ldn
viewUserContactLinkDeleted :: [StyledString] viewUserContactLinkDeleted :: [StyledString]
viewUserContactLinkDeleted = viewUserContactLinkDeleted =
@ -396,6 +409,17 @@ autoAcceptStatus_ autoAccept autoReply =
("auto_accept " <> if autoAccept then "on" else "off") : ("auto_accept " <> if autoAccept then "on" else "off") :
maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply
viewSentInvitation :: Maybe Profile -> Bool -> [StyledString]
viewSentInvitation incognitoProfile testView =
case incognitoProfile of
Just profile ->
if testView
then incognitoProfile' profile : message
else message
where
message = ["connection request sent incognito!"]
Nothing -> ["connection request sent!"]
viewReceivedContactRequest :: ContactName -> Profile -> [StyledString] viewReceivedContactRequest :: ContactName -> Profile -> [StyledString]
viewReceivedContactRequest c Profile {fullName} = viewReceivedContactRequest c Profile {fullName} =
[ ttyFullName c fullName <> " wants to connect to you!", [ ttyFullName c fullName <> " wants to connect to you!",
@ -403,11 +427,22 @@ viewReceivedContactRequest c Profile {fullName} =
"to reject: " <> highlight ("/rc " <> c) <> " (the sender will NOT be notified)" "to reject: " <> highlight ("/rc " <> c) <> " (the sender will NOT be notified)"
] ]
viewGroupCreated :: GroupInfo -> [StyledString] viewGroupCreated :: GroupInfo -> Maybe Profile -> Bool -> [StyledString]
viewGroupCreated g@GroupInfo {localDisplayName} = viewGroupCreated g@GroupInfo {localDisplayName} incognitoProfile testView =
[ "group " <> ttyFullGroup g <> " is created", case incognitoProfile of
"use " <> highlight ("/a " <> localDisplayName <> " <name>") <> " to add members" Just profile ->
] if testView
then incognitoProfile' profile : message
else message
where
message =
[ "group " <> ttyFullGroup g <> " is created incognito, your profile for this group: " <> incognitoProfile' profile,
"use " <> highlight ("/a " <> localDisplayName <> " <name>") <> " to add members"
]
Nothing ->
[ "group " <> ttyFullGroup g <> " is created",
"use " <> highlight ("/a " <> localDisplayName <> " <name>") <> " to add members"
]
viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString] viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString]
viewCannotResendInvitation GroupInfo {localDisplayName = gn} c = viewCannotResendInvitation GroupInfo {localDisplayName = gn} c =
@ -415,11 +450,33 @@ viewCannotResendInvitation GroupInfo {localDisplayName = gn} c =
"to re-send invitation: " <> highlight ("/rm " <> gn <> " " <> c) <> ", " <> highlight ("/a " <> gn <> " " <> c) "to re-send invitation: " <> highlight ("/rm " <> gn <> " " <> c) <> ", " <> highlight ("/a " <> gn <> " " <> c)
] ]
viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString] viewUserJoinedGroup :: GroupInfo -> Bool -> Bool -> [StyledString]
viewReceivedGroupInvitation g c role = viewUserJoinedGroup g@GroupInfo {membership = GroupMember {memberProfile}} incognito testView =
[ ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role), if incognito
"use " <> highlight ("/j " <> groupName' g) <> " to accept" then
] if testView
then incognitoProfile' (fromLocalProfile memberProfile) : incognitoMessage
else incognitoMessage
else [ttyGroup' g <> ": you joined the group"]
where
incognitoMessage = [ttyGroup' g <> ": you joined the group incognito as " <> incognitoProfile' (fromLocalProfile memberProfile)]
viewJoinedGroupMember :: GroupInfo -> GroupMember -> Maybe Profile -> [StyledString]
viewJoinedGroupMember g m@GroupMember {localDisplayName} = \case
Just Profile {displayName = mainProfileName} -> [ttyGroup' g <> ": " <> ttyContact mainProfileName <> " joined the group incognito as " <> styleIncognito localDisplayName]
Nothing -> [ttyGroup' g <> ": " <> ttyMember m <> " joined the group "]
viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> Maybe Profile -> [StyledString]
viewReceivedGroupInvitation g c role hostIncognitoProfile =
case hostIncognitoProfile of
Just profile ->
[ ttyFullGroup g <> ": " <> ttyContact' c <> " (known to the group as " <> incognitoProfile' profile <> ") invites you to join the group incognito as " <> plain (strEncode role),
"use " <> highlight ("/j " <> groupName' g) <> " to join this group incognito"
]
Nothing ->
[ ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role),
"use " <> highlight ("/j " <> groupName' g) <> " to accept"
]
groupPreserved :: GroupInfo -> [StyledString] groupPreserved :: GroupInfo -> [StyledString]
groupPreserved g = ["use " <> highlight ("/d #" <> groupName' g) <> " to delete the group"] groupPreserved g = ["use " <> highlight ("/d #" <> groupName' g) <> " to delete the group"]
@ -434,7 +491,8 @@ viewGroupMembers :: Group -> [StyledString]
viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filter (not . removedOrLeft) $ membership : members viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filter (not . removedOrLeft) $ membership : members
where where
removedOrLeft m = let s = memberStatus m in s == GSMemRemoved || s == GSMemLeft removedOrLeft m = let s = memberStatus m in s == GSMemRemoved || s == GSMemLeft
groupMember m = ttyFullMember m <> ": " <> role m <> ", " <> category m <> status m groupMember m = incognito m <> ttyFullMember m <> ": " <> role m <> ", " <> category m <> status m
incognito m = if memberIncognito m then incognitoPrefix else ""
role m = plain . strEncode $ memberRole (m :: GroupMember) role m = plain . strEncode $ memberRole (m :: GroupMember)
category m = case memberCategory m of category m = case memberCategory m of
GCUserMember -> "you, " GCUserMember -> "you, "
@ -450,6 +508,21 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt
GSMemCreator -> "created group" GSMemCreator -> "created group"
_ -> "" _ -> ""
viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString]
viewContactConnected ct@Contact {localDisplayName} userIncognitoProfile testView =
case userIncognitoProfile of
Just profile ->
if testView
then incognitoProfile' profile : message
else message
where
message =
[ ttyFullContact ct <> ": contact is connected, your incognito profile for this contact is " <> incognitoProfile' profile,
"use " <> highlight ("/info " <> localDisplayName) <> " to print out this incognito profile again"
]
Nothing ->
[ttyFullContact ct <> ": contact is connected"]
viewGroupsList :: [GroupInfo] -> [StyledString] viewGroupsList :: [GroupInfo] -> [StyledString]
viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g <name>"] viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g <name>"]
viewGroupsList gs = map groupSS $ sortOn ldn_ gs viewGroupsList gs = map groupSS $ sortOn ldn_ gs
@ -457,9 +530,10 @@ viewGroupsList gs = map groupSS $ sortOn ldn_ gs
ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName) ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName)
groupSS GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership} = groupSS GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership} =
case memberStatus membership of case memberStatus membership of
GSMemInvited -> groupInvitation' ldn fullName GSMemInvited -> groupInvitation' ldn fullName $ memberIncognito membership
s -> ttyGroup ldn <> optFullName ldn fullName <> viewMemberStatus s s -> incognito <> ttyGroup ldn <> optFullName ldn fullName <> viewMemberStatus s
where where
incognito = if memberIncognito membership then incognitoPrefix else ""
viewMemberStatus = \case viewMemberStatus = \case
GSMemRemoved -> delete "you are removed" GSMemRemoved -> delete "you are removed"
GSMemLeft -> delete "you left" GSMemLeft -> delete "you left"
@ -467,15 +541,20 @@ viewGroupsList gs = map groupSS $ sortOn ldn_ gs
_ -> "" _ -> ""
delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> ldn) <> ")" delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> ldn) <> ")"
groupInvitation' :: GroupName -> Text -> StyledString groupInvitation' :: GroupName -> Text -> Bool -> StyledString
groupInvitation' displayName fullName = groupInvitation' displayName fullName membershipIncognito =
highlight ("#" <> displayName) highlight ("#" <> displayName)
<> optFullName displayName fullName <> optFullName displayName fullName
<> " - you are invited (" <> invitationText
<> highlight ("/j " <> displayName) <> highlight ("/j " <> displayName)
<> " to join, " <> " to join, "
<> highlight ("/d #" <> displayName) <> highlight ("/d #" <> displayName)
<> " to delete invitation)" <> " to delete invitation)"
where
invitationText =
if membershipIncognito
then " - you are invited incognito ("
else " - you are invited ("
viewContactsMerged :: Contact -> Contact -> [StyledString] viewContactsMerged :: Contact -> Contact -> [StyledString]
viewContactsMerged _into@Contact {localDisplayName = c1} _merged@Contact {localDisplayName = c2} = viewContactsMerged _into@Contact {localDisplayName = c1} _merged@Contact {localDisplayName = c2} =
@ -514,16 +593,24 @@ viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} =
"use `/network socks=<on/off/[ipv4]:port>[ timeout=<seconds>]` to change settings" "use `/network socks=<on/off/[ipv4]:port>[ timeout=<seconds>]` to change settings"
] ]
viewContactInfo :: Contact -> ConnectionStats -> [StyledString] viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString]
viewContactInfo Contact {contactId} stats = viewContactInfo Contact {contactId} stats incognitoProfile =
["contact ID: " <> sShow contactId] <> viewConnectionStats stats ["contact ID: " <> sShow contactId] <> viewConnectionStats stats
<> maybe
["you've shared main profile with this contact"]
(\p -> ["you've shared incognito profile with this contact: " <> incognitoProfile' p])
incognitoProfile
viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString] viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> Maybe Profile -> [StyledString]
viewGroupMemberInfo GroupInfo {groupId} GroupMember {groupMemberId} stats = viewGroupMemberInfo GroupInfo {groupId} GroupMember {groupMemberId} stats mainProfile =
[ "group ID: " <> sShow groupId, [ "group ID: " <> sShow groupId,
"member ID: " <> sShow groupMemberId "member ID: " <> sShow groupMemberId
] ]
<> maybe ["member not connected"] viewConnectionStats stats <> maybe ["member not connected"] viewConnectionStats stats
<> maybe
["unknown whether group member uses his main profile or incognito one for the group"]
(\Profile {displayName, fullName} -> ["member is using " <> styleIncognito' "incognito" <> " profile for the group, main profile known: " <> ttyFullName displayName fullName])
mainProfile
viewConnectionStats :: ConnectionStats -> [StyledString] viewConnectionStats :: ConnectionStats -> [StyledString]
viewConnectionStats ConnectionStats {rcvServers, sndServers} = viewConnectionStats ConnectionStats {rcvServers, sndServers} =
@ -559,8 +646,8 @@ viewGroupUpdated
viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated :: Contact -> Contact -> [StyledString]
viewContactUpdated viewContactUpdated
Contact {localDisplayName = n, profile = Profile {fullName}} Contact {localDisplayName = n, profile = LocalProfile {fullName}}
Contact {localDisplayName = n', profile = Profile {fullName = fullName'}} Contact {localDisplayName = n', profile = LocalProfile {fullName = fullName'}}
| n == n' && fullName == fullName' = [] | n == n' && fullName == fullName' = []
| n == n' = ["contact " <> ttyContact n <> fullNameUpdate] | n == n' = ["contact " <> ttyContact n <> fullNameUpdate]
| otherwise = | otherwise =
@ -817,6 +904,7 @@ viewChatError = \case
CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"] CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"]
CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"] CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"]
CEGroupUserRole -> ["you have insufficient permissions for this group command"] CEGroupUserRole -> ["you have insufficient permissions for this group command"]
CEGroupNotIncognitoCantInvite -> ["you're using main profile for this group - prohibited to invite contact to whom you are connected incognito"]
CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"] CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"]
CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> groupName' g)] CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> groupName' g)]
CEGroupMemberNotActive -> ["you cannot invite other members yet, try later"] CEGroupMemberNotActive -> ["you cannot invite other members yet, try later"]
@ -883,14 +971,14 @@ ttyContact' :: Contact -> StyledString
ttyContact' Contact {localDisplayName = c} = ttyContact c ttyContact' Contact {localDisplayName = c} = ttyContact c
ttyFullContact :: Contact -> StyledString ttyFullContact :: Contact -> StyledString
ttyFullContact Contact {localDisplayName, profile = Profile {fullName}} = ttyFullContact Contact {localDisplayName, profile = LocalProfile {fullName}} =
ttyFullName localDisplayName fullName ttyFullName localDisplayName fullName
ttyMember :: GroupMember -> StyledString ttyMember :: GroupMember -> StyledString
ttyMember GroupMember {localDisplayName} = ttyContact localDisplayName ttyMember GroupMember {localDisplayName} = ttyContact localDisplayName
ttyFullMember :: GroupMember -> StyledString ttyFullMember :: GroupMember -> StyledString
ttyFullMember GroupMember {localDisplayName, memberProfile = Profile {fullName}} = ttyFullMember GroupMember {localDisplayName, memberProfile = LocalProfile {fullName}} =
ttyFullName localDisplayName fullName ttyFullName localDisplayName fullName
ttyFullName :: ContactName -> Text -> StyledString ttyFullName :: ContactName -> Text -> StyledString
@ -909,7 +997,8 @@ ttyFromContactDeleted :: ContactName -> StyledString
ttyFromContactDeleted c = ttyFrom $ c <> "> [deleted] " ttyFromContactDeleted c = ttyFrom $ c <> "> [deleted] "
ttyToContact' :: Contact -> StyledString ttyToContact' :: Contact -> StyledString
ttyToContact' Contact {localDisplayName = c} = ttyToContact c ttyToContact' Contact {localDisplayName = c, activeConn = Connection {customUserProfileId}} =
maybe "" (const incognitoPrefix) customUserProfileId <> ttyToContact c
ttyQuotedContact :: Contact -> StyledString ttyQuotedContact :: Contact -> StyledString
ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ c <> ">" ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ c <> ">"
@ -919,7 +1008,8 @@ ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom c
ttyQuotedMember _ = "> " <> ttyFrom "?" ttyQuotedMember _ = "> " <> ttyFrom "?"
ttyFromContact' :: Contact -> StyledString ttyFromContact' :: Contact -> StyledString
ttyFromContact' Contact {localDisplayName = c} = ttyFromContact c ttyFromContact' Contact {localDisplayName = c, activeConn = Connection {customUserProfileId}} =
maybe "" (const incognitoPrefix) customUserProfileId <> ttyFromContact c
ttyGroup :: GroupName -> StyledString ttyGroup :: GroupName -> StyledString
ttyGroup g = styled (colored Blue) $ "#" <> g ttyGroup g = styled (colored Blue) $ "#" <> g
@ -949,10 +1039,12 @@ ttyFrom :: Text -> StyledString
ttyFrom = styled $ colored Yellow ttyFrom = styled $ colored Yellow
ttyFromGroup' :: GroupInfo -> GroupMember -> StyledString ttyFromGroup' :: GroupInfo -> GroupMember -> StyledString
ttyFromGroup' g GroupMember {localDisplayName = m} = ttyFromGroup g m ttyFromGroup' g@GroupInfo {membership} GroupMember {localDisplayName = m} =
(if memberIncognito membership then incognitoPrefix else "") <> ttyFromGroup g m
ttyToGroup :: GroupInfo -> StyledString ttyToGroup :: GroupInfo -> StyledString
ttyToGroup GroupInfo {localDisplayName = g} = styled (colored Cyan) $ "#" <> g <> " " ttyToGroup GroupInfo {localDisplayName = g, membership} =
(if memberIncognito membership then incognitoPrefix else "") <> styled (colored Cyan) ("#" <> g <> " ")
ttyFilePath :: FilePath -> StyledString ttyFilePath :: FilePath -> StyledString
ttyFilePath = plain ttyFilePath = plain
@ -960,12 +1052,24 @@ ttyFilePath = plain
optFullName :: ContactName -> Text -> StyledString optFullName :: ContactName -> Text -> StyledString
optFullName localDisplayName fullName = plain $ optionalFullName localDisplayName fullName optFullName localDisplayName fullName = plain $ optionalFullName localDisplayName fullName
incognitoPrefix :: StyledString
incognitoPrefix = styleIncognito' "i "
incognitoProfile' :: Profile -> StyledString
incognitoProfile' Profile {displayName} = styleIncognito displayName
highlight :: StyledFormat a => a -> StyledString highlight :: StyledFormat a => a -> StyledString
highlight = styled $ colored Cyan highlight = styled $ colored Cyan
highlight' :: String -> StyledString highlight' :: String -> StyledString
highlight' = highlight highlight' = highlight
styleIncognito :: StyledFormat a => a -> StyledString
styleIncognito = styled $ colored Magenta
styleIncognito' :: String -> StyledString
styleIncognito' = styleIncognito
styleTime :: String -> StyledString styleTime :: String -> StyledString
styleTime = Styled [SetColor Foreground Vivid Black] styleTime = Styled [SetColor Foreground Vivid Black]

View file

@ -19,7 +19,7 @@ import qualified Data.Text as T
import Simplex.Chat.Call import Simplex.Chat.Call
import Simplex.Chat.Controller (ChatController (..)) import Simplex.Chat.Controller (ChatController (..))
import Simplex.Chat.Options (ChatOpts (..)) import Simplex.Chat.Options (ChatOpts (..))
import Simplex.Chat.Types (ConnStatus (..), ImageData (..), Profile (..), User (..)) import Simplex.Chat.Types (ConnStatus (..), ImageData (..), LocalProfile (..), Profile (..), User (..))
import Simplex.Messaging.Util (unlessM) import Simplex.Messaging.Util (unlessM)
import System.Directory (copyFile, doesDirectoryExist, doesFileExist) import System.Directory (copyFile, doesDirectoryExist, doesFileExist)
import System.FilePath ((</>)) import System.FilePath ((</>))
@ -88,6 +88,13 @@ chatTests = do
it "reject contact and delete contact link" testRejectContactAndDeleteUserContact it "reject contact and delete contact link" testRejectContactAndDeleteUserContact
it "delete connection requests when contact link deleted" testDeleteConnectionRequests it "delete connection requests when contact link deleted" testDeleteConnectionRequests
it "auto-reply message" testAutoReplyMessage it "auto-reply message" testAutoReplyMessage
describe "incognito mode" $ do
it "connect incognito via invitation link" testConnectIncognitoInvitationLink
it "connect incognito via contact address" testConnectIncognitoContactAddress
it "accept contact request incognito" testAcceptContactRequestIncognito
it "create group incognito" testCreateGroupIncognito
it "join group incognito" testJoinGroupIncognito
it "can't invite contact to whom user connected incognito to non incognito group" testCantInviteIncognitoConnectionNonIncognitoGroup
describe "SMP servers" $ describe "SMP servers" $
it "get and set SMP servers" testGetSetSMPServers it "get and set SMP servers" testGetSetSMPServers
describe "async connection handshake" $ do describe "async connection handshake" $ do
@ -2045,6 +2052,394 @@ testAutoReplyMessage = testChat2 aliceProfile bobProfile $
alice <# "@bob hello!" alice <# "@bob hello!"
] ]
testConnectIncognitoInvitationLink :: IO ()
testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
alice #$> ("/incognito on", id, "ok")
bob #$> ("/incognito on", id, "ok")
alice ##> "/c"
inv <- getInvitation alice
bob ##> ("/c " <> inv)
bob <## "confirmation sent!"
bobIncognito <- getTermLine bob
aliceIncognito <- getTermLine alice
concurrentlyN_
[ do
bob <## (aliceIncognito <> ": contact is connected, your incognito profile for this contact is " <> bobIncognito)
bob <## ("use /info " <> aliceIncognito <> " to print out this incognito profile again"),
do
alice <## (bobIncognito <> ": contact is connected, your incognito profile for this contact is " <> aliceIncognito)
alice <## ("use /info " <> bobIncognito <> " to print out this incognito profile again")
]
-- after turning incognito mode off conversation is incognito
alice #$> ("/incognito off", id, "ok")
bob #$> ("/incognito off", id, "ok")
alice ?#> ("@" <> bobIncognito <> " psst, I'm incognito")
bob ?<# (aliceIncognito <> "> psst, I'm incognito")
bob ?#> ("@" <> aliceIncognito <> " <whispering> me too")
alice ?<# (bobIncognito <> "> <whispering> me too")
-- new contact is connected non incognito
connectUsers alice cath
alice <##> cath
-- bob is not notified on profile change
alice ##> "/p alice"
concurrentlyN_
[ alice <## "user full name removed (your contacts are notified)",
cath <## "contact alice removed full name"
]
alice ?#> ("@" <> bobIncognito <> " do you see that I've changed profile?")
bob ?<# (aliceIncognito <> "> do you see that I've changed profile?")
bob ?#> ("@" <> aliceIncognito <> " no")
alice ?<# (bobIncognito <> "> no")
testConnectIncognitoContactAddress :: IO ()
testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $
\alice bob -> do
alice ##> "/ad"
cLink <- getContactLink alice True
bob #$> ("/incognito on", id, "ok")
bob ##> ("/c " <> cLink)
bobIncognito <- getTermLine bob
bob <## "connection request sent incognito!"
alice <## (bobIncognito <> " wants to connect to you!")
alice <## ("to accept: /ac " <> bobIncognito)
alice <## ("to reject: /rc " <> bobIncognito <> " (the sender will NOT be notified)")
alice ##> ("/ac " <> bobIncognito)
alice <## (bobIncognito <> ": accepting contact request...")
_ <- getTermLine bob
concurrentlyN_
[ do
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
bob <## "use /info alice to print out this incognito profile again",
alice <## (bobIncognito <> ": contact is connected")
]
-- after turning incognito mode off conversation is incognito
alice #$> ("/incognito off", id, "ok")
bob #$> ("/incognito off", id, "ok")
alice #> ("@" <> bobIncognito <> " who are you?")
bob ?<# "alice> who are you?"
bob ?#> "@alice I'm Batman"
alice <# (bobIncognito <> "> I'm Batman")
testAcceptContactRequestIncognito :: IO ()
testAcceptContactRequestIncognito = testChat2 aliceProfile bobProfile $
\alice bob -> do
alice ##> "/ad"
cLink <- getContactLink alice True
bob ##> ("/c " <> cLink)
alice <#? bob
alice #$> ("/incognito on", id, "ok")
alice ##> "/ac bob"
alice <## "bob (Bob): accepting contact request..."
aliceIncognito <- getTermLine alice
concurrentlyN_
[ bob <## (aliceIncognito <> ": contact is connected"),
do
alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito)
alice <## "use /info bob to print out this incognito profile again"
]
-- after turning incognito mode off conversation is incognito
alice #$> ("/incognito off", id, "ok")
bob #$> ("/incognito off", id, "ok")
alice ?#> "@bob my profile is totally inconspicuous"
bob <# (aliceIncognito <> "> my profile is totally inconspicuous")
bob #> ("@" <> aliceIncognito <> " I know!")
alice ?<# "bob> I know!"
testCreateGroupIncognito :: IO ()
testCreateGroupIncognito = testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
-- non incognito connections
connectUsers alice cath
connectUsers bob cath
-- bob connected incognito to alice
alice ##> "/c"
inv <- getInvitation alice
bob #$> ("/incognito on", id, "ok")
bob ##> ("/c " <> inv)
bob <## "confirmation sent!"
bobIncognito <- getTermLine bob
concurrentlyN_
[ do
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
bob <## "use /info alice to print out this incognito profile again",
alice <## (bobIncognito <> ": contact is connected")
]
-- alice creates group incognito
alice #$> ("/incognito on", id, "ok")
alice ##> "/g secret_club"
aliceMemIncognito <- getTermLine alice
alice <## ("group #secret_club is created incognito, your profile for this group: " <> aliceMemIncognito)
alice <## "use /a secret_club <name> to add members"
alice ##> ("/a secret_club " <> bobIncognito)
concurrentlyN_
[ alice <## ("invitation to join the group #secret_club incognito sent to " <> bobIncognito),
do
bob <## ("#secret_club: alice (known to the group as " <> aliceMemIncognito <> ") invites you to join the group incognito as admin")
bob <## "use /j secret_club to join this group incognito"
]
-- bob uses different profile when joining group
bob ##> "/j secret_club"
bobMemIncognito <- getTermLine bob
concurrently_
(alice <## ("#secret_club: " <> bobIncognito <> " joined the group incognito as " <> bobMemIncognito))
(bob <## ("#secret_club: you joined the group incognito as " <> bobMemIncognito))
-- cath is invited incognito
alice ##> "/a secret_club cath"
concurrentlyN_
[ alice <## "invitation to join the group #secret_club incognito sent to cath",
do
cath <## ("#secret_club: alice (known to the group as " <> aliceMemIncognito <> ") invites you to join the group incognito as admin")
cath <## "use /j secret_club to join this group incognito"
]
cath ##> "/j secret_club"
cathMemIncognito <- getTermLine cath
-- bob and cath don't merge contacts
concurrentlyN_
[ alice <## ("#secret_club: cath joined the group incognito as " <> cathMemIncognito),
do
cath <## ("#secret_club: you joined the group incognito as " <> cathMemIncognito)
cath <## ("#secret_club: member " <> bobMemIncognito <> " is connected"),
do
bob <## ("#secret_club: " <> aliceMemIncognito <> " added " <> cathMemIncognito <> " to the group (connecting...)")
bob <## ("#secret_club: new member " <> cathMemIncognito <> " is connected")
]
-- send messages - group is incognito for everybody
alice #$> ("/incognito off", id, "ok")
bob #$> ("/incognito off", id, "ok")
cath #$> ("/incognito off", id, "ok")
alice ?#> "#secret_club hello"
concurrently_
(bob ?<# ("#secret_club " <> aliceMemIncognito <> "> hello"))
(cath ?<# ("#secret_club " <> aliceMemIncognito <> "> hello"))
bob ?#> "#secret_club hi there"
concurrently_
(alice ?<# ("#secret_club " <> bobMemIncognito <> "> hi there"))
(cath ?<# ("#secret_club " <> bobMemIncognito <> "> hi there"))
cath ?#> "#secret_club hey"
concurrently_
(alice ?<# ("#secret_club " <> cathMemIncognito <> "> hey"))
(bob ?<# ("#secret_club " <> cathMemIncognito <> "> hey"))
-- bob and cath can send messages via direct incognito connections
bob ?#> ("@" <> cathMemIncognito <> " hi, I'm bob")
cath ?<# (bobMemIncognito <> "> hi, I'm bob")
cath ?#> ("@" <> bobMemIncognito <> " hey, I'm cath")
bob ?<# (cathMemIncognito <> "> hey, I'm cath")
-- non incognito connections are separate
bob <##> cath
-- list groups
alice ##> "/gs"
alice <## "i #secret_club"
-- list group members
alice ##> "/ms secret_club"
alice
<### [ "i " <> aliceMemIncognito <> ": owner, you, created group",
"i " <> bobMemIncognito <> ": admin, invited, connected",
"i " <> cathMemIncognito <> ": admin, invited, connected"
]
-- remove member
bob ##> ("/rm secret_club " <> cathMemIncognito)
concurrentlyN_
[ bob <## ("#secret_club: you removed " <> cathMemIncognito <> " from the group"),
alice <## ("#secret_club: " <> bobMemIncognito <> " removed " <> cathMemIncognito <> " from the group"),
do
cath <## ("#secret_club: " <> bobMemIncognito <> " removed you from the group")
cath <## "use /d #secret_club to delete the group"
]
bob ?#> "#secret_club hi"
concurrently_
(alice ?<# ("#secret_club " <> bobMemIncognito <> "> hi"))
(cath </)
alice ?#> "#secret_club hello"
concurrently_
(bob ?<# ("#secret_club " <> aliceMemIncognito <> "> hello"))
(cath </)
cath ##> "#secret_club hello"
cath <## "you are no longer a member of the group"
bob ?#> ("@" <> cathMemIncognito <> " I removed you from group")
cath ?<# (bobMemIncognito <> "> I removed you from group")
cath ?#> ("@" <> bobMemIncognito <> " ok")
bob ?<# (cathMemIncognito <> "> ok")
testJoinGroupIncognito :: IO ()
testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfile $
\alice bob cath dan -> do
-- non incognito connections
connectUsers alice cath
connectUsers bob cath
connectUsers cath dan
-- bob connected incognito to alice
alice ##> "/c"
inv <- getInvitation alice
bob #$> ("/incognito on", id, "ok")
bob ##> ("/c " <> inv)
bob <## "confirmation sent!"
bobIncognito <- getTermLine bob
concurrentlyN_
[ do
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
bob <## "use /info alice to print out this incognito profile again",
alice <## (bobIncognito <> ": contact is connected")
]
-- alice creates group non incognito
alice ##> "/g club"
alice <## "group #club is created"
alice <## "use /a club <name> to add members"
alice ##> ("/a club " <> bobIncognito)
concurrentlyN_
[ alice <## ("invitation to join the group #club sent to " <> bobIncognito),
do
bob <## "#club: alice invites you to join the group as admin"
bob <## "use /j club to accept"
]
-- since bob is connected incognito to host, he uses different profile when joining group even though he turned incognito mode off
bob #$> ("/incognito off", id, "ok")
bob ##> "/j club"
bobMemIncognito <- getTermLine bob
concurrently_
(alice <## ("#club: " <> bobIncognito <> " joined the group incognito as " <> bobMemIncognito))
(bob <## ("#club: you joined the group incognito as " <> bobMemIncognito))
-- cath joins incognito
alice ##> "/a club cath"
concurrentlyN_
[ alice <## "invitation to join the group #club sent to cath",
do
cath <## "#club: alice invites you to join the group as admin"
cath <## "use /j club to accept"
]
cath #$> ("/incognito on", id, "ok")
cath ##> "/j club"
cathMemIncognito <- getTermLine cath
-- bob and cath don't merge contacts
concurrentlyN_
[ alice <## ("#club: cath joined the group incognito as " <> cathMemIncognito),
do
cath <## ("#club: you joined the group incognito as " <> cathMemIncognito)
cath <## ("#club: member " <> bobMemIncognito <> " is connected"),
do
bob <## ("#club: alice added " <> cathMemIncognito <> " to the group (connecting...)")
bob <## ("#club: new member " <> cathMemIncognito <> " is connected")
]
-- cath invites dan incognito
cath ##> "/a club dan"
concurrentlyN_
[ cath <## "invitation to join the group #club incognito sent to dan",
do
dan <## ("#club: cath (known to the group as " <> cathMemIncognito <> ") invites you to join the group incognito as admin")
dan <## "use /j club to join this group incognito"
]
dan ##> "/j club"
danMemIncognito <- getTermLine dan
concurrentlyN_
[ cath <## ("#club: dan joined the group incognito as " <> danMemIncognito),
do
dan <## ("#club: you joined the group incognito as " <> danMemIncognito)
dan
<### [ "#club: member alice (Alice) is connected",
"#club: member " <> bobMemIncognito <> " is connected"
],
do
alice <## ("#club: " <> cathMemIncognito <> " added " <> danMemIncognito <> " to the group (connecting...)")
alice <## ("#club: new member " <> danMemIncognito <> " is connected"),
do
bob <## ("#club: " <> cathMemIncognito <> " added " <> danMemIncognito <> " to the group (connecting...)")
bob <## ("#club: new member " <> danMemIncognito <> " is connected")
]
-- send messages - group is incognito for cath and dan
alice #$> ("/incognito off", id, "ok")
bob #$> ("/incognito off", id, "ok")
cath #$> ("/incognito off", id, "ok")
dan #$> ("/incognito off", id, "ok")
alice #> "#club hello"
concurrentlyN_
[ bob ?<# "#club alice> hello",
cath ?<# "#club alice> hello",
dan ?<# "#club alice> hello"
]
bob ?#> "#club hi there"
concurrentlyN_
[ alice <# ("#club " <> bobMemIncognito <> "> hi there"),
cath ?<# ("#club " <> bobMemIncognito <> "> hi there"),
dan ?<# ("#club " <> bobMemIncognito <> "> hi there")
]
cath ?#> "#club hey"
concurrentlyN_
[ alice <# ("#club " <> cathMemIncognito <> "> hey"),
bob ?<# ("#club " <> cathMemIncognito <> "> hey"),
dan ?<# ("#club " <> cathMemIncognito <> "> hey")
]
dan ?#> "#club how is it going?"
concurrentlyN_
[ alice <# ("#club " <> danMemIncognito <> "> how is it going?"),
bob ?<# ("#club " <> danMemIncognito <> "> how is it going?"),
cath ?<# ("#club " <> danMemIncognito <> "> how is it going?")
]
-- bob and cath can send messages via direct incognito connections
bob ?#> ("@" <> cathMemIncognito <> " hi, I'm bob")
cath ?<# (bobMemIncognito <> "> hi, I'm bob")
cath ?#> ("@" <> bobMemIncognito <> " hey, I'm cath")
bob ?<# (cathMemIncognito <> "> hey, I'm cath")
-- non incognito connections are separate
bob <##> cath
-- bob and dan can send messages via direct incognito connections
bob ?#> ("@" <> danMemIncognito <> " hi, I'm bob")
dan ?<# (bobMemIncognito <> "> hi, I'm bob")
dan ?#> ("@" <> bobMemIncognito <> " hey, I'm dan")
bob ?<# (danMemIncognito <> "> hey, I'm dan")
-- list group members
alice ##> "/ms club"
alice
<### [ "alice (Alice): owner, you, created group",
"i " <> bobMemIncognito <> ": admin, invited, connected",
"i " <> cathMemIncognito <> ": admin, invited, connected",
danMemIncognito <> ": admin, connected"
]
bob ##> "/ms club"
bob
<### [ "alice (Alice): owner, host, connected",
"i " <> bobMemIncognito <> ": admin, you, connected",
cathMemIncognito <> ": admin, connected",
danMemIncognito <> ": admin, connected"
]
cath ##> "/ms club"
cath
<### [ "alice (Alice): owner, host, connected",
bobMemIncognito <> ": admin, connected",
"i " <> cathMemIncognito <> ": admin, you, connected",
"i " <> danMemIncognito <> ": admin, invited, connected"
]
dan ##> "/ms club"
dan
<### [ "alice (Alice): owner, connected",
bobMemIncognito <> ": admin, connected",
"i " <> cathMemIncognito <> ": admin, host, connected",
"i " <> danMemIncognito <> ": admin, you, connected"
]
testCantInviteIncognitoConnectionNonIncognitoGroup :: IO ()
testCantInviteIncognitoConnectionNonIncognitoGroup = testChat2 aliceProfile bobProfile $
\alice bob -> do
-- alice connected incognito to bob
alice #$> ("/incognito on", id, "ok")
alice ##> "/c"
inv <- getInvitation alice
bob ##> ("/c " <> inv)
bob <## "confirmation sent!"
aliceIncognito <- getTermLine alice
concurrentlyN_
[ bob <## (aliceIncognito <> ": contact is connected"),
do
alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito)
alice <## "use /info bob to print out this incognito profile again"
]
-- alice creates group non incognito
alice #$> ("/incognito off", id, "ok")
alice ##> "/g club"
alice <## "group #club is created"
alice <## "use /a club <name> to add members"
alice ##> "/a club bob"
alice <## "you're using main profile for this group - prohibited to invite contact to whom you are connected incognito"
testGetSetSMPServers :: IO () testGetSetSMPServers :: IO ()
testGetSetSMPServers = testGetSetSMPServers =
testChat2 aliceProfile bobProfile $ testChat2 aliceProfile bobProfile $
@ -2513,7 +2908,7 @@ connectUsers cc1 cc2 = do
showName :: TestCC -> IO String showName :: TestCC -> IO String
showName (TestCC ChatController {currentUser} _ _ _ _) = do showName (TestCC ChatController {currentUser} _ _ _ _) = do
Just User {localDisplayName, profile = Profile {fullName}} <- readTVarIO currentUser Just User {localDisplayName, profile = LocalProfile {fullName}} <- readTVarIO currentUser
pure . T.unpack $ localDisplayName <> " (" <> fullName <> ")" pure . T.unpack $ localDisplayName <> " (" <> fullName <> ")"
createGroup2 :: String -> TestCC -> TestCC -> IO () createGroup2 :: String -> TestCC -> TestCC -> IO ()
@ -2580,6 +2975,11 @@ cc #> cmd = do
cc `send` cmd cc `send` cmd
cc <# cmd cc <# cmd
(?#>) :: TestCC -> String -> IO ()
cc ?#> cmd = do
cc `send` cmd
cc <# ("i " <> cmd)
(#$>) :: (Eq a, Show a) => TestCC -> (String, String -> a, a) -> Expectation (#$>) :: (Eq a, Show a) => TestCC -> (String, String -> a, a) -> Expectation
cc #$> (cmd, f, res) = do cc #$> (cmd, f, res) = do
cc ##> cmd cc ##> cmd
@ -2632,6 +3032,9 @@ getInAnyOrder f cc ls = do
(<#) :: TestCC -> String -> Expectation (<#) :: TestCC -> String -> Expectation
cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line
(?<#) :: TestCC -> String -> Expectation
cc ?<# line = (dropTime <$> getTermLine cc) `shouldReturn` "i " <> line
(</) :: TestCC -> Expectation (</) :: TestCC -> Expectation
(</) = (<// 500000) (</) = (<// 500000)

View file

@ -31,9 +31,9 @@ activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\"
activeUser :: String activeUser :: String
#if defined(darwin_HOST_OS) && defined(swiftJSON) #if defined(darwin_HOST_OS) && defined(swiftJSON)
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}}" activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}}"
#else #else
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}" activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}"
#endif #endif
chatStarted :: String chatStarted :: String

View file

@ -187,12 +187,18 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
it "x.contact with content (ignored)" $ it "x.contact with content (ignored)" $
"{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" "{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
==# XContact testProfile Nothing ==# XContact testProfile Nothing
it "x.grp.inv" $ it "x.grp.inv with incognito profile" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"},\"fromMemberProfile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberProfile = Just testProfile, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}
it "x.grp.inv without incognito profile" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile} #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberProfile = Nothing, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}
it "x.grp.acpt" $ it "x.grp.acpt with incognito profile" $
"{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\", \"memberProfile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XGrpAcpt (MemberId "\1\2\3\4") (Just testProfile)
it "x.grp.acpt without incognito profile" $
"{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
#==# XGrpAcpt (MemberId "\1\2\3\4") #==# XGrpAcpt (MemberId "\1\2\3\4") Nothing
it "x.grp.mem.new" $ it "x.grp.mem.new" $
"{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}" "{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile}