mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-26 05:58:09 +00:00
Add Find By Username and Find By Phone Number interstitials.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
committed by
Greyson Parrelli
parent
ca3d239ce2
commit
700fe5e463
@@ -24,14 +24,17 @@ import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DisplayMetricsUtil;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
@@ -99,6 +102,10 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
|
||||
private void initializeContactFilterView() {
|
||||
this.contactFilterView = findViewById(R.id.contact_filter_edit_text);
|
||||
|
||||
if (getResources().getDisplayMetrics().heightPixels >= DimensionUnit.DP.toPixels(600) || !FeatureFlags.usernames()) {
|
||||
this.contactFilterView.focusAndShowKeyboard();
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeToolbar() {
|
||||
|
||||
@@ -27,6 +27,8 @@ class ContactSelectionListAdapter(
|
||||
registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item))
|
||||
registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header))
|
||||
registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state))
|
||||
registerFactory(FindByUsernameModel::class.java, LayoutFactory({ FindByUsernameViewHolder(it, onClickCallbacks::onFindByUsernameClicked) }, R.layout.contact_selection_find_by_username_item))
|
||||
registerFactory(FindByPhoneNumberModel::class.java, LayoutFactory({ FindByPhoneNumberViewHolder(it, onClickCallbacks::onFindByPhoneNumberClicked) }, R.layout.contact_selection_find_by_phone_number_item))
|
||||
}
|
||||
|
||||
class NewGroupModel : MappingModel<NewGroupModel> {
|
||||
@@ -44,6 +46,16 @@ class ContactSelectionListAdapter(
|
||||
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
|
||||
}
|
||||
|
||||
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
|
||||
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
|
||||
}
|
||||
|
||||
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
|
||||
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
|
||||
}
|
||||
|
||||
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
|
||||
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
|
||||
|
||||
@@ -92,13 +104,33 @@ class ContactSelectionListAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: FindByPhoneNumberModel) = Unit
|
||||
}
|
||||
|
||||
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: FindByUsernameModel) = Unit
|
||||
}
|
||||
|
||||
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
|
||||
|
||||
enum class ArbitraryRow(val code: String) {
|
||||
NEW_GROUP("new-group"),
|
||||
INVITE_TO_SIGNAL("invite-to-signal"),
|
||||
MORE_HEADING("more-heading"),
|
||||
REFRESH_CONTACTS("refresh-contacts");
|
||||
REFRESH_CONTACTS("refresh-contacts"),
|
||||
FIND_BY_USERNAME("find-by-username"),
|
||||
FIND_BY_PHONE_NUMBER("find-by-phone-number");
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String) = values().first { it.code == code }
|
||||
@@ -120,6 +152,8 @@ class ContactSelectionListAdapter(
|
||||
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
|
||||
ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
|
||||
ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
|
||||
ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
|
||||
ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,5 +162,7 @@ class ContactSelectionListAdapter(
|
||||
fun onNewGroupClicked()
|
||||
fun onInviteToSignalClicked()
|
||||
fun onRefreshContactsClicked()
|
||||
fun onFindByPhoneNumberClicked()
|
||||
fun onFindByUsernameClicked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAci
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -141,6 +142,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
private ContactSearchMediator contactSearchMediator;
|
||||
|
||||
@Nullable private NewConversationCallback newConversationCallback;
|
||||
@Nullable private FindByCallback findByCallback;
|
||||
@Nullable private NewCallCallback newCallCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
@@ -161,6 +163,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
newConversationCallback = (NewConversationCallback) context;
|
||||
}
|
||||
|
||||
if (context instanceof FindByCallback) {
|
||||
findByCallback = (FindByCallback) context;
|
||||
}
|
||||
|
||||
if (context instanceof NewCallCallback) {
|
||||
newCallCallback = (NewCallCallback) context;
|
||||
}
|
||||
@@ -379,6 +385,16 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
newConversationCallback.onNewGroup(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByPhoneNumberClicked() {
|
||||
findByCallback.onFindByPhoneNumber();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByUsernameClicked() {
|
||||
findByCallback.onFindByUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInviteToSignalClicked() {
|
||||
if (newConversationCallback != null) {
|
||||
@@ -660,6 +676,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
public void addRecipientToSelectionIfAble(@NonNull RecipientId recipientId) {
|
||||
listClickListener.onItemClick(new ContactSearchKey.RecipientSearchKey(recipientId, false));
|
||||
}
|
||||
|
||||
private class ListClickListener {
|
||||
public void onItemClick(ContactSearchKey contact) {
|
||||
boolean isUnknown = contact instanceof ContactSearchKey.UnknownRecipientKey;
|
||||
@@ -874,6 +894,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
|
||||
}
|
||||
|
||||
if (findByCallback != null && FeatureFlags.usernames()) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
|
||||
}
|
||||
|
||||
if (transportType != null) {
|
||||
if (!hasQuery && includeRecents) {
|
||||
builder.addSection(new ContactSearchConfiguration.Section.Recents(
|
||||
@@ -891,7 +916,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
|
||||
includeSelf,
|
||||
transportType,
|
||||
newCallCallback == null,
|
||||
newCallCallback == null && findByCallback == null,
|
||||
null,
|
||||
!hideLetterHeaders()
|
||||
));
|
||||
@@ -1011,6 +1036,12 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
void onNewGroup(boolean forceV1);
|
||||
}
|
||||
|
||||
public interface FindByCallback {
|
||||
void onFindByUsername();
|
||||
|
||||
void onFindByPhoneNumber();
|
||||
}
|
||||
|
||||
public interface NewCallCallback {
|
||||
void onInvite();
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ 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.ui.findby.FindByActivity;
|
||||
import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
@@ -70,14 +72,15 @@ import io.reactivex.rxjava3.disposables.Disposable;
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class NewConversationActivity extends ContactSelectionActivity
|
||||
implements ContactSelectionListFragment.NewConversationCallback, ContactSelectionListFragment.OnItemLongClickListener
|
||||
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 ContactsManagementViewModel viewModel;
|
||||
private ActivityResultLauncher<Intent> contactLauncher;
|
||||
private ActivityResultLauncher<FindByMode> findByLauncher;
|
||||
|
||||
private final LifecycleDisposable disposables = new LifecycleDisposable();
|
||||
|
||||
@@ -99,6 +102,12 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
});
|
||||
|
||||
findByLauncher = registerForActivityResult(new FindByActivity.Contract(), result -> {
|
||||
if (result != null) {
|
||||
launch(result);
|
||||
}
|
||||
});
|
||||
|
||||
viewModel = new ViewModelProvider(this, factory).get(ContactsManagementViewModel.class);
|
||||
}
|
||||
|
||||
@@ -163,7 +172,12 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
|
||||
private void launch(Recipient recipient) {
|
||||
Disposable disposable = ConversationIntents.createBuilder(this, recipient.getId(), -1L)
|
||||
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())
|
||||
@@ -234,6 +248,16 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
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();
|
||||
|
||||
@@ -114,23 +114,23 @@ public final class ContactFilterView extends FrameLayout {
|
||||
int defStyle)
|
||||
{
|
||||
final TypedArray attributes = context.obtainStyledAttributes(attrs,
|
||||
R.styleable.ContactFilterToolbar,
|
||||
R.styleable.ContactFilterView,
|
||||
defStyle,
|
||||
0);
|
||||
|
||||
int styleResource = attributes.getResourceId(R.styleable.ContactFilterToolbar_searchTextStyle, -1);
|
||||
int styleResource = attributes.getResourceId(R.styleable.ContactFilterView_searchTextStyle, -1);
|
||||
if (styleResource != -1) {
|
||||
TextViewCompat.setTextAppearance(searchText, styleResource);
|
||||
}
|
||||
if (!attributes.getBoolean(R.styleable.ContactFilterToolbar_showDialpad, true)) {
|
||||
if (!attributes.getBoolean(R.styleable.ContactFilterView_showDialpad, true)) {
|
||||
dialpadToggle.setVisibility(GONE);
|
||||
}
|
||||
|
||||
if (attributes.getBoolean(R.styleable.ContactFilterToolbar_cfv_autoFocus, true)) {
|
||||
if (attributes.getBoolean(R.styleable.ContactFilterView_cfv_autoFocus, true)) {
|
||||
searchText.requestFocus();
|
||||
}
|
||||
|
||||
int backgroundRes = attributes.getResourceId(R.styleable.ContactFilterToolbar_cfv_background, -1);
|
||||
int backgroundRes = attributes.getResourceId(R.styleable.ContactFilterView_cfv_background, -1);
|
||||
if (backgroundRes != -1) {
|
||||
findViewById(R.id.background_holder).setBackgroundResource(backgroundRes);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
@@ -20,20 +21,24 @@ import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity;
|
||||
import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class AddMembersActivity extends PushContactSelectionActivity {
|
||||
public class AddMembersActivity extends PushContactSelectionActivity implements ContactSelectionListFragment.FindByCallback {
|
||||
|
||||
public static final String GROUP_ID = "group_id";
|
||||
public static final String ANNOUNCEMENT_GROUP = "announcement_group";
|
||||
|
||||
private View done;
|
||||
private AddMembersViewModel viewModel;
|
||||
private View done;
|
||||
private AddMembersViewModel viewModel;
|
||||
private ActivityResultLauncher<FindByMode> findByActivityLauncher;
|
||||
|
||||
public static @NonNull Intent createIntent(@NonNull Context context,
|
||||
@NonNull GroupId groupId,
|
||||
@@ -70,6 +75,12 @@ public class AddMembersActivity extends PushContactSelectionActivity {
|
||||
);
|
||||
|
||||
disableDone();
|
||||
|
||||
findByActivityLauncher = registerForActivityResult(new FindByActivity.Contract(), result -> {
|
||||
if (result != null) {
|
||||
contactsFragment.addRecipientToSelectionIfAble(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -119,6 +130,16 @@ public class AddMembersActivity extends PushContactSelectionActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByPhoneNumber() {
|
||||
findByActivityLauncher.launch(FindByMode.PHONE_NUMBER);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByUsername() {
|
||||
findByActivityLauncher.launch(FindByMode.USERNAME);
|
||||
}
|
||||
|
||||
private void enableDone() {
|
||||
done.setEnabled(true);
|
||||
done.animate().alpha(1f);
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
@@ -26,11 +27,14 @@ import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsA
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity;
|
||||
import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.signal.core.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -38,14 +42,16 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class CreateGroupActivity extends ContactSelectionActivity {
|
||||
public class CreateGroupActivity extends ContactSelectionActivity implements ContactSelectionListFragment.FindByCallback {
|
||||
|
||||
private static final String TAG = Log.tag(CreateGroupActivity.class);
|
||||
|
||||
private static final short REQUEST_CODE_ADD_DETAILS = 17275;
|
||||
|
||||
private MaterialButton skip;
|
||||
private FloatingActionButton next;
|
||||
private MaterialButton skip;
|
||||
private FloatingActionButton next;
|
||||
private ActivityResultLauncher<FindByMode> findByActivityLauncher;
|
||||
|
||||
|
||||
public static Intent newIntent(@NonNull Context context) {
|
||||
Intent intent = new Intent(context, CreateGroupActivity.class);
|
||||
@@ -77,6 +83,12 @@ public class CreateGroupActivity extends ContactSelectionActivity {
|
||||
|
||||
skip.setOnClickListener(v -> handleNextPressed());
|
||||
next.setOnClickListener(v -> handleNextPressed());
|
||||
|
||||
findByActivityLauncher = registerForActivityResult(new FindByActivity.Contract(), result -> {
|
||||
if (result != null) {
|
||||
contactsFragment.addRecipientToSelectionIfAble(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -131,6 +143,16 @@ public class CreateGroupActivity extends ContactSelectionActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByPhoneNumber() {
|
||||
findByActivityLauncher.launch(FindByMode.PHONE_NUMBER);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByUsername() {
|
||||
findByActivityLauncher.launch(FindByMode.USERNAME);
|
||||
}
|
||||
|
||||
private void extendSkip() {
|
||||
skip.setVisibility(View.VISIBLE);
|
||||
next.setVisibility(View.GONE);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.phonenumbers
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import com.google.i18n.phonenumbers.AsYouTypeFormatter
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
|
||||
/**
|
||||
* Formats the input number according to the regionCode. Assumes the input is all digits.
|
||||
*/
|
||||
class PhoneNumberVisualTransformation(
|
||||
regionCode: String
|
||||
) : VisualTransformation {
|
||||
|
||||
private val asYouTypeFormatter: AsYouTypeFormatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(regionCode)
|
||||
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
asYouTypeFormatter.clear()
|
||||
val output = text.map { asYouTypeFormatter.inputDigit(it) }.lastOrNull() ?: text.text
|
||||
|
||||
return TransformedText(
|
||||
AnnotatedString(output),
|
||||
PhoneNumberOffsetMapping(output)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Each character in our phone number is either a digit or a transformed offset.
|
||||
*/
|
||||
private class PhoneNumberOffsetMapping(
|
||||
private val transformed: String
|
||||
) : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int {
|
||||
// We need a different algorithm here. We need to take UNTIL we've hit offset digits, and then return the resulting length.
|
||||
var remaining = (offset + 1)
|
||||
return transformed.takeWhile {
|
||||
if (it.isDigit()) {
|
||||
remaining--
|
||||
}
|
||||
|
||||
remaining != 0
|
||||
}.length
|
||||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
val substring = transformed.substring(0, offset)
|
||||
val characterCount = substring.count { !it.isDigit() }
|
||||
return offset - characterCount
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.recipients.ui.findby
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.dialog
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.TextFields
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.invites.InviteActions
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberVisualTransformation
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter
|
||||
|
||||
/**
|
||||
* Allows the user to look up another Signal user by phone number or username and
|
||||
* retrieve a RecipientId for that data.
|
||||
*/
|
||||
class FindByActivity : PassphraseRequiredActivity() {
|
||||
|
||||
companion object {
|
||||
private const val MODE = "FindByActivity.mode"
|
||||
private const val RECIPIENT_ID = "FindByActivity.recipientId"
|
||||
}
|
||||
|
||||
private val viewModel: FindByViewModel by viewModel {
|
||||
FindByViewModel(FindByMode.valueOf(intent.getStringExtra(MODE)!!))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
setContent {
|
||||
val state by viewModel.state
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
SignalTheme {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "find-by-content",
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
|
||||
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
|
||||
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
|
||||
) {
|
||||
composable("find-by-content") {
|
||||
val title = remember(state.mode) {
|
||||
if (state.mode == FindByMode.USERNAME) R.string.FindByActivity__find_by_username else R.string.FindByActivity__find_by_phone_number
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = title),
|
||||
onNavigationClick = { finishAfterTransition() },
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
FindByContent(
|
||||
paddingValues = it,
|
||||
state = state,
|
||||
onUserEntryChanged = viewModel::onUserEntryChanged,
|
||||
onNextClick = {
|
||||
lifecycleScope.launch {
|
||||
when (val result = viewModel.onNextClicked(context)) {
|
||||
is FindByResult.Success -> {
|
||||
setResult(RESULT_OK, Intent().putExtra(RECIPIENT_ID, result.recipientId))
|
||||
finishAfterTransition()
|
||||
}
|
||||
|
||||
FindByResult.InvalidEntry -> navController.navigate("invalid-entry")
|
||||
is FindByResult.NotFound -> navController.navigate("not-found/${result.recipientId.toLong()}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectCountryPrefixClick = {
|
||||
navController.navigate("select-country-prefix")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable("select-country-prefix") {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.FindByActivity__select_country_code),
|
||||
onNavigationClick = { navController.popBackStack() },
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
|
||||
) { paddingValues ->
|
||||
SelectCountryScreen(
|
||||
paddingValues = paddingValues,
|
||||
searchEntry = state.countryPrefixSearchEntry,
|
||||
onSearchEntryChanged = viewModel::onCountryPrefixSearchEntryChanged,
|
||||
supportedCountryPrefixes = state.supportedCountryPrefixes,
|
||||
onCountryPrefixSelected = {
|
||||
navController.popBackStack()
|
||||
viewModel.onCountryPrefixSelected(it)
|
||||
viewModel.onCountryPrefixSearchEntryChanged("")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dialog("invalid-entry") {
|
||||
val title = if (state.mode == FindByMode.USERNAME) {
|
||||
stringResource(id = R.string.FindByActivity__invalid_username)
|
||||
} else {
|
||||
stringResource(id = R.string.FindByActivity__invalid_phone_number)
|
||||
}
|
||||
|
||||
val body = if (state.mode == FindByMode.USERNAME) {
|
||||
stringResource(id = R.string.FindByActivity__s_is_not_a_valid_username, state.userEntry)
|
||||
} else {
|
||||
val formattedNumber = remember(state.userEntry) {
|
||||
val cleansed = state.userEntry.removePrefix(state.selectedCountryPrefix.digits.toString())
|
||||
PhoneNumberFormatter.formatE164(state.selectedCountryPrefix.digits.toString(), cleansed)
|
||||
}
|
||||
stringResource(id = R.string.FindByActivity__s_is_not_a_valid_phone_number, formattedNumber)
|
||||
}
|
||||
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = title,
|
||||
body = body,
|
||||
confirm = stringResource(id = android.R.string.ok),
|
||||
onConfirm = {},
|
||||
onDismiss = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
dialog(
|
||||
route = "not-found/{recipientId}",
|
||||
arguments = listOf(navArgument("recipientId") { type = NavType.LongType })
|
||||
) { navBackStackEntry ->
|
||||
val title = if (state.mode == FindByMode.USERNAME) {
|
||||
stringResource(id = R.string.FindByActivity__username_not_found)
|
||||
} else {
|
||||
stringResource(id = R.string.FindByActivity__invite_to_signal)
|
||||
}
|
||||
|
||||
val body = if (state.mode == FindByMode.USERNAME) {
|
||||
stringResource(id = R.string.FindByActivity__s_is_not_a_signal_user, state.userEntry)
|
||||
} else {
|
||||
val formattedNumber = remember(state.userEntry) {
|
||||
val cleansed = state.userEntry.removePrefix(state.selectedCountryPrefix.digits.toString())
|
||||
PhoneNumberFormatter.formatE164(state.selectedCountryPrefix.digits.toString(), cleansed)
|
||||
}
|
||||
stringResource(id = R.string.FindByActivity__s_is_not_a_signal_user_would, formattedNumber)
|
||||
}
|
||||
|
||||
val confirm = if (state.mode == FindByMode.USERNAME) {
|
||||
stringResource(id = android.R.string.ok)
|
||||
} else {
|
||||
stringResource(id = R.string.FindByActivity__invite)
|
||||
}
|
||||
|
||||
val dismiss = if (state.mode == FindByMode.USERNAME) {
|
||||
Dialogs.NoDismiss
|
||||
} else {
|
||||
stringResource(id = android.R.string.cancel)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = title,
|
||||
body = body,
|
||||
confirm = confirm,
|
||||
dismiss = dismiss,
|
||||
onConfirm = {
|
||||
if (state.mode == FindByMode.PHONE_NUMBER) {
|
||||
val recipientId = navBackStackEntry.arguments?.getLong("recipientId")?.takeIf { it > 0 }?.let { RecipientId.from(it) } ?: RecipientId.UNKNOWN
|
||||
if (recipientId != RecipientId.UNKNOWN) {
|
||||
InviteActions.inviteUserToSignal(
|
||||
context,
|
||||
Recipient.resolved(recipientId),
|
||||
null,
|
||||
this@FindByActivity::startActivity
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Contract : ActivityResultContract<FindByMode, RecipientId?>() {
|
||||
override fun createIntent(context: Context, input: FindByMode): Intent {
|
||||
return Intent(context, FindByActivity::class.java)
|
||||
.putExtra(MODE, input.name)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): RecipientId? {
|
||||
return intent?.getParcelableExtraCompat(RECIPIENT_ID, RecipientId::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun FindByContentPreview() {
|
||||
Previews.Preview {
|
||||
FindByContent(
|
||||
paddingValues = PaddingValues(0.dp),
|
||||
state = FindByState(
|
||||
mode = FindByMode.PHONE_NUMBER,
|
||||
userEntry = ""
|
||||
),
|
||||
onUserEntryChanged = {},
|
||||
onNextClick = {},
|
||||
onSelectCountryPrefixClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FindByContent(
|
||||
paddingValues: PaddingValues,
|
||||
state: FindByState,
|
||||
onUserEntryChanged: (String) -> Unit,
|
||||
onNextClick: () -> Unit,
|
||||
onSelectCountryPrefixClick: () -> Unit
|
||||
) {
|
||||
val placeholderLabel = remember(state.mode) {
|
||||
if (state.mode == FindByMode.PHONE_NUMBER) R.string.FindByActivity__phone_number else R.string.FindByActivity__username
|
||||
}
|
||||
|
||||
val focusRequester = remember {
|
||||
FocusRequester()
|
||||
}
|
||||
|
||||
val keyboardType = remember(state.mode) {
|
||||
if (state.mode == FindByMode.PHONE_NUMBER) {
|
||||
KeyboardType.Phone
|
||||
} else {
|
||||
KeyboardType.Text
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val onNextAction = remember(state.isLookupInProgress) {
|
||||
KeyboardActions(onNext = {
|
||||
if (!state.isLookupInProgress) {
|
||||
onNextClick()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val visualTransformation = if (state.mode == FindByMode.USERNAME) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
remember(state.selectedCountryPrefix) {
|
||||
PhoneNumberVisualTransformation(state.selectedCountryPrefix.regionCode)
|
||||
}
|
||||
}
|
||||
|
||||
TextFields.TextField(
|
||||
enabled = !state.isLookupInProgress,
|
||||
value = state.userEntry,
|
||||
onValueChange = onUserEntryChanged,
|
||||
singleLine = true,
|
||||
placeholder = { Text(text = stringResource(id = placeholderLabel)) },
|
||||
prefix = if (state.mode == FindByMode.USERNAME) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
PhoneNumberEntryPrefix(
|
||||
enabled = !state.isLookupInProgress,
|
||||
selectedCountryPrefix = state.selectedCountryPrefix,
|
||||
onSelectCountryPrefixClick = onSelectCountryPrefixClick
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = visualTransformation,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
keyboardActions = onNextAction,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.heightIn(min = 44.dp),
|
||||
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(top = 10.dp, bottom = 10.dp)
|
||||
)
|
||||
|
||||
if (state.mode == FindByMode.USERNAME) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.FindByActivity__enter_a_full_username),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
enabled = !state.isLookupInProgress,
|
||||
onClick = onNextClick,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_arrow_right_24),
|
||||
contentDescription = stringResource(id = R.string.FindByActivity__next)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneNumberEntryPrefix(
|
||||
enabled: Boolean,
|
||||
selectedCountryPrefix: CountryPrefix,
|
||||
onSelectCountryPrefixClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(end = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = onSelectCountryPrefixClick, enabled = enabled)
|
||||
) {
|
||||
Text(
|
||||
text = selectedCountryPrefix.toString()
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_dropdown_triangle_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Dividers.Vertical(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 2.dp)
|
||||
.padding(start = 8.dp)
|
||||
.height(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SelectCountryScreenPreview() {
|
||||
Previews.Preview {
|
||||
SelectCountryScreen(
|
||||
paddingValues = PaddingValues(0.dp),
|
||||
searchEntry = "",
|
||||
onSearchEntryChanged = {},
|
||||
supportedCountryPrefixes = FindByState(mode = FindByMode.PHONE_NUMBER).supportedCountryPrefixes,
|
||||
onCountryPrefixSelected = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectCountryScreen(
|
||||
paddingValues: PaddingValues,
|
||||
searchEntry: String,
|
||||
onSearchEntryChanged: (String) -> Unit,
|
||||
onCountryPrefixSelected: (CountryPrefix) -> Unit,
|
||||
supportedCountryPrefixes: List<CountryPrefix>
|
||||
) {
|
||||
val focusRequester = remember {
|
||||
FocusRequester()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
TextFields.TextField(
|
||||
value = searchEntry,
|
||||
onValueChange = onSearchEntryChanged,
|
||||
placeholder = { Text(text = stringResource(id = R.string.FindByActivity__search)) },
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.heightIn(min = 44.dp),
|
||||
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(top = 10.dp, bottom = 10.dp)
|
||||
)
|
||||
|
||||
LazyColumn {
|
||||
items(
|
||||
items = supportedCountryPrefixes
|
||||
) {
|
||||
CountryPrefixRowItem(
|
||||
searchTerm = searchEntry,
|
||||
countryPrefix = it,
|
||||
onClick = { onCountryPrefixSelected(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CountryPrefixRowItem(
|
||||
searchTerm: String,
|
||||
countryPrefix: CountryPrefix,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val regionDisplayName = remember(countryPrefix.regionCode, Locale.current) {
|
||||
PhoneNumberFormatter.getRegionDisplayName(countryPrefix.regionCode).orElse(countryPrefix.regionCode)
|
||||
}
|
||||
|
||||
if (searchTerm.isNotBlank() && !regionDisplayName.contains(searchTerm, ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
|
||||
val highlightedName: AnnotatedString = remember(regionDisplayName, searchTerm) {
|
||||
if (searchTerm.isBlank()) {
|
||||
AnnotatedString(regionDisplayName)
|
||||
} else {
|
||||
buildAnnotatedString {
|
||||
append(regionDisplayName)
|
||||
|
||||
val startIndex = regionDisplayName.indexOf(searchTerm, ignoreCase = true)
|
||||
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
start = startIndex,
|
||||
end = startIndex + searchTerm.length
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = spacedBy((-2).dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
.padding(top = 16.dp, bottom = 14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = highlightedName
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countryPrefix.toString(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.recipients.ui.findby
|
||||
|
||||
enum class FindByMode {
|
||||
PHONE_NUMBER,
|
||||
USERNAME
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.recipients.ui.findby
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
sealed interface FindByResult {
|
||||
data class Success(val recipientId: RecipientId) : FindByResult
|
||||
object InvalidEntry : FindByResult
|
||||
data class NotFound(val recipientId: RecipientId = RecipientId.UNKNOWN) : FindByResult
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.recipients.ui.findby
|
||||
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
|
||||
data class FindByState(
|
||||
val mode: FindByMode,
|
||||
val userEntry: String = "",
|
||||
val supportedCountryPrefixes: List<CountryPrefix> = PhoneNumberUtil.getInstance().supportedCallingCodes
|
||||
.map { CountryPrefix(it, PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(it)) }
|
||||
.sortedBy { it.digits.toString() },
|
||||
val selectedCountryPrefix: CountryPrefix = supportedCountryPrefixes.first(),
|
||||
val countryPrefixSearchEntry: String = "",
|
||||
val isLookupInProgress: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.recipients.ui.findby
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import org.signal.core.util.concurrent.safeBlockingGet
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery
|
||||
import org.thoughtcrime.securesms.phonenumbers.NumberUtil
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FindByViewModel(
|
||||
mode: FindByMode
|
||||
) : ViewModel() {
|
||||
|
||||
private val internalState = mutableStateOf(
|
||||
FindByState(
|
||||
mode = mode
|
||||
)
|
||||
)
|
||||
|
||||
val state: State<FindByState> = internalState
|
||||
|
||||
fun onUserEntryChanged(userEntry: String) {
|
||||
val cleansed = if (state.value.mode == FindByMode.PHONE_NUMBER) {
|
||||
userEntry.filter { it.isDigit() }
|
||||
} else {
|
||||
userEntry
|
||||
}
|
||||
|
||||
internalState.value = state.value.copy(userEntry = cleansed)
|
||||
}
|
||||
|
||||
fun onCountryPrefixSearchEntryChanged(searchEntry: String) {
|
||||
internalState.value = state.value.copy(countryPrefixSearchEntry = searchEntry)
|
||||
}
|
||||
|
||||
fun onCountryPrefixSelected(countryPrefix: CountryPrefix) {
|
||||
internalState.value = state.value.copy(selectedCountryPrefix = countryPrefix)
|
||||
}
|
||||
|
||||
suspend fun onNextClicked(context: Context): FindByResult {
|
||||
internalState.value = state.value.copy(isLookupInProgress = true)
|
||||
val findByResult = viewModelScope.async(context = Dispatchers.IO) {
|
||||
if (state.value.mode == FindByMode.USERNAME) {
|
||||
performUsernameLookup()
|
||||
} else {
|
||||
performPhoneLookup(context)
|
||||
}
|
||||
}.await()
|
||||
|
||||
internalState.value = state.value.copy(isLookupInProgress = false)
|
||||
return findByResult
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun performUsernameLookup(): FindByResult {
|
||||
val username = state.value.userEntry
|
||||
|
||||
if (!UsernameUtil.isValidUsernameForSearch(username)) {
|
||||
return FindByResult.InvalidEntry
|
||||
}
|
||||
|
||||
return when (val result = UsernameRepository.fetchAciForUsername(username = username).safeBlockingGet()) {
|
||||
UsernameRepository.UsernameAciFetchResult.NetworkError -> FindByResult.NotFound()
|
||||
UsernameRepository.UsernameAciFetchResult.NotFound -> FindByResult.NotFound()
|
||||
is UsernameRepository.UsernameAciFetchResult.Success -> FindByResult.Success(Recipient.externalUsername(result.aci, username).id)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun performPhoneLookup(context: Context): FindByResult {
|
||||
val stateSnapshot = state.value
|
||||
val countryCode = stateSnapshot.selectedCountryPrefix.digits
|
||||
val nationalNumber = stateSnapshot.userEntry.removePrefix(countryCode.toString())
|
||||
|
||||
val e164 = "$countryCode$nationalNumber"
|
||||
|
||||
if (!NumberUtil.isVisuallyValidNumber(e164)) {
|
||||
return FindByResult.InvalidEntry
|
||||
}
|
||||
|
||||
val recipient = try {
|
||||
Recipient.external(context, e164)
|
||||
} catch (e: Exception) {
|
||||
return FindByResult.InvalidEntry
|
||||
}
|
||||
|
||||
return if (!recipient.isRegistered || !recipient.hasServiceId()) {
|
||||
try {
|
||||
ContactDiscovery.refresh(context, recipient, false, TimeUnit.SECONDS.toMillis(10))
|
||||
val resolved = Recipient.resolved(recipient.id)
|
||||
if (!resolved.isRegistered) {
|
||||
FindByResult.NotFound(recipient.id)
|
||||
} else {
|
||||
FindByResult.Success(recipient.id)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
FindByResult.NotFound(recipient.id)
|
||||
}
|
||||
} else {
|
||||
FindByResult.Success(recipient.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user