From f4f8501eb80960a6f86a4c7d9242baea7aeeb8ec Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 15 Jan 2024 19:56:11 +0400 Subject: [PATCH] core: members profile update, create profile update chat items (#3644) --- .../rfcs/2024-01-04-members-profile-update.md | 50 ++ simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 95 +++- src/Simplex/Chat/Messages/CIContent.hs | 7 +- src/Simplex/Chat/Messages/CIContent/Events.hs | 5 +- .../M20240104_members_profile_update.hs | 20 + src/Simplex/Chat/Migrations/chat_schema.sql | 6 +- src/Simplex/Chat/Protocol.hs | 6 +- src/Simplex/Chat/Store/Connections.hs | 4 +- src/Simplex/Chat/Store/Direct.hs | 27 +- src/Simplex/Chat/Store/Groups.hs | 111 ++++- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 26 +- src/Simplex/Chat/Store/Shared.hs | 8 +- src/Simplex/Chat/Types.hs | 10 +- src/Simplex/Chat/View.hs | 3 +- tests/ChatTests/Groups.hs | 459 +++++++++++++++++- tests/ChatTests/Profiles.hs | 4 +- tests/ProtocolTests.hs | 8 +- 19 files changed, 777 insertions(+), 77 deletions(-) create mode 100644 docs/rfcs/2024-01-04-members-profile-update.md create mode 100644 src/Simplex/Chat/Migrations/M20240104_members_profile_update.hs diff --git a/docs/rfcs/2024-01-04-members-profile-update.md b/docs/rfcs/2024-01-04-members-profile-update.md new file mode 100644 index 0000000000..c91cbcabab --- /dev/null +++ b/docs/rfcs/2024-01-04-members-profile-update.md @@ -0,0 +1,50 @@ +# Sending profile update to group members + +## Problem + +Profile updates are only sent to direct contacts, as sending them to all group member connections is prohibitively expensive. This results in group members not receiving profile updates. Previously the issue was less acute as all group members were created with two sets of connections, one being used as direct connection for their respective contacts (though the traffic issue was more pronounced due to that); also contacts were merged across group members. Since client started to support deletion of group member contact records, and later stopped creating direct connections for group members altogether, it became less likely for group members to receive profile updates. Still even in the latest versions group members can receive profile updates after creating direct contacts via "Send direct message" button, or connecting out-of-band and merging contact and member records. + +## Solution + +Keep track of which members received latest profile updates. Send profile updates when user is active in group. + +### How to track + +- users.user_member_profile_updated_at +- group_members.user_member_profile_sent_at +- when user updates profile, remember new user_member_profile_updated_at, later to be compared against group_members.user_member_profile_sent_at + +### What to track + +- not all profile fields make sense to send in profile update to group members +- changes to displayName, fullName, image should be sent +- changes to preferences aren't necessary to send as they only apply to user contacts +- changes to contactLink may be sent, but can also be excluded for purposes of privacy + - some users don't expect that sharing address (contactLink) shares it not only with contacts, but also group members + - this is a broader issue, as the user's contact link may also be sent in user's profile by admin when introducing members - it makes sense to either ignore this for the purposes of this feature, of change it in group handshake as well +- it then makes sense to remember new timestamp on user record only if name or image is changed + +### When/To whom to send + +- when user is active in group (i.e. broadcasts message via sendGroupMessage), compare group_members.user_member_profile_sent_at against users.user_member_profile_updated_at to determine whether latest profile update wasn't yet sent +- don't send to members in groups where user is incognito +- don't send to members with whom user has direct contact (as it would overwrite full profile update sent to contact)? + - alternatively it may be better to send the same pruned profile to such members, and for them to ignore this update (or only apply name and image updates, in case sender has silently deleted them as contact without notifying?): + - this would ensure that they do receive it in case they silently deleted contact without notifying user + - it simplifies processing, as then the same message is sent to all group members + - may remember "profile update hashes" on receiving side to not apply profile updates received via member connection to contact profile, if they arrive after previously processed updates received via contact connection (e.g. update that was received late would overwrite more up-to-date updates received via contact connection, until following messages arrive) +- it seems unnecessary to send profile updates on service messages to individual members: + - it would otherwise lead to members having different profiles of user at different points in time + - not all of these messages create chat items anyway (forward, intro messages), so user name/image wouldn't matter + - most if not all of these messages are sent by admins, who are likely to send either some content messages, group updates, or announce new members (x.grp.mem.new, which is also broadcasted) + - it simplifies processing, as then profile update is sent to all current members +- considering above points, perhaps we can simplify to track user_member_profile_sent_at on groups instead of group_members + - group_members.user_member_profile_sent_at -> groups.user_member_profile_sent_at + +### How to send + +Two options: +- send as a separate message, don't special case +- send batched with the main message (using chat protocol batching mechanism), it would avoid broadcasting additional message for users without profile images, and likely in some cases (when main message is short) even with them + - conflicts with forwarding as forwarding of batched messages is not supported + - simply implementing forwarding of batched messages is not enough, because currently there is no way to differentiate between history and other batched messages (and received history shouldn't be forwarded) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 8aafb8e867..22113f3c29 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -130,6 +130,7 @@ library Simplex.Chat.Migrations.M20231214_item_content_tag Simplex.Chat.Migrations.M20231215_recreate_msg_deliveries Simplex.Chat.Migrations.M20240102_note_folders + Simplex.Chat.Migrations.M20240104_members_profile_update Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e048e25f73..e2e8682907 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1030,7 +1030,7 @@ processChatCommand' vr = \case filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo withChatLock "deleteChat group" . procCmd $ do deleteFilesAndConns user filesInfo - when (memberActive membership && isOwner) . void $ sendGroupMessage user gInfo members XGrpDel + when (memberActive membership && isOwner) . void $ sendGroupMessage' user gInfo members XGrpDel deleteGroupLinkIfExists user gInfo deleteMembersConnections user members -- functions below are called in separate transactions to prevent crashes on android @@ -1746,7 +1746,7 @@ processChatCommand' vr = \case APILeaveGroup groupId -> withUser $ \user@User {userId} -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId withChatLock "leaveGroup" . procCmd $ do - (msg, _) <- sendGroupMessage user gInfo members XGrpLeave + (msg, _) <- sendGroupMessage' user gInfo members XGrpLeave ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) -- TODO delete direct connections that were unused @@ -3918,7 +3918,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ms = introducedMembers <> invitedMembers msg = XGrpMsgForward memberId chatMsg' brokerTs unless (null ms) . void $ - sendGroupMessage user gInfo ms msg + sendGroupMessage' user gInfo ms msg RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ groupMsgReceived gInfo m conn msgMeta msgRcpt @@ -4849,20 +4849,23 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = brokerTs = metaBrokerTs msgMeta processContactProfileUpdate :: Contact -> Profile -> Bool -> m Contact - processContactProfileUpdate c@Contact {profile = p} p' createItems - | fromLocalProfile p /= p' = do + processContactProfileUpdate c@Contact {profile = lp} p' createItems + | p /= p' = do c' <- withStore $ \db -> if userTTL == rcvTTL then updateContactProfile db user c p' else do c' <- liftIO $ updateContactUserPreferences db user c ctUserPrefs' updateContactProfile db user c' p' - when (directOrUsed c' && createItems) $ createRcvFeatureItems user c c' + when (directOrUsed c' && createItems) $ do + createProfileUpdatedItem c' + createRcvFeatureItems user c c' toView $ CRContactUpdated user c c' pure c' | otherwise = pure c where + p = fromLocalProfile lp Contact {userPreferences = ctUserPrefs@Preferences {timedMessages = ctUserTMPref}} = c userTTL = prefParam $ getPreference SCFTimedMessages ctUserPrefs Profile {preferences = rcvPrefs_} = p' @@ -4876,32 +4879,62 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | rcvTTL /= userDefaultTTL -> Just (userDefault :: TimedMessagesPreference) {ttl = rcvTTL} | otherwise -> Nothing in setPreference_ SCFTimedMessages ctUserTMPref' ctUserPrefs + createProfileUpdatedItem c' = + when visibleProfileUpdated $ do + let ciContent = CIRcvDirectEvent $ RDEProfileUpdated p p' + createInternalChatItem user (CDDirectRcv c') ciContent Nothing + where + visibleProfileUpdated = + n' /= n || fn' /= fn || i' /= i || cl' /= cl + Profile {displayName = n, fullName = fn, image = i, contactLink = cl} = p + Profile {displayName = n', fullName = fn', image = i', contactLink = cl'} = p' xInfoMember :: GroupInfo -> GroupMember -> Profile -> m () - xInfoMember gInfo m p' = void $ processMemberProfileUpdate gInfo m p' + xInfoMember gInfo m p' = void $ processMemberProfileUpdate gInfo m p' True xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> m () xGrpLinkMem gInfo@GroupInfo {membership} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId if viaGroupLink && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived then do - m' <- processMemberProfileUpdate gInfo m p' + m' <- processMemberProfileUpdate gInfo m p' False withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True let connectedIncognito = memberIncognito membership probeMatchingMemberContact m' connectedIncognito else messageError "x.grp.link.mem error: invalid group link host profile update" - processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> m GroupMember - processMemberProfileUpdate gInfo m@GroupMember {memberContactId} p' = - case memberContactId of - Nothing -> do - m' <- withStore $ \db -> updateMemberProfile db user m p' - toView $ CRGroupMemberUpdated user gInfo m m' - pure m' - Just mContactId -> do - mCt <- withStore $ \db -> getContact db user mContactId - Contact {profile} <- processContactProfileUpdate mCt p' True - pure m {memberProfile = profile} + processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Bool -> m GroupMember + processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' createItems + | redactedMemberProfile (fromLocalProfile p) /= redactedMemberProfile p' = + case memberContactId of + Nothing -> do + m' <- withStore $ \db -> updateMemberProfile db user m p' + createProfileUpdatedItem m' + toView $ CRGroupMemberUpdated user gInfo m m' + pure m' + Just mContactId -> do + mCt <- withStore $ \db -> getContact db user mContactId + if canUpdateProfile mCt + then do + (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' + createProfileUpdatedItem m' + toView $ CRGroupMemberUpdated user gInfo m m' + toView $ CRContactUpdated user mCt ct' + pure m' + else pure m + where + canUpdateProfile ct + | not (contactActive ct) = True + | otherwise = case contactConn ct of + Nothing -> True + Just conn -> not (connReady conn) || (authErrCounter conn >= 1) + | otherwise = + pure m + where + createProfileUpdatedItem m' = + when createItems $ do + let ciContent = CIRcvGroupEvent $ RGEMemberProfileUpdated (fromLocalProfile p) p' + createInternalChatItem user (CDGroupRcv gInfo m') ciContent Nothing createFeatureEnabledItems :: Contact -> m () createFeatureEnabledItems ct@Contact {mergedPreferences} = @@ -5835,7 +5868,29 @@ deliverMessagesB msgReqs = do Right <$> createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId}) msgId sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) -sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = do +sendGroupMessage user gInfo members chatMsgEvent = do + when shouldSendProfileUpdate $ + sendProfileUpdate `catchChatError` (\e -> toView (CRChatError (Just user) e)) + sendGroupMessage' user gInfo members chatMsgEvent + where + User {profile = p, userMemberProfileUpdatedAt} = user + GroupInfo {userMemberProfileSentAt} = gInfo + shouldSendProfileUpdate + | incognitoMembership gInfo = False + | otherwise = + case (userMemberProfileSentAt, userMemberProfileUpdatedAt) of + (Just lastSentTs, Just lastUpdateTs) -> lastSentTs < lastUpdateTs + (Nothing, Just _) -> True + _ -> False + sendProfileUpdate = do + let members' = filter (\m -> isCompatibleRange (memberChatVRange' m) memberProfileUpdateVRange) members + profileUpdateEvent = XInfo $ redactedMemberProfile $ fromLocalProfile p + void $ sendGroupMessage' user gInfo members' profileUpdateEvent + currentTs <- liftIO getCurrentTime + withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs + +sendGroupMessage' :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) +sendGroupMessage' user GroupInfo {groupId} members chatMsgEvent = do msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent (GroupId groupId) recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) let msgFlags = MsgFlags {notification = hasNotification $ toCMEventTag chatMsgEvent} diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index b878949cab..cd448599d7 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -169,7 +169,9 @@ ciRequiresAttention content = case msgDirection @d of CIRcvIntegrityError _ -> True CIRcvDecryptionError {} -> True CIRcvGroupInvitation {} -> True - CIRcvDirectEvent _ -> False + CIRcvDirectEvent rde -> case rde of + RDEContactDeleted -> False + RDEProfileUpdated {} -> True CIRcvGroupEvent rge -> case rge of RGEMemberAdded {} -> False RGEMemberConnected -> False @@ -182,6 +184,7 @@ ciRequiresAttention content = case msgDirection @d of RGEGroupUpdated _ -> False RGEInvitedViaGroupLink -> False RGEMemberCreatedContact -> False + RGEMemberProfileUpdated {} -> False CIRcvConnEvent _ -> True CIRcvChatFeature {} -> False CIRcvChatPreference {} -> False @@ -252,6 +255,7 @@ ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayN rcvDirectEventToText :: RcvDirectEvent -> Text rcvDirectEventToText = \case RDEContactDeleted -> "contact deleted" + RDEProfileUpdated {} -> "updated profile" rcvGroupEventToText :: RcvGroupEvent -> Text rcvGroupEventToText = \case @@ -266,6 +270,7 @@ rcvGroupEventToText = \case RGEGroupUpdated _ -> "group profile updated" RGEInvitedViaGroupLink -> "invited via your group link" RGEMemberCreatedContact -> "started direct connection with you" + RGEMemberProfileUpdated {} -> "updated profile" sndGroupEventToText :: SndGroupEvent -> Text sndGroupEventToText = \case diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 16851859e3..3bb3ee836a 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -25,6 +25,7 @@ data RcvGroupEvent -- and be created as unread without adding / working around new status for sent items | RGEInvitedViaGroupLink -- CRSentGroupInvitationViaLink | RGEMemberCreatedContact -- CRNewMemberContactReceivedInv + | RGEMemberProfileUpdated {fromProfile :: Profile, toProfile :: Profile} -- CRGroupMemberUpdated deriving (Show) data SndGroupEvent @@ -47,8 +48,8 @@ data SndConnEvent deriving (Show) data RcvDirectEvent - = -- RDEProfileChanged {...} - RDEContactDeleted + = RDEContactDeleted + | RDEProfileUpdated {fromProfile :: Profile, toProfile :: Profile} -- CRContactUpdated deriving (Show) -- platform-specific JSON encoding (used in API) diff --git a/src/Simplex/Chat/Migrations/M20240104_members_profile_update.hs b/src/Simplex/Chat/Migrations/M20240104_members_profile_update.hs new file mode 100644 index 0000000000..5591c4bdcd --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20240104_members_profile_update.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20240104_members_profile_update where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240104_members_profile_update :: Query +m20240104_members_profile_update = + [sql| +ALTER TABLE users ADD COLUMN user_member_profile_updated_at TEXT; +ALTER TABLE groups ADD COLUMN user_member_profile_sent_at TEXT; +|] + +down_m20240104_members_profile_update :: Query +down_m20240104_members_profile_update = + [sql| +ALTER TABLE groups DROP COLUMN user_member_profile_sent_at; +ALTER TABLE users DROP COLUMN user_member_profile_updated_at; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index c8445f8572..0d048417ef 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -33,7 +33,8 @@ CREATE TABLE users( view_pwd_salt BLOB, show_ntfs INTEGER NOT NULL DEFAULT 1, send_rcpts_contacts INTEGER NOT NULL DEFAULT 0, - send_rcpts_small_groups INTEGER NOT NULL DEFAULT 0, -- 1 for active user + send_rcpts_small_groups INTEGER NOT NULL DEFAULT 0, + user_member_profile_updated_at TEXT, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -118,7 +119,8 @@ CREATE TABLE groups( chat_ts TEXT, favorite INTEGER NOT NULL DEFAULT 0, send_rcpts INTEGER, - via_group_link_uri_hash BLOB, -- received + via_group_link_uri_hash BLOB, + user_member_profile_sent_at TEXT, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 28f4eb1f02..0ad1afde67 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -54,7 +54,7 @@ import Simplex.Messaging.Version hiding (version) -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: Version -currentChatVersion = 6 +currentChatVersion = 7 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRange @@ -84,6 +84,10 @@ batchSendVRange = mkVersionRange 5 currentChatVersion groupHistoryIncludeWelcomeVRange :: VersionRange groupHistoryIncludeWelcomeVRange = mkVersionRange 6 currentChatVersion +-- version range that supports sending member profile updates to groups +memberProfileUpdateVRange :: VersionRange +memberProfileUpdateVRange = mkVersionRange 7 currentChatVersion + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 580120b2ae..9f285549b6 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -96,7 +96,9 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 0d1e470a63..e271c6b28c 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -9,9 +9,11 @@ {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Store.Direct - ( updateContact_, + ( updateContactLDN_, updateContactProfile_, updateContactProfile_', + updateMemberContactProfile_, + updateMemberContactProfile_', deleteContactProfile_, deleteUnusedProfile_, @@ -316,7 +318,7 @@ updateContactProfile db user@User {userId} c p' ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime updateContactProfile_' db userId profileId p' currentTs - updateContact_ db userId contactId localDisplayName ldn currentTs + updateContactLDN_ db userId contactId localDisplayName ldn currentTs pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} where Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c @@ -453,8 +455,25 @@ updateContactProfile_' db userId profileId Profile {displayName, fullName, image |] (displayName, fullName, image, contactLink, preferences, updatedAt, userId, profileId) -updateContact_ :: DB.Connection -> UserId -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () -updateContact_ db userId contactId displayName newName updatedAt = do +-- update only member profile fields +updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () +updateMemberContactProfile_ db userId profileId profile = do + currentTs <- getCurrentTime + updateMemberContactProfile_' db userId profileId profile currentTs + +updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () +updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, image} updatedAt = do + DB.execute + db + [sql| + UPDATE contact_profiles + SET display_name = ?, full_name = ?, image = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + (displayName, fullName, image, updatedAt, userId, profileId) + +updateContactLDN_ :: DB.Connection -> UserId -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () +updateContactLDN_ db userId contactId displayName newName updatedAt = do DB.execute db "UPDATE contacts SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index f174d348fa..5a2c652323 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -108,10 +108,12 @@ module Simplex.Chat.Store.Groups updateMemberContactInvited, resetMemberContactFields, updateMemberProfile, + updateContactMemberProfile, getXGrpLinkMemReceived, setXGrpLinkMemReceived, createNewUnknownGroupMember, updateUnknownMemberAnnounced, + updateUserMemberProfileSentAt, ) where @@ -143,19 +145,19 @@ import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. GroupMemberRow type GroupMemberRow = ((Int64, Int64, MemberId, Version, Version, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe Version, Maybe Version, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) toGroupInfo :: VersionRange -> Int64 -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs) :. userMemberRow) = +toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = JVersionRange vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} - in GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs} + in GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = @@ -261,7 +263,9 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -310,13 +314,31 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc profileId <- insertedRowId db DB.execute db - "INSERT INTO groups (local_display_name, user_id, group_profile_id, enable_ntfs, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?)" - (ldn, userId, profileId, True, currentTs, currentTs, currentTs) + [sql| + INSERT INTO groups + (local_display_name, user_id, group_profile_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (ldn, userId, profileId, True, currentTs, currentTs, currentTs, currentTs) insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs vr let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} - pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs} + pure + GroupInfo + { groupId, + localDisplayName = ldn, + groupProfile, + fullGroupPreferences, + membership, + hostConnCustomUserProfileId = Nothing, + chatSettings, + createdAt = currentTs, + updatedAt = currentTs, + chatTs = Just currentTs, + userMemberProfileSentAt = Just currentTs + } -- | creates a new group record for the group the current user was invited to, or returns an existing one createGroupInvitation :: DB.Connection -> VersionRange -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) @@ -356,14 +378,34 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ profileId <- insertedRowId db DB.execute db - "INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?,?,?)" - (profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs) + [sql| + INSERT INTO groups + (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + |] + (profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) insertedRowId db let JVersionRange hostVRange = peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs vr let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} - pure (GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs}, groupMemberId) + pure + ( GroupInfo + { groupId, + localDisplayName, + groupProfile, + fullGroupPreferences, + membership, + hostConnCustomUserProfileId = customUserProfileId, + chatSettings, + createdAt = currentTs, + updatedAt = currentTs, + chatTs = Just currentTs, + userMemberProfileSentAt = Just currentTs + }, + groupMemberId + ) getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId getHostMemberId_ db User {userId} groupId = @@ -459,8 +501,13 @@ createGroupInvitedViaLink profileId <- insertedRowId db DB.execute db - "INSERT INTO groups (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?,?)" - (profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs) + [sql| + INSERT INTO groups + (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at) + VALUES (?,?,?,?,?,?,?,?,?) + |] + (profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) insertedRowId db insertHost_ currentTs groupId = do let fromMemberProfile = profileFromName fromMemberName @@ -564,7 +611,10 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = <$> DB.query db [sql| - SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, + SELECT + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g @@ -1208,7 +1258,9 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -1301,7 +1353,9 @@ getGroupInfo db vr User {userId, userContactId} groupId = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -1936,12 +1990,12 @@ createMemberContactConn_ updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember updateMemberProfile db User {userId} m p' | displayName == newName = do - liftIO $ updateContactProfile_ db userId profileId p' + liftIO $ updateMemberContactProfile_ db userId profileId p' pure m {memberProfile = profile} | otherwise = ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime - updateContactProfile_' db userId profileId p' currentTs + updateMemberContactProfile_' db userId profileId p' currentTs DB.execute db "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" @@ -1953,6 +2007,22 @@ updateMemberProfile db User {userId} m p' Profile {displayName = newName} = p' profile = toLocalProfile profileId p' localAlias +updateContactMemberProfile :: DB.Connection -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) +updateContactMemberProfile db User {userId} m ct@Contact {contactId} p' + | displayName == newName = do + liftIO $ updateMemberContactProfile_ db userId profileId p' + pure (m {memberProfile = profile}, ct {profile}) + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + currentTs <- getCurrentTime + updateMemberContactProfile_' db userId profileId p' currentTs + updateContactLDN_ db userId contactId localDisplayName ldn currentTs + pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile}) + where + GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m + Profile {displayName = newName} = p' + profile = toLocalProfile profileId p' localAlias + getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool getXGrpLinkMemReceived db mId = ExceptT . firstRow fromOnly (SEGroupMemberNotFound mId) $ @@ -2014,3 +2084,10 @@ updateUnknownMemberAnnounced db user@User {userId} invitingMember unknownMember@ getGroupMemberById db user groupMemberId where VersionRange minV maxV = maybe (fromJVersionRange memberChatVRange) fromChatVRange v + +updateUserMemberProfileSentAt :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO () +updateUserMemberProfileSentAt db User {userId} GroupInfo {groupId} sentTs = + DB.execute + db + "UPDATE groups SET user_member_profile_sent_at = ? WHERE user_id = ? AND group_id = ?" + (sentTs, userId, groupId) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index db70ffeab1..ffd5dbdf57 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -95,6 +95,7 @@ import Simplex.Chat.Migrations.M20231207_chat_list_pagination import Simplex.Chat.Migrations.M20231214_item_content_tag import Simplex.Chat.Migrations.M20231215_recreate_msg_deliveries import Simplex.Chat.Migrations.M20240102_note_folders +import Simplex.Chat.Migrations.M20240104_members_profile_update import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -189,7 +190,8 @@ schemaMigrations = ("20231207_chat_list_pagination", m20231207_chat_list_pagination, Just down_m20231207_chat_list_pagination), ("20231214_item_content_tag", m20231214_item_content_tag, Just down_m20231214_item_content_tag), ("20231215_recreate_msg_deliveries", m20231215_recreate_msg_deliveries, Just down_m20231215_recreate_msg_deliveries), - ("20240102_note_folders", m20240102_note_folders, Just down_m20240102_note_folders) + ("20240102_note_folders", m20240102_note_folders, Just down_m20240102_note_folders), + ("20240104_members_profile_update", m20240104_members_profile_update, Just down_m20240104_members_profile_update) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 6d25c120a2..bffdb2a6d3 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -121,8 +121,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image, (profileId, displayName, userId, True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - - pure $ toUser $ (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, Nothing, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, Nothing, Nothing) + pure $ toUser $ (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, Nothing, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, Nothing, Nothing, Nothing) getUsersInfo :: DB.Connection -> IO [UserInfo] getUsersInfo db = getUsers db >>= mapM getUserInfo @@ -253,23 +252,32 @@ updateUserGroupReceipts db User {userId} UserMsgReceiptSettings {enable, clearOv updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile db user p' - | displayName == newName = do - liftIO $ updateContactProfile_ db userId profileId p' - pure user {profile, fullPreferences} + | displayName == newName = liftIO $ do + updateContactProfile_ db userId profileId p' + currentTs <- getCurrentTime + userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs + pure user {profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} | otherwise = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) + userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (newName, newName, userId, currentTs, currentTs) updateContactProfile_' db userId profileId p' currentTs - updateContact_ db userId userContactId localDisplayName newName currentTs - pure user {localDisplayName = newName, profile, fullPreferences} + updateContactLDN_ db userId userContactId localDisplayName newName currentTs + pure user {localDisplayName = newName, profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} where - User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}} = user - Profile {displayName = newName, preferences} = p' + updateUserMemberProfileUpdatedAt_ currentTs + | userMemberProfileChanged = do + DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (currentTs, userId) + pure $ Just currentTs + | otherwise = pure userMemberProfileUpdatedAt + userMemberProfileChanged = newName /= displayName || newFullName /= fullName || newImage /= image + User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, image, localAlias}, userMemberProfileUpdatedAt} = user + Profile {displayName = newName, fullName = newFullName, image = newImage, preferences} = p' profile = toLocalProfile profileId p' localAlias fullPreferences = mergePreferences Nothing preferences diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 6fdeaee133..e4d47b32cc 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -313,15 +313,15 @@ userQuery :: Query userQuery = [sql| SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.local_display_name, ucp.full_name, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (Bool, Bool, Bool, Maybe B64UrlByteString, Maybe B64UrlByteString) -> User -toUser ((userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, contactLink, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_)) = - User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash} +toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (Bool, Bool, Bool, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime) -> User +toUser ((userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, contactLink, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt)) = + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash, userMemberProfileUpdatedAt} where profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences = userPreferences, localAlias = ""} fullPreferences = mergePreferences Nothing userPreferences diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index c834bc7a65..0bd133387b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -112,7 +112,8 @@ data User = User viewPwdHash :: Maybe UserPwdHash, showNtfs :: Bool, sendRcptsContacts :: Bool, - sendRcptsSmallGroups :: Bool + sendRcptsSmallGroups :: Bool, + userMemberProfileUpdatedAt :: Maybe UTCTime } deriving (Show) @@ -346,7 +347,8 @@ data GroupInfo = GroupInfo chatSettings :: ChatSettings, createdAt :: UTCTime, updatedAt :: UTCTime, - chatTs :: Maybe UTCTime + chatTs :: Maybe UTCTime, + userMemberProfileSentAt :: Maybe UTCTime } deriving (Eq, Show) @@ -481,6 +483,10 @@ profilesMatch LocalProfile {displayName = n2, fullName = fn2, image = i2} = n1 == n2 && fn1 == fn2 && i1 == i2 +redactedMemberProfile :: Profile -> Profile +redactedMemberProfile Profile {displayName, fullName, image} = + Profile {displayName, fullName, image, contactLink = Nothing, preferences = Nothing} + data IncognitoProfile = NewIncognito Profile | ExistingIncognito LocalProfile type LocalAlias = Text diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d0d0135f24..f894916c65 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1160,11 +1160,12 @@ viewGroupInfo GroupInfo {groupId} s = ] viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString] -viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias}, activeConn} stats = +viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias, contactLink}, activeConn} stats = [ "group ID: " <> sShow groupId, "member ID: " <> sShow groupMemberId ] <> maybe ["member not connected"] viewConnectionStats stats + <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact l)]) contactLink <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (memberSecurityCode m) | isJust stats] <> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index c388ccfb93..c2e45d98d4 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -133,6 +133,14 @@ chatGroupTests = do it "disappearing message is sent as disappearing" testGroupHistoryDisappearingMessage it "welcome message (group description) is sent after history" testGroupHistoryWelcomeMessage it "unknown member messages are processed" testGroupHistoryUnknownMember + describe "membership profile updates" $ do + it "send profile update on next message to group" testMembershipProfileUpdateNextGroupMessage + it "multiple groups with same member, update is applied only once" testMembershipProfileUpdateSameMember + it "member contact is active" testMembershipProfileUpdateContactActive + it "member contact is deleted" testMembershipProfileUpdateContactDeleted + it "member contact is deleted silently, then considered disabled" testMembershipProfileUpdateContactDisabled + it "profile update without change is ignored" testMembershipProfileUpdateNoChangeIgnored + it "change of profile contact link is ignored" testMembershipProfileUpdateContactLinkIgnored where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -4126,12 +4134,10 @@ testMemberContactProfileUpdate = bob <# "#team alice> hello" cath <# "#team alice> hello" - bob #> "#team hello too" - alice <# "#team rob> hello too" - cath <# "#team bob> hello too" -- not updated profile - cath #> "#team hello there" - alice <# "#team kate> hello there" - bob <# "#team cath> hello there" -- not updated profile + alice `hasContactProfiles` ["alice", "rob", "kate"] + bob `hasContactProfiles` ["rob", "alice", "cath"] + cath `hasContactProfiles` ["kate", "alice", "bob"] + bob `send` "@cath hi" bob <### [ "member #team cath does not have direct connection, creating", @@ -5248,3 +5254,444 @@ testGroupHistoryUnknownMember = [alice, dan] *<# "#team cath> 2" dan #> "#team 3" [alice, cath] *<# "#team dan> 3" + +testMembershipProfileUpdateNextGroupMessage :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateNextGroupMessage = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + -- create group 1 + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLinkTeam <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLinkTeam) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + -- create group 2 + alice ##> "/g club" + alice <## "group #club is created" + alice <## "to add members use /a club or /create link #club" + alice ##> "/create link #club" + gLinkClub <- getGroupLink alice "club" GRMember True + cath ##> ("/c " <> gLinkClub) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #club..." + concurrentlyN_ + [ alice <## "#club: cath joined the group", + do + cath <## "#club: joining the group..." + cath <## "#club: you joined the group" + ] + + -- alice has no contacts + alice ##> "/contacts" + + alice #> "#team hello team" + bob <# "#team alice> hello team" + + alice #> "#club hello club" + cath <# "#club alice> hello club" + + alice ##> "/p alisa" + alice <## "user profile is changed to alisa (your 0 contacts are notified)" + + -- update profile in group 1 + + bob ##> "/ms team" + bob + <### [ "bob (Bob): member, you, connected", + "alice (Alice): owner, host, connected" + ] + + alice #> "#team team 1" + bob <# "#team alisa> team 1" + cath "/ms team" + bob + <### [ "bob (Bob): member, you, connected", + "alisa: owner, host, connected" + ] + + alice #> "#team team 2" + bob <# "#team alisa> team 2" + + bob ##> "/_get chat #1 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, "updated profile")] + + -- update profile in group 2 + + cath ##> "/ms club" + cath + <### [ "cath (Catherine): member, you, connected", + "alice (Alice): owner, host, connected" + ] + + alice #> "#club club 1" + cath <# "#club alisa> club 1" + + cath ##> "/ms club" + cath + <### [ "cath (Catherine): member, you, connected", + "alisa: owner, host, connected" + ] + + alice #> "#club club 2" + cath <# "#club alisa> club 2" + + cath ##> "/_get chat #1 count=100" + rc <- chat <$> getTermLine cath + rc `shouldContain` [(0, "updated profile")] + +testMembershipProfileUpdateSameMember :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateSameMember = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + createGroup2' "club" alice bob False + + alice ##> "/d bob" + alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + + alice ##> "/p alisa" + alice <## "user profile is changed to alisa (your 0 contacts are notified)" + + bob `hasContactProfiles` ["alice", "bob"] + + alice #> "#team team 1" + bob <## "contact alice changed to alisa" + bob <## "use @alisa to send messages" + bob <# "#team alisa> team 1" + + -- since members were related to the same contact, both member records are updated + bob `hasContactProfiles` ["alisa", "bob"] + checkMembers bob + checkItems bob + + -- profile update is not processed in second group, since it hasn't changed + alice #> "#club club 1" + bob <# "#club alisa> club 1" + + bob `hasContactProfiles` ["alisa", "bob"] + checkMembers bob + checkItems bob + where + checkMembers bob = do + bob ##> "/ms team" + bob + <### [ "bob (Bob): admin, you, connected", + "alisa: owner, host, connected" + ] + bob ##> "/ms club" + bob + <### [ "bob (Bob): admin, you, connected", + "alisa: owner, host, connected" + ] + checkItems bob = do + bob ##> "/_get chat @2 count=100" + rCt <- chat <$> getTermLine bob + rCt `shouldNotContain` [(0, "updated profile")] + + bob ##> "/_get chat #1 count=100" + rTeam <- chat <$> getTermLine bob + rTeam `shouldContain` [(0, "updated profile")] + + bob ##> "/_get chat #2 count=100" + rClub <- chat <$> getTermLine bob + rClub `shouldNotContain` [(0, "updated profile")] + +testMembershipProfileUpdateContactActive :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactActive = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + + alice ##> "/contacts" + alice <## "bob (Bob)" + + alice #> "#team hello team" + bob <# "#team alice> hello team" + + alice ##> "/p alisa" + alice <## "user profile is changed to alisa (your 1 contacts are notified)" + bob <## "contact alice changed to alisa" + bob <## "use @alisa to send messages" + + bob `hasContactProfiles` ["alisa", "bob"] + + alice #> "#team team 1" + bob <# "#team alisa> team 1" + + bob `hasContactProfiles` ["alisa", "bob"] + + checkItems bob + + alice ##> "/ad" + cLink <- getContactLink alice True + alice ##> "/pa on" + alice <## "new contact address set" + bob <## "alisa set new contact address, use /info alisa to view" + + bob `hasContactProfiles` ["alisa", "bob"] + checkAliceProfileLink bob "alisa" cLink + + -- profile update does not remove contact address from profile + alice ##> "/p 'Alice Smith'" + alice <## "user profile is changed to 'Alice Smith' (your 1 contacts are notified)" + bob <## "contact alisa changed to 'Alice Smith'" + bob <## "use @'Alice Smith' to send messages" + + bob `hasContactProfiles` ["Alice Smith", "bob"] + checkAliceProfileLink bob "'Alice Smith'" cLink + + -- receiving group message does not remove contact address from profile + alice #> "#team team 2" + bob <# "#team 'Alice Smith'> team 2" + + bob `hasContactProfiles` ["Alice Smith", "bob"] + checkAliceProfileLink bob "'Alice Smith'" cLink + + checkItems bob + where + checkItems bob = do + bob ##> "/_get chat @2 count=100" + rCt <- chat <$> getTermLine bob + rCt `shouldContain` [(0, "updated profile")] + + bob ##> "/_get chat #1 count=100" + rGrp <- chat <$> getTermLine bob + rGrp `shouldNotContain` [(0, "updated profile")] + checkAliceProfileLink bob name cLink = do + bob ##> ("/info #team " <> name) + bob <## "group ID: 1" + bob <## "member ID: 1" + bob <##. "receiving messages via" + bob <##. "sending messages via" + bob <## ("contact address: " <> cLink) + bob <## "connection not verified, use /code command to see security code" + bob <## currentChatVRangeInfo + +testMembershipProfileUpdateContactDeleted :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactDeleted = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + + alice ##> "/contacts" + alice <## "bob (Bob)" + + alice #> "#team hello team" + bob <# "#team alice> hello team" + + alice ##> "/d bob" + alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + + alice ##> "/p alisa" + alice <## "user profile is changed to alisa (your 0 contacts are notified)" + + bob `hasContactProfiles` ["alice", "bob"] + + alice #> "#team team 1" + bob <## "contact alice changed to alisa" + bob <## "use @alisa to send messages" + bob <# "#team alisa> team 1" + + bob `hasContactProfiles` ["alisa", "bob"] + + checkItems bob + + -- adding contact address to profile does not share it with member + alice ##> "/ad" + _ <- getContactLink alice True + alice ##> "/pa on" + alice <## "new contact address set" + + bob `hasContactProfiles` ["alisa", "bob"] + checkAliceNoProfileLink bob "alisa" + + alice #> "#team team 2" + bob <# "#team alisa> team 2" + + bob `hasContactProfiles` ["alisa", "bob"] + checkAliceNoProfileLink bob "alisa" + + -- profile update does not add contact address to member profile + alice ##> "/p 'Alice Smith'" + alice <## "user profile is changed to 'Alice Smith' (your 0 contacts are notified)" + + bob `hasContactProfiles` ["alisa", "bob"] + checkAliceNoProfileLink bob "alisa" + + alice #> "#team team 3" + bob <## "contact alisa changed to 'Alice Smith'" + bob <## "use @'Alice Smith' to send messages" + bob <# "#team 'Alice Smith'> team 3" + + bob `hasContactProfiles` ["Alice Smith", "bob"] + checkAliceNoProfileLink bob "'Alice Smith'" + + checkItems bob + where + checkItems bob = do + bob ##> "/_get chat @2 count=100" + rCt <- chat <$> getTermLine bob + rCt `shouldNotContain` [(0, "updated profile")] + + bob ##> "/_get chat #1 count=100" + rGrp <- chat <$> getTermLine bob + rGrp `shouldContain` [(0, "updated profile")] + checkAliceNoProfileLink bob name = do + bob ##> ("/info #team " <> name) + bob <## "group ID: 1" + bob <## "member ID: 1" + bob <##. "receiving messages via" + bob <##. "sending messages via" + bob <## "connection not verified, use /code command to see security code" + bob <## currentChatVRangeInfo + +testMembershipProfileUpdateContactDisabled :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactDisabled = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + + alice ##> "/contacts" + alice <## "bob (Bob)" + + alice #> "#team hello team" + bob <# "#team alice> hello team" + + alice ##> "/_delete @2 notify=off" + alice <## "bob: contact is deleted" + + alice ##> "/p alisa" + alice <## "user profile is changed to alisa (your 0 contacts are notified)" + + bob `hasContactProfiles` ["alice", "bob"] + + -- bob expects update from contact, so he doesn't update profile + alice #> "#team team 1" + bob <# "#team alice> team 1" + + bob `hasContactProfiles` ["alice", "bob"] + + -- bob sends any message to alice, increases auth err counter + bob `send` "/feed hi all" + bob <##. "/feed (1)" + bob <## "[alice, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" + + -- on next profile update from alice member, bob considers contact disabled for purposes of profile update + alice #> "#team team 2" + bob <# "#team alice> team 2" + + bob `hasContactProfiles` ["alice", "bob"] + + alice ##> "/p 'Alice Smith'" + alice <## "user profile is changed to 'Alice Smith' (your 0 contacts are notified)" + + alice #> "#team team 3" + bob <## "contact alice changed to 'Alice Smith'" + bob <## "use @'Alice Smith' to send messages" + bob <# "#team 'Alice Smith'> team 3" + + bob `hasContactProfiles` ["Alice Smith", "bob"] + + bob ##> "/_get chat @2 count=100" + rCt <- chat <$> getTermLine bob + rCt `shouldNotContain` [(0, "updated profile")] + + bob ##> "/_get chat #1 count=100" + rGrp <- chat <$> getTermLine bob + rGrp `shouldContain` [(0, "updated profile")] + +testMembershipProfileUpdateNoChangeIgnored :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateNoChangeIgnored = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + + alice ##> "/contacts" + alice <## "bob (Bob)" + + alice #> "#team hello team" + bob <# "#team alice> hello team" + + alice ##> "/d bob" + alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + + alice ##> "/p alisa" + alice <## "user profile is changed to alisa (your 0 contacts are notified)" + + bob `hasContactProfiles` ["alice", "bob"] + + alice ##> "/p alice Alice" + alice <## "user profile is changed to alice (Alice) (your 0 contacts are notified)" + + bob `hasContactProfiles` ["alice", "bob"] + + alice #> "#team team 1" + bob <# "#team alice> team 1" + + bob `hasContactProfiles` ["alice", "bob"] + + bob ##> "/_get chat @2 count=100" + rCt <- chat <$> getTermLine bob + rCt `shouldNotContain` [(0, "updated profile")] + + bob ##> "/_get chat #1 count=100" + rGrp <- chat <$> getTermLine bob + rGrp `shouldNotContain` [(0, "updated profile")] + +testMembershipProfileUpdateContactLinkIgnored :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactLinkIgnored = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + + alice ##> "/contacts" + alice <## "bob (Bob)" + + alice #> "#team hello team" + bob <# "#team alice> hello team" + + alice ##> "/d bob" + alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + + alice ##> "/ad" + _ <- getContactLink alice True + alice ##> "/pa on" + alice <## "new contact address set" + + bob `hasContactProfiles` ["alice", "bob"] + + alice #> "#team team 1" + bob <# "#team alice> team 1" + + bob ##> "/_get chat @2 count=100" + rCt <- chat <$> getTermLine bob + rCt `shouldNotContain` [(0, "updated profile")] + + bob ##> "/_get chat #1 count=100" + rGrp <- chat <$> getTermLine bob + rGrp `shouldNotContain` [(0, "updated profile")] + + bob ##> "/info #team alice" + bob <## "group ID: 1" + bob <## "member ID: 1" + bob <##. "receiving messages via" + bob <##. "sending messages via" + bob <## "connection not verified, use /code command to see security code" + bob <## currentChatVRangeInfo diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index afa9fb3cff..7b5c883b57 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1559,7 +1559,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ alice <## "contact bob removed full name" alice <## "bob updated preferences for you:" alice <## "Voice messages: enabled (you allow: yes, contact allows: yes)" - alice #$> ("/_get chat @2 count=100", chat, startFeatures <> [(1, "Voice messages: enabled for contact"), (0, "voice message (00:10)"), (1, "Voice messages: off"), (0, "Voice messages: enabled")]) + alice #$> ("/_get chat @2 count=100", chat, startFeatures <> [(1, "Voice messages: enabled for contact"), (0, "voice message (00:10)"), (1, "Voice messages: off"), (0, "updated profile"), (0, "Voice messages: enabled")]) (alice "/_set prefs @2 {}" bob <## "your preferences for alice did not change" @@ -1570,7 +1570,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ alice ##> "/_set prefs @2 {\"voice\": {\"allow\": \"no\"}}" alice <## "you updated preferences for bob:" alice <## "Voice messages: off (you allow: no, contact allows: yes)" - alice #$> ("/_get chat @2 count=100", chat, startFeatures <> [(1, "Voice messages: enabled for contact"), (0, "voice message (00:10)"), (1, "Voice messages: off"), (0, "Voice messages: enabled"), (1, "Voice messages: off")]) + alice #$> ("/_get chat @2 count=100", chat, startFeatures <> [(1, "Voice messages: enabled for contact"), (0, "voice message (00:10)"), (1, "Voice messages: off"), (0, "updated profile"), (0, "Voice messages: enabled"), (1, "Voice messages: off")]) bob <## "alice updated preferences for you:" bob <## "Voice messages: off (you allow: default (yes), contact allows: no)" bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you"), (1, "voice message (00:10)"), (0, "Voice messages: off"), (1, "Voice messages: enabled"), (0, "Voice messages: off")]) diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 249a1bd3ce..3ab7cf1a97 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -129,7 +129,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-6\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-7\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -239,13 +239,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"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\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-6\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"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\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-6\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.inv" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/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\":\"simplex:/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\"}}}" @@ -257,7 +257,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/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\":\"simplex:/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\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/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==\",\"v\":\"1-6\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/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==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"