Enable split pane UI for new conversation screen.

This commit is contained in:
jeffrey-signal
2025-10-17 11:23:36 -04:00
committed by Cody Henthorne
parent 4fd4792dd8
commit 9d0422a898
10 changed files with 24 additions and 477 deletions

View File

@@ -696,13 +696,8 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".NewConversationActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity
android:name=".conversation.NewConversationActivityV2"
android:name=".conversation.NewConversationActivity"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />

View File

@@ -92,6 +92,7 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.NewConversationActivity
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
@@ -1005,7 +1006,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
inner class BottomChromeCallback : MainBottomChromeCallback {
override fun onNewChatClick() {
startActivity(Intent(this@MainActivity, NewConversationActivity::class.java))
startActivity(NewConversationActivity.createIntent(this@MainActivity))
}
override fun onNewCallClick() {

View File

@@ -1,403 +0,0 @@
/*
* Copyright (C) 2015 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms;
import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientRepository;
import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity;
import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.reactivex.rxjava3.disposables.Disposable;
/**
* Activity container for starting a new conversation.
*
* @author Moxie Marlinspike
*/
public class NewConversationActivity extends ContactSelectionActivity
implements ContactSelectionListFragment.NewConversationCallback, ContactSelectionListFragment.OnItemLongClickListener, ContactSelectionListFragment.FindByCallback
{
@SuppressWarnings("unused")
private static final String TAG = Log.tag(NewConversationActivity.class);
private ContactsManagementViewModel viewModel;
private ActivityResultLauncher<Intent> contactLauncher;
private ActivityResultLauncher<Intent> createGroupLauncher;
private ActivityResultLauncher<FindByMode> findByLauncher;
private final LifecycleDisposable disposables = new LifecycleDisposable();
@Override
public void onCreate(Bundle bundle, boolean ready) {
super.onCreate(bundle, ready);
assert getSupportActionBar() != null;
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setTitle(R.string.NewConversationActivity__new_message);
disposables.bindTo(this);
ContactsManagementRepository repository = new ContactsManagementRepository(this);
ContactsManagementViewModel.Factory factory = new ContactsManagementViewModel.Factory(repository);
contactLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), activityResult -> {
if (activityResult.getResultCode() != RESULT_CANCELED) {
handleManualRefresh();
}
});
findByLauncher = registerForActivityResult(new FindByActivity.Contract(), result -> {
if (result != null) {
launch(result);
}
});
createGroupLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
if (result.getResultCode() == RESULT_OK) {
finish();
}
});
viewModel = new ViewModelProvider(this, factory).get(ContactsManagementViewModel.class);
}
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
if (recipientId.isPresent()) {
launch(Recipient.resolved(recipientId.get()));
} else {
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.");
if (SignalStore.account().isRegistered()) {
Log.i(TAG, "[onContactSelected] Doing contact refresh.");
AlertDialog progress = SimpleProgressDialog.show(this);
SimpleTask.run(getLifecycle(), () -> RecipientRepository.lookupNewE164(number), result -> {
progress.dismiss();
if (result instanceof RecipientRepository.LookupResult.Success) {
Recipient resolved = Recipient.resolved(((RecipientRepository.LookupResult.Success) result).getRecipientId());
if (resolved.isRegistered() && resolved.getHasServiceId()) {
launch(resolved);
}
} else if (result instanceof RecipientRepository.LookupResult.NotFound || result instanceof RecipientRepository.LookupResult.InvalidEntry) {
new MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, number))
.setPositiveButton(android.R.string.ok, null)
.show();
} else {
new MaterialAlertDialogBuilder(this)
.setMessage(R.string.NetworkFailure__network_error_check_your_connection_and_try_again)
.setPositiveButton(android.R.string.ok, null)
.show();
}
});
}
}
callback.accept(true);
}
@Override
public void onSelectionChanged() {
}
private void launch(Recipient recipient) {
launch(recipient.getId());
}
private void launch(RecipientId recipientId) {
Disposable disposable = ConversationIntents.createBuilder(this, recipientId, -1L)
.map(builder -> builder
.withDraftText(getIntent().getStringExtra(Intent.EXTRA_TEXT))
.withDataUri(getIntent().getData())
.withDataType(getIntent().getType())
.build())
.subscribe(intent -> {
startActivity(intent);
finish();
});
disposables.add(disposable);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
super.onOptionsItemSelected(item);
int itemId = item.getItemId();
if (itemId == android.R.id.home) {
super.onBackPressed();
return true;
} else if (itemId == R.id.menu_refresh) {
handleManualRefresh();
return true;
} else if (itemId == R.id.menu_new_group) {
handleCreateGroup();
return true;
} else if (itemId == R.id.menu_invite) {
handleInvite();
return true;
} else {
return false;
}
}
private void handleManualRefresh() {
if (!contactsFragment.isRefreshing()) {
contactsFragment.setRefreshing(true);
onRefresh();
}
}
private void handleCreateGroup() {
createGroupLauncher.launch(CreateGroupActivity.newIntent(this));
}
private void handleInvite() {
startActivity(AppSettingsActivity.invite(this));
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
menu.clear();
getMenuInflater().inflate(R.menu.new_conversation_activity, menu);
super.onCreateOptionsMenu(menu);
return true;
}
@Override
public void onInvite() {
handleInvite();
finish();
}
@Override
public void onNewGroup(boolean forceV1) {
handleCreateGroup();
// finish();
}
@Override
public void onFindByUsername() {
findByLauncher.launch(FindByMode.USERNAME);
}
@Override
public void onFindByPhoneNumber() {
findByLauncher.launch(FindByMode.PHONE_NUMBER);
}
@Override
public boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView) {
RecipientId recipientId = contactSearchKey.requireRecipientSearchKey().getRecipientId();
List<ActionItem> actions = generateContextualActionsForRecipient(recipientId);
if (actions.isEmpty()) {
return false;
}
new SignalContextMenu.Builder(anchorView, (ViewGroup) anchorView.getRootView())
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.offsetX((int) DimensionUnit.DP.toPixels(12))
.offsetY((int) DimensionUnit.DP.toPixels(12))
.onDismiss(() -> recyclerView.suppressLayout(false))
.show(actions);
recyclerView.suppressLayout(true);
return true;
}
private @NonNull List<ActionItem> generateContextualActionsForRecipient(@NonNull RecipientId recipientId) {
Recipient recipient = Recipient.resolved(recipientId);
return Stream.of(
createMessageActionItem(recipient),
createAudioCallActionItem(recipient),
createVideoCallActionItem(recipient),
createRemoveActionItem(recipient),
createBlockActionItem(recipient)
).filter(Objects::nonNull).collect(Collectors.toList());
}
private @NonNull ActionItem createMessageActionItem(@NonNull Recipient recipient) {
return new ActionItem(
R.drawable.ic_chat_message_24,
getString(R.string.NewConversationActivity__message),
R.color.signal_colorOnSurface,
() -> {
Disposable disposable = ConversationIntents.createBuilder(this, recipient.getId(), -1L)
.subscribe(builder -> startActivity(builder.build()));
disposables.add(disposable);
}
);
}
private @Nullable ActionItem createAudioCallActionItem(@NonNull Recipient recipient) {
if (recipient.isSelf() || recipient.isGroup()) {
return null;
}
if (recipient.isRegistered()) {
return new ActionItem(
R.drawable.ic_phone_right_24,
getString(R.string.NewConversationActivity__audio_call),
R.color.signal_colorOnSurface,
() -> CommunicationActions.startVoiceCall(this, recipient, () -> {
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
})
);
} else {
return null;
}
}
private @Nullable ActionItem createVideoCallActionItem(@NonNull Recipient recipient) {
if (recipient.isSelf() || recipient.isMmsGroup() || !recipient.isRegistered()) {
return null;
}
return new ActionItem(
R.drawable.ic_video_call_24,
getString(R.string.NewConversationActivity__video_call),
R.color.signal_colorOnSurface,
() -> CommunicationActions.startVideoCall(this, recipient, () -> {
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
})
);
}
private @Nullable ActionItem createRemoveActionItem(@NonNull Recipient recipient) {
if (recipient.isSelf() || recipient.isGroup()) {
return null;
}
return new ActionItem(
R.drawable.ic_minus_circle_20, // TODO [alex] -- correct asset
getString(R.string.NewConversationActivity__remove),
R.color.signal_colorOnSurface,
() -> displayRemovalDialog(recipient)
);
}
@SuppressWarnings("CodeBlock2Expr")
private @Nullable ActionItem createBlockActionItem(@NonNull Recipient recipient) {
if (recipient.isSelf()) {
return null;
}
return new ActionItem(
R.drawable.ic_block_tinted_24,
getString(R.string.NewConversationActivity__block),
R.color.signal_colorError,
() -> BlockUnblockDialog.showBlockFor(this,
this.getLifecycle(),
recipient,
() -> {
disposables.add(viewModel.blockContact(recipient).subscribe(() -> {
handleManualRefresh();
displaySnackbar(R.string.NewConversationActivity__s_has_been_blocked, recipient.getDisplayName(this));
contactsFragment.reset();
}, (throwable) -> {
displaySnackbar(R.string.NewConversationActivity__block_failed);
}));
})
);
}
private void displayIsInSystemContactsDialog(@NonNull Recipient recipient) {
new MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.NewConversationActivity__unable_to_remove_s, recipient.getShortDisplayName(this)))
.setMessage(R.string.NewConversationActivity__this_person_is_saved_to_your)
.setPositiveButton(R.string.NewConversationActivity__view_contact,
(dialog, which) -> contactLauncher.launch(new Intent(Intent.ACTION_VIEW, recipient.getContactUri()))
)
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void displayRemovalDialog(@NonNull Recipient recipient) {
new MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.NewConversationActivity__remove_s, recipient.getShortDisplayName(this)))
.setMessage(R.string.NewConversationActivity__you_wont_see_this_person)
.setPositiveButton(R.string.NewConversationActivity__remove,
(dialog, which) -> {
disposables.add(viewModel.hideContact(recipient).subscribe(() -> {
handleManualRefresh();
displaySnackbar(R.string.NewConversationActivity__s_has_been_removed, recipient.getDisplayName(this));
contactsFragment.reset();
}));
}
)
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void displaySnackbar(@StringRes int message, Object... formatArgs) {
Snackbar.make(findViewById(android.R.id.content), getString(message, formatArgs), Snackbar.LENGTH_SHORT).show();
}
}

View File

@@ -12,6 +12,7 @@ import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.conversation.NewConversationActivity;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Rfc5724Uri;
@@ -41,8 +42,7 @@ public class SystemContactsEntrypointActivity extends Activity {
final Intent nextIntent;
if (TextUtils.isEmpty(destination.destination)) {
nextIntent = new Intent(this, NewConversationActivity.class);
nextIntent.putExtra(Intent.EXTRA_TEXT, destination.getBody());
nextIntent = NewConversationActivity.createIntent(this, destination.getBody());
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
} else {
Recipient recipient = Recipient.external(destination.getDestination());
@@ -54,8 +54,7 @@ public class SystemContactsEntrypointActivity extends Activity {
.withDraftText(destination.getBody())
.build();
} else {
nextIntent = new Intent(this, NewConversationActivity.class);
nextIntent.putExtra(Intent.EXTRA_TEXT, destination.getBody());
nextIntent = NewConversationActivity.createIntent(this, destination.getBody());
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
}
}

View File

@@ -1,27 +0,0 @@
package org.thoughtcrime.securesms.contacts.management
import androidx.annotation.CheckResult
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import org.thoughtcrime.securesms.recipients.Recipient
class ContactsManagementViewModel(private val repository: ContactsManagementRepository) : ViewModel() {
@CheckResult
fun hideContact(recipient: Recipient): Completable {
return repository.hideContact(recipient).observeOn(AndroidSchedulers.mainThread())
}
@CheckResult
fun blockContact(recipient: Recipient): Completable {
return repository.blockContact(recipient).observeOn(AndroidSchedulers.mainThread())
}
class Factory(private val repository: ContactsManagementRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ContactsManagementViewModel(repository)) as T
}
}
}

View File

@@ -68,13 +68,16 @@ import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
/**
* Allows the user to start a new conversation by selecting a recipient.
*
* A modernized compose-based replacement for [org.thoughtcrime.securesms.NewConversationActivity].
*/
class NewConversationActivityV2 : PassphraseRequiredActivity() {
class NewConversationActivity : PassphraseRequiredActivity() {
companion object {
@JvmOverloads
@JvmStatic
fun createIntent(context: Context): Intent = Intent(context, NewConversationActivityV2::class.java)
fun createIntent(context: Context, draftMessage: String? = null): Intent {
return Intent(context, NewConversationActivity::class.java).apply {
putExtra(Intent.EXTRA_TEXT, draftMessage)
}
}
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
@@ -121,7 +124,7 @@ private fun NewConversationScreen(
val coroutineScope = rememberCoroutineScope()
val callbacks = remember {
object : Callbacks {
object : UiCallbacks {
override fun onCreateNewGroup() = createGroupLauncher.launch(CreateGroupActivity.newIntent(context))
override fun onFindByUsername() = findByLauncher.launch(FindByMode.USERNAME)
override fun onFindByPhoneNumber() = findByLauncher.launch(FindByMode.PHONE_NUMBER)
@@ -191,7 +194,7 @@ private suspend fun openConversation(
@Composable
private fun NewConversationScreenUi(
uiState: NewConversationUiState,
callbacks: Callbacks
callbacks: UiCallbacks
) {
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
val isSplitPane = windowSizeClass.isSplitPane(forceSplitPaneOnCompactLandscape = uiState.forceSplitPaneOnCompactLandscape)
@@ -259,7 +262,7 @@ private fun NewConversationScreenUi(
}
@Composable
private fun TopAppBarActions(callbacks: Callbacks) {
private fun TopAppBarActions(callbacks: UiCallbacks) {
val menuController = remember { DropdownMenus.MenuController() }
IconButton(
onClick = { menuController.show() },
@@ -303,13 +306,13 @@ private fun TopAppBarActions(callbacks: Callbacks) {
}
}
private interface Callbacks : RecipientPickerCallbacks {
private interface UiCallbacks : RecipientPickerCallbacks {
fun onRemoveConfirmed(recipient: Recipient)
fun onBlockConfirmed(recipient: Recipient)
fun onUserMessageDismissed(userMessage: UserMessage)
fun onBackPressed()
object Empty : Callbacks {
object Empty : UiCallbacks {
override fun onCreateNewGroup() = Unit
override fun onFindByUsername() = Unit
override fun onFindByPhoneNumber() = Unit
@@ -333,7 +336,7 @@ private interface Callbacks : RecipientPickerCallbacks {
@Composable
private fun NewConversationRecipientPicker(
uiState: NewConversationUiState,
callbacks: Callbacks,
callbacks: UiCallbacks,
modifier: Modifier = Modifier
) {
RecipientPicker(
@@ -423,7 +426,7 @@ private fun NewConversationScreenPreview() {
uiState = NewConversationUiState(
forceSplitPaneOnCompactLandscape = false
),
callbacks = Callbacks.Empty
callbacks = UiCallbacks.Empty
)
}
}

View File

@@ -113,7 +113,7 @@ private fun RecipientSearchField(
}
}
// TODO(jeff) This causes the keyboard to re-open on rotation, which doesn't match the existing behavior of ContactFilterView. To fix this,
// TODO [jeff] This causes the keyboard to re-open on rotation, which doesn't match the existing behavior of ContactFilterView. To fix this,
// RecipientSearchField needs to be converted to compose so we can use FocusRequestor.
LaunchedEffect(focusAndShowKeyboard) {
if (focusAndShowKeyboard) {
@@ -298,7 +298,7 @@ private suspend fun showItemContextMenu(anchorView: View, contactSearchKey: Cont
if (!recipient.isSelf && !recipient.isGroup) {
val removeItem = ActionItem(
iconRes = R.drawable.ic_minus_circle_20, // TODO [alex] -- correct asset
iconRes = R.drawable.ic_minus_circle_20,
title = context.getString(R.string.NewConversationActivity__remove),
tintRes = R.color.signal_colorOnSurface,
action = { callbacks.onRemove(recipient) }

View File

@@ -15,8 +15,8 @@ import androidx.core.content.ContextCompat;
import org.signal.core.util.SetUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.NewConversationActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.NewConversationActivity;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
@@ -108,7 +108,7 @@ public class UserNotificationMigrationJob extends MigrationJob {
registeredSystemContacts.size());
Intent mainActivityIntent = new Intent(context, MainActivity.class);
Intent newConversationIntent = new Intent(context, NewConversationActivity.class);
Intent newConversationIntent = NewConversationActivity.createIntent(context);
PendingIntent pendingIntent = TaskStackBuilder.create(context)
.addNextIntent(mainActivityIntent)
.addNextIntent(newConversationIntent)

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:title="@string/new_conversation_activity__refresh"
android:id="@+id/menu_refresh"
app:showAsAction="never" />
<item android:title="@string/text_secure_normal__menu_new_group"
android:id="@+id/menu_new_group" />
<item android:title="@string/text_secure_normal__invite_friends"
android:id="@+id/menu_invite" />
</menu>

View File

@@ -5910,12 +5910,6 @@
<string name="NewConversationActivity__s_has_been_blocked">%1$s has been blocked</string>
<!-- Snackbar message when blocking a contact has failed -->
<string name="NewConversationActivity__block_failed">Block failed</string>
<!-- Dialog title when remove target contact is in system contacts -->
<string name="NewConversationActivity__unable_to_remove_s">Unable to remove %1$s</string>
<!-- Dialog message when remove target contact is in system contacts -->
<string name="NewConversationActivity__this_person_is_saved_to_your">This person is saved to your device\'s Contacts. Delete them from your Contacts and try again.</string>
<!-- Dialog action to view contact when they can\'t be removed otherwise -->
<string name="NewConversationActivity__view_contact">View contact</string>
<!-- Error message shown when looking up a person by phone number and that phone number is not associated with a signal account -->
<string name="NewConversationActivity__s_is_not_a_signal_user">%1$s is not a Signal user</string>
<!-- Accessibility label for opening the screen specific dropdown menu. -->