diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 7b820aa67e..64937b5ccc 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -15,10 +15,12 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.LayoutDirection @@ -55,9 +57,10 @@ actual fun PlatformTextField( userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester?, onDone: () -> Unit, ) { val cs = composeState.value @@ -117,6 +120,11 @@ actual fun PlatformTextField( } return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit) } + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + onMessageChange(ComposeMessage(text.toString(), TextRange(minOf(selStart, selEnd), maxOf(selStart, selEnd)))) + super.onSelectionChanged(selStart, selEnd) + } } editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) editText.maxLines = 16 @@ -126,7 +134,8 @@ actual fun PlatformTextField( editText.background = ColorDrawable(Color.Transparent.toArgb()) editText.textDirection = if (isRtl) EditText.TEXT_DIRECTION_LOCALE else EditText.TEXT_DIRECTION_ANY_RTL editText.setPaddingRelative(paddingStart, paddingTop, paddingEnd, paddingBottom) - editText.setText(cs.message) + editText.setText(cs.message.text) + editText.setSelection(cs.message.selection.start, cs.message.selection.end) editText.hint = placeholder editText.setHintTextColor(hintColor.toArgb()) if (Build.VERSION.SDK_INT >= 29) { @@ -149,9 +158,10 @@ actual fun PlatformTextField( } editText.doOnTextChanged { text, _, _, _ -> if (!composeState.value.inProgress) { - onMessageChange(text.toString()) - } else if (text.toString() != composeState.value.message) { - editText.setText(composeState.value.message) + onMessageChange(ComposeMessage(text.toString(), TextRange(minOf(editText.selectionStart, editText.selectionEnd), maxOf(editText.selectionStart, editText.selectionEnd)))) + } else if (text.toString() != composeState.value.message.text) { + editText.setText(composeState.value.message.text) + editText.setSelection(composeState.value.message.selection.start, composeState.value.message.selection.end) } } editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") } @@ -167,10 +177,9 @@ actual fun PlatformTextField( it.textSize = textStyle.value.fontSize.value * appPrefs.fontScale.get() it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview it.isFocusableInTouchMode = it.isFocusable - if (cs.message != it.text.toString()) { - it.setText(cs.message) - // Set cursor to the end of the text - it.setSelection(it.text.length) + if (cs.message.text != it.text.toString() || cs.message.selection.start != it.selectionStart || cs.message.selection.end != it.selectionEnd) { + it.setText(cs.message.text) + it.setSelection(cs.message.selection.start, cs.message.selection.end) } if (showKeyboard) { it.requestFocus() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt index b3d8e9b52f..0d07de28f8 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt @@ -95,8 +95,10 @@ actual fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State?, additionalTopBar: State, chatBottomBar: State, + maxHeight: State?, + containerAlignment: Alignment, content: LazyListScope.() -> Unit -) { + ) { val state = state ?: rememberLazyListState() LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled) { content() 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 e9b1b3ae66..a148392572 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 @@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration +import chat.simplex.common.model.MsgFilter.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -72,6 +73,7 @@ object ChatModel { val deletedChats = mutableStateOf>>(emptyList()) val groupMembers = mutableStateOf>(emptyList()) val groupMembersIndexes = mutableStateOf>(emptyMap()) + val membersLoaded = mutableStateOf(false) // Chat Tags val userTags = mutableStateOf(emptyList()) @@ -473,7 +475,7 @@ object ChatModel { chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { increaseUnreadCounter(rhId, currentUser.value!!) - chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1) + chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) } else chat.chatStats @@ -595,8 +597,9 @@ object ChatModel { val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) + val chatBefore = chats[i] chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) - markChatTagRead(chats[i]) + markChatTagRead(chatBefore) } // clear current chat if (chatId.value == cInfo.id) { @@ -656,7 +659,7 @@ object ChatModel { } fun markChatItemsRead(remoteHostId: Long?, id: ChatId, itemIds: List? = null) { - val markedRead = markItemsReadInCurrentChat(id, itemIds) + val (markedRead, mentionsMarkedRead) = markItemsReadInCurrentChat(id, itemIds) // update preview val chatIdx = getChatIndex(remoteHostId, id) if (chatIdx >= 0) { @@ -665,17 +668,19 @@ object ChatModel { if (lastId != null) { val wasUnread = chat.unreadTag val unreadCount = if (itemIds != null) chat.chatStats.unreadCount - markedRead else 0 + val unreadMentions = if (itemIds != null) chat.chatStats.unreadMentions - mentionsMarkedRead else 0 decreaseUnreadCounter(remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( - chatStats = chat.chatStats.copy(unreadCount = unreadCount) + chatStats = chat.chatStats.copy(unreadCount = unreadCount, unreadMentions = unreadMentions) ) updateChatTagReadNoContentTag(chats[chatIdx], wasUnread) } } } - private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List? = null): Int { + private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List? = null): Pair { var markedRead = 0 + var mentionsMarkedRead = 0 if (chatId.value == id) { val items = chatItems.value var i = items.lastIndex @@ -693,6 +698,9 @@ object ChatModel { } markedReadIds.add(item.id) markedRead++ + if (item.meta.userMention) { + mentionsMarkedRead++ + } if (itemIds != null) { itemIdsFromRange.remove(item.id) // already set all needed items as read, can finish the loop @@ -703,7 +711,7 @@ object ChatModel { } chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) } - return markedRead + return markedRead to mentionsMarkedRead } private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) { @@ -1176,7 +1184,6 @@ interface SomeChat { val ready: Boolean val chatDeleted: Boolean val sendMsgEnabled: Boolean - val ntfsEnabled: Boolean val incognito: Boolean fun featureEnabled(feature: ChatFeature): Boolean val timedMessagesTTL: Int? @@ -1208,7 +1215,11 @@ data class Chat( else -> false } - val unreadTag: Boolean get() = chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) + val unreadTag: Boolean get() = when (chatInfo.chatSettings?.enableNtfs) { + All -> chatStats.unreadChat || chatStats.unreadCount > 0 + Mentions -> chatStats.unreadChat || chatStats.unreadMentions > 0 + else -> chatStats.unreadChat + } val id: String get() = chatInfo.id @@ -1234,6 +1245,7 @@ data class Chat( data class ChatStats( val unreadCount: Int = 0, // actual only via getChats() and getChat(.initial), otherwise, zero + val unreadMentions: Int = 0, val reportsCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false @@ -1260,7 +1272,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contact.ready override val chatDeleted get() = contact.chatDeleted override val sendMsgEnabled get() = contact.sendMsgEnabled - override val ntfsEnabled get() = contact.ntfsEnabled override val incognito get() = contact.incognito override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL @@ -1286,7 +1297,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = groupInfo.ready override val chatDeleted get() = groupInfo.chatDeleted override val sendMsgEnabled get() = groupInfo.sendMsgEnabled - override val ntfsEnabled get() = groupInfo.ntfsEnabled override val incognito get() = groupInfo.incognito override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature) override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL @@ -1311,7 +1321,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = noteFolder.ready override val chatDeleted get() = noteFolder.chatDeleted override val sendMsgEnabled get() = noteFolder.sendMsgEnabled - override val ntfsEnabled get() = noteFolder.ntfsEnabled override val incognito get() = noteFolder.incognito override fun featureEnabled(feature: ChatFeature) = noteFolder.featureEnabled(feature) override val timedMessagesTTL: Int? get() = noteFolder.timedMessagesTTL @@ -1336,7 +1345,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contactRequest.ready override val chatDeleted get() = contactRequest.chatDeleted override val sendMsgEnabled get() = contactRequest.sendMsgEnabled - override val ntfsEnabled get() = contactRequest.ntfsEnabled override val incognito get() = contactRequest.incognito override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL @@ -1361,7 +1369,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contactConnection.ready override val chatDeleted get() = contactConnection.chatDeleted override val sendMsgEnabled get() = contactConnection.sendMsgEnabled - override val ntfsEnabled get() = false override val incognito get() = contactConnection.incognito override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL @@ -1387,7 +1394,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = false override val chatDeleted get() = false override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null @@ -1403,6 +1409,16 @@ sealed class ChatInfo: SomeChat, NamedChat { } } + fun ntfsEnabled(ci: ChatItem): Boolean = + ntfsEnabled(ci.meta.userMention) + + fun ntfsEnabled(userMention: Boolean): Boolean = + when (chatSettings?.enableNtfs) { + All -> true + Mentions -> userMention + else -> false + } + val chatSettings get() = when(this) { is Direct -> contact.chatSettings @@ -1435,6 +1451,10 @@ sealed class ChatInfo: SomeChat, NamedChat { else -> null } + val nextNtfMode: MsgFilter? get() = this.chatSettings?.enableNtfs?.nextMode(mentions = this.hasMentions) + + val hasMentions: Boolean get() = this is Group + val contactCard: Boolean get() = when (this) { is Direct -> contact.activeConn == null && contact.profile.contactLink != null && contact.active @@ -1502,7 +1522,6 @@ data class Contact( ) || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent - override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser @@ -1736,7 +1755,6 @@ data class GroupInfo ( override val ready get() = membership.memberActive override val chatDeleted get() = false override val sendMsgEnabled get() = membership.memberActive - override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = membership.memberIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on @@ -1867,6 +1885,19 @@ data class GroupMember ( name } + val localAliasAndFullName: String + get() { + val p = memberProfile + val fullName = p.displayName + (if (p.fullName == "" || p.fullName == p.displayName) "" else " / ${p.fullName}") + + val name = if (p.localAlias.isNotEmpty()) { + "${p.localAlias} ($fullName)" + } else { + fullName + } + return pastMember(name) + } + val memberActive: Boolean get() = when (this.memberStatus) { GroupMemberStatus.MemRemoved -> false GroupMemberStatus.MemLeft -> false @@ -2075,7 +2106,6 @@ class NoteFolder( override val chatDeleted get() = false override val ready get() = true override val sendMsgEnabled get() = true - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = feature == ChatFeature.Voice override val timedMessagesTTL: Int? get() = null @@ -2112,7 +2142,6 @@ class UserContactRequest ( override val chatDeleted get() = false override val ready get() = true override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null @@ -2152,7 +2181,6 @@ class PendingContactConnection( override val chatDeleted get() = false override val ready get() = false override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = customUserProfileId != null override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null @@ -2253,6 +2281,30 @@ data class MemberReaction( val reactionTs: Instant ) +@Serializable +data class CIMentionMember( + val groupMemberId: Long, + val displayName: String, + val localAlias: String?, + val memberRole: GroupMemberRole +) + +@Serializable +data class CIMention( + val memberId: String, + val memberRef: CIMentionMember? +) { + constructor(groupMember: GroupMember): this( + groupMember.memberId, + CIMentionMember( + groupMember.groupMemberId, + groupMember.memberProfile.displayName, + groupMember.memberProfile.localAlias, + groupMember.memberRole + ) + ) +} + @Serializable class CIReaction( val chatDir: CIDirection, @@ -2267,6 +2319,7 @@ data class ChatItem ( val meta: CIMeta, val content: CIContent, val formattedText: List? = null, + val mentions: Map? = null, val quotedItem: CIQuote? = null, val reactions: List, val file: CIFile? = null @@ -2566,7 +2619,8 @@ data class ChatItem ( itemTimed = null, itemLive = false, deletable = false, - editable = false + editable = false, + userMention = false, ), content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), quotedItem = null, @@ -2590,7 +2644,8 @@ data class ChatItem ( itemTimed = null, itemLive = true, deletable = false, - editable = false + editable = false, + userMention = false, ), content = CIContent.SndMsgContent(MsgContent.MCText("")), quotedItem = null, @@ -2747,6 +2802,7 @@ data class CIMeta ( val itemEdited: Boolean, val itemTimed: CITimed?, val itemLive: Boolean?, + val userMention: Boolean, val deletable: Boolean, val editable: Boolean ) { @@ -2785,7 +2841,8 @@ data class CIMeta ( itemTimed = itemTimed, itemLive = itemLive, deletable = deletable, - editable = editable + editable = editable, + userMention = false, ) fun invalidJSON(): CIMeta = @@ -2804,7 +2861,8 @@ data class CIMeta ( itemTimed = null, itemLive = false, deletable = false, - editable = false + editable = false, + userMention = false ) } } @@ -3821,6 +3879,10 @@ class FormattedText(val text: String, val format: Format? = null) { fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List): String = "${linkType.description} (${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})" + + companion object { + fun plain(text: String): List = if (text.isEmpty()) emptyList() else listOf(FormattedText(text)) + } } @Serializable @@ -3833,6 +3895,7 @@ sealed class Format { @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() + @Serializable @SerialName("mention") class Mention(val memberName: String): Format() @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() @@ -3845,6 +3908,7 @@ sealed class Format { is Colored -> SpanStyle(color = this.color.uiColor) is Uri -> linkStyle is SimplexLink -> linkStyle + is Mention -> SpanStyle(fontWeight = FontWeight.Medium) is Email -> linkStyle is Phone -> linkStyle } 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 94b0412821..6021d48b0a 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 @@ -19,7 +19,7 @@ import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen -import chat.simplex.common.model.SMPErrorType.BLOCKED +import chat.simplex.common.model.MsgContent.MCUnknown import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -36,6 +36,7 @@ import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel @@ -1018,12 +1019,13 @@ object ChatController { } } - suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { - val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) + suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, updatedMessage: UpdatedMessage, live: Boolean = false): AChatItem? { + val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, updatedMessage, live)) when { r is CR.ChatItemUpdated -> return r.chatItem r is CR.ChatItemNotChanged -> return r.chatItem r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg -> { + val mc = updatedMessage.msgContent AlertManager.shared.showAlertMsg( generalGetString(MR.strings.maximum_message_size_title), if (mc is MsgContent.MCImage || mc is MsgContent.MCVideo || mc is MsgContent.MCLink) { @@ -2523,7 +2525,7 @@ object ChatController { addChatItem(rhId, cInfo, cItem) } } - } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { + } else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { withChats { increaseUnreadCounter(rhId, r.user) } @@ -2573,7 +2575,7 @@ object ChatController { is CR.ChatItemsDeleted -> { if (!active(r.user)) { r.chatItemDeletions.forEach { (deletedChatItem, toChatItem) -> - if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled) { + if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled(deletedChatItem.chatItem)) { withChats { decreaseUnreadCounter(rhId, r.user) } @@ -3370,7 +3372,7 @@ sealed class CC { class ApiReorderChatTags(val tagIds: List): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): CC() class ApiReportMessage(val groupId: Long, val chatItemId: Long, val reportReason: ReportReason, val reportText: String): CC() - class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() + class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val updatedMessage: UpdatedMessage, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() @@ -3548,7 +3550,7 @@ sealed class CC { "/_create *$noteFolderId json $msgs" } is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText" - is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" + is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${updatedMessage.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" @@ -3895,7 +3897,13 @@ sealed class ChatPagination { } @Serializable -class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent) +class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent, val mentions: Map) + +@Serializable +class UpdatedMessage(val msgContent: MsgContent, val mentions: Map) { + val cmdString: String get() = + if (msgContent is MCUnknown) "json $json" else "json ${json.encodeToString(this)}" +} @Serializable class ChatTagData(val emoji: String?, val text: String) @@ -4552,7 +4560,37 @@ data class ChatSettings( enum class MsgFilter { @SerialName("all") All, @SerialName("none") None, - @SerialName("mentions") Mentions, + @SerialName("mentions") Mentions; + + fun nextMode(mentions: Boolean): MsgFilter { + return when (this) { + All -> if (mentions) Mentions else None + Mentions -> None + None -> All + } + } + + fun text(mentions: Boolean): StringResource { + return when (this) { + All -> MR.strings.unmute_chat + Mentions -> MR.strings.mute_chat + None -> if (mentions) MR.strings.mute_all_chat else MR.strings.mute_chat + } + } + + val icon: ImageResource + get() = when (this) { + All -> MR.images.ic_notifications + Mentions -> MR.images.ic_notification_important + None -> MR.images.ic_notifications_off + } + + val iconFilled: ImageResource + get() = when (this) { + All -> MR.images.ic_notifications + Mentions -> MR.images.ic_notification_important_filled + None -> MR.images.ic_notifications_off_filled + } } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 51d26f8ff2..43ea0a499e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -39,7 +39,7 @@ abstract class NtfManager { fun notifyMessageReceived(rhId: Long?, user: UserLike, cInfo: ChatInfo, cItem: ChatItem) { if ( cItem.showNotification && - cInfo.ntfsEnabled && + cInfo.ntfsEnabled(cItem) && ( allowedToShowNotification() || chatModel.chatId.value != cInfo.id || diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt index 1dff386684..1daf5a7ba7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -1,8 +1,10 @@ package chat.simplex.common.platform -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.* +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle +import chat.simplex.common.views.chat.ComposeMessage import chat.simplex.common.views.chat.ComposeState import java.net.URI @@ -16,8 +18,9 @@ expect fun PlatformTextField( userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester? = null, onDone: () -> Unit, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt index e6d4514875..b1965ec845 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt @@ -45,6 +45,8 @@ expect fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State? = null, additionalTopBar: State = remember { mutableStateOf(false) }, chatBottomBar: State = remember { mutableStateOf(true) }, + maxHeight: State? = null, + containerAlignment: Alignment = Alignment.TopStart, content: LazyListScope.() -> Unit ) 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 67fae65897..22b013ff60 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 @@ -10,8 +10,10 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow @@ -45,16 +47,16 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState(CIInfoTab.History) } @Composable - fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState) { + fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { if (text != "") { MarkdownText( text, if (text.isEmpty()) emptyList() else formattedText, sender = sender, + mentions = mentions, + userMemberId = userMemberId, senderBold = true, toggleSecrets = true, linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler, @@ -80,7 +82,12 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, ciVersion.formattedText, sender = null, showMenu) + TextBubble(text, ciVersion.formattedText, sender = null, showMenu = showMenu, mentions = ci.mentions, + userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + } + ) } } Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) { 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 7a53c18e5d..51dc568e24 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 @@ -13,6 +13,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.* +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.layout.layoutId @@ -129,6 +130,7 @@ fun ChatView( val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null } val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) } + SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) { val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value -> if (searchText.value == value) return@onSearchValueChanged @@ -144,7 +146,7 @@ fun ChatView( chatInfo = activeChatInfo, unreadCount, composeState, - composeView = { + composeView = { focusRequester -> if (selectedChatItems.value == null) { Column( Modifier.fillMaxWidth(), @@ -165,7 +167,8 @@ fun ChatView( } ComposeView( chatModel, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, - showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } } + showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }, + focusRequester = focusRequester ) } } else { @@ -244,6 +247,7 @@ fun ChatView( chatModel.chatId.value = null chatModel.groupMembers.value = emptyList() chatModel.groupMembersIndexes.value = emptyMap() + chatModel.membersLoaded.value = false }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -532,7 +536,7 @@ fun ChatView( } }) { close -> var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } - ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) + ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) LaunchedEffect(cItem.id) { withContext(Dispatchers.Default) { for (apiResp in controller.messagesChannel) { @@ -654,7 +658,7 @@ fun ChatLayout( chatInfo: State, unreadCount: State, composeState: MutableState, - composeView: (@Composable () -> Unit), + composeView: (@Composable (FocusRequester?) -> Unit), scrollToItemId: MutableState, attachmentOption: MutableState, attachmentBottomSheetState: ModalBottomSheetState, @@ -690,7 +694,7 @@ fun ChatLayout( openGroupLink: (GroupInfo) -> Unit, markItemsRead: (List) -> Unit, markChatRead: () -> Unit, - changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, + changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, @@ -731,9 +735,10 @@ fun ChatLayout( val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } val chatBottomBar = remember { appPrefs.chatBottomBar.state } + val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null } AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { if (chatInfo != null) { - Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // disables scrolling to top of chat item on click inside the bubble CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec { override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f @@ -746,6 +751,20 @@ fun ChatLayout( setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, ) } + if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) { + Column( + Modifier + .align(Alignment.BottomStart) + .padding(bottom = composeViewHeight.value) + ) { + GroupMentions( + rhId = remoteHostId, + composeState = composeState, + composeViewFocusRequester = composeViewFocusRequester, + chatInfo = chatInfo, + ) + } + } } } if (contentTag == MsgContentTag.Report) { @@ -792,7 +811,7 @@ fun ChatLayout( .navigationBarsPadding() .then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier) ) { - composeView() + composeView(composeViewFocusRequester) } } } @@ -850,7 +869,7 @@ fun BoxScope.ChatInfoToolbar( endCall: () -> Unit, addMembers: (GroupInfo) -> Unit, openGroupLink: (GroupInfo) -> Unit, - changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, + changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, showSearch: MutableState ) { @@ -969,18 +988,20 @@ fun BoxScope.ChatInfoToolbar( } } - if ((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) { - val ntfsEnabled = remember { mutableStateOf(chatInfo.ntfsEnabled) } + val enableNtfs = chatInfo.chatSettings?.enableNtfs + if (((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) && enableNtfs != null) { + val ntfMode = remember { mutableStateOf(enableNtfs) } + val nextNtfMode by remember { derivedStateOf { ntfMode.value.nextMode(chatInfo.hasMentions) } } menuItems.add { ItemAction( - if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), - if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), + stringResource(nextNtfMode.text(chatInfo.hasMentions)), + painterResource(nextNtfMode.icon), onClick = { showMenu.value = false // Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu scope.launch { delay(200) - changeNtfsState(!ntfsEnabled.value, ntfsEnabled) + changeNtfsState(nextNtfMode, ntfMode) } } ) @@ -2680,7 +2701,7 @@ fun PreviewChatLayout() { chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - composeView = {}, + composeView = { _ -> }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), @@ -2755,7 +2776,7 @@ fun PreviewGroupChatLayout() { chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - composeView = {}, + composeView = { _ -> }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), 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 c413e06599..b48b32030f 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,4 +1,4 @@ -@file:UseSerializers(UriSerializer::class) +@file:UseSerializers(UriSerializer::class, ComposeMessageSerializer::class) package chat.simplex.common.views.chat import androidx.compose.foundation.background @@ -11,17 +11,18 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.TextRange 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 import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.* import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.model.ChatModel.withChats @@ -33,10 +34,15 @@ import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import java.io.File import java.net.URI import java.nio.file.Files +const val MAX_NUMBER_OF_MENTIONS = 3 + @Serializable sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @@ -63,23 +69,57 @@ data class LiveMessage( val sent: Boolean ) +typealias MentionedMembers = Map + +@Serializable +data class ComposeMessage( + val text: String = "", + val selection: TextRange = TextRange.Zero +) { + constructor(text: String): this(text, TextRange(text.length)) +} + +@Serializer(forClass = TextRange::class) +object ComposeMessageSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TextRange", PrimitiveKind.LONG) + override fun serialize(encoder: Encoder, value: TextRange) = + encoder.encodeLong(packInts(value.start, value.end)) + override fun deserialize(decoder: Decoder): TextRange = + decoder.decodeLong().let { value -> TextRange(unpackInt1(value), unpackInt2(value)) } +} + @Serializable data class ComposeState( - val message: String = "", + val message: ComposeMessage = ComposeMessage(), + val parsedMessage: List = emptyList(), val liveMessage: LiveMessage? = null, val preview: ComposePreview = ComposePreview.NoPreview, val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, val inProgress: Boolean = false, - val useLinkPreviews: Boolean + val useLinkPreviews: Boolean, + val mentions: MentionedMembers = emptyMap() ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( - editingItem.content.text, + ComposeMessage(editingItem.content.text), + editingItem.formattedText ?: FormattedText.plain(editingItem.content.text), liveMessage, chatItemPreview(editingItem), ComposeContextItem.EditingItem(editingItem), - useLinkPreviews = useLinkPreviews + useLinkPreviews = useLinkPreviews, + mentions = editingItem.mentions ?: emptyMap() ) + val memberMentions: Map + get() = this.mentions.mapNotNull { + val memberRef = it.value.memberRef + + if (memberRef != null) { + it.key to memberRef.groupMemberId + } else { + null + } + }.toMap() + val editing: Boolean get() = when (contextItem) { @@ -100,7 +140,7 @@ data class ComposeState( get() = when (contextItem) { is ComposeContextItem.ReportedItem -> { when (contextItem.reason) { - is ReportReason.Other -> message.isNotEmpty() + is ReportReason.Other -> message.text.isNotEmpty() else -> true } } @@ -112,12 +152,12 @@ data class ComposeState( is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport + else -> message.text.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport } hasContent && !inProgress } val endLiveDisabled: Boolean - get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + get() = liveMessage != null && message.text.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem val linkPreviewAllowed: Boolean get() = @@ -160,7 +200,7 @@ data class ComposeState( } val empty: Boolean - get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + get() = message.text.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem companion object { fun saver(): Saver, *> = Saver( @@ -170,6 +210,18 @@ data class ComposeState( } ) } + + fun mentionMemberName(name: String): String { + var n = 0 + var tryName = name + + while (mentions.containsKey(tryName)) { + n++ + tryName = "${name}_$n" + } + + return tryName + } } private val maxFileSize = getMaxFileSize(FileProtocol.XFTP) @@ -223,7 +275,7 @@ fun MutableState.processPickedFile(uri: URI?, text: String?) { if (fileSize != null && fileSize <= maxFileSize) { val fileName = getFileName(uri) if (fileName != null) { - value = value.copy(message = text ?: value.message, preview = ComposePreview.FilePreview(fileName, uri)) + value = value.copy(message = if (text != null) ComposeMessage(text) else value.message, preview = ComposePreview.FilePreview(fileName, uri)) } } else if (fileSize != null) { AlertManager.shared.showAlertMsg( @@ -276,7 +328,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: } } if (imagesPreview.isNotEmpty()) { - value = value.copy(message = text ?: value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) + value = value.copy(message = if (text != null) ComposeMessage(text) else value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) } } @@ -286,15 +338,15 @@ fun ComposeView( chat: Chat, composeState: MutableState, attachmentOption: MutableState, - showChooseAttachment: () -> Unit + showChooseAttachment: () -> Unit, + focusRequester: FocusRequester?, ) { 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 + fun getSimplexLink(parsedMsg: List?): Pair { + if (parsedMsg == null) 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 @@ -302,7 +354,7 @@ fun ComposeView( val linkUrl = rememberSaveable { mutableStateOf(null) } // default value parsed because of draft - val hasSimplexLink = rememberSaveable { mutableStateOf(parseMessage(composeState.value.message).second) } + val hasSimplexLink = rememberSaveable { mutableStateOf(getSimplexLink(parseToMarkdown(composeState.value.message.text)).second) } val prevLinkUrl = rememberSaveable { mutableStateOf(null) } val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() @@ -310,7 +362,6 @@ fun ComposeView( val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) } val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } - AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } } fun loadLinkPreview(url: String, wait: Long? = null) { @@ -330,11 +381,11 @@ fun ComposeView( } } - fun showLinkPreview(s: String) { + fun showLinkPreview(parsedMessage: List?) { prevLinkUrl.value = linkUrl.value - val parsed = parseMessage(s) - linkUrl.value = parsed.first - hasSimplexLink.value = parsed.second + val linkParsed = getSimplexLink(parsedMessage) + linkUrl.value = linkParsed.first + hasSimplexLink.value = linkParsed.second val url = linkUrl.value if (url != null) { if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) { @@ -403,13 +454,13 @@ fun ComposeView( } } - suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?, mentions: Map): ChatItem? { val cInfo = chat.chatInfo val chatItems = if (chat.chatInfo.chatType == ChatType.Local) chatModel.controller.apiCreateChatItems( rh = chat.remoteHostId, noteFolderId = chat.chatInfo.apiId, - composedMessages = listOf(ComposedMessage(file, null, mc)) + composedMessages = listOf(ComposedMessage(file, null, mc, mentions)) ) else chatModel.controller.apiSendMessages( @@ -418,7 +469,7 @@ fun ComposeView( id = cInfo.apiId, live = live, ttl = ttl, - composedMessages = listOf(ComposedMessage(file, quoted, mc)) + composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) ) if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> @@ -437,7 +488,7 @@ fun ComposeView( val cs = composeState.value var sent: List? var lastMessageFailedToSend: ComposeState? = null - val msgText = text ?: cs.message + val msgText = text ?: cs.message.text fun sending() { composeState.value = composeState.value.copy(inProgress = true) @@ -473,7 +524,8 @@ fun ComposeView( fun checkLinkPreview(): MsgContent { return when (val composePreview = cs.preview) { is ComposePreview.CLinkPreview -> { - val url = parseMessage(msgText).first + val parsedMsg = parseToMarkdown(msgText) + val url = getSimplexLink(parsedMsg).first val lp = composePreview.linkPreview if (lp != null && url == lp.uri) { MsgContent.MCLink(msgText, preview = lp) @@ -544,7 +596,7 @@ fun ComposeView( type = cInfo.chatType, id = cInfo.apiId, itemId = ei.meta.itemId, - mc = updateMsgContent(oldMsgContent), + updatedMessage = UpdatedMessage(updateMsgContent(oldMsgContent), cs.memberMentions), live = live ) if (updatedItem != null) withChats { @@ -572,10 +624,10 @@ fun ComposeView( if (sent == null) { lastMessageFailedToSend = constructFailedMessage(cs) } - if (cs.message.isNotEmpty()) { + if (cs.message.text.isNotEmpty()) { sent?.mapIndexed { index, message -> if (index == sent!!.lastIndex) { - send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl) + send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions) } else { message } @@ -686,7 +738,8 @@ fun ComposeView( } val sendResult = send(chat, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, - ttl = ttl + ttl = ttl, + mentions = cs.memberMentions ) sent = if (sendResult != null) listOf(sendResult) else null if (sent == null && index == msgs.lastIndex && cs.liveMessage == null) { @@ -719,21 +772,22 @@ fun ComposeView( } } - fun onMessageChange(s: String) { - composeState.value = composeState.value.copy(message = s) - if (isShortEmoji(s)) { - textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + fun onMessageChange(s: ComposeMessage) { + val parsedMessage = parseToMarkdown(s.text) + composeState.value = composeState.value.copy(message = s, parsedMessage = parsedMessage ?: FormattedText.plain(s.text)) + if (isShortEmoji(s.text)) { + textStyle.value = if (s.text.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont } else { textStyle.value = smallFont if (composeState.value.linkPreviewAllowed) { - if (s.isNotEmpty()) { - showLinkPreview(s) + if (s.text.isNotEmpty()) { + showLinkPreview(parsedMessage) } else { resetLinkPreview() hasSimplexLink.value = false } - } else if (s.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) { - hasSimplexLink.value = parseMessage(s).second + } else if (s.text.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) { + hasSimplexLink.value = getSimplexLink(parsedMessage).second } else { hasSimplexLink.value = false } @@ -801,7 +855,7 @@ fun ComposeView( suspend fun sendLiveMessage() { val cs = composeState.value - val typedMsg = cs.message + val typedMsg = cs.message.text if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage.sent)) { val ci = sendMessageAsync(typedMsg, live = true, ttl = null) if (!ci.isNullOrEmpty()) { @@ -822,14 +876,14 @@ fun ComposeView( val typedMsg = composeState.value.message val liveMessage = composeState.value.liveMessage if (liveMessage != null) { - val sentMsg = liveMessageToSend(liveMessage, typedMsg) + val sentMsg = liveMessageToSend(liveMessage, typedMsg.text) if (sentMsg != null) { val ci = sendMessageAsync(sentMsg, live = true, ttl = null) if (!ci.isNullOrEmpty()) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg.text, sentMsg = sentMsg, sent = true)) } - } else if (liveMessage.typedMsg != typedMsg) { - composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg)) + } else if (liveMessage.typedMsg != typedMsg.text) { + composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg.text)) } } } @@ -904,16 +958,16 @@ fun ComposeView( fun contextItemView() { when (val contextItem = composeState.value.contextItem) { ComposeContextItem.NoContextItem -> {} - is ComposeContextItem.QuotedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_reply), chatType = chat.chatInfo.chatType) { + is ComposeContextItem.QuotedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_reply), chatInfo = chat.chatInfo) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } - is ComposeContextItem.EditingItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_edit_filled), chatType = chat.chatInfo.chatType) { + is ComposeContextItem.EditingItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_edit_filled), chatInfo = chat.chatInfo) { clearState() } - is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) { + is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatInfo = chat.chatInfo) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } - is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatType = chat.chatInfo.chatType, contextIconColor = Color.Red) { + is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatInfo = chat.chatInfo, contextIconColor = Color.Red) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } } @@ -932,7 +986,7 @@ fun ComposeView( if (chatModel.chatId.value == null) return@LaunchedEffect when (val shared = chatModel.sharedContent.value) { - is SharedContent.Text -> onMessageChange(shared.text) + is SharedContent.Text -> onMessageChange(ComposeMessage(shared.text)) is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text) is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text) is SharedContent.Forward -> composeState.value = composeState.value.copy( @@ -1056,7 +1110,7 @@ fun ComposeView( KeyChangeEffect(chatModel.chatId.value) { prevChatId -> val cs = composeState.value - if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { + if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { sendMessage(null) resetLinkPreview() clearPrevDraft(prevChatId) @@ -1128,7 +1182,8 @@ fun ComposeView( editPrevMessage = ::editPrevMessage, onFilesPasted = { composeState.onFilesAttached(it) }, onMessageChange = ::onMessageChange, - textStyle = textStyle + textStyle = textStyle, + focusRequester = focusRequester, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index 1657a1f0b7..1501fb7938 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -31,7 +31,7 @@ fun ContextItemView( contextItems: List, contextIcon: Painter, showSender: Boolean = true, - chatType: ChatType, + chatInfo: ChatInfo, contextIconColor: Color = MaterialTheme.colors.secondary, cancelContextItem: () -> Unit, ) { @@ -64,6 +64,11 @@ fun ContextItemView( inlineContent = inlineContent, linkMode = SimplexLinkMode.DESCRIPTION, modifier = Modifier.fillMaxWidth(), + mentions = contextItem.mentions, + userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + } ) } @@ -126,7 +131,7 @@ fun ContextItemView( ContextMsgPreview(contextItem, lines = 3) } } else if (contextItems.isNotEmpty()) { - Text(String.format(generalGetString(if (chatType == ChatType.Local) MR.strings.compose_save_messages_n else MR.strings.compose_forward_messages_n), contextItems.count()), fontStyle = FontStyle.Italic) + Text(String.format(generalGetString(if (chatInfo.chatType == ChatType.Local) MR.strings.compose_save_messages_n else MR.strings.compose_forward_messages_n), contextItems.count()), fontStyle = FontStyle.Italic) } } IconButton(onClick = cancelContextItem) { @@ -147,7 +152,7 @@ fun PreviewContextItemView() { ContextItemView( contextItems = listOf(ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello")), contextIcon = painterResource(MR.images.ic_edit_filled), - chatType = ChatType.Direct + chatInfo = Chat.sampleData.chatInfo ) {} } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 45a2a31c04..457f8f1e40 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -7,7 +7,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -63,7 +65,7 @@ fun SelectedItemsBottomToolbar( val forwardCountProhibited = remember { mutableStateOf(false) } Box { // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty - ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}) + ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) Row( Modifier .matchParentSize() 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 e2b44478af..5524eff655 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 @@ -12,10 +12,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.* import chat.simplex.common.model.* @@ -56,11 +58,11 @@ fun SendMsgView( cancelLiveMessage: (() -> Unit)? = null, editPrevMessage: () -> Unit, onFilesPasted: (List) -> Unit, - onMessageChange: (String) -> Unit, - textStyle: MutableState -) { + onMessageChange: (ComposeMessage) -> Unit, + textStyle: MutableState, + focusRequester: FocusRequester? = null, + ) { val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) } - val padding = if (appPlatform.isAndroid) PaddingValues(vertical = 8.dp) else PaddingValues(top = 3.dp, bottom = 4.dp) Box(Modifier.padding(padding)) { val cs = composeState.value @@ -73,7 +75,7 @@ fun SendMsgView( false } } - val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && + val showVoiceButton = !nextSendGrpInv && cs.message.text.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || @@ -92,7 +94,8 @@ fun SendMsgView( showVoiceButton, onMessageChange, editPrevMessage, - onFilesPasted + onFilesPasted, + focusRequester ) { if (!cs.inProgress) { sendMessage(null) @@ -160,7 +163,7 @@ fun SendMsgView( } } } - cs.liveMessage?.sent == false && cs.message.isEmpty() -> { + cs.liveMessage?.sent == false && cs.message.text.isEmpty() -> { CancelLiveMessageButton { cancelLiveMessage?.invoke() } @@ -280,7 +283,7 @@ private fun CustomDisappearingMessageDialog( @Composable private fun BoxScope.DeleteTextButton(composeState: MutableState) { IconButton( - { composeState.value = composeState.value.copy(message = "") }, + { composeState.value = composeState.value.copy(message = ComposeMessage()) }, Modifier.align(Alignment.TopEnd).size(36.dp) ) { Icon(painterResource(MR.images.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary) @@ -586,7 +589,7 @@ fun PreviewSendMsgView() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } @@ -622,7 +625,7 @@ fun PreviewSendMsgViewEditing() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } @@ -658,7 +661,7 @@ fun PreviewSendMsgViewInProgress() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } 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 b2963c4d4a..b3dc333ace 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 @@ -17,7 +17,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString @@ -46,6 +45,8 @@ import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 +val MEMBER_ROW_AVATAR_SIZE = 42.dp +val MEMBER_ROW_VERTICAL_PADDING = 8.dp @Composable fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, scrollToItemId: MutableState, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { @@ -258,16 +259,17 @@ fun MuteButton( chat: Chat, groupInfo: GroupInfo ) { - val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } + val notificationMode = remember { mutableStateOf(groupInfo.chatSettings.enableNtfs) } + val nextNotificationMode by remember { derivedStateOf { notificationMode.value.nextMode(true) } } InfoViewActionButton( modifier = modifier, - icon = if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), - title = if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), + icon = painterResource(nextNotificationMode.icon), + title = generalGetString(nextNotificationMode.text(true)), disabled = !groupInfo.ready, disabledLook = !groupInfo.ready, onClick = { - toggleNotifications(chat.remoteHostId, chat.chatInfo, !ntfsEnabled.value, chatModel, ntfsEnabled) + toggleNotifications(chat.remoteHostId, chat.chatInfo, nextNotificationMode, chatModel, notificationMode) } ) } @@ -451,7 +453,7 @@ fun ModalData.GroupChatInfoLayout( val showMenu = remember { mutableStateOf(false) } SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { DropDownMenuForMember(chat.remoteHostId, member, groupInfo, showMenu) - MemberRow(member, onClick = { showMemberInfo(member) }) + MemberRow(member) } } item { @@ -599,7 +601,7 @@ private fun AddMembersButton(titleId: StringResource, tint: Color = MaterialThem } @Composable -private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -> Unit)? = null) { +fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = true, showlocalAliasAndFullName: Boolean = false, selected: Boolean = false) { @Composable fun MemberInfo() { if (member.blocked) { @@ -628,11 +630,11 @@ private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() - verticalAlignment = Alignment.CenterVertically ) { Row( - Modifier.weight(1f).padding(top = 8.dp, end = DEFAULT_PADDING, bottom = 8.dp), + Modifier.weight(1f).padding(top = MEMBER_ROW_VERTICAL_PADDING, end = DEFAULT_PADDING, bottom = MEMBER_ROW_VERTICAL_PADDING), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - MemberProfileImage(size = 42.dp, member) + MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) Column { Row(verticalAlignment = Alignment.CenterVertically) { @@ -640,22 +642,37 @@ private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() - MemberVerifiedShield() } Text( - member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } - val statusDescr = - if (user) String.format(generalGetString(MR.strings.group_info_member_you), member.memberStatus.shortText) else memberConnStatus() - Text( - statusDescr, - color = MaterialTheme.colors.secondary, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + + if (infoPage) { + val statusDescr = + if (user) String.format(generalGetString(MR.strings.group_info_member_you), member.memberStatus.shortText) else memberConnStatus() + Text( + statusDescr, + color = MaterialTheme.colors.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } - MemberInfo() + if (infoPage) { + MemberInfo() + } + if (selected) { + Icon( + painterResource( + MR.images.ic_check + ), + null, + Modifier.size(20.dp), + tint = MaterialTheme.colors.primary, + ) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt new file mode 100644 index 0000000000..89ef19dcda --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt @@ -0,0 +1,298 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.setGroupMembers +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import kotlinx.coroutines.launch + +private val PICKER_ROW_SIZE = MEMBER_ROW_AVATAR_SIZE + (MEMBER_ROW_VERTICAL_PADDING * 2f) +private val MAX_PICKER_HEIGHT = (PICKER_ROW_SIZE * 4) + (MEMBER_ROW_AVATAR_SIZE + MEMBER_ROW_VERTICAL_PADDING - 4.dp) + +@Composable +fun GroupMentions( + rhId: Long?, + composeState: MutableState, + composeViewFocusRequester: FocusRequester?, + chatInfo: ChatInfo.Group +) { + val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() } + val isVisible = remember { mutableStateOf(false) } + val offsetY = remember { Animatable(maxHeightInPx) } + + val currentMessage = remember { mutableStateOf(composeState.value.message) } + val mentionName = remember { mutableStateOf("") } + val mentionRange = remember { mutableStateOf(null) } + val mentionMemberId = remember { mutableStateOf(null) } + val filteredMembers = remember { + derivedStateOf { + val members = chatModel.groupMembers.value + .filter { + val status = it.memberStatus + status != GroupMemberStatus.MemLeft && status != GroupMemberStatus.MemRemoved && status != GroupMemberStatus.MemInvited + } + .sortedByDescending { it.memberRole } + + if (mentionName.value.isEmpty()) { + members + } else { + members.filter { it.memberProfile.anyNameContains(mentionName.value) } + } + } + } + val scope = rememberCoroutineScope() + + suspend fun closeMembersPicker() { + isVisible.value = false + if (offsetY.value != 0f) { + return + } + + offsetY.animateTo( + targetValue = maxHeightInPx, + animationSpec = mentionPickerAnimSpec() + ) + mentionName.value = "" + mentionRange.value = null + mentionMemberId.value = null + } + + fun messageChanged(msg: ComposeMessage, parsedMsg: List) { + removeUnusedMentions(composeState, parsedMsg) + val selected = selectedMarkdown(parsedMsg, msg.selection) + + if (selected != null) { + val (ft, r) = selected + + when (ft.format) { + is Format.Mention -> { + isVisible.value = true + mentionName.value = ft.format.memberName + mentionRange.value = r + mentionMemberId.value = composeState.value.mentions[mentionName.value]?.memberId + if (!chatModel.membersLoaded.value) { + scope.launch { + setGroupMembers(rhId, chatInfo.groupInfo, chatModel) + } + } + return + } + null -> { + val pos = msg.selection.start + if (msg.selection.length == 0 && getCharacter(msg.text, pos - 1)?.first == "@") { + val prevChar = getCharacter(msg.text, pos - 2)?.first + if (prevChar == null || prevChar == " " || prevChar == "\n") { + isVisible.value = true + mentionName.value = "" + mentionRange.value = TextRange(pos - 1, pos) + mentionMemberId.value = null + scope.launch { + setGroupMembers(rhId, chatInfo.groupInfo, chatModel) + } + return + } + } + } + else -> {} + } + } + scope.launch { + closeMembersPicker() + } + } + + fun addMemberMention(member: GroupMember, range: TextRange) { + val mentions = composeState.value.mentions.toMutableMap() + val existingMention = mentions.entries.firstOrNull { + it.value.memberId == member.memberId + } + val newName = existingMention?.key ?: composeState.value.mentionMemberName(member.memberProfile.displayName) + mentions[newName] = CIMention(member) + var msgMention = "@" + if (newName.contains(" ")) "'$newName'" else newName + var newPos = range.start + msgMention.length + val newMsgLength = composeState.value.message.text.length + msgMention.length - range.length + if (newPos == newMsgLength) { + msgMention += " " + newPos += 1 + } + + val msg = composeState.value.message.text.replaceRange( + range.start, + range.end, + msgMention + ) + composeState.value = composeState.value.copy( + message = ComposeMessage(msg, TextRange(newPos)), + parsedMessage = parseToMarkdown(msg) ?: FormattedText.plain(msg), + mentions = mentions + ) + + composeViewFocusRequester?.requestFocus() + + scope.launch { + closeMembersPicker() + } + } + + LaunchedEffect(composeState.value.parsedMessage) { + currentMessage.value = composeState.value.message + messageChanged(currentMessage.value, composeState.value.parsedMessage) + } + +// KeyChangeEffect(composeState.value.message.selection) { +// // This condition is needed to prevent messageChanged called twice, +// // because composeState.formattedText triggers later when message changes. +// // The condition is only true if position changed without text change +// if (currentMessage.value.text == composeState.value.message.text) { +// messageChanged(currentMessage.value, composeState.value.parsedMessage) +// } +// } + + LaunchedEffect(isVisible.value) { + if (isVisible.value) { + offsetY.animateTo( + targetValue = 0f, + animationSpec = mentionPickerAnimSpec() + ) + } + } + Box( + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(0, offsetY.value.toInt()) } + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { + scope.launch { closeMembersPicker() } + }, + contentAlignment = Alignment.BottomStart + ) { + val showMaxReachedBox = composeState.value.mentions.size >= MAX_NUMBER_OF_MENTIONS && isVisible.value && composeState.value.mentions[mentionName.value] == null + LazyColumnWithScrollBarNoAppBar( + Modifier + .heightIn(max = MAX_PICKER_HEIGHT) + .background(MaterialTheme.colors.surface), + maxHeight = remember { mutableStateOf(MAX_PICKER_HEIGHT) }, + containerAlignment = Alignment.BottomEnd + ) { + if (showMaxReachedBox) { + stickyHeader { + MaxMentionsReached() + } + } + itemsIndexed(filteredMembers.value, key = { _, item -> item.memberId }) { i, member -> + if (i != 0 || !showMaxReachedBox) { + Divider() + } + val mentioned = mentionMemberId.value == member.memberId + val disabled = composeState.value.mentions.size >= MAX_NUMBER_OF_MENTIONS && !mentioned + Row( + Modifier + .fillMaxWidth() + .alpha(if (disabled) 0.6f else 1f) + .clickable(enabled = !disabled) { + val range = mentionRange.value ?: return@clickable + val mentionMemberValue = mentionMemberId.value + + if (mentionMemberValue != null) { + if (mentionMemberValue != member.memberId) { + addMemberMention(member, range) + } else { + return@clickable + } + } else { + addMemberMention(member, range) + } + } + .padding(horizontal = DEFAULT_PADDING_HALF), + verticalAlignment = Alignment.CenterVertically + ) { + MemberRow( + member, + infoPage = false, + showlocalAliasAndFullName = true, + selected = mentioned + ) + } + } + } + } +} + +@Composable +private fun MaxMentionsReached() { + Column(Modifier.background(MaterialTheme.colors.surface)) { + Divider() + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + String.format(generalGetString(MR.strings.max_group_mentions_per_message_reached), MAX_NUMBER_OF_MENTIONS), + Modifier.padding(12.dp), + ) + } + Divider() + } +} + +private fun getCharacter(s: String, pos: Int): Pair? { + return if (pos in s.indices) { + val char = s.subSequence(pos, pos + 1) + char to (pos until pos + 1) + } else { + null + } +} + +private fun selectedMarkdown( + parsedMsg: List, + range: TextRange +): Pair? { + if (parsedMsg.isEmpty()) return null + + var i = 0 + var pos = 0 + + while (i < parsedMsg.size && pos + parsedMsg[i].text.length < range.start) { + pos += parsedMsg[i].text.length + i++ + } + + return if (i >= parsedMsg.size || range.end > pos + parsedMsg[i].text.length) { + null + } else { + parsedMsg[i] to TextRange(pos, pos + parsedMsg[i].text.length) + } +} + +private fun removeUnusedMentions(composeState: MutableState, parsedMsg: List) { + val usedMentions = parsedMsg.mapNotNull { ft -> + when (ft.format) { + is Format.Mention -> ft.format.memberName + else -> null + } + }.toSet() + + if (usedMentions.size < composeState.value.mentions.size) { + composeState.value = composeState.value.copy( + mentions = composeState.value.mentions.filterKeys { it in usedMentions } + ) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 784563dbb2..1fee43f80e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -59,7 +59,7 @@ fun FramedItemView( style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface), linkMode = linkMode, uriHandler = if (appPlatform.isDesktop) uriHandler else null, - showTimestamp = showTimestamp + showTimestamp = showTimestamp, ) } @@ -76,7 +76,7 @@ fun FramedItemView( ) { Text( sender, - style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary), + style = TextStyle(fontSize = 13.5.sp, color = if (qi.chatDir is CIDirection.GroupSnd) CurrentColors.value.colors.primary else CurrentColors.value.colors.secondary), maxLines = 1 ) ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp) @@ -176,7 +176,7 @@ fun FramedItemView( fun ciFileView(ci: ChatItem, text: String) { CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile) if (text != "" || ci.meta.isLive) { - CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } @@ -285,7 +285,7 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCVideo -> { @@ -293,26 +293,26 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCVoice -> { CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) if (mc.text != "") { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCFile -> ciFileView(ci, mc.text) is MsgContent.MCUnknown -> if (ci.file == null) { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } else { ciFileView(ci, mc.text) } is MsgContent.MCLink -> { ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true }) Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCReport -> { @@ -321,9 +321,9 @@ fun FramedItemView( append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") } } - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) } - else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + else -> CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } } @@ -344,6 +344,7 @@ fun FramedItemView( @Composable fun CIMarkdownText( ci: ChatItem, + chatInfo: ChatInfo, chatTTL: Int?, linkMode: SimplexLinkMode, uriHandler: UriHandler?, @@ -357,6 +358,10 @@ fun CIMarkdownText( MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, + mentions = ci.mentions, userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + }, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 257ede7d4a..ad11eb4897 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.sp @@ -22,7 +21,6 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.views.helpers.* import kotlinx.coroutines.* -import java.awt.* val reserveTimestampStyle = SpanStyle(color = Color.Transparent) val boldFont = SpanStyle(fontWeight = FontWeight.Medium) @@ -60,6 +58,8 @@ fun MarkdownText ( sender: String? = null, meta: CIMeta? = null, chatTTL: Int? = null, + mentions: Map? = null, + userMemberId: String? = null, toggleSecrets: Boolean, style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp), maxLines: Int = Int.MAX_VALUE, @@ -148,6 +148,26 @@ fun MarkdownText ( withAnnotation(tag = "SECRET", annotation = key) { if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } } + } else if (ft.format is Format.Mention) { + val mention = mentions?.get(ft.format.memberName) + + if (mention != null) { + if (mention.memberRef != null) { + val displayName = mention.memberRef.displayName + val name = if (mention.memberRef.localAlias.isNullOrEmpty()) { + displayName + } else { + "${mention.memberRef.localAlias} ($displayName)" + } + val mentionStyle = if (mention.memberId == userMemberId) ft.format.style.copy(color = MaterialTheme.colors.primary) else ft.format.style + + withStyle(mentionStyle) { append(mentionText(name)) } + } else { + withStyle( ft.format.style) { append(mentionText(ft.format.memberName)) } + } + } else { + append(ft.text) + } } else { val link = ft.link(linkMode) if (link != null) { @@ -291,3 +311,5 @@ private fun isRtl(s: CharSequence): Boolean { } return false } + +private fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 3ba15bc79c..8e01c2b4bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -244,6 +244,7 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo } chatModel.groupMembersIndexes.value = emptyMap() chatModel.groupMembers.value = newMembers + chatModel.membersLoaded.value = true chatModel.populateGroupMembersIndexes() } @@ -256,7 +257,7 @@ fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMen MarkUnreadChatAction(chat, chatModel, showMenu) } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) - ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + ToggleNotificationsChatAction(chat, chatModel, contact.chatSettings.enableNtfs.nextMode(false), showMenu) TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) } @@ -296,7 +297,7 @@ fun GroupMenuItems( MarkUnreadChatAction(chat, chatModel, showMenu) } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) - ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + ToggleNotificationsChatAction(chat, chatModel, groupInfo.chatSettings.enableNtfs.nextMode(true), showMenu) TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) if (groupInfo.membership.memberCurrent) { @@ -379,12 +380,12 @@ fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolea } @Composable -fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState) { +fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, nextMsgFilter: MsgFilter, showMenu: MutableState) { ItemAction( - if (ntfsEnabled) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), - if (ntfsEnabled) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), + generalGetString(nextMsgFilter.text(chat.chatInfo.hasMentions)), + painterResource(nextMsgFilter.icon), onClick = { - toggleNotifications(chat.remoteHostId, chat.chatInfo, !ntfsEnabled, chatModel) + toggleNotifications(chat.remoteHostId, chat.chatInfo, nextMsgFilter, chatModel) showMenu.value = false } ) @@ -830,8 +831,8 @@ fun groupInvitationAcceptedAlert(rhId: Long?) { ) } -fun toggleNotifications(remoteHostId: Long?, chatInfo: ChatInfo, enableAllNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) { - val chatSettings = (chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = if (enableAllNtfs) MsgFilter.All else MsgFilter.None) +fun toggleNotifications(remoteHostId: Long?, chatInfo: ChatInfo, filter: MsgFilter, chatModel: ChatModel, currentState: MutableState? = null) { + val chatSettings = (chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = filter) updateChatSettings(remoteHostId, chatInfo, chatSettings, chatModel, currentState) } @@ -840,7 +841,7 @@ fun toggleChatFavorite(remoteHostId: Long?, chatInfo: ChatInfo, favorite: Boolea updateChatSettings(remoteHostId, chatInfo, chatSettings, chatModel) } -fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: ChatSettings, chatModel: ChatModel, currentState: MutableState? = null) { +fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: ChatSettings, chatModel: ChatModel, currentState: MutableState? = null) { val newChatInfo = when(chatInfo) { is ChatInfo.Direct -> with (chatInfo) { ChatInfo.Direct(contact.copy(chatSettings = chatSettings)) @@ -868,7 +869,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch withChats { updateChatInfo(remoteHostId, newChatInfo) } - if (chatSettings.enableNtfs != MsgFilter.All) { + if (chatSettings.enableNtfs == MsgFilter.None) { ntfManager.cancelNotificationsForChat(chatInfo.id) } val updatedChat = chatModel.getChat(chatInfo.id) @@ -879,7 +880,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch } val current = currentState?.value if (current != null) { - currentState.value = !current + currentState.value = chatSettings.enableNtfs } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 3205a084f0..2375e26dd4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1219,7 +1219,7 @@ private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean = when (activeFilter) { is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo, chat.chatStats) is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false - is ActiveFilter.Unread -> chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 + is ActiveFilter.Unread -> chat.unreadTag else -> true } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 63a0d6fc7f..d379131171 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -19,7 +19,6 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontStyle @@ -176,7 +175,7 @@ fun ChatPreviewView( if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) { val sp20 = with(LocalDensity.current) { 20.sp.toDp() } val (text: CharSequence, inlineTextContent) = when { - chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) } + chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message.text to messageDraft(chatModelDraft, sp20) } ci.meta.itemDeleted == null -> ci.text to null else -> markedDeletedText(ci, chat.chatInfo) to null } @@ -203,6 +202,11 @@ fun ChatPreviewView( cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName else -> null }, + mentions = ci.mentions, + userMemberId = when { + cInfo is ChatInfo.Group -> cInfo.groupInfo.membership.memberId + else -> null + }, toggleSecrets = false, linkMode = linkMode, senderBold = true, @@ -426,23 +430,56 @@ fun ChatPreviewView( Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { val n = chat.chatStats.unreadCount - val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) + val ntfsMode = chat.chatInfo.chatSettings?.enableNtfs + val showNtfsIcon = !chat.chatInfo.ntfsEnabled(false) && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) if (n > 0 || chat.chatStats.unreadChat) { - Text( - if (n > 0) unreadCountStr(n) else "", - color = Color.White, - fontSize = 10.sp, - style = TextStyle(textAlign = TextAlign.Center), - modifier = Modifier - .offset(y = 3.sp.toDp()) - .background(if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape) - .badgeLayout() - .padding(horizontal = 2.sp.toDp()) - .padding(vertical = 1.sp.toDp()) - ) - } else if (showNtfsIcon) { + val unreadMentions = chat.chatStats.unreadMentions + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.sp.toDp())) { + val mentionColor = when { + disabled -> MaterialTheme.colors.secondary + cInfo is ChatInfo.Group -> { + val enableNtfs = cInfo.groupInfo.chatSettings.enableNtfs + if (enableNtfs == MsgFilter.All || enableNtfs == MsgFilter.Mentions) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary + } + + else -> if (showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant + } + if (unreadMentions > 0 && n > 1) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = mentionColor, + modifier = Modifier.size(12.sp.toDp()).offset(y = 3.sp.toDp()) + ) + } + + if (unreadMentions > 0 && n == 1) { + Box(modifier = Modifier.offset(y = 2.sp.toDp()).size(15.sp.toDp()).background(mentionColor, shape = CircleShape), contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = Color.White, + modifier = Modifier.size(9.sp.toDp()) + ) + } + } else { + Text( + if (n > 0) unreadCountStr(n) else "", + color = Color.White, + fontSize = 10.sp, + style = TextStyle(textAlign = TextAlign.Center), + modifier = Modifier + .offset(y = 3.sp.toDp()) + .background(if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 2.sp.toDp()) + .padding(vertical = 1.sp.toDp()) + ) + } + } + } else if (showNtfsIcon && ntfsMode != null) { Icon( - painterResource(MR.images.ic_notifications_off_filled), + painterResource(ntfsMode.iconFilled), contentDescription = generalGetString(MR.strings.notifications), tint = MaterialTheme.colors.secondary, modifier = Modifier @@ -470,11 +507,6 @@ fun ChatPreviewView( } } } - - val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) } - if (deleting) { - DefaultProgressView(description = null) - } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt index 4fdbd97d23..078cdde9da 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt @@ -9,3 +9,5 @@ fun newChatSheetAnimSpec() = tween(256, 0, LinearEasing) fun audioProgressBarAnimationSpec() = tween(durationMillis = 30, easing = LinearEasing) fun userPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) + +fun mentionPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) 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 d3256e285c..5441f0816a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -679,9 +679,11 @@ Mute + Mute all Unmute Favorite Unfavorite + Unread mentions Create list @@ -2555,4 +2557,7 @@ Download errors Server address Open server settings + + + You can mention up to %1$s members per message! \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg new file mode 100644 index 0000000000..39007022b8 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg new file mode 100644 index 0000000000..37c79840d7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg new file mode 100644 index 0000000000..75f247a21e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index e7bcf4802a..d0d4fb5e92 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -10,8 +10,7 @@ import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.* @@ -51,19 +50,21 @@ actual fun PlatformTextField( userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester?, onDone: () -> Unit, ) { + val cs = composeState.value - val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current + val focusReq = focusRequester ?: remember { FocusRequester() } LaunchedEffect(cs.contextItem) { if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect // In replying state - focusRequester.requestFocus() + focusReq.requestFocus() delay(50) keyboard?.show() } @@ -74,9 +75,9 @@ actual fun PlatformTextField( keyboard?.hide() } } - val lastTimeWasRtlByCharacters = remember { mutableStateOf(isRtl(cs.message.subSequence(0, min(50, cs.message.length)))) } + val lastTimeWasRtlByCharacters = remember { mutableStateOf(isRtl(cs.message.text.subSequence(0, min(50, cs.message.text.length)))) } val isRtlByCharacters = remember(cs.message) { - if (cs.message.isNotEmpty()) isRtl(cs.message.subSequence(0, min(50, cs.message.length))) else lastTimeWasRtlByCharacters.value + if (cs.message.text.isNotEmpty()) isRtl(cs.message.text.subSequence(0, min(50, cs.message.text.length))) else lastTimeWasRtlByCharacters.value } LaunchedEffect(isRtlByCharacters) { lastTimeWasRtlByCharacters.value = isRtlByCharacters @@ -84,12 +85,12 @@ actual fun PlatformTextField( val isLtrGlobally = LocalLayoutDirection.current == LayoutDirection.Ltr // Different padding here is for a text that is considered RTL with non-RTL locale set globally. // In this case padding from right side should be bigger - val startEndPadding = if (cs.message.isEmpty() && showVoiceButton && isRtlByCharacters && isLtrGlobally) 95.dp else 50.dp + val startEndPadding = if (cs.message.text.isEmpty() && showVoiceButton && isRtlByCharacters && isLtrGlobally) 95.dp else 50.dp val startPadding = if (isRtlByCharacters && isLtrGlobally) startEndPadding else 0.dp val endPadding = if (isRtlByCharacters && isLtrGlobally) 0.dp else startEndPadding val padding = PaddingValues(startPadding, 12.dp, endPadding, 0.dp) - var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message)) } - val textFieldValue = textFieldValueState.copy(text = cs.message) + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message.text, selection = cs.message.selection)) } + val textFieldValue = textFieldValueState.copy(text = cs.message.text, selection = cs.message.selection) val clipboard = LocalClipboardManager.current BasicTextField( value = textFieldValue, @@ -105,7 +106,7 @@ actual fun PlatformTextField( } } textFieldValueState = it - onMessageChange(it.text) + onMessageChange(ComposeMessage(it.text, it.selection)) } }, textStyle = textStyle.value, @@ -118,7 +119,7 @@ actual fun PlatformTextField( .padding(start = startPadding, end = endPadding) .offset(y = (-5).dp) .fillMaxWidth() - .focusRequester(focusRequester) + .focusRequester(focusReq) .onPreviewKeyEvent { if ((it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyDown) { if (it.isShiftPressed) { @@ -129,12 +130,12 @@ actual fun PlatformTextField( text = newText, selection = TextRange(textFieldValue.selection.min + 1) ) - onMessageChange(newText) + onMessageChange(ComposeMessage(newText, textFieldValueState.selection)) } else if (!sendMsgButtonDisabled) { onDone() } true - } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.isEmpty()) { + } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.text.isEmpty()) { onUpArrow() true } else if (it.key == Key.V && @@ -166,7 +167,7 @@ actual fun PlatformTextField( chatModel.filesToDelete.add(tempFile) tempFile.writeBytes(bytes) - composeState.processPickedMedia(listOf(tempFile.toURI()), composeState.value.message) + composeState.processPickedMedia(listOf(tempFile.toURI()), composeState.value.message.text) } } catch (e: Exception) { Log.e(TAG, "Pasting image exception: ${e.stackTraceToString()}") @@ -200,7 +201,7 @@ actual fun PlatformTextField( } } ) - showDeleteTextButton.value = cs.message.split("\n").size >= 4 && !cs.inProgress + showDeleteTextButton.value = cs.message.text.split("\n").size >= 4 && !cs.inProgress if (composeState.value.preview is ComposePreview.VoicePreview) { ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) } else if (userIsObserver) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt index 3f5703365d..7a2a1dff0a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt @@ -111,7 +111,9 @@ actual fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State?, additionalTopBar: State, chatBottomBar: State, - content: LazyListScope.() -> Unit + maxHeight: State?, + containerAlignment: Alignment, + content: LazyListScope.() -> Unit, ) { val scope = rememberCoroutineScope() val scrollBarAlpha = remember { Animatable(0f) } @@ -135,9 +137,11 @@ actual fun LazyColumnWithScrollBarNoAppBar( // When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state // (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row) val scrollBarDraggingState = remember { mutableStateOf(false) } - Box { + Box(contentAlignment = containerAlignment) { LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) - ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar) + Box(if (maxHeight?.value != null) Modifier.height(maxHeight.value).fillMaxWidth() else Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) { + DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout, scrollBarDraggingState) + } } }