feat(ui): move quick chat menu to topBar (#2535)

This commit is contained in:
Pedro 2025-07-27 11:13:25 -03:00 committed by GitHub
parent 712ff946f5
commit f8aa6ebff5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 124 additions and 160 deletions

View file

@ -46,6 +46,7 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
@ -53,6 +54,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.SpeakerNotesOff
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -101,9 +103,9 @@ import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.node.components.NodeKeyStatusIcon import com.geeksville.mesh.ui.node.components.NodeKeyStatusIcon
import com.geeksville.mesh.ui.node.components.NodeMenuAction import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.sharing.SharedContactDialog import com.geeksville.mesh.ui.sharing.SharedContactDialog
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.nio.charset.StandardCharsets
private const val MESSAGE_CHARACTER_LIMIT_BYTES = 200 private const val MESSAGE_CHARACTER_LIMIT_BYTES = 200
private const val SNIPPET_CHARACTER_LIMIT = 50 private const val SNIPPET_CHARACTER_LIMIT = 50
@ -136,12 +138,8 @@ internal fun MessageScreen(
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(initialValue = false) val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(initialValue = false)
val channels by viewModel.channels.collectAsStateWithLifecycle() val channels by viewModel.channels.collectAsStateWithLifecycle()
val quickChatActions by val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList())
viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList()) val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(initialValue = emptyList())
val messages by
viewModel
.getMessagesFrom(contactKey)
.collectAsStateWithLifecycle(initialValue = emptyList())
// UI State managed within this Composable // UI State managed within this Composable
var replyingTo by rememberSaveable { mutableStateOf<Message?>(null) } var replyingTo by rememberSaveable { mutableStateOf<Message?>(null) }
@ -149,14 +147,14 @@ internal fun MessageScreen(
var sharedContact by rememberSaveable { mutableStateOf<Node?>(null) } var sharedContact by rememberSaveable { mutableStateOf<Node?>(null) }
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) } val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
val messageInputState = rememberTextFieldState(message) val messageInputState = rememberTextFieldState(message)
var showQuickChat by rememberSaveable { mutableStateOf(false) }
// Derived state, memoized for performance // Derived state, memoized for performance
val channelInfo = val channelInfo =
remember(contactKey, channels) { remember(contactKey, channels) {
val index = contactKey.firstOrNull()?.digitToIntOrNull() val index = contactKey.firstOrNull()?.digitToIntOrNull()
val id = contactKey.substring(1) val id = contactKey.substring(1)
val name = val name = index?.let { channels.getChannel(it)?.name } // channels can be null initially
index?.let { channels.getChannel(it)?.name } // channels can be null initially
Triple(index, id, name) Triple(index, id, name)
} }
val (channelIndex, nodeId, rawChannelName) = channelInfo val (channelIndex, nodeId, rawChannelName) = channelInfo
@ -180,8 +178,7 @@ internal fun MessageScreen(
val listState = val listState =
rememberLazyListState( rememberLazyListState(
initialFirstVisibleItemIndex = initialFirstVisibleItemIndex = remember(messages) { messages.indexOfLast { !it.read }.coerceAtLeast(0) },
remember(messages) { messages.indexOfLast { !it.read }.coerceAtLeast(0) }
) )
val onEvent: (MessageScreenEvent) -> Unit = val onEvent: (MessageScreenEvent) -> Unit =
@ -227,13 +224,10 @@ internal fun MessageScreen(
is MessageScreenEvent.SetTitle -> viewModel.setTitle(event.title) is MessageScreenEvent.SetTitle -> viewModel.setTitle(event.title)
is MessageScreenEvent.NavigateToMessages -> navigateToMessages(event.contactKey) is MessageScreenEvent.NavigateToMessages -> navigateToMessages(event.contactKey)
is MessageScreenEvent.NavigateToNodeDetails -> is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum)
navigateToNodeDetails(event.nodeNum)
MessageScreenEvent.NavigateBack -> onNavigateBack() MessageScreenEvent.NavigateBack -> onNavigateBack()
is MessageScreenEvent.CopyToClipboard -> { is MessageScreenEvent.CopyToClipboard -> {
clipboardManager.nativeClipboard.setPrimaryClip( clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(event.text, event.text))
ClipData.newPlainText(event.text, event.text)
)
selectedMessageIds.value = emptySet() selectedMessageIds.value = emptySet()
} }
} }
@ -243,16 +237,12 @@ internal fun MessageScreen(
if (showDeleteDialog) { if (showDeleteDialog) {
DeleteMessageDialog( DeleteMessageDialog(
count = selectedMessageIds.value.size, count = selectedMessageIds.value.size,
onConfirm = { onConfirm = { onEvent(MessageScreenEvent.DeleteMessages(selectedMessageIds.value.toList())) },
onEvent(MessageScreenEvent.DeleteMessages(selectedMessageIds.value.toList()))
},
onDismiss = { showDeleteDialog = false }, onDismiss = { showDeleteDialog = false },
) )
} }
sharedContact?.let { contact -> sharedContact?.let { contact -> SharedContactDialog(contact = contact, onDismiss = { sharedContact = null }) }
SharedContactDialog(contact = contact, onDismiss = { sharedContact = null })
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -291,6 +281,8 @@ internal fun MessageScreen(
onNavigateBack = { onEvent(MessageScreenEvent.NavigateBack) }, onNavigateBack = { onEvent(MessageScreenEvent.NavigateBack) },
channels = channels, channels = channels,
channelIndexParam = channelIndex, channelIndexParam = channelIndex,
showQuickChat = showQuickChat,
onToggleQuickChat = { showQuickChat = !showQuickChat },
) )
} }
}, },
@ -302,40 +294,32 @@ internal fun MessageScreen(
listState = listState, listState = listState,
messages = messages, messages = messages,
selectedIds = selectedMessageIds, selectedIds = selectedMessageIds,
onUnreadChanged = { messageId -> onUnreadChanged = { messageId -> onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) },
onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) },
},
onSendReaction = { emoji, id ->
onEvent(MessageScreenEvent.SendReaction(emoji, id))
},
viewModel = viewModel, viewModel = viewModel,
contactKey = contactKey, contactKey = contactKey,
onReply = { message -> replyingTo = message }, onReply = { message -> replyingTo = message },
onNodeMenuAction = { action -> onNodeMenuAction = { action -> onEvent(MessageScreenEvent.HandleNodeMenuAction(action)) },
onEvent(MessageScreenEvent.HandleNodeMenuAction(action))
},
) )
// Show FAB if we can scroll towards the newest messages (index 0). // Show FAB if we can scroll towards the newest messages (index 0).
if (listState.canScrollBackward) { if (listState.canScrollBackward) {
ScrollToBottomFab(coroutineScope, listState) ScrollToBottomFab(coroutineScope, listState)
} }
} }
QuickChatRow( AnimatedVisibility(visible = showQuickChat) {
enabled = isConnected, QuickChatRow(
actions = quickChatActions, enabled = isConnected,
onClick = { action -> actions = quickChatActions,
handleQuickChatAction( onClick = { action ->
action = action, handleQuickChatAction(
messageInputState = messageInputState, action = action,
onSendMessage = { text -> onEvent(MessageScreenEvent.SendMessage(text)) }, messageInputState = messageInputState,
) onSendMessage = { text -> onEvent(MessageScreenEvent.SendMessage(text)) },
}, )
) },
ReplySnippet( )
originalMessage = replyingTo, }
onClearReply = { replyingTo = null }, ReplySnippet(originalMessage = replyingTo, onClearReply = { replyingTo = null }, ourNode = ourNode)
ourNode = ourNode,
)
MessageInput( MessageInput(
isEnabled = isConnected, isEnabled = isConnected,
textFieldState = messageInputState, textFieldState = messageInputState,
@ -391,10 +375,10 @@ private fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ou
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.clip(RoundedCornerShape(24.dp)) .clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
@ -404,11 +388,7 @@ private fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ou
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( Text(
text = text = stringResource(R.string.replying_to, replyingToNodeUser?.shortName ?: unknownUserText),
stringResource(
R.string.replying_to,
replyingToNodeUser?.shortName ?: unknownUserText,
),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
) )
Text( Text(
@ -421,8 +401,7 @@ private fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ou
IconButton(onClick = onClearReply) { IconButton(onClick = onClearReply) {
Icon( Icon(
Icons.Filled.Close, Icons.Filled.Close,
contentDescription = contentDescription = stringResource(R.string.cancel_reply), // Specific action
stringResource(R.string.cancel_reply), // Specific action
) )
} }
} }
@ -437,12 +416,10 @@ private fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ou
* @return The ellipsized string. * @return The ellipsized string.
* @receiver The string to ellipsize. * @receiver The string to ellipsize.
*/ */
private fun String.ellipsize(maxLength: Int): String = private fun String.ellipsize(maxLength: Int): String = if (length > maxLength) "${take(maxLength)}" else this
if (length > maxLength) "${take(maxLength)}" else this
/** /**
* Handles a quick chat action, either appending its message to the input field or sending it * Handles a quick chat action, either appending its message to the input field or sending it directly.
* directly.
* *
* @param action The [QuickChatAction] to handle. * @param action The [QuickChatAction] to handle.
* @param messageInputState The [TextFieldState] of the message input field. * @param messageInputState The [TextFieldState] of the message input field.
@ -460,12 +437,12 @@ private fun handleQuickChatAction(
if (!originalText.contains(action.message)) { if (!originalText.contains(action.message)) {
val newText = val newText =
buildString { buildString {
append(originalText) append(originalText)
if (originalText.isNotEmpty() && !originalText.endsWith(' ')) { if (originalText.isNotEmpty() && !originalText.endsWith(' ')) {
append(' ') append(' ')
}
append(action.message)
} }
append(action.message)
}
.limitBytes(MESSAGE_CHARACTER_LIMIT_BYTES) .limitBytes(MESSAGE_CHARACTER_LIMIT_BYTES)
messageInputState.setTextAndPlaceCursorAtEnd(newText) messageInputState.setTextAndPlaceCursorAtEnd(newText)
} }
@ -481,8 +458,7 @@ private fun handleQuickChatAction(
/** /**
* Truncates a string to ensure its UTF-8 byte representation does not exceed [maxBytes]. * Truncates a string to ensure its UTF-8 byte representation does not exceed [maxBytes].
* *
* This implementation iterates by characters and checks byte length to avoid splitting multi-byte * This implementation iterates by characters and checks byte length to avoid splitting multi-byte characters.
* characters.
* *
* @param maxBytes The maximum allowed byte length. * @param maxBytes The maximum allowed byte length.
* @return The truncated string, or the original string if it's within the byte limit. * @return The truncated string, or the original string if it's within the byte limit.
@ -524,12 +500,8 @@ private fun DeleteMessageDialog(count: Int, onConfirm: () -> Unit, onDismiss: ()
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
title = { Text(stringResource(R.string.delete_messages_title)) }, title = { Text(stringResource(R.string.delete_messages_title)) },
text = { Text(text = deleteMessagesString) }, text = { Text(text = deleteMessagesString) },
confirmButton = { confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(R.string.delete)) } },
TextButton(onClick = onConfirm) { Text(stringResource(R.string.delete)) } dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
},
) )
} }
@ -552,38 +524,31 @@ internal sealed class MessageMenuAction {
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ActionModeTopBar(selectedCount: Int, onAction: (MessageMenuAction) -> Unit) = private fun ActionModeTopBar(selectedCount: Int, onAction: (MessageMenuAction) -> Unit) = TopAppBar(
TopAppBar( title = { Text(text = selectedCount.toString()) },
title = { Text(text = selectedCount.toString()) }, navigationIcon = {
navigationIcon = { IconButton(onClick = { onAction(MessageMenuAction.Dismiss) }) {
IconButton(onClick = { onAction(MessageMenuAction.Dismiss) }) { Icon(
Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack,
imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.clear_selection),
contentDescription = stringResource(id = R.string.clear_selection), )
) }
} },
}, actions = {
actions = { IconButton(onClick = { onAction(MessageMenuAction.ClipboardCopy) }) {
IconButton(onClick = { onAction(MessageMenuAction.ClipboardCopy) }) { Icon(imageVector = Icons.Default.ContentCopy, contentDescription = stringResource(id = R.string.copy))
Icon( }
imageVector = Icons.Default.ContentCopy, IconButton(onClick = { onAction(MessageMenuAction.Delete) }) {
contentDescription = stringResource(id = R.string.copy), Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.delete))
) }
} IconButton(onClick = { onAction(MessageMenuAction.SelectAll) }) {
IconButton(onClick = { onAction(MessageMenuAction.Delete) }) { Icon(
Icon( imageVector = Icons.Default.SelectAll,
imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.select_all),
contentDescription = stringResource(id = R.string.delete), )
) }
} },
IconButton(onClick = { onAction(MessageMenuAction.SelectAll) }) { )
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(id = R.string.select_all),
)
}
},
)
/** /**
* The default top app bar for the message screen. * The default top app bar for the message screen.
@ -604,32 +569,44 @@ private fun MessageTopBar(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
channels: AppOnlyProtos.ChannelSet?, channels: AppOnlyProtos.ChannelSet?,
channelIndexParam: Int?, channelIndexParam: Int?,
) = showQuickChat: Boolean,
TopAppBar( onToggleQuickChat: () -> Unit,
title = { ) = TopAppBar(
Row(verticalAlignment = Alignment.CenterVertically) { title = {
Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.width(10.dp)) Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(modifier = Modifier.width(10.dp))
if (channels != null && channelIndexParam != null) { if (channels != null && channelIndexParam != null) {
SecurityIcon(channels, channelIndexParam) SecurityIcon(channels, channelIndexParam)
}
} }
}, }
navigationIcon = { },
IconButton(onClick = onNavigateBack) { navigationIcon = {
Icon( IconButton(onClick = onNavigateBack) {
imageVector = Icons.AutoMirrored.Filled.ArrowBack, Icon(
contentDescription = stringResource(id = R.string.navigate_back), imageVector = Icons.AutoMirrored.Filled.ArrowBack,
) contentDescription = stringResource(id = R.string.navigate_back),
} )
}, }
actions = { },
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) { actions = {
NodeKeyStatusIcon(hasPKC = true, mismatchKey = mismatchKey) IconButton(onClick = onToggleQuickChat) {
} Icon(
}, imageVector = if (showQuickChat) Icons.Filled.SpeakerNotesOff else Icons.AutoMirrored.Filled.Chat,
) contentDescription =
if (showQuickChat) {
stringResource(id = R.string.quick_chat_hide)
} else {
stringResource(id = R.string.quick_chat_show)
},
)
}
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) {
NodeKeyStatusIcon(hasPKC = true, mismatchKey = mismatchKey)
}
},
)
/** /**
* A row of quick chat action buttons. * A row of quick chat action buttons.
@ -659,10 +636,7 @@ private fun QuickChatRow(
val allActions = remember(alertAction, actions) { listOf(alertAction) + actions } val allActions = remember(alertAction, actions) { listOf(alertAction) + actions }
LazyRow( LazyRow(modifier = modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
modifier = modifier.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items(allActions, key = { it.uuid }) { action -> items(allActions, key = { it.uuid }) { action ->
Button(onClick = { onClick(action) }, enabled = enabled) { Text(text = action.name) } Button(onClick = { onClick(action) }, enabled = enabled) { Text(text = action.name) }
} }
@ -676,8 +650,7 @@ private fun QuickChatRow(
* @param textFieldState The [TextFieldState] managing the input's text. * @param textFieldState The [TextFieldState] managing the input's text.
* @param modifier The modifier for this composable. * @param modifier The modifier for this composable.
* @param maxByteSize The maximum allowed size of the message in bytes. * @param maxByteSize The maximum allowed size of the message in bytes.
* @param onSendMessage Callback invoked when the send button is pressed or send IME action is * @param onSendMessage Callback invoked when the send button is pressed or send IME action is triggered.
* triggered.
*/ */
@Suppress("LongMethod") // Due to multiple parts of the OutlinedTextField @Suppress("LongMethod") // Due to multiple parts of the OutlinedTextField
@Composable @Composable
@ -708,10 +681,7 @@ private fun MessageInput(
isError = isOverLimit, isError = isOverLimit,
placeholder = { Text(stringResource(R.string.type_a_message)) }, placeholder = { Text(stringResource(R.string.type_a_message)) },
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send),
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Send,
),
onKeyboardAction = { onKeyboardAction = {
if (canSend) { if (canSend) {
onSendMessage() onSendMessage()
@ -723,11 +693,11 @@ private fun MessageInput(
text = "$currentByteLength/$maxByteSize", text = "$currentByteLength/$maxByteSize",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = color =
if (isOverLimit) { if (isOverLimit) {
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End, textAlign = TextAlign.End,
) )
@ -754,25 +724,17 @@ private fun MessageInputPreview() {
AppTheme { AppTheme {
Surface { Surface {
Column(modifier = Modifier.padding(8.dp)) { Column(modifier = Modifier.padding(8.dp)) {
MessageInput( MessageInput(isEnabled = true, textFieldState = rememberTextFieldState("Hello"), onSendMessage = {})
isEnabled = true,
textFieldState = rememberTextFieldState("Hello"),
onSendMessage = {},
)
Spacer(Modifier.size(16.dp)) Spacer(Modifier.size(16.dp))
MessageInput( MessageInput(isEnabled = false, textFieldState = rememberTextFieldState("Disabled"), onSendMessage = {})
isEnabled = false,
textFieldState = rememberTextFieldState("Disabled"),
onSendMessage = {},
)
Spacer(Modifier.size(16.dp)) Spacer(Modifier.size(16.dp))
MessageInput( MessageInput(
isEnabled = true, isEnabled = true,
textFieldState = textFieldState =
rememberTextFieldState( rememberTextFieldState(
"A very long message that might exceed the byte limit " + "A very long message that might exceed the byte limit " +
"and cause an error state display for the user to see clearly." "and cause an error state display for the user to see clearly.",
), ),
onSendMessage = {}, onSendMessage = {},
maxByteSize = 50, // Test with a smaller limit maxByteSize = 50, // Test with a smaller limit
) )

View file

@ -258,6 +258,8 @@
<string name="quick_chat_edit">Edit quick chat</string> <string name="quick_chat_edit">Edit quick chat</string>
<string name="quick_chat_append">Append to message</string> <string name="quick_chat_append">Append to message</string>
<string name="quick_chat_instant">Instantly send</string> <string name="quick_chat_instant">Instantly send</string>
<string name="quick_chat_show">Show quick chat menu</string>
<string name="quick_chat_hide">Hide quick chat menu</string>
<string name="factory_reset">Factory reset</string> <string name="factory_reset">Factory reset</string>
<string name="factory_reset_description">This will clear all device configuration you have done.</string> <string name="factory_reset_description">This will clear all device configuration you have done.</string>
<string name="bluetooth_disabled">Bluetooth disabled</string> <string name="bluetooth_disabled">Bluetooth disabled</string>