multiplatform: improve new and existing chat interactions - new chat sheet, one hand ui, info views action buttons; new modes of contact deletion (keep conversation, only delete conversation) (#4435)

* android, desktop: added action buttons and delete to contact card, added toolbar

* android, desktop: added setting for one hand ui

* android: implemented one hand ui for chat list screen (#4448)

* android: implemented one hand ui for chat list screen

* android, desktop: remove extra toolbar

* android: fixed user picker positioning

* android, desktop: new chat sheet (#4479)

* (early draft) android, desktop: new chat sheet

* first draft

* android, desktop: new chat UI improvements

* android, desktop: removed group connections

not needed, missunderstanding in requirements

* android, desktop: deleted contacts and requests

* android, desktop: showing only actionable contacts

* android, desktop: made full new chat sheet scrollable

* android, desktop: handled empty lists

* refactor: fixed fn access scopes

* android, desktop: made sure contacts list refreshes on changes

* android: removed one hand ui for new chat sheet

* android, desktop: removed no longer used code

* android: moved new chat button to toolbar for one hand ui

* removed unused imports

* android, desktop: remove favorite contact set functionality from new chat sheet

* android, desktop: improved chat redirect

* android, desktop: removed padding from contact rows

* android, desktop: improved paddings

* android, desktop: started to use accent color for contact cards and requests

* android, desktop: fixed modals and improved contact stage tracking

* android, desktop: made deleted contacts contactable

* android, desktop: allowed for simplex links to be pasted in new chat sheet

* android, desktop: added interaction for contact cards

* close modal

* android, desktop: started to hide cards from chat list

* android, desktop: translations cleanup

* android, desktop: started to mark deleted chat as non deleted when open from new chat sheet

* android, desktop: fixed link pastes for existing connections

* android, desktop: redirect to groups when group links are pasted in new chat sheet

* move one hand ui toggle

* refactor

* on contact card interaction only close new chat sheet on connect

* android, desktop: removed usages of connection stage enum

* android, desktop: stopped preloading active chats on new chat sheet

* android: fixed invitation cleanup

* desktop: fixed invitation cleanup

* desktop: improved consistency on modals to close

* desktop: added small delay to focus re-position logic to avoid focus change cancelling click events

* android, desktop: made add contact learn more smaller to avoid header becoming bigger than expected

* android, desktop: redirect to chat on accept if send is ready

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* android, desktop: hide new chat sheet action buttons when search text is not empty instead of when search is focused (#4529)

* android, desktop: contacts, groups and group member action buttons (#4523)

* android, desktop: made action buttons round

* android, desktop: updated action buttons for contacts

* android, desktop: added action buttons for groups

* android, desktop: removed context menu items

* android, desktop: cleaned up visuals and paddings for contact and group card action buttons

* android, desktop: improved modal close logic

* android, desktop: improved search

* adjust color, fix paddings

* android, desktop: avoided async calls to open chats and simplified search as result

* android, desktop: moved mute button to the end on group view to match chat view

* android, desktop: made filling of icons consistent

* android, desktop: fixed contacts sheet close and dismiss actions on contact connection

* order

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* android, desktop: streamlined delete actions based on contact type (#4538)

* android, desktop: streamlined delete actions based on contact type

* removed unused translations

* refactor, adjust texts

* move toggle closer to buttons

* fix text

* fix accept request

* android, desktop: made sure deleted contacts update on deletes

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* restore deleted file

* desktop: stop closing modal on message search

* android, desktop: remove scroll preservation on new chat sheet unmount

* android, desktop: add functionality to search inside deleted conversation on main new chat sheet screen

* android, desktop: fixed attachment bug when clicking contact with keyboard open inside new chat sheet

* desktop, android: set incognito contacts color to Indigo in contact list

* remove unused code

* remove openedFromChatView

* android, desktop: change icon for contact requests and added icon for contact cards

* refactor

* fix paddings

* fix padding

* refactor

* android, desktop: fix attachment issue for deleted contacts

* remove unused

* android: invert new chat sheet on one hand ui

* info buttons alerts

* info buttons paddings

* android: one hand ui for new chat sheet and deleted chats

* fix build after latest master changes on chat model and mutations in chat

* android,desktop: add menu items back

* add scrollbars to new chat sheet

* desktop: inactivate and rephrase scan since it is not supported

* android: one hand ui for forward chat list

* android, desktop: fix for no chats in one hand ui

* desktop: use left side of screen for new chat actions

* desktop: close end modal when new chat sheet is clicked

* android: fix no filtered contacts on delete contacts view

* fix scrollbar not showing

* android: few adjustmnets in one hand ui

* change icon

* increase icon size

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
This commit is contained in:
Diogo
2024-08-02 11:09:57 +01:00
committed by GitHub
parent 74e2b7582e
commit 0975079a93
28 changed files with 1919 additions and 409 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 }
@@ -36,7 +36,7 @@ private val CALL_BOTTOM_ICON_OFFSET = (-15).dp
private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET
@Composable
actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
actual fun ActiveCallInteractiveArea(call: Call) {
val onClick = { platform.androidStartCallActivity(false) }
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT)) {
val source = remember { MutableInteractionSource() }
@@ -78,7 +78,7 @@ fun AppearanceScope.AppearanceLayout(
Modifier.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.appearance_settings))
SectionView(stringResource(MR.strings.settings_section_title_language), padding = PaddingValues()) {
SectionView(stringResource(MR.strings.settings_section_title_interface), padding = PaddingValues()) {
val context = LocalContext.current
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// SectionItemWithValue(
@@ -104,6 +104,8 @@ fun AppearanceScope.AppearanceLayout(
}
}
// }
SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI)
}
SectionDividerSpaced(maxTopPadding = true)
@@ -278,7 +278,7 @@ fun AndroidScreen(settingsState: SettingsViewState) {
}
}
if (call != null && showCallArea) {
ActiveCallInteractiveArea(call, remember { MutableStateFlow(AnimatedViewState.GONE) })
ActiveCallInteractiveArea(call)
}
}
}
@@ -807,6 +807,7 @@ interface SomeChat {
val id: ChatId
val apiId: Long
val ready: Boolean
val chatDeleted: Boolean
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val incognito: Boolean
@@ -887,6 +888,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
@@ -911,6 +913,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
@@ -935,6 +938,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
@@ -959,6 +963,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
@@ -983,6 +988,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
@@ -1008,6 +1014,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
@@ -1084,6 +1091,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
@@ -1158,6 +1166,7 @@ data class Contact(
updatedAt = Clock.System.now(),
chatTs = Clock.System.now(),
contactGrpInvSent = false,
chatDeleted = false,
uiThemes = null,
)
}
@@ -1166,7 +1175,8 @@ data class Contact(
@Serializable
enum class ContactStatus {
@SerialName("active") Active,
@SerialName("deleted") Deleted;
@SerialName("deleted") Deleted,
@SerialName("deletedByUser") DeletedByUser;
}
@Serializable
@@ -1309,6 +1319,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
@@ -1614,6 +1625,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
@@ -1650,6 +1662,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
@@ -1689,6 +1702,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
@@ -217,13 +217,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),
@@ -381,6 +384,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
@@ -401,6 +405,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"
@@ -1177,18 +1183,18 @@ 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)) {
withChats {
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
@@ -1209,6 +1215,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)
@@ -2099,6 +2121,12 @@ 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)
}
@@ -2873,7 +2901,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()
@@ -3026,11 +3054,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)}"
@@ -3252,8 +3276,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 {
@@ -3263,6 +3285,8 @@ sealed class CC {
}
}
fun onOff(b: Boolean): String = if (b) "on" else "off"
@Serializable
data class NewUser(
val profile: Profile?,
@@ -5230,6 +5254,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()
@@ -6117,6 +6154,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()
@@ -6147,6 +6185,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
}
@@ -6185,6 +6224,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 {
@@ -6216,6 +6256,7 @@ data class AppSettings(
uiDarkColorScheme = DefaultTheme.SIMPLEX.themeName,
uiCurrentThemeIds = null,
uiThemes = null,
oneHandUI = false
)
val current: AppSettings
@@ -6248,6 +6289,7 @@ data class AppSettings(
uiDarkColorScheme = def.systemDarkTheme.get() ?: DefaultTheme.SIMPLEX.themeName,
uiCurrentThemeIds = def.currentThemeIds.get(),
uiThemes = def.themeOverrides.get(),
oneHandUI = def.oneHandUI.get()
)
}
}
@@ -12,18 +12,23 @@ import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -55,6 +60,7 @@ fun ChatInfoView(
localAlias: String,
connectionCode: String?,
close: () -> Unit,
onSearchClicked: () -> Unit
) {
BackHandler(onBack = close)
val contact = rememberUpdatedState(contact).value
@@ -177,7 +183,9 @@ fun ChatInfoView(
)
}
}
}
},
close = close,
onSearchClicked = onSearchClicked
)
}
}
@@ -212,34 +220,42 @@ sealed class SendReceipts {
fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
val contact = chatInfo.contact
when {
contact.sndReady && contact.active && !chatInfo.chatDeleted ->
deleteContactOrConversationDialog(chat, contact, chatModel, close)
contact.sndReady && contact.active && chatInfo.chatDeleted ->
deleteContactWithoutConversation(chat, chatModel, close)
else -> // !(contact.sndReady && contact.active)
deleteNotReadyContact(chat, chatModel, close)
}
}
}
private fun deleteContactOrConversationDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)?) {
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.sndReady && chatInfo.contact.active) {
// Delete and notify contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, notify = true)
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete
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)
}
} 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)
// Only delete conversation
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Messages())
if (chatModel.controller.appPrefs.showDeleteConversationNotice.get()) {
showDeleteConversationNotice(contact)
}
}) {
Text(generalGetString(MR.strings.only_delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteActiveContactDialog(chat, contact, chatModel, close)
}) {
Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Cancel
SectionItemView({
@@ -252,14 +268,206 @@ fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? =
)
}
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) {
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)
},
)
}
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)
}
}
private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)? = null) {
val contactDeleteMode = mutableStateOf<ContactDeleteMode>(ContactDeleteMode.Full())
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question),
text = generalGetString(MR.strings.delete_contact_cannot_undo_warning),
buttons = {
Column {
// Keep conversation toggle
SectionItemView {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(stringResource(MR.strings.keep_conversation))
Spacer(Modifier.width(DEFAULT_PADDING))
DefaultSwitch(
checked = contactDeleteMode.value is ContactDeleteMode.Entity,
onCheckedChange = {
contactDeleteMode.value =
if (it) ContactDeleteMode.Entity() else ContactDeleteMode.Full()
},
)
}
}
// Delete without notification
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = false))
if (contactDeleteMode.value is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(contact)
}
}) {
Text(generalGetString(MR.strings.delete_without_notification), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete contact and notify
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = true))
if (contactDeleteMode.value is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(contact)
}
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), 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 deleteContactWithoutConversation(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.confirm_delete_contact_question),
text = generalGetString(MR.strings.delete_contact_cannot_undo_warning),
buttons = {
Column {
// Delete and notify contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(
chat,
chatModel,
close,
chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = true)
)
}) {
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.Full().toChatDeleteMode(notify = false)
)
}) {
Text(
generalGetString(MR.strings.delete_without_notification),
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 deleteNotReadyContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.confirm_delete_contact_question),
text = generalGetString(MR.strings.delete_contact_cannot_undo_warning),
buttons = {
// Confirm
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(
chat,
chatModel,
close,
chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = false)
)
}) {
Text(
generalGetString(MR.strings.confirm_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)?, 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) {
val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode)
if (ct != null) {
withChats {
removeChat(chatRh, chatInfo.id)
when (chatDeleteMode) {
is ChatDeleteMode.Full ->
removeChat(chatRh, chatInfo.id)
is ChatDeleteMode.Entity ->
updateContact(chatRh, ct)
is ChatDeleteMode.Messages ->
clearChat(chatRh, ChatInfo.Direct(ct))
}
}
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
@@ -313,6 +521,8 @@ fun ChatInfoLayout(
syncContactConnection: () -> Unit,
syncContactConnectionForce: () -> Unit,
verifyClicked: () -> Unit,
close: () -> Unit,
onSearchClicked: () -> Unit
) {
val cStats = connStats.value
val scrollState = rememberScrollState()
@@ -332,7 +542,27 @@ fun ChatInfoLayout(
}
LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged)
SectionSpacer()
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
SearchButton(chat, contact, close, onSearchClicked)
Spacer(Modifier.weight(1f))
AudioCallButton(chat, contact)
Spacer(Modifier.weight(1f))
VideoButton(chat, contact)
Spacer(Modifier.weight(1f))
MuteButton(chat, contact)
}
SectionSpacer()
if (customUserProfile != null) {
SectionView(generalGetString(MR.strings.incognito).uppercase()) {
SectionItemViewSpaceBetween {
@@ -548,6 +778,174 @@ fun LocalAliasEditor(
}
}
@Composable
fun SearchButton(chat: Chat, contact: Contact, close: () -> Unit, onSearchClicked: () -> Unit) {
val disabled = !contact.ready || chat.chatItems.isEmpty()
InfoViewActionButton(
icon = painterResource(MR.images.ic_search),
title = generalGetString(MR.strings.info_view_search_button),
disabled = disabled,
disabledLook = disabled,
onClick = {
if (appPlatform.isAndroid) {
close.invoke()
}
onSearchClicked()
}
)
}
@Composable
fun MuteButton(chat: Chat, contact: Contact) {
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
val disabled = !contact.ready || !contact.active
InfoViewActionButton(
icon = if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications),
title = if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat),
disabled = disabled,
disabledLook = disabled,
onClick = {
toggleNotifications(chat, !ntfsEnabled.value, chatModel, ntfsEnabled)
}
)
}
@Composable
fun AudioCallButton(chat: Chat, contact: Contact) {
CallButton(
chat,
contact,
icon = painterResource(MR.images.ic_call),
title = generalGetString(MR.strings.info_view_call_button),
mediaType = CallMediaType.Audio
)
}
@Composable
fun VideoButton(chat: Chat, contact: Contact) {
CallButton(
chat,
contact,
icon = painterResource(MR.images.ic_videocam),
title = generalGetString(MR.strings.info_view_video_button),
mediaType = CallMediaType.Video
)
}
@Composable
fun CallButton(chat: Chat, contact: Contact, icon: Painter, title: String, mediaType: CallMediaType) {
val canCall = contact.ready && contact.active && contact.mergedPreferences.calls.enabled.forUser && chatModel.activeCall.value == null
val needToAllowCallsToContact = remember(chat.chatInfo) {
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.calls) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
}
val allowedCallsByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Calls) }
InfoViewActionButton(
icon = icon,
title = title,
disabled = chatModel.activeCall.value != null,
disabledLook = !canCall,
onClick =
when {
canCall -> { { startChatCall(chat, mediaType) } }
contact.nextSendGrpInv -> { { showCantCallContactSendMessageAlert() } }
!contact.active -> { { showCantCallContactDeletedAlert() } }
!contact.ready -> { { showCantCallContactConnectingAlert() } }
needToAllowCallsToContact -> { { showNeedToAllowCallsAlert(onConfirm = { allowCallsToContact(chat) }) } }
!allowedCallsByPrefs -> { { showCallsProhibitedAlert() }}
else -> { { AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.cant_call_contact_alert_title)) } }
}
)
}
private fun showCantCallContactSendMessageAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.cant_call_contact_alert_title),
text = generalGetString(MR.strings.cant_call_member_send_message_alert_text)
)
}
private fun showCantCallContactConnectingAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.cant_call_contact_alert_title),
text = generalGetString(MR.strings.cant_call_contact_connecting_wait_alert_text)
)
}
private fun showCantCallContactDeletedAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.cant_call_contact_alert_title),
text = generalGetString(MR.strings.cant_call_contact_deleted_alert_text)
)
}
private fun showNeedToAllowCallsAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.allow_calls_question),
text = generalGetString(MR.strings.you_need_to_allow_calls),
confirmText = generalGetString(MR.strings.allow_verb),
dismissText = generalGetString(MR.strings.cancel_verb),
onConfirm = onConfirm,
)
}
private fun allowCallsToContact(chat: Chat) {
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
withBGApi {
chatModel.controller.allowFeatureToContact(chat.remoteHostId, contact, ChatFeature.Calls)
}
}
private fun showCallsProhibitedAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.calls_prohibited_alert_title),
text = generalGetString(MR.strings.calls_prohibited_ask_to_enable_calls_alert_text)
)
}
// for ChatInfoView (it has most buttons - 4) we use Spacer(Modifier.weight(1f)) to fit,
// for GroupChat And GroupMemberInfoViews (2 to 3 buttons) we use this as approximately equal to spacing in ChatInfoView
val INFO_VIEW_BUTTONS_PADDING = 36.dp
@Composable
fun InfoViewActionButton(icon: Painter, title: String, disabled: Boolean, disabledLook: Boolean, onClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
IconButton(
onClick = onClick,
enabled = !disabled
) {
Box(
modifier = Modifier
.background(
if (disabledLook) MaterialTheme.colors.secondaryVariant else MaterialTheme.colors.primary,
shape = CircleShape
)
.padding(16.dp)
) {
Icon(
icon,
contentDescription = null,
Modifier.size(24.dp * fontSizeSqrtMultiplier),
tint = if (disabledLook) MaterialTheme.colors.secondary else MaterialTheme.colors.onPrimary
)
}
}
Text(
title.capitalize(Locale.current),
style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.secondary,
modifier = Modifier.padding(top = DEFAULT_SPACE_AFTER_ICON)
)
}
}
@Composable
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
Row(
@@ -864,6 +1262,8 @@ fun PreviewChatInfoLayout() {
syncContactConnection = {},
syncContactConnectionForce = {},
verifyClicked = {},
close = {},
onSearchClicked = {}
)
}
}
@@ -11,7 +11,6 @@ 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.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.*
@@ -48,6 +47,7 @@ import kotlin.math.sign
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: String) -> Unit) {
val activeChat = remember { mutableStateOf(chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
val searchText = rememberSaveable { mutableStateOf("") }
val showSearch = rememberSaveable { mutableStateOf(false) }
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val composeState = rememberSaveable(saver = ComposeState.saver()) {
@@ -75,6 +75,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
if (activeChat.value?.id != chatId) {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
showSearch.value = false
activeChat.value = chatModel.getChat(chatId)
}
markUnreadChatAsRead(activeChat, chatModel)
@@ -191,7 +192,9 @@ 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, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) {
showSearch.value = true
}
} else if (chat?.chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chat.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chat.id) {
@@ -202,7 +205,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
GroupChatInfoView(chatModel, chatRh, chat.id, link?.first, link?.second, {
link = it
preloadedLink = it
}, close)
}, close, { showSearch.value = true })
} else {
LaunchedEffect(Unit) {
close()
@@ -308,18 +311,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) }
@@ -458,26 +450,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
},
addMembers = { groupInfo ->
hideKeyboard(view)
withBGApi {
setGroupMembers(chatRh, groupInfo, chatModel)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(chatRh, groupInfo, false, chatModel, close)
}
}
},
openGroupLink = { groupInfo ->
hideKeyboard(view)
withBGApi {
val link = chatModel.controller.apiGetGroupLink(chatRh, groupInfo.groupId)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) {
GroupLinkView(chatModel, chatRh, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null)
}
}
},
addMembers = { groupInfo -> addGroupMembers(view = view, groupInfo = groupInfo, rhId = chatRh, close = { ModalManager.end.closeModals() }) },
openGroupLink = { groupInfo -> openGroupLink(view = view, groupInfo = groupInfo, rhId = chatRh, close = { ModalManager.end.closeModals() }) },
markRead = { range, unreadCountAfter ->
withBGApi {
withChats {
@@ -505,6 +479,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
onComposed,
developerTools = chatModel.controller.appPrefs.developerTools.get(),
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
showSearch = showSearch
)
}
}
@@ -535,6 +510,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,
@@ -576,10 +564,12 @@ fun ChatLayout(
onSearchValueChanged: (String) -> Unit,
onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean,
showViaProxy: Boolean
showViaProxy: Boolean,
showSearch: MutableState<Boolean>
) {
val scope = rememberCoroutineScope()
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
Box(
Modifier
.fillMaxWidth()
@@ -619,7 +609,7 @@ fun ChatLayout(
}
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged) },
topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
@@ -665,16 +655,17 @@ fun ChatInfoToolbar(
openGroupLink: (GroupInfo) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
showSearch: MutableState<Boolean>
) {
val scope = rememberCoroutineScope()
val showMenu = rememberSaveable { mutableStateOf(false) }
var showSearch by rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch) {
if (!showSearch.value) {
back()
} else {
onSearchValueChanged("")
showSearch = false
showSearch.value = false
}
}
if (appPlatform.isAndroid) {
@@ -685,9 +676,10 @@ fun ChatInfoToolbar(
val activeCall by remember { chatModel.activeCall }
if (chat.chatInfo is ChatInfo.Local) {
barButtons.add {
IconButton({
showMenu.value = false
showSearch = true
IconButton(
{
showMenu.value = false
showSearch.value = true
}, enabled = chat.chatInfo.noteFolder.ready
) {
Icon(
@@ -701,7 +693,7 @@ fun ChatInfoToolbar(
menuItems.add {
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
showMenu.value = false
showSearch = true
showSearch.value = true
})
}
}
@@ -800,6 +792,7 @@ fun ChatInfoToolbar(
}
}
}
if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready && chat.chatInfo.contact.active) || chat.chatInfo is ChatInfo.Group) {
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
menuItems.add {
@@ -827,10 +820,10 @@ fun ChatInfoToolbar(
}
DefaultTopAppBar(
navigationButton = { if (appPlatform.isAndroid || showSearch) { NavigationButtonBack(onBackClicked) } },
navigationButton = { if (appPlatform.isAndroid || showSearch.value) { NavigationButtonBack(onBackClicked) } },
title = { ChatInfoToolbarTitle(chat.chatInfo) },
onTitleClick = if (chat.chatInfo is ChatInfo.Local) null else info,
showSearch = showSearch,
showSearch = showSearch.value,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
)
@@ -1349,6 +1342,28 @@ private fun TopEndFloatingButton(
val chatViewScrollState = MutableStateFlow(false)
fun addGroupMembers(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (() -> Unit)? = null) {
hideKeyboard(view)
withBGApi {
setGroupMembers(rhId, groupInfo, chatModel)
close?.invoke()
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(rhId, groupInfo, false, chatModel, close)
}
}
}
fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (() -> Unit)? = null) {
hideKeyboard(view)
withBGApi {
val link = chatModel.controller.apiGetGroupLink(rhId, groupInfo.groupId)
close?.invoke()
ModalManager.end.showModalCloseable(true) {
GroupLinkView(chatModel, rhId, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null)
}
}
}
private fun bottomEndFloatingButton(
unreadCount: Int,
showButtonWithCounter: Boolean,
@@ -1606,6 +1621,7 @@ fun PreviewChatLayout() {
onComposed = {},
developerTools = false,
showViaProxy = false,
showSearch = remember { mutableStateOf(false) }
)
}
}
@@ -1680,6 +1696,7 @@ fun PreviewGroupChatLayout() {
onComposed = {},
developerTools = false,
showViaProxy = false,
showSearch = remember { mutableStateOf(false) }
)
}
}
@@ -41,7 +41,7 @@ import kotlinx.coroutines.launch
const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
@Composable
fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit) {
fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) {
BackHandler(onBack = close)
// TODO derivedStateOf?
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId }
@@ -114,7 +114,8 @@ fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLi
leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) },
manageGroupLink = {
ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
}
},
onSearchClicked = onSearchClicked
)
}
}
@@ -182,6 +183,57 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe
)
}
@Composable
fun SearchButton(chat: Chat, group: GroupInfo, close: () -> Unit, onSearchClicked: () -> Unit) {
val disabled = !group.ready || chat.chatItems.isEmpty()
InfoViewActionButton(
icon = painterResource(MR.images.ic_search),
title = generalGetString(MR.strings.info_view_search_button),
disabled = disabled,
disabledLook = disabled,
onClick = {
if (appPlatform.isAndroid) {
close.invoke()
}
onSearchClicked()
}
)
}
@Composable
fun MuteButton(chat: Chat, groupInfo: GroupInfo) {
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
InfoViewActionButton(
icon = if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications),
title = if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat),
disabled = !groupInfo.ready,
disabledLook = !groupInfo.ready,
onClick = {
toggleNotifications(chat, !ntfsEnabled.value, chatModel, ntfsEnabled)
}
)
}
@Composable
fun AddGroupMembersButton(chat: Chat, groupInfo: GroupInfo) {
InfoViewActionButton(
icon = if (groupInfo.incognito) painterResource(MR.images.ic_add_link) else painterResource(MR.images.ic_person_add_500),
title = stringResource(MR.strings.action_button_add_members),
disabled = !groupInfo.ready,
disabledLook = !groupInfo.ready,
onClick = {
if (groupInfo.incognito) {
openGroupLink(groupInfo = groupInfo, rhId = chat.remoteHostId)
} else {
addGroupMembers(groupInfo = groupInfo, rhId = chat.remoteHostId)
}
}
)
}
@Composable
fun GroupChatInfoLayout(
chat: Chat,
@@ -201,6 +253,8 @@ fun GroupChatInfoLayout(
clearChat: () -> Unit,
leaveGroup: () -> Unit,
manageGroupLink: () -> Unit,
close: () -> Unit = { ModalManager.closeAllModalsEverywhere()},
onSearchClicked: () -> Unit
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
@@ -224,6 +278,24 @@ fun GroupChatInfoLayout(
}
SectionSpacer()
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
SearchButton(chat, groupInfo, close, onSearchClicked)
if (groupInfo.canAddMembers) {
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
AddGroupMembersButton(chat, groupInfo)
}
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
MuteButton(chat, groupInfo)
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
EditGroupProfileButton(editGroupProfile)
@@ -589,7 +661,7 @@ fun PreviewGroupChatInfoLayout() {
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
groupLink = null,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {},
)
}
}
@@ -265,10 +265,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
}
@@ -328,17 +328,53 @@ fun GroupMemberInfoLayout(
val contactId = member.memberContactId
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
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(INFO_VIEW_BUTTONS_PADDING))
AudioCallButton(chat, contact)
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
VideoButton(chat, contact)
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group
} else {
OpenChatButton(onClick = { createMemberContact() })
}
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
InfoViewActionButton(painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = {
showSendMessageToEnableCallsAlert()
})
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
InfoViewActionButton(painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = {
showSendMessageToEnableCallsAlert()
})
} else { // no known contact chat && directMessages are off
InfoViewActionButton(painterResource(MR.images.ic_chat_bubble), generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = true, onClick = {
showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_send_message_to_member_alert_title))
})
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
InfoViewActionButton(painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = {
showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title))
})
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
InfoViewActionButton(painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = {
showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title))
})
}
}
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)
}
@@ -439,6 +475,20 @@ fun GroupMemberInfoLayout(
}
}
private fun showSendMessageToEnableCallsAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.cant_call_member_alert_title),
text = generalGetString(MR.strings.cant_call_member_send_message_alert_text)
)
}
private fun showDirectMessagesProhibitedAlert(title: String) {
AlertManager.shared.showAlertMsg(
title = title,
text = generalGetString(MR.strings.direct_messages_are_prohibited_in_chat)
)
}
@Composable
fun GroupMemberInfoHeader(member: GroupMember) {
Column(
@@ -532,12 +582,12 @@ 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),
title = generalGetString(MR.strings.info_view_message_button),
disabled = false,
disabledLook = 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.*
@@ -33,7 +34,7 @@ import kotlinx.coroutines.*
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
@@ -48,6 +49,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)
@@ -78,6 +80,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
is ChatInfo.Group ->
@@ -97,6 +100,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.Local -> {
ChatListNavLinkLayout(
@@ -115,6 +119,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
is ChatInfo.ContactRequest ->
@@ -134,6 +139,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.ContactConnection ->
ChatListNavLinkLayout(
@@ -154,6 +160,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.InvalidJSON ->
ChatListNavLinkLayout(
@@ -170,12 +177,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)
}
@@ -472,13 +480,13 @@ fun LeaveGroupAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, sh
}
@Composable
fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>, onSuccess: ((chat: Chat) -> Unit)? = null) {
ItemAction(
stringResource(MR.strings.accept_contact_button),
painterResource(MR.images.ic_check),
color = MaterialTheme.colors.onBackground,
onClick = {
acceptContactRequest(rhId, incognito = false, chatInfo.apiId, chatInfo, true, chatModel)
acceptContactRequest(rhId, incognito = false, chatInfo.apiId, chatInfo, true, chatModel, onSuccess)
showMenu.value = false
}
)
@@ -487,7 +495,7 @@ fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chat
painterResource(MR.images.ic_theater_comedy),
color = MaterialTheme.colors.onBackground,
onClick = {
acceptContactRequest(rhId, incognito = true, chatInfo.apiId, chatInfo, true, chatModel)
acceptContactRequest(rhId, incognito = true, chatInfo.apiId, chatInfo, true, chatModel, onSuccess)
showMenu.value = false
}
)
@@ -603,7 +611,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, onSucess: ((chat: Chat) -> Unit)? = null) {
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)),
@@ -611,13 +619,13 @@ fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactReque
Column {
SectionItemView({
AlertManager.shared.hideAlert()
acceptContactRequest(rhId, incognito = false, contactRequest.apiId, contactRequest, true, chatModel)
acceptContactRequest(rhId, incognito = false, contactRequest.apiId, contactRequest, true, chatModel, onSucess)
}) {
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)
acceptContactRequest(rhId, incognito = true, contactRequest.apiId, contactRequest, true, chatModel, onSucess)
}) {
Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
@@ -633,7 +641,7 @@ fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactReque
)
}
fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) {
fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel, close: ((chat: Chat) -> Unit)? = null ) {
withBGApi {
val contact = chatModel.controller.apiAcceptContactRequest(rhId, incognito, apiId)
if (contact != null && isCurrentUser && contactRequest != null) {
@@ -642,6 +650,7 @@ fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRe
replaceChat(rhId, contactRequest.id, chat)
}
chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected())
close?.invoke(chat)
}
}
}
@@ -869,6 +878,7 @@ expect fun ChatListNavLinkLayout(
disabled: Boolean,
selectedChat: State<Boolean>,
nextChatSelected: State<Boolean>,
oneHandUI: State<Boolean>
)
@Preview/*(
@@ -911,7 +921,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) }
)
}
}
@@ -956,7 +967,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) }
)
}
}
@@ -978,7 +990,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) }
)
}
}
@@ -12,7 +12,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.shadow
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontStyle
@@ -33,7 +33,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
@@ -42,28 +41,31 @@ 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
private fun showNewChatSheet(oneHandUI: State<Boolean>, barTitle: String) {
ModalManager.start.closeModals()
ModalManager.end.closeModals()
ModalManager.start.showModalCloseable(
closeOnTop = !oneHandUI.value,
closeBarTitle = if (oneHandUI.value) barTitle else null,
endButtons = { Spacer(Modifier.minimumInteractiveComponentSize()) }
) { close ->
NewChatSheet(rh = chatModel.currentRemoteHost.value, close)
}
}
@Composable
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val showNewChatSheet = {
newChatSheetState.value = AnimatedViewState.VISIBLE
}
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
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)
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
}
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
}
if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
@@ -77,7 +79,31 @@ 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,
oneHandUI
)
}
}
},
bottomBar = {
if (oneHandUI.state.value) {
Box(Modifier.padding(end = endPadding)) {
ChatListToolbar(
scaffoldState.drawerState,
userPickerState,
stopped,
oneHandUI
)
}
}
},
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
@@ -89,11 +115,11 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
if (!oneHandUI.state.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton(
onClick = {
if (!stopped) {
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
showNewChatSheet(oneHandUI.state, generalGetString(MR.strings.new_chat))
}
},
Modifier
@@ -108,25 +134,33 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
) {
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(24.dp * fontSizeSqrtMultiplier))
Icon(painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(24.dp * fontSizeSqrtMultiplier))
}
}
}
) {
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.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
if (!stopped && !newChatSheetState.collectAsState().value.isVisible() && chatModel.chatRunning.value == true && searchText.value.text.isEmpty()) {
OnboardingButtons(showNewChatSheet)
var textModifier = Modifier.align(Alignment.Center)
if (oneHandUI.state.value) {
textModifier = textModifier.scale(scaleX = 1f, scaleY = -1f)
}
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), textModifier, color = MaterialTheme.colors.secondary)
}
}
}
@@ -135,17 +169,17 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
if (appPlatform.isDesktop) {
val call = remember { chatModel.activeCall }.value
if (call != null) {
ActiveCallInteractiveArea(call, newChatSheetState)
ActiveCallInteractiveArea(call)
}
}
// TODO disable this button and sheet for the duration of the switch
tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
}
if (appPlatform.isAndroid) {
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
UserPicker(
chatModel = chatModel,
userPickerState = userPickerState,
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
}
@@ -153,27 +187,6 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
}
@Composable
private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
ConnectButton(generalGetString(MR.strings.tap_to_start_new_chat), openNewChatSheet)
val color = MaterialTheme.colors.primaryVariant
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
val trianglePath = Path().apply {
moveTo(0.dp.toPx(), 0f)
lineTo(16.dp.toPx(), 0.dp.toPx())
lineTo(8.dp.toPx(), 10.dp.toPx())
lineTo(0.dp.toPx(), 0.dp.toPx())
}
drawPath(
color = color,
path = trianglePath
)
})
Spacer(Modifier.height(62.dp))
}
}
@Composable
private fun ConnectButton(text: String, onClick: () -> Unit) {
Button(
@@ -191,10 +204,37 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
}
@Composable
private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, oneHandUI: SharedPreference<Boolean>) {
val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) }
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val updatingProgress = remember { chatModel.updatingProgress }.value
if (oneHandUI.state.value) {
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
barButtons.add {
IconButton(
onClick = {
if (!stopped) {
showNewChatSheet(oneHandUI.state, generalGetString(MR.strings.new_chat))
}
},
) {
Box(
modifier = Modifier
.background(if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, shape = CircleShape)
.padding(DEFAULT_PADDING_HALF)
){
Icon(
painterResource(MR.images.ic_edit_filled),
stringResource(MR.strings.add_contact_or_create_group),
Modifier.size(sp16),
tint = if (!stopped) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSecondary)
}
}
}
}
if (updatingProgress != null) {
barButtons.add {
val interactionSource = remember { MutableInteractionSource() }
@@ -344,6 +384,7 @@ fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> U
}
}
@Composable
private fun BoxScope.unreadBadge(text: String? = "") {
Text(
@@ -379,7 +420,7 @@ private fun ToggleFilterEnabledButton() {
}
@Composable
expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>)
expect fun ActiveCallInteractiveArea(call: Call)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
@@ -393,11 +434,22 @@ 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)
Icon(
painterResource(MR.images.ic_search),
contentDescription = null,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(24.dp * fontSizeSqrtMultiplier),
tint = MaterialTheme.colors.secondary
)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_or_paste_simplex_link),
@@ -483,9 +535,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 }
}
@@ -506,7 +584,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
}
@@ -514,7 +594,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()
}
}
@@ -522,11 +602,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.value.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)
}
}
@@ -545,17 +631,18 @@ private fun filteredChats(
} else {
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
if (s.isEmpty() && !showUnreadAndFavorites)
chats
chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD }
else {
chats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> 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))
}
is ChatInfo.Direct -> chatContactType(chat) != ContactType.CARD && !chat.chatInfo.chatDeleted && (
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))
})
is ChatInfo.Group -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
} else {
@@ -207,7 +207,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.contact.sndReady && cInfo.contact.activeConn != null) {
if (cInfo.contact.nextSendGrpInv) {
@@ -6,6 +6,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -21,7 +22,8 @@ fun ShareListNavLinkView(
chatModel: ChatModel,
isMediaOrFileAttachment: Boolean,
isVoice: Boolean,
hasSimplexLink: Boolean
hasSimplexLink: Boolean,
oneHandUI: State<Boolean>
) {
val stopped = chatModel.chatRunning.value == false
val scope = rememberCoroutineScope()
@@ -29,7 +31,7 @@ fun ShareListNavLinkView(
is ChatInfo.Direct -> {
val voiceProhibited = isVoice && !chat.chatInfo.featureEnabled(ChatFeature.Voice)
ShareListNavLinkLayout(
chatLinkPreview = { SharePreviewView(chat, disabled = voiceProhibited) },
chatLinkPreview = { SharePreviewView(chat, disabled = voiceProhibited, oneHandUI = oneHandUI) },
click = {
if (voiceProhibited) {
showForwardProhibitedByPrefAlert()
@@ -46,7 +48,7 @@ fun ShareListNavLinkView(
val voiceProhibited = isVoice && !chat.chatInfo.featureEnabled(ChatFeature.Voice)
val prohibitedByPref = simplexLinkProhibited || fileProhibited || voiceProhibited
ShareListNavLinkLayout(
chatLinkPreview = { SharePreviewView(chat, disabled = prohibitedByPref) },
chatLinkPreview = { SharePreviewView(chat, disabled = prohibitedByPref, oneHandUI = oneHandUI) },
click = {
if (prohibitedByPref) {
showForwardProhibitedByPrefAlert()
@@ -59,7 +61,7 @@ fun ShareListNavLinkView(
}
is ChatInfo.Local ->
ShareListNavLinkLayout(
chatLinkPreview = { SharePreviewView(chat, disabled = false) },
chatLinkPreview = { SharePreviewView(chat, disabled = false, oneHandUI = oneHandUI) },
click = { scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } },
stopped
)
@@ -78,7 +80,7 @@ private fun showForwardProhibitedByPrefAlert() {
private fun ShareListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,
click: () -> Unit,
stopped: Boolean
stopped: Boolean,
) {
SectionItemView(minHeight = 50.dp, click = click, disabled = stopped) {
chatLinkPreview()
@@ -87,9 +89,15 @@ private fun ShareListNavLinkLayout(
}
@Composable
private fun SharePreviewView(chat: Chat, disabled: Boolean) {
private fun SharePreviewView(chat: Chat, disabled: Boolean, oneHandUI: State<Boolean>) {
var modifier = Modifier.fillMaxSize()
if (oneHandUI.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Row(
Modifier.fillMaxSize(),
modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -7,6 +7,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.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -24,13 +25,16 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
var searchInList by rememberSaveable { mutableStateOf("") }
val (userPickerState, scaffoldState) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI }
Scaffold(
Modifier.padding(end = endPadding),
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
scaffoldState = scaffoldState,
topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
) {
topBar = { if (!oneHandUI.state.value) Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
bottomBar = { if (oneHandUI.state.value) Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
) {
val sharedContent = chatModel.sharedContent.value
var isMediaOrFileAttachment = false
var isVoice = false
@@ -56,10 +60,15 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
}
null -> {}
}
var modifier = Modifier.fillMaxSize()
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Box(Modifier.padding(it)) {
Column(
modifier = Modifier
.fillMaxSize()
modifier = modifier
) {
if (chatModel.chats.value.isNotEmpty()) {
ShareList(
@@ -67,10 +76,11 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
search = searchInList,
isMediaOrFileAttachment = isMediaOrFileAttachment,
isVoice = isVoice,
hasSimplexLink = hasSimplexLink
hasSimplexLink = hasSimplexLink,
oneHandUI = oneHandUI.state
)
} else {
EmptyList()
EmptyList(oneHandUI = oneHandUI.state)
}
}
}
@@ -91,8 +101,14 @@ private fun hasSimplexLink(msg: String): Boolean {
}
@Composable
private fun EmptyList() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
private fun EmptyList(oneHandUI: State<Boolean>) {
var modifier = Modifier.fillMaxSize()
if (oneHandUI.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Box(modifier, contentAlignment = Alignment.Center) {
Text(stringResource(MR.strings.you_have_no_chats), color = MaterialTheme.colors.secondary)
}
}
@@ -182,7 +198,8 @@ private fun ShareList(
search: String,
isMediaOrFileAttachment: Boolean,
isVoice: Boolean,
hasSimplexLink: Boolean
hasSimplexLink: Boolean,
oneHandUI: State<Boolean>
) {
val chats by remember(search) {
derivedStateOf {
@@ -203,7 +220,8 @@ private fun ShareList(
chatModel,
isMediaOrFileAttachment = isMediaOrFileAttachment,
isVoice = isVoice,
hasSimplexLink = hasSimplexLink
hasSimplexLink = hasSimplexLink,
oneHandUI = oneHandUI
)
}
}
@@ -40,6 +40,7 @@ fun UserPicker(
chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>,
showSettings: Boolean = true,
contentAlignment: Alignment = Alignment.TopStart,
showCancel: Boolean = false,
cancelClicked: () -> Unit = {},
useFromDesktopClicked: () -> Unit = {},
@@ -149,7 +150,8 @@ fun UserPicker(
.graphicsLayer {
alpha = animatedFloat.value
translationY = (animatedFloat.value - 1) * xOffset
}
},
contentAlignment = contentAlignment
) {
Column(
Modifier
@@ -0,0 +1,143 @@
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.model.ChatModel.withChats
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.common.views.newchat.ContactType
import chat.simplex.common.views.newchat.chatContactType
import chat.simplex.res.MR
import kotlinx.coroutines.delay
private fun onRequestAccepted(chat: Chat) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
ModalManager.start.closeModals()
if (chatInfo.contact.sndReady) {
openLoadedChat(chat, chatModel)
}
}
}
@Composable
fun ContactListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>, oneHandUI: State<Boolean>) {
val showMenu = remember { mutableStateOf(false) }
val rhId = chat.remoteHostId
val disabled = chatModel.chatRunning.value == false || chatModel.deletedChats.value.contains(rhId to chat.chatInfo.id)
val contactType = chatContactType(chat)
LaunchedEffect(chat.id) {
showMenu.value = false
delay(500L)
}
val selectedChat = remember(chat.id) { derivedStateOf { chat.id == chatModel.chatId.value } }
val view = LocalMultiplatformView()
when (chat.chatInfo) {
is ChatInfo.Direct -> {
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) {
ContactPreviewView(chat, disabled)
}
},
click = {
hideKeyboard(view)
when (contactType) {
ContactType.RECENT -> {
withApi {
openChat(rhId, chat.chatInfo, chatModel)
ModalManager.start.closeModals()
}
}
ContactType.CHAT_DELETED -> {
withApi {
openChat(rhId, chat.chatInfo, chatModel)
withChats {
updateContact(rhId, chat.chatInfo.contact.copy(chatDeleted = false))
}
ModalManager.start.closeModals()
}
}
ContactType.CARD -> {
askCurrentOrIncognitoProfileConnectContactViaAddress(
chatModel,
rhId,
chat.chatInfo.contact,
close = { ModalManager.start.closeModals() },
openChat = true
)
}
else -> {}
}
},
dropdownMenuItems = {
tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) {
DeleteContactAction(chat, chatModel, showMenu)
}
},
showMenu,
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
is ChatInfo.ContactRequest -> {
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) {
ContactPreviewView(chat, disabled)
}
},
click = {
hideKeyboard(view)
contactRequestAlertDialog(
rhId,
chat.chatInfo,
chatModel,
onSucess = { onRequestAccepted(it) }
)
},
dropdownMenuItems = {
tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) {
ContactRequestMenuItems(
rhId = chat.remoteHostId,
chatInfo = chat.chatInfo,
chatModel = chatModel,
showMenu = showMenu,
onSuccess = { onRequestAccepted(it) }
)
}
},
showMenu,
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
else -> {}
}
}
@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,131 @@
package chat.simplex.common.views.contacts
import androidx.compose.foundation.layout.*
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.*
import chat.simplex.common.views.newchat.ContactType
import chat.simplex.common.views.newchat.chatContactType
import chat.simplex.res.MR
@Composable
fun ContactPreviewView(
chat: Chat,
disabled: Boolean,
) {
val cInfo = chat.chatInfo
val contactType = chatContactType(chat)
@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)) }
val textColor = when {
deleting -> MaterialTheme.colors.secondary
contactType == ContactType.CARD -> MaterialTheme.colors.primary
contactType == ContactType.REQUEST -> MaterialTheme.colors.primary
contactType == ContactType.RECENT && chat.chatInfo.incognito -> Indigo
else -> Color.Unspecified
}
when (cInfo) {
is ChatInfo.Direct ->
Row(verticalAlignment = Alignment.CenterVertically) {
if (cInfo.contact.verified) {
VerifiedIcon()
}
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = textColor
)
}
is ChatInfo.ContactRequest ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = textColor
)
}
else -> {}
}
}
Row(
modifier = Modifier.padding(PaddingValues(horizontal = DEFAULT_PADDING_HALF)),
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) {
Icon(
painterResource(MR.images.ic_check),
contentDescription = null,
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(23.dp)
)
}
if (contactType == ContactType.CARD) {
Icon(
painterResource(MR.images.ic_mail),
contentDescription = null,
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(21.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)
)
}
}
}
@@ -11,6 +11,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.*
@@ -18,17 +20,26 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, endButtons: @Composable RowScope.() -> Unit = {}) {
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, arrangement: Arrangement.Vertical = Arrangement.Top, closeBarTitle: String? = null, endButtons: @Composable RowScope.() -> Unit = {}) {
var rowModifier = Modifier
.fillMaxWidth()
.height(AppBarHeight * fontSizeSqrtMultiplier)
if (!closeBarTitle.isNullOrEmpty()) {
rowModifier = rowModifier.background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f))
}
Column(
Modifier
verticalArrangement = arrangement,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = AppBarHeight * fontSizeSqrtMultiplier)
.padding(horizontal = AppBarHorizontalPadding)
) {
Row(
modifier = Modifier.padding(horizontal = AppBarHorizontalPadding),
content = {
Row(
Modifier.fillMaxWidth().height(AppBarHeight * fontSizeSqrtMultiplier),
rowModifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -37,6 +48,18 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Co
} else {
Spacer(Modifier)
}
if (!closeBarTitle.isNullOrEmpty()) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
closeBarTitle,
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
}
}
Row {
endButtons()
}
@@ -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,
@@ -126,5 +126,6 @@ private fun TopAppBar(
val AppBarHeight = 56.dp
val AppBarHorizontalPadding = 4.dp
val BottomAppBarHeight = 60.dp
private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding
val TitleInsetWithIcon = 72.dp
@@ -6,6 +6,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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 chat.simplex.common.model.ChatController.appPrefs
@@ -24,6 +25,8 @@ fun ModalView(
enableClose: Boolean = true,
background: Color = MaterialTheme.colors.background,
modifier: Modifier = Modifier,
closeOnTop: Boolean = true,
closeBarTitle: String? = null,
endButtons: @Composable RowScope.() -> Unit = {},
content: @Composable () -> Unit,
) {
@@ -32,8 +35,16 @@ fun ModalView(
}
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons)
Box(modifier) { content() }
if (closeOnTop) {
CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons)
}
Box(if (closeOnTop) modifier else modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier)) {
content()
}
}
if (!closeOnTop) {
CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons, arrangement = Arrangement.Bottom, closeBarTitle = closeBarTitle)
}
}
}
@@ -60,17 +71,17 @@ class ModalManager(private val placement: ModalPlacement? = null) {
// java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
fun showModal(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, closeBarTitle: String? = null,endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
val data = ModalData()
showCustomModal { close ->
ModalView(close, showClose = showClose, endButtons = endButtons, content = { data.content() })
ModalView(close, showClose = showClose, closeOnTop = closeOnTop, closeBarTitle = closeBarTitle, endButtons = endButtons, content = { data.content() })
}
}
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, closeBarTitle: String? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
val data = ModalData()
showCustomModal { close ->
ModalView(close, showClose = showClose, endButtons = endButtons, content = { data.content(close) })
ModalView(close, showClose = showClose, endButtons = endButtons, closeOnTop = closeOnTop, closeBarTitle = closeBarTitle, content = { data.content(close) })
}
}
@@ -105,6 +116,8 @@ class ModalManager(private val placement: ModalPlacement? = null) {
val hasModalsOpen: Boolean
@Composable get () = remember { modalCount }.value > 0
fun openModalCount() = modalCount.value
fun closeModal() {
if (modalViews.isNotEmpty()) {
if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex)
@@ -33,7 +33,7 @@ import kotlinx.coroutines.launch
import java.net.URI
@Composable
fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) {
fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, closeAll: () -> Unit) {
val rhId = rh?.remoteHostId
AddGroupLayout(
createGroup = { incognito, groupProfile ->
@@ -47,7 +47,8 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) {
chatModel.chatId.value = groupInfo.id
}
setGroupMembers(rhId, groupInfo, chatModel)
close.invoke()
closeAll.invoke()
if (!groupInfo.incognito) {
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(rhId, groupInfo, creatingGroup = true, chatModel, close)
@@ -1,177 +1,597 @@
package chat.simplex.common.views.newchat
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import SectionDividerSpaced
import SectionItemView
import SectionView
import TextIconSpaced
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.*
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
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
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.IntOffset
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatModel
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.ScrollDirection
import chat.simplex.common.views.contacts.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.distinctUntilChanged
import java.net.URI
@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,
addContact = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) }
},
scanPaste = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
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) }
},
closeNewChatSheet,
)
fun NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) {
val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI }
Column(
modifier = Modifier.fillMaxSize()
) {
if (!oneHandUI.state.value) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.new_chat),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
}
val closeAll = { ModalManager.start.closeModals() }
var modifier = Modifier.fillMaxSize()
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Column(modifier = modifier) {
NewChatSheetLayout(
addContact = {
ModalManager.start.showModalCloseable { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = closeAll ) }
},
scanPaste = {
ModalManager.start.showModalCloseable { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = closeAll) }
},
createGroup = {
ModalManager.start.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) }
},
rh = rh,
close = close,
oneHandUI = oneHandUI
)
}
}
}
private val titles = listOf(
MR.strings.add_contact_tab,
MR.strings.scan_paste_link,
MR.strings.create_group_button
)
private val icons = listOf(MR.images.ic_add_link, MR.images.ic_qr_code, MR.images.ic_group)
enum class ContactType {
CARD, REQUEST, RECENT, CHAT_DELETED, UNLISTED
}
fun chatContactType(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.CHAT_DELETED
contact.contactStatus == ContactStatus.Active -> ContactType.RECENT
else -> ContactType.UNLISTED
}
}
else -> ContactType.UNLISTED
}
}
private fun filterContactTypes(c: List<Chat>, contactTypes: List<ContactType>): List<Chat> {
return c.filter { chat -> contactTypes.contains(chatContactType(chat)) }
}
private var lazyListState = 0 to 0
@Composable
private fun NewChatSheetLayout(
newChatSheetState: StateFlow<AnimatedViewState>,
stopped: Boolean,
rh: RemoteHostInfo?,
addContact: () -> Unit,
scanPaste: () -> Unit,
createGroup: () -> Unit,
closeNewChatSheet: (animated: Boolean) -> Unit,
close: () -> Unit,
oneHandUI: SharedPreference<Boolean>
) {
var newChat by remember { mutableStateOf(newChatSheetState.value) }
val resultingColor = if (isInDarkTheme()) Color.Black.copy(0.64f) else DrawerDefaults.scrimColor
val animatedColor = remember {
Animatable(
if (newChat.isVisible()) Color.Transparent else resultingColor,
Color.VectorConverter(resultingColor.colorSpace)
)
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val baseContactTypes = listOf(ContactType.CARD, ContactType.RECENT, ContactType.REQUEST)
val contactTypes by remember(baseContactTypes, searchText.value.text.isEmpty()) {
derivedStateOf { contactTypesSearchTargets(baseContactTypes, searchText.value.text.isEmpty()) }
}
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
LaunchedEffect(Unit) {
launch {
newChatSheetState.collect {
newChat = it
launch {
animatedColor.animateTo(if (newChat.isVisible()) resultingColor else Color.Transparent, newChatSheetAnimSpec())
}
launch {
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
if (newChat.isHiding()) closeNewChatSheet(false)
val allChats by remember(chatModel.chats.value, contactTypes) {
derivedStateOf { filterContactTypes(chatModel.chats.value, contactTypes) }
}
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
}
val filteredContactChats = filteredContactChats(
showUnreadAndFavorites = showUnreadAndFavorites,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
searchShowingSimplexLink = searchShowingSimplexLink,
searchText = searchText.value.text,
contactChats = allChats
)
var sectionModifier = Modifier.fillMaxWidth()
if (oneHandUI.state.value) {
sectionModifier = sectionModifier.scale(scaleX = 1f, scaleY = -1f)
}
LazyColumnWithScrollBar(
Modifier.fillMaxWidth(),
listState
) {
stickyHeader {
Column(
Modifier
.offset {
val y = if (searchText.value.text.isEmpty()) {
if (oneHandUI.state.value && scrollDirection == ScrollDirection.Up) {
0
} else if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000
} else {
0
}
IntOffset(0, y)
}
.background(MaterialTheme.colors.background)
) {
if (!oneHandUI.state.value) {
Divider()
}
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
oneHandUI = oneHandUI
)
Divider()
}
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val maxWidth = with(LocalDensity.current) { windowWidth() * density }
Column(
Modifier
.fillMaxSize()
.padding(end = endPadding)
.offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else 0, 0) }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { closeNewChatSheet(true) }
.drawBehind { drawRect(animatedColor.value) },
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.End
) {
val actions = remember { listOf(addContact, scanPaste, createGroup) }
val backgroundColor = if (isInDarkTheme())
blendARGB(MaterialTheme.colors.primary, Color.Black, 0.7F)
else
MaterialTheme.colors.background
LazyColumn(Modifier
.graphicsLayer {
alpha = animatedFloat.value
translationY = (1 - animatedFloat.value) * 20.dp.toPx()
}) {
items(actions.size) { index ->
item {
Spacer(Modifier.padding(bottom = DEFAULT_PADDING))
if (searchText.value.text.isEmpty()) {
Row {
Spacer(Modifier.weight(1f))
Box(contentAlignment = Alignment.CenterEnd) {
Button(
actions[index],
shape = RoundedCornerShape(21.dp * fontSizeSqrtMultiplier),
colors = ButtonDefaults.textButtonColors(backgroundColor = backgroundColor),
elevation = null,
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF),
modifier = Modifier.height(42.dp * fontSizeSqrtMultiplier)
) {
Text(
stringResource(titles[index]),
Modifier.padding(start = DEFAULT_PADDING_HALF),
color = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary,
fontWeight = FontWeight.Medium,
)
Icon(
painterResource(icons[index]),
stringResource(titles[index]),
Modifier.size(42.dp * fontSizeSqrtMultiplier),
tint = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary
)
SectionView {
NewChatButton(
icon = painterResource(MR.images.ic_add_link),
text = stringResource(MR.strings.add_contact_tab),
click = addContact,
extraPadding = true,
oneHandUI = oneHandUI.state
)
NewChatButton(
icon = painterResource(MR.images.ic_qr_code),
text = if (appPlatform.isAndroid) stringResource(MR.strings.scan_paste_link) else stringResource(MR.strings.paste_link),
click = scanPaste,
extraPadding = true,
oneHandUI = oneHandUI.state
)
NewChatButton(
icon = painterResource(MR.images.ic_group),
text = stringResource(MR.strings.create_group_button),
click = createGroup,
extraPadding = true,
oneHandUI = oneHandUI.state
)
}
}
SectionDividerSpaced(maxBottomPadding = false)
val deletedContactTypes = listOf(ContactType.CHAT_DELETED)
val deletedChats by remember(chatModel.chats.value, deletedContactTypes) {
derivedStateOf { filterContactTypes(chatModel.chats.value, deletedContactTypes) }
}
if (deletedChats.isNotEmpty()) {
Row(modifier = sectionModifier) {
SectionView {
SectionItemView(
click = {
ModalManager.start.showCustomModal { closeDeletedChats ->
ModalView(
close = closeDeletedChats,
closeOnTop = !oneHandUI.state.value,
closeBarTitle = if (oneHandUI.state.value) generalGetString(MR.strings.deleted_chats) else null,
endButtons = { Spacer(Modifier.minimumInteractiveComponentSize()) }
) {
DeletedContactsView(rh = rh, close = {
ModalManager.start.closeModals()
})
}
}
}
) {
Icon(
painterResource(MR.images.ic_folder_open),
contentDescription = stringResource(MR.strings.deleted_chats),
tint = MaterialTheme.colors.secondary,
)
TextIconSpaced(extraPadding = true)
Text(text = stringResource(MR.strings.deleted_chats), color = MaterialTheme.colors.onBackground)
}
}
}
Spacer(Modifier.width(DEFAULT_PADDING))
SectionDividerSpaced()
}
Spacer(Modifier.height(DEFAULT_PADDING))
}
}
FloatingActionButton(
onClick = { if (!stopped) closeNewChatSheet(true) },
Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING).size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
item {
if (filteredContactChats.isNotEmpty() && !oneHandUI.state.value) {
Text(
stringResource(MR.strings.contact_list_header_title).uppercase(), color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2,
modifier = sectionModifier.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
}
}
ContactListNavLinkView(chat, nextChatSelected, oneHandUI.state)
}
}
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(sectionModifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary
)
}
}
}
}
@Composable
private fun NewChatButton(
icon: Painter,
text: String,
click: () -> Unit,
textColor: Color = Color.Unspecified,
iconColor: Color = MaterialTheme.colors.secondary,
disabled: Boolean = false,
extraPadding: Boolean = false,
oneHandUI: State<Boolean>
) {
SectionItemView(click, disabled = disabled) {
Row(modifier = if (oneHandUI.value) Modifier.scale(scaleX = 1f, scaleY = -1f) else Modifier) {
Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else iconColor)
TextIconSpaced(extraPadding)
Text(text, color = if (disabled) MaterialTheme.colors.secondary else textColor)
}
}
}
@Composable
private fun ContactsSearchBar(
listState: LazyListState,
searchText: MutableState<TextFieldValue>,
searchShowingSimplexLink: MutableState<Boolean>,
searchChatFilteredBySimplexLink: MutableState<String?>,
close: () -> Unit,
oneHandUI: SharedPreference<Boolean>
) {
var modifier = Modifier.fillMaxWidth();
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
var focused by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
val focusRequester = remember { FocusRequester() }
Icon(
painterResource(MR.images.ic_search),
contentDescription = null,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(24.dp * fontSizeSqrtMultiplier),
tint = MaterialTheme.colors.secondary
)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_or_paste_simplex_link),
alwaysVisible = true,
searchText = searchText,
trailingContent = null,
) {
Icon(
painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group),
Modifier.graphicsLayer { alpha = 1 - animatedFloat.value }.size(24.dp * fontSizeSqrtMultiplier)
)
Icon(
painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group),
Modifier.graphicsLayer { alpha = animatedFloat.value }.size(24.dp * fontSizeSqrtMultiplier)
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()
}
}
val view = LocalMultiplatformView()
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
val link = strHasSingleSimplexLink(it.trim())
if (link != null) {
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText =
link.simplexLinkText(link.format.linkType, link.format.smpHosts)
searchText.value =
searchText.value.copy(linkText, selection = TextRange.Zero)
}
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(
link = link.text,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
cleanup = { searchText.value = TextFieldValue() }
)
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
// if some other text is pasted, enter search mode
focusRequester.requestFocus()
} else if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
}
}
}
}
@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 fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<String?>, close: () -> Unit, cleanup: (() -> Unit)?) {
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
URI.create(link),
incognito = null,
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
close = close,
cleanup = cleanup,
)
}
}
private fun filteredContactChats(
showUnreadAndFavorites: Boolean,
searchShowingSimplexLink: State<Boolean>,
searchChatFilteredBySimplexLink: State<String?>,
searchText: String,
contactChats: List<Chat>
): List<Chat> {
val linkChatId = searchChatFilteredBySimplexLink.value
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
return if (linkChatId != null) {
contactChats.filter { it.id == linkChatId }
} else {
contactChats.filter { chat ->
filterChat(
chat = chat,
searchText = s,
showUnreadAndFavorites = showUnreadAndFavorites
)
}
}
.sortedWith(chatsByTypeComparator)
}
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 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)
when {
chat1Type.ordinal < chat2Type.ordinal -> -1
chat1Type.ordinal > chat2Type.ordinal -> 1
else -> chat2.chatInfo.chatTs.compareTo(chat1.chatInfo.chatTs)
}
}
private fun contactTypesSearchTargets(baseContactTypes: List<ContactType>, searchEmpty: Boolean): List<ContactType> {
return if (baseContactTypes.contains(ContactType.CHAT_DELETED) || searchEmpty) {
baseContactTypes
} else {
baseContactTypes + ContactType.CHAT_DELETED
}
}
@Composable
private fun DeletedContactsView(rh: RemoteHostInfo?, close: () -> Unit) {
val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI }
var modifier = Modifier.fillMaxSize()
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Column(
modifier,
) {
if (!oneHandUI.state.value) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.deleted_chats),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
}
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val contactTypes = listOf(ContactType.CHAT_DELETED)
val allChats by remember(chatModel.chats.value, contactTypes) {
derivedStateOf { filterContactTypes(chatModel.chats.value, contactTypes) }
}
val filteredContactChats = filteredContactChats(
showUnreadAndFavorites = showUnreadAndFavorites,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
searchShowingSimplexLink = searchShowingSimplexLink,
searchText = searchText.value.text,
contactChats = allChats
)
LazyColumnWithScrollBar(
Modifier.fillMaxWidth(),
listState
) {
item {
Divider()
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
oneHandUI = oneHandUI
)
Divider()
Spacer(Modifier.padding(bottom = DEFAULT_PADDING))
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
}
}
ContactListNavLinkView(chat, nextChatSelected, oneHandUI.state)
}
}
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,
modifier = if (oneHandUI.state.value) Modifier.scale(scaleX = 1f, scaleY = -1f) else Modifier
)
}
}
}
}
}
@Composable
@@ -267,13 +687,6 @@ fun ActionButton(
@Composable
private fun PreviewNewChatSheet() {
SimpleXTheme {
NewChatSheetLayout(
MutableStateFlow(AnimatedViewState.VISIBLE),
stopped = false,
addContact = {},
scanPaste = {},
createGroup = {},
closeNewChatSheet = {},
)
NewChatSheet(rh = null, close = {})
}
}
@@ -63,7 +63,7 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC
* It will be dropped automatically when connection established or when user goes away from this screen.
* It applies only to Android because on Desktop center space will not be overlapped by [AddContactLearnMore]
**/
if (chatModel.showingInvitation.value != null && (!ModalManager.center.hasModalsOpen() || appPlatform.isDesktop)) {
if (chatModel.showingInvitation.value != null && (ModalManager.start.openModalCount() == 1 || appPlatform.isDesktop)) {
val conn = contactConnection.value
if (chatModel.showingInvitation.value?.connChatUsed == false && conn != null) {
AlertManager.shared.showAlertDialog(
@@ -237,7 +237,8 @@ private fun AddContactLearnMoreButton() {
ModalManager.end.showModalCloseable { close ->
AddContactLearnMore(close)
}
}
},
Modifier.size(18.dp * fontSizeSqrtMultiplier)
) {
Icon(
painterResource(MR.images.ic_info),
@@ -351,6 +351,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>
@@ -442,10 +443,25 @@
<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_search_button">search</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="keep_conversation">Keep conversation</string>
<string name="only_delete_conversation">Only delete conversation</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 Deleted chats.</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 list of chats.</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>
@@ -633,6 +649,7 @@
<string name="new_chat">New chat</string>
<string name="add_contact_tab">Add contact</string>
<string name="scan_paste_link">Scan / Paste link</string>
<string name="paste_link">Paste link</string>
<string name="one_time_link">One-time invitation link</string>
<string name="one_time_link_short">1-time link</string>
<string name="simplex_address">SimpleX address</string>
@@ -651,6 +668,10 @@
<string name="invalid_qr_code">Invalid QR code</string>
<string name="code_you_scanned_is_not_simplex_link_qr_code">The code you scanned is not a SimpleX link QR code.</string>
<string name="deleted_chats">Deleted chats</string>
<string name="no_filtered_contacts">No filtered contacts</string>
<string name="contact_list_header_title">Your contacts</string>
<!-- ScanCodeView.kt -->
<string name="scan_code">Scan code</string>
<string name="incorrect_code">Incorrect security code!</string>
@@ -1123,6 +1144,7 @@
<string name="settings_developer_tools">Developer tools</string>
<string name="settings_experimental_features">Experimental features</string>
<string name="settings_section_title_socks">SOCKS PROXY</string>
<string name="settings_section_title_interface" translatable="false">INTERFACE</string>
<string name="settings_section_title_language" translatable="false">LANGUAGE</string>
<string name="settings_section_title_icon">APP ICON</string>
<string name="settings_section_title_themes">THEMES</string>
@@ -1258,6 +1280,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>
@@ -1450,6 +1473,7 @@
<string name="send_receipts_disabled">disabled</string>
<string name="send_receipts_disabled_alert_title">Receipts are disabled</string>
<string name="send_receipts_disabled_alert_msg">This group has over %1$d members, delivery receipts are not sent.</string>
<string name="action_button_add_members">Invite</string>
<!-- Chat / Chat item info -->
<string name="section_title_for_console">FOR CONSOLE</string>
@@ -1527,6 +1551,17 @@
<string name="message_queue_info_none">none</string>
<string name="message_queue_info_server_info">server queue info: %1$s\n\nlast received msg: %2$s</string>
<string name="cant_call_contact_alert_title">Can\'t call contact</string>
<string name="cant_call_contact_connecting_wait_alert_text">Connecting to contact, please wait or check later!</string>
<string name="cant_call_contact_deleted_alert_text">Contact is deleted.</string>
<string name="allow_calls_question">Allow calls?</string>
<string name="you_need_to_allow_calls">You need to allow your contact to call to be able to call them.</string>
<string name="calls_prohibited_alert_title">Calls prohibited!</string>
<string name="calls_prohibited_ask_to_enable_calls_alert_text">Please ask your contact to enable calls.</string>
<string name="cant_call_member_alert_title">Can\'t call group member</string>
<string name="cant_call_member_send_message_alert_text">Send message to enable calls.</string>
<string name="cant_send_message_to_member_alert_title">Can\'t message group member</string>
<!-- GroupWelcomeView.kt -->
<string name="group_welcome_title">Welcome message</string>
<string name="save_welcome_message_question">Save welcome message?</string>
@@ -52,9 +52,11 @@ actual fun LazyColumnWithScrollBar(
}
}
}
LazyColumn(modifier.then(if (appPlatform.isDesktop) scrollModifier else Modifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout)
Box {
LazyColumn(modifier.then(if (appPlatform.isDesktop) scrollModifier else Modifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout)
}
}
}
}
@@ -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
@@ -22,53 +22,67 @@ import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
// if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
if (!newChatSheetState.collectAsState().value.isVisible()) {
val showMenu = remember { mutableStateOf(false) }
val media = call.peerMedia ?: call.localMedia
CompositionLocalProvider(
LocalIndication provides NoIndication
) {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Box(
Modifier
.padding(end = 71.dp, bottom = 92.dp)
.size(67.dp)
.combinedClickable(onClick = {
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
}
}
},
onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true },
contentAlignment = Alignment.Center
) {
Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) {
ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image)
}
Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) {
if (media == CallMediaType.Video) {
Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White)
} else {
Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White)
actual fun ActiveCallInteractiveArea(call: Call) {
val showMenu = remember { mutableStateOf(false) }
val media = call.peerMedia ?: call.localMedia
CompositionLocalProvider(
LocalIndication provides NoIndication
) {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Box(
Modifier
.padding(end = 71.dp, bottom = 92.dp)
.size(67.dp)
.combinedClickable(onClick = {
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
}
}
DefaultDropdownMenu(showMenu) {
ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = {
withBGApi { chatModel.callManager.endCall(call) }
showMenu.value = false
})
}
},
onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true },
contentAlignment = Alignment.Center
) {
Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) {
ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image)
}
Box(
Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp)
.align(Alignment.TopEnd)
) {
if (media == CallMediaType.Video) {
Icon(
painterResource(MR.images.ic_videocam_filled),
stringResource(MR.strings.icon_descr_video_call),
Modifier.size(18.dp),
tint = Color.White
)
} else {
Icon(
painterResource(MR.images.ic_call_filled),
stringResource(MR.strings.icon_descr_audio_call),
Modifier.size(18.dp),
tint = Color.White
)
}
}
DefaultDropdownMenu(showMenu) {
ItemAction(
stringResource(MR.strings.icon_descr_hang_up),
painterResource(MR.images.ic_call_end_filled),
color = MaterialTheme.colors.error,
onClick = {
withBGApi { chatModel.callManager.endCall(call) }
showMenu.value = false
})
}
}
}
}
}