Merge pull request #2022 from simplex-chat/ep/v454

v4.5.4: add support for observer role
This commit is contained in:
Evgeny Poberezkin 2023-03-17 17:14:43 +00:00 committed by GitHub
commit 9127b1bbc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 723 additions and 227 deletions

View File

@ -486,6 +486,24 @@ data class Chat (
val chatItems: List<ChatItem>, val chatItems: List<ChatItem>,
val chatStats: ChatStats = ChatStats(), val chatStats: ChatStats = ChatStats(),
) { ) {
val userCanSend: Boolean
get() = when (chatInfo) {
is ChatInfo.Direct -> true
is ChatInfo.Group -> {
val m = chatInfo.groupInfo.membership
m.memberActive && m.memberRole >= GroupMemberRole.Member
}
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
@ -932,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
@ -963,11 +981,13 @@ class GroupMemberRef(
@Serializable @Serializable
enum class GroupMemberRole(val memberRole: String) { enum class GroupMemberRole(val memberRole: String) {
@SerialName("member") Member("member"), // order matters in comparisons @SerialName("observer") Observer("observer"), // order matters in comparisons
@SerialName("member") Member("member"),
@SerialName("admin") Admin("admin"), @SerialName("admin") Admin("admin"),
@SerialName("owner") Owner("owner"); @SerialName("owner") Owner("owner");
val text: String get() = when (this) { val text: String get() = when (this) {
Observer -> generalGetString(R.string.group_member_role_observer)
Member -> generalGetString(R.string.group_member_role_member) Member -> generalGetString(R.string.group_member_role_member)
Admin -> generalGetString(R.string.group_member_role_admin) Admin -> generalGetString(R.string.group_member_role_admin)
Owner -> generalGetString(R.string.group_member_role_owner) Owner -> generalGetString(R.string.group_member_role_owner)

View File

@ -549,7 +549,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
suspend fun testSMPServer(smpServer: String): SMPTestFailure? { suspend fun testSMPServer(smpServer: String): SMPTestFailure? {
val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("testSMPServer: no current user") } val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("testSMPServer: no current user") }
val r = sendCmd(CC.TestSMPServer(userId, smpServer)) val r = sendCmd(CC.APITestSMPServer(userId, smpServer))
return when (r) { return when (r) {
is CR.SmpTestResult -> r.smpTestFailure is CR.SmpTestResult -> r.smpTestFailure
else -> { else -> {
@ -1060,9 +1060,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
} }
} }
suspend fun apiCreateGroupLink(groupId: Long): String? { suspend fun apiCreateGroupLink(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair<String, GroupMemberRole>? {
return when (val r = sendCmd(CC.APICreateGroupLink(groupId))) { return when (val r = sendCmd(CC.APICreateGroupLink(groupId, memberRole))) {
is CR.GroupLinkCreated -> r.connReqContact is CR.GroupLinkCreated -> r.connReqContact to r.memberRole
else -> { else -> {
if (!(networkErrorAlert(r))) { if (!(networkErrorAlert(r))) {
apiErrorAlert("apiCreateGroupLink", generalGetString(R.string.error_creating_link_for_group), r) apiErrorAlert("apiCreateGroupLink", generalGetString(R.string.error_creating_link_for_group), r)
@ -1072,6 +1072,18 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
} }
} }
suspend fun apiGroupLinkMemberRole(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair<String, GroupMemberRole>? {
return when (val r = sendCmd(CC.APIGroupLinkMemberRole(groupId, memberRole))) {
is CR.GroupLink -> r.connReqContact to r.memberRole
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiGroupLinkMemberRole", generalGetString(R.string.error_updating_link_for_group), r)
}
null
}
}
}
suspend fun apiDeleteGroupLink(groupId: Long): Boolean { suspend fun apiDeleteGroupLink(groupId: Long): Boolean {
return when (val r = sendCmd(CC.APIDeleteGroupLink(groupId))) { return when (val r = sendCmd(CC.APIDeleteGroupLink(groupId))) {
is CR.GroupLinkDeleted -> true is CR.GroupLinkDeleted -> true
@ -1084,9 +1096,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
} }
} }
suspend fun apiGetGroupLink(groupId: Long): String? { suspend fun apiGetGroupLink(groupId: Long): Pair<String, GroupMemberRole>? {
return when (val r = sendCmd(CC.APIGetGroupLink(groupId))) { return when (val r = sendCmd(CC.APIGetGroupLink(groupId))) {
is CR.GroupLink -> r.connReqContact is CR.GroupLink -> r.connReqContact to r.memberRole
else -> { else -> {
Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}") Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}")
null null
@ -1343,6 +1355,10 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
if (active(r.user)) { if (active(r.user)) {
chatModel.updateGroup(r.toGroup) chatModel.updateGroup(r.toGroup)
} }
is CR.MemberRole ->
if (active(r.user)) {
chatModel.updateGroup(r.groupInfo)
}
is CR.RcvFileStart -> is CR.RcvFileStart ->
chatItemSimpleUpdate(r.user, r.chatItem) chatItemSimpleUpdate(r.user, r.chatItem)
is CR.RcvFileComplete -> is CR.RcvFileComplete ->
@ -1752,12 +1768,13 @@ sealed class CC {
class ApiLeaveGroup(val groupId: Long): CC() class ApiLeaveGroup(val groupId: Long): CC()
class ApiListMembers(val groupId: Long): CC() class ApiListMembers(val groupId: Long): CC()
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC() class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
class APICreateGroupLink(val groupId: Long): CC() class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole): CC()
class APIGroupLinkMemberRole(val groupId: Long, val memberRole: GroupMemberRole): CC()
class APIDeleteGroupLink(val groupId: Long): CC() class APIDeleteGroupLink(val groupId: Long): CC()
class APIGetGroupLink(val groupId: Long): CC() class APIGetGroupLink(val groupId: Long): CC()
class APIGetUserSMPServers(val userId: Long): CC() class APIGetUserSMPServers(val userId: Long): CC()
class APISetUserSMPServers(val userId: Long, val smpServers: List<ServerCfg>): CC() class APISetUserSMPServers(val userId: Long, val smpServers: List<ServerCfg>): CC()
class TestSMPServer(val userId: Long, val smpServer: String): CC() class APITestSMPServer(val userId: Long, val smpServer: String): CC()
class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC() class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC()
class APIGetChatItemTTL(val userId: Long): CC() class APIGetChatItemTTL(val userId: Long): CC()
class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC()
@ -1827,12 +1844,13 @@ sealed class CC {
is ApiLeaveGroup -> "/_leave #$groupId" is ApiLeaveGroup -> "/_leave #$groupId"
is ApiListMembers -> "/_members #$groupId" is ApiListMembers -> "/_members #$groupId"
is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}" is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}"
is APICreateGroupLink -> "/_create link #$groupId" is APICreateGroupLink -> "/_create link #$groupId ${memberRole.name.lowercase()}"
is APIGroupLinkMemberRole -> "/_set link role #$groupId ${memberRole.name.lowercase()}"
is APIDeleteGroupLink -> "/_delete link #$groupId" is APIDeleteGroupLink -> "/_delete link #$groupId"
is APIGetGroupLink -> "/_get link #$groupId" is APIGetGroupLink -> "/_get link #$groupId"
is APIGetUserSMPServers -> "/_smp $userId" is APIGetUserSMPServers -> "/_smp $userId"
is APISetUserSMPServers -> "/_smp $userId ${smpServersStr(smpServers)}" is APISetUserSMPServers -> "/_smp $userId ${smpServersStr(smpServers)}"
is TestSMPServer -> "/smp test $userId $smpServer" is APITestSMPServer -> "/_smp test $userId $smpServer"
is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}"
is APIGetChatItemTTL -> "/_ttl $userId" is APIGetChatItemTTL -> "/_ttl $userId"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
@ -1904,11 +1922,12 @@ sealed class CC {
is ApiListMembers -> "apiListMembers" is ApiListMembers -> "apiListMembers"
is ApiUpdateGroupProfile -> "apiUpdateGroupProfile" is ApiUpdateGroupProfile -> "apiUpdateGroupProfile"
is APICreateGroupLink -> "apiCreateGroupLink" is APICreateGroupLink -> "apiCreateGroupLink"
is APIGroupLinkMemberRole -> "apiGroupLinkMemberRole"
is APIDeleteGroupLink -> "apiDeleteGroupLink" is APIDeleteGroupLink -> "apiDeleteGroupLink"
is APIGetGroupLink -> "apiGetGroupLink" is APIGetGroupLink -> "apiGetGroupLink"
is APIGetUserSMPServers -> "apiGetUserSMPServers" is APIGetUserSMPServers -> "apiGetUserSMPServers"
is APISetUserSMPServers -> "apiSetUserSMPServers" is APISetUserSMPServers -> "apiSetUserSMPServers"
is TestSMPServer -> "testSMPServer" is APITestSMPServer -> "testSMPServer"
is APISetChatItemTTL -> "apiSetChatItemTTL" is APISetChatItemTTL -> "apiSetChatItemTTL"
is APIGetChatItemTTL -> "apiGetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL"
is APISetNetworkConfig -> "/apiSetNetworkConfig" is APISetNetworkConfig -> "/apiSetNetworkConfig"
@ -2925,8 +2944,8 @@ sealed class CR {
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("groupRemoved") class GroupRemoved(val user: User, val groupInfo: GroupInfo): CR() // unused @Serializable @SerialName("groupRemoved") class GroupRemoved(val user: User, val groupInfo: GroupInfo): CR() // unused
@Serializable @SerialName("groupUpdated") class GroupUpdated(val user: User, val toGroup: GroupInfo): CR() @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: User, val toGroup: GroupInfo): CR()
@Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: User, val groupInfo: GroupInfo, val connReqContact: String): CR() @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: User, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("groupLink") class GroupLink(val user: User, val groupInfo: GroupInfo, val connReqContact: String): CR() @Serializable @SerialName("groupLink") class GroupLink(val user: User, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: User, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: User, val groupInfo: GroupInfo): CR()
// receiving file events // receiving file events
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: User, val chatItem: AChatItem): CR() @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: User, val chatItem: AChatItem): CR()
@ -3129,8 +3148,8 @@ sealed class CR {
is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
is GroupRemoved -> withUser(user, json.encodeToString(groupInfo)) is GroupRemoved -> withUser(user, json.encodeToString(groupInfo))
is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) is GroupUpdated -> withUser(user, json.encodeToString(toGroup))
is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact") is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole")
is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact") is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole")
is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo))
is RcvFileAcceptedSndCancelled -> withUser(user, noDetails()) is RcvFileAcceptedSndCancelled -> withUser(user, noDetails())
is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem)) is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem))

View File

@ -17,6 +17,7 @@ enum class DefaultTheme {
val DEFAULT_PADDING = 16.dp val DEFAULT_PADDING = 16.dp
val DEFAULT_SPACE_AFTER_ICON = 4.dp val DEFAULT_SPACE_AFTER_ICON = 4.dp
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2 val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
val DEFAULT_BOTTOM_PADDING = 48.dp
val DarkColorPalette = darkColors( val DarkColorPalette = darkColors(
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files

View File

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

View File

@ -37,6 +37,7 @@ import androidx.webkit.WebViewClientCompat
import chat.simplex.app.* import chat.simplex.app.*
import chat.simplex.app.R import chat.simplex.app.R
import chat.simplex.app.model.* import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.DEFAULT_BOTTOM_PADDING
import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.ProfileImage import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.helpers.withApi import chat.simplex.app.views.helpers.withApi
@ -240,7 +241,7 @@ private fun ActiveCallOverlayLayout(
CallInfoView(call, alignment = Alignment.CenterHorizontally) CallInfoView(call, alignment = Alignment.CenterHorizontally)
} }
Spacer(Modifier.fillMaxHeight().weight(1f)) Spacer(Modifier.fillMaxHeight().weight(1f))
Box(Modifier.fillMaxWidth().padding(bottom = 48.dp), contentAlignment = Alignment.CenterStart) { Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
IconButton(onClick = dismiss) { IconButton(onClick = dismiss) {
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp)) Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))

View File

@ -152,9 +152,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
} }
} else if (chat.chatInfo is ChatInfo.Group) { } else if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chat.chatInfo.groupInfo, chatModel) setGroupMembers(chat.chatInfo.groupInfo, chatModel)
var groupLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId) val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
var groupLink = link?.first
var groupLinkMemberRole = link?.second
ModalManager.shared.showModalCloseable(true) { close -> ModalManager.shared.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, groupLink, { groupLink = it }, close) GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, {
groupLink = it.first;
groupLinkMemberRole = it.second
}, close)
} }
} }
} }

View File

@ -17,6 +17,7 @@ import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.* import androidx.compose.material.*
@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import chat.simplex.app.* import chat.simplex.app.*
@ -645,6 +647,9 @@ fun ComposeView(
chatModel.sharedContent.value = null chatModel.sharedContent.value = null
} }
val userCanSend = rememberUpdatedState(chat.userCanSend)
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
Column { Column {
contextItemView() contextItemView()
when { when {
@ -656,11 +661,11 @@ fun ComposeView(
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
) { ) {
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled) { IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) {
Icon( Icon(
Icons.Filled.AttachFile, Icons.Filled.AttachFile,
contentDescription = stringResource(R.string.attach), contentDescription = stringResource(R.string.attach),
tint = if (!composeState.value.attachmentDisabled) MaterialTheme.colors.primary else HighOrLowlight, tint = if (!composeState.value.attachmentDisabled && userCanSend.value) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier modifier = Modifier
.size(28.dp) .size(28.dp)
.clip(CircleShape) .clip(CircleShape)
@ -698,6 +703,13 @@ fun ComposeView(
} }
} }
LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) {
if (!chat.userCanSend) {
clearCurrentDraft()
clearState()
}
}
val activity = LocalContext.current as Activity val activity = LocalContext.current as Activity
DisposableEffect(Unit) { DisposableEffect(Unit) {
val orientation = activity.resources.configuration.orientation val orientation = activity.resources.configuration.orientation
@ -733,6 +745,8 @@ fun ComposeView(
needToAllowVoiceToContact, needToAllowVoiceToContact,
allowedVoiceByPrefs, allowedVoiceByPrefs,
allowVoiceToContact = ::allowVoiceToContact, allowVoiceToContact = ::allowVoiceToContact,
userIsObserver = userIsObserver.value,
userCanSend = userCanSend.value,
sendMessage = { sendMessage = {
sendMessage() sendMessage()
resetLinkPreview() resetLinkPreview()

View File

@ -60,6 +60,8 @@ fun SendMsgView(
liveMessageAlertShown: SharedPreference<Boolean>, liveMessageAlertShown: SharedPreference<Boolean>,
needToAllowVoiceToContact: Boolean, needToAllowVoiceToContact: Boolean,
allowedVoiceByPrefs: Boolean, allowedVoiceByPrefs: Boolean,
userIsObserver: Boolean,
userCanSend: Boolean,
allowVoiceToContact: () -> Unit, allowVoiceToContact: () -> Unit,
sendMessage: () -> Unit, sendMessage: () -> Unit,
sendLiveMessage: (suspend () -> Unit)? = null, sendLiveMessage: (suspend () -> Unit)? = null,
@ -74,10 +76,18 @@ 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, onMessageChange) NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange)
// Disable clicks on text field // Disable clicks on text field
if (cs.preview is ComposePreview.VoicePreview) { if (cs.preview is ComposePreview.VoicePreview || !userCanSend) {
Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { })) Box(Modifier
.matchParentSize()
.clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.observer_cant_send_message_title),
text = generalGetString(R.string.observer_cant_send_message_desc)
)
})
)
} }
if (showDeleteTextButton.value) { if (showDeleteTextButton.value) {
DeleteTextButton(composeState) DeleteTextButton(composeState)
@ -99,11 +109,11 @@ fun SendMsgView(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
val stopRecOnNextClick = remember { mutableStateOf(false) } val stopRecOnNextClick = remember { mutableStateOf(false) }
when { when {
needToAllowVoiceToContact || !allowedVoiceByPrefs -> { needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> {
DisallowedVoiceButton { DisallowedVoiceButton(userCanSend) {
if (needToAllowVoiceToContact) { if (needToAllowVoiceToContact) {
showNeedToAllowVoiceAlert(allowVoiceToContact) showNeedToAllowVoiceAlert(allowVoiceToContact)
} else { } else if (!allowedVoiceByPrefs) {
showDisabledVoiceAlert(isDirectChat) showDisabledVoiceAlert(isDirectChat)
} }
} }
@ -118,7 +128,7 @@ fun SendMsgView(
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value) && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
&& cs.contextItem is ComposeContextItem.NoContextItem) { && cs.contextItem is ComposeContextItem.NoContextItem) {
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
StartLiveMessageButton { StartLiveMessageButton(userCanSend) {
if (composeState.value.preview is ComposePreview.NoPreview) { if (composeState.value.preview is ComposePreview.NoPreview) {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
} }
@ -173,6 +183,7 @@ private fun NativeKeyboard(
composeState: MutableState<ComposeState>, composeState: MutableState<ComposeState>,
textStyle: MutableState<TextStyle>, textStyle: MutableState<TextStyle>,
showDeleteTextButton: MutableState<Boolean>, showDeleteTextButton: MutableState<Boolean>,
userIsObserver: Boolean,
onMessageChange: (String) -> Unit onMessageChange: (String) -> Unit
) { ) {
val cs = composeState.value val cs = composeState.value
@ -253,13 +264,20 @@ private fun NativeKeyboard(
showDeleteTextButton.value = it.lineCount >= 4 showDeleteTextButton.value = it.lineCount >= 4
} }
if (composeState.value.preview is ComposePreview.VoicePreview) { 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<TextStyle>, padding: PaddingValues) {
Text( Text(
generalGetString(R.string.voice_message_send_text), generalGetString(textId),
Modifier.padding(padding), Modifier.padding(padding),
color = HighOrLowlight, color = HighOrLowlight,
style = textStyle.value.copy(fontStyle = FontStyle.Italic) style = textStyle.value.copy(fontStyle = FontStyle.Italic)
) )
}
} }
@Composable @Composable
@ -322,8 +340,8 @@ private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNex
} }
@Composable @Composable
private fun DisallowedVoiceButton(onClick: () -> Unit) { private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) { IconButton(onClick, Modifier.size(36.dp), enabled = enabled) {
Icon( Icon(
Icons.Outlined.KeyboardVoice, Icons.Outlined.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message), stringResource(R.string.icon_descr_record_voice_message),
@ -454,13 +472,13 @@ private fun SendMsgButton(
} }
@Composable @Composable
private fun StartLiveMessageButton(onClick: () -> Unit) { private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
Box( Box(
modifier = Modifier.requiredSize(36.dp) modifier = Modifier.requiredSize(36.dp)
.clickable( .clickable(
onClick = onClick, onClick = onClick,
enabled = true, enabled = enabled,
role = Role.Button, role = Role.Button,
interactionSource = interactionSource, interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp) indication = rememberRipple(bounded = false, radius = 24.dp)
@ -470,7 +488,7 @@ private fun StartLiveMessageButton(onClick: () -> Unit) {
Icon( Icon(
Icons.Filled.Bolt, Icons.Filled.Bolt,
stringResource(R.string.icon_descr_send_message), stringResource(R.string.icon_descr_send_message),
tint = MaterialTheme.colors.primary, tint = if (enabled) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.padding(4.dp) .padding(4.dp)
@ -571,6 +589,8 @@ fun PreviewSendMsgView() {
liveMessageAlertShown = SharedPreference(get = { true }, set = { }), liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false, needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true, allowedVoiceByPrefs = true,
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {}, allowVoiceToContact = {},
sendMessage = {}, sendMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
@ -599,6 +619,8 @@ fun PreviewSendMsgViewEditing() {
liveMessageAlertShown = SharedPreference(get = { true }, set = { }), liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false, needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true, allowedVoiceByPrefs = true,
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {}, allowVoiceToContact = {},
sendMessage = {}, sendMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
@ -627,6 +649,8 @@ fun PreviewSendMsgViewInProgress() {
liveMessageAlertShown = SharedPreference(get = { true }, set = { }), liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false, needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true, allowedVoiceByPrefs = true,
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {}, allowVoiceToContact = {},
sendMessage = {}, sendMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },

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

@ -34,7 +34,7 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.* import chat.simplex.app.views.usersettings.*
@Composable @Composable
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdated: (String?) -> Unit, close: () -> Unit) { fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit, close: () -> Unit) {
BackHandler(onBack = close) BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get() val developerTools = chatModel.controller.appPrefs.developerTools.get()
@ -95,9 +95,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdat
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }, leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
manageGroupLink = { manageGroupLink = {
withApi { ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, onGroupLinkUpdated) }
}
} }
) )
} }
@ -300,6 +298,7 @@ private fun MemberRow(member: GroupMember, user: Boolean = false) {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row( Row(
Modifier.weight(1f).padding(end = DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {

View File

@ -1,6 +1,9 @@
package chat.simplex.app.views.chat.group package chat.simplex.app.views.chat.group
import SectionItemView
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -15,22 +18,26 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.simplex.app.R import chat.simplex.app.R
import chat.simplex.app.model.ChatModel import chat.simplex.app.model.*
import chat.simplex.app.model.GroupInfo
import chat.simplex.app.ui.theme.* import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.* import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode import chat.simplex.app.views.newchat.QRCode
@Composable @Composable
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, onGroupLinkUpdated: (String?) -> Unit) { fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit) {
var groupLink by rememberSaveable { mutableStateOf(connReqContact) } var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
var creatingLink by rememberSaveable { mutableStateOf(false) } var creatingLink by rememberSaveable { mutableStateOf(false) }
val cxt = LocalContext.current val cxt = LocalContext.current
fun createLink() { fun createLink() {
creatingLink = true creatingLink = true
withApi { withApi {
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId) val link = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
onGroupLinkUpdated(groupLink) if (link != null) {
groupLink = link.first
groupLinkMemberRole.value = link.second
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
}
creatingLink = false creatingLink = false
} }
} }
@ -41,9 +48,24 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
} }
GroupLinkLayout( GroupLinkLayout(
groupLink = groupLink, groupLink = groupLink,
groupInfo,
groupLinkMemberRole,
creatingLink, creatingLink,
createLink = ::createLink, createLink = ::createLink,
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) }, share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
updateLink = {
val role = groupLinkMemberRole.value
if (role != null) {
withBGApi {
val link = chatModel.controller.apiGroupLinkMemberRole(groupInfo.groupId, role)
if (link != null) {
groupLink = link.first
groupLinkMemberRole.value = link.second
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
}
}
}
},
deleteLink = { deleteLink = {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_link_question), title = generalGetString(R.string.delete_link_question),
@ -54,7 +76,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId) val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
if (r) { if (r) {
groupLink = null groupLink = null
onGroupLinkUpdated(null) onGroupLinkUpdated(null to null)
} }
} }
} }
@ -69,13 +91,18 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
@Composable @Composable
fun GroupLinkLayout( fun GroupLinkLayout(
groupLink: String?, groupLink: String?,
groupInfo: GroupInfo,
groupLinkMemberRole: MutableState<GroupMemberRole?>,
creatingLink: Boolean, creatingLink: Boolean,
createLink: () -> Unit, createLink: () -> Unit,
share: () -> Unit, share: () -> Unit,
updateLink: () -> Unit,
deleteLink: () -> Unit deleteLink: () -> Unit
) { ) {
Column( Column(
Modifier.padding(horizontal = DEFAULT_PADDING), Modifier
.verticalScroll(rememberScrollState())
.padding(start = DEFAULT_PADDING, bottom = DEFAULT_BOTTOM_PADDING, end = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top verticalArrangement = Arrangement.Top
) { ) {
@ -93,7 +120,17 @@ 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 {
QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f)) // SectionItemView(padding = PaddingValues(bottom = DEFAULT_PADDING)) {
// RoleSelectionRow(groupInfo, groupLinkMemberRole)
// }
var initialLaunch by remember { mutableStateOf(true) }
LaunchedEffect(groupLinkMemberRole.value) {
if (!initialLaunch) {
updateLink()
}
initialLaunch = false
}
QRCode(groupLink, Modifier.aspectRatio(1f))
Row( Row(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -116,6 +153,25 @@ fun GroupLinkLayout(
} }
} }
@Composable
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole?>, enabled: Boolean = true) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
val values = listOf(GroupMemberRole.Member, GroupMemberRole.Observer).map { it to it.text }
ExposedDropDownSettingRow(
generalGetString(R.string.initial_member_role),
values,
selectedRole,
icon = null,
enabled = rememberUpdatedState(enabled),
onSelected = { selectedRole.value = it }
)
}
}
@Composable @Composable
fun ProgressIndicator() { fun ProgressIndicator() {
Box( Box(

View File

@ -154,7 +154,7 @@ fun DatabaseLayout(
val operationsDisabled = !stopped || progressIndicator val operationsDisabled = !stopped || progressIndicator
Column( Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 48.dp), Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = DEFAULT_BOTTOM_PADDING),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
) { ) {
AppBarTitle(stringResource(R.string.your_chat_database)) AppBarTitle(stringResource(R.string.your_chat_database))

View File

@ -85,13 +85,14 @@ fun SectionItemView(
click: (() -> Unit)? = null, click: (() -> Unit)? = null,
minHeight: Dp = 46.dp, minHeight: Dp = 46.dp,
disabled: Boolean = false, disabled: Boolean = false,
padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING),
content: (@Composable RowScope.() -> Unit) content: (@Composable RowScope.() -> Unit)
) { ) {
val modifier = Modifier val modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.sizeIn(minHeight = minHeight) .sizeIn(minHeight = minHeight)
Row( Row(
if (click == null || disabled) modifier.padding(horizontal = DEFAULT_PADDING) else modifier.clickable(onClick = click).padding(horizontal = DEFAULT_PADDING), if (click == null || disabled) modifier.padding(padding) else modifier.clickable(onClick = click).padding(padding),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
content() content()

View File

@ -222,6 +222,9 @@
<string name="images_limit_desc">Only 10 images can be sent at the same time</string> <string name="images_limit_desc">Only 10 images can be sent at the same time</string>
<string name="image_decoding_exception_title">Decoding error</string> <string name="image_decoding_exception_title">Decoding error</string>
<string name="image_decoding_exception_desc">The image cannot be decoded. Please, try a different image or contact developers.</string> <string name="image_decoding_exception_desc">The image cannot be decoded. Please, try a different image or contact developers.</string>
<string name="you_are_observer">you are observer</string>
<string name="observer_cant_send_message_title">You can\'t send messages!</string>
<string name="observer_cant_send_message_desc">Please contact group admin.</string>
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt --> <!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Image</string> <string name="image_descr">Image</string>
@ -868,6 +871,7 @@
<string name="snd_conn_event_switch_queue_phase_changing">changing address…</string> <string name="snd_conn_event_switch_queue_phase_changing">changing address…</string>
<!-- GroupMemberRole --> <!-- GroupMemberRole -->
<string name="group_member_role_observer">observer</string>
<string name="group_member_role_member">member</string> <string name="group_member_role_member">member</string>
<string name="group_member_role_admin">admin</string> <string name="group_member_role_admin">admin</string>
<string name="group_member_role_owner">owner</string> <string name="group_member_role_owner">owner</string>
@ -890,6 +894,7 @@
<!-- AddGroupMembersView.kt --> <!-- AddGroupMembersView.kt -->
<string name="no_contacts_to_add">No contacts to add</string> <string name="no_contacts_to_add">No contacts to add</string>
<string name="new_member_role">New member role</string> <string name="new_member_role">New member role</string>
<string name="initial_member_role">Initial role</string>
<string name="icon_descr_expand_role">Expand role selection</string> <string name="icon_descr_expand_role">Expand role selection</string>
<string name="invite_to_group_button">Invite to group</string> <string name="invite_to_group_button">Invite to group</string>
<string name="skip_inviting_button">Skip inviting members</string> <string name="skip_inviting_button">Skip inviting members</string>
@ -919,6 +924,7 @@
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">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.</string> <string name="you_can_share_group_link_anybody_will_be_able_to_connect">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.</string>
<string name="all_group_members_will_remain_connected">All group members will remain connected.</string> <string name="all_group_members_will_remain_connected">All group members will remain connected.</string>
<string name="error_creating_link_for_group">Error creating group link</string> <string name="error_creating_link_for_group">Error creating group link</string>
<string name="error_updating_link_for_group">Error updating group link</string>
<string name="error_deleting_link_for_group">Error deleting group link</string> <string name="error_deleting_link_for_group">Error deleting group link</string>
<string name="only_group_owners_can_change_prefs">Only group owners can change group preferences.</string> <string name="only_group_owners_can_change_prefs">Only group owners can change group preferences.</string>

View File

@ -545,6 +545,25 @@ final class Chat: ObservableObject, Identifiable {
self.chatStats = chatStats self.chatStats = chatStats
} }
var userCanSend: Bool {
switch chatInfo {
case .direct: return true
case let .group(groupInfo):
let m = groupInfo.membership
return m.memberActive && m.memberRole >= .member
default: return false
}
}
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

@ -344,7 +344,7 @@ func setUserSMPServers(smpServers: [ServerCfg]) async throws {
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> { func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("testSMPServer: no current user") } guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("testSMPServer: no current user") }
let r = await chatSendCmd(.testSMPServer(userId: userId, smpServer: smpServer)) let r = await chatSendCmd(.apiTestSMPServer(userId: userId, smpServer: smpServer))
if case let .smpTestResult(_, testFailure) = r { if case let .smpTestResult(_, testFailure) = r {
if let t = testFailure { if let t = testFailure {
return .failure(t) return .failure(t)
@ -868,9 +868,15 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws
throw r throw r
} }
func apiCreateGroupLink(_ groupId: Int64) async throws -> String { func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) {
let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId)) let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole))
if case let .groupLinkCreated(_, _, connReq) = r { return connReq } if case let .groupLinkCreated(_, _, connReq, memberRole) = r { return (connReq, memberRole) }
throw r
}
func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) {
let r = await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole))
if case let .groupLink(_, _, connReq, memberRole) = r { return (connReq, memberRole) }
throw r throw r
} }
@ -880,11 +886,11 @@ func apiDeleteGroupLink(_ groupId: Int64) async throws {
throw r throw r
} }
func apiGetGroupLink(_ groupId: Int64) throws -> String? { func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? {
let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId)) let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId))
switch r { switch r {
case let .groupLink(_, _, connReq): case let .groupLink(_, _, connReq, memberRole):
return connReq return (connReq, memberRole)
case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)): case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)):
return nil return nil
default: throw r default: throw r
@ -1180,6 +1186,10 @@ func processReceivedMsg(_ res: ChatResponse) async {
if active(user) { if active(user) {
m.updateGroup(toGroup) m.updateGroup(toGroup)
} }
case let .memberRole(user, groupInfo, _, _, _, _):
if active(user) {
m.updateGroup(groupInfo)
}
case let .rcvFileStart(user, aChatItem): case let .rcvFileStart(user, aChatItem):
chatItemSimpleUpdate(user, aChatItem) chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileComplete(user, aChatItem): case let .rcvFileComplete(user, aChatItem):

View File

@ -258,10 +258,11 @@ struct ComposeView: View {
Image(systemName: "paperclip") Image(systemName: "paperclip")
.resizable() .resizable()
} }
.disabled(composeState.attachmentDisabled) .disabled(composeState.attachmentDisabled || !chat.userCanSend)
.frame(width: 25, height: 25) .frame(width: 25, height: 25)
.padding(.bottom, 12) .padding(.bottom, 12)
.padding(.leading, 12) .padding(.leading, 12)
ZStack(alignment: .leading) {
SendMessageView( SendMessageView(
composeState: $composeState, composeState: $composeState,
sendMessage: { sendMessage: {
@ -288,6 +289,21 @@ struct ComposeView: View {
) )
.padding(.trailing, 12) .padding(.trailing, 12)
.background(.background) .background(.background)
.disabled(!chat.userCanSend)
if chat.userIsObserver {
Text("you are observer")
.italic()
.foregroundColor(.secondary)
.padding(.horizontal, 12)
.onTapGesture {
AlertManager.shared.showAlertMsg(
title: "You can't send messages!",
message: "Please contact group admin."
)
}
}
}
} }
} }
.onChange(of: composeState.message) { _ in .onChange(of: composeState.message) { _ in
@ -299,6 +315,13 @@ struct ComposeView: View {
} }
} }
} }
.onChange(of: chat.userCanSend) { canSend in
if !canSend {
cancelCurrentVoiceRecording()
clearCurrentDraft()
clearState()
}
}
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) { .confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") { Button("Take picture") {
showTakePhoto = true showTakePhoto = true

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

@ -17,6 +17,7 @@ struct GroupChatInfoView: View {
@ObservedObject private var alertManager = AlertManager.shared @ObservedObject private var alertManager = AlertManager.shared
@State private var alert: GroupChatInfoViewAlert? = nil @State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String? @State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var showAddMembersSheet: Bool = false @State private var showAddMembersSheet: Bool = false
@State private var connectionStats: ConnectionStats? @State private var connectionStats: ConnectionStats?
@State private var connectionCode: String? @State private var connectionCode: String?
@ -107,7 +108,9 @@ struct GroupChatInfoView: View {
} }
.onAppear { .onAppear {
do { do {
groupLink = try apiGetGroupLink(groupInfo.groupId) if let link = try apiGetGroupLink(groupInfo.groupId) {
(groupLink, groupLinkMemberRole) = link
}
} catch let error { } catch let error {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
} }
@ -187,7 +190,7 @@ struct GroupChatInfoView: View {
private func groupLinkButton() -> some View { private func groupLinkButton() -> some View {
NavigationLink { NavigationLink {
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink) GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole)
.navigationBarTitle("Group link") .navigationBarTitle("Group link")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} label: { } label: {

View File

@ -12,6 +12,7 @@ import SimpleXChat
struct GroupLinkView: View { struct GroupLinkView: View {
var groupId: Int64 var groupId: Int64
@Binding var groupLink: String? @Binding var groupLink: String?
@Binding var groupLinkMemberRole: GroupMemberRole
@State private var creatingLink = false @State private var creatingLink = false
@State private var alert: GroupLinkAlert? @State private var alert: GroupLinkAlert?
@ -33,6 +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 {
// 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) QRCode(uri: groupLink)
HStack { HStack {
Button { Button {
@ -85,6 +95,16 @@ struct GroupLinkView: View {
return Alert(title: Text(title), message: Text(error)) return Alert(title: Text(title), message: Text(error))
} }
} }
.onChange(of: groupLinkMemberRole) { _ in
Task {
do {
_ = try await apiGroupLinkMemberRole(groupId, memberRole: groupLinkMemberRole)
} catch let error {
let a = getErrorAlert(error, "Error updating group link")
alert = .error(title: a.title, error: a.message)
}
}
}
.onAppear { .onAppear {
if groupLink == nil && !creatingLink { if groupLink == nil && !creatingLink {
createGroupLink() createGroupLink()
@ -100,7 +120,7 @@ struct GroupLinkView: View {
let link = try await apiCreateGroupLink(groupId) let link = try await apiCreateGroupLink(groupId)
await MainActor.run { await MainActor.run {
creatingLink = false creatingLink = false
groupLink = link (groupLink, groupLinkMemberRole) = link
} }
} catch let error { } catch let error {
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))") logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
@ -120,8 +140,8 @@ struct GroupLinkView_Previews: PreviewProvider {
@State var noGroupLink: String? = nil @State var noGroupLink: String? = nil
return Group { return Group {
GroupLinkView(groupId: 1, groupLink: $groupLink) GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member))
GroupLinkView(groupId: 1, groupLink: $noGroupLink) GroupLinkView(groupId: 1, groupLink: $noGroupLink, groupLinkMemberRole: Binding.constant(.member))
} }
} }
} }

View File

@ -46,12 +46,13 @@ public enum ChatCommand {
case apiLeaveGroup(groupId: Int64) case apiLeaveGroup(groupId: Int64)
case apiListMembers(groupId: Int64) case apiListMembers(groupId: Int64)
case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile)
case apiCreateGroupLink(groupId: Int64) case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole)
case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole)
case apiDeleteGroupLink(groupId: Int64) case apiDeleteGroupLink(groupId: Int64)
case apiGetGroupLink(groupId: Int64) case apiGetGroupLink(groupId: Int64)
case apiGetUserSMPServers(userId: Int64) case apiGetUserSMPServers(userId: Int64)
case apiSetUserSMPServers(userId: Int64, smpServers: [ServerCfg]) case apiSetUserSMPServers(userId: Int64, smpServers: [ServerCfg])
case testSMPServer(userId: Int64, smpServer: String) case apiTestSMPServer(userId: Int64, smpServer: String)
case apiSetChatItemTTL(userId: Int64, seconds: Int64?) case apiSetChatItemTTL(userId: Int64, seconds: Int64?)
case apiGetChatItemTTL(userId: Int64) case apiGetChatItemTTL(userId: Int64)
case apiSetNetworkConfig(networkConfig: NetCfg) case apiSetNetworkConfig(networkConfig: NetCfg)
@ -134,12 +135,13 @@ public enum ChatCommand {
case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)" case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)"
case let .apiListMembers(groupId): return "/_members #\(groupId)" case let .apiListMembers(groupId): return "/_members #\(groupId)"
case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))" case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))"
case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)" case let .apiCreateGroupLink(groupId, memberRole): return "/_create link #\(groupId) \(memberRole)"
case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)"
case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)"
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
case let .apiGetUserSMPServers(userId): return "/_smp \(userId)" case let .apiGetUserSMPServers(userId): return "/_smp \(userId)"
case let .apiSetUserSMPServers(userId, smpServers): return "/_smp \(userId) \(smpServersStr(smpServers: smpServers))" case let .apiSetUserSMPServers(userId, smpServers): return "/_smp \(userId) \(smpServersStr(smpServers: smpServers))"
case let .testSMPServer(userId, smpServer): return "/smp test \(userId) \(smpServer)" case let .apiTestSMPServer(userId, smpServer): return "/_smp test \(userId) \(smpServer)"
case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
@ -228,11 +230,12 @@ public enum ChatCommand {
case .apiListMembers: return "apiListMembers" case .apiListMembers: return "apiListMembers"
case .apiUpdateGroupProfile: return "apiUpdateGroupProfile" case .apiUpdateGroupProfile: return "apiUpdateGroupProfile"
case .apiCreateGroupLink: return "apiCreateGroupLink" case .apiCreateGroupLink: return "apiCreateGroupLink"
case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole"
case .apiDeleteGroupLink: return "apiDeleteGroupLink" case .apiDeleteGroupLink: return "apiDeleteGroupLink"
case .apiGetGroupLink: return "apiGetGroupLink" case .apiGetGroupLink: return "apiGetGroupLink"
case .apiGetUserSMPServers: return "apiGetUserSMPServers" case .apiGetUserSMPServers: return "apiGetUserSMPServers"
case .apiSetUserSMPServers: return "apiSetUserSMPServers" case .apiSetUserSMPServers: return "apiSetUserSMPServers"
case .testSMPServer: return "testSMPServer" case .apiTestSMPServer: return "testSMPServer"
case .apiSetChatItemTTL: return "apiSetChatItemTTL" case .apiSetChatItemTTL: return "apiSetChatItemTTL"
case .apiGetChatItemTTL: return "apiGetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL"
case .apiSetNetworkConfig: return "apiSetNetworkConfig" case .apiSetNetworkConfig: return "apiSetNetworkConfig"
@ -391,8 +394,8 @@ public enum ChatResponse: Decodable, Error {
case connectedToGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember) case connectedToGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember)
case groupRemoved(user: User, groupInfo: GroupInfo) // unused case groupRemoved(user: User, groupInfo: GroupInfo) // unused
case groupUpdated(user: User, toGroup: GroupInfo) case groupUpdated(user: User, toGroup: GroupInfo)
case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String) case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole)
case groupLink(user: User, groupInfo: GroupInfo, connReqContact: String) case groupLink(user: User, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole)
case groupLinkDeleted(user: User, groupInfo: GroupInfo) case groupLinkDeleted(user: User, groupInfo: GroupInfo)
// receiving file events // receiving file events
case rcvFileAccepted(user: User, chatItem: AChatItem) case rcvFileAccepted(user: User, chatItem: AChatItem)
@ -606,8 +609,8 @@ public enum ChatResponse: Decodable, Error {
case let .connectedToGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .connectedToGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
case let .groupLinkCreated(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") case let .groupLinkCreated(u, groupInfo, connReqContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)")
case let .groupLink(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") case let .groupLink(u, groupInfo, connReqContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)")
case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem))
case .rcvFileAcceptedSndCancelled: return noDetails case .rcvFileAcceptedSndCancelled: return noDetails

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 {
@ -1546,6 +1546,7 @@ public struct GroupMemberRef: Decodable {
} }
public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable {
case observer = "observer"
case member = "member" case member = "member"
case admin = "admin" case admin = "admin"
case owner = "owner" case owner = "owner"
@ -1554,6 +1555,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Dec
public var text: String { public var text: String {
switch self { switch self {
case .observer: return NSLocalizedString("observer", comment: "member role")
case .member: return NSLocalizedString("member", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role")
case .admin: return NSLocalizedString("admin", comment: "member role") case .admin: return NSLocalizedString("admin", comment: "member role")
case .owner: return NSLocalizedString("owner", comment: "member role") case .owner: return NSLocalizedString("owner", comment: "member role")
@ -1562,9 +1564,10 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Dec
private var comparisonValue: Int { private var comparisonValue: Int {
switch self { switch self {
case .member: return 0 case .observer: return 0
case .admin: return 1 case .member: return 1
case .owner: return 2 case .admin: return 2
case .owner: return 3
} }
} }

View File

@ -1,5 +1,5 @@
name: simplex-chat name: simplex-chat
version: 4.5.3.1 version: 4.5.4.1
#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.1
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
@ -84,6 +84,7 @@ library
Simplex.Chat.Migrations.M20230118_recreate_smp_servers Simplex.Chat.Migrations.M20230118_recreate_smp_servers
Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx
Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id
Simplex.Chat.Migrations.M20230303_group_link_role
Simplex.Chat.Mobile Simplex.Chat.Mobile
Simplex.Chat.Mobile.WebRTC Simplex.Chat.Mobile.WebRTC
Simplex.Chat.Options Simplex.Chat.Options

View File

@ -15,7 +15,7 @@
module Simplex.Chat where module Simplex.Chat where
import Control.Applicative (optional, (<|>)) import Control.Applicative (optional, (<|>))
import Control.Concurrent.STM (retry, stateTVar) import Control.Concurrent.STM (retry)
import Control.Logger.Simple import Control.Logger.Simple
import Control.Monad.Except import Control.Monad.Except
import Control.Monad.IO.Unlift import Control.Monad.IO.Unlift
@ -824,8 +824,10 @@ processChatCommand = \case
ok user ok user
SetUserSMPServers smpServersConfig -> withUser $ \User {userId} -> SetUserSMPServers smpServersConfig -> withUser $ \User {userId} ->
processChatCommand $ APISetUserSMPServers userId smpServersConfig processChatCommand $ APISetUserSMPServers userId smpServersConfig
TestSMPServer userId smpServer -> withUserId userId $ \user -> APITestSMPServer userId smpServer -> withUserId userId $ \user ->
CRSmpTestResult user <$> withAgent (\a -> testSMPServerConnection a (aUserId user) smpServer) CRSmpTestResult user <$> withAgent (\a -> testSMPServerConnection a (aUserId user) smpServer)
TestSMPServer smpServer -> withUser $ \User {userId} ->
processChatCommand $ APITestSMPServer userId smpServer
APISetChatItemTTL userId newTTL_ -> withUser' $ \user -> do APISetChatItemTTL userId newTTL_ -> withUser' $ \user -> do
checkSameUser userId user checkSameUser userId user
checkStoreNotChanged $ checkStoreNotChanged $
@ -1189,25 +1191,36 @@ processChatCommand = \case
CRGroupProfile user <$> withStore (\db -> getGroupInfoByName db user gName) CRGroupProfile user <$> withStore (\db -> getGroupInfoByName db user gName)
UpdateGroupDescription gName description -> UpdateGroupDescription gName description ->
updateGroupProfileByName gName $ \p -> p {description} updateGroupProfileByName gName $ \p -> p {description}
APICreateGroupLink groupId -> withUser $ \user -> withChatLock "createGroupLink" $ do APICreateGroupLink groupId mRole -> withUser $ \user -> withChatLock "createGroupLink" $ do
gInfo <- withStore $ \db -> getGroupInfo db user groupId gInfo <- withStore $ \db -> getGroupInfo db user groupId
assertUserGroupRole gInfo GRAdmin assertUserGroupRole gInfo GRAdmin
when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole
groupLinkId <- GroupLinkId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) groupLinkId <- GroupLinkId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
let crClientData = encodeJSON $ CRDataGroup groupLinkId let crClientData = encodeJSON $ CRDataGroup groupLinkId
(connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData
withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole
pure $ CRGroupLinkCreated user gInfo cReq pure $ CRGroupLinkCreated user gInfo cReq mRole
APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withChatLock "groupLinkMemberRole " $ do
gInfo <- withStore $ \db -> getGroupInfo db user groupId
(groupLinkId, groupLink, mRole) <- withStore $ \db -> getGroupLink db user gInfo
assertUserGroupRole gInfo GRAdmin
when (mRole' > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole'
when (mRole' /= mRole) $ withStore' $ \db -> setGroupLinkMemberRole db user groupLinkId mRole'
pure $ CRGroupLink user gInfo groupLink mRole'
APIDeleteGroupLink groupId -> withUser $ \user -> withChatLock "deleteGroupLink" $ do APIDeleteGroupLink groupId -> withUser $ \user -> withChatLock "deleteGroupLink" $ do
gInfo <- withStore $ \db -> getGroupInfo db user groupId gInfo <- withStore $ \db -> getGroupInfo db user groupId
deleteGroupLink' user gInfo deleteGroupLink' user gInfo
pure $ CRGroupLinkDeleted user gInfo pure $ CRGroupLinkDeleted user gInfo
APIGetGroupLink groupId -> withUser $ \user -> do APIGetGroupLink groupId -> withUser $ \user -> do
gInfo <- withStore $ \db -> getGroupInfo db user groupId gInfo <- withStore $ \db -> getGroupInfo db user groupId
groupLink <- withStore $ \db -> getGroupLink db user gInfo (_, groupLink, mRole) <- withStore $ \db -> getGroupLink db user gInfo
pure $ CRGroupLink user gInfo groupLink pure $ CRGroupLink user gInfo groupLink mRole
CreateGroupLink gName -> withUser $ \user -> do CreateGroupLink gName mRole -> withUser $ \user -> do
groupId <- withStore $ \db -> getGroupIdByName db user gName groupId <- withStore $ \db -> getGroupIdByName db user gName
processChatCommand $ APICreateGroupLink groupId processChatCommand $ APICreateGroupLink groupId mRole
GroupLinkMemberRole gName mRole -> withUser $ \user -> do
groupId <- withStore $ \db -> getGroupIdByName db user gName
processChatCommand $ APIGroupLinkMemberRole groupId mRole
DeleteGroupLink gName -> withUser $ \user -> do DeleteGroupLink gName -> withUser $ \user -> do
groupId <- withStore $ \db -> getGroupIdByName db user gName groupId <- withStore $ \db -> getGroupIdByName db user gName
processChatCommand $ APIDeleteGroupLink groupId processChatCommand $ APIDeleteGroupLink groupId
@ -2211,7 +2224,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct
forM_ viaUserContactLink $ \userContactLinkId -> forM_ viaUserContactLink $ \userContactLinkId ->
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
Just (UserContactLink {autoAccept = Just AutoAccept {autoReply = mc_}}, groupId_) -> do Just (UserContactLink {autoAccept = Just AutoAccept {autoReply = mc_}}, groupId_, gLinkMemRole) -> do
forM_ mc_ $ \mc -> do forM_ mc_ $ \mc -> do
(msg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) (msg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing))
ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc)
@ -2219,7 +2232,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
forM_ groupId_ $ \groupId -> do forM_ groupId_ $ \groupId -> do
gVar <- asks idsDrg gVar <- asks idsDrg
groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation
withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct GRMember groupConnIds withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds
_ -> pure () _ -> pure ()
Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) -> Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) ->
when (maybe False ((== ConnReady) . connStatus) activeConn) $ do when (maybe False ((== ConnReady) . connStatus) activeConn) $ do
@ -2576,7 +2589,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact
CORRequest cReq@UserContactRequest {localDisplayName} -> do CORRequest cReq@UserContactRequest {localDisplayName} -> do
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
Just (UserContactLink {autoAccept}, groupId_) -> Just (UserContactLink {autoAccept}, groupId_, _) ->
case autoAccept of case autoAccept of
Just AutoAccept {acceptIncognito} -> case groupId_ of Just AutoAccept {acceptIncognito} -> case groupId_ of
Nothing -> do Nothing -> do
@ -3211,9 +3224,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
@ -4000,7 +4013,8 @@ chatCommandP =
"/smp_servers " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP), "/smp_servers " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP),
"/smp_servers" $> GetUserSMPServers, "/smp_servers" $> GetUserSMPServers,
"/smp default" $> SetUserSMPServers (SMPServersConfig []), "/smp default" $> SetUserSMPServers (SMPServersConfig []),
"/smp test " *> (TestSMPServer <$> A.decimal <* A.space <*> strP), "/_smp test " *> (APITestSMPServer <$> A.decimal <* A.space <*> strP),
"/smp test " *> (TestSMPServer <$> strP),
"/_smp " *> (APISetUserSMPServers <$> A.decimal <* A.space <*> jsonP), "/_smp " *> (APISetUserSMPServers <$> A.decimal <* A.space <*> jsonP),
"/smp " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP), "/smp " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP),
"/_smp " *> (APIGetUserSMPServers <$> A.decimal), "/_smp " *> (APIGetUserSMPServers <$> A.decimal),
@ -4035,13 +4049,14 @@ chatCommandP =
"/enable #" *> (EnableGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), "/enable #" *> (EnableGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName),
("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles, ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles,
("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups, ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups,
("/help contacts" <|> "/help contact" <|> "/hc") $> ChatHelp HSContacts,
("/help address" <|> "/ha") $> ChatHelp HSMyAddress, ("/help address" <|> "/ha") $> ChatHelp HSMyAddress,
("/help messages" <|> "/hm") $> ChatHelp HSMessages, ("/help messages" <|> "/hm") $> ChatHelp HSMessages,
("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help settings" <|> "/hs") $> ChatHelp HSSettings,
("/help" <|> "/h") $> ChatHelp HSMain, ("/help" <|> "/h") $> ChatHelp HSMain,
("/group " <|> "/g ") *> char_ '#' *> (NewGroup <$> groupProfile), ("/group " <|> "/g ") *> char_ '#' *> (NewGroup <$> groupProfile),
"/_group " *> (APINewGroup <$> A.decimal <* A.space <*> jsonP), "/_group " *> (APINewGroup <$> A.decimal <* A.space <*> jsonP),
("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> (memberRole <|> pure GRAdmin)),
("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName), ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName),
("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole),
("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayName <* A.space <* char_ '@' <*> displayName), ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayName <* A.space <* char_ '@' <*> displayName),
@ -4056,10 +4071,12 @@ chatCommandP =
("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayName <* A.space <*> groupProfile), ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayName <* A.space <*> groupProfile),
("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayName), ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayName),
"/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> optional (A.space *> msgTextP)), "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> optional (A.space *> msgTextP)),
"/_create link #" *> (APICreateGroupLink <$> A.decimal), "/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember)),
"/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole),
"/_delete link #" *> (APIDeleteGroupLink <$> A.decimal), "/_delete link #" *> (APIDeleteGroupLink <$> A.decimal),
"/_get link #" *> (APIGetGroupLink <$> A.decimal), "/_get link #" *> (APIGetGroupLink <$> A.decimal),
"/create link #" *> (CreateGroupLink <$> displayName), "/create link #" *> (CreateGroupLink <$> displayName <*> (memberRole <|> pure GRMember)),
"/set link role #" *> (GroupLinkMemberRole <$> displayName <*> memberRole),
"/delete link #" *> (DeleteGroupLink <$> displayName), "/delete link #" *> (DeleteGroupLink <$> displayName),
"/show link #" *> (ShowGroupLink <$> displayName), "/show link #" *> (ShowGroupLink <$> displayName),
(">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP), (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP),
@ -4169,8 +4186,7 @@ chatCommandP =
[ " owner" $> GROwner, [ " owner" $> GROwner,
" admin" $> GRAdmin, " admin" $> GRAdmin,
" member" $> GRMember, " member" $> GRMember,
-- " observer" $> GRObserver, " observer" $> GRObserver
pure GRAdmin
] ]
chatNameP = ChatName <$> chatTypeP <*> displayName chatNameP = ChatName <$> chatTypeP <*> displayName
chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName

View File

@ -170,7 +170,7 @@ data ChatController = ChatController
logFilePath :: Maybe FilePath logFilePath :: Maybe FilePath
} }
data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSMessages | HSSettings data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSMarkdown | HSMessages | HSSettings
deriving (Show, Generic) deriving (Show, Generic)
instance ToJSON HelpSection where instance ToJSON HelpSection where
@ -237,14 +237,16 @@ data ChatCommand
| APILeaveGroup GroupId | APILeaveGroup GroupId
| APIListMembers GroupId | APIListMembers GroupId
| APIUpdateGroupProfile GroupId GroupProfile | APIUpdateGroupProfile GroupId GroupProfile
| APICreateGroupLink GroupId | APICreateGroupLink GroupId GroupMemberRole
| APIGroupLinkMemberRole GroupId GroupMemberRole
| APIDeleteGroupLink GroupId | APIDeleteGroupLink GroupId
| APIGetGroupLink GroupId | APIGetGroupLink GroupId
| APIGetUserSMPServers UserId | APIGetUserSMPServers UserId
| GetUserSMPServers | GetUserSMPServers
| APISetUserSMPServers UserId SMPServersConfig | APISetUserSMPServers UserId SMPServersConfig
| SetUserSMPServers SMPServersConfig | SetUserSMPServers SMPServersConfig
| TestSMPServer UserId SMPServerWithAuth | APITestSMPServer UserId SMPServerWithAuth
| TestSMPServer SMPServerWithAuth
| APISetChatItemTTL UserId (Maybe Int64) | APISetChatItemTTL UserId (Maybe Int64)
| SetChatItemTTL (Maybe Int64) | SetChatItemTTL (Maybe Int64)
| APIGetChatItemTTL UserId | APIGetChatItemTTL UserId
@ -316,7 +318,8 @@ data ChatCommand
| UpdateGroupNames GroupName GroupProfile | UpdateGroupNames GroupName GroupProfile
| ShowGroupProfile GroupName | ShowGroupProfile GroupName
| UpdateGroupDescription GroupName (Maybe Text) | UpdateGroupDescription GroupName (Maybe Text)
| CreateGroupLink GroupName | CreateGroupLink GroupName GroupMemberRole
| GroupLinkMemberRole GroupName GroupMemberRole
| DeleteGroupLink GroupName | DeleteGroupLink GroupName
| ShowGroupLink GroupName | ShowGroupLink GroupName
| SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: Text, message :: Text} | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: Text, message :: Text}
@ -454,8 +457,8 @@ data ChatResponse
| CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
| CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember}
| CRGroupProfile {user :: User, groupInfo :: GroupInfo} | CRGroupProfile {user :: User, groupInfo :: GroupInfo}
| CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact} | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole}
| CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact} | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole}
| CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo}
| CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact} | CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact}
| CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError}
@ -684,6 +687,7 @@ data ChatErrorType
| CEContactDisabled {contact :: Contact} | CEContactDisabled {contact :: Contact}
| CEConnectionDisabled {connection :: Connection} | CEConnectionDisabled {connection :: Connection}
| CEGroupUserRole {groupInfo :: GroupInfo, requiredRole :: GroupMemberRole} | CEGroupUserRole {groupInfo :: GroupInfo, requiredRole :: GroupMemberRole}
| CEGroupMemberInitialRole {groupInfo :: GroupInfo, initialRole :: GroupMemberRole}
| CEContactIncognitoCantInvite | CEContactIncognitoCantInvite
| CEGroupIncognitoCantInvite | CEGroupIncognitoCantInvite
| CEGroupContactRole {contactName :: ContactName} | CEGroupContactRole {contactName :: ContactName}

View File

@ -6,6 +6,7 @@ module Simplex.Chat.Help
chatHelpInfo, chatHelpInfo,
filesHelpInfo, filesHelpInfo,
groupsHelpInfo, groupsHelpInfo,
contactsHelpInfo,
myAddressHelpInfo, myAddressHelpInfo,
messagesHelpInfo, messagesHelpInfo,
markdownInfo, markdownInfo,
@ -84,7 +85,7 @@ chatHelpInfo =
green "Create your address: " <> highlight "/address", green "Create your address: " <> highlight "/address",
"", "",
green "Other commands:", green "Other commands:",
indent <> highlight "/help <topic> " <> " - help on: " <> listHighlight ["messages", "files", "groups", "address", "settings"], indent <> highlight "/help <topic> " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "settings"],
indent <> highlight "/profile " <> " - show / update user profile", indent <> highlight "/profile " <> " - show / update user profile",
indent <> highlight "/delete <contact>" <> " - delete contact and all messages with them", indent <> highlight "/delete <contact>" <> " - delete contact and all messages with them",
indent <> highlight "/chats " <> " - most recent chats", indent <> highlight "/chats " <> " - most recent chats",
@ -103,10 +104,12 @@ filesHelpInfo =
indent <> highlight "/file @<contact> <file_path> " <> " - send file to contact", indent <> highlight "/file @<contact> <file_path> " <> " - send file to contact",
indent <> highlight "/file #<group> <file_path> " <> " - send file to group", indent <> highlight "/file #<group> <file_path> " <> " - send file to group",
indent <> highlight "/image <name> [<file_path>] " <> " - send file as image to @contact or #group", indent <> highlight "/image <name> [<file_path>] " <> " - send file as image to @contact or #group",
indent <> highlight "/freceive <file_id> [<file_path>]" <> " - accept to receive file", indent <> highlight "/freceive <file_id> [<file_path>] " <> " - accept to receive file",
indent <> highlight "/fforward <name> [<file_id>] " <> " - forward received file to @contact or #group", indent <> highlight "/fforward <name> [<file_id>] " <> " - forward received file to @contact or #group",
indent <> highlight "/fcancel <file_id> " <> " - cancel sending / receiving file", indent <> highlight "/fcancel <file_id> " <> " - cancel sending / receiving file",
indent <> highlight "/fstatus <file_id> " <> " - show file transfer status", indent <> highlight "/fstatus <file_id> " <> " - show file transfer status",
indent <> highlight "/imgf @<contact> <file_id> " <> " - forward received image to contact",
indent <> highlight "/imgf #<group> <file_id> " <> " - forward received image to group",
"", "",
"The commands may be abbreviated: " <> listHighlight ["/f", "/img", "/fr", "/ff", "/fc", "/fs"] "The commands may be abbreviated: " <> listHighlight ["/f", "/img", "/fr", "/ff", "/fc", "/fs"]
] ]
@ -115,31 +118,89 @@ groupsHelpInfo :: [StyledString]
groupsHelpInfo = groupsHelpInfo =
map map
styleMarkdown styleMarkdown
[ green "Group management commands:", [ green "Group commands:",
indent <> highlight "/group <group> [<full_name>] " <> " - create group", indent <> highlight "/group <group> [<full_name>] " <> " - create group",
indent <> highlight "/add <group> <contact> [<role>]" <> " - add contact to group, roles: " <> highlight "owner" <> ", " <> highlight "admin" <> " (default), " <> highlight "member", indent <> highlight "/add <group> <contact> [<role>] " <> " - add contact to group, roles: " <> highlight "owner" <> ", " <> highlight "admin" <> " (default), " <> highlight "member",
indent <> highlight "/join <group> " <> " - accept group invitation", indent <> highlight "/join <group> " <> " - accept group invitation",
indent <> highlight "/members <group> " <> " - list group members",
indent <> highlight "/remove <group> <member> " <> " - remove member from group", indent <> highlight "/remove <group> <member> " <> " - remove member from group",
indent <> highlight "/leave <group> " <> " - leave group", indent <> highlight "/leave <group> " <> " - leave group",
indent <> highlight "/delete <group> " <> " - delete group", indent <> highlight "/clear #<group> " <> " - clear all messages in the group locally",
indent <> highlight "/members <group> " <> " - list group members", indent <> highlight "/delete #<group> " <> " - delete group and all messages",
indent <> highlight "/gp <group> " <> " - view group profile", indent <> highlight "/gp <group> " <> " - view group profile",
indent <> highlight "/gp <group> <new_name> [<full_name>] " <> " - update group profile", indent <> highlight "/gp <group> <name> [<full_name>] " <> " - update group profile names",
indent <> highlight "/group_descr <group> [<descr>] " <> " - update/remove group description", indent <> highlight "/group_descr <group> [<descr>] " <> " - update/remove group description",
indent <> highlight "/groups " <> " - list groups", indent <> highlight "/groups " <> " - list groups",
indent <> highlight "#<group> <message> " <> " - send message to group", indent <> highlight "#<group> <message> " <> " - send message to group",
indent <> highlight "/create link #<group> " <> " - create public group link", "",
green "Public group links:",
indent <> highlight "/create link #<group> [role] " <> " - create public group link (with optional role, default: member)",
indent <> highlight "/set link role #<group> role " <> " - change role assigned to the users joining via the link (member/observer)",
indent <> highlight "/show link #<group> " <> " - show public group link and initial member role",
indent <> highlight "/delete link #<group> " <> " - delete link to join the group (does NOT delete any members)",
"",
green "Mute group messages:",
indent <> highlight "/mute #<group> " <> " - do not show contact's messages",
indent <> highlight "/unmute #<group> " <> " - show contact's messages",
"",
green "Group member connection and security:",
indent <> highlight "/code #<group> <member> " <> " - show member's security code",
indent <> highlight "/verify #<group> <member> <code> " <> " - verify member's security code",
indent <> highlight "/verify #<group> <member> " <> " - clear security code verification",
indent <> highlight "/info #<group> <member> " <> " - info about member connection",
indent <> highlight "/switch #<group> <member> " <> " - switch receiving messages to another SMP relay",
"",
green "Group chat preferences:",
indent <> highlight "/set voice #<group> on/off " <> " - enable/disable voice messages",
indent <> highlight "/set delete #<group> on/off " <> " - enable/disable full message deletion",
indent <> highlight "/set direct #<group> on/off " <> " - enable/disable direct messages to other members",
indent <> highlight "/set disappear #<group> on <time> " <> " - enable disappearing messages with <time>:",
indent <> highlight " " <> " 30s, 5min, 1h, 8h, day, week, month",
indent <> highlight "/set disappear #<group> off " <> " - disable disappearing messages",
"", "",
"The commands may be abbreviated: " <> listHighlight ["/g", "/a", "/j", "/rm", "/l", "/d", "/ms", "/gs"] "The commands may be abbreviated: " <> listHighlight ["/g", "/a", "/j", "/rm", "/l", "/d", "/ms", "/gs"]
] ]
contactsHelpInfo :: [StyledString]
contactsHelpInfo =
map
styleMarkdown
[ green "Contact commands:",
indent <> highlight "/contacts " <> " - list contacts",
indent <> highlight "/clear @<name> " <> " - clear all messages with the contact locally",
indent <> highlight "/delete @<name> " <> " - delete contact and all messages",
"",
green "Mute contact messages:",
indent <> highlight "/mute @<name> " <> " - do not show contact's messages",
indent <> highlight "/unmute @<name> " <> " - show contact's messages",
"",
green "Contact connection and security:",
indent <> highlight "/code @<name> " <> " - show contact's security code",
indent <> highlight "/verify @<name> <code> " <> " - verify contact's security code",
indent <> highlight "/verify @<name> " <> " - clear security code verification",
indent <> highlight "/info @<name> " <> " - info about contact connection",
indent <> highlight "/switch @<name> " <> " - switch receiving messages to another SMP relay",
"",
green "Contact chat preferences:",
indent <> highlight "/set voice @<name> yes/no/always " <> " - allow/prohibit voice messages with the contact",
indent <> highlight "/set voice @<name> " <> " - reset voice messages to user's default",
indent <> highlight "/set delete @<name> yes/no/always " <> " - allow/prohibit full message deletion",
indent <> highlight "/set delete @<name> " <> " - reset full deletion to user's default",
indent <> highlight "/set disappear @<name> yes <time> " <> " - enable disappearing messages with <time>:",
indent <> highlight " " <> " 30s, 5min, 1h, 8h, day, week, month",
indent <> highlight "/set disappear @<name> yes " <> " - enable disappearing messages with offered time",
indent <> highlight "/set disappear @<name> no " <> " - disable disappearing messages",
"",
"The commands may be abbreviated: " <> listHighlight ["/d", "/i"]
]
myAddressHelpInfo :: [StyledString] myAddressHelpInfo :: [StyledString]
myAddressHelpInfo = myAddressHelpInfo =
map map
styleMarkdown styleMarkdown
[ green "Your contact address commands:", [ green "Your contact address commands:",
indent <> highlight "/address " <> " - create your address", indent <> highlight "/address " <> " - create your address",
indent <> highlight "/delete_address" <> " - delete your address (accepted contacts will remain connected)", indent <> highlight "/delete_address " <> " - delete your address (accepted contacts will remain connected)",
indent <> highlight "/show_address " <> " - show your address", indent <> highlight "/show_address " <> " - show your address",
indent <> highlight "/accept <name> " <> " - accept contact request", indent <> highlight "/accept <name> " <> " - accept contact request",
indent <> highlight "/reject <name> " <> " - reject contact request", indent <> highlight "/reject <name> " <> " - reject contact request",
@ -158,7 +219,7 @@ messagesHelpInfo =
indent <> highlight "/chats all " <> " - all conversations", indent <> highlight "/chats all " <> " - all conversations",
"", "",
green "Show recent messages", green "Show recent messages",
indent <> highlight "/tail @alice [N]" <> " - the last N messages with alice (10 by default)", indent <> highlight "/tail @alice [N] " <> " - the last N messages with alice (10 by default)",
indent <> highlight "/tail #team [N] " <> " - the last N messages in the group team", indent <> highlight "/tail #team [N] " <> " - the last N messages in the group team",
indent <> highlight "/tail [N] " <> " - the last N messages in all chats", indent <> highlight "/tail [N] " <> " - the last N messages in all chats",
"", "",
@ -205,10 +266,13 @@ settingsInfo =
map map
styleMarkdown styleMarkdown
[ green "Chat settings:", [ green "Chat settings:",
indent <> highlight "/incognito on/off " <> " - enable/disable incognito mode",
indent <> highlight "/network " <> " - show / set network access options", indent <> highlight "/network " <> " - show / set network access options",
indent <> highlight "/smp " <> " - show / set custom SMP servers", indent <> highlight "/smp " <> " - show / set custom SMP servers",
indent <> highlight "/info <contact> " <> " - information about contact connection", indent <> highlight "/info <contact> " <> " - information about contact connection",
indent <> highlight "/info #<group> <member> " <> " - information about member connection", indent <> highlight "/info #<group> <member> " <> " - information about member connection",
indent <> highlight "/(un)mute <contact> " <> " - (un)mute contact, the last messages can be printed with /tail command", indent <> highlight "/(un)mute <contact> " <> " - (un)mute contact, the last messages can be printed with /tail command",
indent <> highlight "/(un)mute #<group> " <> " - (un)mute group" indent <> highlight "/(un)mute #<group> " <> " - (un)mute group",
indent <> highlight "/get stats " <> " - get usage statistics",
indent <> highlight "/reset stats " <> " - reset usage statistics"
] ]

View File

@ -0,0 +1,12 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20230303_group_link_role where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20230303_group_link_role :: Query
m20230303_group_link_role =
[sql|
ALTER TABLE user_contact_links ADD COLUMN group_link_member_role TEXT NULL; -- member or observer
|]

View File

@ -282,6 +282,7 @@ CREATE TABLE user_contact_links(
group_id INTEGER REFERENCES groups ON DELETE CASCADE, group_id INTEGER REFERENCES groups ON DELETE CASCADE,
auto_accept_incognito INTEGER DEFAULT 0 CHECK(auto_accept_incognito NOT NULL), auto_accept_incognito INTEGER DEFAULT 0 CHECK(auto_accept_incognito NOT NULL),
group_link_id BLOB, group_link_id BLOB,
group_link_member_role TEXT NULL,
UNIQUE(user_id, local_display_name) UNIQUE(user_id, local_display_name)
); );
CREATE TABLE contact_requests( CREATE TABLE contact_requests(

View File

@ -75,6 +75,7 @@ module Simplex.Chat.Store
deleteGroupLink, deleteGroupLink,
getGroupLink, getGroupLink,
getGroupLinkId, getGroupLinkId,
setGroupLinkMemberRole,
createOrUpdateContactRequest, createOrUpdateContactRequest,
getContactRequest', getContactRequest',
getContactRequest, getContactRequest,
@ -258,7 +259,6 @@ module Simplex.Chat.Store
where where
import Control.Applicative ((<|>)) import Control.Applicative ((<|>))
import Control.Concurrent.STM (stateTVar)
import Control.Exception (Exception) import Control.Exception (Exception)
import qualified Control.Exception as E import qualified Control.Exception as E
import Control.Monad.Except import Control.Monad.Except
@ -341,6 +341,7 @@ import Simplex.Chat.Migrations.M20230117_fkey_indexes
import Simplex.Chat.Migrations.M20230118_recreate_smp_servers import Simplex.Chat.Migrations.M20230118_recreate_smp_servers
import Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx import Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx
import Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id import Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id
import Simplex.Chat.Migrations.M20230303_group_link_role
import Simplex.Chat.Protocol import Simplex.Chat.Protocol
import Simplex.Chat.Types import Simplex.Chat.Types
import Simplex.Chat.Util (week) import Simplex.Chat.Util (week)
@ -406,7 +407,8 @@ schemaMigrations =
("20230117_fkey_indexes", m20230117_fkey_indexes), ("20230117_fkey_indexes", m20230117_fkey_indexes),
("20230118_recreate_smp_servers", m20230118_recreate_smp_servers), ("20230118_recreate_smp_servers", m20230118_recreate_smp_servers),
("20230129_drop_chat_items_group_idx", m20230129_drop_chat_items_group_idx), ("20230129_drop_chat_items_group_idx", m20230129_drop_chat_items_group_idx),
("20230206_item_deleted_by_group_member_id", m20230206_item_deleted_by_group_member_id) ("20230206_item_deleted_by_group_member_id", m20230206_item_deleted_by_group_member_id),
("20230303_group_link_role", m20230303_group_link_role)
] ]
-- | The list of migrations in ascending order by date -- | The list of migrations in ascending order by date
@ -1086,13 +1088,13 @@ getUserAddress db User {userId} =
|] |]
(Only userId) (Only userId)
getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> IO (Maybe (UserContactLink, Maybe GroupId)) getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> IO (Maybe (UserContactLink, Maybe GroupId, GroupMemberRole))
getUserContactLinkById db userId userContactLinkId = getUserContactLinkById db userId userContactLinkId =
maybeFirstRow (\(ucl :. Only groupId_) -> (toUserContactLink ucl, groupId_)) $ maybeFirstRow (\(ucl :. (groupId_, mRole_)) -> (toUserContactLink ucl, groupId_, fromMaybe GRMember mRole_)) $
DB.query DB.query
db db
[sql| [sql|
SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content, group_id SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role
FROM user_contact_links FROM user_contact_links
WHERE user_id = ? WHERE user_id = ?
AND user_contact_link_id = ? AND user_contact_link_id = ?
@ -1117,14 +1119,14 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do
Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply) Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply)
_ -> (False, False, Nothing) _ -> (False, False, Nothing)
createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> ExceptT StoreError IO () createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> ExceptT StoreError IO ()
createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId = createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId memberRole =
checkConstraint (SEDuplicateGroupLink groupInfo) . liftIO $ do checkConstraint (SEDuplicateGroupLink groupInfo) . liftIO $ do
currentTs <- getCurrentTime currentTs <- getCurrentTime
DB.execute DB.execute
db db
"INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)"
(userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, True, currentTs, currentTs) (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs)
userContactLinkId <- insertedRowId db userContactLinkId <- insertedRowId db
void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing Nothing Nothing 0 currentTs void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing Nothing Nothing 0 currentTs
@ -1182,16 +1184,22 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do
(userId, groupId) (userId, groupId)
DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND group_id = ?" (userId, groupId) DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND group_id = ?" (userId, groupId)
getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO ConnReqContact getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO (Int64, ConnReqContact, GroupMemberRole)
getGroupLink db User {userId} gInfo@GroupInfo {groupId} = getGroupLink db User {userId} gInfo@GroupInfo {groupId} =
ExceptT . firstRow fromOnly (SEGroupLinkNotFound gInfo) $ ExceptT . firstRow groupLink (SEGroupLinkNotFound gInfo) $
DB.query db "SELECT conn_req_contact FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) DB.query db "SELECT user_contact_link_id, conn_req_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId)
where
groupLink (linkId, cReq, mRole_) = (linkId, cReq, fromMaybe GRMember mRole_)
getGroupLinkId :: DB.Connection -> User -> GroupInfo -> IO (Maybe GroupLinkId) getGroupLinkId :: DB.Connection -> User -> GroupInfo -> IO (Maybe GroupLinkId)
getGroupLinkId db User {userId} GroupInfo {groupId} = getGroupLinkId db User {userId} GroupInfo {groupId} =
fmap join . maybeFirstRow fromOnly $ fmap join . maybeFirstRow fromOnly $
DB.query db "SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) DB.query db "SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId)
setGroupLinkMemberRole :: DB.Connection -> User -> Int64 -> GroupMemberRole -> IO ()
setGroupLinkMemberRole db User {userId} userContactLinkId memberRole =
DB.execute db "UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ?" (memberRole, userId, userContactLinkId)
createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest
createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ = createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ =
liftIO (maybeM getContact' xContactId_) >>= \case liftIO (maybeM getContact' xContactId_) >>= \case
@ -1583,8 +1591,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 = ?"
@ -1620,6 +1637,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

@ -160,9 +160,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

@ -94,6 +94,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case
HSMain -> chatHelpInfo HSMain -> chatHelpInfo
HSFiles -> filesHelpInfo HSFiles -> filesHelpInfo
HSGroups -> groupsHelpInfo HSGroups -> groupsHelpInfo
HSContacts -> contactsHelpInfo
HSMyAddress -> myAddressHelpInfo HSMyAddress -> myAddressHelpInfo
HSMessages -> messagesHelpInfo HSMessages -> messagesHelpInfo
HSMarkdown -> markdownInfo HSMarkdown -> markdownInfo
@ -185,8 +186,8 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case
CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"]
CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m
CRGroupProfile u g -> ttyUser u $ viewGroupProfile g CRGroupProfile u g -> ttyUser u $ viewGroupProfile g
CRGroupLinkCreated u g cReq -> ttyUser u $ groupLink_ "Group link is created!" g cReq CRGroupLinkCreated u g cReq mRole -> ttyUser u $ groupLink_ "Group link is created!" g cReq mRole
CRGroupLink u g cReq -> ttyUser u $ groupLink_ "Group link:" g cReq CRGroupLink u g cReq mRole -> ttyUser u $ groupLink_ "Group link:" g cReq mRole
CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g
CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."]
CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e]
@ -540,13 +541,13 @@ autoAcceptStatus_ = \case
maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply
_ -> ["auto_accept off"] _ -> ["auto_accept off"]
groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> [StyledString] groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> GroupMemberRole -> [StyledString]
groupLink_ intro g cReq = groupLink_ intro g cReq mRole =
[ intro, [ intro,
"", "",
(plain . strEncode) cReq, (plain . strEncode) cReq,
"", "",
"Anybody can connect to you and join group with: " <> highlight' "/c <group_link_above>", "Anybody can connect to you and join group as " <> showRole mRole <> " with: " <> highlight' "/c <group_link_above>",
"to show it again: " <> highlight ("/show link #" <> groupName' g), "to show it again: " <> highlight ("/show link #" <> groupName' g),
"to delete it: " <> highlight ("/delete link #" <> groupName' g) <> " (joined members will remain connected to you)" "to delete it: " <> highlight ("/delete link #" <> groupName' g) <> " (joined members will remain connected to you)"
] ]
@ -1224,6 +1225,7 @@ viewChatError logLevel = \case
(: []) . (ttyGroup' g <>) $ case role of (: []) . (ttyGroup' g <>) $ case role of
GRAuthor -> ": you don't have permission to send messages" GRAuthor -> ": you don't have permission to send messages"
_ -> ": you have insufficient permissions for this action, the required role is " <> plain (strEncode role) _ -> ": you have insufficient permissions for this action, the required role is " <> plain (strEncode role)
CEGroupMemberInitialRole g role -> [ttyGroup' g <> ": initial role for group member cannot be " <> plain (strEncode role) <> ", use member or observer"]
CEContactIncognitoCantInvite -> ["you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"] CEContactIncognitoCantInvite -> ["you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"]
CEGroupIncognitoCantInvite -> ["you've connected to this group using an incognito profile - prohibited to invite contacts"] CEGroupIncognitoCantInvite -> ["you've connected to this group using an incognito profile - prohibited to invite contacts"]
CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"] CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"]

View File

@ -405,14 +405,14 @@ testTestSMPServerConnection :: HasCallStack => FilePath -> IO ()
testTestSMPServerConnection = testTestSMPServerConnection =
testChat2 aliceProfile bobProfile $ testChat2 aliceProfile bobProfile $
\alice _ -> do \alice _ -> do
alice ##> "/smp test 1 smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001" alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"
alice <## "SMP server test passed" alice <## "SMP server test passed"
-- to test with password: -- to test with password:
-- alice <## "SMP server test failed at CreateQueue, error: SMP AUTH" -- alice <## "SMP server test failed at CreateQueue, error: SMP AUTH"
-- alice <## "Server requires authorization to create queues, check password" -- alice <## "Server requires authorization to create queues, check password"
alice ##> "/smp test 1 smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"
alice <## "SMP server test passed" alice <## "SMP server test passed"
alice ##> "/smp test 1 smp://LcJU@localhost:7001" alice ##> "/smp test smp://LcJU@localhost:7001"
alice <## "SMP server test failed at Connect, error: BROKER smp://LcJU@localhost:7001 NETWORK" alice <## "SMP server test failed at Connect, error: BROKER smp://LcJU@localhost:7001 NETWORK"
alice <## "Possibly, certificate fingerprint in server address is incorrect" alice <## "Possibly, certificate fingerprint in server address is incorrect"

View File

@ -46,6 +46,8 @@ chatGroupTests = do
it "create group link, join via group link - incognito membership" testGroupLinkIncognitoMembership it "create group link, join via group link - incognito membership" testGroupLinkIncognitoMembership
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 "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
@ -127,28 +129,27 @@ testGroupShared alice bob cath checkMessages = do
alice <## "bob (Bob)" alice <## "bob (Bob)"
alice <## "cath (Catherine)" alice <## "cath (Catherine)"
-- test observer role -- test observer role
-- to be enabled once the role is enabled in parser alice ##> "/mr team bob observer"
-- alice ##> "/mr team bob observer" concurrentlyN_
-- concurrentlyN_ [ alice <## "#team: you changed the role of bob from admin to observer",
-- [ alice <## "#team: you changed the role of bob from admin to observer", bob <## "#team: alice changed your role from admin to observer",
-- bob <## "#team: alice changed your role from admin to observer", cath <## "#team: alice changed the role of bob from admin to observer"
-- cath <## "#team: alice changed the role of bob from admin to observer" ]
-- ] bob ##> "#team hello"
-- bob ##> "#team hello" bob <## "#team: you don't have permission to send messages"
-- bob <## "#team: you don't have permission to send messages to this group" bob ##> "/rm team cath"
-- bob ##> "/rm team cath" bob <## "#team: you have insufficient permissions for this action, the required role is admin"
-- bob <## "#team: you have insufficient permissions for this action, the required role is admin" cath #> "#team hello"
-- cath #> "#team hello" concurrentlyN_
-- concurrentlyN_ [ alice <# "#team cath> hello",
-- [ alice <# "#team cath> hello", bob <# "#team cath> hello"
-- bob <# "#team cath> hello" ]
-- ] alice ##> "/mr team bob admin"
-- alice ##> "/mr team bob admin" concurrentlyN_
-- concurrentlyN_ [ alice <## "#team: you changed the role of bob from observer to admin",
-- [ alice <## "#team: you changed the role of bob from observer to admin", bob <## "#team: alice changed your role from observer to admin",
-- bob <## "#team: alice changed your role from observer to admin", cath <## "#team: alice changed the role of bob from observer to admin"
-- cath <## "#team: alice changed the role of bob from observer to admin" ]
-- ]
-- remove member -- remove member
bob ##> "/rm team cath" bob ##> "/rm team cath"
concurrentlyN_ concurrentlyN_
@ -1423,14 +1424,14 @@ testGroupLink =
alice ##> "/show link #team" alice ##> "/show link #team"
alice <## "no group link, to create: /create link #team" alice <## "no group link, to create: /create link #team"
alice ##> "/create link #team" alice ##> "/create link #team"
_ <- getGroupLink alice "team" True _ <- getGroupLink alice "team" GRMember True
alice ##> "/delete link #team" alice ##> "/delete link #team"
alice <## "Group link is deleted - joined members will remain connected." alice <## "Group link is deleted - joined members will remain connected."
alice <## "To create a new group link use /create link #team" alice <## "To create a new group link use /create link #team"
alice ##> "/create link #team" alice ##> "/create link #team"
gLink <- getGroupLink alice "team" True gLink <- getGroupLink alice "team" GRMember True
alice ##> "/show link #team" alice ##> "/show link #team"
_ <- getGroupLink alice "team" False _ <- getGroupLink alice "team" GRMember False
alice ##> "/create link #team" alice ##> "/create link #team"
alice <## "you already have link for this group, to show: /show link #team" alice <## "you already have link for this group, to show: /show link #team"
bob ##> ("/c " <> gLink) bob ##> ("/c " <> gLink)
@ -1522,7 +1523,7 @@ testGroupLinkDeleteGroupRejoin =
alice <## "group #team is created" alice <## "group #team is created"
alice <## "to add members use /a team <name> or /create link #team" alice <## "to add members use /a team <name> or /create link #team"
alice ##> "/create link #team" alice ##> "/create link #team"
gLink <- getGroupLink alice "team" True gLink <- getGroupLink alice "team" GRMember True
bob ##> ("/c " <> gLink) bob ##> ("/c " <> gLink)
bob <## "connection request sent!" bob <## "connection request sent!"
alice <## "bob (Bob): accepting request to join group #team..." alice <## "bob (Bob): accepting request to join group #team..."
@ -1578,7 +1579,7 @@ testGroupLinkContactUsed =
alice <## "group #team is created" alice <## "group #team is created"
alice <## "to add members use /a team <name> or /create link #team" alice <## "to add members use /a team <name> or /create link #team"
alice ##> "/create link #team" alice ##> "/create link #team"
gLink <- getGroupLink alice "team" True gLink <- getGroupLink alice "team" GRMember True
bob ##> ("/c " <> gLink) bob ##> ("/c " <> gLink)
bob <## "connection request sent!" bob <## "connection request sent!"
alice <## "bob (Bob): accepting request to join group #team..." alice <## "bob (Bob): accepting request to join group #team..."
@ -1638,7 +1639,7 @@ testGroupLinkIncognitoMembership =
(bob <## ("#team: you joined the group incognito as " <> bobIncognito)) (bob <## ("#team: you joined the group incognito as " <> bobIncognito))
-- bob creates group link, cath joins -- bob creates group link, cath joins
bob ##> "/create link #team" bob ##> "/create link #team"
gLink <- getGroupLink bob "team" True gLink <- getGroupLink bob "team" GRMember True
cath ##> ("/c " <> gLink) cath ##> ("/c " <> gLink)
cath <## "connection request sent!" cath <## "connection request sent!"
bob <## "cath (Catherine): accepting request to join group #team..." bob <## "cath (Catherine): accepting request to join group #team..."
@ -1729,7 +1730,7 @@ testGroupLinkUnusedHostContactDeleted =
alice <## "group #team is created" alice <## "group #team is created"
alice <## "to add members use /a team <name> or /create link #team" alice <## "to add members use /a team <name> or /create link #team"
alice ##> "/create link #team" alice ##> "/create link #team"
gLinkTeam <- getGroupLink alice "team" True gLinkTeam <- getGroupLink alice "team" GRMember True
bob ##> ("/c " <> gLinkTeam) bob ##> ("/c " <> gLinkTeam)
bob <## "connection request sent!" bob <## "connection request sent!"
alice <## "bob (Bob): accepting request to join group #team..." alice <## "bob (Bob): accepting request to join group #team..."
@ -1747,7 +1748,7 @@ testGroupLinkUnusedHostContactDeleted =
alice <## "group #club is created" alice <## "group #club is created"
alice <## "to add members use /a club <name> or /create link #club" alice <## "to add members use /a club <name> or /create link #club"
alice ##> "/create link #club" alice ##> "/create link #club"
gLinkClub <- getGroupLink alice "club" True gLinkClub <- getGroupLink alice "club" GRMember True
bob ##> ("/c " <> gLinkClub) bob ##> ("/c " <> gLinkClub)
bob <## "connection request sent!" bob <## "connection request sent!"
alice <## "bob_1 (Bob): accepting request to join group #club..." alice <## "bob_1 (Bob): accepting request to join group #club..."
@ -1822,7 +1823,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted =
alice <## ("group #" <> group <> " is created") alice <## ("group #" <> group <> " is created")
alice <## ("to add members use /a " <> group <> " <name> or /create link #" <> group) alice <## ("to add members use /a " <> group <> " <name> or /create link #" <> group)
alice ##> ("/create link #" <> group) alice ##> ("/create link #" <> group)
gLinkTeam <- getGroupLink alice group True gLinkTeam <- getGroupLink alice group GRMember True
bob ##> ("/c " <> gLinkTeam) bob ##> ("/c " <> gLinkTeam)
bobIncognito <- getTermLine bob bobIncognito <- getTermLine bob
bob <## "connection request sent incognito!" bob <## "connection request sent incognito!"
@ -1850,3 +1851,138 @@ testGroupLinkIncognitoUnusedHostContactsDeleted =
] ]
bob ##> ("/d #" <> group) bob ##> ("/d #" <> group)
bob <## ("#" <> group <> ": you deleted the group") bob <## ("#" <> group <> ": you deleted the group")
testGroupLinkMemberRole :: HasCallStack => FilePath -> IO ()
testGroupLinkMemberRole =
testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
alice ##> "/g team"
alice <## "group #team is created"
alice <## "to add members use /a team <name> or /create link #team"
alice ##> "/create link #team admin"
alice <## "#team: initial role for group member cannot be admin, use member or observer"
alice ##> "/create link #team observer"
gLink <- getGroupLink alice "team" GRObserver True
bob ##> ("/c " <> gLink)
bob <## "connection request sent!"
alice <## "bob (Bob): accepting request to join group #team..."
concurrentlyN_
[ do
alice <## "bob (Bob): contact is connected"
alice <## "bob invited to group #team via your group link"
alice <## "#team: bob joined the group",
do
bob <## "alice (Alice): contact is connected"
bob <## "#team: you joined the group"
]
alice ##> "/set link role #team admin"
alice <## "#team: initial role for group member cannot be admin, use member or observer"
alice ##> "/set link role #team member"
_ <- getGroupLink alice "team" GRMember False
cath ##> ("/c " <> gLink)
cath <## "connection request sent!"
alice <## "cath (Catherine): accepting request to join group #team..."
-- if contact existed it is merged
concurrentlyN_
[ alice
<### [ "cath (Catherine): contact is connected",
EndsWith "invited to group #team via your group link",
EndsWith "joined the group"
],
cath
<### [ "alice (Alice): contact is connected",
"#team: you joined the group",
"#team: member bob (Bob) is connected"
],
do
bob <## "#team: alice added cath (Catherine) to the group (connecting...)"
bob <## "#team: new member cath is connected"
]
alice #> "#team hello"
concurrently_
(bob <# "#team alice> hello")
(cath <# "#team alice> hello")
cath #> "#team hello too"
concurrently_
(alice <# "#team cath> hello too")
(bob <# "#team cath> hello too")
bob ##> "#team hey"
bob <## "#team: you don't have permission to send messages"
alice ##> "/mr #team bob member"
alice <## "#team: you changed the role of bob from observer to member"
concurrently_
(bob <## "#team: alice changed your role from observer to member")
(cath <## "#team: alice changed the role of bob from observer to member")
bob #> "#team hey now"
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 <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)"

View File

@ -323,13 +323,13 @@ getContactLink cc created = do
cc <## "to delete it: /da (accepted contacts will remain connected)" cc <## "to delete it: /da (accepted contacts will remain connected)"
pure link pure link
getGroupLink :: HasCallStack => TestCC -> String -> Bool -> IO String getGroupLink :: HasCallStack => TestCC -> String -> GroupMemberRole -> Bool -> IO String
getGroupLink cc gName created = do getGroupLink cc gName mRole created = do
cc <## if created then "Group link is created!" else "Group link:" cc <## if created then "Group link is created!" else "Group link:"
cc <## "" cc <## ""
link <- getTermLine cc link <- getTermLine cc
cc <## "" cc <## ""
cc <## "Anybody can connect to you and join group with: /c <group_link_above>" cc <## ("Anybody can connect to you and join group as " <> B.unpack (strEncode mRole) <> " with: /c <group_link_above>")
cc <## ("to show it again: /show link #" <> gName) cc <## ("to show it again: /show link #" <> gName)
cc <## ("to delete it: /delete link #" <> gName <> " (joined members will remain connected to you)") cc <## ("to delete it: /delete link #" <> gName <> " (joined members will remain connected to you)")
pure link pure link