(early draft) android, desktop: new chat sheet

This commit is contained in:
Diogo Cunha
2024-07-14 22:19:43 +01:00
parent 387faa0c27
commit a57a2c277d
6 changed files with 632 additions and 3 deletions

View File

@@ -101,7 +101,10 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
FloatingActionButton(
onClick = {
if (!stopped) {
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
ModalManager.start.closeModals()
ModalManager.start.showModalCloseable{
NewChatView(rh = chatModel.currentRemoteHost.value)
}
}
},
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = bottom).size(AppBarHeight * fontSizeSqrtMultiplier),

View File

@@ -0,0 +1,105 @@
package chat.simplex.common.views.contacts
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@Composable
fun ContactListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>, oneHandUI: State<Boolean>) {
val showMenu = remember { mutableStateOf(false) }
val disabled = chatModel.chatRunning.value == false || chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)
LaunchedEffect(chat.id) {
showMenu.value = false
delay(500L)
}
val selectedChat = remember(chat.id) { derivedStateOf { chat.id == chatModel.chatId.value } }
when (chat.chatInfo) {
is ChatInfo.Direct -> {
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) {
ContactPreviewView(chat, disabled)
}
},
click = {
directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel)
ModalManager.start.closeModals()
},
dropdownMenuItems = {
tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) {
ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu)
}
},
showMenu,
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
is ChatInfo.ContactRequest -> {
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) {
ContactPreviewView(chat, disabled)
}
},
click = { contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) },
dropdownMenuItems = {
tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) {
ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu)
}
},
showMenu,
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
else -> {}
}
}
@Composable
fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
if (contact.activeConn != null) {
ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu)
}
DeleteContactAction(chat, chatModel, showMenu)
}
@Composable
fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolean, showMenu: MutableState<Boolean>) {
ItemAction(
if (favorite) stringResource(MR.strings.unfavorite_chat) else stringResource(MR.strings.favorite_chat),
if (favorite) painterResource(MR.images.ic_star_off) else painterResource(MR.images.ic_star),
onClick = {
toggleChatFavorite(chat, !favorite, chatModel)
showMenu.value = false
}
)
}
@Composable
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.delete_contact_menu_action),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactDialog(chat, chatModel)
showMenu.value = false
},
color = Color.Red
)
}

View File

@@ -0,0 +1,125 @@
package chat.simplex.common.views.contacts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.DEFAULT_SPACE_AFTER_ICON
import chat.simplex.res.MR
@Composable
fun ContactPreviewView(
chat: Chat,
disabled: Boolean,
) {
val cInfo = chat.chatInfo
@Composable
fun inactiveIcon() {
Icon(
painterResource(MR.images.ic_cancel_filled),
stringResource(MR.strings.icon_descr_group_inactive),
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
tint = MaterialTheme.colors.secondary
)
}
@Composable
fun chatPreviewImageOverlayIcon() {
when (cInfo) {
is ChatInfo.Direct ->
if (!cInfo.contact.active) {
inactiveIcon()
}
else -> {}
}
}
@Composable
fun VerifiedIcon() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
}
@Composable
fun chatPreviewTitle() {
val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) }
when (cInfo) {
is ChatInfo.Direct ->
Row(verticalAlignment = Alignment.CenterVertically) {
if (cInfo.contact.verified) {
VerifiedIcon()
}
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (deleting) MaterialTheme.colors.secondary else Color.Unspecified
)
}
is ChatInfo.ContactRequest ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Unspecified
)
}
else -> {}
}
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(contentAlignment = Alignment.BottomEnd) {
ChatInfoImage(cInfo, size = 42.dp)
Box(Modifier.padding(end = 2.dp, bottom = 2.dp)) {
chatPreviewImageOverlayIcon()
}
}
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Box(modifier = Modifier.weight(10f, fill = true)) {
chatPreviewTitle()
}
Spacer(Modifier.fillMaxWidth().weight(1f))
if (chat.chatInfo.chatSettings?.favorite == true) {
Icon(
painterResource(MR.images.ic_star_filled),
contentDescription = generalGetString(MR.strings.favorite_chat),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(17.dp)
)
if (chat.chatInfo.incognito) {
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
}
}
if (chat.chatInfo.incognito) {
Icon(
painterResource(MR.images.ic_theater_comedy),
contentDescription = null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(21.dp)
)
}
}
}

View File

@@ -0,0 +1,311 @@
package chat.simplex.common.views.contacts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.Chat
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatInfo
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.ChatType
import chat.simplex.common.model.ContactStatus
import chat.simplex.common.model.Format
import chat.simplex.common.model.SharedPreference
import chat.simplex.common.platform.BackHandler
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.LazyColumnWithScrollBar
import chat.simplex.common.platform.LocalMultiplatformView
import chat.simplex.common.platform.Log
import chat.simplex.common.platform.TAG
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.chatModel
import chat.simplex.common.platform.getKeyboardState
import chat.simplex.common.platform.hideKeyboard
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.strHasSingleSimplexLink
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
enum class ContactType {
RECENT, NEW, REMOVED
}
private fun contactChats(c: List<Chat>, contactType: ContactType): List<Chat> {
return c.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> when (contactType) {
ContactType.REMOVED -> cInfo.contact.contactStatus == ContactStatus.DeletedByUser || cInfo.contact.contactStatus == ContactStatus.Deleted
ContactType.NEW -> false
ContactType.RECENT -> cInfo.contact.contactStatus != ContactStatus.DeletedByUser && cInfo.contact.contactStatus != ContactStatus.Deleted
}
is ChatInfo.ContactRequest -> when (contactType) {
ContactType.NEW -> true
else -> false
}
else -> false
}
}
}
@Composable
fun ContactTypeTabs(contactActions: @Composable () -> Unit, searchText: MutableState<TextFieldValue>) {
val scope = rememberCoroutineScope()
val selectedContactType =
remember { mutableStateOf(ContactType.RECENT) }
val contactTypeTabTitles = ContactType.entries.map {
when (it) {
ContactType.NEW ->
stringResource(MR.strings.contact_type_new)
ContactType.RECENT ->
stringResource(MR.strings.contact_type_recent)
ContactType.REMOVED ->
stringResource(MR.strings.contact_type_removed)
}
}
val contactTypePagerState = rememberPagerState(
initialPage = selectedContactType.value.ordinal,
initialPageOffsetFraction = 0f
) { ContactType.entries.size }
KeyChangeEffect(contactTypePagerState.currentPage) {
selectedContactType.value = ContactType.values()[contactTypePagerState.currentPage]
}
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
ContactsSearchBar(listState, searchText)
contactActions()
TabRow(
selectedTabIndex = contactTypePagerState.currentPage,
backgroundColor = Color.Transparent,
contentColor = MaterialTheme.colors.primary,
) {
contactTypeTabTitles.forEachIndexed { index, it ->
Tab(
selected = contactTypePagerState.currentPage == index,
onClick = {
scope.launch {
contactTypePagerState.animateScrollToPage(index)
}
},
text = { Text(it, fontSize = 13.sp) },
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = MaterialTheme.colors.secondary,
)
}
}
HorizontalPager(
state = contactTypePagerState,
Modifier.fillMaxSize(),
verticalAlignment = Alignment.Top,
userScrollEnabled = appPlatform.isAndroid
) { index ->
val contactType = when (index) {
ContactType.NEW.ordinal -> ContactType.NEW
ContactType.RECENT.ordinal -> ContactType.RECENT
ContactType.REMOVED.ordinal -> ContactType.REMOVED
else -> ContactType.RECENT
}
ContactsList(listState = listState, chatModel = chatModel, searchText = searchText, contactType = contactType)
}
}
@Composable
fun ContactsSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>) {
Row(verticalAlignment = Alignment.CenterVertically) {
val focusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF), tint = MaterialTheme.colors.secondary)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_verb),
alwaysVisible = true,
searchText = searchText,
trailingContent = null,
) {
searchText.value = searchText.value.copy(it)
}
val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } }
if (hasText.value) {
val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() }
BackHandler(onBack = hideSearchOnBack)
KeyChangeEffect(chatModel.currentRemoteHost.value) {
hideSearchOnBack()
}
} else {
Row {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.size > 0) {
ToggleFilterButton()
}
Spacer(Modifier.width(padding))
}
}
val focusManager = LocalFocusManager.current
val keyboardState = getKeyboardState()
LaunchedEffect(keyboardState.value) {
if (keyboardState.value == KeyboardState.Closed && focused) {
focusManager.clearFocus()
}
}
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
}
}
}
}
@Composable
fun ToggleFilterButton() {
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
IconButton(onClick = { pref.set(!pref.get()) }) {
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(sp16)
)
}
}
private var lazyListState = 0 to 0
@Composable
fun ContactsList(listState: LazyListState, chatModel: ChatModel, searchText: MutableState<TextFieldValue>, contactType: ContactType) {
val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI }
DisposableEffect(Unit) {
onDispose {
lazyListState =
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}
}
val showUnreadAndFavorites =
remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val allChats = remember { contactChats(chatModel.chats, contactType) }
val filteredContactChats = filteredContactChats(
showUnreadAndFavorites = showUnreadAndFavorites,
searchText = searchText.value.text,
contactChats = allChats
)
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth() ,contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary
)
}
}
} else {
LazyColumnWithScrollBar(
Modifier.fillMaxWidth(),
listState
) {
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
}
}
ContactListNavLinkView(chat, nextChatSelected, oneHandUI.state)
}
}
}
}
private fun filterChat(chat: Chat, searchText: String, showUnreadAndFavorites: Boolean): Boolean {
var meetsPredicate = true;
val s = searchText.trim().lowercase()
val cInfo = chat.chatInfo
if (searchText.isNotEmpty()) {
meetsPredicate = viewNameContains(cInfo, s) ||
if (cInfo is ChatInfo.Direct) (cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s)) else false
}
if (showUnreadAndFavorites) {
meetsPredicate = meetsPredicate && (cInfo.chatSettings?.favorite ?: false)
}
return meetsPredicate;
}
private fun filteredContactChats(
showUnreadAndFavorites: Boolean,
searchText: String,
contactChats: List<Chat>
): List<Chat> {
val s = searchText.trim().lowercase()
return contactChats
.filter { chat -> filterChat(
chat = chat,
searchText = searchText,
showUnreadAndFavorites = showUnreadAndFavorites) }
.sortedByDescending { it.chatInfo.chatTs }
}
private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean =
cInfo.chatViewName.lowercase().contains(s.lowercase())

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
@@ -19,12 +20,15 @@ import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.RemoteHostInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.contacts.ContactTypeTabs
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,10 +36,86 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun ModalData.NewChatView(rh: RemoteHostInfo?) {
Column(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.new_chat),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(
TextFieldValue("")
) }
ContactTypeTabs(
searchText = searchText,
contactActions = {
NewChatOptions(
addContact = {
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) }
},
scanPaste = {
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = true, close = close) }
},
createGroup = {
ModalManager.center.closeModals()
ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close) }
}
)
}
)
}
}
@Composable
fun NewChatOptions(addContact: () -> Unit, scanPaste: () -> Unit, createGroup: () -> Unit) {
val actions = remember { listOf(addContact, scanPaste, createGroup) }
val backgroundColor = if (isInDarkTheme())
blendARGB(MaterialTheme.colors.primary, Color.Black, 0.7F)
else
MaterialTheme.colors.background
LazyColumn {
items(actions.size) { index ->
Row {
Box(contentAlignment = Alignment.Center) {
Button(
actions[index],
shape = RoundedCornerShape(21.dp * fontSizeSqrtMultiplier),
colors = ButtonDefaults.textButtonColors(backgroundColor = backgroundColor),
elevation = null,
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF),
modifier = Modifier.height(42.dp * fontSizeSqrtMultiplier)
) {
Icon(
painterResource(icons[index]),
stringResource(titles[index]),
Modifier.size(42.dp * fontSizeSqrtMultiplier),
tint = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary
)
Text(
stringResource(titles[index]),
color = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary,
fontWeight = FontWeight.Medium,
)
}
}
}
}
}
}
@Composable
fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedViewState>, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) {
// TODO close new chat if remote host changes in model
if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) }
NewChatSheetLayout(
newChatSheetState,
stopped,
@@ -50,7 +130,6 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedView
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = true, close = close) }
},
createGroup = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close) }
},

View File

@@ -2187,4 +2187,10 @@
<string name="download_errors">Download errors</string>
<string name="server_address">Server address</string>
<string name="open_server_settings_button">Open server settings</string>
<!-- ContactsView.kt -->
<string name="contact_type_new">New</string>
<string name="contact_type_removed">Removed</string>
<string name="contact_type_recent">Recent</string>
<string name="no_filtered_contacts">No filtered contacts</string>
</resources>