mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 498ffe8a71 | |||
| 3dd5b5d835 | |||
| 9127b1bbc6 | |||
| 1657bcf97d | |||
| 428db2f8f4 | |||
| 4cc59d9fbd | |||
| c50306709b | |||
| 37d0bc2f14 | |||
| 2fda0454e3 | |||
| be19af62d9 | |||
| f915eb2a20 | |||
| 2bc1236a2c | |||
| 01acbb970a | |||
| 36cad35d46 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 102
|
||||
versionName "4.5.3"
|
||||
versionCode 103
|
||||
versionName "4.5.4"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
|
||||
@@ -486,6 +486,24 @@ data class Chat (
|
||||
val chatItems: List<ChatItem>,
|
||||
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
|
||||
|
||||
@Serializable
|
||||
@@ -932,7 +950,7 @@ data class GroupMember (
|
||||
fun canChangeRoleTo(groupInfo: GroupInfo): List<GroupMemberRole>? =
|
||||
if (!canBeRemoved(groupInfo)) null
|
||||
else groupInfo.membership.memberRole.let { userRole ->
|
||||
GroupMemberRole.values().filter { it <= userRole }
|
||||
GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Observer }
|
||||
}
|
||||
|
||||
val memberIncognito = memberProfile.profileId != memberContactProfileId
|
||||
@@ -963,11 +981,13 @@ class GroupMemberRef(
|
||||
|
||||
@Serializable
|
||||
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("owner") Owner("owner");
|
||||
|
||||
val text: String get() = when (this) {
|
||||
Observer -> generalGetString(R.string.group_member_role_observer)
|
||||
Member -> generalGetString(R.string.group_member_role_member)
|
||||
Admin -> generalGetString(R.string.group_member_role_admin)
|
||||
Owner -> generalGetString(R.string.group_member_role_owner)
|
||||
|
||||
@@ -549,7 +549,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
|
||||
suspend fun testSMPServer(smpServer: String): SMPTestFailure? {
|
||||
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) {
|
||||
is CR.SmpTestResult -> r.smpTestFailure
|
||||
else -> {
|
||||
@@ -1060,9 +1060,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiCreateGroupLink(groupId: Long): String? {
|
||||
return when (val r = sendCmd(CC.APICreateGroupLink(groupId))) {
|
||||
is CR.GroupLinkCreated -> r.connReqContact
|
||||
suspend fun apiCreateGroupLink(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair<String, GroupMemberRole>? {
|
||||
return when (val r = sendCmd(CC.APICreateGroupLink(groupId, memberRole))) {
|
||||
is CR.GroupLinkCreated -> r.connReqContact to r.memberRole
|
||||
else -> {
|
||||
if (!(networkErrorAlert(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 {
|
||||
return when (val r = sendCmd(CC.APIDeleteGroupLink(groupId))) {
|
||||
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))) {
|
||||
is CR.GroupLink -> r.connReqContact
|
||||
is CR.GroupLink -> r.connReqContact to r.memberRole
|
||||
else -> {
|
||||
Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
@@ -1343,6 +1355,10 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
if (active(r.user)) {
|
||||
chatModel.updateGroup(r.toGroup)
|
||||
}
|
||||
is CR.MemberRole ->
|
||||
if (active(r.user)) {
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
}
|
||||
is CR.RcvFileStart ->
|
||||
chatItemSimpleUpdate(r.user, r.chatItem)
|
||||
is CR.RcvFileComplete ->
|
||||
@@ -1752,12 +1768,13 @@ sealed class CC {
|
||||
class ApiLeaveGroup(val groupId: Long): CC()
|
||||
class ApiListMembers(val groupId: Long): 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 APIGetGroupLink(val groupId: Long): CC()
|
||||
class APIGetUserSMPServers(val userId: Long): 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 APIGetChatItemTTL(val userId: Long): CC()
|
||||
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
|
||||
@@ -1827,12 +1844,13 @@ sealed class CC {
|
||||
is ApiLeaveGroup -> "/_leave #$groupId"
|
||||
is ApiListMembers -> "/_members #$groupId"
|
||||
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 APIGetGroupLink -> "/_get link #$groupId"
|
||||
is APIGetUserSMPServers -> "/_smp $userId"
|
||||
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 APIGetChatItemTTL -> "/_ttl $userId"
|
||||
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
|
||||
@@ -1904,11 +1922,12 @@ sealed class CC {
|
||||
is ApiListMembers -> "apiListMembers"
|
||||
is ApiUpdateGroupProfile -> "apiUpdateGroupProfile"
|
||||
is APICreateGroupLink -> "apiCreateGroupLink"
|
||||
is APIGroupLinkMemberRole -> "apiGroupLinkMemberRole"
|
||||
is APIDeleteGroupLink -> "apiDeleteGroupLink"
|
||||
is APIGetGroupLink -> "apiGetGroupLink"
|
||||
is APIGetUserSMPServers -> "apiGetUserSMPServers"
|
||||
is APISetUserSMPServers -> "apiSetUserSMPServers"
|
||||
is TestSMPServer -> "testSMPServer"
|
||||
is APITestSMPServer -> "testSMPServer"
|
||||
is APISetChatItemTTL -> "apiSetChatItemTTL"
|
||||
is APIGetChatItemTTL -> "apiGetChatItemTTL"
|
||||
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("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("groupLinkCreated") class GroupLinkCreated(val user: User, val groupInfo: GroupInfo, val connReqContact: String): CR()
|
||||
@Serializable @SerialName("groupLink") class GroupLink(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, val memberRole: GroupMemberRole): CR()
|
||||
@Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: User, val groupInfo: GroupInfo): CR()
|
||||
// receiving file events
|
||||
@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 GroupRemoved -> withUser(user, json.encodeToString(groupInfo))
|
||||
is GroupUpdated -> withUser(user, json.encodeToString(toGroup))
|
||||
is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact")
|
||||
is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact")
|
||||
is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole")
|
||||
is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole")
|
||||
is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo))
|
||||
is RcvFileAcceptedSndCancelled -> withUser(user, noDetails())
|
||||
is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem))
|
||||
|
||||
@@ -17,6 +17,7 @@ enum class DefaultTheme {
|
||||
val DEFAULT_PADDING = 16.dp
|
||||
val DEFAULT_SPACE_AFTER_ICON = 4.dp
|
||||
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
|
||||
val DEFAULT_BOTTOM_PADDING = 48.dp
|
||||
|
||||
val DarkColorPalette = darkColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
|
||||
@@ -83,6 +83,8 @@ fun TerminalLayout(
|
||||
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = sendCommand,
|
||||
sendLiveMessage = null,
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.webkit.WebViewClientCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
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.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
@@ -240,7 +241,7 @@ private fun ActiveCallOverlayLayout(
|
||||
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
||||
}
|
||||
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) {
|
||||
IconButton(onClick = dismiss) {
|
||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
|
||||
@@ -152,9 +152,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
}
|
||||
} else if (chat.chatInfo is ChatInfo.Group) {
|
||||
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 ->
|
||||
GroupChatInfoView(chatModel, groupLink, { groupLink = it }, close)
|
||||
GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, {
|
||||
groupLink = it.first;
|
||||
groupLinkMemberRole = it.second
|
||||
}, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
@@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.app.*
|
||||
@@ -645,6 +647,9 @@ fun ComposeView(
|
||||
chatModel.sharedContent.value = null
|
||||
}
|
||||
|
||||
val userCanSend = rememberUpdatedState(chat.userCanSend)
|
||||
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
|
||||
|
||||
Column {
|
||||
contextItemView()
|
||||
when {
|
||||
@@ -656,11 +661,11 @@ fun ComposeView(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled) {
|
||||
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) {
|
||||
Icon(
|
||||
Icons.Filled.AttachFile,
|
||||
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
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
@@ -698,6 +703,13 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) {
|
||||
if (!chat.userCanSend) {
|
||||
clearCurrentDraft()
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
|
||||
val activity = LocalContext.current as Activity
|
||||
DisposableEffect(Unit) {
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
@@ -733,6 +745,8 @@ fun ComposeView(
|
||||
needToAllowVoiceToContact,
|
||||
allowedVoiceByPrefs,
|
||||
allowVoiceToContact = ::allowVoiceToContact,
|
||||
userIsObserver = userIsObserver.value,
|
||||
userCanSend = userCanSend.value,
|
||||
sendMessage = {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
|
||||
@@ -60,6 +60,8 @@ fun SendMsgView(
|
||||
liveMessageAlertShown: SharedPreference<Boolean>,
|
||||
needToAllowVoiceToContact: Boolean,
|
||||
allowedVoiceByPrefs: Boolean,
|
||||
userIsObserver: Boolean,
|
||||
userCanSend: Boolean,
|
||||
allowVoiceToContact: () -> Unit,
|
||||
sendMessage: () -> Unit,
|
||||
sendLiveMessage: (suspend () -> Unit)? = null,
|
||||
@@ -74,10 +76,18 @@ fun SendMsgView(
|
||||
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
||||
NativeKeyboard(composeState, textStyle, showDeleteTextButton, onMessageChange)
|
||||
NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange)
|
||||
// Disable clicks on text field
|
||||
if (cs.preview is ComposePreview.VoicePreview) {
|
||||
Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { }))
|
||||
if (cs.preview is ComposePreview.VoicePreview || !userCanSend) {
|
||||
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) {
|
||||
DeleteTextButton(composeState)
|
||||
@@ -99,11 +109,11 @@ fun SendMsgView(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
||||
when {
|
||||
needToAllowVoiceToContact || !allowedVoiceByPrefs -> {
|
||||
DisallowedVoiceButton {
|
||||
needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> {
|
||||
DisallowedVoiceButton(userCanSend) {
|
||||
if (needToAllowVoiceToContact) {
|
||||
showNeedToAllowVoiceAlert(allowVoiceToContact)
|
||||
} else {
|
||||
} else if (!allowedVoiceByPrefs) {
|
||||
showDisabledVoiceAlert(isDirectChat)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +128,7 @@ fun SendMsgView(
|
||||
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
|
||||
&& cs.contextItem is ComposeContextItem.NoContextItem) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
StartLiveMessageButton {
|
||||
StartLiveMessageButton(userCanSend) {
|
||||
if (composeState.value.preview is ComposePreview.NoPreview) {
|
||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||
}
|
||||
@@ -173,6 +183,7 @@ private fun NativeKeyboard(
|
||||
composeState: MutableState<ComposeState>,
|
||||
textStyle: MutableState<TextStyle>,
|
||||
showDeleteTextButton: MutableState<Boolean>,
|
||||
userIsObserver: Boolean,
|
||||
onMessageChange: (String) -> Unit
|
||||
) {
|
||||
val cs = composeState.value
|
||||
@@ -253,15 +264,22 @@ private fun NativeKeyboard(
|
||||
showDeleteTextButton.value = it.lineCount >= 4
|
||||
}
|
||||
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
Text(
|
||||
generalGetString(R.string.voice_message_send_text),
|
||||
Modifier.padding(padding),
|
||||
color = HighOrLowlight,
|
||||
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||
)
|
||||
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(
|
||||
generalGetString(textId),
|
||||
Modifier.padding(padding),
|
||||
color = HighOrLowlight,
|
||||
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>) {
|
||||
IconButton(
|
||||
@@ -322,8 +340,8 @@ private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNex
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisallowedVoiceButton(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp), enabled = enabled) {
|
||||
Icon(
|
||||
Icons.Outlined.KeyboardVoice,
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
@@ -454,13 +472,13 @@ private fun SendMsgButton(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartLiveMessageButton(onClick: () -> Unit) {
|
||||
private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = Modifier.requiredSize(36.dp)
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
enabled = true,
|
||||
enabled = enabled,
|
||||
role = Role.Button,
|
||||
interactionSource = interactionSource,
|
||||
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||
@@ -470,7 +488,7 @@ private fun StartLiveMessageButton(onClick: () -> Unit) {
|
||||
Icon(
|
||||
Icons.Filled.Bolt,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
tint = if (enabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
@@ -571,6 +589,8 @@ fun PreviewSendMsgView() {
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
@@ -599,6 +619,8 @@ fun PreviewSendMsgViewEditing() {
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
@@ -627,6 +649,8 @@ fun PreviewSendMsgViewInProgress() {
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
userIsObserver = false,
|
||||
userCanSend = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
|
||||
+1
-1
@@ -166,7 +166,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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(
|
||||
generalGetString(R.string.new_member_role),
|
||||
values,
|
||||
|
||||
+3
-4
@@ -34,7 +34,7 @@ import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
|
||||
@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)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
@@ -95,9 +95,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdat
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||
manageGroupLink = {
|
||||
withApi {
|
||||
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, onGroupLinkUpdated) }
|
||||
}
|
||||
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -300,6 +298,7 @@ private fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
Modifier.weight(1f).padding(end = DEFAULT_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Text
|
||||
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.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.GroupInfo
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
|
||||
@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) }
|
||||
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
|
||||
var creatingLink by rememberSaveable { mutableStateOf(false) }
|
||||
val cxt = LocalContext.current
|
||||
fun createLink() {
|
||||
creatingLink = true
|
||||
withApi {
|
||||
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||
onGroupLinkUpdated(groupLink)
|
||||
val link = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||
if (link != null) {
|
||||
groupLink = link.first
|
||||
groupLinkMemberRole.value = link.second
|
||||
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
|
||||
}
|
||||
creatingLink = false
|
||||
}
|
||||
}
|
||||
@@ -41,9 +48,24 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
||||
}
|
||||
GroupLinkLayout(
|
||||
groupLink = groupLink,
|
||||
groupInfo,
|
||||
groupLinkMemberRole,
|
||||
creatingLink,
|
||||
createLink = ::createLink,
|
||||
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 = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
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)
|
||||
if (r) {
|
||||
groupLink = null
|
||||
onGroupLinkUpdated(null)
|
||||
onGroupLinkUpdated(null to null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,13 +91,18 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
||||
@Composable
|
||||
fun GroupLinkLayout(
|
||||
groupLink: String?,
|
||||
groupInfo: GroupInfo,
|
||||
groupLinkMemberRole: MutableState<GroupMemberRole?>,
|
||||
creatingLink: Boolean,
|
||||
createLink: () -> Unit,
|
||||
share: () -> Unit,
|
||||
updateLink: () -> Unit,
|
||||
deleteLink: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = DEFAULT_PADDING, bottom = DEFAULT_BOTTOM_PADDING, end = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
@@ -93,7 +120,17 @@ fun GroupLinkLayout(
|
||||
if (groupLink == null) {
|
||||
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink)
|
||||
} 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(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
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
|
||||
fun ProgressIndicator() {
|
||||
Box(
|
||||
|
||||
@@ -154,7 +154,7 @@ fun DatabaseLayout(
|
||||
val operationsDisabled = !stopped || progressIndicator
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 48.dp),
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = DEFAULT_BOTTOM_PADDING),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.your_chat_database))
|
||||
|
||||
@@ -85,13 +85,14 @@ fun SectionItemView(
|
||||
click: (() -> Unit)? = null,
|
||||
minHeight: Dp = 46.dp,
|
||||
disabled: Boolean = false,
|
||||
padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING),
|
||||
content: (@Composable RowScope.() -> Unit)
|
||||
) {
|
||||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = minHeight)
|
||||
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
|
||||
) {
|
||||
content()
|
||||
|
||||
@@ -222,6 +222,9 @@
|
||||
<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_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 -->
|
||||
<string name="image_descr">Image</string>
|
||||
@@ -868,6 +871,7 @@
|
||||
<string name="snd_conn_event_switch_queue_phase_changing">changing address…</string>
|
||||
|
||||
<!-- GroupMemberRole -->
|
||||
<string name="group_member_role_observer">observer</string>
|
||||
<string name="group_member_role_member">member</string>
|
||||
<string name="group_member_role_admin">admin</string>
|
||||
<string name="group_member_role_owner">owner</string>
|
||||
@@ -890,6 +894,7 @@
|
||||
<!-- AddGroupMembersView.kt -->
|
||||
<string name="no_contacts_to_add">No contacts to add</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="invite_to_group_button">Invite to group</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="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_updating_link_for_group">Error updating 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>
|
||||
|
||||
|
||||
@@ -545,6 +545,25 @@ final class Chat: ObservableObject, Identifiable {
|
||||
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 viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
||||
|
||||
@@ -344,7 +344,7 @@ func setUserSMPServers(smpServers: [ServerCfg]) async throws {
|
||||
|
||||
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
|
||||
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 let t = testFailure {
|
||||
return .failure(t)
|
||||
@@ -868,9 +868,15 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiCreateGroupLink(_ groupId: Int64) async throws -> String {
|
||||
let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId))
|
||||
if case let .groupLinkCreated(_, _, connReq) = r { return connReq }
|
||||
func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) {
|
||||
let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -880,11 +886,11 @@ func apiDeleteGroupLink(_ groupId: Int64) async throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetGroupLink(_ groupId: Int64) throws -> String? {
|
||||
func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? {
|
||||
let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId))
|
||||
switch r {
|
||||
case let .groupLink(_, _, connReq):
|
||||
return connReq
|
||||
case let .groupLink(_, _, connReq, memberRole):
|
||||
return (connReq, memberRole)
|
||||
case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)):
|
||||
return nil
|
||||
default: throw r
|
||||
@@ -1180,6 +1186,10 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
if active(user) {
|
||||
m.updateGroup(toGroup)
|
||||
}
|
||||
case let .memberRole(user, groupInfo, _, _, _, _):
|
||||
if active(user) {
|
||||
m.updateGroup(groupInfo)
|
||||
}
|
||||
case let .rcvFileStart(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileComplete(user, aChatItem):
|
||||
|
||||
@@ -258,36 +258,52 @@ struct ComposeView: View {
|
||||
Image(systemName: "paperclip")
|
||||
.resizable()
|
||||
}
|
||||
.disabled(composeState.attachmentDisabled)
|
||||
.disabled(composeState.attachmentDisabled || !chat.userCanSend)
|
||||
.frame(width: 25, height: 25)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.leading, 12)
|
||||
SendMessageView(
|
||||
composeState: $composeState,
|
||||
sendMessage: {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
},
|
||||
sendLiveMessage: sendLiveMessage,
|
||||
updateLiveMessage: updateLiveMessage,
|
||||
cancelLiveMessage: {
|
||||
composeState.liveMessage = nil
|
||||
chatModel.removeLiveDummy()
|
||||
},
|
||||
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
|
||||
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
||||
startVoiceMessageRecording: {
|
||||
Task {
|
||||
await startVoiceMessageRecording()
|
||||
}
|
||||
},
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
|
||||
onImagesAdded: { images in if !images.isEmpty { chosenImages = images }},
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
.background(.background)
|
||||
ZStack(alignment: .leading) {
|
||||
SendMessageView(
|
||||
composeState: $composeState,
|
||||
sendMessage: {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
},
|
||||
sendLiveMessage: sendLiveMessage,
|
||||
updateLiveMessage: updateLiveMessage,
|
||||
cancelLiveMessage: {
|
||||
composeState.liveMessage = nil
|
||||
chatModel.removeLiveDummy()
|
||||
},
|
||||
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
|
||||
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
||||
startVoiceMessageRecording: {
|
||||
Task {
|
||||
await startVoiceMessageRecording()
|
||||
}
|
||||
},
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
|
||||
onImagesAdded: { images in if !images.isEmpty { chosenImages = images }},
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
.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
|
||||
@@ -299,6 +315,13 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: chat.userCanSend) { canSend in
|
||||
if !canSend {
|
||||
cancelCurrentVoiceRecording()
|
||||
clearCurrentDraft()
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
|
||||
Button("Take picture") {
|
||||
showTakePhoto = true
|
||||
|
||||
@@ -140,7 +140,7 @@ struct AddGroupMembersView: View {
|
||||
private func rolePicker() -> some View {
|
||||
Picker("New member role", selection: $selectedRole) {
|
||||
ForEach(GroupMemberRole.allCases) { role in
|
||||
if role <= groupInfo.membership.memberRole {
|
||||
if role <= groupInfo.membership.memberRole && role != .observer {
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ struct GroupChatInfoView: View {
|
||||
@ObservedObject private var alertManager = AlertManager.shared
|
||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var connectionCode: String?
|
||||
@@ -107,7 +108,9 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
.onAppear {
|
||||
do {
|
||||
groupLink = try apiGetGroupLink(groupInfo.groupId)
|
||||
if let link = try apiGetGroupLink(groupInfo.groupId) {
|
||||
(groupLink, groupLinkMemberRole) = link
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
||||
}
|
||||
@@ -187,7 +190,7 @@ struct GroupChatInfoView: View {
|
||||
|
||||
private func groupLinkButton() -> some View {
|
||||
NavigationLink {
|
||||
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
|
||||
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole)
|
||||
.navigationBarTitle("Group link")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import SimpleXChat
|
||||
struct GroupLinkView: View {
|
||||
var groupId: Int64
|
||||
@Binding var groupLink: String?
|
||||
@Binding var groupLinkMemberRole: GroupMemberRole
|
||||
@State private var creatingLink = false
|
||||
@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.")
|
||||
.padding(.bottom)
|
||||
if let groupLink = groupLink {
|
||||
// HStack {
|
||||
// Text("Initial role")
|
||||
// Picker("Initial role", selection: $groupLinkMemberRole) {
|
||||
// ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in
|
||||
// Text(role.text)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// .frame(maxWidth: .infinity, alignment: .leading)
|
||||
QRCode(uri: groupLink)
|
||||
HStack {
|
||||
Button {
|
||||
@@ -85,6 +95,16 @@ struct GroupLinkView: View {
|
||||
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 {
|
||||
if groupLink == nil && !creatingLink {
|
||||
createGroupLink()
|
||||
@@ -100,7 +120,7 @@ struct GroupLinkView: View {
|
||||
let link = try await apiCreateGroupLink(groupId)
|
||||
await MainActor.run {
|
||||
creatingLink = false
|
||||
groupLink = link
|
||||
(groupLink, groupLinkMemberRole) = link
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
|
||||
@@ -120,8 +140,8 @@ struct GroupLinkView_Previews: PreviewProvider {
|
||||
@State var noGroupLink: String? = nil
|
||||
|
||||
return Group {
|
||||
GroupLinkView(groupId: 1, groupLink: $groupLink)
|
||||
GroupLinkView(groupId: 1, groupLink: $noGroupLink)
|
||||
GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member))
|
||||
GroupLinkView(groupId: 1, groupLink: $noGroupLink, groupLinkMemberRole: Binding.constant(.member))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,11 @@
|
||||
5CBD285C29575B8E00EC2CF4 /* WhatsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */; };
|
||||
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */; };
|
||||
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */; };
|
||||
5CC036F029C5B08600C0EF20 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC036EB29C5B08600C0EF20 /* libgmp.a */; };
|
||||
5CC036F129C5B08600C0EF20 /* libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC036EC29C5B08600C0EF20 /* libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS-ghc8.10.7.a */; };
|
||||
5CC036F229C5B08600C0EF20 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC036ED29C5B08600C0EF20 /* libgmpxx.a */; };
|
||||
5CC036F329C5B08600C0EF20 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC036EE29C5B08600C0EF20 /* libffi.a */; };
|
||||
5CC036F429C5B08600C0EF20 /* libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC036EF29C5B08600C0EF20 /* libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS.a */; };
|
||||
5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; };
|
||||
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; };
|
||||
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; };
|
||||
@@ -144,11 +149,6 @@
|
||||
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; };
|
||||
6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; };
|
||||
6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; };
|
||||
644E729229AFA1FA003534BE /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644E728D29AFA1FA003534BE /* libgmpxx.a */; };
|
||||
644E729329AFA1FA003534BE /* libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644E728E29AFA1FA003534BE /* libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP-ghc8.10.7.a */; };
|
||||
644E729429AFA1FA003534BE /* libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644E728F29AFA1FA003534BE /* libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP.a */; };
|
||||
644E729529AFA1FA003534BE /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644E729029AFA1FA003534BE /* libgmp.a */; };
|
||||
644E729629AFA1FA003534BE /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644E729129AFA1FA003534BE /* libffi.a */; };
|
||||
644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; };
|
||||
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */; };
|
||||
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; };
|
||||
@@ -344,6 +344,11 @@
|
||||
5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewView.swift; sourceTree = "<group>"; };
|
||||
5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyCodeView.swift; sourceTree = "<group>"; };
|
||||
5CBE6C132944CC12002D9531 /* ScanCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanCodeView.swift; sourceTree = "<group>"; };
|
||||
5CC036EB29C5B08600C0EF20 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5CC036EC29C5B08600C0EF20 /* libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5CC036ED29C5B08600C0EF20 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5CC036EE29C5B08600C0EF20 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5CC036EF29C5B08600C0EF20 /* libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS.a"; sourceTree = "<group>"; };
|
||||
5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = "<group>"; };
|
||||
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
|
||||
5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -384,11 +389,6 @@
|
||||
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
|
||||
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = "<group>"; };
|
||||
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = "<group>"; };
|
||||
644E728D29AFA1FA003534BE /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
644E728E29AFA1FA003534BE /* libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
644E728F29AFA1FA003534BE /* libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP.a"; sourceTree = "<group>"; };
|
||||
644E729029AFA1FA003534BE /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
644E729129AFA1FA003534BE /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = "<group>"; };
|
||||
644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = "<group>"; };
|
||||
644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = "<group>"; };
|
||||
@@ -440,13 +440,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
644E729229AFA1FA003534BE /* libgmpxx.a in Frameworks */,
|
||||
5CC036F129C5B08600C0EF20 /* libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS-ghc8.10.7.a in Frameworks */,
|
||||
5CC036F329C5B08600C0EF20 /* libffi.a in Frameworks */,
|
||||
5CC036F429C5B08600C0EF20 /* libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS.a in Frameworks */,
|
||||
5CC036F229C5B08600C0EF20 /* libgmpxx.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5CC036F029C5B08600C0EF20 /* libgmp.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
644E729429AFA1FA003534BE /* libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP.a in Frameworks */,
|
||||
644E729629AFA1FA003534BE /* libffi.a in Frameworks */,
|
||||
644E729529AFA1FA003534BE /* libgmp.a in Frameworks */,
|
||||
644E729329AFA1FA003534BE /* libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP-ghc8.10.7.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -504,11 +504,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
644E729129AFA1FA003534BE /* libffi.a */,
|
||||
644E729029AFA1FA003534BE /* libgmp.a */,
|
||||
644E728D29AFA1FA003534BE /* libgmpxx.a */,
|
||||
644E728E29AFA1FA003534BE /* libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP-ghc8.10.7.a */,
|
||||
644E728F29AFA1FA003534BE /* libHSsimplex-chat-4.5.3.1-GB6S5O5gUsm71G47YsC7xP.a */,
|
||||
5CC036EE29C5B08600C0EF20 /* libffi.a */,
|
||||
5CC036EB29C5B08600C0EF20 /* libgmp.a */,
|
||||
5CC036ED29C5B08600C0EF20 /* libgmpxx.a */,
|
||||
5CC036EC29C5B08600C0EF20 /* libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS-ghc8.10.7.a */,
|
||||
5CC036EF29C5B08600C0EF20 /* libHSsimplex-chat-4.5.4.2-HF2RWOiM0P81BlqfANMWLS.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -1348,7 +1348,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 127;
|
||||
CURRENT_PROJECT_VERSION = 128;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1369,7 +1369,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 4.5.3;
|
||||
MARKETING_VERSION = 4.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1390,7 +1390,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 127;
|
||||
CURRENT_PROJECT_VERSION = 128;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1411,7 +1411,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 4.5.3;
|
||||
MARKETING_VERSION = 4.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1469,7 +1469,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 127;
|
||||
CURRENT_PROJECT_VERSION = 128;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1482,7 +1482,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 4.5.3;
|
||||
MARKETING_VERSION = 4.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1499,7 +1499,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 127;
|
||||
CURRENT_PROJECT_VERSION = 128;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1512,7 +1512,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 4.5.3;
|
||||
MARKETING_VERSION = 4.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -46,12 +46,13 @@ public enum ChatCommand {
|
||||
case apiLeaveGroup(groupId: Int64)
|
||||
case apiListMembers(groupId: Int64)
|
||||
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 apiGetGroupLink(groupId: Int64)
|
||||
case apiGetUserSMPServers(userId: Int64)
|
||||
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 apiGetChatItemTTL(userId: Int64)
|
||||
case apiSetNetworkConfig(networkConfig: NetCfg)
|
||||
@@ -134,12 +135,13 @@ public enum ChatCommand {
|
||||
case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)"
|
||||
case let .apiListMembers(groupId): return "/_members #\(groupId)"
|
||||
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 .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
|
||||
case let .apiGetUserSMPServers(userId): return "/_smp \(userId)"
|
||||
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 .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
|
||||
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
|
||||
@@ -228,11 +230,12 @@ public enum ChatCommand {
|
||||
case .apiListMembers: return "apiListMembers"
|
||||
case .apiUpdateGroupProfile: return "apiUpdateGroupProfile"
|
||||
case .apiCreateGroupLink: return "apiCreateGroupLink"
|
||||
case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole"
|
||||
case .apiDeleteGroupLink: return "apiDeleteGroupLink"
|
||||
case .apiGetGroupLink: return "apiGetGroupLink"
|
||||
case .apiGetUserSMPServers: return "apiGetUserSMPServers"
|
||||
case .apiSetUserSMPServers: return "apiSetUserSMPServers"
|
||||
case .testSMPServer: return "testSMPServer"
|
||||
case .apiTestSMPServer: return "testSMPServer"
|
||||
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
|
||||
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
|
||||
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
|
||||
@@ -391,8 +394,8 @@ public enum ChatResponse: Decodable, Error {
|
||||
case connectedToGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember)
|
||||
case groupRemoved(user: User, groupInfo: GroupInfo) // unused
|
||||
case groupUpdated(user: User, toGroup: GroupInfo)
|
||||
case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String)
|
||||
case groupLink(user: User, groupInfo: GroupInfo, connReqContact: String)
|
||||
case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole)
|
||||
case groupLink(user: User, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole)
|
||||
case groupLinkDeleted(user: User, groupInfo: GroupInfo)
|
||||
// receiving file events
|
||||
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 .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo))
|
||||
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 .groupLink(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, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)")
|
||||
case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo))
|
||||
case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem))
|
||||
case .rcvFileAcceptedSndCancelled: return noDetails
|
||||
|
||||
@@ -1517,7 +1517,7 @@ public struct GroupMember: Identifiable, Decodable {
|
||||
public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? {
|
||||
if !canBeRemoved(groupInfo: groupInfo) { return nil }
|
||||
let userRole = groupInfo.membership.memberRole
|
||||
return GroupMemberRole.allCases.filter { $0 <= userRole }
|
||||
return GroupMemberRole.allCases.filter { $0 <= userRole && $0 != .observer }
|
||||
}
|
||||
|
||||
public var memberIncognito: Bool {
|
||||
@@ -1546,6 +1546,7 @@ public struct GroupMemberRef: Decodable {
|
||||
}
|
||||
|
||||
public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable {
|
||||
case observer = "observer"
|
||||
case member = "member"
|
||||
case admin = "admin"
|
||||
case owner = "owner"
|
||||
@@ -1554,6 +1555,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Dec
|
||||
|
||||
public var text: String {
|
||||
switch self {
|
||||
case .observer: return NSLocalizedString("observer", comment: "member role")
|
||||
case .member: return NSLocalizedString("member", comment: "member role")
|
||||
case .admin: return NSLocalizedString("admin", 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 {
|
||||
switch self {
|
||||
case .member: return 0
|
||||
case .admin: return 1
|
||||
case .owner: return 2
|
||||
case .observer: return 0
|
||||
case .member: return 1
|
||||
case .admin: return 2
|
||||
case .owner: return 3
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
layout: layouts/article.html
|
||||
title: "SimpleX File Transfer Protocol - a new protocol for sending large files efficiently, privately and securely."
|
||||
date: 2023-03-01
|
||||
preview: CLI and relays implementing the new XFTP protocol are released - you can use them now!
|
||||
image: images/20230301-xftp.jpg
|
||||
permalink: "/blog/20230301-simplex-file-transfer-protocol.html"
|
||||
---
|
||||
|
||||
# SimpleX File Transfer Protocol – a new protocol for sending large files efficiently, privately and securely.
|
||||
|
||||
**Published:** Mar 1, 2023
|
||||
|
||||
- [Quick start: how to send a file using XFTP CLI](#⚡️-quick-start-send-a-file-with-xftp-cli-in-3-simple-steps)
|
||||
- [What's the problem](#whats-the-problem)
|
||||
- [Why didn't we just use some existing solution?](#why-didnt-we-just-use-some-existing-solution)
|
||||
- [What is XFTP and how does it work?](#what-is-xftp-and-how-does-it-work)
|
||||
- [What is next?](#what-is-next)
|
||||
|
||||
## ⚡️ Quick start: send a file with XFTP CLI in 3 simple steps
|
||||
|
||||
Download XFTP binary for Linux from [the release](https://github.com/simplex-chat/simplexmq/releases/tag/v5.0.0-beta.3) – you need the file `xftp-ubuntu-20_04-x86-64` - rename it as `xftp`.
|
||||
|
||||
**Step 1**: To send the file:
|
||||
|
||||
```bash
|
||||
xftp send filename.ext
|
||||
```
|
||||
|
||||
You can also send the file that can be received by multiple recipients using `-n` option:
|
||||
|
||||
```bash
|
||||
xftp send filename.ext -n 10
|
||||
```
|
||||
|
||||
**Step 2**: Pass file description(s) (files `rcvN.xftp`) to the recipient(s) securely, e.g. send it as a file via SimpleX Chat.
|
||||
|
||||
**Step 3**: To receive the file:
|
||||
|
||||
```bash
|
||||
xftp recv rcvN.xftp
|
||||
```
|
||||
|
||||
The sender also delete all file chunks from the relays before they expire in 48 hours with this command:
|
||||
|
||||
```bash
|
||||
xftp del ./filename.ext/snd.xftp.private
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## What's the problem?
|
||||
|
||||
If you are using SimpleX Chat apps you know that support of sending files and images is not very good, and sending videos and large files is simply impossible. There are currently these problems:
|
||||
|
||||
- the sender has to be online for file transfer to complete, once it was confirmed by the recipient.
|
||||
- when the file is sent to the group, the sender will have to transfer it separately to each member, creating a lot of traffic.
|
||||
- the file transfer is slow, as it is sent in small chunks - approximately 16kb per message.
|
||||
|
||||
As a result, we limited the supported size of files in the app to 8mb. Even for supported files, it is quite inefficient for sending any files to large groups.
|
||||
|
||||
## Why didn't we just use some existing solution?
|
||||
|
||||
We really hoped to find some existing open-source solution that we could integrate with SimpleX Chat.
|
||||
|
||||
We decided not to use torrent-like or any other P2P solutions because of their lack of privacy, challenging legality in some jurisdictions and, in many cases, because they are inefficient in groups.
|
||||
|
||||
We reviewed several S3-compatible solutions (e.g., [minio](https://github.com/minio/minio), [garage](https://git.deuxfleurs.fr/Deuxfleurs/garage), [SeaweedFS](https://github.com/seaweedfs/seaweedfs)), but they all require the development of a separate service layer, making them unusable as standalone services and harder to deploy for the users who want to self-host the file transfer service. As a side note, the solution that we developed can still be composed with S3-compatible storage for higher capacity servers with some privacy/efficiency trade-offs.
|
||||
|
||||
We also looked at a few independent implementations of file sharing, with some ad-hoc protocols (e.g., [ceph](https://github.com/ceph/ceph) and [lufi](https://framagit.org/fiat-tux/hat-softwares/lufi)), but neither seemed sufficiently mature, and also not as private as we would like.
|
||||
|
||||
So after a lot of searching we decided to design and implement a new protocol for file transfers, that both solved the problems above, and provided a higher level of metadata privacy than any other file transfer solution has.
|
||||
|
||||
## What is XFTP and how does it work?
|
||||
|
||||
```
|
||||
Sender Internet XFTP relays Internet Recipient
|
||||
---------------------------- | ----------------- | ------------------- | ------------ | ----------
|
||||
| | | |
|
||||
| | (can be self-hosted) | |
|
||||
| | +---------+ | |
|
||||
chunk 1 ----- HTTP2 over TLS ------ | XFTP | ---- HTTP2 / TLS ----- chunk 1
|
||||
|---> SimpleX File Transfer Protocol (XFTP) --> | Relay | ---> XFTP ------------->|
|
||||
| --------------------------- +---------+ ---------------------- |
|
||||
| | | | | |
|
||||
| | | | | v
|
||||
+----------+ | | +---------+ | | +-------------+
|
||||
| Sending | ch. 2 ------- HTTP2 / TLS ------- | XFTP | ---- HTTP2 / TLS ---- ch. 2 | Receiving |
|
||||
file ---> | XFTP | ------> XFTP ----> | Relay | ---> XFTP ------> | XFTP | ---> file
|
||||
| Client | --------------------------- +---------+ ---------------------- | Client |
|
||||
+----------+ | | | | +-------------+
|
||||
| | | | | ^
|
||||
| | | +---------+ | | |
|
||||
| ------- HTTP2 / TLS ------- | XFTP | ---- HTTP2 / TLS ---- |
|
||||
|-------------> XFTP ----> | Relay | ---> XFTP ------------->|
|
||||
chunk N --------------------------- +---------+ --------------------- chunk N
|
||||
| | (store file chunks) | |
|
||||
| | | |
|
||||
| | | |
|
||||
```
|
||||
|
||||
XFTP stands for SimpleX File Transfer Protocol. Its design is based on the same ideas and has some of the qualities of SimpleX Messaging Protocol, that is used in SimpleX Chat:
|
||||
|
||||
- recipient cannot see sender's IP address, as the file fragments (chunks) are temporarily stored on multiple XFTP relays.
|
||||
- file can be sent asynchronously, without requiring the sender to be online for file to be received.
|
||||
- there is no network of peers that can observe this transfer - sender chooses which XFTP relays to use, and can self-host their own.
|
||||
- XFTP relays do not have any file metadata - they only see individual chunks, with access to each chunk authorized with anonymous credentials (using Edwards curve cryptographic signature) that are random per chunk.
|
||||
- chunks have one of the sizes allowed by the servers - currently we allow 256kb, 1mb and 4mb chunks, so if you send, say 1gb file, to XFTP relays it will look indistinguishable from sending many small files, and they would only know that chunks are sent by the same user only via the transport information, but none of the relays will see all chunks. Also, once this feature is available in mobile apps you can use transport isolation per chunk, when each file fragment will be uploaded via a separate TCP connection (and Tor circuit, if you use Tor) – the CLI we released does not yet support per-chunk transport isolation.
|
||||
- each chunk can be downloaded by multiple recipients, but each recipient uses their own key and chunk ID to authorize access, and the chunk is encrypted by a different key agreed via ephemeral DH keys (NaCl crypto_box (SalsaX20Poly1305 authenticated encryption scheme ) with shared secret derived from Curve25519 key exchange) on the way from the server to each recipient. XFTP protocol as a result has the same quality as SMP protocol - there are no identifiers and ciphertext in common between sent and received traffic inside TLS connection, so even if TLS is compromised, it complicates traffic correlation attacks.
|
||||
- XFTP protocol also supports redundancy - each file chunk can be sent via multiple relays, and the recipient can choose the one that is available. The released CLI does not support redundancy though.
|
||||
- the file as a whole is encrypted with a random symmetric key using NaCl secret_box.
|
||||
|
||||
So, how would any recipient know where to get all these file fragments from and how to put them back together into the original file? Normally, when you send a file via any file-sharing service it provides you a link that you can pass to the recipient. The link allows to download the original file, but it also provides the server a lot of file meta-data, that often includes file name and exact size, and in many cases the server also has access to a file content.
|
||||
|
||||
Instead of using a link, XFTP protocol includes a special format for a "file description" - it is a small text file containing the locations, access keys and digests for all file chunks, and also the encryption key and digest (SHA512) for the whole file. This file description does not contain the original file name or exact file size, so if it is used after the file fragments are expired or removed from XFTP relays, this information is not accessible.
|
||||
|
||||
CLI generates a separate file description for each intended recipient - you need to specify how many people you want to be able to receive this file. You can specify a larger number of recipients to avoid revealing the real number of recipients from XFTP relays. Mobile apps, when this protocol is integrated, will do it automatically, choosing some large random number of possible recipients, so while the relays will be able to observe how many people time the file was downloaded, they won't know how many intended recipients you had - sending to a group of 10 people and to 1 recipient can look the same to the relays.
|
||||
|
||||
File description is a security-sensitive file that contains private keys and chunk addresses necessary to receive the whole file, and also a symmetric key to decrypt the file. Therefore you must use a secure channel to send file description - e.g., it can be sent via SimpleX Chat. But once the recipient downloaded the file, CLI invalidates the file fragment addresses on relays and the same file description cannot be used again to download the file.
|
||||
|
||||
## What is next?
|
||||
|
||||
We released and deployed several XFTP relays for you to experiment with (they are hardcoded in the XFTP CLI), and you can deploy your own relays either from [downloadable binary](https://github.com/simplex-chat/simplexmq/releases/tag/v5.0.0-beta.3) or by compiling [the source code](https://github.com/simplex-chat/simplexmq). We also released XFTP CLI - it is available in the same release.
|
||||
|
||||
We are currently integrating support for sending large files using XFTP protocol into SimpleX Chat clients. SimpleX Chat v5.0 will have support for receiving files sent via XFTP protocol (you will be able to send a file description via a SimpleX Chat CLI app, so that mobile apps will be able to receive them as normal files, only much faster), and v5.1 will fully support for sending large files (up to 1gb) in the mobile apps.
|
||||
|
||||
We will also publish a formal specification for XFTP protocol and overview of its security qualities and threat model. For now you can learn more about the protocol design and motivations from this internal [XFTP protocol RFC](https://github.com/simplex-chat/simplexmq/blob/stable/rfcs/2022-12-26-simplex-file-transfer.md).
|
||||
|
||||
Using and sending files with the available XFTP CLI will hugely help us stabilizing both the protocol and implementations. What we really like about this design is that it is completely independent from SimpleX Chat - you can use it on its own, sending files and passing file descriptions to your contacts via any other messenger - e.g. via Signal, – without this messenger being able to observe that you are in fact sending a large file.
|
||||
|
||||
We did not yet decide whether we will be making a separate security audit of XFTP implementation, or if we combine it with the next security audit of SimpleX Chat. The latter seems more likely, as XFTP uses the same cryptographic primitives that were reviewed during [SimpleX Chat security assessment by Trail of Bits](./20221108-simplex-chat-v4.2-security-audit-new-website.md) in November 2022.
|
||||
|
||||
## SimpleX platform
|
||||
|
||||
Some links to answer the most common questions:
|
||||
|
||||
[How can SimpleX deliver messages without user identifiers](./20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers).
|
||||
|
||||
[What are the risks to have identifiers assigned to the users](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md#why-having-users-identifiers-is-bad-for-the-users).
|
||||
|
||||
[Technical details and limitations](https://github.com/simplex-chat/simplex-chat#privacy-technical-details-and-limitations).
|
||||
|
||||
[How SimpleX is different from Session, Matrix, Signal, etc.](https://github.com/simplex-chat/simplex-chat/blob/stable/README.md#frequently-asked-questions).
|
||||
|
||||
Please also see our [website](https://simplex.chat).
|
||||
|
||||
## Help us with donations
|
||||
|
||||
Huge thank you to everybody who donated to SimpleX Chat!
|
||||
|
||||
We are prioritizing users privacy and security - it would be impossible without your support.
|
||||
|
||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||
|
||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, makes a big difference for us.
|
||||
|
||||
See [this section](https://github.com/simplex-chat/simplex-chat/tree/master#help-us-with-donations) for the ways to donate.
|
||||
|
||||
Thank you,
|
||||
|
||||
Evgeny
|
||||
|
||||
SimpleX Chat founder
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: simplex-chat
|
||||
version: 4.5.3.1
|
||||
version: 4.5.4.2
|
||||
#synopsis:
|
||||
#description:
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 4.5.3.1
|
||||
version: 4.5.4.2
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
@@ -84,6 +84,7 @@ library
|
||||
Simplex.Chat.Migrations.M20230118_recreate_smp_servers
|
||||
Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx
|
||||
Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id
|
||||
Simplex.Chat.Migrations.M20230303_group_link_role
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Mobile.WebRTC
|
||||
Simplex.Chat.Options
|
||||
|
||||
+36
-20
@@ -824,8 +824,10 @@ processChatCommand = \case
|
||||
ok user
|
||||
SetUserSMPServers smpServersConfig -> withUser $ \User {userId} ->
|
||||
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)
|
||||
TestSMPServer smpServer -> withUser $ \User {userId} ->
|
||||
processChatCommand $ APITestSMPServer userId smpServer
|
||||
APISetChatItemTTL userId newTTL_ -> withUser' $ \user -> do
|
||||
checkSameUser userId user
|
||||
checkStoreNotChanged $
|
||||
@@ -1189,25 +1191,36 @@ processChatCommand = \case
|
||||
CRGroupProfile user <$> withStore (\db -> getGroupInfoByName db user gName)
|
||||
UpdateGroupDescription gName 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
|
||||
assertUserGroupRole gInfo GRAdmin
|
||||
when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole
|
||||
groupLinkId <- GroupLinkId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
||||
let crClientData = encodeJSON $ CRDataGroup groupLinkId
|
||||
(connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData
|
||||
withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId
|
||||
pure $ CRGroupLinkCreated user gInfo cReq
|
||||
withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole
|
||||
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
|
||||
gInfo <- withStore $ \db -> getGroupInfo db user groupId
|
||||
deleteGroupLink' user gInfo
|
||||
pure $ CRGroupLinkDeleted user gInfo
|
||||
APIGetGroupLink groupId -> withUser $ \user -> do
|
||||
gInfo <- withStore $ \db -> getGroupInfo db user groupId
|
||||
groupLink <- withStore $ \db -> getGroupLink db user gInfo
|
||||
pure $ CRGroupLink user gInfo groupLink
|
||||
CreateGroupLink gName -> withUser $ \user -> do
|
||||
(_, groupLink, mRole) <- withStore $ \db -> getGroupLink db user gInfo
|
||||
pure $ CRGroupLink user gInfo groupLink mRole
|
||||
CreateGroupLink gName mRole -> withUser $ \user -> do
|
||||
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
|
||||
groupId <- withStore $ \db -> getGroupIdByName db user gName
|
||||
processChatCommand $ APIDeleteGroupLink groupId
|
||||
@@ -2211,7 +2224,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct
|
||||
forM_ viaUserContactLink $ \userContactLinkId ->
|
||||
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
|
||||
(msg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing))
|
||||
ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc)
|
||||
@@ -2219,7 +2232,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
forM_ groupId_ $ \groupId -> do
|
||||
gVar <- asks idsDrg
|
||||
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 ()
|
||||
Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) ->
|
||||
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
|
||||
CORRequest cReq@UserContactRequest {localDisplayName} -> do
|
||||
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
|
||||
Just (UserContactLink {autoAccept}, groupId_) ->
|
||||
Just (UserContactLink {autoAccept}, groupId_, _) ->
|
||||
case autoAccept of
|
||||
Just AutoAccept {acceptIncognito} -> case groupId_ of
|
||||
Nothing -> do
|
||||
@@ -3211,9 +3224,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState)
|
||||
|
||||
mergeContacts :: Contact -> Contact -> m ()
|
||||
mergeContacts to from = do
|
||||
withStore' $ \db -> mergeContactRecords db userId to from
|
||||
toView $ CRContactsMerged user to from
|
||||
mergeContacts c1 c2 = do
|
||||
withStore' $ \db -> mergeContactRecords db userId c1 c2
|
||||
toView $ CRContactsMerged user c1 c2
|
||||
|
||||
saveConnInfo :: Connection -> ConnInfo -> m ()
|
||||
saveConnInfo activeConn connInfo = do
|
||||
@@ -4000,7 +4013,8 @@ chatCommandP =
|
||||
"/smp_servers " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP),
|
||||
"/smp_servers" $> GetUserSMPServers,
|
||||
"/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 " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP),
|
||||
"/_smp " *> (APIGetUserSMPServers <$> A.decimal),
|
||||
@@ -4035,13 +4049,14 @@ chatCommandP =
|
||||
"/enable #" *> (EnableGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName),
|
||||
("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles,
|
||||
("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups,
|
||||
("/help contacts" <|> "/help contact" <|> "/hc") $> ChatHelp HSContacts,
|
||||
("/help address" <|> "/ha") $> ChatHelp HSMyAddress,
|
||||
("/help messages" <|> "/hm") $> ChatHelp HSMessages,
|
||||
("/help settings" <|> "/hs") $> ChatHelp HSSettings,
|
||||
("/help" <|> "/h") $> ChatHelp HSMain,
|
||||
("/group " <|> "/g ") *> char_ '#' *> (NewGroup <$> groupProfile),
|
||||
"/_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),
|
||||
("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole),
|
||||
("/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_ '#' *> (ShowGroupProfile <$> displayName),
|
||||
"/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),
|
||||
"/_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),
|
||||
"/show link #" *> (ShowGroupLink <$> displayName),
|
||||
(">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP),
|
||||
@@ -4169,8 +4186,7 @@ chatCommandP =
|
||||
[ " owner" $> GROwner,
|
||||
" admin" $> GRAdmin,
|
||||
" member" $> GRMember,
|
||||
-- " observer" $> GRObserver,
|
||||
pure GRAdmin
|
||||
" observer" $> GRObserver
|
||||
]
|
||||
chatNameP = ChatName <$> chatTypeP <*> displayName
|
||||
chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName
|
||||
|
||||
@@ -170,7 +170,7 @@ data ChatController = ChatController
|
||||
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)
|
||||
|
||||
instance ToJSON HelpSection where
|
||||
@@ -237,14 +237,16 @@ data ChatCommand
|
||||
| APILeaveGroup GroupId
|
||||
| APIListMembers GroupId
|
||||
| APIUpdateGroupProfile GroupId GroupProfile
|
||||
| APICreateGroupLink GroupId
|
||||
| APICreateGroupLink GroupId GroupMemberRole
|
||||
| APIGroupLinkMemberRole GroupId GroupMemberRole
|
||||
| APIDeleteGroupLink GroupId
|
||||
| APIGetGroupLink GroupId
|
||||
| APIGetUserSMPServers UserId
|
||||
| GetUserSMPServers
|
||||
| APISetUserSMPServers UserId SMPServersConfig
|
||||
| SetUserSMPServers SMPServersConfig
|
||||
| TestSMPServer UserId SMPServerWithAuth
|
||||
| APITestSMPServer UserId SMPServerWithAuth
|
||||
| TestSMPServer SMPServerWithAuth
|
||||
| APISetChatItemTTL UserId (Maybe Int64)
|
||||
| SetChatItemTTL (Maybe Int64)
|
||||
| APIGetChatItemTTL UserId
|
||||
@@ -316,7 +318,8 @@ data ChatCommand
|
||||
| UpdateGroupNames GroupName GroupProfile
|
||||
| ShowGroupProfile GroupName
|
||||
| UpdateGroupDescription GroupName (Maybe Text)
|
||||
| CreateGroupLink GroupName
|
||||
| CreateGroupLink GroupName GroupMemberRole
|
||||
| GroupLinkMemberRole GroupName GroupMemberRole
|
||||
| DeleteGroupLink GroupName
|
||||
| ShowGroupLink GroupName
|
||||
| SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: Text, message :: Text}
|
||||
@@ -454,8 +457,8 @@ data ChatResponse
|
||||
| CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
|
||||
| CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember}
|
||||
| CRGroupProfile {user :: User, groupInfo :: GroupInfo}
|
||||
| CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact}
|
||||
| CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact}
|
||||
| CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole}
|
||||
| CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole}
|
||||
| CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo}
|
||||
| CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact}
|
||||
| CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError}
|
||||
@@ -684,6 +687,7 @@ data ChatErrorType
|
||||
| CEContactDisabled {contact :: Contact}
|
||||
| CEConnectionDisabled {connection :: Connection}
|
||||
| CEGroupUserRole {groupInfo :: GroupInfo, requiredRole :: GroupMemberRole}
|
||||
| CEGroupMemberInitialRole {groupInfo :: GroupInfo, initialRole :: GroupMemberRole}
|
||||
| CEContactIncognitoCantInvite
|
||||
| CEGroupIncognitoCantInvite
|
||||
| CEGroupContactRole {contactName :: ContactName}
|
||||
|
||||
+97
-33
@@ -6,6 +6,7 @@ module Simplex.Chat.Help
|
||||
chatHelpInfo,
|
||||
filesHelpInfo,
|
||||
groupsHelpInfo,
|
||||
contactsHelpInfo,
|
||||
myAddressHelpInfo,
|
||||
messagesHelpInfo,
|
||||
markdownInfo,
|
||||
@@ -84,7 +85,7 @@ chatHelpInfo =
|
||||
green "Create your address: " <> highlight "/address",
|
||||
"",
|
||||
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 "/delete <contact>" <> " - delete contact and all messages with them",
|
||||
indent <> highlight "/chats " <> " - most recent chats",
|
||||
@@ -100,13 +101,15 @@ filesHelpInfo =
|
||||
map
|
||||
styleMarkdown
|
||||
[ green "File transfer commands:",
|
||||
indent <> highlight "/file @<contact> <file_path> " <> " - send file to contact",
|
||||
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 "/freceive <file_id> [<file_path>]" <> " - accept to receive file",
|
||||
indent <> highlight "/fforward <name> [<file_id>] " <> " - forward received file to @contact or #group",
|
||||
indent <> highlight "/fcancel <file_id> " <> " - cancel sending / receiving file",
|
||||
indent <> highlight "/fstatus <file_id> " <> " - show file transfer status",
|
||||
indent <> highlight "/file @<contact> <file_path> " <> " - send file to contact",
|
||||
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 "/freceive <file_id> [<file_path>] " <> " - accept to receive file",
|
||||
indent <> highlight "/fforward <name> [<file_id>] " <> " - forward received file to @contact or #group",
|
||||
indent <> highlight "/fcancel <file_id> " <> " - cancel sending / receiving file",
|
||||
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"]
|
||||
]
|
||||
@@ -115,34 +118,92 @@ groupsHelpInfo :: [StyledString]
|
||||
groupsHelpInfo =
|
||||
map
|
||||
styleMarkdown
|
||||
[ green "Group management commands:",
|
||||
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 "/join <group> " <> " - accept group invitation",
|
||||
indent <> highlight "/remove <group> <member> " <> " - remove member from group",
|
||||
indent <> highlight "/leave <group> " <> " - leave group",
|
||||
indent <> highlight "/delete <group> " <> " - delete group",
|
||||
indent <> highlight "/members <group> " <> " - list group members",
|
||||
indent <> highlight "/gp <group> " <> " - view group profile",
|
||||
indent <> highlight "/gp <group> <new_name> [<full_name>] " <> " - update group profile",
|
||||
indent <> highlight "/group_descr <group> [<descr>] " <> " - update/remove group description",
|
||||
indent <> highlight "/groups " <> " - list groups",
|
||||
indent <> highlight "#<group> <message> " <> " - send message to group",
|
||||
indent <> highlight "/create link #<group> " <> " - create public group link",
|
||||
[ green "Group commands:",
|
||||
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 "/join <group> " <> " - accept group invitation",
|
||||
indent <> highlight "/members <group> " <> " - list group members",
|
||||
indent <> highlight "/remove <group> <member> " <> " - remove member from group",
|
||||
indent <> highlight "/leave <group> " <> " - leave group",
|
||||
indent <> highlight "/clear #<group> " <> " - clear all messages in the group locally",
|
||||
indent <> highlight "/delete #<group> " <> " - delete group and all messages",
|
||||
indent <> highlight "/gp <group> " <> " - view 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 "/groups " <> " - list groups",
|
||||
indent <> highlight "#<group> <message> " <> " - send message to group",
|
||||
"",
|
||||
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"]
|
||||
]
|
||||
|
||||
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 =
|
||||
map
|
||||
styleMarkdown
|
||||
[ green "Your contact address commands:",
|
||||
indent <> highlight "/address " <> " - create your address",
|
||||
indent <> highlight "/delete_address" <> " - delete your address (accepted contacts will remain connected)",
|
||||
indent <> highlight "/show_address " <> " - show your address",
|
||||
indent <> highlight "/accept <name> " <> " - accept contact request",
|
||||
indent <> highlight "/reject <name> " <> " - reject contact request",
|
||||
indent <> highlight "/address " <> " - create your address",
|
||||
indent <> highlight "/delete_address " <> " - delete your address (accepted contacts will remain connected)",
|
||||
indent <> highlight "/show_address " <> " - show your address",
|
||||
indent <> highlight "/accept <name> " <> " - accept contact request",
|
||||
indent <> highlight "/reject <name> " <> " - reject contact request",
|
||||
"",
|
||||
"Please note: you can receive spam contact requests, but it's safe to delete the address!",
|
||||
"",
|
||||
@@ -154,13 +215,13 @@ messagesHelpInfo =
|
||||
map
|
||||
styleMarkdown
|
||||
[ green "Show recent chats",
|
||||
indent <> highlight "/chats [N] " <> " - the most recent N conversations (20 by default)",
|
||||
indent <> highlight "/chats all " <> " - all conversations",
|
||||
indent <> highlight "/chats [N] " <> " - the most recent N conversations (20 by default)",
|
||||
indent <> highlight "/chats all " <> " - all conversations",
|
||||
"",
|
||||
green "Show recent messages",
|
||||
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 [N] " <> " - the last N messages in all chats",
|
||||
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 [N] " <> " - the last N messages in all chats",
|
||||
"",
|
||||
green "Search for messages",
|
||||
indent <> highlight "/search @alice [N] <text>" <> " - the last N messages with alice containing <text> (10 by default)",
|
||||
@@ -205,10 +266,13 @@ settingsInfo =
|
||||
map
|
||||
styleMarkdown
|
||||
[ green "Chat settings:",
|
||||
indent <> highlight "/incognito on/off " <> " - enable/disable incognito mode",
|
||||
indent <> highlight "/network " <> " - show / set network access options",
|
||||
indent <> highlight "/smp " <> " - show / set custom SMP servers",
|
||||
indent <> highlight "/info <contact> " <> " - information about contact 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 #<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"
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|]
|
||||
@@ -282,6 +282,7 @@ CREATE TABLE user_contact_links(
|
||||
group_id INTEGER REFERENCES groups ON DELETE CASCADE,
|
||||
auto_accept_incognito INTEGER DEFAULT 0 CHECK(auto_accept_incognito NOT NULL),
|
||||
group_link_id BLOB,
|
||||
group_link_member_role TEXT NULL,
|
||||
UNIQUE(user_id, local_display_name)
|
||||
);
|
||||
CREATE TABLE contact_requests(
|
||||
|
||||
+41
-12
@@ -75,6 +75,7 @@ module Simplex.Chat.Store
|
||||
deleteGroupLink,
|
||||
getGroupLink,
|
||||
getGroupLinkId,
|
||||
setGroupLinkMemberRole,
|
||||
createOrUpdateContactRequest,
|
||||
getContactRequest',
|
||||
getContactRequest,
|
||||
@@ -341,6 +342,7 @@ import Simplex.Chat.Migrations.M20230117_fkey_indexes
|
||||
import Simplex.Chat.Migrations.M20230118_recreate_smp_servers
|
||||
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.M20230303_group_link_role
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Util (week)
|
||||
@@ -406,7 +408,8 @@ schemaMigrations =
|
||||
("20230117_fkey_indexes", m20230117_fkey_indexes),
|
||||
("20230118_recreate_smp_servers", m20230118_recreate_smp_servers),
|
||||
("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
|
||||
@@ -1086,13 +1089,13 @@ getUserAddress db User {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 =
|
||||
maybeFirstRow (\(ucl :. Only groupId_) -> (toUserContactLink ucl, groupId_)) $
|
||||
maybeFirstRow (\(ucl :. (groupId_, mRole_)) -> (toUserContactLink ucl, groupId_, fromMaybe GRMember mRole_)) $
|
||||
DB.query
|
||||
db
|
||||
[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
|
||||
WHERE user_id = ?
|
||||
AND user_contact_link_id = ?
|
||||
@@ -1117,14 +1120,14 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do
|
||||
Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply)
|
||||
_ -> (False, False, Nothing)
|
||||
|
||||
createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> ExceptT StoreError IO ()
|
||||
createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId =
|
||||
createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> ExceptT StoreError IO ()
|
||||
createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId memberRole =
|
||||
checkConstraint (SEDuplicateGroupLink groupInfo) . liftIO $ do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
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 (?,?,?,?,?,?,?,?)"
|
||||
(userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, True, currentTs, currentTs)
|
||||
"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, memberRole, True, currentTs, currentTs)
|
||||
userContactLinkId <- insertedRowId db
|
||||
void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing Nothing Nothing 0 currentTs
|
||||
|
||||
@@ -1182,16 +1185,22 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do
|
||||
(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} =
|
||||
ExceptT . firstRow fromOnly (SEGroupLinkNotFound gInfo) $
|
||||
DB.query db "SELECT conn_req_contact FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId)
|
||||
ExceptT . firstRow groupLink (SEGroupLinkNotFound gInfo) $
|
||||
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 User {userId} GroupInfo {groupId} =
|
||||
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)
|
||||
|
||||
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 user@User {userId} userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ =
|
||||
liftIO (maybeM getContact' xContactId_) >>= \case
|
||||
@@ -1583,8 +1592,17 @@ matchSentProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = d
|
||||
cId : _ -> eitherToMaybe <$> runExceptT (getContact db user cId)
|
||||
|
||||
mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO ()
|
||||
mergeContactRecords db userId Contact {contactId = toContactId} Contact {contactId = fromContactId, localDisplayName} = do
|
||||
mergeContactRecords db userId ct1 ct2 = do
|
||||
let (toCt, fromCt) = toFromContacts ct1 ct2
|
||||
Contact {contactId = toContactId} = toCt
|
||||
Contact {contactId = fromContactId, localDisplayName} = fromCt
|
||||
currentTs <- getCurrentTime
|
||||
-- TODO next query fixes incorrect unused contacts deletion; consider more thorough fix
|
||||
when (contactDirect toCt && not (contactUsed toCt)) $
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?"
|
||||
(currentTs, userId, toContactId)
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?"
|
||||
@@ -1620,6 +1638,17 @@ mergeContactRecords db userId Contact {contactId = toContactId} Contact {contact
|
||||
deleteContactProfile_ db userId fromContactId
|
||||
DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId)
|
||||
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId)
|
||||
where
|
||||
toFromContacts :: Contact -> Contact -> (Contact, Contact)
|
||||
toFromContacts c1 c2
|
||||
| d1 && not d2 = (c1, c2)
|
||||
| d2 && not d1 = (c2, c1)
|
||||
| ctCreatedAt c1 <= ctCreatedAt c2 = (c1, c2)
|
||||
| otherwise = (c2, c1)
|
||||
where
|
||||
d1 = directOrUsed c1
|
||||
d2 = directOrUsed c2
|
||||
ctCreatedAt Contact {createdAt} = createdAt
|
||||
|
||||
getConnectionEntity :: DB.Connection -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity
|
||||
getConnectionEntity db user@User {userId, userContactId} agentConnId = do
|
||||
|
||||
@@ -160,9 +160,12 @@ contactConnId = aConnId . contactConn
|
||||
contactConnIncognito :: Contact -> Bool
|
||||
contactConnIncognito = connIncognito . contactConn
|
||||
|
||||
contactDirect :: Contact -> Bool
|
||||
contactDirect Contact {activeConn = Connection {connLevel, viaGroupLink}} = connLevel == 0 && not viaGroupLink
|
||||
|
||||
directOrUsed :: Contact -> Bool
|
||||
directOrUsed Contact {contactUsed, activeConn = Connection {connLevel, viaGroupLink}} =
|
||||
(connLevel == 0 && not viaGroupLink) || contactUsed
|
||||
directOrUsed ct@Contact {contactUsed} =
|
||||
contactDirect ct || contactUsed
|
||||
|
||||
anyDirectOrUsed :: Contact -> Bool
|
||||
anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed
|
||||
|
||||
@@ -94,6 +94,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case
|
||||
HSMain -> chatHelpInfo
|
||||
HSFiles -> filesHelpInfo
|
||||
HSGroups -> groupsHelpInfo
|
||||
HSContacts -> contactsHelpInfo
|
||||
HSMyAddress -> myAddressHelpInfo
|
||||
HSMessages -> messagesHelpInfo
|
||||
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"]
|
||||
CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m
|
||||
CRGroupProfile u g -> ttyUser u $ viewGroupProfile g
|
||||
CRGroupLinkCreated u g cReq -> ttyUser u $ groupLink_ "Group link is created!" g cReq
|
||||
CRGroupLink u g cReq -> ttyUser u $ groupLink_ "Group link:" g cReq
|
||||
CRGroupLinkCreated u g cReq mRole -> ttyUser u $ groupLink_ "Group link is created!" g cReq mRole
|
||||
CRGroupLink u g cReq mRole -> ttyUser u $ groupLink_ "Group link:" g cReq mRole
|
||||
CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted 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]
|
||||
@@ -540,13 +541,13 @@ autoAcceptStatus_ = \case
|
||||
maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply
|
||||
_ -> ["auto_accept off"]
|
||||
|
||||
groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> [StyledString]
|
||||
groupLink_ intro g cReq =
|
||||
groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> GroupMemberRole -> [StyledString]
|
||||
groupLink_ intro g cReq mRole =
|
||||
[ intro,
|
||||
"",
|
||||
(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 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
|
||||
GRAuthor -> ": you don't have permission to send messages"
|
||||
_ -> ": 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"]
|
||||
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"]
|
||||
|
||||
@@ -405,14 +405,14 @@ testTestSMPServerConnection :: HasCallStack => FilePath -> IO ()
|
||||
testTestSMPServerConnection =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\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"
|
||||
-- to test with password:
|
||||
-- alice <## "SMP server test failed at CreateQueue, error: SMP AUTH"
|
||||
-- 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 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 <## "Possibly, certificate fingerprint in server address is incorrect"
|
||||
|
||||
|
||||
+167
-31
@@ -46,6 +46,8 @@ chatGroupTests = do
|
||||
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 "leaving groups with unused host contacts deletes incognito profiles" testGroupLinkIncognitoUnusedHostContactsDeleted
|
||||
it "group link member role" testGroupLinkMemberRole
|
||||
it "leaving and deleting the group joined via link should NOT delete previously existing direct contacts" testGroupLinkLeaveDelete
|
||||
|
||||
testGroup :: HasCallStack => SpecWith FilePath
|
||||
testGroup = versionTestMatrix3 runTestGroup
|
||||
@@ -127,28 +129,27 @@ testGroupShared alice bob cath checkMessages = do
|
||||
alice <## "bob (Bob)"
|
||||
alice <## "cath (Catherine)"
|
||||
-- test observer role
|
||||
-- to be enabled once the role is enabled in parser
|
||||
-- alice ##> "/mr team bob observer"
|
||||
-- concurrentlyN_
|
||||
-- [ alice <## "#team: you changed the role of bob 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"
|
||||
-- ]
|
||||
-- bob ##> "#team hello"
|
||||
-- bob <## "#team: you don't have permission to send messages to this group"
|
||||
-- bob ##> "/rm team cath"
|
||||
-- bob <## "#team: you have insufficient permissions for this action, the required role is admin"
|
||||
-- cath #> "#team hello"
|
||||
-- concurrentlyN_
|
||||
-- [ alice <# "#team cath> hello",
|
||||
-- bob <# "#team cath> hello"
|
||||
-- ]
|
||||
-- alice ##> "/mr team bob admin"
|
||||
-- concurrentlyN_
|
||||
-- [ alice <## "#team: you changed the role of bob 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"
|
||||
-- ]
|
||||
alice ##> "/mr team bob observer"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of bob 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"
|
||||
]
|
||||
bob ##> "#team hello"
|
||||
bob <## "#team: you don't have permission to send messages"
|
||||
bob ##> "/rm team cath"
|
||||
bob <## "#team: you have insufficient permissions for this action, the required role is admin"
|
||||
cath #> "#team hello"
|
||||
concurrentlyN_
|
||||
[ alice <# "#team cath> hello",
|
||||
bob <# "#team cath> hello"
|
||||
]
|
||||
alice ##> "/mr team bob admin"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of bob 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"
|
||||
]
|
||||
-- remove member
|
||||
bob ##> "/rm team cath"
|
||||
concurrentlyN_
|
||||
@@ -1423,14 +1424,14 @@ testGroupLink =
|
||||
alice ##> "/show link #team"
|
||||
alice <## "no group link, to create: /create link #team"
|
||||
alice ##> "/create link #team"
|
||||
_ <- getGroupLink alice "team" True
|
||||
_ <- getGroupLink alice "team" GRMember True
|
||||
alice ##> "/delete link #team"
|
||||
alice <## "Group link is deleted - joined members will remain connected."
|
||||
alice <## "To create a new group link use /create link #team"
|
||||
alice ##> "/create link #team"
|
||||
gLink <- getGroupLink alice "team" True
|
||||
gLink <- getGroupLink alice "team" GRMember True
|
||||
alice ##> "/show link #team"
|
||||
_ <- getGroupLink alice "team" False
|
||||
_ <- getGroupLink alice "team" GRMember False
|
||||
alice ##> "/create link #team"
|
||||
alice <## "you already have link for this group, to show: /show link #team"
|
||||
bob ##> ("/c " <> gLink)
|
||||
@@ -1522,7 +1523,7 @@ testGroupLinkDeleteGroupRejoin =
|
||||
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" True
|
||||
gLink <- getGroupLink alice "team" GRMember True
|
||||
bob ##> ("/c " <> gLink)
|
||||
bob <## "connection request sent!"
|
||||
alice <## "bob (Bob): accepting request to join group #team..."
|
||||
@@ -1578,7 +1579,7 @@ testGroupLinkContactUsed =
|
||||
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" True
|
||||
gLink <- getGroupLink alice "team" GRMember True
|
||||
bob ##> ("/c " <> gLink)
|
||||
bob <## "connection request sent!"
|
||||
alice <## "bob (Bob): accepting request to join group #team..."
|
||||
@@ -1638,7 +1639,7 @@ testGroupLinkIncognitoMembership =
|
||||
(bob <## ("#team: you joined the group incognito as " <> bobIncognito))
|
||||
-- bob creates group link, cath joins
|
||||
bob ##> "/create link #team"
|
||||
gLink <- getGroupLink bob "team" True
|
||||
gLink <- getGroupLink bob "team" GRMember True
|
||||
cath ##> ("/c " <> gLink)
|
||||
cath <## "connection request sent!"
|
||||
bob <## "cath (Catherine): accepting request to join group #team..."
|
||||
@@ -1729,7 +1730,7 @@ testGroupLinkUnusedHostContactDeleted =
|
||||
alice <## "group #team is created"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/create link #team"
|
||||
gLinkTeam <- getGroupLink alice "team" True
|
||||
gLinkTeam <- getGroupLink alice "team" GRMember True
|
||||
bob ##> ("/c " <> gLinkTeam)
|
||||
bob <## "connection request sent!"
|
||||
alice <## "bob (Bob): accepting request to join group #team..."
|
||||
@@ -1747,7 +1748,7 @@ testGroupLinkUnusedHostContactDeleted =
|
||||
alice <## "group #club is created"
|
||||
alice <## "to add members use /a club <name> or /create link #club"
|
||||
alice ##> "/create link #club"
|
||||
gLinkClub <- getGroupLink alice "club" True
|
||||
gLinkClub <- getGroupLink alice "club" GRMember True
|
||||
bob ##> ("/c " <> gLinkClub)
|
||||
bob <## "connection request sent!"
|
||||
alice <## "bob_1 (Bob): accepting request to join group #club..."
|
||||
@@ -1822,7 +1823,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted =
|
||||
alice <## ("group #" <> group <> " is created")
|
||||
alice <## ("to add members use /a " <> group <> " <name> or /create link #" <> group)
|
||||
alice ##> ("/create link #" <> group)
|
||||
gLinkTeam <- getGroupLink alice group True
|
||||
gLinkTeam <- getGroupLink alice group GRMember True
|
||||
bob ##> ("/c " <> gLinkTeam)
|
||||
bobIncognito <- getTermLine bob
|
||||
bob <## "connection request sent incognito!"
|
||||
@@ -1850,3 +1851,138 @@ testGroupLinkIncognitoUnusedHostContactsDeleted =
|
||||
]
|
||||
bob ##> ("/d #" <> 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)"
|
||||
|
||||
@@ -323,13 +323,13 @@ getContactLink cc created = do
|
||||
cc <## "to delete it: /da (accepted contacts will remain connected)"
|
||||
pure link
|
||||
|
||||
getGroupLink :: HasCallStack => TestCC -> String -> Bool -> IO String
|
||||
getGroupLink cc gName created = do
|
||||
getGroupLink :: HasCallStack => TestCC -> String -> GroupMemberRole -> Bool -> IO String
|
||||
getGroupLink cc gName mRole created = do
|
||||
cc <## if created then "Group link is created!" else "Group link:"
|
||||
cc <## ""
|
||||
link <- getTermLine 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 delete it: /delete link #" <> gName <> " (joined members will remain connected to you)")
|
||||
pure link
|
||||
|
||||
Reference in New Issue
Block a user