From e55cd82ec3852a72473ed948472a3a78918ddb72 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 21 Dec 2022 02:55:01 +0300 Subject: [PATCH] android: Live messages (#1612) * android: Live messages * White color * Spacer * button sizes * Do not show voice button in live mode * Add text to the last image in a row Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 12 +- .../java/chat/simplex/app/model/SimpleXAPI.kt | 18 +- .../chat/simplex/app/views/TerminalView.kt | 16 +- .../simplex/app/views/chat/ComposeView.kt | 351 ++++++++++++------ .../simplex/app/views/chat/SendMsgView.kt | 205 ++++++++-- .../app/views/chat/item/ChatItemView.kt | 2 +- .../app/views/chat/item/FramedItemView.kt | 40 +- .../app/views/chat/item/TextItemView.kt | 74 +++- .../app/views/chatlist/ChatPreviewView.kt | 1 - .../app/src/main/res/values/strings.xml | 5 + 10 files changed, 544 insertions(+), 180 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index c3f96e2a90..10132572b0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -21,6 +21,8 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import java.io.File +import kotlin.time.DurationUnit +import kotlin.time.toDuration /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it @@ -1228,8 +1230,10 @@ data class ChatItem ( itemText = generalGetString(R.string.deleted_description), itemStatus = CIStatus.RcvRead(), createdAt = Clock.System.now(), + updatedAt = Clock.System.now(), itemDeleted = false, itemEdited = false, + itemLive = false, editable = false ), content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), @@ -1261,16 +1265,20 @@ data class CIMeta ( val itemText: String, val itemStatus: CIStatus, val createdAt: Instant, + val updatedAt: Instant, val itemDeleted: Boolean, val itemEdited: Boolean, + val itemLive: Boolean?, val editable: Boolean ) { val timestampText: String get() = getTimestampText(itemTs) + val recent: Boolean get() = updatedAt + 10.toDuration(DurationUnit.SECONDS) > Clock.System.now() + val isLive: Boolean get() = itemLive == true companion object { fun getSample( id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(), - itemDeleted: Boolean = false, itemEdited: Boolean = false, editable: Boolean = true + itemDeleted: Boolean = false, itemEdited: Boolean = false, itemLive: Boolean = false, editable: Boolean = true ): CIMeta = CIMeta( itemId = id, @@ -1278,8 +1286,10 @@ data class CIMeta ( itemText = text, itemStatus = status, createdAt = ts, + updatedAt = ts, itemDeleted = itemDeleted, itemEdited = itemEdited, + itemLive = itemLive, editable = editable ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index a7bd23b6ac..8d1ef98ff1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -121,6 +121,7 @@ class AppPreferences(val context: Context) { val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt) val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false) val connectViaLinkTab = mkStrPreference(SHARED_PREFS_CONNECT_VIA_LINK_TAB, ConnectViaLinkTab.SCAN.name) + val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false) val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true) val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false) @@ -214,6 +215,7 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt" private const val SHARED_PREFS_INCOGNITO = "Incognito" private const val SHARED_PREFS_CONNECT_VIA_LINK_TAB = "ConnectViaLinkTab" + private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown" private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase" private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase" private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase" @@ -418,8 +420,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a return null } - suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent): AChatItem? { - val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc) + suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false): AChatItem? { + val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live) val r = sendCmd(cmd) return when (r) { is CR.NewChatItem -> r.chatItem @@ -432,8 +434,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } - suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? { - val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc)) + suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { + val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc, live)) if (r is CR.ChatItemUpdated) return r.chatItem Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null @@ -1541,8 +1543,8 @@ sealed class CC { class ApiStorageEncryption(val config: DBEncryptionConfig): CC() class ApiGetChats: CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() - class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC() - class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() + class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean): CC() + class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class NewGroup(val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() @@ -1612,8 +1614,8 @@ sealed class CC { is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}" is ApiGetChats -> "/_get chats pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") - is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" - is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" + is ApiSendMessage -> "/_send ${chatRef(type, id)} live=${onOff(live)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" + is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is NewGroup -> "/_group ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 33163ecc1c..c1fdc7d105 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -135,7 +135,21 @@ fun TerminalLayout( topBar = { CloseSheetBar(close) }, bottomBar = { Box(Modifier.padding(horizontal = 8.dp)) { - SendMsgView(composeState, false, mutableStateOf(RecordingState.NotStarted), false, false, false, {}, sendCommand, ::onMessageChange, textStyle) + SendMsgView( + composeState = composeState, + showVoiceRecordIcon = false, + recState = mutableStateOf(RecordingState.NotStarted), + isDirectChat = false, + liveMessageAlertShown = SharedPreference(get = { false }, set = {}), + needToAllowVoiceToContact = false, + allowedVoiceByPrefs = false, + allowVoiceToContact = {}, + sendMessage = sendCommand, + sendLiveMessage = null, + updateLiveMessage = null, + ::onMessageChange, + textStyle + ) } }, modifier = Modifier.navigationBarsWithImePadding() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 6d028bd318..5c2de2337b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -3,6 +3,7 @@ package chat.simplex.app.views.chat import ComposeVoiceView import ComposeFileView import android.Manifest +import android.app.Activity import android.content.* import android.content.pm.PackageManager import android.graphics.Bitmap @@ -63,16 +64,25 @@ sealed class ComposeContextItem { @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() } +@Serializable +data class LiveMessage( + val chatItem: ChatItem, + val typedMsg: String, + val sentMsg: String +) + @Serializable data class ComposeState( val message: String = "", + val liveMessage: LiveMessage? = null, val preview: ComposePreview = ComposePreview.NoPreview, val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, val inProgress: Boolean = false, val useLinkPreviews: Boolean ) { - constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this( + constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( editingItem.content.text, + liveMessage, chatItemPreview(editingItem), ComposeContextItem.EditingItem(editingItem), useLinkPreviews = useLinkPreviews @@ -90,7 +100,7 @@ data class ComposeState( is ComposePreview.ImagePreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() + else -> message.isNotEmpty() || liveMessage != null } hasContent && !inProgress } @@ -109,6 +119,16 @@ data class ComposeState( else -> null } + val attachmentDisabled: Boolean + get() { + if (editing || liveMessage != null) return true + return when (preview) { + ComposePreview.NoPreview -> false + is ComposePreview.CLinkPreview -> false + else -> true + } + } + companion object { fun saver(): Saver, *> = Saver( save = { json.encodeToString(serializer(), it.value) }, @@ -321,130 +341,157 @@ fun ComposeView( cancelledLinks.clear() } - fun checkLinkPreview(): MsgContent { - val cs = composeState.value - return when (val composePreview = cs.preview) { - is ComposePreview.CLinkPreview -> { - val url = parseMessage(cs.message) - val lp = composePreview.linkPreview - if (lp != null && url == lp.uri) { - MsgContent.MCLink(cs.message, preview = lp) - } else { - MsgContent.MCText(cs.message) - } - } - else -> MsgContent.MCText(cs.message) + fun clearState(live: Boolean = false) { + if (live) { + composeState.value = composeState.value.copy(inProgress = false) + } else { + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + resetLinkPreview() } - } - - fun updateMsgContent(msgContent: MsgContent): MsgContent { - val cs = composeState.value - return when (msgContent) { - is MsgContent.MCText -> checkLinkPreview() - is MsgContent.MCLink -> checkLinkPreview() - is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image) - is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration) - is MsgContent.MCFile -> MsgContent.MCFile(cs.message) - is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json) - } - } - - fun clearState() { - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) recState.value = RecordingState.NotStarted textStyle.value = smallFont chosenContent.value = emptyList() chosenAudio.value = null chosenFile.value = null - linkUrl.value = null - prevLinkUrl.value = null - pendingLinkUrl.value = null - cancelledLinks.clear() + } + + suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? { + val aChatItem = chatModel.controller.apiSendMessage( + type = cInfo.chatType, + id = cInfo.apiId, + file = file, + quotedItemId = quoted, + mc = mc, + live = live + ) + if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem) + return aChatItem?.chatItem + } + + + + suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? { + val cInfo = chat.chatInfo + val cs = composeState.value + var sent: ChatItem? + val msgText = text ?: cs.message + + fun sending() { + composeState.value = composeState.value.copy(inProgress = true) + } + + fun checkLinkPreview(): MsgContent { + return when (val composePreview = cs.preview) { + is ComposePreview.CLinkPreview -> { + val url = parseMessage(msgText) + val lp = composePreview.linkPreview + if (lp != null && url == lp.uri) { + MsgContent.MCLink(msgText, preview = lp) + } else { + MsgContent.MCText(msgText) + } + } + else -> MsgContent.MCText(msgText) + } + } + + fun updateMsgContent(msgContent: MsgContent): MsgContent { + return when (msgContent) { + is MsgContent.MCText -> checkLinkPreview() + is MsgContent.MCLink -> checkLinkPreview() + is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image) + is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration) + is MsgContent.MCFile -> MsgContent.MCFile(msgText) + is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json) + } + } + + suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? { + val oldMsgContent = ei.content.msgContent + if (oldMsgContent != null) { + val updatedItem = chatModel.controller.apiUpdateChatItem( + type = cInfo.chatType, + id = cInfo.apiId, + itemId = ei.meta.itemId, + mc = updateMsgContent(oldMsgContent), + live = live + ) + if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) + return updatedItem?.chatItem + } + return null + } + + if (!live) { + sending() + } + + if (cs.contextItem is ComposeContextItem.EditingItem) { + val ei = cs.contextItem.chatItem + sent = updateMessage(ei, cInfo, live) + } else if (cs.liveMessage != null) { + sent = updateMessage(cs.liveMessage.chatItem, cInfo, live) + } else { + val msgs: ArrayList = ArrayList() + val files: ArrayList = ArrayList() + when (val preview = cs.preview) { + ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) + is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) + is ComposePreview.ImagePreview -> { + chosenContent.value.forEachIndexed { index, it -> + val file = when (it) { + is UploadContent.SimpleImage -> saveImage(context, it.uri) + is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri) + } + if (file != null) { + files.add(file) + msgs.add(MsgContent.MCImage(if (chosenContent.value.lastIndex == index) msgText else "", preview.images[index])) + } + } + } + is ComposePreview.VoicePreview -> { + val chosenAudioVal = chosenAudio.value + if (chosenAudioVal != null) { + val file = chosenAudioVal.first.toFile().name + files.add((file)) + chatModel.filesToDelete.remove(chosenAudioVal.first.toFile()) + AudioPlayer.stop(chosenAudioVal.first.toFile().absolutePath) + msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", chosenAudioVal.second / 1000)) + } + } + is ComposePreview.FilePreview -> { + val chosenFileVal = chosenFile.value + if (chosenFileVal != null) { + val file = saveFileFromUri(context, chosenFileVal) + if (file != null) { + files.add((file)) + msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) + } + } + } + } + val quotedItemId: Long? = when (cs.contextItem) { + is ComposeContextItem.QuotedItem -> cs.contextItem.chatItem.id + else -> null + } + sent = null + msgs.forEachIndexed { index, content -> + if (index > 0) delay(100) + sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index), + if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false + ) + } + if (sent == null && chosenContent.value.isNotEmpty()) { + sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live) + } + } + clearState(live) + return sent } fun sendMessage() { - composeState.value = composeState.value.copy(inProgress = true) - val cInfo = chat.chatInfo - val cs = composeState.value - when (val contextItem = cs.contextItem) { - is ComposeContextItem.EditingItem -> { - val ei = contextItem.chatItem - val oldMsgContent = ei.content.msgContent - if (oldMsgContent != null) { - withApi { - val updatedItem = chatModel.controller.apiUpdateChatItem( - type = cInfo.chatType, - id = cInfo.apiId, - itemId = ei.meta.itemId, - mc = updateMsgContent(oldMsgContent) - ) - if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) - clearState() - } - } - } - else -> { - val msgs: ArrayList = ArrayList() - val files: ArrayList = ArrayList() - when (val preview = cs.preview) { - ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(cs.message)) - is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) - is ComposePreview.ImagePreview -> { - chosenContent.value.forEachIndexed { index, it -> - val file = when (it) { - is UploadContent.SimpleImage -> saveImage(context, it.uri) - is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri) - } - if (file != null) { - files.add(file) - msgs.add(MsgContent.MCImage(if (msgs.isEmpty()) cs.message else "", preview.images[index])) - } - } - } - is ComposePreview.VoicePreview -> { - val chosenAudioVal = chosenAudio.value - if (chosenAudioVal != null) { - val file = chosenAudioVal.first.toFile().name - files.add((file)) - chatModel.filesToDelete.remove(chosenAudioVal.first.toFile()) - AudioPlayer.stop(chosenAudioVal.first.toFile().absolutePath) - msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000)) - } - } - is ComposePreview.FilePreview -> { - val chosenFileVal = chosenFile.value - if (chosenFileVal != null) { - val file = saveFileFromUri(context, chosenFileVal) - if (file != null) { - files.add((file)) - msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) cs.message else "")) - } - } - } - } - val quotedItemId: Long? = when (contextItem) { - is ComposeContextItem.QuotedItem -> contextItem.chatItem.id - else -> null - } - if (msgs.isNotEmpty()) { - withApi { - msgs.forEachIndexed { index, content -> - if (index > 0) delay(100) - val aChatItem = chatModel.controller.apiSendMessage( - type = cInfo.chatType, - id = cInfo.apiId, - file = files.getOrNull(index), - quotedItemId = if (index == 0) quotedItemId else null, - mc = content - ) - if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem) - } - clearState() - } - } else { - clearState() - } - } + withBGApi { + sendMessageAsync(null, false) } } @@ -510,6 +557,52 @@ fun ComposeView( chosenFile.value = null } + fun truncateToWords(s: String): String { + var acc = "" + val word = StringBuilder() + for (c in s) { + if (c.isLetter() || c.isDigit()) { + word.append(c) + } else { + acc = acc + word.toString() + c + word.clear() + } + } + return acc + } + + suspend fun sendLiveMessage() { + val typedMsg = composeState.value.message + val sentMsg = truncateToWords(typedMsg) + if (composeState.value.liveMessage == null) { + val ci = sendMessageAsync(sentMsg, live = true) + if (ci != null) { + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg)) + } + } + } + + fun liveMessageToSend(lm: LiveMessage, t: String): String? { + val s = if (t != lm.typedMsg) truncateToWords(t) else t + return if (s != lm.sentMsg) s else null + } + + suspend fun updateLiveMessage() { + val typedMsg = composeState.value.message + val liveMessage = composeState.value.liveMessage + if (liveMessage != null) { + val sentMsg = liveMessageToSend(liveMessage, typedMsg) + if (sentMsg != null) { + val ci = sendMessageAsync(sentMsg, live = true) + if (ci != null) { + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg)) + } + } else if (liveMessage.typedMsg != typedMsg) { + composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg)) + } + } + } + @Composable fun previewView() { when (val preview = composeState.value.preview) { @@ -572,13 +665,11 @@ fun ComposeView( modifier = Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom, ) { - val attachEnabled = !composeState.value.editing && - (composeState.value.preview is ComposePreview.NoPreview || composeState.value.preview is ComposePreview.CLinkPreview) - IconButton(showChooseAttachment, enabled = attachEnabled) { + IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled) { Icon( Icons.Filled.AttachFile, contentDescription = stringResource(R.string.attach), - tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight, + tint = if (!composeState.value.attachmentDisabled) MaterialTheme.colors.primary else HighOrLowlight, modifier = Modifier .size(28.dp) .clip(CircleShape) @@ -609,11 +700,23 @@ fun ComposeView( } } + val activity = LocalContext.current as Activity + DisposableEffect(Unit) { + val orientation = activity.resources.configuration.orientation + onDispose { + if (orientation == activity.resources.configuration.orientation && composeState.value.liveMessage != null) { + sendMessage() + resetLinkPreview() + } + } + } + SendMsgView( composeState, showVoiceRecordIcon = true, recState, chat.chatInfo is ChatInfo.Direct, + liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, needToAllowVoiceToContact, allowedVoiceByPrefs, allowVoiceToContact = ::allowVoiceToContact, @@ -621,8 +724,10 @@ fun ComposeView( sendMessage() resetLinkPreview() }, - ::onMessageChange, - textStyle + sendLiveMessage = ::sendLiveMessage, + updateLiveMessage = ::updateLiveMessage, + onMessageChange = ::onMessageChange, + textStyle = textStyle ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index c6e40f9a41..f50e9285ef 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -10,6 +10,7 @@ import android.text.InputType import android.view.ViewGroup import android.view.inputmethod.* import android.widget.EditText +import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* @@ -18,17 +19,21 @@ import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.* import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.* import androidx.compose.ui.viewinterop.AndroidView import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.inputmethod.EditorInfoCompat @@ -39,6 +44,7 @@ import chat.simplex.app.SimplexApp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.item.ItemAction import chat.simplex.app.views.helpers.* import com.google.accompanist.permissions.rememberMultiplePermissionsState import kotlinx.coroutines.* @@ -49,10 +55,13 @@ fun SendMsgView( showVoiceRecordIcon: Boolean, recState: MutableState, isDirectChat: Boolean, + liveMessageAlertShown: SharedPreference, needToAllowVoiceToContact: Boolean, allowedVoiceByPrefs: Boolean, allowVoiceToContact: () -> Unit, sendMessage: () -> Unit, + sendLiveMessage: ( suspend () -> Unit)? = null, + updateLiveMessage: (suspend () -> Unit)? = null, onMessageChange: (String) -> Unit, textStyle: MutableState ) { @@ -60,35 +69,80 @@ fun SendMsgView( val cs = composeState.value val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview) val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && - (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) + cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) NativeKeyboard(composeState, textStyle, onMessageChange) // Disable clicks on text field if (cs.preview is ComposePreview.VoicePreview) { Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { })) } Box(Modifier.align(Alignment.BottomEnd)) { + val sendButtonSize = remember { Animatable(36f) } + val sendButtonAlpha = remember { Animatable(1f) } val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO)) + val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { + // Making LiveMessage alive when screen orientation was changed + if (cs.liveMessage != null && sendLiveMessage != null && updateLiveMessage != null) { + startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) + } + } when { showProgress -> ProgressIndicator() - showVoiceButton -> when { - needToAllowVoiceToContact || !allowedVoiceByPrefs -> { - DisallowedVoiceButton { - if (needToAllowVoiceToContact) { - showNeedToAllowVoiceAlert(allowVoiceToContact) - } else { - showDisabledVoiceAlert(isDirectChat) + showVoiceButton -> { + Row(verticalAlignment = Alignment.CenterVertically) { + val stopRecOnNextClick = remember { mutableStateOf(false) } + when { + needToAllowVoiceToContact || !allowedVoiceByPrefs -> { + DisallowedVoiceButton { + if (needToAllowVoiceToContact) { + showNeedToAllowVoiceAlert(allowVoiceToContact) + } else { + showDisabledVoiceAlert(isDirectChat) + } + } + } + !permissionsState.allPermissionsGranted -> + VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() } + else -> + RecordVoiceView(recState, stopRecOnNextClick) + } + if (sendLiveMessage != null && updateLiveMessage != null && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)) { + Spacer(Modifier.width(10.dp)) + StartLiveMessageButton { + if (composeState.value.preview is ComposePreview.NoPreview) { + startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) + } } } } - !permissionsState.allPermissionsGranted -> - VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() } - else -> - RecordVoiceView(recState) } else -> { - val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward + val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight - SendTextButton(icon, color, cs.sendEnabled(), sendMessage) + if (composeState.value.liveMessage == null && + cs.preview !is ComposePreview.VoicePreview && !cs.editing && + sendLiveMessage != null && updateLiveMessage != null + ) { + var showDropdown by rememberSaveable { mutableStateOf(false) } + SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) { showDropdown = true } + + DropdownMenu( + expanded = showDropdown, + onDismissRequest = { showDropdown = false }, + Modifier.width(220.dp), + ) { + ItemAction( + generalGetString(R.string.send_live_message), + Icons.Filled.MoreHoriz, + onClick = { + startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) + showDropdown = false + } + ) + } + } else { + SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) + } } } } @@ -188,7 +242,7 @@ private fun NativeKeyboard( } @Composable -private fun RecordVoiceView(recState: MutableState) { +private fun RecordVoiceView(recState: MutableState, stopRecOnNextClick: MutableState) { val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) } DisposableEffect(Unit) { onDispose { rec.stop() } } val stopRecordingAndAddAudio: () -> Unit = { @@ -196,11 +250,10 @@ private fun RecordVoiceView(recState: MutableState) { recState.value = RecordingState.Finished(it, rec.stop()) } } - var stopRecOnNextClick by remember { mutableStateOf(false) } - if (stopRecOnNextClick) { + if (stopRecOnNextClick.value) { LaunchedEffect(recState.value) { if (recState.value is RecordingState.NotStarted) { - stopRecOnNextClick = false + stopRecOnNextClick.value = false } } // Lock orientation to current orientation because screen rotation will break the recording @@ -223,11 +276,11 @@ private fun RecordVoiceView(recState: MutableState) { val interactionSource = interactionSourceWithTapDetection( onPress = { if (recState.value is RecordingState.NotStarted) startRecording() }, onClick = { - if (stopRecOnNextClick) { + if (stopRecOnNextClick.value) { stopRecordingAndAddAudio() } else { // tapped and didn't hold a finger - stopRecOnNextClick = true + stopRecOnNextClick.value = true } }, onCancel = stopRecordingAndAddAudio, @@ -316,21 +369,122 @@ private fun ProgressIndicator() { } @Composable -private fun SendTextButton(icon: ImageVector, backgroundColor: Color, enabled: Boolean, sendMessage: () -> Unit) { - IconButton(sendMessage, Modifier.size(36.dp), enabled = enabled) { +private fun SendTextButton( + icon: ImageVector, + backgroundColor: Color, + sizeDp: Animatable, + alpha: Animatable, + enabled: Boolean, + sendMessage: () -> Unit, + onLongClick: (() -> Unit)? = null +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier.requiredSize(36.dp) + .combinedClickable( + onClick = sendMessage, + onLongClick = onLongClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = rememberRipple(bounded = false, radius = 24.dp) + ), + contentAlignment = Alignment.Center + ) { Icon( icon, stringResource(R.string.icon_descr_send_message), tint = Color.White, + modifier = Modifier + .size(sizeDp.value.dp) + .padding(4.dp) + .alpha(alpha.value) + .clip(CircleShape) + .background(backgroundColor) + .padding(3.dp) + ) + } +} + +@Composable +private fun StartLiveMessageButton(onClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier.requiredSize(36.dp) + .clickable( + onClick = onClick, + enabled = true, + role = Role.Button, + interactionSource = interactionSource, + indication = rememberRipple(bounded = false, radius = 24.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.MoreHoriz, + stringResource(R.string.icon_descr_send_message), + tint = Color.White, modifier = Modifier .size(36.dp) .padding(4.dp) .clip(CircleShape) - .background(backgroundColor) + .background(MaterialTheme.colors.primary) + .padding(1.dp) ) } } +private fun startLiveMessage( + scope: CoroutineScope, + send: suspend () -> Unit, + update: suspend () -> Unit, + sendButtonSize: Animatable, + sendButtonAlpha: Animatable, + composeState: MutableState, + liveMessageAlertShown: SharedPreference +) { + fun run() { + scope.launch { + while (composeState.value.liveMessage != null) { + sendButtonSize.animateTo(if (sendButtonSize.value == 36f) 32f else 36f, tween(700, 50)) + } + sendButtonSize.snapTo(36f) + } + scope.launch { + while (composeState.value.liveMessage != null) { + sendButtonAlpha.animateTo(if (sendButtonAlpha.value == 1f) 0.75f else 1f, tween(700, 50)) + } + sendButtonAlpha.snapTo(1f) + } + scope.launch { + while (composeState.value.liveMessage != null) { + delay(3000) + update() + } + } + } + + fun start() = withBGApi { + if (composeState.value.liveMessage == null) { + send() + } + run() + } + + if (liveMessageAlertShown.state.value) { + start() + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.live_message), + text = generalGetString(R.string.send_live_message_desc), + confirmText = generalGetString(R.string.send_verb), + onConfirm = { + liveMessageAlertShown.set(true) + start() + }) + } +} + private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(R.string.allow_voice_messages_question), @@ -369,6 +523,7 @@ fun PreviewSendMsgView() { showVoiceRecordIcon = false, recState = mutableStateOf(RecordingState.NotStarted), isDirectChat = true, + liveMessageAlertShown = SharedPreference(get = { true }, set = { }), needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, allowVoiceToContact = {}, @@ -396,6 +551,7 @@ fun PreviewSendMsgViewEditing() { showVoiceRecordIcon = false, recState = mutableStateOf(RecordingState.NotStarted), isDirectChat = true, + liveMessageAlertShown = SharedPreference(get = { true }, set = { }), needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, allowVoiceToContact = {}, @@ -423,6 +579,7 @@ fun PreviewSendMsgViewInProgress() { showVoiceRecordIcon = false, recState = mutableStateOf(RecordingState.NotStarted), isDirectChat = true, + liveMessageAlertShown = SharedPreference(get = { true }, set = { }), needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, allowVoiceToContact = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 7254a1fb7b..1d18c9e5c3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -176,7 +176,7 @@ fun ChatItemView( if (cItem.meta.itemDeleted && !revealed.value) { MarkedDeletedItemView(cItem, showMember = showMember) MarkedDeletedItemDropdownMenu() - } else if (cItem.quotedItem == null && !cItem.meta.itemDeleted) { + } else if (cItem.quotedItem == null && !cItem.meta.itemDeleted && !cItem.meta.isLive) { if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { EmojiItemView(cItem) MsgContentItemDropdownMenu() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 9c40e381f0..4f8233ddf5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.* import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.stringResource @@ -67,7 +68,7 @@ fun FramedItemView( } @Composable - fun ciDeletedView() { + fun FramedItemHeader(caption: String, italic: Boolean, icon: ImageVector? = null) { Row( Modifier .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight) @@ -78,15 +79,19 @@ fun FramedItemView( .padding(bottom = if (ci.quotedItem == null) 6.dp else 0.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon( - Icons.Outlined.Delete, - stringResource(R.string.marked_deleted_description), - Modifier.size(18.dp), - tint = if (isInDarkTheme()) FileDark else FileLight - ) + if (icon != null) { + Icon( + icon, + caption, + Modifier.size(18.dp), + tint = if (isInDarkTheme()) FileDark else FileLight + ) + } Text( buildAnnotatedString { - withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) } + withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = HighOrLowlight)) { + append(caption) + } }, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), ) @@ -138,12 +143,12 @@ fun FramedItemView( @Composable fun ciFileView(ci: ChatItem, text: String) { CIFileView(ci.file, ci.meta.itemEdited, receiveFile) - if (text != "") { + if (text != "" || ci.meta.isLive) { CIMarkdownText(ci, showMember, linkMode = linkMode, uriHandler) } } - val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && ci.content.text.isEmpty() && ci.quotedItem == null + val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null Box(Modifier .clip(RoundedCornerShape(18.dp)) @@ -158,9 +163,13 @@ fun FramedItemView( Box(contentAlignment = Alignment.BottomEnd) { Column(Modifier.width(IntrinsicSize.Max)) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { - if (ci.meta.itemDeleted) { ciDeletedView() } + if (ci.meta.itemDeleted) { + FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete) + } else if (ci.meta.isLive) { + FramedItemHeader(stringResource(R.string.live), false) + } ci.quotedItem?.let { ciQuoteView(it) } - if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) { + if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { Column( Modifier @@ -176,7 +185,7 @@ fun FramedItemView( when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile) - if (mc.text == "") { + if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { CIMarkdownText(ci, showMember, linkMode, uriHandler) @@ -220,9 +229,10 @@ fun CIMarkdownText( onLinkLongClick: (link: String) -> Unit = {} ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { + val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text MarkdownText( - ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null, - metaText = ci.timestampText, edited = ci.meta.itemEdited, linkMode = linkMode, + text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null, + meta = ci.meta, linkMode = linkMode, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt index 7df95365f6..f8c9947d5c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt @@ -1,16 +1,20 @@ package chat.simplex.app.views.chat.item +import android.app.Activity import android.content.ActivityNotFoundException import android.util.Log +import androidx.annotation.IntRange import androidx.compose.foundation.text.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.* import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow @@ -18,7 +22,9 @@ import androidx.compose.ui.unit.* import androidx.core.text.BidiFormatter import chat.simplex.app.TAG import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.helpers.detectGesture +import kotlinx.coroutines.* val reserveTimestampStyle = SpanStyle(color = Color.Transparent) val boldFont = SpanStyle(fontWeight = FontWeight.Medium) @@ -40,13 +46,30 @@ fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolea } } +private val noTyping: AnnotatedString = AnnotatedString(" ") + +private val typingIndicators: List = listOf( + typing(FontWeight.Black) + typing() + typing(), + typing(FontWeight.Bold) + typing(FontWeight.Black) + typing(), + typing() + typing(FontWeight.Bold) + typing(FontWeight.Black), + typing() + typing() + typing(FontWeight.Bold) +) + + +private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString { + pushStyle(SpanStyle(color = HighOrLowlight, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp)) + append(if (recent) typingIndicators[typingIdx] else noTyping) +} + +private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = + AnnotatedString(".", SpanStyle(fontWeight = w)) + @Composable fun MarkdownText ( text: String, formattedText: List? = null, sender: String? = null, - metaText: String? = null, - edited: Boolean = false, + meta: CIMeta? = null, style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp), maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, @@ -60,21 +83,57 @@ fun MarkdownText ( if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } val reserve = when { - textLayoutDirection != LocalLayoutDirection.current && metaText != null -> "\n" - edited -> " " + textLayoutDirection != LocalLayoutDirection.current && meta != null -> "\n" + meta?.itemEdited == true -> " " else -> " " } + val scope = rememberCoroutineScope() CompositionLocalProvider( LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current) if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr else LocalLayoutDirection.current ) { + var timer: Job? by remember { mutableStateOf(null) } + var typingIdx by rememberSaveable { mutableStateOf(0) } + fun stopTyping() { + timer?.cancel() + timer = null + } + fun switchTyping() { + if (meta != null && meta.isLive && meta.recent) { + timer = timer ?: scope.launch { + while (isActive) { + typingIdx = (typingIdx + 1) % typingIndicators.size + delay(250) + } + } + } else { + stopTyping() + } + } + if (meta?.isLive == true) { + val activity = LocalContext.current as Activity + LaunchedEffect(meta.recent, meta.isLive) { + switchTyping() + } + DisposableEffect(Unit) { + val orientation = activity.resources.configuration.orientation + onDispose { + if (orientation == activity.resources.configuration.orientation) { + stopTyping() + } + } + } + } if (formattedText == null) { val annotatedText = buildAnnotatedString { appendSender(this, sender, senderBold) append(text) - if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) } + if (meta?.isLive == true) { + append(typingIndicator(meta.recent, typingIdx)) + } + if (meta != null) withStyle(reserveTimestampStyle) { append(reserve + meta.timestampText) } } Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow) } else { @@ -100,10 +159,13 @@ fun MarkdownText ( } } } + if (meta?.isLive == true) { + append(typingIndicator(meta.recent, typingIdx)) + } // With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good /*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl) withStyle(reserveTimestampStyle) { append("\n" + metaText) } - else */if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) } + else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve + meta.timestampText) } } if (hasLinks && uriHandler != null) { ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 680619a4d4..7bdc675a6d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -97,7 +97,6 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null, linkMode = linkMode, senderBold = true, - metaText = null, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp), diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 5820cd060c..e96855a759 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -27,6 +27,7 @@ you unknown message format invalid message format + LIVE connection %1$d @@ -264,6 +265,10 @@ Voice messages prohibited! Please ask your contact to enable sending voice messages. Only group owners can enable voice messages. + Send live message + Live message! + Send a live message - it will update for the recipient(s) as you type it + Send Back