From 89de5497eff8dac2320cf9c48a94db1306b92dff Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 4 Nov 2022 17:05:21 +0000 Subject: [PATCH] core: update chat preferences (#1292) * core: update chat preferences * refactor, types * rename types * rename types * make voice on by default * create new user with empty preferences * fix test --- apps/simplex-chat/Main.hs | 4 +- src/Simplex/Chat.hs | 25 +-- src/Simplex/Chat/Controller.hs | 6 +- src/Simplex/Chat/Store.hs | 36 ++-- src/Simplex/Chat/Terminal/Input.hs | 3 +- src/Simplex/Chat/Terminal/Output.hs | 6 +- src/Simplex/Chat/Types.hs | 287 ++++++++++++++++++++++++---- src/Simplex/Chat/View.hs | 95 +++++++-- tests/ChatTests.hs | 114 +++++++---- tests/ProtocolTests.hs | 33 ++-- 10 files changed, 455 insertions(+), 154 deletions(-) diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 0a025311cb..4bdfa30137 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -25,9 +25,9 @@ main = do welcome opts t <- withTerminal pure simplexChatTerminal terminalChatConfig opts t - else simplexChatCore terminalChatConfig opts Nothing $ \_ cc -> do + else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do r <- sendChatCmd cc chatCmd - putStrLn $ serializeChatResponse r + putStrLn $ serializeChatResponse (Just user) r threadDelay $ chatCmdDelay opts * 1000000 welcome :: ChatOpts -> IO () diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 30eff3d412..69b3d8cbac 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1161,9 +1161,9 @@ processChatCommand = \case let mergedProfile = userProfileToSend user' Nothing $ Just ct void (sendDirectContactMessage ct $ XInfo mergedProfile) `catchError` (toView . CRChatError) pure $ CRUserProfileUpdated (fromLocalProfile p) p' - updateContactPrefs :: User -> Contact -> ChatPreferences -> m ChatResponse + updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse updateContactPrefs user@User {userId} ct@Contact {contactId, activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' - | contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct -- nothing changed actually + | contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct ct $ contactUserPreferences user ct -- nothing changed actually | otherwise = do withStore' $ \db -> updateContactUserPreferences db userId contactId contactUserPrefs' -- [incognito] filter out contacts with whom user has incognito connections @@ -1172,7 +1172,7 @@ processChatCommand = \case let p' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') withChatLock "updateProfile" . procCmd $ do void (sendDirectContactMessage ct' $ XInfo p') `catchError` (toView . CRChatError) - pure $ CRContactPrefsUpdated ct' + pure $ CRContactPrefsUpdated ct ct' $ contactUserPreferences user ct' isReady :: Contact -> Bool isReady ct = @@ -2465,7 +2465,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = xInfo :: Contact -> Profile -> m () xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do c' <- withStore $ \db -> updateContactProfile db userId c p' - toView $ CRContactUpdated c c' + toView $ CRContactUpdated c c' $ contactUserPreferences user c' xInfoProbe :: Contact -> Probe -> m () xInfoProbe c2 probe = @@ -3023,15 +3023,10 @@ deleteAgentConnectionAsync' user connId (AgentConnId acId) = do withAgent $ \a -> deleteConnectionAsync a (aCorrId cmdId) acId userProfileToSend :: User -> Maybe Profile -> Maybe Contact -> Profile -userProfileToSend user@User {profile} incognitoProfile ct = - let p = fromMaybe (fromLocalProfile profile) incognitoProfile - preferences = Just . mergeChatPreferences user $ userPreferences <$> ct - in (p :: Profile) {preferences} - -mergeChatPreferences :: User -> Maybe ChatPreferences -> ChatPreferences -mergeChatPreferences User {profile = LocalProfile {preferences}} contactPrefs = - let ChatPreferences {voice = defaultVoice} = defaultChatPrefs - in ChatPreferences {voice = (contactPrefs >>= voice) <|> (preferences >>= voice) <|> defaultVoice} +userProfileToSend user@User {profile = p} incognitoProfile ct = + let p' = fromMaybe (fromLocalProfile p) incognitoProfile + userPrefs = maybe (preferences' user) (const Nothing) incognitoProfile + in (p' :: Profile) {preferences = Just . toChatPrefs $ mergePreferences (userPreferences <$> ct) userPrefs} getCreateActiveUser :: SQLiteStore -> IO User getCreateActiveUser st = do @@ -3054,7 +3049,7 @@ getCreateActiveUser st = do loop = do displayName <- getContactName fullName <- T.pack <$> getWithPrompt "full name (optional)" - withTransaction st (\db -> runExceptT $ createUser db Profile {displayName, fullName, image = Nothing, preferences = Just defaultChatPrefs} True) >>= \case + withTransaction st (\db -> runExceptT $ createUser db Profile {displayName, fullName, image = Nothing, preferences = Nothing} True) >>= \case Left SEDuplicateName -> do putStrLn "chosen display name is already used by another profile on this device, choose another one" loop @@ -3311,7 +3306,7 @@ chatCommandP = groupProfile = do gName <- displayName fullName <- fullNameP gName - pure GroupProfile {displayName = gName, fullName, image = Nothing, preferences = Nothing} + pure GroupProfile {displayName = gName, fullName, image = Nothing, groupPreferences = Nothing} fullNameP name = do n <- (A.space *> A.takeByteString) <|> pure "" pure $ if B.null n then name else safeDecodeUtf8 n diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 27820bf905..4cbfe3757c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -166,7 +166,7 @@ data ChatCommand | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus | APIUpdateProfile Profile - | APISetContactPrefs Int64 ChatPreferences + | APISetContactPrefs Int64 Preferences | APISetContactAlias ContactId LocalAlias | APISetConnectionAlias Int64 LocalAlias | APIParseMarkdown Text @@ -296,7 +296,7 @@ data ChatResponse | CRInvitation {connReqInvitation :: ConnReqInvitation} | CRSentConfirmation | CRSentInvitation {customUserProfile :: Maybe Profile} - | CRContactUpdated {fromContact :: Contact, toContact :: Contact} + | CRContactUpdated {fromContact :: Contact, toContact :: Contact, preferences :: ContactUserPreferences} | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} | CRContactDeleted {contact :: Contact} | CRChatCleared {chatInfo :: AChatInfo} @@ -322,7 +322,7 @@ data ChatResponse | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} | CRContactAliasUpdated {toContact :: Contact} | CRConnectionAliasUpdated {toConnection :: PendingContactConnection} - | CRContactPrefsUpdated {toContact :: Contact} + | CRContactPrefsUpdated {fromContact :: Contact, toContact :: Contact, preferences :: ContactUserPreferences} | CRContactConnecting {contact :: Contact} | CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile} | CRContactAnotherClient {contact :: Contact} diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 7338794870..fd20689a49 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -409,7 +409,7 @@ getUsers db = JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id |] -toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe ChatPreferences) -> User +toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) = let profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""} in User {userId, userContactId, localDisplayName = displayName, profile, activeUser} @@ -508,7 +508,7 @@ getProfileById db userId profileId = |] (userId, profileId) where - toProfile :: (ContactName, Text, Maybe ImageData, LocalAlias, Maybe ChatPreferences) -> LocalProfile + toProfile :: (ContactName, Text, Maybe ImageData, LocalAlias, Maybe Preferences) -> LocalProfile toProfile (displayName, fullName, image, localAlias, preferences) = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias} createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> IO Connection @@ -641,7 +641,7 @@ updateContactProfile db userId c@Contact {contactId, localDisplayName, profile = updateContact_ db userId contactId localDisplayName ldn currentTs pure . Right $ (c :: Contact) {localDisplayName = ldn, profile = toLocalProfile profileId p' localAlias} -updateContactUserPreferences :: DB.Connection -> UserId -> Int64 -> ChatPreferences -> IO () +updateContactUserPreferences :: DB.Connection -> UserId -> Int64 -> Preferences -> IO () updateContactUserPreferences db userId contactId userPreferences = do updatedAt <- getCurrentTime DB.execute @@ -718,7 +718,7 @@ updateContact_ db userId contactId displayName newName updatedAt = do (newName, updatedAt, userId, contactId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId) -type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, LocalAlias, Bool, Maybe Bool) :. (Maybe ChatPreferences, ChatPreferences, UTCTime, UTCTime) +type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, LocalAlias, Bool, Maybe Bool) :. (Maybe Preferences, Preferences, UTCTime, UTCTime) toContact :: ContactRow :. ConnectionRow -> Contact toContact (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) = @@ -1098,7 +1098,7 @@ getContactRequest db userId contactRequestId = |] (userId, contactRequestId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData) :. (Maybe XContactId, Maybe ChatPreferences, UTCTime, UTCTime) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData) :. (Maybe XContactId, Maybe Preferences, UTCTime, UTCTime) toContactRequest :: ContactRequestRow -> UserContactRequest toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image) :. (xContactId, preferences, createdAt, updatedAt)) = do @@ -1437,7 +1437,7 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do WHERE c.user_id = ? AND c.contact_id = ? |] (userId, contactId) - toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, LocalAlias, Maybe Int64, Bool, Maybe Bool) :. (Maybe ChatPreferences, ChatPreferences, UTCTime, UTCTime)] -> Either StoreError Contact + toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, LocalAlias, Maybe Int64, Bool, Maybe Bool) :. (Maybe Preferences, Preferences, UTCTime, UTCTime)] -> Either StoreError Contact toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, localAlias, viaGroup, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)] = let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} @@ -1592,14 +1592,14 @@ updateConnectionStatus db Connection {connId} connStatus = do -- | creates completely new group with a single member - the current user createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do - let GroupProfile {displayName, fullName, image, preferences} = groupProfile + let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile currentTs <- getCurrentTime withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do groupId <- liftIO $ do DB.execute db "INSERT INTO group_profiles (display_name, full_name, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (displayName, fullName, image, userId, preferences, currentTs, currentTs) + (displayName, fullName, image, userId, groupPreferences, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -1635,7 +1635,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation_ = do - let GroupProfile {displayName, fullName, image, preferences} = groupProfile + let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile ExceptT $ withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do currentTs <- liftIO getCurrentTime @@ -1643,7 +1643,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo DB.execute db "INSERT INTO group_profiles (display_name, full_name, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (displayName, fullName, image, userId, preferences, currentTs, currentTs) + (displayName, fullName, image, userId, groupPreferences, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -1786,13 +1786,13 @@ getGroupInfoByName db user gName = do gId <- getGroupIdByName db user gName getGroupInfo db user gId -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, Maybe ProfileId, Maybe Bool, Maybe ChatPreferences, UTCTime, UTCTime) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, Maybe ProfileId, Maybe Bool, Maybe GroupPreferences, UTCTime, UTCTime) :. GroupMemberRow toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo -toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, hostConnCustomUserProfileId, enableNtfs_, preferences, createdAt, updatedAt) :. userMemberRow) = +toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, hostConnCustomUserProfileId, enableNtfs_, groupPreferences, createdAt, updatedAt) :. userMemberRow) = let membership = toGroupMember userContactId userMemberRow chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} - in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image, preferences}, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt} + in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image, groupPreferences}, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt} getGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember getGroupMember db user@User {userId} groupId groupMemberId = @@ -1884,9 +1884,9 @@ getGroupInvitation db user groupId = firstRow fromOnly (SEGroupNotFound groupId) $ DB.query db "SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ?" (groupId, userId) -type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, LocalAlias, Maybe ChatPreferences)) +type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, LocalAlias, Maybe Preferences)) -type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe LocalAlias, Maybe ChatPreferences)) +type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe LocalAlias, Maybe Preferences)) toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, localAlias, preferences)) = @@ -2329,7 +2329,7 @@ getViaGroupContact db User {userId} GroupMember {groupMemberId} = |] (userId, groupMemberId) where - toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, LocalAlias, Maybe Int64, Bool, Maybe Bool) :. (Maybe ChatPreferences, ChatPreferences, UTCTime, UTCTime)) :. ConnectionRow -> Contact + toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, LocalAlias, Maybe Int64, Bool, Maybe Bool) :. (Maybe Preferences, Preferences, UTCTime, UTCTime)) :. ConnectionRow -> Contact toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, localAlias, viaGroup, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} @@ -3636,7 +3636,7 @@ getGroupInfo db User {userId, userContactId} groupId = (groupId, userId, userContactId) updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo -updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, preferences}} p'@GroupProfile {displayName = newName, fullName, image} +updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, groupPreferences}} p'@GroupProfile {displayName = newName, fullName, image} | displayName == newName = liftIO $ do currentTs <- getCurrentTime updateGroupProfile_ currentTs $> (g :: GroupInfo) {groupProfile = p'} @@ -3659,7 +3659,7 @@ updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, grou WHERE user_id = ? AND group_id = ? ) |] - (newName, fullName, image, preferences, currentTs, userId, groupId) + (newName, fullName, image, groupPreferences, currentTs, userId, groupId) updateGroup_ ldn currentTs = do DB.execute db diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index d0daad9e45..fa167d2784 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -40,7 +40,8 @@ runInputLoop ct cc = forever $ do CRChatCmdError _ -> when (isMessage cmd) $ echo s _ -> pure () let testV = testView $ config cc - printToTerminal ct $ responseToView testV r + user <- readTVarIO $ currentUser cc + printToTerminal ct $ responseToView user testV r where echo s = printToTerminal ct [plain s] isMessage = \case diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index d793266af3..522b9bb486 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -75,8 +75,10 @@ withTermLock ChatTerminal {termLock} action = do runTerminalOutput :: ChatTerminal -> ChatController -> IO () runTerminalOutput ct cc = do let testV = testView $ config cc - forever $ - atomically (readTBQueue $ outputQ cc) >>= printToTerminal ct . responseToView testV . snd + forever $ do + (_, r) <- atomically . readTBQueue $ outputQ cc + user <- readTVarIO $ currentUser cc + printToTerminal ct $ responseToView user testV r printToTerminal :: ChatTerminal -> [StyledString] -> IO () printToTerminal ct s = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 1fa06deb27..dbd49e8f1f 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -19,6 +19,7 @@ module Simplex.Chat.Types where +import Control.Applicative ((<|>)) import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE @@ -28,7 +29,7 @@ import Data.ByteString.Char8 (ByteString, pack, unpack) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) -import Data.Maybe (isJust) +import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -85,7 +86,7 @@ data Contact = Contact viaGroup :: Maybe Int64, contactUsed :: Bool, chatSettings :: ChatSettings, - userPreferences :: ChatPreferences, + userPreferences :: Preferences, createdAt :: UTCTime, updatedAt :: UTCTime } @@ -102,7 +103,10 @@ contactConnId :: Contact -> ConnId contactConnId Contact {activeConn} = aConnId activeConn contactConnIncognito :: Contact -> Bool -contactConnIncognito Contact {activeConn = Connection {customUserProfileId}} = isJust customUserProfileId +contactConnIncognito = isJust . customUserProfileId' + +customUserProfileId' :: Contact -> Maybe Int64 +customUserProfileId' Contact {activeConn} = customUserProfileId (activeConn :: Connection) data ContactRef = ContactRef { contactId :: ContactId, @@ -230,70 +234,277 @@ defaultChatSettings = ChatSettings {enableNtfs = True} pattern DisableNtfs :: ChatSettings pattern DisableNtfs = ChatSettings {enableNtfs = False} -data ChatPreferences = ChatPreferences - { voice :: Maybe Preference - -- image :: Maybe Preference, - -- file :: Maybe Preference, - -- delete :: Maybe Preference, - -- acceptDelete :: Maybe Preference, - -- edit :: Maybe Preference, - -- receipts :: Maybe Preference +data ChatFeature + = CFFullDelete + | -- | CFReceipts + CFVoice + +allChatFeatures :: [ChatFeature] +allChatFeatures = + [ CFFullDelete, + -- CFReceipts, + CFVoice + ] + +chatPrefSel :: ChatFeature -> Preferences -> Maybe Preference +chatPrefSel = \case + CFFullDelete -> fullDelete + -- CFReceipts -> receipts + CFVoice -> voice + +chatPrefName :: ChatFeature -> Text +chatPrefName = \case + CFFullDelete -> "full message deletion" + -- CFReceipts -> "delivery receipts" + CFVoice -> "voice messages" + +class HasPreferences p where + preferences' :: p -> Maybe Preferences + +instance HasPreferences User where + preferences' User {profile = LocalProfile {preferences}} = preferences + {-# INLINE preferences' #-} + +instance HasPreferences Contact where + preferences' Contact {profile = LocalProfile {preferences}} = preferences + {-# INLINE preferences' #-} + +class PreferenceI p where + getPreference :: ChatFeature -> p -> Preference + +instance PreferenceI Preferences where + getPreference pt prefs = fromMaybe (getPreference pt defaultChatPrefs) (chatPrefSel pt prefs) + +instance PreferenceI (Maybe Preferences) where + getPreference pt prefs = fromMaybe (getPreference pt defaultChatPrefs) (chatPrefSel pt =<< prefs) + +instance PreferenceI FullPreferences where + getPreference = \case + CFFullDelete -> fullDelete + -- CFReceipts -> receipts + CFVoice -> voice + {-# INLINE getPreference #-} + +-- collection of optional chat preferences for the user and the contact +data Preferences = Preferences + { fullDelete :: Maybe Preference, + -- receipts :: Maybe Preference, + voice :: Maybe Preference } deriving (Eq, Show, Generic, FromJSON) -defaultChatPrefs :: ChatPreferences -defaultChatPrefs = ChatPreferences {voice = Just Preference {enable = PSOff}} +data GroupPreferences = GroupPreferences + { fullDelete :: Maybe GroupPreference, + -- receipts :: Maybe GroupPreference, + voice :: Maybe GroupPreference + } + deriving (Eq, Show, Generic, FromJSON) -emptyChatPrefs :: ChatPreferences -emptyChatPrefs = ChatPreferences {voice = Nothing} - -instance ToJSON ChatPreferences where +instance ToJSON GroupPreferences where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} -instance ToField ChatPreferences where +instance ToField GroupPreferences where toField = toField . encodeJSON -instance FromField ChatPreferences where +instance FromField GroupPreferences where + fromField = fromTextField_ decodeJSON + +-- full collection of chat preferences defined in the app - it is used to ensure we include all preferences and to simplify processing +-- if some of the preferences are not defined in Preferences, defaults from defaultChatPrefs are used here. +data FullPreferences = FullPreferences + { fullDelete :: Preference, + -- receipts :: Preference, + voice :: Preference + } + deriving (Eq) + +-- merged preferences of user for a given contact - they differentiate between specific preferences for the contact and global user preferences +data ContactUserPreferences = ContactUserPreferences + { fullDelete :: ContactUserPreference, + -- receipts :: ContactUserPreference, + voice :: ContactUserPreference + } + deriving (Show, Generic) + +data ContactUserPreference = ContactUserPreference + { enabled :: PrefEnabled, + userPreference :: ContactUserPref, + contactPreference :: Preference + } + deriving (Show, Generic) + +data ContactUserPref = CUPContact {preference :: Preference} | CUPUser {preference :: Preference} + deriving (Show, Generic) + +instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions + +instance ToJSON ContactUserPreference where toEncoding = J.genericToEncoding J.defaultOptions + +instance ToJSON ContactUserPref where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CUP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CUP" + +toChatPrefs :: FullPreferences -> Preferences +toChatPrefs FullPreferences {fullDelete, voice} = + Preferences + { fullDelete = Just fullDelete, + -- receipts = Just receipts, + voice = Just voice + } + +defaultChatPrefs :: FullPreferences +defaultChatPrefs = + FullPreferences + { fullDelete = Preference {allow = FANo}, + -- receipts = Preference {allow = FANo}, + voice = Preference {allow = FAYes} + } + +emptyChatPrefs :: Preferences +emptyChatPrefs = Preferences Nothing Nothing + +instance ToJSON Preferences where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +instance ToField Preferences where + toField = toField . encodeJSON + +instance FromField Preferences where fromField = fromTextField_ decodeJSON data Preference = Preference - {enable :: PrefSwitch} + {allow :: FeatureAllowed} deriving (Eq, Show, Generic, FromJSON) -instance ToJSON Preference where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data GroupPreference = GroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show, Generic, FromJSON) -data PrefSwitch = PSOn | PSOff -- for example it can be extended to include PSMutual, that is only enabled if it's enabled by another party +instance ToJSON Preference where toEncoding = J.genericToEncoding J.defaultOptions + +instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + +data FeatureAllowed + = FAAlways -- allow unconditionally + | FAYes -- allow, if peer allows it + | FANo -- do not allow deriving (Eq, Show, Generic) -instance FromField PrefSwitch where fromField = fromBlobField_ strDecode +data GroupFeatureEnabled = FEOn | FEOff + deriving (Eq, Show, Generic) -instance ToField PrefSwitch where toField = toField . strEncode +instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode -instance StrEncoding PrefSwitch where +instance ToField FeatureAllowed where toField = toField . strEncode + +instance StrEncoding FeatureAllowed where strEncode = \case - PSOn -> "on" - PSOff -> "off" + FAAlways -> "always" + FAYes -> "yes" + FANo -> "no" strDecode = \case - "on" -> Right PSOn - "off" -> Right PSOff - r -> Left $ "bad PrefSwitch " <> B.unpack r + "always" -> Right FAAlways + "yes" -> Right FAYes + "no" -> Right FANo + r -> Left $ "bad FeatureAllowed " <> B.unpack r strP = strDecode <$?> A.takeByteString -instance FromJSON PrefSwitch where - parseJSON = strParseJSON "PrefSwitch" +instance FromJSON FeatureAllowed where + parseJSON = strParseJSON "FeatureAllowed" -instance ToJSON PrefSwitch where +instance ToJSON FeatureAllowed where toJSON = strToJSON toEncoding = strToJEncoding +instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode + +instance ToField GroupFeatureEnabled where toField = toField . strEncode + +instance StrEncoding GroupFeatureEnabled where + strEncode = \case + FEOn -> "on" + FEOff -> "off" + strDecode = \case + "on" -> Right FEOn + "off" -> Right FEOff + r -> Left $ "bad GroupFeatureEnabled " <> B.unpack r + strP = strDecode <$?> A.takeByteString + +instance FromJSON GroupFeatureEnabled where + parseJSON = strParseJSON "GroupFeatureEnabled" + +instance ToJSON GroupFeatureEnabled where + toJSON = strToJSON + toEncoding = strToJEncoding + +mergePreferences :: Maybe Preferences -> Maybe Preferences -> FullPreferences +mergePreferences contactPrefs userPreferences = + FullPreferences + { fullDelete = pref CFFullDelete, + -- receipts = pref CFReceipts, + voice = pref CFVoice + } + where + pref pt = + let sel = chatPrefSel pt + in fromMaybe (getPreference pt defaultChatPrefs) $ (contactPrefs >>= sel) <|> (userPreferences >>= sel) + +mergeUserChatPrefs :: User -> Contact -> FullPreferences +mergeUserChatPrefs user ct = + let userPrefs = if contactConnIncognito ct then Nothing else preferences' user + in mergePreferences (Just $ userPreferences ct) userPrefs + +data PrefEnabled = PrefEnabled {forUser :: Bool, forContact :: Bool} + deriving (Show, Generic) + +instance ToJSON PrefEnabled where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +prefEnabled :: Preference -> Preference -> PrefEnabled +prefEnabled Preference {allow = user} Preference {allow = contact} = case (user, contact) of + (FAAlways, FANo) -> PrefEnabled {forUser = False, forContact = True} + (FANo, FAAlways) -> PrefEnabled {forUser = True, forContact = False} + (_, FANo) -> PrefEnabled False False + (FANo, _) -> PrefEnabled False False + _ -> PrefEnabled True True + +contactUserPreferences :: User -> Contact -> ContactUserPreferences +contactUserPreferences user ct = + ContactUserPreferences + { fullDelete = pref CFFullDelete, + -- receipts = pref CFReceipts, + voice = pref CFVoice + } + where + pref pt = + ContactUserPreference + { enabled = prefEnabled userPref ctPref, + -- incognito contact cannot have default user preference used + userPreference = if contactConnIncognito ct then CUPContact ctUserPref else maybe (CUPUser userPref) CUPContact ctUserPref_, + contactPreference = ctPref + } + where + ctUserPref = getPreference pt $ userPreferences ct + ctUserPref_ = chatPrefSel pt $ userPreferences ct + userPref = getPreference pt ctUserPrefs + ctPref = getPreference pt ctPrefs + ctUserPrefs = mergeUserChatPrefs user ct + ctPrefs = mergePreferences (preferences' ct) Nothing + +getContactUserPrefefence :: ChatFeature -> ContactUserPreferences -> ContactUserPreference +getContactUserPrefefence = \case + CFFullDelete -> fullDelete + -- CFReceipts -> receipts + CFVoice -> voice + data Profile = Profile { displayName :: ContactName, fullName :: Text, image :: Maybe ImageData, - preferences :: Maybe ChatPreferences + preferences :: Maybe Preferences -- fields that should not be read into this data type to prevent sending them as part of profile to contacts: -- - contact_profile_id -- - incognito @@ -314,7 +525,7 @@ data LocalProfile = LocalProfile displayName :: ContactName, fullName :: Text, image :: Maybe ImageData, - preferences :: Maybe ChatPreferences, + preferences :: Maybe Preferences, localAlias :: LocalAlias } deriving (Eq, Show, Generic, FromJSON) @@ -338,7 +549,7 @@ data GroupProfile = GroupProfile { displayName :: GroupName, fullName :: Text, image :: Maybe ImageData, - preferences :: Maybe ChatPreferences + groupPreferences :: Maybe GroupPreferences } deriving (Eq, Show, Generic, FromJSON) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2e58a838fa..52fa614c2d 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -18,7 +18,7 @@ import Data.Char (toUpper) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) -import Data.Maybe (isJust, isNothing) +import Data.Maybe (isJust, isNothing, mapMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (DiffTime) @@ -49,11 +49,11 @@ import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (bshow) import System.Console.ANSI.Types -serializeChatResponse :: ChatResponse -> String -serializeChatResponse = unlines . map unStyle . responseToView False +serializeChatResponse :: Maybe User -> ChatResponse -> String +serializeChatResponse user_ = unlines . map unStyle . responseToView user_ False -responseToView :: Bool -> ChatResponse -> [StyledString] -responseToView testView = \case +responseToView :: Maybe User -> Bool -> ChatResponse -> [StyledString] +responseToView user_ testView = \case CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] @@ -123,10 +123,14 @@ responseToView testView = \case CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft CRUserProfileUpdated p p' -> viewUserProfileUpdated p p' - CRContactPrefsUpdated ct -> viewContactPrefsUpdated ct + CRContactPrefsUpdated {fromContact, toContact, preferences} -> case user_ of + Just user -> viewUserContactPrefsUpdated user fromContact toContact preferences + _ -> ["unexpected chat event CRContactPrefsUpdated without current user"] CRContactAliasUpdated c -> viewContactAliasUpdated c CRConnectionAliasUpdated c -> viewConnectionAliasUpdated c - CRContactUpdated c c' -> viewContactUpdated c c' + CRContactUpdated {fromContact = c, toContact = c', preferences} -> case user_ of + Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c' preferences + _ -> ["unexpected chat event CRContactUpdated without current user"] CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile CRRcvFileStart ci -> receivingFile_' "started" ci @@ -694,25 +698,74 @@ viewSwitchPhase SPCompleted = "changed address" viewSwitchPhase phase = plain (strEncode phase) <> " changing address" viewUserProfileUpdated :: Profile -> Profile -> [StyledString] -viewUserProfileUpdated Profile {displayName = n, fullName, image} Profile {displayName = n', fullName = fullName', image = image'} - | n == n' && fullName == fullName' && image == image' = [] - | n == n' && fullName == fullName' = [if isNothing image' then "profile image removed" else "profile image updated"] - | n == n' = ["user full name " <> (if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName') <> notified] - | otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified] +viewUserProfileUpdated Profile {displayName = n, fullName, image, preferences} Profile {displayName = n', fullName = fullName', image = image', preferences = prefs'} = + profileUpdated <> viewPrefsUpdated preferences prefs' where + profileUpdated + | n == n' && fullName == fullName' && image == image' = [] + | n == n' && fullName == fullName' = [if isNothing image' then "profile image removed" else "profile image updated"] + | n == n' = ["user full name " <> (if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName') <> notified] + | otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified] notified = " (your contacts are notified)" -viewContactPrefsUpdated :: Contact -> [StyledString] -viewContactPrefsUpdated Contact {profile = LocalProfile {preferences}, userPreferences = ChatPreferences {voice = userVoice}} = - let contactVoice = preferences >>= voice - in ["preferences were updated: " <> "contact's voice messages are " <> viewPreference contactVoice <> ", user's voice messages are " <> viewPreference userVoice] +viewUserContactPrefsUpdated :: User -> Contact -> Contact -> ContactUserPreferences -> [StyledString] +viewUserContactPrefsUpdated user ct ct' cups + | null prefs = ["your preferences for " <> ttyContact' ct' <> " did not change"] + | otherwise = ("you updated preferences for " <> ttyContact' ct' <> ":") : prefs + where + prefs = viewContactPreferences user ct ct' cups -viewPreference :: Maybe Preference -> StyledString +viewContactPrefsUpdated :: User -> Contact -> Contact -> ContactUserPreferences -> [StyledString] +viewContactPrefsUpdated user ct ct' cups + | null prefs = [] + | otherwise = (ttyContact' ct' <> " updated preferences for you:") : prefs + where + prefs = viewContactPreferences user ct ct' cups + +viewContactPreferences :: User -> Contact -> Contact -> ContactUserPreferences -> [StyledString] +viewContactPreferences user ct ct' cups = + mapMaybe (viewContactPref (mergeUserChatPrefs user ct) (mergeUserChatPrefs user ct') (preferences' ct) cups) allChatFeatures + +viewContactPref :: FullPreferences -> FullPreferences -> Maybe Preferences -> ContactUserPreferences -> ChatFeature -> Maybe StyledString +viewContactPref userPrefs userPrefs' ctPrefs cups pt + | userPref == userPref' && ctPref == contactPreference = Nothing + | otherwise = Just $ plain (chatPrefName pt) <> ": " <> viewPrefEnabled enabled <> " (you allow: " <> viewCountactUserPref userPreference <> ", contact allows: " <> viewPreference contactPreference <> ")" + where + userPref = getPreference pt userPrefs + userPref' = getPreference pt userPrefs' + ctPref = getPreference pt ctPrefs + ContactUserPreference {enabled, userPreference, contactPreference} = getContactUserPrefefence pt cups + +viewPrefsUpdated :: Maybe Preferences -> Maybe Preferences -> [StyledString] +viewPrefsUpdated ps ps' + | null prefs = [] + | otherwise = "updated preferences:" : prefs + where + prefs = mapMaybe viewPref allChatFeatures + viewPref pt + | pref ps == pref ps' = Nothing + | otherwise = Just $ plain (chatPrefName pt) <> " allowed: " <> viewPreference (pref ps') + where + pref pss = getPreference pt $ mergePreferences pss Nothing + +viewPreference :: Preference -> StyledString viewPreference = \case - Just Preference {enable} -> case enable of - PSOn -> "on" - PSOff -> "off" - _ -> "unset" + Preference {allow} -> case allow of + FAAlways -> "always" + FAYes -> "yes" + FANo -> "no" + +viewCountactUserPref :: ContactUserPref -> StyledString +viewCountactUserPref = \case + CUPUser p -> "default (" <> viewPreference p <> ")" + CUPContact p -> viewPreference p + +viewPrefEnabled :: PrefEnabled -> StyledString +viewPrefEnabled = \case + PrefEnabled True True -> "enabled" + PrefEnabled False False -> "off" + PrefEnabled {forUser = True, forContact = False} -> "enabled for you" + PrefEnabled {forUser = False, forContact = True} -> "enabled for contact" viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> [StyledString] viewGroupUpdated diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 4733efa003..e6a58d693f 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -24,24 +24,27 @@ import qualified Data.Text as T import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), InlineFilesConfig (..), defaultInlineFilesConfig) import Simplex.Chat.Options (ChatOpts (..)) -import Simplex.Chat.Types (ConnStatus (..), GroupMemberRole (..), ImageData (..), LocalProfile (..), Profile (..), User (..), defaultChatPrefs) +import Simplex.Chat.Types import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util (unlessM) import System.Directory (copyFile, doesDirectoryExist, doesFileExist) import System.FilePath (()) import Test.Hspec +defaultPrefs :: Maybe Preferences +defaultPrefs = Just $ toChatPrefs defaultChatPrefs + aliceProfile :: Profile -aliceProfile = Profile {displayName = "alice", fullName = "Alice", image = Nothing, preferences = Just defaultChatPrefs} +aliceProfile = Profile {displayName = "alice", fullName = "Alice", image = Nothing, preferences = defaultPrefs} bobProfile :: Profile -bobProfile = Profile {displayName = "bob", fullName = "Bob", image = Just (ImageData "data:image/png;base64,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"), preferences = Just defaultChatPrefs} +bobProfile = Profile {displayName = "bob", fullName = "Bob", image = Just (ImageData "data:image/png;base64,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"), preferences = defaultPrefs} cathProfile :: Profile -cathProfile = Profile {displayName = "cath", fullName = "Catherine", image = Nothing, preferences = Just defaultChatPrefs} +cathProfile = Profile {displayName = "cath", fullName = "Catherine", image = Nothing, preferences = defaultPrefs} danProfile :: Profile -danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing, preferences = Just defaultChatPrefs} +danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing, preferences = defaultPrefs} chatTests :: Spec chatTests = do @@ -2410,16 +2413,21 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi bob ?#> ("@" <> aliceIncognito <> " no") alice ?<# (bobIncognito <> "> no") alice ##> "/_set prefs @2 {}" - alice <## "preferences were updated: contact's voice messages are off, user's voice messages are unset" - alice ##> "/_set prefs @2 {\"voice\": {\"enable\": \"on\"}}" - alice <## "preferences were updated: contact's voice messages are off, user's voice messages are on" - -- with delay it shouldn't fail here (and without it too) - threadDelay 1000000 + alice <## ("your preferences for " <> bobIncognito <> " did not change") + (bob "/_set prefs @2 {\"fullDelete\": {\"allow\": \"always\"}}" + alice <## ("you updated preferences for " <> bobIncognito <> ":") + alice <## "full message deletion: enabled for contact (you allow: always, contact allows: no)" + bob <## (aliceIncognito <> " updated preferences for you:") + bob <## "full message deletion: enabled for you (you allow: no, contact allows: always)" bob ##> "/_set prefs @2 {}" - bob <## "preferences were updated: contact's voice messages are on, user's voice messages are unset" - threadDelay 1000000 - alice ##> "/_set prefs @2 {\"voice\": {\"enable\": \"off\"}}" - alice <## "preferences were updated: contact's voice messages are off, user's voice messages are off" + bob <## ("your preferences for " <> aliceIncognito <> " did not change") + (alice "/_set prefs @2 {\"fullDelete\": {\"allow\": \"no\"}}" + alice <## ("you updated preferences for " <> bobIncognito <> ":") + alice <## "full message deletion: off (you allow: no, contact allows: no)" + bob <## (aliceIncognito <> " updated preferences for you:") + bob <## "full message deletion: off (you allow: no, contact allows: no)" testConnectIncognitoContactAddress :: IO () testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ @@ -2715,22 +2723,35 @@ testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathPr cath <## "alice (Alice): contact is connected" ] alice <## "cath (Catherine): contact is connected" - alice ##> "/_profile {\"displayName\": \"alice\", \"fullName\": \"\", \"preferences\": {\"voice\": {\"enable\": \"on\"}}}" + alice ##> "/_profile {\"displayName\": \"alice\", \"fullName\": \"\", \"preferences\": {\"fullDelete\": {\"allow\": \"always\"}}}" alice <## "user full name removed (your contacts are notified)" + alice <## "updated preferences:" + alice <## "full message deletion allowed: always" + (alice "/_set prefs @2 {\"voice\": {\"enable\": \"on\"}}" - bob <## "preferences were updated: contact's voice messages are off, user's voice messages are on" - threadDelay 1000000 - alice ##> "/_set prefs @2 {\"voice\": {\"enable\": \"on\"}}" - alice <## "preferences were updated: contact's voice messages are on, user's voice messages are on" - threadDelay 1000000 - alice ##> "/_set prefs @3 {\"voice\": {\"enable\": \"on\"}}" - alice <## "preferences were updated: contact's voice messages are off, user's voice messages are on" - threadDelay 1000000 - cath ##> "/_set prefs @2 {}" - cath <## "preferences were updated: contact's voice messages are on, user's voice messages are unset" + cath <## "alice updated preferences for you:" + cath <## "full message deletion: enabled for you (you allow: default (no), contact allows: always)" + (cath "/_set prefs @2 {\"fullDelete\": {\"allow\": \"always\"}}" + bob <## ("you updated preferences for " <> aliceIncognito <> ":") + bob <## "full message deletion: enabled for contact (you allow: always, contact allows: no)" + alice <## "bob updated preferences for you:" + alice <## "full message deletion: enabled for you (you allow: no, contact allows: always)" + alice ##> "/_set prefs @2 {\"fullDelete\": {\"allow\": \"yes\"}}" + alice <## "you updated preferences for bob:" + alice <## "full message deletion: enabled (you allow: yes, contact allows: always)" + bob <## (aliceIncognito <> " updated preferences for you:") + bob <## "full message deletion: enabled (you allow: always, contact allows: yes)" + (cath "/_set prefs @3 {\"fullDelete\": {\"allow\": \"always\"}}" + alice <## "your preferences for cath did not change" + alice ##> "/_set prefs @3 {\"fullDelete\": {\"allow\": \"yes\"}}" + alice <## "you updated preferences for cath:" + alice <## "full message deletion: off (you allow: yes, contact allows: no)" + cath <## "alice updated preferences for you:" + cath <## "full message deletion: off (you allow: default (no), contact allows: yes)" testSetAlias :: IO () testSetAlias = testChat2 aliceProfile bobProfile $ @@ -2765,24 +2786,39 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob alice ##> "/_set prefs @2 {}" - alice <## "preferences were updated: contact's voice messages are off, user's voice messages are unset" - alice ##> "/_set prefs @2 {\"voice\": {\"enable\": \"on\"}}" - alice <## "preferences were updated: contact's voice messages are off, user's voice messages are on" - alice ##> "/_profile {\"displayName\": \"alice\", \"fullName\": \"\", \"preferences\": {\"voice\": {\"enable\": \"off\"}}}" + alice <## "your preferences for bob did not change" + (bob "/_set prefs @2 {\"fullDelete\": {\"allow\": \"always\"}}" + alice <## "you updated preferences for bob:" + alice <## "full message deletion: enabled for contact (you allow: always, contact allows: no)" + bob <## "alice updated preferences for you:" + bob <## "full message deletion: enabled for you (you allow: default (no), contact allows: always)" + (bob "/_profile {\"displayName\": \"alice\", \"fullName\": \"\", \"preferences\": {\"fullDelete\": {\"allow\": \"no\"}}}" alice <## "user full name removed (your contacts are notified)" bob <## "contact alice removed full name" - alice ##> "/_set prefs @2 {\"voice\": {\"enable\": \"on\"}}" - alice <## "preferences were updated: contact's voice messages are off, user's voice messages are on" - bob ##> "/_profile {\"displayName\": \"bob\", \"fullName\": \"\", \"preferences\": {\"voice\": {\"enable\": \"on\"}}}" + alice ##> "/_set prefs @2 {\"fullDelete\": {\"allow\": \"yes\"}}" + alice <## "you updated preferences for bob:" + alice <## "full message deletion: off (you allow: yes, contact allows: no)" + bob <## "alice updated preferences for you:" + bob <## "full message deletion: off (you allow: default (no), contact allows: yes)" + (bob "/_profile {\"displayName\": \"bob\", \"fullName\": \"\", \"preferences\": {\"fullDelete\": {\"allow\": \"yes\"}}}" bob <## "user full name removed (your contacts are notified)" + bob <## "updated preferences:" + bob <## "full message deletion allowed: yes" alice <## "contact bob removed full name" + alice <## "bob updated preferences for you:" + alice <## "full message deletion: enabled (you allow: yes, contact allows: yes)" + (alice "/_set prefs @2 {}" - bob <## "preferences were updated: contact's voice messages are on, user's voice messages are unset" - alice ##> "/_set prefs @2 {\"voice\": {\"enable\": \"off\"}}" - alice <## "preferences were updated: contact's voice messages are on, user's voice messages are off" - threadDelay 1000000 - bob ##> "/_set prefs @2 {}" - bob <## "preferences were updated: contact's voice messages are off, user's voice messages are unset" + bob <## "your preferences for alice did not change" + (alice "/_set prefs @2 {\"fullDelete\": {\"allow\": \"no\"}}" + alice <## "you updated preferences for bob:" + alice <## "full message deletion: off (you allow: no, contact allows: yes)" + bob <## "alice updated preferences for you:" + bob <## "full message deletion: off (you allow: default (yes), contact allows: no)" testGetSetSMPServers :: IO () testGetSetSMPServers = diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index d5ccc6b0aa..8295b37ee7 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -79,14 +79,17 @@ s #==# msg = do s #== msg s ==# msg -testChatPreferences :: Maybe ChatPreferences -testChatPreferences = Just ChatPreferences {voice = Just Preference {enable = PSOn}} +testChatPreferences :: Maybe Preferences +testChatPreferences = Just Preferences {voice = Just Preference {allow = FAYes}, fullDelete = Nothing} + +testGroupPreferences :: Maybe GroupPreferences +testGroupPreferences = Just GroupPreferences {voice = Just GroupPreference {enable = FEOn}, fullDelete = Nothing} testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), preferences = testChatPreferences} testGroupProfile :: GroupProfile -testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", image = Nothing, preferences = testChatPreferences} +testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", image = Nothing, groupPreferences = testGroupPreferences} decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do @@ -177,46 +180,46 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XFileCancel (SharedMsgId "\1\2\3\4") it "x.info" $ - "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}}}}" + "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" #==# XInfo testProfile it "x.info with empty full name" $ - "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}}}}" + "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing, preferences = testChatPreferences} it "x.contact with xContactId" $ - "{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}}}}" + "{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile (Just $ XContactId "\1\2\3\4") it "x.contact without XContactId" $ - "{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}}}}" + "{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile Nothing it "x.contact with content null" $ - "{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}}}}" + "{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing 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=\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}}}}" + "{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.grp.inv" $ - "{\"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-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}},\"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-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"voice\":{\"enable\":\"on\"}}},\"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, groupLinkId = Nothing} it "x.grp.inv with group link id" $ - "{\"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-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"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-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"} it "x.grp.acpt without incognito profile" $ "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") 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=\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}}}}}" + "{\"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=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} it "x.grp.mem.intro" $ - "{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}}}}}" + "{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} it "x.grp.mem.inv" $ "{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"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-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"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-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq} it "x.grp.mem.fwd" $ - "{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"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-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"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-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}}}}}" + "{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"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-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"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-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq} it "x.grp.mem.info" $ - "{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}}}}" + "{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile it "x.grp.mem.con" $ "{\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}"