android, desktop: group member mentions (#5574)

* initial wip

* initial parser

* limit mentions

* wip types and ohter changes

* small animation

* better limit

* show mentioned member when mention is in selectable area

* better space handling

* animation working

* changes

* auto tagging

* centralize state

* focus in desktop fix

* close picker on click outside

* use profile display name, avoid local

* show box with max number of mentions

* scrollbar in group mentions desktop

* sending and displaying mentions in views based on latest core code

* latest types and updates new api

* desktop selection area fix

* show mentions correctly

* new notifications UI changes

* local alias support

* mention notifications working

* mentions markdown changes

* fix notifications

* Revert "fix notifications"

This reverts commit 59643c24725d3caee3c629df6732f4b5bc294f8f.

* simple cleanup

* mentions in info view

* refactor/renames

* show member name to replies of my messages as primary

* show local alias and display name for mentions

* show 4 rows and almost all of 5th as picker max height

* only call list members api on new @ and searchn in all names

* fix

* correction

* fixes

* unread mentions chat stats

* unread indication in chat

* filtering of unread

* show @ in chat previews

* @ style

* alone @

* forgotten change

* deleted

* remove whitespace

* fix to make clear chat mark tags red

* comments changes

* @ as icon to avoid issues

* change

* simplify like ios

* renames

* wip using haskell parser

* show mention name containing @ in quotes

* cleanup and position of cursor after replace

* move

* show selected tick and edits working

* cimention in map

* eol

* text selection

* refactor

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
This commit is contained in:
Diogo
2025-02-03 18:05:40 +00:00
committed by GitHub
parent 82dffd55a9
commit 760ea17fb9
29 changed files with 830 additions and 225 deletions
@@ -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<URI>) -> 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()
@@ -95,8 +95,10 @@ actual fun LazyColumnWithScrollBarNoAppBar(
additionalBarOffset: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
maxHeight: State<Dp>?,
containerAlignment: Alignment,
content: LazyListScope.() -> Unit
) {
) {
val state = state ?: rememberLazyListState()
LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled) {
content()
@@ -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<List<Pair<Long?, String>>>(emptyList())
val groupMembers = mutableStateOf<List<GroupMember>>(emptyList())
val groupMembersIndexes = mutableStateOf<Map<Long, Int>>(emptyMap())
val membersLoaded = mutableStateOf(false)
// Chat Tags
val userTags = mutableStateOf(emptyList<ChatTag>())
@@ -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<Long>? = 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<Long>? = null): Int {
private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List<Long>? = null): Pair<Int, Int> {
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<FormattedText>? = null,
val mentions: Map<String, CIMention>? = null,
val quotedItem: CIQuote? = null,
val reactions: List<CIReactionCount>,
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>): String =
"${linkType.description} (${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})"
companion object {
fun plain(text: String): List<FormattedText> = 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<String>): 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
}
@@ -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<Long>): CC()
class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List<ComposedMessage>): 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<Long>, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List<Long>): 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<String, Long>)
@Serializable
class UpdatedMessage(val msgContent: MsgContent, val mentions: Map<String, Long>) {
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
@@ -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 ||
@@ -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<URI>) -> Unit,
focusRequester: FocusRequester? = null,
onDone: () -> Unit,
)
@@ -45,6 +45,8 @@ expect fun LazyColumnWithScrollBarNoAppBar(
additionalBarOffset: State<Dp>? = null,
additionalTopBar: State<Boolean> = remember { mutableStateOf(false) },
chatBottomBar: State<Boolean> = remember { mutableStateOf(true) },
maxHeight: State<Dp>? = null,
containerAlignment: Alignment = Alignment.TopStart,
content: LazyListScope.() -> Unit
)
@@ -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<Compose
val developerTools = chatModel.controller.appPrefs.developerTools.get()
val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
val s = composeState.value.message
if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
if (s.text.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
val resp = CR.ChatCmdError(null, ChatError.ChatErrorChat(ChatErrorType.CommandError("Failed reading: empty")))
chatModel.addTerminalItem(TerminalItem.cmd(null, CC.Console(s)))
chatModel.addTerminalItem(TerminalItem.cmd(null, CC.Console(s.text)))
chatModel.addTerminalItem(TerminalItem.resp(null, resp))
composeState.value = ComposeState(useLinkPreviews = false)
} else {
withBGApi {
// show "in progress"
// TODO show active remote host in chat console?
chatModel.controller.sendCmd(chatModel.remoteHostId(), CC.Console(s))
chatModel.controller.sendCmd(chatModel.remoteHostId(), CC.Console(s.text))
composeState.value = ComposeState(useLinkPreviews = false)
// hide "in progress"
}
@@ -70,7 +72,7 @@ fun TerminalLayout(
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
fun onMessageChange(s: String) {
fun onMessageChange(s: ComposeMessage) {
composeState.value = composeState.value.copy(message = s)
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
@@ -111,7 +113,8 @@ fun TerminalLayout(
editPrevMessage = {},
onMessageChange = ::onMessageChange,
onFilesPasted = {},
textStyle = textStyle
textStyle = textStyle,
focusRequester = remember { FocusRequester() }
)
}
}
@@ -837,17 +837,18 @@ fun MuteButton(
chat: Chat,
contact: Contact
) {
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
val enableNtfs = remember { mutableStateOf(contact.chatSettings.enableNtfs ) }
val nextNtfMode by remember { derivedStateOf { enableNtfs.value.nextMode(false) } }
val disabled = !contact.ready || !contact.active
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(nextNtfMode.icon),
title = stringResource(nextNtfMode.text(false)),
disabled = disabled,
disabledLook = disabled,
onClick = {
toggleNotifications(chat.remoteHostId, chat.chatInfo, !ntfsEnabled.value, chatModel, ntfsEnabled)
toggleNotifications(chat.remoteHostId, chat.chatInfo, nextNtfMode, chatModel, enableNtfs)
}
)
}
@@ -42,18 +42,20 @@ sealed class CIInfoTab {
}
@Composable
fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean, chatInfo: ChatInfo?) {
val sent = ci.chatDir.sent
val appColors = MaterialTheme.appColors
val uriHandler = LocalUriHandler.current
val selection = remember { mutableStateOf<CIInfoTab>(CIInfoTab.History) }
@Composable
fun TextBubble(text: String, formattedText: List<FormattedText>?, sender: String?, showMenu: MutableState<Boolean>) {
fun TextBubble(text: String, formattedText: List<FormattedText>?, sender: String?, showMenu: MutableState<Boolean>, mentions: Map<String, CIMention>? = 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)) {
@@ -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<ChatInfo?>,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
composeView: (@Composable (FocusRequester?) -> Unit),
scrollToItemId: MutableState<Long?>,
attachmentOption: MutableState<AttachmentOption?>,
attachmentBottomSheetState: ModalBottomSheetState,
@@ -690,7 +694,7 @@ fun ChatLayout(
openGroupLink: (GroupInfo) -> Unit,
markItemsRead: (List<Long>) -> Unit,
markChatRead: () -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
changeNtfsState: (MsgFilter, currentValue: MutableState<MsgFilter>) -> 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<Boolean>) -> Unit,
changeNtfsState: (MsgFilter, currentValue: MutableState<MsgFilter>) -> Unit,
onSearchValueChanged: (String) -> Unit,
showSearch: MutableState<Boolean>
) {
@@ -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<AttachmentOption?>(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<AttachmentOption?>(null) },
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
@@ -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<String, CIMention>
@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<TextRange> {
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<FormattedText> = 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<String, Long>
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<MutableState<ComposeState>, *> = 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<ComposeState>.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<ComposeState>.processPickedMedia(uris: List<URI>, 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<ComposeState>,
attachmentOption: MutableState<AttachmentOption?>,
showChooseAttachment: () -> Unit
showChooseAttachment: () -> Unit,
focusRequester: FocusRequester?,
) {
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
fun isSimplexLink(link: String): Boolean =
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
fun parseMessage(msg: String): Pair<String?, Boolean> {
if (msg.isBlank()) return null to false
val parsedMsg = parseToMarkdown(msg) ?: return null to false
fun getSimplexLink(parsedMsg: List<FormattedText>?): Pair<String?, Boolean> {
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<String?>(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<String?>(null) }
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(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<RecordingState> = 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<FormattedText>?) {
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<String, Long>): 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<ChatItem>?
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,
)
}
}
@@ -31,7 +31,7 @@ fun ContextItemView(
contextItems: List<ChatItem>,
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
) {}
}
}
@@ -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()
@@ -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<URI>) -> Unit,
onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle>
) {
onMessageChange: (ComposeMessage) -> Unit,
textStyle: MutableState<TextStyle>,
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<ComposeState>) {
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,
)
}
}
@@ -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<Long?>, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> 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,
)
}
}
}
@@ -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<ComposeState>,
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<TextRange?>(null) }
val mentionMemberId = remember { mutableStateOf<String?>(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<FormattedText>) {
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<CharSequence, IntRange>? {
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<FormattedText>,
range: TextRange
): Pair<FormattedText, TextRange>? {
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<ComposeState>, parsedMsg: List<FormattedText>) {
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 }
)
}
}
@@ -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
)
}
@@ -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<String, CIMention>? = 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"
@@ -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<Boolean>) {
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, nextMsgFilter: MsgFilter, showMenu: MutableState<Boolean>) {
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<Boolean>? = 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<MsgFilter>? = 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<Boolean>? = null) {
fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: ChatSettings, chatModel: ChatModel, currentState: MutableState<MsgFilter>? = 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
}
}
}
@@ -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
}
@@ -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)
}
}
}
@@ -9,3 +9,5 @@ fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
fun <T> userPickerAnimSpec() = tween<T>(256, 0, FastOutSlowInEasing)
fun <T> mentionPickerAnimSpec() = tween<T>(256, 0, FastOutSlowInEasing)
@@ -679,9 +679,11 @@
<!-- Actions - ChatListNavLinkView.kt -->
<string name="mute_chat">Mute</string>
<string name="mute_all_chat">Mute all</string>
<string name="unmute_chat">Unmute</string>
<string name="favorite_chat">Favorite</string>
<string name="unfavorite_chat">Unfavorite</string>
<string name="unread_mentions">Unread mentions</string>
<!-- Tags - ChatListNavLinkView.kt -->
<string name="create_list">Create list</string>
@@ -2555,4 +2557,7 @@
<string name="download_errors">Download errors</string>
<string name="server_address">Server address</string>
<string name="open_server_settings_button">Open server settings</string>
<!-- GroupMentions.kt -->
<string name="max_group_mentions_per_message_reached">You can mention up to %1$s members per message!</string>
</resources>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M480-90q-80.91 0-152.07-30.76-71.15-30.77-123.79-83.5Q151.5-257 120.75-328.09 90-399.17 90-480q0-80.91 30.76-152.07 30.77-71.15 83.5-123.79Q257-808.5 328.09-839.25 399.17-870 480-870q80.91 0 152.07 30.76 71.15 30.77 123.79 83.5Q808.5-703 839.25-631.91 870-560.83 870-480v55.97q0 56.93-39.5 96.98Q791-287 734-287q-34.5 0-64.5-15.25T619-345q-27.5 28.5-63.75 43.25T480.14-287q-80.64 0-136.89-56.25Q287-399.5 287-480t56.25-136.75Q399.5-673 480-673t136.75 56.25Q673-560.5 673-480.15V-424q0 26 17.5 44t43.5 18q26 0 43.5-18t17.5-44v-56q0-131.5-91.75-223.25T480-795q-131.5 0-223.25 91.75T165-480q0 131.5 91.75 223.25T480-165h159.5q15.5 0 26.5 11t11 26.5q0 15.5-11 26.5t-26.5 11H480Zm-.12-272q49.12 0 83.62-34.38 34.5-34.38 34.5-83.5t-34.38-83.62q-34.38-34.5-83.5-34.5t-83.62 34.38q-34.5 34.38-34.5 83.5t34.38 83.62q34.38 34.5 83.5 34.5Z"/></svg>

After

Width:  |  Height:  |  Size: 934 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M204-207.5q-15.5 0-26.5-11t-11-26.5q0-15.5 11-26.5t26.5-11h37.5v-271q0-83 50.25-147.5T422-786v-26q0-23.96 16.88-40.73 16.88-16.77 41-16.77T521-852.73q17 16.77 17 40.73v26q80 20.5 130.25 85t50.25 147.5v271H756q15.5 0 26.5 11t11 26.5q0 15.5-11 26.5t-26.5 11H204ZM480-500Zm0 408q-32.5 0-55.25-22.75T402-170h156q0 32.5-22.75 55.25T480-92ZM316.5-282.5h327v-271q0-67.44-48.04-115.47T479.96-717q-67.46 0-115.46 48.03t-48 115.47v271ZM480-437q15.5 0 26.5-11t11-26.5v-123q0-15.5-11-26.5T480-635q-15.5 0-26.5 11t-11 26.5v123q0 15.5 11 26.5t26.5 11Zm-.11 114Q496-323 507-333.89q11-10.9 11-27Q518-377 507.11-388q-10.9-11-27-11Q464-399 453-388.11q-11 10.9-11 27Q442-345 452.89-334q10.9 11 27 11Z"/></svg>

After

Width:  |  Height:  |  Size: 787 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M204-207.5q-15.5 0-26.5-11t-11-26.5q0-15.5 11-26.5t26.5-11h37.5v-271q0-83 50.25-147.5T422-786v-26q0-23.96 16.88-40.73 16.88-16.77 41-16.77T521-852.73q17 16.77 17 40.73v26q80 20.5 130.25 85t50.25 147.5v271H756q15.5 0 26.5 11t11 26.5q0 15.5-11 26.5t-26.5 11H204ZM480-92q-32.5 0-55.25-22.75T402-170h156q0 32.5-22.75 55.25T480-92Zm0-345q15.5 0 26.5-11t11-26.5v-123q0-15.5-11-26.5T480-635q-15.5 0-26.5 11t-11 26.5v123q0 15.5 11 26.5t26.5 11Zm-.11 114Q496-323 507-333.89q11-10.9 11-27Q518-377 507.11-388q-10.9-11-27-11Q464-399 453-388.11q-11 10.9-11 27Q442-345 452.89-334q10.9 11 27 11Z"/></svg>

After

Width:  |  Height:  |  Size: 686 B

@@ -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<URI>) -> 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) {
@@ -111,7 +111,9 @@ actual fun LazyColumnWithScrollBarNoAppBar(
additionalBarOffset: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
content: LazyListScope.() -> Unit
maxHeight: State<Dp>?,
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)
}
}
}