Compare commits

...

16 Commits

Author SHA1 Message Date
Diogo Cunha 3bd47130e7 android: removed one hand ui for new chat sheet 2024-07-18 10:15:43 +01:00
Diogo Cunha 12c1b348fe android, desktop: made sure contacts list refreshes on changes 2024-07-17 23:21:58 +01:00
Diogo Cunha ecc8a42b66 refactor: fixed fn access scopes 2024-07-17 23:16:52 +01:00
Diogo Cunha cd5bb4c146 android, desktop: handled empty lists 2024-07-17 23:15:31 +01:00
Diogo Cunha 64f4bcc6fc android, desktop: made full new chat sheet scrollable 2024-07-17 23:12:54 +01:00
Diogo Cunha 060d3dd4d4 android, desktop: showing only actionable contacts 2024-07-17 22:27:17 +01:00
Diogo Cunha cea3aad0d4 android, desktop: deleted contacts and requests 2024-07-16 23:33:14 +01:00
Diogo Cunha f43a2d070b android, desktop: removed group connections
not needed, missunderstanding in requirements
2024-07-16 21:20:42 +01:00
Diogo Cunha 69ad380245 android, desktop: new chat UI improvements 2024-07-15 23:24:21 +01:00
Diogo Cunha 3133d01690 first draft 2024-07-15 15:11:56 +01:00
Diogo Cunha 69da8b6345 Merge branch 'android-chats-one-hand-ui' into android-chats-new-chat-sheet 2024-07-14 23:28:41 +01:00
Diogo Cunha f053447f5f Merge branch 'master' into android-chats-one-hand-ui and removed new toolbar 2024-07-14 23:27:37 +01:00
Diogo Cunha a57a2c277d (early draft) android, desktop: new chat sheet 2024-07-14 22:19:43 +01:00
Diogo Cunha 387faa0c27 android: implemented one hand ui for chat list screen 2024-07-12 23:02:14 +01:00
Diogo Cunha aa990da17c android, desktop: added setting for one hand ui 2024-07-11 21:25:03 +01:00
Diogo Cunha a48c82f4a1 android, desktop: added action buttons and delete to contact card, added toolbar 2024-07-10 22:23:43 +01:00
19 changed files with 1335 additions and 111 deletions
@@ -6,6 +6,7 @@ import androidx.compose.material.Divider
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.helpers.*
@@ -19,8 +20,14 @@ actual fun ChatListNavLinkLayout(
disabled: Boolean,
selectedChat: State<Boolean>,
nextChatSelected: State<Boolean>,
oneHandUI: State<Boolean>?
) {
var modifier = Modifier.fillMaxWidth()
if (oneHandUI != null && oneHandUI.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
if (!disabled) modifier = modifier
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true }
@@ -802,6 +802,7 @@ interface SomeChat {
val id: ChatId
val apiId: Long
val ready: Boolean
val chatDeleted: Boolean
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val incognito: Boolean
@@ -882,6 +883,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contact.id
override val apiId get() = contact.apiId
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
@@ -906,6 +908,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = groupInfo.id
override val apiId get() = groupInfo.apiId
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
@@ -930,6 +933,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = noteFolder.id
override val apiId get() = noteFolder.apiId
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
@@ -954,6 +958,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contactRequest.id
override val apiId get() = contactRequest.apiId
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
@@ -978,6 +983,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contactConnection.id
override val apiId get() = contactConnection.apiId
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
@@ -1003,6 +1009,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = ""
override val apiId get() = 0L
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
@@ -1079,6 +1086,7 @@ data class Contact(
val chatTs: Instant?,
val contactGroupMemberId: Long? = null,
val contactGrpInvSent: Boolean,
override val chatDeleted: Boolean,
val uiThemes: ThemeModeOverrides? = null,
): SomeChat, NamedChat {
override val chatType get() = ChatType.Direct
@@ -1152,6 +1160,7 @@ data class Contact(
updatedAt = Clock.System.now(),
chatTs = Clock.System.now(),
contactGrpInvSent = false,
chatDeleted = false,
uiThemes = null,
)
}
@@ -1160,7 +1169,8 @@ data class Contact(
@Serializable
enum class ContactStatus {
@SerialName("active") Active,
@SerialName("deleted") Deleted;
@SerialName("deleted") Deleted,
@SerialName("deletedByUser") DeletedByUser;
}
@Serializable
@@ -1303,6 +1313,7 @@ data class GroupInfo (
override val id get() = "#$groupId"
override val apiId get() = groupId
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
@@ -1608,6 +1619,7 @@ class NoteFolder(
override val chatType get() = ChatType.Local
override val id get() = "*$noteFolderId"
override val apiId get() = noteFolderId
override val chatDeleted get() = false
override val ready get() = true
override val sendMsgEnabled get() = true
override val ntfsEnabled get() = false
@@ -1644,6 +1656,7 @@ class UserContactRequest (
override val chatType get() = ChatType.ContactRequest
override val id get() = "<@$contactRequestId"
override val apiId get() = contactRequestId
override val chatDeleted get() = false
override val ready get() = true
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
@@ -1683,6 +1696,7 @@ class PendingContactConnection(
override val chatType get() = ChatType.ContactConnection
override val id get () = ":$pccConnId"
override val apiId get() = pccConnId
override val chatDeleted get() = false
override val ready get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
@@ -216,13 +216,16 @@ class AppPreferences {
val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null)
val showDeleteConversationNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE, true)
val showDeleteContactNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE, true)
val showSentViaProxy = mkBoolPreference(SHARED_PREFS_SHOW_SENT_VIA_RPOXY, false)
val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true)
val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false)
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, false)
private fun mkIntPreference(prefName: String, default: Int) =
SharedPreference(
get = fun() = settings.getInt(prefName, default),
@@ -379,6 +382,7 @@ class AppPreferences {
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
private const val SHARED_PREFS_NEW_DATABASE_INITIALIZED = "NewDatabaseInitialized"
private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades"
private const val SHARED_PREFS_ONE_HAND_UI = "OneHandUI"
private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct"
private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName"
private const val SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED = "PQExperimentalEnabled" // no longer used
@@ -399,6 +403,8 @@ class AppPreferences {
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled"
@@ -1155,16 +1161,16 @@ object ChatController {
}
}
suspend fun deleteChat(chat: Chat, notify: Boolean? = null) {
suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
val cInfo = chat.chatInfo
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, notify = notify)) {
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode)) {
chatModel.removeChat(chat.remoteHostId, cInfo.id)
}
}
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, notify: Boolean? = null): Boolean {
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Boolean {
chatModel.deletedChats.value += rh to type.type + id
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, notify))
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
val success = when {
r is CR.ContactDeleted && type == ChatType.Direct -> true
r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> true
@@ -1185,6 +1191,22 @@ object ChatController {
return success
}
suspend fun apiDeleteContact(rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Contact? {
val type = ChatType.Direct
chatModel.deletedChats.value += rh to type.type + id
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
val contact = when {
r is CR.ContactDeleted -> r.contact
else -> {
val titleId = MR.strings.error_deleting_contact
apiErrorAlert("apiDeleteChat", generalGetString(titleId), r)
null
}
}
chatModel.deletedChats.value -= rh to type.type + id
return contact
}
fun clearChat(chat: Chat, close: (() -> Unit)? = null) {
withBGApi {
val updatedChatInfo = apiClearChat(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId)
@@ -1997,6 +2019,10 @@ 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)
chatModel.updateContact(rhId, updatedContact)
}
chatModel.addChatItem(rhId, cInfo, cItem)
} else if (cItem.isRcvNew && cInfo.ntfsEnabled) {
chatModel.increaseUnreadCounter(rhId, r.user)
@@ -2710,7 +2736,7 @@ sealed class CC {
class APIConnectPlan(val userId: Long, val connReq: String): CC()
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC()
class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC()
class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC()
class ApiClearChat(val type: ChatType, val id: Long): CC()
class ApiListContacts(val userId: Long): CC()
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
@@ -2861,11 +2887,7 @@ sealed class CC {
is APIConnectPlan -> "/_connect plan $userId $connReq"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId"
is ApiDeleteChat -> if (notify != null) {
"/_delete ${chatRef(type, id)} notify=${onOff(notify)}"
} else {
"/_delete ${chatRef(type, id)}"
}
is ApiDeleteChat -> "/_delete ${chatRef(type, id)} ${chatDeleteMode.cmdString}"
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ApiListContacts -> "/_contacts $userId"
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
@@ -3084,8 +3106,6 @@ sealed class CC {
null
}
private fun onOff(b: Boolean): String = if (b) "on" else "off"
private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd)
companion object {
@@ -3095,6 +3115,8 @@ sealed class CC {
}
}
fun onOff(b: Boolean): String = if (b) "on" else "off"
@Serializable
data class NewUser(
val profile: Profile?,
@@ -5067,6 +5089,19 @@ fun chatError(r: CR): ChatErrorType? {
)
}
@Serializable
sealed class ChatDeleteMode {
@Serializable @SerialName("full") class Full(val notify: Boolean): ChatDeleteMode()
@Serializable @SerialName("entity") class Entity(val notify: Boolean): ChatDeleteMode()
@Serializable @SerialName("messages") class Messages: ChatDeleteMode()
val cmdString: String get() = when (this) {
is ChatDeleteMode.Full -> "full notify=${onOff(notify)}"
is ChatDeleteMode.Entity -> "entity notify=${onOff(notify)}"
is ChatDeleteMode.Messages -> "messages"
}
}
@Serializable
sealed class ConnectionPlan {
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
@@ -5953,6 +5988,7 @@ data class AppSettings(
var uiDarkColorScheme: String? = null,
var uiCurrentThemeIds: Map<String, String>? = null,
var uiThemes: List<ThemeOverrides>? = null,
var oneHandUI: Boolean? = null
) {
fun prepareForExport(): AppSettings {
val empty = AppSettings()
@@ -5982,6 +6018,7 @@ data class AppSettings(
if (uiDarkColorScheme != def.uiDarkColorScheme) { empty.uiDarkColorScheme = uiDarkColorScheme }
if (uiCurrentThemeIds != def.uiCurrentThemeIds) { empty.uiCurrentThemeIds = uiCurrentThemeIds }
if (uiThemes != def.uiThemes) { empty.uiThemes = uiThemes }
if (oneHandUI != def.oneHandUI) { empty.oneHandUI = oneHandUI }
return empty
}
@@ -6019,6 +6056,7 @@ data class AppSettings(
uiDarkColorScheme?.let { def.systemDarkTheme.set(it) }
uiCurrentThemeIds?.let { def.currentThemeIds.set(it) }
uiThemes?.let { def.themeOverrides.set(it.skipDuplicates()) }
oneHandUI?.let { def.oneHandUI.set(it) }
}
companion object {
@@ -6049,6 +6087,7 @@ data class AppSettings(
uiDarkColorScheme = DefaultTheme.SIMPLEX.themeName,
uiCurrentThemeIds = null,
uiThemes = null,
oneHandUI = false
)
val current: AppSettings
@@ -6080,6 +6119,7 @@ data class AppSettings(
uiDarkColorScheme = def.systemDarkTheme.get() ?: DefaultTheme.SIMPLEX.themeName,
uiCurrentThemeIds = def.currentThemeIds.get(),
uiThemes = def.themeOverrides.get(),
oneHandUI = def.oneHandUI.get()
)
}
}
@@ -12,13 +12,17 @@ import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.*
import androidx.compose.material.*
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.painter.Painter
import androidx.compose.ui.platform.*
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chatlist.*
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -45,9 +49,21 @@ import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import java.io.File
sealed class ContactDeleteMode {
class Full: ContactDeleteMode()
class Entity: ContactDeleteMode()
fun toChatDeleteMode(notify: Boolean): ChatDeleteMode =
when (this) {
is Full -> ChatDeleteMode.Full(notify)
is Entity -> ChatDeleteMode.Entity(notify)
}
}
@Composable
fun ChatInfoView(
chatModel: ChatModel,
openedFromChatView: Boolean,
contact: Contact,
connectionStats: ConnectionStats?,
customUserProfile: Profile?,
@@ -68,6 +84,7 @@ fun ChatInfoView(
val chatRh = chat.remoteHostId
val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) }
ChatInfoLayout(
openedFromChatView = openedFromChatView,
chat,
contact,
currentUser,
@@ -166,7 +183,8 @@ fun ChatInfoView(
)
}
}
}
},
close = close
)
}
}
@@ -201,53 +219,127 @@ sealed class SendReceipts {
fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) {
val chatInfo = chat.chatInfo
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question),
text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)),
buttons = {
Column {
if (chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) {
// Delete and notify contact
if (chatInfo is ChatInfo.Direct) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question),
buttons = {
Column {
// Delete contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, notify = true)
notifyDeleteContactDialog(chat, chatModel, close, contactDeleteMode = ContactDeleteMode.Full())
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete
if (!chatInfo.contact.chatDeleted) {
// Delete contact, keep conversation
SectionItemView({
AlertManager.shared.hideAlert()
notifyDeleteContactDialog(chat, chatModel, close, contactDeleteMode = ContactDeleteMode.Entity())
}) {
Text(generalGetString(MR.strings.delete_contact_keep_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, notify = false)
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
} else {
// Delete
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close)
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
fun notifyDeleteContactDialog(
chat: Chat,
chatModel: ChatModel,
close: (() -> Unit)? = null,
contactDeleteMode: ContactDeleteMode = ContactDeleteMode.Full()
) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
val contactActive = chatInfo.contact.ready && chatInfo.contact.active
AlertManager.shared.showAlertDialogButtonsColumn(
title = if (contactActive) generalGetString(MR.strings.notify_delete_contact_question) else generalGetString(MR.strings.confirm_delete_contact_question),
text = when (contactDeleteMode) {
is ContactDeleteMode.Full -> generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)
is ContactDeleteMode.Entity -> generalGetString(MR.strings.delete_contact_cannot_undo_warning)
},
buttons = {
Column {
if (contactActive) {
// Delete and notify contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = true))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete without notification
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = false))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_without_notification), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
} else {
// Delete
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = false))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
private fun showDeleteContactNotice(contact: Contact) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.contact_deleted),
text = String.format(generalGetString(MR.strings.you_can_still_view_conversation_with_contact), contact.displayName),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.dont_show_again),
onDismiss = {
chatModel.controller.appPrefs.showDeleteContactNotice.set(false)
},
)
}
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) {
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
val chatInfo = chat.chatInfo
withBGApi {
val chatRh = chat.remoteHostId
val r = chatModel.controller.apiDeleteChat(chatRh, chatInfo.chatType, chatInfo.apiId, notify)
if (r) {
chatModel.removeChat(chatRh, chatInfo.id)
val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode)
if (ct != null) {
when (chatDeleteMode) {
is ChatDeleteMode.Full ->
chatModel.removeChat(chatRh, chatInfo.id)
is ChatDeleteMode.Entity ->
chatModel.updateContact(chatRh, ct)
is ChatDeleteMode.Messages ->
chatModel.clearChat(chatRh, ChatInfo.Direct(ct))
}
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
@@ -280,6 +372,7 @@ fun clearNoteFolderDialog(chat: Chat, close: (() -> Unit)? = null) {
@Composable
fun ChatInfoLayout(
openedFromChatView: Boolean,
chat: Chat,
contact: Contact,
currentUser: User,
@@ -300,6 +393,7 @@ fun ChatInfoLayout(
syncContactConnection: () -> Unit,
syncContactConnectionForce: () -> Unit,
verifyClicked: () -> Unit,
close: () -> Unit,
) {
val cStats = connStats.value
val scrollState = rememberScrollState()
@@ -319,7 +413,31 @@ fun ChatInfoLayout(
}
LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged)
SectionSpacer()
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (contact.activeConn == null && contact.profile.contactLink != null && contact.active) {
ConnectButton(openedFromChatView, chat, contact, close)
} else if (!contact.active && !contact.chatDeleted) {
OpenButton(openedFromChatView, chat, contact, close)
} else {
MessageButton(openedFromChatView, chat, contact, close)
}
Spacer(Modifier.width(10.dp))
CallButton(chat, contact)
Spacer(Modifier.width(10.dp))
VideoButton(chat, contact)
}
SectionSpacer()
if (customUserProfile != null) {
SectionView(generalGetString(MR.strings.incognito).uppercase()) {
SectionItemViewSpaceBetween {
@@ -535,6 +653,155 @@ fun LocalAliasEditor(
}
}
// when contact is a "contact card"
@Composable
private fun ConnectButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_connect_button),
disabled = false,
onClick = {
AlertManager.privacySensitive.showAlertDialogButtonsColumn(
title = String.format(generalGetString(MR.strings.connect_with_contact_name_question), contact.chatViewName),
buttons = {
Column {
SectionItemView({
AlertManager.privacySensitive.hideAlert()
infoConnectContactViaAddress(openedFromChatView, chat, contact, incognito = false, close)
}) {
Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
infoConnectContactViaAddress(openedFromChatView, chat, contact, incognito = true, close)
}) {
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
},
hostDevice = hostDevice(chat.remoteHostId),
)
}
)
}
private fun infoConnectContactViaAddress(openedFromChatView: Boolean, chat: Chat, contact: Contact, incognito: Boolean, close: () -> Unit) {
withBGApi {
val ok = connectContactViaAddress(chatModel, chat.remoteHostId, contact.contactId, incognito = incognito)
if (ok) {
if (openedFromChatView) {
close.invoke()
} else {
if (contact.chatDeleted) {
chatModel.updateContact(chat.remoteHostId, contact.copy(chatDeleted = false))
}
close.invoke()
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
}
@Composable
private fun OpenButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_open_button),
disabled = false,
onClick = {
if (openedFromChatView) {
close.invoke()
} else {
close.invoke()
withBGApi {
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
)
}
@Composable
private fun MessageButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_message_button),
disabled = !contact.sendMsgEnabled,
onClick = {
if (openedFromChatView) {
close.invoke()
} else {
if (contact.chatDeleted) {
chatModel.updateContact(chat.remoteHostId, contact.copy(chatDeleted = false))
}
close.invoke()
withBGApi {
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
)
}
@Composable
fun CallButton(chat: Chat, contact: Contact) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_call_filled),
title = generalGetString(MR.strings.info_view_call_button),
disabled = !contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall.value != null,
onClick = {
startChatCall(chat, CallMediaType.Audio)
}
)
}
@Composable
fun VideoButton(chat: Chat, contact: Contact) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_videocam_filled),
title = generalGetString(MR.strings.info_view_video_button),
disabled = !contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall.value != null,
onClick = {
startChatCall(chat, CallMediaType.Video)
}
)
}
@Composable
fun InfoViewActionButton(icon: Painter, title: String, disabled: Boolean, onClick: () -> Unit) {
Surface(
Modifier
.width(96.dp)
.height(66.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colors.secondaryVariant,
) {
val modifier = if (disabled) Modifier else Modifier.clickable { onClick () }
Column(
modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
icon,
contentDescription = null,
Modifier.size(26.dp),
tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
Text(
title,
style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal),
color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}
}
@Composable
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
Row(
@@ -823,6 +1090,7 @@ fun queueInfoText(info: Pair<RcvMsgInfo?, QueueInfo>): String {
fun PreviewChatInfoLayout() {
SimpleXTheme {
ChatInfoLayout(
openedFromChatView = false,
chat = Chat(
remoteHostId = null,
chatInfo = ChatInfo.Direct.sampleData,
@@ -847,6 +1115,7 @@ fun PreviewChatInfoLayout() {
syncContactConnection = {},
syncContactConnectionForce = {},
verifyClicked = {},
close = {},
)
}
}
@@ -190,7 +190,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
preloadedCode = code
}
ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
ChatInfoView(chatModel, openedFromChatView = true, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
} else if (chat?.chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chat.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chat.id) {
@@ -307,18 +307,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
onComplete.invoke()
}
},
startCall = out@{ media ->
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
},
startCall = out@{ media -> startChatCall(chat, media) },
endCall = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
@@ -522,6 +511,19 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
fun startChatCall(chat: Chat, media: CallMediaType) {
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chat.remoteHostId, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
}
@Composable
fun ChatLayout(
chat: Chat,
@@ -246,10 +246,10 @@ fun GroupMemberInfoLayout(
verifyClicked: () -> Unit,
) {
val cStats = connStats.value
fun knownDirectChat(contactId: Long): Chat? {
fun knownDirectChat(contactId: Long): Pair<Chat, Contact>? {
val chat = getContactChat(contactId)
return if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
chat
chat to chat.chatInfo.contact
} else {
null
}
@@ -309,17 +309,37 @@ fun GroupMemberInfoLayout(
val contactId = member.memberContactId
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
val knownChat = if (contactId != null) knownDirectChat(contactId) else null
if (knownChat != null) {
val (chat, contact) = knownChat
OpenChatButton(onClick = { openDirectChat(contact.contactId) })
Spacer(Modifier.width(10.dp))
CallButton(chat, contact)
Spacer(Modifier.width(10.dp))
VideoButton(chat, contact)
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else {
OpenChatButton(onClick = { createMemberContact() })
}
Spacer(Modifier.width(10.dp))
InfoViewActionButton(painterResource(MR.images.ic_call_filled), generalGetString(MR.strings.info_view_call_button), disabled = true, onClick = {})
Spacer(Modifier.width(10.dp))
InfoViewActionButton(painterResource(MR.images.ic_videocam_filled), generalGetString(MR.strings.info_view_video_button), disabled = true, onClick = {})
}
}
SectionSpacer()
if (member.memberActive) {
SectionView {
if (contactId != null && knownDirectChat(contactId) != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (member.activeConn?.peerChatVRange?.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) == true) {
OpenChatButton(onClick = { createMemberContact() })
}
}
if (connectionCode != null) {
VerifyCodeButton(member.verified, verifyClicked)
}
@@ -513,12 +533,11 @@ fun RemoveMemberButton(onClick: () -> Unit) {
@Composable
fun OpenChatButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_chat),
stringResource(MR.strings.button_send_direct_message),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_message_button),
disabled = false,
onClick = onClick
)
}
@@ -1,6 +1,7 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -32,7 +33,7 @@ import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
@Composable
fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>, oneHandUI: State<Boolean>) {
val showMenu = remember { mutableStateOf(false) }
val showMarkRead = remember(chat.chatStats.unreadCount, chat.chatStats.unreadChat) {
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
@@ -47,6 +48,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
val showChatPreviews = chatModel.showChatPreviews.value
val inProgress = remember { mutableStateOf(false) }
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(inProgress.value) {
progressByTimeout = if (inProgress.value) {
delay(1000)
@@ -75,6 +77,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
is ChatInfo.Group ->
@@ -94,6 +97,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.Local -> {
ChatListNavLinkLayout(
@@ -112,6 +116,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
is ChatInfo.ContactRequest ->
@@ -131,6 +136,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.ContactConnection ->
ChatListNavLinkLayout(
@@ -151,6 +157,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.InvalidJSON ->
ChatListNavLinkLayout(
@@ -167,12 +174,13 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
}
@Composable
private fun ErrorChatListItem() {
fun ErrorChatListItem() {
Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) {
Text(stringResource(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
@@ -409,13 +417,73 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState
stringResource(MR.strings.delete_contact_menu_action),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactDialog(chat, chatModel)
deleteContactConversationDialog(chat, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
fun deleteContactConversationDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
val contactDeletedByUser = chatInfo.contact.contactStatus == ContactStatus.DeletedByUser
AlertManager.shared.showAlertDialogButtonsColumn(
title = if (contactDeletedByUser) generalGetString(MR.strings.delete_conversation_question) else generalGetString(MR.strings.delete_contact_question),
text = if (contactDeletedByUser) generalGetString(MR.strings.delete_conversation_all_messages_deleted_cannot_undo_warning) else null,
buttons = {
Column {
if (contactDeletedByUser) {
// Delete conversation
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Full(notify = false))
}) {
Text(generalGetString(MR.strings.delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
} else {
// Delete contact
SectionItemView({
AlertManager.shared.hideAlert()
notifyDeleteContactDialog(chat, chatModel, close)
}) {
Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Only delete conversation
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Messages())
if (chatModel.controller.appPrefs.showDeleteConversationNotice.get()) {
showDeleteConversationNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.only_delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
private fun showDeleteConversationNotice(contact: Contact) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.conversation_deleted),
text = String.format(generalGetString(MR.strings.you_can_still_send_messages_to_contact), contact.displayName),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.dont_show_again),
onDismiss = {
chatModel.controller.appPrefs.showDeleteConversationNotice.set(false)
},
)
}
@Composable
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
@@ -594,7 +662,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
}
}
fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel, onSuccess: () -> Unit = {}) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.accept_connection_request__question),
text = AnnotatedString(generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified)),
@@ -603,12 +671,14 @@ fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactReque
SectionItemView({
AlertManager.shared.hideAlert()
acceptContactRequest(rhId, incognito = false, contactRequest.apiId, contactRequest, true, chatModel)
onSuccess()
}) {
Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
acceptContactRequest(rhId, incognito = true, contactRequest.apiId, contactRequest, true, chatModel)
onSuccess()
}) {
Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
@@ -845,6 +915,7 @@ expect fun ChatListNavLinkLayout(
disabled: Boolean,
selectedChat: State<Boolean>,
nextChatSelected: State<Boolean>,
oneHandUI: State<Boolean>? = null
)
@Preview/*(
@@ -887,7 +958,8 @@ fun PreviewChatListNavLinkDirect() {
showMenu = remember { mutableStateOf(false) },
disabled = false,
selectedChat = remember { mutableStateOf(false) },
nextChatSelected = remember { mutableStateOf(false) }
nextChatSelected = remember { mutableStateOf(false) },
oneHandUI = remember { mutableStateOf(false) }
)
}
}
@@ -932,7 +1004,8 @@ fun PreviewChatListNavLinkGroup() {
showMenu = remember { mutableStateOf(false) },
disabled = false,
selectedChat = remember { mutableStateOf(false) },
nextChatSelected = remember { mutableStateOf(false) }
nextChatSelected = remember { mutableStateOf(false) },
oneHandUI = remember { mutableStateOf(false) }
)
}
}
@@ -954,7 +1027,8 @@ fun PreviewChatListNavLinkContactRequest() {
showMenu = remember { mutableStateOf(false) },
disabled = false,
selectedChat = remember { mutableStateOf(false) },
nextChatSelected = remember { mutableStateOf(false) }
nextChatSelected = remember { mutableStateOf(false) },
oneHandUI = remember { mutableStateOf(false) }
)
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.BoxScope.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -12,6 +13,7 @@ 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.scale
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontStyle
@@ -32,7 +34,6 @@ import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.chat.group.ProgressIndicator
import chat.simplex.common.views.chat.item.CIFileViewScope
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
@@ -41,7 +42,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.Json
import java.net.URI
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable
@@ -54,6 +54,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
if (animated) newChatSheetState.value = AnimatedViewState.HIDING
else newChatSheetState.value = AnimatedViewState.GONE
}
val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI }
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
@@ -76,7 +78,29 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState ) = settingsState
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(scaffoldState.drawerState, userPickerState, stopped)} },
Scaffold(
topBar = {
if (!oneHandUI.state.value) {
Box(Modifier.padding(end = endPadding)) {
ChatListToolbar(
scaffoldState.drawerState,
userPickerState,
stopped
)
}
}
},
bottomBar = {
if (oneHandUI.state.value) {
Box(Modifier.padding(end = endPadding)) {
ChatListToolbar(
scaffoldState.drawerState,
userPickerState,
stopped
)
}
}
},
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
@@ -89,13 +113,23 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
var bottom = DEFAULT_PADDING
if (oneHandUI.state.value) {
bottom = DEFAULT_BOTTOM_PADDING
} else {
bottom -= 16.dp
}
FloatingActionButton(
onClick = {
if (!stopped) {
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
ModalManager.start.closeModals()
ModalManager.start.showModalCloseable{
NewChatView(rh = chatModel.currentRemoteHost.value)
}
}
},
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp).size(AppBarHeight * fontSizeSqrtMultiplier),
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = bottom).size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
@@ -110,13 +144,18 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
}
) {
Box(Modifier.padding(it).padding(end = endPadding)) {
var modifier = Modifier.padding(it).padding(end = endPadding)
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Box(modifier) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(chatModel, searchText = searchText)
ChatList(chatModel, searchText = searchText, oneHandUI = oneHandUI)
}
if (chatModel.chats.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
@@ -142,7 +181,12 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
if (appPlatform.isAndroid) {
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
UserPicker(
chatModel = chatModel,
userPickerState = userPickerState,
containerModifier = Modifier.padding(bottom = AppBarHeight),
contentAlignment = if (oneHandUI.state.value) Alignment.BottomStart else Alignment.TopStart) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
@@ -341,6 +385,7 @@ fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> U
}
}
@Composable
private fun BoxScope.unreadBadge(text: String? = "") {
Text(
@@ -390,8 +435,14 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
}
@Composable
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>) {
Row(verticalAlignment = Alignment.CenterVertically) {
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>, oneHandUI: SharedPreference<Boolean>) {
var modifier = Modifier.fillMaxWidth();
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
val focusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF).size(24.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.secondary)
@@ -415,7 +466,7 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
} else {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.size > 0) {
ToggleFilterEnabledButton()
ToggleFilterEnabledButton()
}
Spacer(Modifier.width(padding))
}
@@ -480,9 +531,35 @@ private fun ErrorSettingsView() {
private var lazyListState = 0 to 0
enum class ScrollDirection {
Up, Down, Idle
}
@Composable
private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>, oneHandUI: SharedPreference<Boolean>) {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
val currentIndex = listState.firstVisibleItemIndex
val currentScrollOffset = listState.firstVisibleItemScrollOffset
val threshold = 25
scrollDirection = when {
currentIndex > previousIndex -> ScrollDirection.Down
currentIndex < previousIndex -> ScrollDirection.Up
currentScrollOffset > previousScrollOffset + threshold -> ScrollDirection.Down
currentScrollOffset < previousScrollOffset - threshold -> ScrollDirection.Up
currentScrollOffset == previousScrollOffset -> ScrollDirection.Idle
else -> scrollDirection
}
previousIndex = currentIndex
previousScrollOffset = currentScrollOffset
}
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
@@ -503,7 +580,9 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
Modifier
.offset {
val y = if (searchText.value.text.isEmpty()) {
if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000
if (oneHandUI.state.value && scrollDirection == ScrollDirection.Up) {
0
} else if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000
} else {
0
}
@@ -511,7 +590,7 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
}
.background(MaterialTheme.colors.background)
) {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, oneHandUI)
Divider()
}
}
@@ -519,11 +598,17 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
val nextChatSelected = remember(chat.id, chats) { derivedStateOf {
chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value
} }
ChatListNavLinkView(chat, nextChatSelected)
ChatListNavLinkView(chat, nextChatSelected, oneHandUI.state)
}
}
if (chats.isEmpty() && chatModel.chats.isNotEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
var modifier = Modifier.fillMaxSize();
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Box(modifier, contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary)
}
}
@@ -550,8 +635,8 @@ private fun filteredChats(
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.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
}
is ChatInfo.Group -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
@@ -570,8 +655,8 @@ private fun filteredChats(
private fun filtered(chat: Chat): Boolean =
(chat.chatInfo.chatSettings?.favorite ?: false) ||
chat.chatStats.unreadChat ||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
chat.chatStats.unreadChat ||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean =
cInfo.chatViewName.lowercase().contains(s.lowercase())
@@ -201,7 +201,7 @@ fun ChatPreviewView(
} else {
when (cInfo) {
is ChatInfo.Direct ->
if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null) {
if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null && cInfo.contact.active) {
Text(stringResource(MR.strings.contact_tap_to_connect), color = MaterialTheme.colors.primary)
} else if (!cInfo.ready && cInfo.contact.activeConn != null) {
if (cInfo.contact.nextSendGrpInv) {
@@ -39,11 +39,13 @@ import kotlin.math.roundToInt
fun UserPicker(
chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>,
containerModifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
showSettings: Boolean = true,
showCancel: Boolean = false,
cancelClicked: () -> Unit = {},
useFromDesktopClicked: () -> Unit = {},
settingsClicked: () -> Unit = {},
settingsClicked: () -> Unit = {}
) {
val scope = rememberCoroutineScope()
var newChat by remember { mutableStateOf(userPickerState.value) }
@@ -149,16 +151,17 @@ fun UserPicker(
.graphicsLayer {
alpha = animatedFloat.value
translationY = (animatedFloat.value - 1) * xOffset
}
},
contentAlignment = contentAlignment
) {
Column(
Modifier
containerModifier
.widthIn(min = 260.dp)
.width(IntrinsicSize.Min)
.height(IntrinsicSize.Min)
.shadow(8.dp, RoundedCornerShape(corner = CornerSize(25.dp)), clip = true)
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
.clip(RoundedCornerShape(corner = CornerSize(25.dp)))
.clip(RoundedCornerShape(corner = CornerSize(25.dp))),
) {
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
@@ -0,0 +1,112 @@
package chat.simplex.common.views.contacts
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@Composable
fun ContactListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
val showMenu = remember { mutableStateOf(false) }
val disabled = chatModel.chatRunning.value == false || chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)
LaunchedEffect(chat.id) {
showMenu.value = false
delay(500L)
}
val selectedChat = remember(chat.id) { derivedStateOf { chat.id == chatModel.chatId.value } }
when (chat.chatInfo) {
is ChatInfo.Direct -> {
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) {
ContactPreviewView(chat, disabled)
}
},
click = {
directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel)
ModalManager.start.closeModals()
},
dropdownMenuItems = {
tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) {
ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu)
}
},
showMenu,
disabled,
selectedChat,
nextChatSelected,
)
}
is ChatInfo.ContactRequest -> {
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) {
ContactPreviewView(chat, disabled)
}
},
click = {
contactRequestAlertDialog(
chat.remoteHostId,
chat.chatInfo,
chatModel,
onSuccess = {
ModalManager.start.closeModals()
}
)
},
dropdownMenuItems = {
tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) {
ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu)
}
},
showMenu,
disabled,
selectedChat,
nextChatSelected,
)
}
else -> {}
}
}
@Composable
fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
if (contact.activeConn != null) {
ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu)
}
DeleteContactAction(chat, chatModel, showMenu)
}
@Composable
fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolean, showMenu: MutableState<Boolean>) {
ItemAction(
if (favorite) stringResource(MR.strings.unfavorite_chat) else stringResource(MR.strings.favorite_chat),
if (favorite) painterResource(MR.images.ic_star_off) else painterResource(MR.images.ic_star),
onClick = {
toggleChatFavorite(chat, !favorite, chatModel)
showMenu.value = false
}
)
}
@Composable
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.delete_contact_menu_action),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactDialog(chat, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@@ -0,0 +1,113 @@
package chat.simplex.common.views.contacts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.DEFAULT_SPACE_AFTER_ICON
import chat.simplex.res.MR
@Composable
fun ContactPreviewView(
chat: Chat,
disabled: Boolean,
) {
val cInfo = chat.chatInfo
@Composable
fun VerifiedIcon() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
}
@Composable
fun chatPreviewTitle() {
val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) }
when (cInfo) {
is ChatInfo.Direct ->
Row(verticalAlignment = Alignment.CenterVertically) {
if (cInfo.contact.verified) {
VerifiedIcon()
}
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (deleting) MaterialTheme.colors.secondary else Color.Unspecified
)
}
is ChatInfo.ContactRequest ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Unspecified
)
}
else -> {}
}
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(contentAlignment = Alignment.BottomEnd) {
ChatInfoImage(cInfo, size = 42.dp)
}
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Box(modifier = Modifier.weight(10f, fill = true)) {
chatPreviewTitle()
}
Spacer(Modifier.fillMaxWidth().weight(1f))
if (chat.chatInfo is ChatInfo.ContactRequest) {
Text(
text = generalGetString(MR.strings.contact_type_new).uppercase(),
color = MaterialTheme.colors.onPrimary,
fontSize = 10.sp * fontSizeMultiplier,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 4.dp)
.padding(vertical = 2.dp)
)
}
if (chat.chatInfo.chatSettings?.favorite == true) {
Icon(
painterResource(MR.images.ic_star_filled),
contentDescription = generalGetString(MR.strings.favorite_chat),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(17.dp)
)
if (chat.chatInfo.incognito) {
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
}
}
if (chat.chatInfo.incognito) {
Icon(
painterResource(MR.images.ic_theater_comedy),
contentDescription = null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(21.dp)
)
}
}
}
@@ -0,0 +1,385 @@
package chat.simplex.common.views.contacts
import SectionItemView
import SectionView
import TextIconSpaced
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.Chat
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatInfo
import chat.simplex.common.model.ContactStatus
import chat.simplex.common.model.RemoteHostInfo
import chat.simplex.common.platform.BackHandler
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.chatModel
import chat.simplex.common.platform.getKeyboardState
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
import chat.simplex.common.views.helpers.AppBarTitle
import chat.simplex.common.views.helpers.KeyChangeEffect
import chat.simplex.common.views.helpers.KeyboardState
import chat.simplex.common.views.helpers.ModalData
import chat.simplex.common.views.helpers.ModalManager
import chat.simplex.common.views.helpers.ModalView
import chat.simplex.common.views.helpers.SearchTextField
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.common.views.helpers.hostDevice
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.distinctUntilChanged
enum class ContactType {
CARD, REQUEST, RECENT, REMOVED, UNKNOWN
}
private fun contactChats(c: List<Chat>, contactTypes: List<ContactType>): List<Chat> {
return c.filter { chat -> contactTypes.contains(getContactType(chat)) }
}
private fun getContactType(chat: Chat): ContactType {
return when (val cInfo = chat.chatInfo) {
is ChatInfo.ContactRequest -> ContactType.REQUEST
is ChatInfo.Direct -> {
val contact = cInfo.contact;
when {
contact.activeConn == null && contact.profile.contactLink != null -> ContactType.CARD
contact.chatDeleted -> ContactType.REMOVED
contact.contactStatus != ContactStatus.DeletedByUser && contact.contactStatus != ContactStatus.Deleted -> ContactType.RECENT
else -> ContactType.UNKNOWN
}
}
else -> ContactType.UNKNOWN
}
}
private val chatsByTypeComparator = Comparator<Chat> { chat1, chat2 ->
val chat1Type = getContactType(chat1)
val chat2Type = getContactType(chat2)
when {
chat1Type.ordinal < chat2Type.ordinal -> -1
chat1Type.ordinal > chat2Type.ordinal -> 1
else -> chat2.chatInfo.chatTs.compareTo(chat1.chatInfo.chatTs)
}
}
@Composable
private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, close: () -> Unit) {
ModalView(
close = close
) {
Column(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.chat_deleted),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
ContactsLayout(
contactActions = {},
contactTypes = listOf(ContactType.REMOVED),
)
}
}
}
@Composable
private fun ContactActionsSection(contactActions: @Composable () -> Unit, rh: RemoteHostInfo?) {
contactActions()
Spacer(Modifier.height(DEFAULT_PADDING))
val archived = remember { contactChats(chatModel.chats, listOf(ContactType.REMOVED)) }
if (archived.isNotEmpty()) {
SectionView {
SectionItemView(
click = {
ModalManager.start.showCustomModal { close -> DeletedContactsView(
rh = rh,
close = close)
}
}
) {
Icon(
painterResource(MR.images.ic_folder_open),
contentDescription = stringResource(MR.strings.chat_deleted),
tint = MaterialTheme.colors.secondary,
)
TextIconSpaced(extraPadding = true)
Text(text = stringResource(MR.strings.chat_deleted), color = MaterialTheme.colors.onBackground)
}
}
}
}
@Composable
private fun ContactsLayout(
contactActions: @Composable () -> Unit,
contactTypes: List<ContactType>,
contactListTitle: String? = null) {
SectionView {
ContactsList(
contactTypes = contactTypes,
contactActions = contactActions,
contactListTitle = contactListTitle
)
}
}
@Composable
fun ContactsView(
contactActions: @Composable () -> Unit,
rh: RemoteHostInfo?
) {
ContactsLayout(
contactActions = { ContactActionsSection(contactActions, rh) },
contactTypes = listOf(ContactType.CARD, ContactType.RECENT, ContactType.REQUEST),
contactListTitle = stringResource(MR.strings.contact_list_header_title).uppercase()
)
}
@Composable
private fun ContactsSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, focused: Boolean, onFocusChanged: (hasFocus: Boolean) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
val focusRequester = remember { FocusRequester() }
Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF), tint = MaterialTheme.colors.secondary)
SearchTextField(
Modifier.weight(1f).onFocusChanged { onFocusChanged(it.hasFocus) }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_verb),
alwaysVisible = true,
searchText = searchText,
trailingContent = null,
) {
searchText.value = searchText.value.copy(it)
}
val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } }
if (hasText.value) {
val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() }
BackHandler(onBack = hideSearchOnBack)
KeyChangeEffect(chatModel.currentRemoteHost.value) {
hideSearchOnBack()
}
} else {
Row {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.size > 0) {
ToggleFilterButton()
}
Spacer(Modifier.width(padding))
}
}
val focusManager = LocalFocusManager.current
val keyboardState = getKeyboardState()
LaunchedEffect(keyboardState.value) {
if (keyboardState.value == KeyboardState.Closed && focused) {
focusManager.clearFocus()
}
}
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
}
}
}
}
@Composable
private fun ToggleFilterButton() {
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
IconButton(onClick = { pref.set(!pref.get()) }) {
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(sp16)
)
}
}
private var lazyListState = 0 to 0
@Composable
private fun ContactsList(
contactActions: @Composable () -> Unit,
contactTypes: List<ContactType>,
contactListTitle: String ? = null
) {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(
TextFieldValue("")
) }
var searchFocused by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
onDispose {
lazyListState =
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}
}
val showUnreadAndFavorites =
remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val allChats by remember(chatModel.chats, contactTypes) {
derivedStateOf { contactChats(chatModel.chats, contactTypes) }
}
val filteredContactChats = filteredContactChats(
showUnreadAndFavorites = showUnreadAndFavorites,
searchText = searchText.value.text,
contactChats = allChats
)
LazyColumn(
Modifier.fillMaxWidth(),
listState
) {
item {
SectionView {
Divider()
ContactsSearchBar(
listState = listState,
searchText = searchText,
focused = searchFocused,
onFocusChanged = {
searchFocused = it
}
)
Divider()
}
if (!searchFocused) {
contactActions()
}
Spacer(Modifier.height(DEFAULT_PADDING))
if (contactListTitle != null && filteredContactChats.isNotEmpty()) {
Text(
contactListTitle, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2,
modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), fontSize = 12.sp
)
}
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
}
}
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
ContactListNavLinkView(chat, nextChatSelected)
}
}
}
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary
)
}
}
}
}
private fun filterChat(chat: Chat, searchText: String, showUnreadAndFavorites: Boolean): Boolean {
var meetsPredicate = true;
val s = searchText.trim().lowercase()
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
}
if (showUnreadAndFavorites) {
meetsPredicate = meetsPredicate && (cInfo.chatSettings?.favorite ?: false)
}
return meetsPredicate;
}
private fun filteredContactChats(
showUnreadAndFavorites: Boolean,
searchText: String,
contactChats: List<Chat>
): List<Chat> {
return contactChats
.filter { chat -> filterChat(
chat = chat,
searchText = searchText,
showUnreadAndFavorites = showUnreadAndFavorites) }
.sortedWith(chatsByTypeComparator)
}
private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean =
cInfo.chatViewName.lowercase().contains(s.lowercase())
@@ -16,7 +16,7 @@ import chat.simplex.res.MR
@Composable
fun DefaultTopAppBar(
navigationButton: @Composable RowScope.() -> Unit,
navigationButton: (@Composable RowScope.() -> Unit)? = null,
title: (@Composable () -> Unit)?,
onTitleClick: (() -> Unit)? = null,
showSearch: Boolean,
@@ -1,5 +1,8 @@
package chat.simplex.common.views.newchat
import SectionItemView
import SectionView
import TextIconSpaced
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.desktop.ui.tooling.preview.Preview
@@ -10,6 +13,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
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.drawBehind
@@ -19,12 +23,16 @@ import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.RemoteHostInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.contacts.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,10 +40,69 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun ModalData.NewChatView(rh: RemoteHostInfo?) {
Column(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.new_chat),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
ContactsView(
contactActions = {
NewChatOptions(
addContact = {
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) }
},
scanPaste = {
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = true, close = close) }
},
createGroup = {
ModalManager.center.closeModals()
ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close) }
}
)
},
rh = rh
)
}
}
@Composable
fun NewChatButton(icon: Painter, text: String, click: () -> Unit, textColor: Color = Color.Unspecified, iconColor: Color = MaterialTheme.colors.secondary, disabled: Boolean = false, extraPadding: Boolean = false) {
SectionItemView(click, disabled = disabled) {
Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else iconColor)
TextIconSpaced(extraPadding)
Text(text, color = if (disabled) MaterialTheme.colors.secondary else textColor)
}
}
@Composable
fun NewChatOptions(addContact: () -> Unit, scanPaste: () -> Unit, createGroup: () -> Unit) {
val actions = remember { listOf(addContact, scanPaste, createGroup) }
Column {
actions.forEachIndexed { index, _ ->
NewChatButton(
icon = painterResource(icons[index]),
text = stringResource(titles[index]),
click = actions[index],
extraPadding = true)
}
}
}
@Composable
fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedViewState>, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) {
// TODO close new chat if remote host changes in model
if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) }
NewChatSheetLayout(
newChatSheetState,
stopped,
@@ -50,7 +117,6 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedView
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = true, close = close) }
},
createGroup = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close) }
},
@@ -43,6 +43,10 @@ fun DeveloperView(
SectionSpacer()
SectionView(stringResource(MR.strings.developer_options_section).uppercase()) {
SettingsPreferenceItem(painterResource(MR.images.ic_drive_folder_upload), stringResource(MR.strings.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades)
if (appPlatform.isAndroid) {
SettingsPreferenceItem(painterResource(MR.images.ic_back_hand), stringResource(MR.strings.one_hand_ui), m.controller.appPrefs.oneHandUI)
}
if (appPlatform.isDesktop) {
TerminalAlwaysVisibleItem(m.controller.appPrefs.terminalAlwaysVisible) { checked ->
if (checked) {
@@ -345,6 +345,7 @@
<string name="welcome">Welcome!</string>
<string name="this_text_is_available_in_settings">This text is available in settings</string>
<string name="your_chats">Chats</string>
<string name="toolbar_settings">Settings</string>
<string name="contact_connection_pending">connecting…</string>
<string name="member_contact_send_direct_message">send direct message</string>
<string name="group_preview_you_are_invited">you are invited to group</string>
@@ -436,10 +437,28 @@
<string name="notifications">Notifications</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="info_view_connect_button">connect</string>
<string name="info_view_open_button">open</string>
<string name="info_view_message_button">message</string>
<string name="info_view_call_button">call</string>
<string name="info_view_video_button">video</string>
<string name="delete_contact_question">Delete contact?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Contact and all messages will be deleted - this cannot be undone!</string>
<string name="delete_contact_cannot_undo_warning">Contact will be deleted - this cannot be undone!</string>
<string name="delete_conversation_question">Delete conversation?</string>
<string name="delete_conversation_all_messages_deleted_cannot_undo_warning">Conversation and all messages will be deleted - this cannot be undone!</string>
<string name="delete_conversation">Delete conversation</string>
<string name="only_delete_conversation">Only delete conversation</string>
<string name="delete_contact_keep_conversation">Delete contact, keep conversation</string>
<string name="notify_delete_contact_question">Notify contact?</string>
<string name="confirm_delete_contact_question">Confirm contact deletion?</string>
<string name="delete_and_notify_contact">Delete and notify contact</string>
<string name="delete_without_notification">Delete without notification</string>
<string name="button_delete_contact">Delete contact</string>
<string name="conversation_deleted">Conversation deleted!</string>
<string name="you_can_still_send_messages_to_contact">You can still send messages to %1$s from the Contacts tab.</string>
<string name="contact_deleted">Contact deleted!</string>
<string name="you_can_still_view_conversation_with_contact">You can still view conversation with %1$s in the Chats tab.</string>
<string name="text_field_set_contact_placeholder">Set contact name…</string>
<string name="icon_descr_server_status_connected">Connected</string>
<string name="icon_descr_server_status_disconnected">Disconnected</string>
@@ -1247,6 +1266,7 @@
<string name="database_downgrade">Database downgrade</string>
<string name="incompatible_database_version">Incompatible database version</string>
<string name="confirm_database_upgrades">Confirm database upgrades</string>
<string name="one_hand_ui">One-hand UI</string>
<string name="terminal_always_visible">Show console in new window</string>
<string name="chat_list_always_visible">Show chat list in new window</string>
<string name="invalid_migration_confirmation">Invalid migration confirmation</string>
@@ -2194,4 +2214,13 @@
<string name="download_errors">Download errors</string>
<string name="server_address">Server address</string>
<string name="open_server_settings_button">Open server settings</string>
<!-- ContactsView.kt -->
<string name="contact_type_new">New</string>
<string name="contact_type_deleted">Deleted contacts</string>
<string name="chat_deleted">Deleted chats</string>
<string name="contact_type_recent">Recent</string>
<string name="no_filtered_contacts">No filtered contacts</string>
<string name="contact_list_header_title">Your contacts</string>
<string name="icon_descr_pending_contact_connection">Pending contact connection</string>
</resources>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M241.78-244.5 134-136.5q-13.5 13.5-31.25 6.52Q85-136.97 85-156.5V-818q0-22.97 17.27-40.23 17.26-17.27 40.23-17.27h675q22.97 0 40.23 17.27Q875-840.97 875-818v516q0 22.97-17.27 40.23-17.26 17.27-40.23 17.27H241.78Z"/></svg>

After

Width:  |  Height:  |  Size: 337 B

@@ -35,6 +35,7 @@ actual fun ChatListNavLinkLayout(
disabled: Boolean,
selectedChat: State<Boolean>,
nextChatSelected: State<Boolean>,
oneHandUI: State<Boolean>?,
) {
var modifier = Modifier.fillMaxWidth()
if (!disabled) modifier = modifier