diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index cd58b2000..a40827003 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1822,9 +1822,16 @@ public struct GroupMember: Identifiable, Decodable { public var chatViewName: String { get { let p = memberProfile - return p.localAlias == "" - ? p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)") - : p.localAlias + let name = ( + p.localAlias == "" + ? p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)") + : p.localAlias + ) + return ( + memberStatus == .memUnknown + ? String.localizedStringWithFormat(NSLocalizedString("_Previous member_ %@", comment: "previous/unknown group member"), name) + : name + ) } } @@ -1833,6 +1840,7 @@ public struct GroupMember: Identifiable, Decodable { case .memRemoved: return false case .memLeft: return false case .memGroupDeleted: return false + case .memUnknown: return false case .memInvited: return false case .memIntroduced: return false case .memIntroInvited: return false @@ -1849,6 +1857,7 @@ public struct GroupMember: Identifiable, Decodable { case .memRemoved: return false case .memLeft: return false case .memGroupDeleted: return false + case .memUnknown: return false case .memInvited: return false case .memIntroduced: return true case .memIntroInvited: return true @@ -1953,6 +1962,7 @@ public enum GroupMemberStatus: String, Decodable { case memRemoved = "removed" case memLeft = "left" case memGroupDeleted = "deleted" + case memUnknown = "unknown" case memInvited = "invited" case memIntroduced = "introduced" case memIntroInvited = "intro-inv" @@ -1967,6 +1977,7 @@ public enum GroupMemberStatus: String, Decodable { case .memRemoved: return "removed" case .memLeft: return "left" case .memGroupDeleted: return "group deleted" + case .memUnknown: return "unknown status" case .memInvited: return "invited" case .memIntroduced: return "connecting (introduced)" case .memIntroInvited: return "connecting (introduction invitation)" @@ -1983,6 +1994,7 @@ public enum GroupMemberStatus: String, Decodable { case .memRemoved: return "removed" case .memLeft: return "left" case .memGroupDeleted: return "group deleted" + case .memUnknown: return "unknown" case .memInvited: return "invited" case .memIntroduced: return "connecting" case .memIntroInvited: return "connecting" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index b59d57d8a..b76bc05a0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1268,12 +1268,19 @@ data class GroupMember ( val verified get() = activeConn?.connectionCode != null val chatViewName: String - get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") } + get() { + val name = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") } + return if (memberStatus == GroupMemberStatus.MemUnknown) + String.format(generalGetString(MR.strings.previous_member_vName), name) + else + name + } val memberActive: Boolean get() = when (this.memberStatus) { GroupMemberStatus.MemRemoved -> false GroupMemberStatus.MemLeft -> false GroupMemberStatus.MemGroupDeleted -> false + GroupMemberStatus.MemUnknown -> false GroupMemberStatus.MemInvited -> false GroupMemberStatus.MemIntroduced -> false GroupMemberStatus.MemIntroInvited -> false @@ -1288,6 +1295,7 @@ data class GroupMember ( GroupMemberStatus.MemRemoved -> false GroupMemberStatus.MemLeft -> false GroupMemberStatus.MemGroupDeleted -> false + GroupMemberStatus.MemUnknown -> false GroupMemberStatus.MemInvited -> false GroupMemberStatus.MemIntroduced -> true GroupMemberStatus.MemIntroInvited -> true @@ -1377,6 +1385,7 @@ enum class GroupMemberStatus { @SerialName("removed") MemRemoved, @SerialName("left") MemLeft, @SerialName("deleted") MemGroupDeleted, + @SerialName("unknown") MemUnknown, @SerialName("invited") MemInvited, @SerialName("introduced") MemIntroduced, @SerialName("intro-inv") MemIntroInvited, @@ -1390,6 +1399,7 @@ enum class GroupMemberStatus { MemRemoved -> generalGetString(MR.strings.group_member_status_removed) MemLeft -> generalGetString(MR.strings.group_member_status_left) MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted) + MemUnknown -> generalGetString(MR.strings.group_member_status_unknown) MemInvited -> generalGetString(MR.strings.group_member_status_invited) MemIntroduced -> generalGetString(MR.strings.group_member_status_introduced) MemIntroInvited -> generalGetString(MR.strings.group_member_status_intro_invitation) @@ -1404,6 +1414,7 @@ enum class GroupMemberStatus { MemRemoved -> generalGetString(MR.strings.group_member_status_removed) MemLeft -> generalGetString(MR.strings.group_member_status_left) MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted) + MemUnknown -> generalGetString(MR.strings.group_member_status_unknown_short) MemInvited -> generalGetString(MR.strings.group_member_status_invited) MemIntroduced -> generalGetString(MR.strings.group_member_status_connecting) MemIntroInvited -> generalGetString(MR.strings.group_member_status_connecting) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 0d076c94c..8f1b25f2e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1217,6 +1217,7 @@ removed left group deleted + unknown status invited connecting (introduced) connecting (introduction invitation) @@ -1227,6 +1228,9 @@ creator connecting + unknown + + Previous member %1$s]]> No contacts to add diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index dbf7c6079..f455ca63a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -5100,16 +5100,24 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure conn' xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> UTCTime -> m () - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ memberProfile) msg brokerTs = do + xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msg brokerTs = do checkHostRole m memRole unless (sameMemberId memId $ membership gInfo) $ withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do + updatedMember <- withStore $ \db -> updateUnknownMemberAnnounced db user m unknownMember memInfo + toView $ CRUnknownMemberAnnounced user gInfo m unknownMember updatedMember + memberAnnouncedToView updatedMember Right _ -> messageError "x.grp.mem.new error: member already exists" Left _ -> do - newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile) - groupMsgToView gInfo ci - toView $ CRJoinedGroupMemberConnecting user gInfo m newMember + newMember <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + memberAnnouncedToView newMember + where + memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} = do + let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent event) + groupMsgToView gInfo ci + toView $ CRJoinedGroupMemberConnecting user gInfo m announcedMember xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) = do @@ -5355,8 +5363,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> ChatMessage 'Json -> UTCTime -> m () xGrpMsgForward gInfo@GroupInfo {groupId} m@GroupMember {memberRole, localDisplayName} memberId msg msgTs = do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole localDisplayName) - author <- withStore $ \db -> getGroupMemberByMemberId db user gInfo memberId - processForwardedMsg author msg + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memberId) >>= \case + Right author -> processForwardedMsg author msg + Left (SEGroupMemberNotFoundByMemberId _) -> do + let name = T.take 7 . safeDecodeUtf8 . B64.encode . unMemberId $ memberId + unknownAuthor <- withStore $ \db -> createNewUnknownGroupMember db vr user gInfo memberId name + toView $ CRUnknownMemberCreated user gInfo m unknownAuthor + processForwardedMsg unknownAuthor msg + Left e -> throwError $ ChatErrorStore e where -- Note: forwarded group events (see forwardedGroupMsg) should include msgId to be deduplicated processForwardedMsg :: GroupMember -> ChatMessage 'Json -> m () diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a5282c2b3..f9004db4c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -630,6 +630,8 @@ data ChatResponse | CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} | CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} + | CRUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} | CRGroupEmpty {user :: User, groupInfo :: GroupInfo} | CRGroupRemoved {user :: User, groupInfo :: GroupInfo} | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 7fedc10fa..f174d348f 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -110,6 +110,8 @@ module Simplex.Chat.Store.Groups updateMemberProfile, getXGrpLinkMemReceived, setXGrpLinkMemReceived, + createNewUnknownGroupMember, + updateUnknownMemberAnnounced, ) where @@ -594,11 +596,9 @@ getGroupSummary db User {userId} groupId = do JOIN group_members m USING (group_id) WHERE g.user_id = ? AND g.group_id = ? - AND m.member_status != ? - AND m.member_status != ? - AND m.member_status != ? + AND m.member_status NOT IN (?,?,?,?) |] - (userId, groupId, GSMemRemoved, GSMemLeft, GSMemInvited) + (userId, groupId, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited) pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_} getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [FullGroupPreferences] @@ -682,13 +682,13 @@ getGroupMembersForExpiration db user@User {userId, userContactId} GroupInfo {gro ( groupMemberQuery <> [sql| WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) - AND m.member_status IN (?, ?, ?) + AND m.member_status IN (?, ?, ?, ?) AND m.group_member_id NOT IN ( SELECT DISTINCT group_member_id FROM chat_items ) |] ) - (userId, groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + (userId, groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) toContactMember :: User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember toContactMember User {userContactId} (memberRow :. connRow) = @@ -1339,10 +1339,10 @@ getGroupInfoByGroupLinkHash db vr user@User {userId, userContactId} (groupLinkHa FROM groups g JOIN group_members mu ON mu.group_id = g.group_id WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?) - AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?) + AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?,?) LIMIT 1 |] - (userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + (userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db vr user) groupId_ getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId @@ -1965,3 +1965,52 @@ setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do db "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" (xGrpLinkMemReceived, currentTs, mId) + +createNewUnknownGroupMember :: DB.Connection -> VersionRange -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember +createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName = do + currentTs <- liftIO getCurrentTime + let memberProfile = profileFromName memberName + (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs + groupMemberId <- liftIO $ do + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, GRAuthor, GCPreMember, GSMemUnknown, fromInvitedBy userContactId IBUnknown) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + :. (minV, maxV) + ) + insertedRowId db + getGroupMemberById db user groupMemberId + where + VersionRange minV maxV = vr + +updateUnknownMemberAnnounced :: DB.Connection -> User -> GroupMember -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember +updateUnknownMemberAnnounced db user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do + _ <- updateMemberProfile db user unknownMember profile + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + UPDATE group_members + SET member_role = ?, + member_category = ?, + member_status = ?, + invited_by_group_member_id = ?, + peer_chat_min_version = ?, + peer_chat_max_version = ?, + updated_at = ? + WHERE user_id = ? AND group_member_id = ? + |] + ( (memberRole, GCPostMember, GSMemAnnounced, groupMemberId' invitingMember) + :. (minV, maxV, currentTs, userId, groupMemberId) + ) + getGroupMemberById db user groupMemberId + where + VersionRange minV maxV = maybe (fromJVersionRange memberChatVRange) fromChatVRange v diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index dd832d6fa..889c6fe5e 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -825,6 +825,7 @@ data GroupMemberStatus = GSMemRemoved -- member who was removed from the group | GSMemLeft -- member who left the group | GSMemGroupDeleted -- user member of the deleted group + | GSMemUnknown -- unknown member, whose message was forwarded by an admin (likely member wasn't introduced due to not being a current member, but message was included in history) | GSMemInvited -- member is sent to or received invitation to join the group | GSMemIntroduced -- user received x.grp.mem.intro for this member (only with GCPreMember) | GSMemIntroInvited -- member is sent to or received from intro invitation @@ -851,6 +852,7 @@ memberActive m = case memberStatus m of GSMemRemoved -> False GSMemLeft -> False GSMemGroupDeleted -> False + GSMemUnknown -> False GSMemInvited -> False GSMemIntroduced -> False GSMemIntroInvited -> False @@ -869,6 +871,7 @@ memberCurrent' = \case GSMemRemoved -> False GSMemLeft -> False GSMemGroupDeleted -> False + GSMemUnknown -> False GSMemInvited -> False GSMemIntroduced -> True GSMemIntroInvited -> True @@ -883,6 +886,7 @@ memberRemoved m = case memberStatus m of GSMemRemoved -> True GSMemLeft -> True GSMemGroupDeleted -> True + GSMemUnknown -> False GSMemInvited -> False GSMemIntroduced -> False GSMemIntroInvited -> False @@ -897,6 +901,7 @@ instance TextEncoding GroupMemberStatus where "removed" -> Just GSMemRemoved "left" -> Just GSMemLeft "deleted" -> Just GSMemGroupDeleted + "unknown" -> Just GSMemUnknown "invited" -> Just GSMemInvited "introduced" -> Just GSMemIntroduced "intro-inv" -> Just GSMemIntroInvited @@ -910,6 +915,7 @@ instance TextEncoding GroupMemberStatus where GSMemRemoved -> "removed" GSMemLeft -> "left" GSMemGroupDeleted -> "deleted" + GSMemUnknown -> "unknown" GSMemInvited -> "invited" GSMemIntroduced -> "introduced" GSMemIntroInvited -> "intro-inv" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index bc1743276..c68f54eca 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -178,6 +178,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] CRUserDeletedMember u g m -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g + CRUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] + CRUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m] CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"] CRRcvFileDescrReady _ _ -> [] CRRcvFileDescrNotReady _ _ -> [] @@ -963,6 +965,7 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt status m = case memberStatus m of GSMemRemoved -> ["removed"] GSMemLeft -> ["left"] + GSMemUnknown -> ["status unknown"] GSMemInvited -> ["not yet joined"] GSMemConnected -> ["connected"] GSMemComplete -> ["connected"] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 28ca35cba..c388ccfb9 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -132,6 +132,7 @@ chatGroupTests = do it "deleted message is not included" testGroupHistoryDeletedMessage 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 where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -5179,3 +5180,71 @@ testGroupHistoryWelcomeMessage = [alice, cath] *<# "#team bob> 2" cath #> "#team 3" [alice, bob] *<# "#team cath> 3" + +testGroupHistoryUnknownMember :: HasCallStack => FilePath -> IO () +testGroupHistoryUnknownMember = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup3 "team" alice bob cath + + threadDelay 1000000 + + alice #> "#team hi from alice" + [bob, cath] *<# "#team alice> hi from alice" + + threadDelay 1000000 + + bob #> "#team hi from bob" + [alice, cath] *<# "#team bob> hi from bob" + + threadDelay 1000000 + + cath #> "#team hi from cath" + [alice, bob] *<# "#team cath> hi from cath" + + bob ##> "/l team" + concurrentlyN_ + [ do + bob <## "#team: you left the group" + bob <## "use /d #team to delete the group", + alice <## "#team: bob left the group", + cath <## "#team: bob left the group" + ] + + connectUsers alice dan + addMember "team" alice dan GRAdmin + dan ##> "/j team" + concurrentlyN_ + [ alice <## "#team: dan joined the group", + dan + <### [ "#team: you joined the group", + WithTime "#team alice> hi from alice [>>]", + StartsWith "#team: alice forwarded a message from an unknown member, creating unknown member record", + EndsWith "hi from bob [>>]", + WithTime "#team cath> hi from cath [>>]", + "#team: member cath (Catherine) is connected" + ], + do + cath <## "#team: alice added dan (Daniel) to the group (connecting...)" + cath <## "#team: new member dan is connected" + ] + + dan ##> "/_get chat #1 count=100" + r <- chat <$> getTermLine dan + r `shouldContain` [(0, "hi from alice"), (0, "hi from bob"), (0, "hi from cath")] + + dan ##> "/ms team" + dan + <### [ "dan (Daniel): admin, you, connected", + "alice (Alice): owner, host, connected", + "cath (Catherine): admin, connected", + EndsWith "author, status unknown" + ] + + -- message delivery works after sending history + alice #> "#team 1" + [cath, dan] *<# "#team alice> 1" + cath #> "#team 2" + [alice, dan] *<# "#team cath> 2" + dan #> "#team 3" + [alice, cath] *<# "#team dan> 3"