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 220d717585..efafa22a6a 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 @@ -1625,18 +1625,12 @@ data class CIMeta ( val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew - fun statusIcon(primaryColor: Color, metaColor: Color = CurrentColors.value.colors.secondary): Pair? = - when (itemStatus) { - is CIStatus.SndSent -> MR.images.ic_check_filled to metaColor - is CIStatus.SndRcvd -> when(itemStatus.msgRcptStatus) { - MsgReceiptStatus.Ok -> MR.images.ic_double_check to metaColor - MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red - } - is CIStatus.SndErrorAuth -> MR.images.ic_close to Color.Red - is CIStatus.SndError -> MR.images.ic_warning_filled to WarningYellow - is CIStatus.RcvNew -> MR.images.ic_circle_filled to primaryColor - else -> null - } + fun statusIcon( + primaryColor: Color, + metaColor: Color = CurrentColors.value.colors.secondary, + paleMetaColor: Color = CurrentColors.value.colors.secondary + ): Pair? = + itemStatus.statusIcon(primaryColor, metaColor, paleMetaColor) companion object { fun getSample( @@ -1715,12 +1709,56 @@ fun localTimestamp(t: Instant): String { @Serializable sealed class CIStatus { @Serializable @SerialName("sndNew") class SndNew: CIStatus() - @Serializable @SerialName("sndSent") class SndSent: CIStatus() - @Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus): CIStatus() + @Serializable @SerialName("sndSent") class SndSent(val sndProgress: SndCIStatusProgress): CIStatus() + @Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus, val sndProgress: SndCIStatusProgress): CIStatus() @Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus() @Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus() @Serializable @SerialName("rcvNew") class RcvNew: CIStatus() @Serializable @SerialName("rcvRead") class RcvRead: CIStatus() + + fun statusIcon( + primaryColor: Color, + metaColor: Color = CurrentColors.value.colors.secondary, + paleMetaColor: Color = CurrentColors.value.colors.secondary + ): Pair? = + when (this) { + is SndNew -> null + is SndSent -> when (this.sndProgress) { + SndCIStatusProgress.Complete -> MR.images.ic_check_filled to metaColor + SndCIStatusProgress.Partial -> MR.images.ic_check_filled to paleMetaColor + } + is SndRcvd -> when(this.msgRcptStatus) { + MsgReceiptStatus.Ok -> when (this.sndProgress) { + SndCIStatusProgress.Complete -> MR.images.ic_double_check to metaColor + SndCIStatusProgress.Partial -> MR.images.ic_double_check to paleMetaColor + } + MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red + } + is SndErrorAuth -> MR.images.ic_close to Color.Red + is SndError -> MR.images.ic_warning_filled to WarningYellow + is RcvNew -> MR.images.ic_circle_filled to primaryColor + is RcvRead -> null + } + + val statusText: String get() = when (this) { + is SndNew -> generalGetString(MR.strings.item_status_snd_new_text) + is SndSent -> generalGetString(MR.strings.item_status_snd_sent_text) + is SndRcvd -> generalGetString(MR.strings.item_status_snd_rcvd_text) + is SndErrorAuth -> generalGetString(MR.strings.item_status_snd_error_text) + is SndError -> generalGetString(MR.strings.item_status_snd_error_text) + is RcvNew -> generalGetString(MR.strings.item_status_rcv_new_text) + is RcvRead -> generalGetString(MR.strings.item_status_rcv_read_text) + } + + val statusDescription: String get() = when (this) { + is SndNew -> generalGetString(MR.strings.item_status_snd_new_desc) + is SndSent -> generalGetString(MR.strings.item_status_snd_sent_desc) + is SndRcvd -> generalGetString(MR.strings.item_status_snd_rcvd_desc) + is SndErrorAuth -> generalGetString(MR.strings.item_status_snd_error_auth_desc) + is SndError -> String.format(generalGetString(MR.strings.item_status_snd_error_unexpected_desc), this.agentError) + is RcvNew -> generalGetString(MR.strings.item_status_rcv_new_desc) + is RcvRead -> generalGetString(MR.strings.item_status_rcv_read_desc) + } } @Serializable @@ -1729,6 +1767,12 @@ enum class MsgReceiptStatus { @SerialName("badMsgHash") BadMsgHash; } +@Serializable +enum class SndCIStatusProgress { + @SerialName("partial") Partial, + @SerialName("complete") Complete; +} + @Serializable sealed class CIDeleted { @Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted() @@ -2489,6 +2533,7 @@ sealed class ChatItemTTL: Comparable { @Serializable class ChatItemInfo( val itemVersions: List, + val memberDeliveryStatuses: List? ) @Serializable @@ -2500,6 +2545,12 @@ data class ChatItemVersion( val createdAt: Instant, ) +@Serializable +data class MemberDeliveryStatus( + val groupMemberId: Long, + val memberDeliveryStatus: CIStatus +) + enum class NotificationPreviewMode { MESSAGE, CONTACT, HIDDEN; 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 0e2f53a581..74efdfa549 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 @@ -471,13 +471,19 @@ object ChatController { suspend fun apiSetAllContactReceipts(enable: Boolean) { val r = sendCmd(CC.SetAllContactReceipts(enable)) if (r is CR.CmdOk) return - throw Exception("failed to enable receipts for all users ${r.responseType} ${r.details}") + throw Exception("failed to set receipts for all users ${r.responseType} ${r.details}") } suspend fun apiSetUserContactReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) { val r = sendCmd(CC.ApiSetUserContactReceipts(userId, userMsgReceiptSettings)) if (r is CR.CmdOk) return - throw Exception("failed to enable receipts for user contacts ${r.responseType} ${r.details}") + throw Exception("failed to set receipts for user contacts ${r.responseType} ${r.details}") + } + + suspend fun apiSetUserGroupReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) { + val r = sendCmd(CC.ApiSetUserGroupReceipts(userId, userMsgReceiptSettings)) + if (r is CR.CmdOk) return + throw Exception("failed to set receipts for user groups ${r.responseType} ${r.details}") } suspend fun apiHideUser(userId: Long, viewPwd: String): User = @@ -1785,6 +1791,7 @@ sealed class CC { class ApiSetActiveUser(val userId: Long, val viewPwd: String?): CC() class SetAllContactReceipts(val enable: Boolean): CC() class ApiSetUserContactReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC() + class ApiSetUserGroupReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC() class ApiHideUser(val userId: Long, val viewPwd: String): CC() class ApiUnhideUser(val userId: Long, val viewPwd: String): CC() class ApiMuteUser(val userId: Long): CC() @@ -1884,6 +1891,10 @@ sealed class CC { val mrs = userMsgReceiptSettings "/_set receipts contacts $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}" } + is ApiSetUserGroupReceipts -> { + val mrs = userMsgReceiptSettings + "/_set receipts groups $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}" + } is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}" is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}" is ApiMuteUser -> "/_mute user $userId" @@ -1981,6 +1992,7 @@ sealed class CC { is ApiSetActiveUser -> "apiSetActiveUser" is SetAllContactReceipts -> "setAllContactReceipts" is ApiSetUserContactReceipts -> "apiSetUserContactReceipts" + is ApiSetUserGroupReceipts -> "apiSetUserGroupReceipts" is ApiHideUser -> "apiHideUser" is ApiUnhideUser -> "apiUnhideUser" is ApiMuteUser -> "apiMuteUser" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 0ab8c4633a..f69116be82 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -3,6 +3,7 @@ package chat.simplex.common.views.chat import InfoRow import SectionBottomSpacer import SectionDividerSpaced +import SectionItemView import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -19,28 +20,30 @@ import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.ui.theme.CurrentColors -import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.shareText +import chat.simplex.common.ui.theme.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource -enum class CIInfoTab { - History, Quote +sealed class CIInfoTab { + class Delivery(val memberDeliveryStatuses: List): CIInfoTab() + object History: CIInfoTab() + class Quote(val quotedItem: CIQuote): CIInfoTab() } @Composable -fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { +fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { val sent = ci.chatDir.sent val appColors = CurrentColors.collectAsState().value.appColors val uriHandler = LocalUriHandler.current - val selection = remember { mutableStateOf(CIInfoTab.History) } + val selection = remember { mutableStateOf(CIInfoTab.History) } @Composable fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState) { @@ -160,10 +163,12 @@ fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { if (itemDeleted.deletedTs != null) { InfoRow(stringResource(MR.strings.info_row_deleted_at), localTimestamp(itemDeleted.deletedTs)) } + is CIDeleted.Moderated -> if (itemDeleted.deletedTs != null) { InfoRow(stringResource(MR.strings.info_row_moderated_at), localTimestamp(itemDeleted.deletedTs)) } + else -> {} } val deleteAt = ci.meta.itemTimed?.deleteAt @@ -214,55 +219,154 @@ fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { } } + @Composable + fun MemberDeliveryStatusView(member: GroupMember, status: CIStatus) { + SectionItemView( + padding = PaddingValues(horizontal = 0.dp) + ) { + ProfileImage(size = 36.dp, member.image) + Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) + Text( + member.chatViewName, + modifier = Modifier.weight(10f, fill = true), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + val statusIcon = status.statusIcon(MaterialTheme.colors.primary, CurrentColors.value.colors.secondary) + Box( + Modifier + .size(36.dp) + .clip(RoundedCornerShape(20.dp)) + .clickable { + AlertManager.shared.showAlertMsg( + title = status.statusText, + text = status.statusDescription + ) + }, + contentAlignment = Alignment.Center + ) { + if (statusIcon != null) { + val (icon, statusColor) = statusIcon + Icon( + painterResource(icon), + contentDescription = null, + tint = statusColor + ) + } else { + Icon( + painterResource(MR.images.ic_more_horiz), + contentDescription = null, + tint = CurrentColors.value.colors.secondary + ) + } + } + } + } + + @Composable + fun DeliveryTab(memberDeliveryStatuses: List) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + Details() + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + val mss = membersStatuses(chatModel, memberDeliveryStatuses) + if (mss.isNotEmpty()) { + SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text(stringResource(MR.strings.delivery), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING)) + mss.forEach { (member, status) -> + MemberDeliveryStatusView(member, status) + } + } + } else { + SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(MR.strings.no_info_on_delivery), color = MaterialTheme.colors.secondary) + } + } + } + SectionBottomSpacer() + } + } + @Composable fun tabTitle(tab: CIInfoTab): String { return when (tab) { - CIInfoTab.History -> stringResource(MR.strings.edit_history) - CIInfoTab.Quote -> stringResource(MR.strings.in_reply_to) + is CIInfoTab.Delivery -> stringResource(MR.strings.delivery) + is CIInfoTab.History -> stringResource(MR.strings.edit_history) + is CIInfoTab.Quote -> stringResource(MR.strings.in_reply_to) } } fun tabIcon(tab: CIInfoTab): ImageResource { return when (tab) { - CIInfoTab.History -> MR.images.ic_history - CIInfoTab.Quote -> MR.images.ic_reply + is CIInfoTab.Delivery -> MR.images.ic_double_check + is CIInfoTab.History -> MR.images.ic_history + is CIInfoTab.Quote -> MR.images.ic_reply } } - Column { + fun numTabs(): Int { + var numTabs = 1 + if (ciInfo.memberDeliveryStatuses != null) { + numTabs += 1 + } if (ci.quotedItem != null) { + numTabs += 1 + } + return numTabs + } + + Column { + if (numTabs() > 1) { Column( Modifier .fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween ) { + LaunchedEffect(Unit) { + if (ciInfo.memberDeliveryStatuses != null) { + selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses) + } + } Column(Modifier.weight(1f)) { - when (selection.value) { - CIInfoTab.History -> { + when (val sel = selection.value) { + is CIInfoTab.Delivery -> { + DeliveryTab(sel.memberDeliveryStatuses) + } + + is CIInfoTab.History -> { HistoryTab() } - CIInfoTab.Quote -> { - QuoteTab(ci.quotedItem) + is CIInfoTab.Quote -> { + QuoteTab(sel.quotedItem) } } } + val availableTabs = mutableListOf() + if (ciInfo.memberDeliveryStatuses != null) { + availableTabs.add(CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses)) + } + availableTabs.add(CIInfoTab.History) + if (ci.quotedItem != null) { + availableTabs.add(CIInfoTab.Quote(ci.quotedItem)) + } TabRow( - selectedTabIndex = selection.value.ordinal, + selectedTabIndex = availableTabs.indexOfFirst { it::class == selection.value::class }, backgroundColor = Color.Transparent, contentColor = MaterialTheme.colors.primary, ) { - CIInfoTab.values().forEachIndexed { index, it -> + availableTabs.forEach { ciInfoTab -> Tab( - selected = selection.value.ordinal == index, + selected = selection.value::class == ciInfoTab::class, onClick = { - selection.value = CIInfoTab.values()[index] + selection.value = ciInfoTab }, - text = { Text(tabTitle(it), fontSize = 13.sp) }, + text = { Text(tabTitle(ciInfoTab), fontSize = 13.sp) }, icon = { Icon( - painterResource(tabIcon(it)), - tabTitle(it) + painterResource(tabIcon(ciInfoTab)), + tabTitle(ciInfoTab) ) }, selectedContentColor = MaterialTheme.colors.primary, @@ -277,10 +381,18 @@ fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { } } -fun itemInfoShareText(ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolean): String { +private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List): List> { + return memberDeliveryStatuses.mapNotNull { mds -> + chatModel.groupMembers.firstOrNull { it.groupMemberId == mds.groupMemberId }?.let { mem -> + mem to mds.memberDeliveryStatus + } + } +} + +fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolean): String { val meta = ci.meta val sent = ci.chatDir.sent - val shareText = mutableListOf(generalGetString(if (sent) MR.strings.sent_message else MR.strings.received_message), "") + val shareText = mutableListOf("# " + generalGetString(if (sent) MR.strings.sent_message else MR.strings.received_message), "") shareText.add(String.format(generalGetString(MR.strings.share_text_sent_at), localTimestamp(meta.itemTs))) if (!ci.chatDir.sent) { @@ -291,10 +403,12 @@ fun itemInfoShareText(ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolea if (itemDeleted.deletedTs != null) { shareText.add(String.format(generalGetString(MR.strings.share_text_deleted_at), localTimestamp(itemDeleted.deletedTs))) } + is CIDeleted.Moderated -> if (itemDeleted.deletedTs != null) { shareText.add(String.format(generalGetString(MR.strings.share_text_moderated_at), localTimestamp(itemDeleted.deletedTs))) } + else -> {} } val deleteAt = ci.meta.itemTimed?.deleteAt @@ -308,7 +422,7 @@ fun itemInfoShareText(ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolea val qi = ci.quotedItem if (qi != null) { shareText.add("") - shareText.add(generalGetString(MR.strings.in_reply_to)) + shareText.add("## " + generalGetString(MR.strings.in_reply_to)) shareText.add("") val ts = localTimestamp(qi.sentAt) val sender = qi.sender(null) @@ -320,10 +434,26 @@ fun itemInfoShareText(ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolea val t = qi.text shareText.add(if (t != "") t else generalGetString(MR.strings.item_info_no_text)) } + val mdss = chatItemInfo.memberDeliveryStatuses + if (mdss != null) { + val mss = membersStatuses(chatModel, mdss) + if (mss.isNotEmpty()) { + shareText.add("") + shareText.add("## " + generalGetString(MR.strings.delivery)) + shareText.add("") + mss.forEach { (member, status) -> + shareText.add(String.format( + generalGetString(MR.strings.recipient_colon_delivery_status), + member.chatViewName, + status.statusDescription + )) + } + } + } val versions = chatItemInfo.itemVersions if (versions.isNotEmpty()) { shareText.add("") - shareText.add(generalGetString(MR.strings.edit_history)) + shareText.add("## " + generalGetString(MR.strings.edit_history)) versions.forEachIndexed { index, itemVersion -> val ts = localTimestamp(itemVersion.itemVersionTs) shareText.add("") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 209d1a80a9..0f7dd1a081 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -321,11 +321,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { withApi { val ciInfo = chatModel.controller.apiGetChatItemInfo(cInfo.chatType, cInfo.apiId, cItem.id) if (ciInfo != null) { + if (chat.chatInfo is ChatInfo.Group) { + setGroupMembers(chat.chatInfo.groupInfo, chatModel) + } ModalManager.end.closeModals() ModalManager.end.showModal(endButtons = { ShareButton { - clipboard.shareText(itemInfoShareText(cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get())) + clipboard.shareText(itemInfoShareText(chatModel, cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get())) } }) { - ChatItemInfoView(cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) + ChatItemInfoView(chatModel, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 4b37badfeb..402a9f28ca 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -26,26 +26,37 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chatlist.cantInviteIncognitoAlert -import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.common.views.chat.ClearChatButton -import chat.simplex.common.views.chat.clearChatDialog +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.* import chat.simplex.res.MR +const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 + @Composable fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair) -> Unit, close: () -> Unit) { BackHandler(onBack = close) val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + val currentUser = chatModel.currentUser.value val developerTools = chatModel.controller.appPrefs.developerTools.get() - if (chat != null && chat.chatInfo is ChatInfo.Group) { + if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) { val groupInfo = chat.chatInfo.groupInfo + val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) } GroupChatInfoLayout( chat, groupInfo, + currentUser, + sendReceipts = sendReceipts, + setSendReceipts = { sendRcpts -> + withApi { + val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(sendRcpts = sendRcpts.bool) + updateChatSettings(chat, chatSettings, chatModel) + sendReceipts.value = sendRcpts + } + }, members = chatModel.groupMembers .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } .sortedBy { it.displayName.lowercase() }, @@ -150,6 +161,9 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U fun GroupChatInfoLayout( chat: Chat, groupInfo: GroupInfo, + currentUser: User, + sendReceipts: State, + setSendReceipts: (SendReceipts) -> Unit, members: List, developerTools: Boolean, groupLink: String?, @@ -184,6 +198,11 @@ fun GroupChatInfoLayout( AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage) } GroupPreferencesButton(openPreferences) + if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { + SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) + } else { + SendReceiptsOptionDisabled() + } } SectionTextFooter(stringResource(MR.strings.only_group_owners_can_change_prefs)) SectionDividerSpaced(maxTopPadding = true) @@ -269,6 +288,37 @@ private fun GroupPreferencesButton(onClick: () -> Unit) { ) } +@Composable +private fun SendReceiptsOption(currentUser: User, state: State, onSelected: (SendReceipts) -> Unit) { + val values = remember { + mutableListOf(SendReceipts.Yes, SendReceipts.No, SendReceipts.UserDefault(currentUser.sendRcptsSmallGroups)).map { it to it.text } + } + ExposedDropDownSettingRow( + generalGetString(MR.strings.send_receipts), + values, + state, + icon = painterResource(MR.images.ic_double_check), + enabled = remember { mutableStateOf(true) }, + onSelected = onSelected + ) +} + +@Composable +fun SendReceiptsOptionDisabled() { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_double_check), + text = generalGetString(MR.strings.send_receipts), + click = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.send_receipts_disabled_alert_title), + text = String.format(generalGetString(MR.strings.send_receipts_disabled_alert_msg), SMALL_GROUPS_RCPS_MEM_LIMIT) + ) + } + ) { + Text(generalGetString(MR.strings.send_receipts_disabled), color = MaterialTheme.colors.secondary) + } +} + @Composable private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick: () -> Unit) { SettingsActionItem( @@ -429,6 +479,9 @@ fun PreviewGroupChatInfoLayout() { chatItems = arrayListOf() ), groupInfo = GroupInfo.sampleData, + User.sampleData, + sendReceipts = remember { mutableStateOf(SendReceipts.Yes) }, + setSendReceipts = {}, members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, groupLink = null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index 86cd0acbfe..ab121c6272 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -14,11 +14,27 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.isInDarkTheme import chat.simplex.res.MR import kotlinx.datetime.Clock @Composable -fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = MaterialTheme.colors.secondary) { +fun CIMetaView( + chatItem: ChatItem, + timedMessagesTTL: Int?, + metaColor: Color = MaterialTheme.colors.secondary, + paleMetaColor: Color = if (isInDarkTheme()) { + metaColor.copy( + red = metaColor.red * 0.67F, + green = metaColor.green * 0.67F, + blue = metaColor.red * 0.67F) + } else { + metaColor.copy( + red = minOf(metaColor.red * 1.33F, 1F), + green = minOf(metaColor.green * 1.33F, 1F), + blue = minOf(metaColor.red * 1.33F, 1F)) + } +) { Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) { if (chatItem.isDeletedContent) { Text( @@ -28,14 +44,14 @@ fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = Ma modifier = Modifier.padding(start = 3.dp) ) } else { - CIMetaText(chatItem.meta, timedMessagesTTL, metaColor) + CIMetaText(chatItem.meta, timedMessagesTTL, metaColor, paleMetaColor) } } } @Composable // changing this function requires updating reserveSpaceForMeta -private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) { +private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Color) { if (meta.itemEdited) { StatusIconText(painterResource(MR.images.ic_edit), color) Spacer(Modifier.width(3.dp)) @@ -48,7 +64,7 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) { } Spacer(Modifier.width(4.dp)) } - val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color) + val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color, paleColor) if (statusIcon != null) { val (icon, statusColor) = statusIcon if (meta.itemStatus is CIStatus.SndSent || meta.itemStatus is CIStatus.SndRcvd) { @@ -138,7 +154,7 @@ fun PreviewCIMetaViewSendNoAuth() { fun PreviewCIMetaViewSendSent() { CIMetaView( chatItem = ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent() + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent(SndCIStatusProgress.Complete) ), null ) @@ -176,7 +192,7 @@ fun PreviewCIMetaViewEditedSent() { chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = true, - status= CIStatus.SndSent() + status= CIStatus.SndSent(SndCIStatusProgress.Complete) ), null ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 9c7c4e25c5..a46decb3d0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -101,6 +101,29 @@ fun PrivacySettingsView( } } + fun setSendReceiptsGroups(enable: Boolean, clearOverrides: Boolean) { + withApi { + val mrs = UserMsgReceiptSettings(enable, clearOverrides) + chatModel.controller.apiSetUserGroupReceipts(currentUser.userId, mrs) + chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) + chatModel.currentUser.value = currentUser.copy(sendRcptsSmallGroups = enable) + if (clearOverrides) { + // For loop here is to prevent ConcurrentModificationException that happens with forEach + for (i in 0 until chatModel.chats.size) { + val chat = chatModel.chats[i] + if (chat.chatInfo is ChatInfo.Group) { + var groupInfo = chat.chatInfo.groupInfo + val sendRcpts = groupInfo.chatSettings.sendRcpts + if (sendRcpts != null && sendRcpts != enable) { + groupInfo = groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(sendRcpts = null)) + chatModel.updateGroup(groupInfo) + } + } + } + } + } + } + DeliveryReceiptsSection( currentUser = currentUser, setOrAskSendReceiptsContacts = { enable -> @@ -117,6 +140,21 @@ fun PrivacySettingsView( } else { showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts) } + }, + setOrAskSendReceiptsGroups = { enable -> + val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> + if (chat.chatInfo is ChatInfo.Group) { + val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts + count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + } else { + count + } + } + if (groupReceiptsOverrides == 0) { + setSendReceiptsGroups(enable, clearOverrides = false) + } else { + showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups) + } } ) } @@ -155,6 +193,7 @@ expect fun PrivacyDeviceSection( private fun DeliveryReceiptsSection( currentUser: User, setOrAskSendReceiptsContacts: (Boolean) -> Unit, + setOrAskSendReceiptsGroups: (Boolean) -> Unit, ) { SectionView(stringResource(MR.strings.settings_section_title_delivery_receipts)) { SettingsActionItemWithContent(painterResource(MR.images.ic_person), stringResource(MR.strings.receipts_section_contacts)) { @@ -165,6 +204,14 @@ private fun DeliveryReceiptsSection( } ) } + SettingsActionItemWithContent(painterResource(MR.images.ic_group), stringResource(MR.strings.receipts_section_groups)) { + DefaultSwitch( + checked = currentUser.sendRcptsSmallGroups ?: false, + onCheckedChange = { enable -> + setOrAskSendReceiptsGroups(enable) + } + ) + } } SectionTextFooter( remember(currentUser.displayName) { @@ -215,6 +262,41 @@ private fun showUserContactsReceiptsAlert( ) } +private fun showUserGroupsReceiptsAlert( + enable: Boolean, + groupReceiptsOverrides: Int, + setSendReceiptsGroups: (Boolean, Boolean) -> Unit +) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(if (enable) MR.strings.receipts_groups_title_enable else MR.strings.receipts_groups_title_disable), + text = AnnotatedString(String.format(generalGetString(if (enable) MR.strings.receipts_groups_override_disabled else MR.strings.receipts_groups_override_enabled), groupReceiptsOverrides)), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + setSendReceiptsGroups(enable, false) + }) { + val t = stringResource(if (enable) MR.strings.receipts_groups_enable_keep_overrides else MR.strings.receipts_groups_disable_keep_overrides) + Text(t, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + setSendReceiptsGroups(enable, true) + } + ) { + val t = stringResource(if (enable) MR.strings.receipts_groups_enable_for_all else MR.strings.receipts_groups_disable_for_all) + Text(t, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.onBackground) + } + } + } + ) +} + private val laDelays = listOf(10, 30, 60, 180, 0) @Composable 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 9ed0c6fbd4..a54a1a9320 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -222,6 +222,8 @@ History No history In reply to + Delivery + No info on delivery Delete Reveal Hide @@ -869,7 +871,7 @@ If you enter this passcode when opening the app, all app data will be irreversibly removed! Set passcode These settings are for your current profile - They can be overridden in contact settings + They can be overridden in contact and group settings. Contacts Enable receipts? Disable receipts? @@ -879,6 +881,15 @@ Disable (keep overrides) Enable for all Disable for all + Small groups (max 20) + Enable receipts for groups? + Disable receipts for groups? + Sending receipts is enabled for %d groups + Sending receipts is disabled for %d groups + Enable (keep group overrides) + Disable (keep group overrides) + Enable for all groups + Disable for all groups YOU @@ -1161,6 +1172,9 @@ Share address You can share this address with your contacts to let them connect with %s. Send receipts + disabled + Receipts are disabled + This group has over %1$d members, delivery receipts are not sent. FOR CONSOLE @@ -1183,6 +1197,7 @@ %s at %s %s (current) no text + %s: %s Remove member @@ -1527,6 +1542,22 @@ You can enable them later via app Privacy & Security settings. Error enabling delivery receipts! + + Sending message + Message sent + Sent message received + Error sending message + Message received + Message read + + Sending message is in progress or pending. + Message has been sent to the recipient\'s relay. + Message has been received by the recipient. + Message delivery error. Most likely this recipient has deleted the connection with you. + Unexpected message delivery error: %1$s + New message from this sender. + You\'ve read this received message. + Coming soon! This feature is not yet supported. Try the next release.