mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-14 21:15:37 +00:00
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:
+18
-9
@@ -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()
|
||||
|
||||
+3
-1
@@ -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()
|
||||
|
||||
+87
-23
@@ -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
|
||||
}
|
||||
|
||||
+47
-9
@@ -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
|
||||
|
||||
+1
-1
@@ -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 ||
|
||||
|
||||
+6
-3
@@ -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,
|
||||
)
|
||||
|
||||
+2
@@ -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
|
||||
)
|
||||
|
||||
|
||||
+8
-5
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+10
-3
@@ -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)) {
|
||||
|
||||
+36
-15
@@ -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),
|
||||
|
||||
+107
-52
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -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
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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()
|
||||
|
||||
+14
-11
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-20
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+298
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
-10
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+24
-2
@@ -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"
|
||||
|
||||
+12
-11
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+54
-22
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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 |
+1
@@ -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 |
+1
@@ -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 |
+17
-16
@@ -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) {
|
||||
|
||||
+7
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user