Merge branch 'master' into xftp

This commit is contained in:
Evgeny Poberezkin 2023-03-18 08:38:27 +00:00
commit 858f0f2650
17 changed files with 158 additions and 34 deletions

View File

@ -496,6 +496,14 @@ data class Chat (
else -> false 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 val id: String get() = chatInfo.id
@Serializable @Serializable
@ -942,7 +950,7 @@ data class GroupMember (
fun canChangeRoleTo(groupInfo: GroupInfo): List<GroupMemberRole>? = fun canChangeRoleTo(groupInfo: GroupInfo): List<GroupMemberRole>? =
if (!canBeRemoved(groupInfo)) null if (!canBeRemoved(groupInfo)) null
else groupInfo.membership.memberRole.let { userRole -> else groupInfo.membership.memberRole.let { userRole ->
GroupMemberRole.values().filter { it <= userRole } GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Observer }
} }
val memberIncognito = memberProfile.profileId != memberContactProfileId val memberIncognito = memberProfile.profileId != memberContactProfileId

View File

@ -83,6 +83,7 @@ fun TerminalLayout(
liveMessageAlertShown = SharedPreference(get = { false }, set = {}), liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
needToAllowVoiceToContact = false, needToAllowVoiceToContact = false,
allowedVoiceByPrefs = false, allowedVoiceByPrefs = false,
userIsObserver = false,
userCanSend = true, userCanSend = true,
allowVoiceToContact = {}, allowVoiceToContact = {},
sendMessage = sendCommand, sendMessage = sendCommand,

View File

@ -648,6 +648,7 @@ fun ComposeView(
} }
val userCanSend = rememberUpdatedState(chat.userCanSend) val userCanSend = rememberUpdatedState(chat.userCanSend)
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
Column { Column {
contextItemView() contextItemView()
@ -744,6 +745,7 @@ fun ComposeView(
needToAllowVoiceToContact, needToAllowVoiceToContact,
allowedVoiceByPrefs, allowedVoiceByPrefs,
allowVoiceToContact = ::allowVoiceToContact, allowVoiceToContact = ::allowVoiceToContact,
userIsObserver = userIsObserver.value,
userCanSend = userCanSend.value, userCanSend = userCanSend.value,
sendMessage = { sendMessage = {
sendMessage() sendMessage()

View File

@ -60,6 +60,7 @@ fun SendMsgView(
liveMessageAlertShown: SharedPreference<Boolean>, liveMessageAlertShown: SharedPreference<Boolean>,
needToAllowVoiceToContact: Boolean, needToAllowVoiceToContact: Boolean,
allowedVoiceByPrefs: Boolean, allowedVoiceByPrefs: Boolean,
userIsObserver: Boolean,
userCanSend: Boolean, userCanSend: Boolean,
allowVoiceToContact: () -> Unit, allowVoiceToContact: () -> Unit,
sendMessage: () -> Unit, sendMessage: () -> Unit,
@ -75,7 +76,7 @@ fun SendMsgView(
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
NativeKeyboard(composeState, textStyle, showDeleteTextButton, userCanSend, onMessageChange) NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange)
// Disable clicks on text field // Disable clicks on text field
if (cs.preview is ComposePreview.VoicePreview || !userCanSend) { if (cs.preview is ComposePreview.VoicePreview || !userCanSend) {
Box(Modifier Box(Modifier
@ -182,7 +183,7 @@ private fun NativeKeyboard(
composeState: MutableState<ComposeState>, composeState: MutableState<ComposeState>,
textStyle: MutableState<TextStyle>, textStyle: MutableState<TextStyle>,
showDeleteTextButton: MutableState<Boolean>, showDeleteTextButton: MutableState<Boolean>,
userCanSend: Boolean, userIsObserver: Boolean,
onMessageChange: (String) -> Unit onMessageChange: (String) -> Unit
) { ) {
val cs = composeState.value val cs = composeState.value
@ -262,16 +263,23 @@ private fun NativeKeyboard(
} }
showDeleteTextButton.value = it.lineCount >= 4 showDeleteTextButton.value = it.lineCount >= 4
} }
if (composeState.value.preview is ComposePreview.VoicePreview || !userCanSend) { if (composeState.value.preview is ComposePreview.VoicePreview) {
Text( ComposeOverlay(R.string.voice_message_send_text, textStyle, padding)
if (composeState.value.preview is ComposePreview.VoicePreview) generalGetString(R.string.voice_message_send_text) else generalGetString(R.string.you_are_observer), } else if (userIsObserver) {
Modifier.padding(padding), ComposeOverlay(R.string.you_are_observer, textStyle, padding)
color = HighOrLowlight,
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
)
} }
} }
@Composable
private fun ComposeOverlay(textId: Int, textStyle: MutableState<TextStyle>, padding: PaddingValues) {
Text(
generalGetString(textId),
Modifier.padding(padding),
color = HighOrLowlight,
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
)
}
@Composable @Composable
private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>) { private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>) {
IconButton( IconButton(
@ -581,6 +589,7 @@ fun PreviewSendMsgView() {
liveMessageAlertShown = SharedPreference(get = { true }, set = { }), liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false, needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true, allowedVoiceByPrefs = true,
userIsObserver = false,
userCanSend = true, userCanSend = true,
allowVoiceToContact = {}, allowVoiceToContact = {},
sendMessage = {}, sendMessage = {},
@ -610,6 +619,7 @@ fun PreviewSendMsgViewEditing() {
liveMessageAlertShown = SharedPreference(get = { true }, set = { }), liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false, needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true, allowedVoiceByPrefs = true,
userIsObserver = false,
userCanSend = true, userCanSend = true,
allowVoiceToContact = {}, allowVoiceToContact = {},
sendMessage = {}, sendMessage = {},
@ -639,6 +649,7 @@ fun PreviewSendMsgViewInProgress() {
liveMessageAlertShown = SharedPreference(get = { true }, set = { }), liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false, needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true, allowedVoiceByPrefs = true,
userIsObserver = false,
userCanSend = true, userCanSend = true,
allowVoiceToContact = {}, allowVoiceToContact = {},
sendMessage = {}, sendMessage = {},

View File

@ -166,7 +166,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
val values = GroupMemberRole.values().filter { it <= groupInfo.membership.memberRole }.map { it to it.text } val values = GroupMemberRole.values().filter { it <= groupInfo.membership.memberRole && it != GroupMemberRole.Observer }.map { it to it.text }
ExposedDropDownSettingRow( ExposedDropDownSettingRow(
generalGetString(R.string.new_member_role), generalGetString(R.string.new_member_role),
values, values,

View File

@ -120,9 +120,9 @@ fun GroupLinkLayout(
if (groupLink == null) { if (groupLink == null) {
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink) SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink)
} else { } else {
SectionItemView(padding = PaddingValues(bottom = DEFAULT_PADDING)) { // SectionItemView(padding = PaddingValues(bottom = DEFAULT_PADDING)) {
RoleSelectionRow(groupInfo, groupLinkMemberRole) // RoleSelectionRow(groupInfo, groupLinkMemberRole)
} // }
var initialLaunch by remember { mutableStateOf(true) } var initialLaunch by remember { mutableStateOf(true) }
LaunchedEffect(groupLinkMemberRole.value) { LaunchedEffect(groupLinkMemberRole.value) {
if (!initialLaunch) { if (!initialLaunch) {

View File

@ -555,6 +555,15 @@ final class Chat: ObservableObject, Identifiable {
} }
} }
var userIsObserver: Bool {
switch chatInfo {
case let .group(groupInfo):
let m = groupInfo.membership
return m.memberActive && m.memberRole == .observer
default: return false
}
}
var id: ChatId { get { chatInfo.id } } var id: ChatId { get { chatInfo.id } }
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }

View File

@ -291,7 +291,7 @@ struct ComposeView: View {
.background(.background) .background(.background)
.disabled(!chat.userCanSend) .disabled(!chat.userCanSend)
if (!chat.userCanSend) { if chat.userIsObserver {
Text("you are observer") Text("you are observer")
.italic() .italic()
.foregroundColor(.secondary) .foregroundColor(.secondary)

View File

@ -140,7 +140,7 @@ struct AddGroupMembersView: View {
private func rolePicker() -> some View { private func rolePicker() -> some View {
Picker("New member role", selection: $selectedRole) { Picker("New member role", selection: $selectedRole) {
ForEach(GroupMemberRole.allCases) { role in ForEach(GroupMemberRole.allCases) { role in
if role <= groupInfo.membership.memberRole { if role <= groupInfo.membership.memberRole && role != .observer {
Text(role.text) Text(role.text)
} }
} }

View File

@ -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.") 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) .padding(.bottom)
if let groupLink = groupLink { if let groupLink = groupLink {
HStack { // HStack {
Text("Initial role") // Text("Initial role")
Picker("Initial role", selection: $groupLinkMemberRole) { // Picker("Initial role", selection: $groupLinkMemberRole) {
ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in // ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in
Text(role.text) // Text(role.text)
} // }
} // }
} // }
.frame(maxWidth: .infinity, alignment: .leading) // .frame(maxWidth: .infinity, alignment: .leading)
QRCode(uri: groupLink) QRCode(uri: groupLink)
HStack { HStack {
Button { Button {

View File

@ -1517,7 +1517,7 @@ public struct GroupMember: Identifiable, Decodable {
public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? {
if !canBeRemoved(groupInfo: groupInfo) { return nil } if !canBeRemoved(groupInfo: groupInfo) { return nil }
let userRole = groupInfo.membership.memberRole let userRole = groupInfo.membership.memberRole
return GroupMemberRole.allCases.filter { $0 <= userRole } return GroupMemberRole.allCases.filter { $0 <= userRole && $0 != .observer }
} }
public var memberIncognito: Bool { public var memberIncognito: Bool {

View File

@ -1,5 +1,5 @@
name: simplex-chat name: simplex-chat
version: 4.5.3.1 version: 4.5.4.2
#synopsis: #synopsis:
#description: #description:
homepage: https://github.com/simplex-chat/simplex-chat#readme homepage: https://github.com/simplex-chat/simplex-chat#readme

View File

@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack -- see: https://github.com/sol/hpack
name: simplex-chat name: simplex-chat
version: 4.5.3.1 version: 4.5.4.2
category: Web, System, Services, Cryptography category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat author: simplex.chat

View File

@ -3439,9 +3439,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState) messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState)
mergeContacts :: Contact -> Contact -> m () mergeContacts :: Contact -> Contact -> m ()
mergeContacts to from = do mergeContacts c1 c2 = do
withStore' $ \db -> mergeContactRecords db userId to from withStore' $ \db -> mergeContactRecords db userId c1 c2
toView $ CRContactsMerged user to from toView $ CRContactsMerged user c1 c2
saveConnInfo :: Connection -> ConnInfo -> m () saveConnInfo :: Connection -> ConnInfo -> m ()
saveConnInfo activeConn connInfo = do saveConnInfo activeConn connInfo = do

View File

@ -1622,8 +1622,17 @@ matchSentProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = d
cId : _ -> eitherToMaybe <$> runExceptT (getContact db user cId) cId : _ -> eitherToMaybe <$> runExceptT (getContact db user cId)
mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO () 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 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.execute
db db
"UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" "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 deleteContactProfile_ db userId fromContactId
DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) 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) 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.Connection -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity
getConnectionEntity db user@User {userId, userContactId} agentConnId = do getConnectionEntity db user@User {userId, userContactId} agentConnId = do

View File

@ -159,9 +159,12 @@ contactConnId = aConnId . contactConn
contactConnIncognito :: Contact -> Bool contactConnIncognito :: Contact -> Bool
contactConnIncognito = connIncognito . contactConn contactConnIncognito = connIncognito . contactConn
contactDirect :: Contact -> Bool
contactDirect Contact {activeConn = Connection {connLevel, viaGroupLink}} = connLevel == 0 && not viaGroupLink
directOrUsed :: Contact -> Bool directOrUsed :: Contact -> Bool
directOrUsed Contact {contactUsed, activeConn = Connection {connLevel, viaGroupLink}} = directOrUsed ct@Contact {contactUsed} =
(connLevel == 0 && not viaGroupLink) || contactUsed contactDirect ct || contactUsed
anyDirectOrUsed :: Contact -> Bool anyDirectOrUsed :: Contact -> Bool
anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed

View File

@ -47,6 +47,7 @@ chatGroupTests = do
it "unused host contact is deleted after all groups with it are deleted" testGroupLinkUnusedHostContactDeleted 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 "leaving groups with unused host contacts deletes incognito profiles" testGroupLinkIncognitoUnusedHostContactsDeleted
it "group link member role" testGroupLinkMemberRole 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 :: HasCallStack => SpecWith FilePath
testGroup = versionTestMatrix3 runTestGroup testGroup = versionTestMatrix3 runTestGroup
@ -1916,3 +1917,72 @@ testGroupLinkMemberRole =
concurrently_ concurrently_
(alice <# "#team bob> hey now") (alice <# "#team bob> hey now")
(cath <# "#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 <name> 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 <message> 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 <message> 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 <message> 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 <message> 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)"