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>
This commit is contained in:
Stanislav Dmitrenko
2022-12-21 02:55:01 +03:00
committed by GitHub
parent 34e08b2058
commit e55cd82ec3
10 changed files with 544 additions and 180 deletions
@@ -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
)
}
@@ -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}"
@@ -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()
@@ -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<MutableState<ComposeState>, *> = 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<MsgContent> = ArrayList()
val files: ArrayList<String> = 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<MsgContent> = ArrayList()
val files: ArrayList<String> = 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
)
}
}
@@ -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<RecordingState>,
isDirectChat: Boolean,
liveMessageAlertShown: SharedPreference<Boolean>,
needToAllowVoiceToContact: Boolean,
allowedVoiceByPrefs: Boolean,
allowVoiceToContact: () -> Unit,
sendMessage: () -> Unit,
sendLiveMessage: ( suspend () -> Unit)? = null,
updateLiveMessage: (suspend () -> Unit)? = null,
onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle>
) {
@@ -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<RecordingState>) {
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
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<RecordingState>) {
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<RecordingState>) {
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<Float, AnimationVector1D>,
alpha: Animatable<Float, AnimationVector1D>,
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<Float, AnimationVector1D>,
sendButtonAlpha: Animatable<Float, AnimationVector1D>,
composeState: MutableState<ComposeState>,
liveMessageAlertShown: SharedPreference<Boolean>
) {
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 = {},
@@ -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()
@@ -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
)
}
@@ -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<AnnotatedString> = 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<FormattedText>? = 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,
@@ -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),
@@ -27,6 +27,7 @@
<string name="sender_you_pronoun">you</string>
<string name="unknown_message_format">unknown message format</string>
<string name="invalid_message_format">invalid message format</string>
<string name="live">LIVE</string>
<!-- PendingContactConnection - ChatModel.kt -->
<string name="connection_local_display_name">connection <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
@@ -264,6 +265,10 @@
<string name="voice_messages_prohibited">Voice messages prohibited!</string>
<string name="ask_your_contact_to_enable_voice">Please ask your contact to enable sending voice messages.</string>
<string name="only_group_owners_can_enable_voice">Only group owners can enable voice messages.</string>
<string name="send_live_message">Send live message</string>
<string name="live_message">Live message!</string>
<string name="send_live_message_desc">Send a live message - it will update for the recipient(s) as you type it</string>
<string name="send_verb">Send</string>
<!-- General Actions / Responses -->
<string name="back">Back</string>