diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 8233a7cc5d..d0a02c332f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -805,6 +805,24 @@ data class Chat( val id: String get() = chatInfo.id + fun groupFeatureEnabled(feature: GroupFeature): Boolean = + if (chatInfo is ChatInfo.Group) { + val groupInfo = chatInfo.groupInfo + val p = groupInfo.fullGroupPreferences + when (feature) { + GroupFeature.TimedMessages -> p.timedMessages.on + GroupFeature.DirectMessages -> p.directMessages.on(groupInfo.membership) + GroupFeature.FullDelete -> p.fullDelete.on + GroupFeature.Reactions -> p.reactions.on + GroupFeature.Voice -> p.voice.on(groupInfo.membership) + GroupFeature.Files -> p.files.on(groupInfo.membership) + GroupFeature.SimplexLinks -> p.simplexLinks.on(groupInfo.membership) + GroupFeature.History -> p.history.on + } + } else { + true + } + @Serializable data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false) @@ -1240,7 +1258,7 @@ data class GroupInfo ( ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on ChatFeature.Reactions -> fullGroupPreferences.reactions.on - ChatFeature.Voice -> fullGroupPreferences.voice.on + ChatFeature.Voice -> fullGroupPreferences.voice.on(membership) ChatFeature.Calls -> false } override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null } @@ -2319,8 +2337,8 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvChatPreference") class RcvChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndChatPreference") class SndChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } - @Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } - @Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null, val memberRole_: GroupMemberRole?): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null, val memberRole_: GroupMemberRole?): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndModerated") object SndModerated: CIContent() { override val msgContent: MsgContent? get() = null } @@ -2352,8 +2370,8 @@ sealed class CIContent: ItemContent { is SndChatFeature -> featureText(feature, enabled.text, param) is RcvChatPreference -> preferenceText(feature, allowed, param) is SndChatPreference -> preferenceText(feature, allowed, param) - is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param) - is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param) + is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param, memberRole_) + is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param, memberRole_) is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(MR.strings.feature_received_prohibited)}" is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(MR.strings.feature_received_prohibited)}" is SndModerated -> generalGetString(MR.strings.moderated_description) @@ -2390,11 +2408,23 @@ sealed class CIContent: ItemContent { private val e2eeInfoNoPQStr: String = generalGetString(MR.strings.e2ee_info_no_pq_short) - fun featureText(feature: Feature, enabled: String, param: Int?): String = - if (feature.hasParam) { + fun featureText(feature: Feature, enabled: String, param: Int?, role: GroupMemberRole? = null): String = + (if (feature.hasParam) { "${feature.text}: ${timeText(param)}" } else { "${feature.text}: $enabled" + }) + ( + if (feature.hasRole && role != null) + " (${roleText(role)})" + else + "" + ) + + private fun roleText(role: GroupMemberRole?): String = + when (role) { + GroupMemberRole.Owner -> generalGetString(MR.strings.feature_roles_owners) + GroupMemberRole.Admin -> generalGetString(MR.strings.feature_roles_admins) + else -> generalGetString(MR.strings.feature_roles_all_members) } fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index b70c970623..4e239598fb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3430,6 +3430,7 @@ interface Feature { @Composable fun iconFilled(): Painter val hasParam: Boolean + val hasRole: Boolean } @Serializable @@ -3449,6 +3450,7 @@ enum class ChatFeature: Feature { TimedMessages -> true else -> false } + override val hasRole: Boolean = false override val text: String get() = when(this) { @@ -3549,6 +3551,7 @@ enum class GroupFeature: Feature { @SerialName("reactions") Reactions, @SerialName("voice") Voice, @SerialName("files") Files, + @SerialName("simplexLinks") SimplexLinks, @SerialName("history") History; override val hasParam: Boolean get() = when(this) { @@ -3556,6 +3559,18 @@ enum class GroupFeature: Feature { else -> false } + override val hasRole: Boolean + get() = when (this) { + TimedMessages -> false + DirectMessages -> true + FullDelete -> false + Reactions -> false + Voice -> true + Files -> true + SimplexLinks -> true + History -> false + } + override val text: String get() = when(this) { TimedMessages -> generalGetString(MR.strings.timed_messages) @@ -3564,6 +3579,7 @@ enum class GroupFeature: Feature { Reactions -> generalGetString(MR.strings.message_reactions) Voice -> generalGetString(MR.strings.voice_messages) Files -> generalGetString(MR.strings.files_and_media) + SimplexLinks -> generalGetString(MR.strings.simplex_links) History -> generalGetString(MR.strings.recent_history) } @@ -3575,6 +3591,7 @@ enum class GroupFeature: Feature { Reactions -> painterResource(MR.images.ic_add_reaction) Voice -> painterResource(MR.images.ic_keyboard_voice) Files -> painterResource(MR.images.ic_draft) + SimplexLinks -> painterResource(MR.images.ic_link) History -> painterResource(MR.images.ic_schedule) } @@ -3586,6 +3603,7 @@ enum class GroupFeature: Feature { Reactions -> painterResource(MR.images.ic_add_reaction_filled) Voice -> painterResource(MR.images.ic_keyboard_voice_filled) Files -> painterResource(MR.images.ic_draft_filled) + SimplexLinks -> painterResource(MR.images.ic_link) History -> painterResource(MR.images.ic_schedule_filled) } @@ -3616,6 +3634,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(MR.strings.allow_to_send_files) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_sending_files) } + SimplexLinks -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.allow_to_send_simplex_links) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_sending_simplex_links) + } History -> when(enabled) { GroupFeatureEnabled.ON -> generalGetString(MR.strings.enable_sending_recent_history) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.disable_sending_recent_history) @@ -3647,6 +3669,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_files) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.files_are_prohibited_in_group) } + SimplexLinks -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_simplex_links) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.simplex_links_are_prohibited_in_group) + } History -> when(enabled) { GroupFeatureEnabled.ON -> generalGetString(MR.strings.recent_history_is_sent_to_new_members) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.recent_history_is_not_sent_to_new_members) @@ -3760,11 +3786,12 @@ enum class FeatureAllowed { @Serializable data class FullGroupPreferences( val timedMessages: TimedMessagesGroupPreference, - val directMessages: GroupPreference, + val directMessages: RoleGroupPreference, val fullDelete: GroupPreference, val reactions: GroupPreference, - val voice: GroupPreference, - val files: GroupPreference, + val voice: RoleGroupPreference, + val files: RoleGroupPreference, + val simplexLinks: RoleGroupPreference, val history: GroupPreference, ) { fun toGroupPreferences(): GroupPreferences = @@ -3775,17 +3802,19 @@ data class FullGroupPreferences( reactions = reactions, voice = voice, files = files, + simplexLinks = simplexLinks, history = history ) companion object { val sampleData = FullGroupPreferences( timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF), - directMessages = GroupPreference(GroupFeatureEnabled.OFF), + directMessages = RoleGroupPreference(GroupFeatureEnabled.OFF, role = null), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), reactions = GroupPreference(GroupFeatureEnabled.ON), - voice = GroupPreference(GroupFeatureEnabled.ON), - files = GroupPreference(GroupFeatureEnabled.ON), + voice = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + files = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), history = GroupPreference(GroupFeatureEnabled.ON), ) } @@ -3794,21 +3823,23 @@ data class FullGroupPreferences( @Serializable data class GroupPreferences( val timedMessages: TimedMessagesGroupPreference? = null, - val directMessages: GroupPreference? = null, + val directMessages: RoleGroupPreference? = null, val fullDelete: GroupPreference? = null, val reactions: GroupPreference? = null, - val voice: GroupPreference? = null, - val files: GroupPreference? = null, + val voice: RoleGroupPreference? = null, + val files: RoleGroupPreference? = null, + val simplexLinks: RoleGroupPreference? = null, val history: GroupPreference? = null, ) { companion object { val sampleData = GroupPreferences( timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF), - directMessages = GroupPreference(GroupFeatureEnabled.OFF), + directMessages = RoleGroupPreference(GroupFeatureEnabled.OFF, role = null), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), reactions = GroupPreference(GroupFeatureEnabled.ON), - voice = GroupPreference(GroupFeatureEnabled.ON), - files = GroupPreference(GroupFeatureEnabled.ON), + voice = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + files = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), history = GroupPreference(GroupFeatureEnabled.ON), ) } @@ -3819,6 +3850,26 @@ data class GroupPreference( val enable: GroupFeatureEnabled ) { val on: Boolean get() = enable == GroupFeatureEnabled.ON + + fun enabled(role: GroupMemberRole?, m: GroupMember?): GroupFeatureEnabled = + when (enable) { + GroupFeatureEnabled.OFF -> GroupFeatureEnabled.OFF + GroupFeatureEnabled.ON -> + if (role != null && m != null) { + if (m.memberRole >= role) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF + } else { + GroupFeatureEnabled.ON + } + } +} + +@Serializable +data class RoleGroupPreference( + val enable: GroupFeatureEnabled, + val role: GroupMemberRole? = null, +) { + fun on(m: GroupMember): Boolean = + enable == GroupFeatureEnabled.ON && m.memberRole >= (role ?: GroupMemberRole.Observer) } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 1cd85ae7f9..1f49a98728 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -85,6 +85,7 @@ fun TerminalLayout( isDirectChat = false, liveMessageAlertShown = SharedPreference(get = { false }, set = {}), sendMsgEnabled = true, + sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index a438ea2bfc..1c8b32d467 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -1,6 +1,7 @@ @file:UseSerializers(UriSerializer::class) package chat.simplex.common.views.chat +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* @@ -11,6 +12,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.font.FontStyle import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp @@ -18,8 +21,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.Indigo -import chat.simplex.common.ui.theme.isSystemInDarkTheme +import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -115,6 +117,15 @@ data class ComposeState( } } + val attachmentPreview: Boolean + get() = when (preview) { + ComposePreview.NoPreview -> false + is ComposePreview.CLinkPreview -> false + is ComposePreview.MediaPreview -> preview.content.isNotEmpty() + is ComposePreview.VoicePreview -> false + is ComposePreview.FilePreview -> true + } + val empty: Boolean get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem @@ -243,10 +254,23 @@ fun ComposeView( attachmentOption: MutableState, showChooseAttachment: () -> Unit ) { + val cancelledLinks = rememberSaveable { mutableSetOf() } + fun isSimplexLink(link: String): Boolean = + link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) + + fun parseMessage(msg: String): Pair { + if (msg.isBlank()) return null to false + val parsedMsg = parseToMarkdown(msg) ?: return null to false + val link = parsedMsg.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } + val simplexLink = parsedMsg.any { ft -> ft.format is Format.SimplexLink } + return link?.text to simplexLink + } + val linkUrl = rememberSaveable { mutableStateOf(null) } + // default value parsed because of draft + val hasSimplexLink = rememberSaveable { mutableStateOf(parseMessage(composeState.value.message).second) } val prevLinkUrl = rememberSaveable { mutableStateOf(null) } val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } - val cancelledLinks = rememberSaveable { mutableSetOf() } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val saveLastDraft = chatModel.controller.appPrefs.privacySaveLastDraft.get() val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) @@ -255,15 +279,6 @@ fun ComposeView( AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } } - fun isSimplexLink(link: String): Boolean = - link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) - - fun parseMessage(msg: String): String? { - val parsedMsg = parseToMarkdown(msg) - val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } - return link?.text - } - fun loadLinkPreview(url: String, wait: Long? = null) { if (pendingLinkUrl.value == url) { composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null)) @@ -283,7 +298,9 @@ fun ComposeView( fun showLinkPreview(s: String) { prevLinkUrl.value = linkUrl.value - linkUrl.value = parseMessage(s) + val parsed = parseMessage(s) + linkUrl.value = parsed.first + hasSimplexLink.value = parsed.second val url = linkUrl.value if (url != null) { if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) { @@ -387,7 +404,7 @@ fun ComposeView( fun checkLinkPreview(): MsgContent { return when (val composePreview = cs.preview) { is ComposePreview.CLinkPreview -> { - val url = parseMessage(msgText) + val url = parseMessage(msgText).first val lp = composePreview.linkPreview if (lp != null && url == lp.uri) { MsgContent.MCLink(msgText, preview = lp) @@ -563,8 +580,16 @@ fun ComposeView( } else { textStyle.value = smallFont if (composeState.value.linkPreviewAllowed) { - if (s.isNotEmpty()) showLinkPreview(s) - else resetLinkPreview() + if (s.isNotEmpty()) { + showLinkPreview(s) + } else { + resetLinkPreview() + hasSimplexLink.value = false + } + } else if (s.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) { + hasSimplexLink.value = parseMessage(s).second + } else { + hasSimplexLink.value = false } } } @@ -700,6 +725,16 @@ fun ComposeView( } } + @Composable + fun MsgNotAllowedView(reason: String, icon: Painter) { + val color = CurrentColors.collectAsState().value.appColors.receivedMessage + Row(Modifier.padding(top = 5.dp).fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + Text(reason, fontStyle = FontStyle.Italic) + } + } + @Composable fun contextItemView() { when (val contextItem = composeState.value.contextItem) { @@ -743,7 +778,17 @@ fun ComposeView( if (nextSendGrpInv.value) { ComposeContextInvitingContactMemberView() } + val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks) + val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files) + val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice) if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) { + if (simplexLinkProhibited) { + MsgNotAllowedView(generalGetString(MR.strings.simplex_links_not_allowed), icon = painterResource(MR.images.ic_link)) + } else if (fileProhibited) { + MsgNotAllowedView(generalGetString(MR.strings.files_and_media_not_allowed), icon = painterResource(MR.images.ic_draft)) + } else if (voiceProhibited) { + MsgNotAllowedView(generalGetString(MR.strings.voice_messages_not_allowed), icon = painterResource(MR.images.ic_mic)) + } contextItemView() when { composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {} @@ -764,7 +809,7 @@ fun ComposeView( modifier = Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom, ) { - val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on + val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) val attachmentClicked = if (isGroupAndProhibitedFiles) { { AlertManager.shared.showAlertMsg( @@ -871,6 +916,7 @@ fun ComposeView( chat.chatInfo is ChatInfo.Direct, liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, sendMsgEnabled = sendMsgEnabled.value, + sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), nextSendGrpInv = nextSendGrpInv.value, needToAllowVoiceToContact, allowedVoiceByPrefs, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 456e2a538b..14bcf7dfb7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -39,6 +39,7 @@ fun SendMsgView( isDirectChat: Boolean, liveMessageAlertShown: SharedPreference, sendMsgEnabled: Boolean, + sendButtonEnabled: Boolean, nextSendGrpInv: Boolean, needToAllowVoiceToContact: Boolean, allowedVoiceByPrefs: Boolean, @@ -75,7 +76,8 @@ fun SendMsgView( val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || - cs.endLiveDisabled + cs.endLiveDisabled || + !sendButtonEnabled PlatformTextField(composeState, sendMsgEnabled, sendMsgButtonDisabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) { if (!cs.inProgress) { sendMessage(null) @@ -430,7 +432,7 @@ private fun SendMsgButton( .padding(4.dp) .alpha(alpha.value) .clip(CircleShape) - .background(if (enabled) sendButtonColor else MaterialTheme.colors.secondary) + .background(if (enabled) sendButtonColor else MaterialTheme.colors.secondary.copy(alpha = 0.75f)) .padding(3.dp) ) } @@ -552,6 +554,7 @@ fun PreviewSendMsgView() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, @@ -586,6 +589,7 @@ fun PreviewSendMsgViewEditing() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, @@ -620,6 +624,7 @@ fun PreviewSendMsgViewInProgress() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index ebb35a4468..e90efa7d1b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -309,7 +309,7 @@ fun GroupMemberInfoLayout( SectionView { if (contactId != null && knownDirectChat(contactId) != null) { OpenChatButton(onClick = { openDirectChat(contactId) }) - } else if (groupInfo.fullGroupPreferences.directMessages.on) { + } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { if (contactId != null) { OpenChatButton(onClick = { openDirectChat(contactId) }) } else if (member.activeConn?.peerChatVRange?.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) == true) { @@ -335,7 +335,7 @@ fun GroupMemberInfoLayout( val clipboard = LocalClipboardManager.current ShareAddressButton { clipboard.shareText(simplexChatLink(member.contactLink)) } if (contactId != null) { - if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on) { + if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) }) } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 607efb9a3b..265d0cdeae 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -6,7 +6,6 @@ import SectionDividerSpaced import SectionItemView import SectionTextFooter import SectionView -import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -20,13 +19,20 @@ import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +private val featureRoles: List> = listOf( + null to generalGetString(MR.strings.feature_roles_all_members), + GroupMemberRole.Admin to generalGetString(MR.strings.feature_roles_admins), + GroupMemberRole.Owner to generalGetString(MR.strings.feature_roles_owners) +) @Composable -fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit,) { +fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit) { val groupInfo = remember { derivedStateOf { val ch = m.getChat(chatId) val g = (ch?.chatInfo as? ChatInfo.Group)?.groupInfo - if (g == null || ch?.remoteHostId != rhId) null else g + if (g == null || ch.remoteHostId != rhId) null else g }} val gInfo = groupInfo.value ?: return var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) } @@ -81,7 +87,7 @@ private fun GroupPreferencesLayout( val onTTLUpdated = { ttl: Int? -> applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl))) } - FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable -> + FeatureSection(GroupFeature.TimedMessages, timedMessages, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> if (enable == GroupFeatureEnabled.ON) { applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400))) } else { @@ -90,33 +96,45 @@ private fun GroupPreferencesLayout( } SectionDividerSpaced(true, maxBottomPadding = false) val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) } - FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it))) + val directMessagesRole = remember(preferences) { mutableStateOf(preferences.directMessages.role) } + FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, directMessagesRole, groupInfo, preferences, onTTLUpdated) { enable, role -> + applyPrefs(preferences.copy(directMessages = RoleGroupPreference(enable = enable, role))) } SectionDividerSpaced(true, maxBottomPadding = false) val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) } - FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it))) + FeatureSection(GroupFeature.FullDelete, allowFullDeletion, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> + applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = enable))) } SectionDividerSpaced(true, maxBottomPadding = false) val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.enable) } - FeatureSection(GroupFeature.Reactions, allowReactions, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(reactions = GroupPreference(enable = it))) + FeatureSection(GroupFeature.Reactions, allowReactions, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> + applyPrefs(preferences.copy(reactions = GroupPreference(enable = enable))) } SectionDividerSpaced(true, maxBottomPadding = false) val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) } - FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(voice = GroupPreference(enable = it))) + val voiceRole = remember(preferences) { mutableStateOf(preferences.voice.role) } + FeatureSection(GroupFeature.Voice, allowVoice, voiceRole, groupInfo, preferences, onTTLUpdated) { enable, role -> + applyPrefs(preferences.copy(voice = RoleGroupPreference(enable = enable, role))) } SectionDividerSpaced(true, maxBottomPadding = false) val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) } - FeatureSection(GroupFeature.Files, allowFiles, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(files = GroupPreference(enable = it))) + val filesRole = remember(preferences) { mutableStateOf(preferences.files.role) } + FeatureSection(GroupFeature.Files, allowFiles, filesRole, groupInfo, preferences, onTTLUpdated) { enable, role -> + applyPrefs(preferences.copy(files = RoleGroupPreference(enable = enable, role))) } + + // TODO enable simplexLinks preference in 5.8 +// SectionDividerSpaced(true, maxBottomPadding = false) +// val allowSimplexLinks = remember(preferences) { mutableStateOf(preferences.simplexLinks.enable) } +// val simplexLinksRole = remember(preferences) { mutableStateOf(preferences.simplexLinks.role) } +// FeatureSection(GroupFeature.SimplexLinks, allowSimplexLinks, simplexLinksRole, groupInfo, preferences, onTTLUpdated) { enable, role -> +// applyPrefs(preferences.copy(simplexLinks = RoleGroupPreference(enable = enable, role))) +// } + SectionDividerSpaced(true, maxBottomPadding = false) val enableHistory = remember(preferences) { mutableStateOf(preferences.history.enable) } - FeatureSection(GroupFeature.History, enableHistory, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(history = GroupPreference(enable = it))) + FeatureSection(GroupFeature.History, enableHistory, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> + applyPrefs(preferences.copy(history = GroupPreference(enable = enable))) } if (groupInfo.canEdit) { SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) @@ -134,10 +152,11 @@ private fun GroupPreferencesLayout( private fun FeatureSection( feature: GroupFeature, enableFeature: State, + enableForRole: State? = null, groupInfo: GroupInfo, preferences: FullGroupPreferences, onTTLUpdated: (Int?) -> Unit, - onSelected: (GroupFeatureEnabled) -> Unit + onSelected: (GroupFeatureEnabled, GroupMemberRole?) -> Unit ) { SectionView { val on = enableFeature.value == GroupFeatureEnabled.ON @@ -151,7 +170,7 @@ private fun FeatureSection( iconTint, enableFeature.value == GroupFeatureEnabled.ON, ) { checked -> - onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF) + onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF, enableForRole?.value) } if (timedOn) { val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) } @@ -165,6 +184,18 @@ private fun FeatureSection( onSelected = onTTLUpdated ) } + if (enableFeature.value == GroupFeatureEnabled.ON && enableForRole != null) { + ExposedDropDownSettingRow( + generalGetString(MR.strings.feature_enabled_for), + featureRoles, + enableForRole, + // remove in v5.8 + enabled = remember { mutableStateOf(false) }, + onSelected = { value -> + onSelected(enableFeature.value, value) + } + ) + } } else { InfoRow( feature.text, @@ -175,6 +206,14 @@ private fun FeatureSection( if (timedOn) { InfoRow(generalGetString(MR.strings.delete_after), timeText(preferences.timedMessages.ttl)) } + if (enableFeature.value == GroupFeatureEnabled.ON && enableForRole != null) { + InfoRow(generalGetString(MR.strings.feature_enabled_for), featureRoles.firstOrNull { it.first == enableForRole.value }?.second ?: generalGetString(MR.strings.feature_roles_all_members), textColor = MaterialTheme.colors.secondary) + } + } + } + KeyChangeEffect(enableFeature.value) { + if (enableFeature.value == GroupFeatureEnabled.OFF) { + onSelected(enableFeature.value, null) } } SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index a9a4963c96..68a8eb44a4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -16,6 +16,7 @@ import chat.simplex.common.platform.onRightClick @Composable fun CIChatFeatureView( + chatInfo: ChatInfo, chatItem: ChatItem, feature: Feature, iconColor: Color, @@ -23,7 +24,7 @@ fun CIChatFeatureView( revealed: MutableState, showMenu: MutableState, ) { - val merged = if (!revealed.value) mergedFeatures(chatItem) else emptyList() + val merged = if (!revealed.value) mergedFeatures(chatItem, chatInfo) else emptyList() Box( Modifier .combinedClickable( @@ -70,7 +71,7 @@ private fun Feature.toFeatureInfo(color: Color, param: Int?, type: String): Feat ) @Composable -private fun mergedFeatures(chatItem: ChatItem): List? { +private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List? { val m = ChatModel val fs: ArrayList = arrayListOf() val icons: MutableSet = mutableSetOf() @@ -78,7 +79,7 @@ private fun mergedFeatures(chatItem: ChatItem): List? { if (i != null) { val reversedChatItems = m.chatItems.asReversed() while (i < reversedChatItems.size) { - val f = featureInfo(reversedChatItems[i]) ?: break + val f = featureInfo(reversedChatItems[i], chatInfo) ?: break if (!icons.contains(f.icon)) { fs.add(0, f) icons.add(f.icon) @@ -90,12 +91,12 @@ private fun mergedFeatures(chatItem: ChatItem): List? { } @Composable -private fun featureInfo(ci: ChatItem): FeatureInfo? = +private fun featureInfo(ci: ChatItem, chatInfo: ChatInfo): FeatureInfo? = when (ci.content) { is CIContent.RcvChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) is CIContent.SndChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) - is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name) - is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name) + is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enabled(ci.content.memberRole_, (chatInfo as ChatInfo.Group).groupInfo.membership).iconColor, ci.content.param, ci.content.groupFeature.name) + is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enabled(ci.content.memberRole_, (chatInfo as ChatInfo.Group).groupInfo.membership).iconColor, ci.content.param, ci.content.groupFeature.name) else -> null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 6f6deeb6a4..fe70139d91 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -442,11 +442,11 @@ fun ChatItemView( MsgContentItemDropdownMenu() } is CIContent.RcvChatFeature -> { - CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndChatFeature -> { - CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvChatPreference -> { @@ -454,23 +454,23 @@ fun ChatItemView( CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) } is CIContent.SndChatPreference -> { - CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvGroupFeature -> { - CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as ChatInfo.Group).groupInfo.membership).iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndGroupFeature -> { - CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as ChatInfo.Group).groupInfo.membership).iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvChatFeatureRejected -> { - CIChatFeatureView(cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvGroupFeatureRejected -> { - CIChatFeatureView(cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndModerated -> DeletedItem() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index d930a2841e..c96a277fb8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -254,12 +254,12 @@ fun TextIconSpaced(extraPadding: Boolean = false) { } @Composable -fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null) { +fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null, textColor: Color = MaterialTheme.colors.onBackground) { SectionItemViewSpaceBetween { Row { val iconSize = with(LocalDensity.current) { 21.sp.toDp() } if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: MaterialTheme.colors.secondary) - Text(title) + Text(title, color = textColor) } Text(value, color = MaterialTheme.colors.secondary) } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index c4d9e191c6..b0287ed9fc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -348,6 +348,9 @@ Files and media prohibited! Only group owners can enable files and media. Send direct message to connect + SimpleX links not allowed + Files and media not allowed + Voice messages not allowed Image @@ -1532,6 +1535,7 @@ Message reactions Voice messages Files and media + SimpleX links Visible history Audio/video calls \nAvailable in v5.1 @@ -1589,6 +1593,8 @@ Prohibit messages reactions. Allow to send files and media. Prohibit sending files and media. + Allow to send SimpleX links. + Prohibit sending SimpleX links Send up to 100 last messages to new members. Do not send history to new members. Group members can send disappearing messages. @@ -1603,6 +1609,8 @@ Message reactions are prohibited in this group. Group members can send files and media. Files and media are prohibited in this group. + Group members can send SimpleX links. + SimpleX links are prohibited in this group. Up to 100 last messages are sent to new members. History is not sent to new members. Delete after @@ -1625,6 +1633,10 @@ offered %s offered %s: %2s cancelled %s + all members + admins + owners + Enabled for What\'s new