diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 124da977c..e3d10e1ad 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -496,6 +496,14 @@ data class Chat ( else -> false } + val userIsObserver: Boolean get() = when(chatInfo) { + is ChatInfo.Group -> { + val m = chatInfo.groupInfo.membership + m.memberActive && m.memberRole == GroupMemberRole.Observer + } + else -> false + } + val id: String get() = chatInfo.id @Serializable @@ -942,7 +950,7 @@ data class GroupMember ( fun canChangeRoleTo(groupInfo: GroupInfo): List? = if (!canBeRemoved(groupInfo)) null else groupInfo.membership.memberRole.let { userRole -> - GroupMemberRole.values().filter { it <= userRole } + GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Observer } } val memberIncognito = memberProfile.profileId != memberContactProfileId diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 2222d50a8..8641b4a08 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -83,6 +83,7 @@ fun TerminalLayout( liveMessageAlertShown = SharedPreference(get = { false }, set = {}), needToAllowVoiceToContact = false, allowedVoiceByPrefs = false, + userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, sendMessage = sendCommand, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index b4ea1ec0f..7d0ebe38c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -648,6 +648,7 @@ fun ComposeView( } val userCanSend = rememberUpdatedState(chat.userCanSend) + val userIsObserver = rememberUpdatedState(chat.userIsObserver) Column { contextItemView() @@ -744,6 +745,7 @@ fun ComposeView( needToAllowVoiceToContact, allowedVoiceByPrefs, allowVoiceToContact = ::allowVoiceToContact, + userIsObserver = userIsObserver.value, userCanSend = userCanSend.value, sendMessage = { sendMessage() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 01bebb0ad..d213296a3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -60,6 +60,7 @@ fun SendMsgView( liveMessageAlertShown: SharedPreference, needToAllowVoiceToContact: Boolean, allowedVoiceByPrefs: Boolean, + userIsObserver: Boolean, userCanSend: Boolean, allowVoiceToContact: () -> Unit, sendMessage: () -> Unit, @@ -75,7 +76,7 @@ fun SendMsgView( val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } - NativeKeyboard(composeState, textStyle, showDeleteTextButton, userCanSend, onMessageChange) + NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange) // Disable clicks on text field if (cs.preview is ComposePreview.VoicePreview || !userCanSend) { Box(Modifier @@ -182,7 +183,7 @@ private fun NativeKeyboard( composeState: MutableState, textStyle: MutableState, showDeleteTextButton: MutableState, - userCanSend: Boolean, + userIsObserver: Boolean, onMessageChange: (String) -> Unit ) { val cs = composeState.value @@ -262,16 +263,23 @@ private fun NativeKeyboard( } showDeleteTextButton.value = it.lineCount >= 4 } - if (composeState.value.preview is ComposePreview.VoicePreview || !userCanSend) { - Text( - if (composeState.value.preview is ComposePreview.VoicePreview) generalGetString(R.string.voice_message_send_text) else generalGetString(R.string.you_are_observer), - Modifier.padding(padding), - color = HighOrLowlight, - style = textStyle.value.copy(fontStyle = FontStyle.Italic) - ) + if (composeState.value.preview is ComposePreview.VoicePreview) { + ComposeOverlay(R.string.voice_message_send_text, textStyle, padding) + } else if (userIsObserver) { + ComposeOverlay(R.string.you_are_observer, textStyle, padding) } } +@Composable +private fun ComposeOverlay(textId: Int, textStyle: MutableState, padding: PaddingValues) { + Text( + generalGetString(textId), + Modifier.padding(padding), + color = HighOrLowlight, + style = textStyle.value.copy(fontStyle = FontStyle.Italic) + ) +} + @Composable private fun BoxScope.DeleteTextButton(composeState: MutableState) { IconButton( @@ -581,6 +589,7 @@ fun PreviewSendMsgView() { liveMessageAlertShown = SharedPreference(get = { true }, set = { }), needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, + userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, sendMessage = {}, @@ -610,6 +619,7 @@ fun PreviewSendMsgViewEditing() { liveMessageAlertShown = SharedPreference(get = { true }, set = { }), needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, + userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, sendMessage = {}, @@ -639,6 +649,7 @@ fun PreviewSendMsgViewInProgress() { liveMessageAlertShown = SharedPreference(get = { true }, set = { }), needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, + userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, sendMessage = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt index 1ff21e665..753b2746b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt @@ -166,7 +166,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState some View { Picker("New member role", selection: $selectedRole) { ForEach(GroupMemberRole.allCases) { role in - if role <= groupInfo.membership.memberRole { + if role <= groupInfo.membership.memberRole && role != .observer { Text(role.text) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index aeef91212..0c4c1c839 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -34,15 +34,15 @@ struct GroupLinkView: View { Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") .padding(.bottom) if let groupLink = groupLink { - HStack { - Text("Initial role") - Picker("Initial role", selection: $groupLinkMemberRole) { - ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in - Text(role.text) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) +// HStack { +// Text("Initial role") +// Picker("Initial role", selection: $groupLinkMemberRole) { +// ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in +// Text(role.text) +// } +// } +// } +// .frame(maxWidth: .infinity, alignment: .leading) QRCode(uri: groupLink) HStack { Button { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 71850a5ee..d88c4c581 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1517,7 +1517,7 @@ public struct GroupMember: Identifiable, Decodable { public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { if !canBeRemoved(groupInfo: groupInfo) { return nil } let userRole = groupInfo.membership.memberRole - return GroupMemberRole.allCases.filter { $0 <= userRole } + return GroupMemberRole.allCases.filter { $0 <= userRole && $0 != .observer } } public var memberIncognito: Bool { diff --git a/package.yaml b/package.yaml index cacaf09a5..cd9a258f8 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 4.5.3.1 +version: 4.5.4.2 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 94b035af2..f25ff03e6 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 4.5.3.1 +version: 4.5.4.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e8c0790bc..d3044b267 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3439,9 +3439,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState) mergeContacts :: Contact -> Contact -> m () - mergeContacts to from = do - withStore' $ \db -> mergeContactRecords db userId to from - toView $ CRContactsMerged user to from + mergeContacts c1 c2 = do + withStore' $ \db -> mergeContactRecords db userId c1 c2 + toView $ CRContactsMerged user c1 c2 saveConnInfo :: Connection -> ConnInfo -> m () saveConnInfo activeConn connInfo = do diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 7669816dc..492dc0ad1 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1622,8 +1622,17 @@ matchSentProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = d cId : _ -> eitherToMaybe <$> runExceptT (getContact db user cId) mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO () -mergeContactRecords db userId Contact {contactId = toContactId} Contact {contactId = fromContactId, localDisplayName} = do +mergeContactRecords db userId ct1 ct2 = do + let (toCt, fromCt) = toFromContacts ct1 ct2 + Contact {contactId = toContactId} = toCt + Contact {contactId = fromContactId, localDisplayName} = fromCt currentTs <- getCurrentTime + -- TODO next query fixes incorrect unused contacts deletion; consider more thorough fix + when (contactDirect toCt && not (contactUsed toCt)) $ + DB.execute + db + "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" + (currentTs, userId, toContactId) DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" @@ -1659,6 +1668,17 @@ mergeContactRecords db userId Contact {contactId = toContactId} Contact {contact deleteContactProfile_ db userId fromContactId DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) + where + toFromContacts :: Contact -> Contact -> (Contact, Contact) + toFromContacts c1 c2 + | d1 && not d2 = (c1, c2) + | d2 && not d1 = (c2, c1) + | ctCreatedAt c1 <= ctCreatedAt c2 = (c1, c2) + | otherwise = (c2, c1) + where + d1 = directOrUsed c1 + d2 = directOrUsed c2 + ctCreatedAt Contact {createdAt} = createdAt getConnectionEntity :: DB.Connection -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity getConnectionEntity db user@User {userId, userContactId} agentConnId = do diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 6c475b24f..5140ca7f7 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -159,9 +159,12 @@ contactConnId = aConnId . contactConn contactConnIncognito :: Contact -> Bool contactConnIncognito = connIncognito . contactConn +contactDirect :: Contact -> Bool +contactDirect Contact {activeConn = Connection {connLevel, viaGroupLink}} = connLevel == 0 && not viaGroupLink + directOrUsed :: Contact -> Bool -directOrUsed Contact {contactUsed, activeConn = Connection {connLevel, viaGroupLink}} = - (connLevel == 0 && not viaGroupLink) || contactUsed +directOrUsed ct@Contact {contactUsed} = + contactDirect ct || contactUsed anyDirectOrUsed :: Contact -> Bool anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 5b9048a62..a6b180266 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -47,6 +47,7 @@ chatGroupTests = do it "unused host contact is deleted after all groups with it are deleted" testGroupLinkUnusedHostContactDeleted it "leaving groups with unused host contacts deletes incognito profiles" testGroupLinkIncognitoUnusedHostContactsDeleted it "group link member role" testGroupLinkMemberRole + it "leaving and deleting the group joined via link should NOT delete previously existing direct contacts" testGroupLinkLeaveDelete testGroup :: HasCallStack => SpecWith FilePath testGroup = versionTestMatrix3 runTestGroup @@ -1916,3 +1917,72 @@ testGroupLinkMemberRole = concurrently_ (alice <# "#team bob> hey now") (cath <# "#team bob> hey now") + +testGroupLinkLeaveDelete :: HasCallStack => FilePath -> IO () +testGroupLinkLeaveDelete = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + connectUsers cath bob + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice + <### [ "bob_1 (Bob): contact is connected", + "contact bob_1 is merged into bob", + "use @bob to send messages", + EndsWith "invited to group #team via your group link", + EndsWith "joined the group" + ], + bob + <### [ "alice_1 (Alice): contact is connected", + "contact alice_1 is merged into alice", + "use @alice to send messages", + "#team: you joined the group" + ] + ] + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ alice + <### [ "cath (Catherine): contact is connected", + "cath invited to group #team via your group link", + "#team: cath joined the group" + ], + cath + <### [ "alice (Alice): contact is connected", + "#team: you joined the group", + "#team: member bob_1 (Bob) is connected", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ], + bob + <### [ "#team: alice added cath_1 (Catherine) to the group (connecting...)", + "#team: new member cath_1 is connected", + "contact cath_1 is merged into cath", + "use @cath to send messages" + ] + ] + 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" + ] + bob ##> "/contacts" + bob <## "alice (Alice)" + bob <## "cath (Catherine)" + bob ##> "/d #team" + bob <## "#team: you deleted the group" + bob ##> "/contacts" + bob <## "alice (Alice)" + bob <## "cath (Catherine)"