Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2024-08-14 20:30:44 +01:00
60 changed files with 751 additions and 274 deletions
@@ -19,6 +19,7 @@ import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.*
import chat.simplex.common.platform.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import java.lang.ref.WeakReference
@@ -149,7 +150,12 @@ fun processIntent(intent: Intent?) {
"android.intent.action.VIEW" -> {
val uri = intent.data
if (uri != null) {
chatModel.appOpenUrl.value = null to uri.toURI()
val transformedUri = uri.toURIOrNull()
if (transformedUri != null) {
chatModel.appOpenUrl.value = null to transformedUri
} else {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_parsing_uri_title), generalGetString(MR.strings.error_parsing_uri_desc))
}
}
}
}
@@ -18,5 +18,6 @@ val NotificationsMode.requiresIgnoringBattery
lateinit var APPLICATION_ID: String
fun Uri.toURI(): URI = URI(toString().replace("\n", ""))
fun Uri.toURIOrNull(): URI? = try { toURI() } catch (e: Exception) { null }
fun URI.toUri(): Uri = Uri.parse(toString())
@@ -15,13 +15,14 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.DrawableCompat
@@ -35,11 +36,12 @@ import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.SharedContent
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import java.lang.reflect.Field
import java.net.URI
@@ -52,6 +54,7 @@ actual fun PlatformTextField(
showDeleteTextButton: MutableState<Boolean>,
userIsObserver: Boolean,
placeholder: String,
showVoiceButton: Boolean,
onMessageChange: (String) -> Unit,
onUpArrow: () -> Unit,
onFilesPasted: (List<URI>) -> Unit,
@@ -82,7 +85,15 @@ actual fun PlatformTextField(
freeFocus = true
}
}
LaunchedEffect(Unit) {
snapshotFlow { ModalManager.start.modalCount.value }
.filter { it > 0 }
.collect {
freeFocus = true
}
}
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
AndroidView(modifier = Modifier, factory = {
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
override fun setOnReceiveContentListener(
@@ -113,7 +124,8 @@ actual fun PlatformTextField(
editText.setTextColor(textColor.toArgb())
editText.textSize = textStyle.value.fontSize.value * appPrefs.fontScale.get()
editText.background = ColorDrawable(Color.Transparent.toArgb())
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
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.hint = placeholder
editText.setHintTextColor(hintColor.toArgb())
@@ -241,10 +241,15 @@ private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int,
}
actual fun getFileName(uri: URI): String? {
return androidAppContext.contentResolver.query(uri.toUri(), null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
cursor.getString(nameIndex)
return try {
androidAppContext.contentResolver.query(uri.toUri(), null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
// Can make an exception
cursor.getString(nameIndex)
}
} catch (e: Exception) {
null
}
}
@@ -333,7 +333,7 @@ fun StartPartOfScreen(settingsState: SettingsViewState) {
fun CenterPartOfScreen() {
val currentChatId = remember { ChatModel.chatId }
LaunchedEffect(Unit) {
snapshotFlow { currentChatId }
snapshotFlow { currentChatId.value }
.distinctUntilChanged()
.collect {
if (it != null) {
@@ -275,6 +275,11 @@ object ChatModel {
}
}
suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) {
// mark chat non deleted
if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) {
val updatedContact = cInfo.contact.copy(chatDeleted = false)
updateContact(rhId, updatedContact)
}
// update previews
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
@@ -879,6 +884,11 @@ interface NamedChat {
val localAlias: String
val chatViewName: String
get() = localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
fun anyNameContains(searchAnyCase: String): Boolean {
val s = searchAnyCase.trim().lowercase()
return chatViewName.lowercase().contains(s) || displayName.lowercase().contains(s) || fullName.lowercase().contains(s)
}
}
interface SomeChat {
@@ -1482,21 +1492,23 @@ data class GroupMember (
val memberContactId: Long? = null,
val memberContactProfileId: Long,
var activeConn: Connection? = null
) {
): NamedChat {
val id: String get() = "#$groupId @$groupMemberId"
val displayName: String
override val displayName: String
get() {
val p = memberProfile
val name = p.localAlias.ifEmpty { p.displayName }
return pastMember(name)
}
val fullName: String get() = memberProfile.fullName
val image: String? get() = memberProfile.image
override val fullName: String get() = memberProfile.fullName
override val image: String? get() = memberProfile.image
val contactLink: String? = memberProfile.contactLink
val verified get() = activeConn?.connectionCode != null
val blocked get() = blockedByAdmin || !memberSettings.showMessages
val chatViewName: String
override val localAlias: String = memberProfile.localAlias
override val chatViewName: String
get() {
val p = memberProfile
val name = p.localAlias.ifEmpty { p.displayName + (if (p.fullName == "" || p.fullName == p.displayName) "" else " / ${p.fullName}") }
@@ -2136,12 +2136,6 @@ object ChatController {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
if (active(r.user)) {
if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) {
val updatedContact = cInfo.contact.copy(chatDeleted = false)
withChats {
updateContact(rhId, updatedContact)
}
}
withChats {
addChatItem(rhId, cInfo, cItem)
}
@@ -2529,6 +2523,8 @@ object ChatController {
ModalManager.fullscreen.closeModals()
fun showAlert(chatError: ChatError) {
when {
r.rcStopReason is RemoteCtrlStopReason.Disconnected ->
{}
r.rcStopReason is RemoteCtrlStopReason.ConnectionFailed
&& r.rcStopReason.chatError is ChatError.ChatErrorAgent
&& r.rcStopReason.chatError.agentError is AgentErrorType.RCP
@@ -15,6 +15,7 @@ expect fun PlatformTextField(
showDeleteTextButton: MutableState<Boolean>,
userIsObserver: Boolean,
placeholder: String,
showVoiceButton: Boolean,
onMessageChange: (String) -> Unit,
onUpArrow: () -> Unit,
onFilesPasted: (List<URI>) -> Unit,
@@ -10,7 +10,6 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.drawWithCache
@@ -49,26 +48,17 @@ import kotlin.math.sign
// staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat
// to chat list smooth. Otherwise, chat view will become blank right before the transition starts
fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -> Unit) {
val shouldReturn = remember { mutableStateOf(false) }
val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } }
val showSearch = rememberSaveable { mutableStateOf(false) }
val activeChatInfo = remember {
derivedStateOf {
val info = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo
if (info == null) {
shouldReturn.value = true
}
return@derivedStateOf info ?: ChatInfo.Direct.sampleData
}
}
val activeChatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo } }
val user = chatModel.currentUser.value
if (shouldReturn.value || user == null) {
val chatInfo = activeChatInfo.value
if (chatInfo == null || user == null) {
LaunchedEffect(Unit) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
}
} else {
val chatInfo = activeChatInfo.value
val searchText = rememberSaveable { mutableStateOf("") }
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val composeState = rememberSaveable(saver = ComposeState.saver()) {
@@ -96,21 +86,17 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
.collect { chatId ->
markUnreadChatAsRead(chatId)
showSearch.value = false
selectedChatItems.value = null
}
}
}
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
selectedChatItems.value = null
}
}
val view = LocalMultiplatformView()
val chatRh = remoteHostId.value
// We need to have real unreadCount value for displaying it inside top right button
// Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember {
derivedStateOf {
chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == activeChatInfo.value?.id }?.chatStats?.unreadCount ?: 0
}
}
val clipboard = LocalClipboardManager.current
@@ -267,9 +253,9 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
}
}
},
loadPrevMessages = {
if (chatModel.chatId.value != activeChatInfo.value.id) return@ChatLayout
val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout)
loadPrevMessages = { chatId ->
val c = chatModel.getChat(chatId)
if (chatModel.chatId.value != chatId) return@ChatLayout
val firstId = chatModel.chatItems.value.firstOrNull()?.id
if (c != null && firstId != null) {
withBGApi {
@@ -279,7 +265,6 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
},
deleteMessage = { itemId, mode ->
withBGApi {
val cInfo = chatInfo
val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId }
val toModerate = toDeleteItem?.memberToModerate(chatInfo)
val groupInfo = toModerate?.first
@@ -295,8 +280,8 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
} else {
chatModel.controller.apiDeleteChatItems(
chatRh,
type = cInfo.chatType,
id = cInfo.apiId,
type = chatInfo.chatType,
id = chatInfo.apiId,
itemIds = listOf(itemId),
mode = mode
)
@@ -307,9 +292,9 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
toChatItem = deleted.toChatItem?.chatItem
withChats {
if (toChatItem != null) {
upsertChatItem(chatRh, cInfo, toChatItem)
upsertChatItem(chatRh, chatInfo, toChatItem)
} else {
removeChatItem(chatRh, cInfo, deletedChatItem)
removeChatItem(chatRh, chatInfo, deletedChatItem)
}
}
}
@@ -472,7 +457,10 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
markRead = { range, unreadCountAfter ->
withBGApi {
withChats {
markChatItemsRead(chatRh, chatInfo, range, unreadCountAfter)
// It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace
withContext(Dispatchers.Main) {
markChatItemsRead(chatRh, chatInfo, range, unreadCountAfter)
}
ntfManager.cancelNotificationsForChat(chatInfo.id)
chatModel.controller.apiChatRead(
chatRh,
@@ -486,8 +474,8 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) },
onSearchValueChanged = { value ->
if (searchText.value == value) return@ChatLayout
if (chatModel.chatId.value != activeChatInfo.value.id) return@ChatLayout
val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) ?: return@ChatLayout
val c = chatModel.getChat(chatInfo.id) ?: return@ChatLayout
if (chatModel.chatId.value != chatInfo.id) return@ChatLayout
withBGApi {
apiFindMessages(c, chatModel, value)
searchText.value = value
@@ -556,7 +544,7 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType)
@Composable
fun ChatLayout(
remoteHostId: State<Long?>,
chatInfo: State<ChatInfo>,
chatInfo: State<ChatInfo?>,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
@@ -569,7 +557,7 @@ fun ChatLayout(
back: () -> Unit,
info: () -> Unit,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: () -> Unit,
loadPrevMessages: (ChatId) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long) -> Unit,
@@ -601,12 +589,11 @@ fun ChatLayout(
) {
val scope = rememberCoroutineScope()
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
Box(
Modifier
.fillMaxWidth()
.desktopOnExternalDrag(
enabled = !attachmentDisabled.value && rememberUpdatedState(chatInfo.value).value.userCanSend,
enabled = remember(attachmentDisabled.value, chatInfo.value?.userCanSend) { mutableStateOf(!attachmentDisabled.value && chatInfo.value?.userCanSend == true) }.value,
onFiles = { paths -> composeState.onFilesAttached(paths.map { it.toURI() }) },
onImage = {
// TODO: file is not saved anywhere?!
@@ -644,7 +631,10 @@ fun ChatLayout(
Scaffold(
topBar = {
if (selectedChatItems.value == null) {
ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
val chatInfo = chatInfo.value
if (chatInfo != null) {
ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
}
} else {
SelectedItemsTopToolbar(selectedChatItems)
}
@@ -669,13 +659,17 @@ fun ChatLayout(
Modifier)
.padding(contentPadding)
) {
ChatItemsList(
remoteHostId, chatInfo, unreadCount, composeState, searchValue,
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, showViaProxy,
)
val remoteHostId = remember { remoteHostId }.value
val chatInfo = remember { chatInfo }.value
if (chatInfo != null) {
ChatItemsList(
remoteHostId, chatInfo, unreadCount, composeState, searchValue,
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, showViaProxy,
)
}
}
}
}
@@ -685,7 +679,7 @@ fun ChatLayout(
@Composable
fun ChatInfoToolbar(
chatInfo: State<ChatInfo>,
chatInfo: ChatInfo,
back: () -> Unit,
info: () -> Unit,
startCall: (CallMediaType) -> Unit,
@@ -710,7 +704,6 @@ fun ChatInfoToolbar(
if (appPlatform.isAndroid) {
BackHandler(onBack = onBackClicked)
}
val chatInfo = chatInfo.value
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val menuItems = arrayListOf<@Composable () -> Unit>()
val activeCall by remember { chatModel.activeCall }
@@ -915,22 +908,10 @@ private fun ContactVerifiedShield() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(18.dp * fontSizeSqrtMultiplier).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
}
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
val CIListStateSaver = run {
val scrolledKey = "scrolled"
val countKey = "itemCount"
val keyboardKey = "keyboardState"
mapSaver(
save = { mapOf(scrolledKey to it.scrolled, countKey to it.itemCount, keyboardKey to it.keyboardState) },
restore = { CIListState(it[scrolledKey] as Boolean, it[countKey] as Int, it[keyboardKey] as KeyboardState) }
)
}
@Composable
fun BoxWithConstraintsScope.ChatItemsList(
remoteHostId: State<Long?>,
chatInfo: State<ChatInfo>,
remoteHostId: Long?,
chatInfo: ChatInfo,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
searchValue: State<String>,
@@ -938,7 +919,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
linkMode: SimplexLinkMode,
selectedChatItems: MutableState<Set<Long>?>,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: () -> Unit,
loadPrevMessages: (ChatId) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long) -> Unit,
@@ -964,8 +945,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val remoteHostId = remember { remoteHostId }.value
val chatInfo = remember { chatInfo }.value
ScrollToBottom(chatInfo.id, listState, chatModel.chatItems)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back
@@ -980,7 +959,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages)
PreloadItems(chatInfo.id, listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages)
Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatModel.chatItems.asReversed() } }
@@ -991,6 +970,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
}
}
// TODO: Having this block on desktop makes ChatItemsList() to recompose twice on chatModel.chatId update instead of once
LaunchedEffect(chatInfo.id) {
var stopListening = false
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
@@ -1343,14 +1323,17 @@ fun BoxWithConstraintsScope.FloatingButtons(
@Composable
fun PreloadItems(
chatId: String,
listState: LazyListState,
remaining: Int = 10,
onLoadMore: () -> Unit,
onLoadMore: (ChatId) -> Unit,
) {
// Prevent situation when initial load and load more happens one after another after selecting a chat with long scroll position from previous selection
val allowLoad = remember { mutableStateOf(false) }
val chatId = rememberUpdatedState(chatId)
val onLoadMore = rememberUpdatedState(onLoadMore)
LaunchedEffect(Unit) {
snapshotFlow { chatModel.chatId.value }
snapshotFlow { chatId.value }
.filterNotNull()
.collect {
allowLoad.value = listState.layoutInfo.totalItemsCount == listState.layoutInfo.visibleItemsInfo.size
@@ -1370,7 +1353,7 @@ fun PreloadItems(
}
.filter { it > 0 }
.collect {
onLoadMore()
onLoadMore.value(chatId.value)
}
}
}
@@ -88,6 +88,7 @@ fun SendMsgView(
showDeleteTextButton,
userIsObserver,
if (clicksOnTextFieldDisabled) "" else placeholder,
showVoiceButton,
onMessageChange,
editPrevMessage,
onFilesPasted
@@ -89,7 +89,8 @@ fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
.map { it.chatInfo }
.filterIsInstance<ChatInfo.Direct>()
.map { it.contact }
.filter { c -> c.sendMsgEnabled && !c.nextSendGrpInv && c.contactId !in memberContactIds && c.chatViewName.lowercase().contains(s) }
.filter { c -> c.sendMsgEnabled && !c.nextSendGrpInv && c.contactId !in memberContactIds && c.anyNameContains(s)
}
.sortedBy { it.displayName.lowercase() }
.toList()
}
@@ -278,7 +278,12 @@ fun GroupChatInfoLayout(
scope.launch { listState.scrollToItem(0) }
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
val filteredMembers = remember(members) { derivedStateOf { members.filter { it.chatViewName.lowercase().contains(searchText.value.text.trim().lowercase()) } } }
val filteredMembers = remember(members) {
derivedStateOf {
val s = searchText.value.text.trim().lowercase()
if (s.isEmpty()) members else members.filter { m -> m.anyNameContains(s) }
}
}
// LALAL strange scrolling
LazyColumnWithScrollBar(
Modifier
@@ -421,7 +421,10 @@ fun SubscriptionStatusIndicator(click: (() -> Unit)) {
}
}
SimpleButtonFrame(click = click) {
SimpleButtonFrame(
click = click,
disabled = chatModel.chatRunning.value != true
) {
SubscriptionStatusIndicatorView(subs = subs, hasSess = hasSess)
}
}
@@ -698,7 +701,7 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
}
}
private fun filteredChats(
fun filteredChats(
showUnreadAndFavorites: Boolean,
searchShowingSimplexLink: State<Boolean>,
searchChatFilteredBySimplexLink: State<String?>,
@@ -719,18 +722,16 @@ private fun filteredChats(
if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
cInfo.anyNameContains(s)
})
is ChatInfo.Group -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
} else {
viewNameContains(cInfo, s)
cInfo.anyNameContains(s)
}
is ChatInfo.Local -> s.isEmpty() || viewNameContains(cInfo, s)
is ChatInfo.ContactRequest -> s.isEmpty() || viewNameContains(cInfo, s)
is ChatInfo.ContactConnection -> (s.isNotEmpty() && cInfo.contactConnection.localAlias.lowercase().contains(s)) || (s.isEmpty() && chat.id == chatModel.chatId.value)
is ChatInfo.Local -> s.isEmpty() || cInfo.anyNameContains(s)
is ChatInfo.ContactRequest -> s.isEmpty() || cInfo.anyNameContains(s)
is ChatInfo.ContactConnection -> (s.isNotEmpty() && cInfo.anyNameContains(s)) || (s.isEmpty() && chat.id == chatModel.chatId.value)
is ChatInfo.InvalidJSON -> chat.id == chatModel.chatId.value
}
}
@@ -742,6 +743,3 @@ private fun filtered(chat: Chat): Boolean =
(chat.chatInfo.chatSettings?.favorite ?: false) ||
chat.chatStats.unreadChat ||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean =
cInfo.chatViewName.lowercase().contains(s.lowercase())
@@ -203,12 +203,8 @@ private fun ShareList(
val oneHandUI = remember { appPrefs.oneHandUI.state }
val chats by remember(search) {
derivedStateOf {
val sorted = chatModel.chats.value.toList().sortedByDescending { it.chatInfo is ChatInfo.Local }
if (search.isEmpty()) {
sorted.filter { it.chatInfo.ready }
} else {
sorted.filter { it.chatInfo.ready && it.chatInfo.chatViewName.lowercase().contains(search.lowercase()) }
}
val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready }.sortedByDescending { it.chatInfo is ChatInfo.Local }
filteredChats(false, mutableStateOf(false), mutableStateOf(null), search, sorted)
}
}
LazyColumnWithScrollBar(
@@ -61,9 +61,6 @@ fun ContactListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>, showDel
ContactType.CHAT_DELETED -> {
withApi {
openChat(rhId, chat.chatInfo, chatModel)
withChats {
updateContact(rhId, chat.chatInfo.contact.copy(chatDeleted = false))
}
ModalManager.start.closeModals()
}
}
@@ -10,7 +10,6 @@ import androidx.compose.material.TextFieldDefaults.indicatorLine
import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -25,10 +24,12 @@ import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.platform.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SearchTextField(
modifier: Modifier,
@@ -50,6 +51,25 @@ fun SearchTextField(
keyboard?.show()
}
}
if (appPlatform.isAndroid) {
LaunchedEffect(Unit) {
val modalCountOnOpen = ModalManager.start.modalCount.value
launch {
snapshotFlow { ModalManager.start.modalCount.value }
.filter { it > modalCountOnOpen }
.collect {
keyboard?.hide()
}
}
}
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
// Delay is needed here because when ChatView is being opened and keyboard is hiding, bottom sheet (to choose attachment) is visible on a screen
delay(300)
keyboard?.hide()
}
}
}
DisposableEffect(Unit) {
onDispose {
@@ -35,8 +35,10 @@ import java.net.URI
@Composable
fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, closeAll: () -> Unit) {
val rhId = rh?.remoteHostId
val view = LocalMultiplatformView()
AddGroupLayout(
createGroup = { incognito, groupProfile ->
hideKeyboard(view)
withBGApi {
val groupInfo = chatModel.controller.apiNewGroup(rhId, incognito, groupProfile)
if (groupInfo != null) {
@@ -491,9 +491,7 @@ private fun filterChat(chat: Chat, searchText: String, showUnreadAndFavorites: B
val cInfo = chat.chatInfo
if (searchText.isNotEmpty()) {
meetsPredicate = viewNameContains(cInfo, s) ||
if (cInfo is ChatInfo.Direct) (cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s)) else false
meetsPredicate = cInfo.anyNameContains(s)
}
if (showUnreadAndFavorites) {
@@ -503,9 +501,6 @@ private fun filterChat(chat: Chat, searchText: String, showUnreadAndFavorites: B
return meetsPredicate
}
private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean =
cInfo.chatViewName.lowercase().contains(s.lowercase())
private val chatsByTypeComparator = Comparator<Chat> { chat1, chat2 ->
val chat1Type = chatContactType(chat1)
val chat2Type = chatContactType(chat2)
@@ -100,10 +100,14 @@ fun SettingsLayout(
) {
val scope = rememberCoroutineScope()
val closeSettings: () -> Unit = { scope.launch { drawerState.close() } }
val view = LocalMultiplatformView()
if (drawerState.isOpen) {
BackHandler {
closeSettings()
}
LaunchedEffect(Unit) {
hideKeyboard(view)
}
}
val theme = CurrentColors.collectAsState()
val uriHandler = LocalUriHandler.current
@@ -307,7 +307,7 @@ private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<User
val s = searchTextOrPassword.trim()
val lower = s.lowercase()
return m.users.filter { u ->
if ((u.user.activeUser || !u.user.hidden) && (s == "" || u.user.chatViewName.lowercase().contains(lower))) {
if ((u.user.activeUser || !u.user.hidden) && (s == "" || u.user.anyNameContains(lower))) {
true
} else {
correctPassword(u.user, s)
@@ -13,6 +13,8 @@
<string name="you_will_join_group">You will connect to all group members.</string>
<string name="connect_via_link_verb">Connect</string>
<string name="connect_via_link_incognito">Connect incognito</string>
<string name="error_parsing_uri_title">Invalid link</string>
<string name="error_parsing_uri_desc">Please check that SimpleX link is correct.</string>
<!-- MainActivity.kt -->
<string name="opening_database">Opening database…</string>
@@ -47,6 +47,7 @@ actual fun PlatformTextField(
showDeleteTextButton: MutableState<Boolean>,
userIsObserver: Boolean,
placeholder: String,
showVoiceButton: Boolean,
onMessageChange: (String) -> Unit,
onUpArrow: () -> Unit,
onFilesPasted: (List<URI>) -> Unit,
@@ -56,7 +57,6 @@ actual fun PlatformTextField(
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
val padding = PaddingValues(0.dp, 12.dp, 50.dp, 0.dp)
LaunchedEffect(cs.contextItem) {
if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect
// In replying state
@@ -71,7 +71,20 @@ actual fun PlatformTextField(
keyboard?.hide()
}
}
val isRtl = remember(cs.message) { isRtl(cs.message.subSequence(0, min(50, cs.message.length))) }
val lastTimeWasRtlByCharacters = remember { mutableStateOf(isRtl(cs.message.subSequence(0, min(50, cs.message.length)))) }
val isRtlByCharacters = remember(cs.message) {
if (cs.message.isNotEmpty()) isRtl(cs.message.subSequence(0, min(50, cs.message.length))) else lastTimeWasRtlByCharacters.value
}
LaunchedEffect(isRtlByCharacters) {
lastTimeWasRtlByCharacters.value = isRtlByCharacters
}
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 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)
val clipboard = LocalClipboardManager.current
@@ -165,9 +178,9 @@ actual fun PlatformTextField(
decorationBox = { innerTextField ->
Row(verticalAlignment = Alignment.Bottom) {
CompositionLocalProvider(
LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
LocalLayoutDirection provides if (isRtlByCharacters) LayoutDirection.Rtl else LocalLayoutDirection.current
) {
Column(Modifier.weight(1f).padding(start = 0.dp, end = 50.dp)) {
Column(Modifier.weight(1f).padding(start = startPadding, end = endPadding)) {
Spacer(Modifier.height(8.dp))
TextFieldDefaults.TextFieldDecorationBox(
value = textFieldValue.text,
@@ -186,7 +199,6 @@ actual fun PlatformTextField(
}
}
},
)
showDeleteTextButton.value = cs.message.split("\n").size >= 4 && !cs.inProgress
if (composeState.value.preview is ComposePreview.VoicePreview) {