Add initial Mentions UI/UX for picker and compose edit.

This commit is contained in:
Cody Henthorne
2020-07-27 09:58:58 -04:00
committed by Greyson Parrelli
parent 8e45a546c9
commit 1ab61beeb9
28 changed files with 1019 additions and 16 deletions

View File

@@ -125,6 +125,7 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState;
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.crypto.SecurityEvent;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -226,6 +227,7 @@ import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
@@ -341,6 +343,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
private InputPanel inputPanel;
private View panelParent;
private View noLongerMemberBanner;
private Stub<View> mentionsSuggestions;
private LinkPreviewViewModel linkPreviewViewModel;
private ConversationSearchViewModel searchViewModel;
@@ -414,6 +417,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
initializeStickerObserver();
initializeViewModel();
initializeGroupViewModel();
if (FeatureFlags.mentions()) initializeMentionsViewModel();
initializeEnabledCheck();
initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
@Override
@@ -1700,6 +1704,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
searchNav = ViewUtil.findById(this, R.id.conversation_search_nav);
messageRequestBottomView = ViewUtil.findById(this, R.id.conversation_activity_message_request_bottom_bar);
reactionOverlay = ViewUtil.findById(this, R.id.conversation_reaction_scrubber);
mentionsSuggestions = ViewUtil.findStubById(this, R.id.conversation_mention_suggestions_stub);
ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
@@ -1864,6 +1869,28 @@ public class ConversationActivity extends PassphraseRequiredActivity
groupViewModel.getGroupActiveState().observe(this, unused -> invalidateOptionsMenu());
}
private void initializeMentionsViewModel() {
MentionsPickerViewModel mentionsViewModel = ViewModelProviders.of(this, new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class);
recipient.observe(this, mentionsViewModel::onRecipientChange);
composeText.setMentionQueryChangedListener(query -> {
if (getRecipient().isGroup()) {
if (!mentionsSuggestions.resolved()) {
mentionsSuggestions.get();
}
mentionsViewModel.onQueryChange(query);
}
});
mentionsViewModel.getSelectedRecipient().observe(this, recipient -> {
String replacementDisplayName = recipient.getDisplayName(this);
if (replacementDisplayName.equals(recipient.getDisplayUsername())) {
replacementDisplayName = recipient.getUsername().or(replacementDisplayName);
}
composeText.replaceTextWithMention(replacementDisplayName, recipient.requireUuid());
});
}
private void showStickerIntroductionTooltip() {
TextSecurePreferences.setMediaKeyboardMode(this, MediaKeyboardMode.STICKER);
inputPanel.setMediaKeyboardToggleMode(true);

View File

@@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.conversation.ui.mentions;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MappingAdapter;
import org.thoughtcrime.securesms.util.MappingViewHolder;
public class MentionViewHolder extends MappingViewHolder<MentionViewState> {
private final AvatarImageView avatar;
private final TextView name;
@Nullable private final MentionEventsListener mentionEventsListener;
public MentionViewHolder(@NonNull View itemView, @Nullable MentionEventsListener mentionEventsListener) {
super(itemView);
this.mentionEventsListener = mentionEventsListener;
avatar = findViewById(R.id.mention_recipient_avatar);
name = findViewById(R.id.mention_recipient_name);
}
@Override
public void bind(@NonNull MentionViewState model) {
avatar.setRecipient(model.getRecipient());
name.setText(model.getName(context));
itemView.setOnClickListener(v -> {
if (mentionEventsListener != null) {
mentionEventsListener.onMentionClicked(model.getRecipient());
}
});
}
public interface MentionEventsListener {
void onMentionClicked(@NonNull Recipient recipient);
}
public static MappingAdapter.Factory<MentionViewState> createFactory(@Nullable MentionEventsListener mentionEventsListener) {
return new MappingAdapter.LayoutFactory<>(view -> new MentionViewHolder(view, mentionEventsListener), R.layout.mentions_recipient_list_item);
}
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.conversation.ui.mentions;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MappingModel;
import java.util.Objects;
public final class MentionViewState implements MappingModel<MentionViewState> {
private final Recipient recipient;
public MentionViewState(@NonNull Recipient recipient) {
this.recipient = recipient;
}
@NonNull String getName(@NonNull Context context) {
return recipient.getDisplayName(context);
}
@NonNull Recipient getRecipient() {
return recipient;
}
@Override
public boolean areItemsTheSame(@NonNull MentionViewState newItem) {
return recipient.getId().equals(newItem.recipient.getId());
}
@Override
public boolean areContentsTheSame(@NonNull MentionViewState newItem) {
Context context = ApplicationDependencies.getApplication();
return recipient.getDisplayName(context).equals(newItem.recipient.getDisplayName(context)) &&
Objects.equals(recipient.getProfileAvatar(), newItem.recipient.getProfileAvatar());
}
}

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.conversation.ui.mentions;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionViewHolder.MentionEventsListener;
import org.thoughtcrime.securesms.util.MappingAdapter;
public class MentionsPickerAdapter extends MappingAdapter {
public MentionsPickerAdapter(@Nullable MentionEventsListener mentionEventsListener) {
registerFactory(MentionViewState.class, MentionViewHolder.createFactory(mentionEventsListener));
}
}

View File

@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.conversation.ui.mentions;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.List;
public class MentionsPickerFragment extends LoggingFragment {
private MentionsPickerAdapter adapter;
private RecyclerView list;
private BottomSheetBehavior<View> behavior;
private MentionsPickerViewModel viewModel;
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.mentions_picker_fragment, container, false);
list = view.findViewById(R.id.mentions_picker_list);
behavior = BottomSheetBehavior.from(view.findViewById(R.id.mentions_picker_bottom_sheet));
return view;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
initializeList();
viewModel = ViewModelProviders.of(requireActivity()).get(MentionsPickerViewModel.class);
viewModel.getMentionList().observe(getViewLifecycleOwner(), this::updateList);
}
private void initializeList() {
adapter = new MentionsPickerAdapter(this::handleMentionClicked);
RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(requireContext()) {
@Override
public void onLayoutCompleted(RecyclerView.State state) {
super.onLayoutCompleted(state);
updateBottomSheetBehavior(adapter.getItemCount());
}
};
list.setLayoutManager(layoutManager);
list.setAdapter(adapter);
list.setItemAnimator(null);
}
private void handleMentionClicked(@NonNull Recipient recipient) {
viewModel.onSelectionChange(recipient);
}
private void updateList(@NonNull List<MappingModel<?>> mappingModels) {
adapter.submitList(mappingModels);
if (mappingModels.isEmpty()) {
updateBottomSheetBehavior(0);
}
}
private void updateBottomSheetBehavior(int count) {
if (count > 0) {
if (behavior.getPeekHeight() == 0) {
behavior.setPeekHeight(ViewUtil.dpToPx(240), true);
behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
} else {
behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
behavior.setPeekHeight(0);
}
}
}

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.conversation.ui.mentions;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry.FullMember;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.Collections;
import java.util.List;
public class MentionsPickerViewModel extends ViewModel {
private final SingleLiveEvent<Recipient> selectedRecipient;
private final LiveData<List<MappingModel<?>>> mentionList;
private final MutableLiveData<LiveGroup> group;
private final MutableLiveData<CharSequence> liveQuery;
MentionsPickerViewModel() {
group = new MutableLiveData<>();
liveQuery = new MutableLiveData<>();
selectedRecipient = new SingleLiveEvent<>();
// TODO [cody] [mentions] simple query support implement for building UI/UX, to be replaced with better search before launch
LiveData<List<FullMember>> members = Transformations.distinctUntilChanged(Transformations.switchMap(group, LiveGroup::getFullMembers));
mentionList = LiveDataUtil.combineLatest(Transformations.distinctUntilChanged(liveQuery), members, this::filterMembers);
}
@NonNull LiveData<List<MappingModel<?>>> getMentionList() {
return mentionList;
}
void onSelectionChange(@NonNull Recipient recipient) {
selectedRecipient.setValue(recipient);
}
public @NonNull LiveData<Recipient> getSelectedRecipient() {
return selectedRecipient;
}
public void onQueryChange(@NonNull CharSequence query) {
liveQuery.setValue(query);
}
public void onRecipientChange(@NonNull Recipient recipient) {
GroupId groupId = recipient.getGroupId().orNull();
if (groupId != null) {
LiveGroup liveGroup = new LiveGroup(groupId);
group.setValue(liveGroup);
}
}
private @NonNull List<MappingModel<?>> filterMembers(@NonNull CharSequence query, @NonNull List<FullMember> members) {
if (TextUtils.isEmpty(query)) {
return Collections.emptyList();
}
return Stream.of(members)
.filter(m -> m.getMember().getDisplayName(ApplicationDependencies.getApplication()).toLowerCase().replaceAll("\\s", "").startsWith(query.toString()))
.<MappingModel<?>>map(m -> new MentionViewState(m.getMember()))
.toList();
}
public static final class Factory implements ViewModelProvider.Factory {
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new MentionsPickerViewModel());
}
}
}