From 13d95523a367bf081f9e5b59c92ef20cf50cdf4e Mon Sep 17 00:00:00 2001 From: Ammar Githam Date: Sun, 17 Jan 2021 03:09:07 +0900 Subject: [PATCH] Swipe to reply --- .../adapters/DirectItemsAdapter.java | 2 +- .../DirectItemActionLogViewHolder.java | 6 + .../DirectItemAnimatedMediaViewHolder.java | 6 + .../DirectItemDefaultViewHolder.java | 6 + .../DirectItemLikeViewHolder.java | 6 + .../DirectItemMediaShareViewHolder.java | 17 +- .../DirectItemPlaceholderViewHolder.java | 6 + .../DirectItemProfileViewHolder.java | 6 + .../DirectItemStoryShareViewHolder.java | 6 + .../DirectItemVideoCallEventViewHolder.java | 6 + .../directmessages/DirectItemViewHolder.java | 14 +- .../customviews/DirectItemFrameLayout.java | 16 +- ...wipeAndRestoreItemTouchHelperCallback.java | 184 +++++++++++++++ .../DirectMessageThreadFragment.java | 214 ++++++++++++++++-- .../models/enums/DirectItemType.java | 6 +- .../directmessages/BroadcastOptions.java | 19 ++ .../responses/directmessages/DirectItem.java | 6 +- .../instagrabber/utils/DirectItemFactory.java | 5 +- .../java/awais/instagrabber/utils/Utils.java | 6 +- .../viewmodels/DirectThreadViewModel.java | 38 +++- .../webservices/DirectMessagesService.java | 24 +- .../main/res/drawable/ic_round_reply_24.xml | 10 + .../fragment_direct_messages_thread.xml | 90 +++++++- app/src/main/res/values/strings.xml | 6 + 24 files changed, 650 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/awais/instagrabber/customviews/helpers/SwipeAndRestoreItemTouchHelperCallback.java create mode 100644 app/src/main/res/drawable/ic_round_reply_24.xml diff --git a/app/src/main/java/awais/instagrabber/adapters/DirectItemsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/DirectItemsAdapter.java index 785aad31..ae72002c 100644 --- a/app/src/main/java/awais/instagrabber/adapters/DirectItemsAdapter.java +++ b/app/src/main/java/awais/instagrabber/adapters/DirectItemsAdapter.java @@ -342,7 +342,7 @@ public final class DirectItemsAdapter extends RecyclerView.Adapter openLocation(location.getPk())); } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } } diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemStoryShareViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemStoryShareViewHolder.java index 682ed78a..869e0ca8 100644 --- a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemStoryShareViewHolder.java +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemStoryShareViewHolder.java @@ -5,6 +5,7 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.core.util.Pair; +import androidx.recyclerview.widget.ItemTouchHelper; import com.facebook.drawee.drawable.ScalingUtils; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; @@ -110,4 +111,9 @@ public class DirectItemStoryShareViewHolder extends DirectItemViewHolder { protected boolean canForward() { return false; } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } } diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVideoCallEventViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVideoCallEventViewHolder.java index 39c52b81..c33c346e 100644 --- a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVideoCallEventViewHolder.java +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVideoCallEventViewHolder.java @@ -7,6 +7,7 @@ import android.text.style.ForegroundColorSpan; import android.view.View; import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; import java.util.List; @@ -76,4 +77,9 @@ public class DirectItemVideoCallEventViewHolder extends DirectItemViewHolder { protected boolean allowLongClick() { return false; } + + @Override + public int getSwipeDirection() { + return ItemTouchHelper.ACTION_STATE_IDLE; + } } diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemViewHolder.java index ebcda8f8..3e360503 100644 --- a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemViewHolder.java +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemViewHolder.java @@ -18,6 +18,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.widget.ImageViewCompat; +import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import androidx.transition.TransitionManager; @@ -33,6 +34,7 @@ import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemInternalLongClic import awais.instagrabber.customviews.DirectItemContextMenu; import awais.instagrabber.customviews.DirectItemFrameLayout; import awais.instagrabber.customviews.RamboTextViewV2; +import awais.instagrabber.customviews.helpers.SwipeAndRestoreItemTouchHelperCallback.SwipeableViewHolder; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.models.enums.DirectItemType; import awais.instagrabber.models.enums.MediaItemType; @@ -46,7 +48,7 @@ import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.DeepLinkParser; import awais.instagrabber.utils.ResponseBodyUtils; -public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder { +public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder implements SwipeableViewHolder { private static final String TAG = DirectItemViewHolder.class.getSimpleName(); private final LayoutDmBaseBinding binding; @@ -72,6 +74,7 @@ public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder { private DirectItemInternalLongClickListener longClickListener; private DirectItem item; private ViewPropertyAnimator shrinkGrowAnimator; + private MessageDirection messageDirection; // private View.OnLayoutChangeListener layoutChangeListener; public DirectItemViewHolder(@NonNull final LayoutDmBaseBinding binding, @@ -108,7 +111,7 @@ public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder { public void bind(final int position, final DirectItem item) { this.item = item; - final MessageDirection messageDirection = isSelf(item) ? MessageDirection.OUTGOING : MessageDirection.INCOMING; + messageDirection = isSelf(item) ? MessageDirection.OUTGOING : MessageDirection.INCOMING; itemView.post(() -> bindBase(item, messageDirection, position)); itemView.post(() -> bindItem(item, messageDirection)); itemView.post(() -> setupLongClickListener(position, messageDirection)); @@ -266,6 +269,7 @@ public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder { // if (media == null) break; // url = ResponseBodyUtils.getThumbUrl(media.getImageVersions2()); // break; + // case LOCATION } if (text == null && url == null) { binding.quoteLine.setVisibility(View.GONE); @@ -568,6 +572,12 @@ public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder { shrinkGrowAnimator.start(); } + @Override + public int getSwipeDirection() { + if (item == null || messageDirection == null) return ItemTouchHelper.ACTION_STATE_IDLE; + return messageDirection == MessageDirection.OUTGOING ? ItemTouchHelper.START : ItemTouchHelper.END; + } + public enum MessageDirection { INCOMING, OUTGOING diff --git a/app/src/main/java/awais/instagrabber/customviews/DirectItemFrameLayout.java b/app/src/main/java/awais/instagrabber/customviews/DirectItemFrameLayout.java index 09ca687b..e119fcdf 100644 --- a/app/src/main/java/awais/instagrabber/customviews/DirectItemFrameLayout.java +++ b/app/src/main/java/awais/instagrabber/customviews/DirectItemFrameLayout.java @@ -73,9 +73,20 @@ public class DirectItemFrameLayout extends FrameLayout { touchY = ev.getRawY(); break; case MotionEvent.ACTION_MOVE: - if (longPressed || Math.abs(touchX - ev.getRawX()) > touchSlop || Math.abs(touchY - ev.getRawY()) > touchSlop) { + final float diffX = touchX - ev.getRawX(); + final float diffXAbs = Math.abs(diffX); + final boolean isMoved = diffXAbs > touchSlop || Math.abs(touchY - ev.getRawY()) > touchSlop; + if (longPressed || isMoved) { handler.removeCallbacks(longPressStartRunnable); handler.removeCallbacks(longPressRunnable); + if (!longPressed) { + if (onItemLongClickListener != null) { + onItemLongClickListener.onLongClickCancel(this); + } + } + // if (diffXAbs > touchSlop) { + // setTranslationX(-diffX); + // } } break; case MotionEvent.ACTION_UP: @@ -91,6 +102,9 @@ public class DirectItemFrameLayout extends FrameLayout { case MotionEvent.ACTION_CANCEL: handler.removeCallbacks(longPressRunnable); handler.removeCallbacks(longPressStartRunnable); + if (onItemLongClickListener != null) { + onItemLongClickListener.onLongClickCancel(this); + } break; } final boolean dispatchTouchEvent = super.dispatchTouchEvent(ev); diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/SwipeAndRestoreItemTouchHelperCallback.java b/app/src/main/java/awais/instagrabber/customviews/helpers/SwipeAndRestoreItemTouchHelperCallback.java new file mode 100644 index 00000000..dee19e68 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/SwipeAndRestoreItemTouchHelperCallback.java @@ -0,0 +1,184 @@ +package awais.instagrabber.customviews.helpers; + + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.utils.Utils; + +/** + * Thanks to https://github.com/izjumovfs/SwipeToReply/blob/master/swipetoreply/src/main/java/com/capybaralabs/swipetoreply/SwipeController.java + */ +public class SwipeAndRestoreItemTouchHelperCallback extends ItemTouchHelper.Callback { + private static final String TAG = "SwipeRestoreCallback"; + + private final float swipeThreshold; + private final float swipeAutoCancelThreshold; + private final OnSwipeListener onSwipeListener; + private final Drawable replyIcon; + // private final Drawable replyIconBackground; + private final int replyIconShowThreshold; + private final float replyIconMaxTranslation; + private final Rect replyIconBounds = new Rect(); + private final float replyIconXOffset; + private final int replyIconSize; + + private boolean mSwipeBack = false; + private boolean hasVibrated; + + public SwipeAndRestoreItemTouchHelperCallback(final Context context, final OnSwipeListener onSwipeListener) { + this.onSwipeListener = onSwipeListener; + swipeThreshold = Utils.displayMetrics.widthPixels * 0.25f; + swipeAutoCancelThreshold = swipeThreshold + Utils.convertDpToPx(5); + replyIcon = AppCompatResources.getDrawable(context, R.drawable.ic_round_reply_24); + if (replyIcon == null) { + throw new IllegalArgumentException("reply icon is null"); + } + replyIcon.setTint(context.getResources().getColor(R.color.white)); //todo need to update according to theme + replyIconShowThreshold = Utils.convertDpToPx(24); + replyIconMaxTranslation = swipeThreshold - replyIconShowThreshold; + // Log.d(TAG, "replyIconShowThreshold: " + replyIconShowThreshold + ", swipeThreshold: " + swipeThreshold); + replyIconSize = replyIconShowThreshold; // Utils.convertDpToPx(24); + replyIconXOffset = swipeThreshold * 0.25f /*Utils.convertDpToPx(20)*/; + } + + @Override + public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + if (!(viewHolder instanceof SwipeableViewHolder)) { + return makeMovementFlags(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.ACTION_STATE_IDLE); + } + return makeMovementFlags(ItemTouchHelper.ACTION_STATE_IDLE, ((SwipeableViewHolder) viewHolder).getSwipeDirection()); + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + @NonNull RecyclerView.ViewHolder viewHolder1) { + return false; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int i) {} + + @Override + public int convertToAbsoluteDirection(int flags, int layoutDirection) { + if (mSwipeBack) { + mSwipeBack = false; + return 0; + } + return super.convertToAbsoluteDirection(flags, layoutDirection); + } + + @Override + public void onChildDraw(@NonNull Canvas c, + @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float dX, + float dY, + int actionState, + boolean isCurrentlyActive) { + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { + setTouchListener(recyclerView, viewHolder); + } + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + drawReplyButton(c, viewHolder); + } + + @SuppressLint("ClickableViewAccessibility") + private void setTouchListener(RecyclerView recyclerView, final RecyclerView.ViewHolder viewHolder) { + recyclerView.setOnTouchListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_MOVE) { + if (Math.abs(viewHolder.itemView.getTranslationX()) >= swipeAutoCancelThreshold) { + if (!hasVibrated) { + viewHolder.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING); + hasVibrated = true; + } + // MotionEvent cancelEvent = MotionEvent.obtain(event); + // cancelEvent.setAction(MotionEvent.ACTION_CANCEL); + // recyclerView.dispatchTouchEvent(cancelEvent); + // cancelEvent.recycle(); + } + } + mSwipeBack = event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_UP; + if (mSwipeBack) { + hasVibrated = false; + if (Math.abs(viewHolder.itemView.getTranslationX()) >= swipeThreshold) { + if (onSwipeListener != null) { + onSwipeListener.onSwipe(viewHolder.getBindingAdapterPosition(), viewHolder); + } + } + } + return false; + }); + } + + public interface SwipeableViewHolder { + int getSwipeDirection(); + } + + public interface OnSwipeListener { + void onSwipe(final int adapterPosition, final RecyclerView.ViewHolder viewHolder); + } + + private void drawReplyButton(Canvas canvas, final RecyclerView.ViewHolder viewHolder) { + if (!(viewHolder instanceof SwipeableViewHolder)) return; + final int swipeDirection = ((SwipeableViewHolder) viewHolder).getSwipeDirection(); + if (swipeDirection != ItemTouchHelper.START && swipeDirection != ItemTouchHelper.END) return; + final View view = viewHolder.itemView; + float translationX = view.getTranslationX(); + boolean show = false; + float progress; + final float translationXAbs = Math.abs(translationX); + if (translationXAbs >= replyIconShowThreshold) { + show = true; + } + if (show) { + // replyIconShowThreshold -> swipeThreshold <=> progress 0 -> 1 + final float replyIconTranslation = translationXAbs - replyIconShowThreshold; + progress = replyIconTranslation / replyIconMaxTranslation; + if (progress > 1) { + progress = 1f; + } + if (progress < 0) { + progress = 0; + } + // Log.d(TAG, /*"translationX: " + translationX + ", replyIconTranslation: " + replyIconTranslation +*/ "progress: " + progress); + } else { + progress = 0f; + // Log.d(TAG, /*"translationX: " + translationX + ", replyIconTranslation: " + 0 +*/ "progress: " + progress); + } + if (progress > 0) { + // calculate the reply icon y position, then offset top, bottom with icon size + final int y = view.getTop() + (view.getMeasuredHeight() / 2); + final int tempIconSize = (int) (replyIconSize * progress); + final int tempIconSizeHalf = tempIconSize / 2; + final int xOffset = (int) (replyIconXOffset * progress); + final int left; + if (swipeDirection == ItemTouchHelper.END) { + // draw arrow of left side + left = xOffset; + } else { + // draw arrow of right side + left = view.getMeasuredWidth() - xOffset - tempIconSize; + } + final int right = tempIconSize + left; + replyIconBounds.set(left, y - tempIconSizeHalf, right, y + tempIconSizeHalf); + replyIcon.setBounds(replyIconBounds); + replyIcon.draw(canvas); + } + } + +} diff --git a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java index f0692017..c4671f51 100644 --- a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java @@ -41,9 +41,11 @@ import androidx.navigation.NavBackStackEntry; import androidx.navigation.NavController; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.SimpleItemAnimator; +import androidx.transition.TransitionManager; import androidx.vectordrawable.graphics.drawable.Animatable2Compat; import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat; @@ -73,6 +75,7 @@ import awais.instagrabber.customviews.emoji.Emoji; import awais.instagrabber.customviews.helpers.HeaderItemDecoration; import awais.instagrabber.customviews.helpers.HeightProvider; import awais.instagrabber.customviews.helpers.RecyclerLazyLoaderAtEdge; +import awais.instagrabber.customviews.helpers.SwipeAndRestoreItemTouchHelperCallback; import awais.instagrabber.customviews.helpers.TextWatcherAdapter; import awais.instagrabber.databinding.FragmentDirectMessagesThreadBinding; import awais.instagrabber.dialogs.DirectItemReactionDialogFragment; @@ -81,16 +84,19 @@ import awais.instagrabber.fragments.PostViewV2Fragment; import awais.instagrabber.fragments.UserSearchFragment; import awais.instagrabber.fragments.UserSearchFragmentDirections; import awais.instagrabber.models.Resource; +import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.requests.StoryViewerOptions; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemEmojiReaction; import awais.instagrabber.repositories.responses.directmessages.DirectItemStoryShare; +import awais.instagrabber.repositories.responses.directmessages.DirectItemVisualMedia; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.PermissionUtils; +import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.AppStateViewModel; @@ -133,6 +139,7 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact private DirectItemReactionDialogFragment reactionDialogFragment; private DirectItem itemToForward; private MutableLiveData backStackSavedStateResultLiveData; + private int prevLength; private final AppExecutors appExecutors = AppExecutors.getInstance(); private final Animatable2Compat.AnimationCallback micToSendAnimationCallback = new Animatable2Compat.AnimationCallback() { @@ -252,7 +259,6 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact } } }; - private final DirectItemLongClickListener directItemLongClickListener = position -> { // viewModel.setSelectedPosition(position); }; @@ -280,6 +286,7 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact // clear result backStackSavedStateResultLiveData.postValue(null); }; + private final MutableLiveData inputLength = new MutableLiveData<>(0); @Override public void onCreate(@Nullable final Bundle savedInstanceState) { @@ -491,6 +498,17 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact } }); binding.chats.addItemDecoration(headerItemDecoration); + final SwipeAndRestoreItemTouchHelperCallback touchHelperCallback = new SwipeAndRestoreItemTouchHelperCallback( + context, + (adapterPosition, viewHolder) -> { + if (itemsAdapter == null) return; + final DirectItemOrHeader directItemOrHeader = itemsAdapter.getList().get(adapterPosition); + if (directItemOrHeader.isHeader()) return; + viewModel.setReplyToItem(directItemOrHeader.item); + } + ); + final ItemTouchHelper itemTouchHelper = new ItemTouchHelper(touchHelperCallback); + itemTouchHelper.attachToRecyclerView(binding.chats); // final MentionClickListener mentionClickListener = (view, text, isHashtag, isLocation) -> searchUsername(text); // final DialogInterface.OnClickListener onDialogListener = (dialogInterface, which) -> { // if (which == 0) { @@ -632,6 +650,141 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact setupItemsAdapter(userThreadPair.first, userThreadPair.second); }); viewModel.getItems().observe(getViewLifecycleOwner(), this::submitItemsToAdapter); + viewModel.getReplyToItem().observe(getViewLifecycleOwner(), item -> { + if (item == null) { + if (binding.input.length() == 0) { + showExtraInputOption(true); + } + binding.getRoot().post(() -> { + TransitionManager.beginDelayedTransition(binding.getRoot()); + binding.replyBg.setVisibility(View.GONE); + binding.replyInfo.setVisibility(View.GONE); + binding.replyPreviewImage.setVisibility(View.GONE); + binding.replyCancel.setVisibility(View.GONE); + binding.replyPreviewText.setVisibility(View.GONE); + }); + return; + } + showExtraInputOption(false); + binding.getRoot().postDelayed(() -> { + binding.replyBg.setVisibility(View.VISIBLE); + binding.replyInfo.setVisibility(View.VISIBLE); + binding.replyPreviewImage.setVisibility(View.VISIBLE); + binding.replyCancel.setVisibility(View.VISIBLE); + binding.replyPreviewText.setVisibility(View.VISIBLE); + if (item.getUserId() == viewModel.getViewerId()) { + binding.replyInfo.setText(R.string.replying_to_yourself); + } else { + final User user = viewModel.getUser(item.getUserId()); + if (user != null) { + binding.replyInfo.setText(getString(R.string.replying_to_user, user.getFullName())); + } else { + binding.replyInfo.setVisibility(View.GONE); + } + } + final String previewText = getDirectItemPreviewText(item); + binding.replyPreviewText.setText(TextUtils.isEmpty(previewText) ? getString(R.string.message) : previewText); + final String previewImageUrl = getDirectItemPreviewImageUrl(item); + if (TextUtils.isEmpty(previewImageUrl)) { + binding.replyPreviewImage.setVisibility(View.GONE); + } else { + binding.replyPreviewImage.setImageURI(previewImageUrl); + } + binding.replyCancel.setOnClickListener(v -> viewModel.setReplyToItem(null)); + }, 200); + }); + inputLength.observe(getViewLifecycleOwner(), length -> { + if (length == null) return; + final boolean hasReplyToItem = viewModel.getReplyToItem().getValue() != null; + if (hasReplyToItem) { + prevLength = length; + return; + } + if ((prevLength == 0 && length != 0) || (prevLength != 0 && length == 0)) { + showExtraInputOption(length == 0); + } + prevLength = length; + }); + } + + private void showExtraInputOption(final boolean show) { + if (show) { + if (!binding.send.isListenForRecord()) { + binding.send.setListenForRecord(true); + startIconAnimation(); + } + binding.gallery.setVisibility(View.VISIBLE); + binding.camera.setVisibility(View.VISIBLE); + return; + } + if (binding.send.isListenForRecord()) { + binding.send.setListenForRecord(false); + startIconAnimation(); + } + binding.gallery.setVisibility(View.GONE); + binding.camera.setVisibility(View.GONE); + } + + private String getDirectItemPreviewText(final DirectItem item) { + switch (item.getItemType()) { + case TEXT: + return item.getText(); + case LINK: + return item.getLink().getText(); + case MEDIA: { + final Media media = item.getMedia(); + return getMediaPreviewTextString(media); + } + case RAVEN_MEDIA: { + final DirectItemVisualMedia visualMedia = item.getVisualMedia(); + final Media media = visualMedia.getMedia(); + return getMediaPreviewTextString(media); + } + case VOICE_MEDIA: + return getString(R.string.voice_message); + case MEDIA_SHARE: + return getString(R.string.post); + case REEL_SHARE: + return item.getReelShare().getText(); + } + return ""; + } + + @NonNull + private String getMediaPreviewTextString(final Media media) { + final MediaItemType mediaType = media.getMediaType(); + switch (mediaType) { + case MEDIA_TYPE_IMAGE: + return getString(R.string.photo); + case MEDIA_TYPE_VIDEO: + return getString(R.string.video); + default: + return ""; + } + } + + private String getDirectItemPreviewImageUrl(final DirectItem item) { + switch (item.getItemType()) { + case TEXT: + case LINK: + case VOICE_MEDIA: + case REEL_SHARE: + return null; + case MEDIA: { + final Media media = item.getMedia(); + return ResponseBodyUtils.getThumbUrl(media); + } + case RAVEN_MEDIA: { + final DirectItemVisualMedia visualMedia = item.getVisualMedia(); + final Media media = visualMedia.getMedia(); + return ResponseBodyUtils.getThumbUrl(media); + } + case MEDIA_SHARE: { + final Media media = item.getMediaShare(); + return ResponseBodyUtils.getThumbUrl(media); + } + } + return null; } private void setupBackStackResultObserver() { @@ -750,33 +903,29 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact binding.camera.setVisibility(View.VISIBLE); }); binding.input.addTextChangedListener(new TextWatcherAdapter() { - int prevLength = 0; + // int prevLength = 0; @Override public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { final int length = s.length(); - if (prevLength != 0 && length == 0) { - binding.send.setListenForRecord(true); - startIconAnimation(); - } - if (prevLength == 0 && length != 0) { - binding.send.setListenForRecord(false); - startIconAnimation(); - } - binding.gallery.setVisibility(length == 0 ? View.VISIBLE : View.GONE); - binding.camera.setVisibility(length == 0 ? View.VISIBLE : View.GONE); - prevLength = length; - } - - private void startIconAnimation() { - final Drawable icon = binding.send.getIcon(); - if (icon instanceof Animatable) { - final Animatable animatable = (Animatable) icon; - if (animatable.isRunning()) { - animatable.stop(); - } - animatable.start(); - } + inputLength.postValue(length); + // boolean showExtraInputOptionsChanged = false; + // if (prevLength != 0 && length == 0) { + // inputLength.postValue(true); + // showExtraInputOptionsChanged = true; + // binding.send.setListenForRecord(true); + // startIconAnimation(); + // } + // if (prevLength == 0 && length != 0) { + // inputLength.postValue(false); + // showExtraInputOptionsChanged = true; + // binding.send.setListenForRecord(false); + // startIconAnimation(); + // } + // if (!showExtraInputOptionsChanged) { + // showExtraInputOptions.postValue(length == 0); + // } + // prevLength = length; } }); binding.send.setOnRecordClickListener(v -> { @@ -785,6 +934,7 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact final LiveData> resourceLiveData = viewModel.sendText(text.toString()); resourceLiveData.observe(getViewLifecycleOwner(), resource -> handleSentMessage(resourceLiveData)); binding.input.setText(""); + viewModel.setReplyToItem(null); }); binding.send.setOnRecordLongClickListener(v -> { Log.d(TAG, "setOnRecordLongClickListener"); @@ -833,6 +983,17 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact }); } + private void startIconAnimation() { + final Drawable icon = binding.send.getIcon(); + if (icon instanceof Animatable) { + final Animatable animatable = (Animatable) icon; + if (animatable.isRunning()) { + animatable.stop(); + } + animatable.start(); + } + } + private void navigateToImageEditFragment(final String path) { navigateToImageEditFragment(Uri.fromFile(new File(path))); } @@ -1168,6 +1329,11 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact ObjectAnimator.ofFloat(binding.gallery, TRANSLATION_Y, -height), ObjectAnimator.ofFloat(binding.camera, TRANSLATION_Y, -height), ObjectAnimator.ofFloat(binding.send, TRANSLATION_Y, -height), + ObjectAnimator.ofFloat(binding.replyBg, TRANSLATION_Y, -height), + ObjectAnimator.ofFloat(binding.replyInfo, TRANSLATION_Y, -height), + ObjectAnimator.ofFloat(binding.replyCancel, TRANSLATION_Y, -height), + ObjectAnimator.ofFloat(binding.replyPreviewImage, TRANSLATION_Y, -height), + ObjectAnimator.ofFloat(binding.replyPreviewText, TRANSLATION_Y, -height), ObjectAnimator.ofFloat(binding.emojiPicker, TRANSLATION_Y, keyboardHeight - height) ); // if (headerItemDecoration != null && headerItemDecoration.getCurrentHeader() != null) { diff --git a/app/src/main/java/awais/instagrabber/models/enums/DirectItemType.java b/app/src/main/java/awais/instagrabber/models/enums/DirectItemType.java index fdf6ac2c..8f6f5ceb 100755 --- a/app/src/main/java/awais/instagrabber/models/enums/DirectItemType.java +++ b/app/src/main/java/awais/instagrabber/models/enums/DirectItemType.java @@ -1,7 +1,5 @@ package awais.instagrabber.models.enums; -import androidx.annotation.Nullable; - import com.google.gson.annotations.SerializedName; import java.io.Serializable; @@ -65,7 +63,6 @@ public enum DirectItemType implements Serializable { return map.get(id); } - @Nullable public String getName() { switch (this) { case TEXT: @@ -102,8 +99,7 @@ public enum DirectItemType implements Serializable { return "felix_share"; case LOCATION: return "location"; - default: - return null; } + return null; } } \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/BroadcastOptions.java b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/BroadcastOptions.java index bd2d200d..af4fa00f 100644 --- a/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/BroadcastOptions.java +++ b/app/src/main/java/awais/instagrabber/repositories/requests/directmessages/BroadcastOptions.java @@ -13,6 +13,9 @@ public abstract class BroadcastOptions { private final ThreadIdOrUserIds threadIdOrUserIds; private final BroadcastItemType itemType; + private String repliedToItemId; + private String repliedToClientContext; + public BroadcastOptions(final String clientContext, @NonNull final ThreadIdOrUserIds threadIdOrUserIds, @NonNull final BroadcastItemType itemType) { @@ -39,6 +42,22 @@ public abstract class BroadcastOptions { public abstract Map getFormMap(); + public String getRepliedToItemId() { + return repliedToItemId; + } + + public void setRepliedToItemId(final String repliedToItemId) { + this.repliedToItemId = repliedToItemId; + } + + public String getRepliedToClientContext() { + return repliedToClientContext; + } + + public void setRepliedToClientContext(final String repliedToClientContext) { + this.repliedToClientContext = repliedToClientContext; + } + public static final class ThreadIdOrUserIds { private final String threadId; private final List userIds; diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItem.java b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItem.java index 3d5524a6..a687be3e 100644 --- a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItem.java +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItem.java @@ -11,7 +11,7 @@ import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; public class DirectItem implements Cloneable { - private final String itemId; + private String itemId; private final long userId; private long timestamp; private final DirectItemType itemType; @@ -213,6 +213,10 @@ public class DirectItem implements Cloneable { return date; } + public void setItemId(final String itemId) { + this.itemId = itemId; + } + public boolean isPending() { return isPending; } diff --git a/app/src/main/java/awais/instagrabber/utils/DirectItemFactory.java b/app/src/main/java/awais/instagrabber/utils/DirectItemFactory.java index a959cdf0..8d7954c3 100644 --- a/app/src/main/java/awais/instagrabber/utils/DirectItemFactory.java +++ b/app/src/main/java/awais/instagrabber/utils/DirectItemFactory.java @@ -20,7 +20,8 @@ public class DirectItemFactory { public static DirectItem createText(final long userId, final String clientContext, - final String text) { + final String text, + final DirectItem repliedToMessage) { return new DirectItem( UUID.randomUUID().toString(), userId, @@ -44,7 +45,7 @@ public class DirectItemFactory { null, null, null, - null, + repliedToMessage, null, null, 0, diff --git a/app/src/main/java/awais/instagrabber/utils/Utils.java b/app/src/main/java/awais/instagrabber/utils/Utils.java index e8850927..3e45a21c 100644 --- a/app/src/main/java/awais/instagrabber/utils/Utils.java +++ b/app/src/main/java/awais/instagrabber/utils/Utils.java @@ -62,8 +62,8 @@ public final class Utils { public static SettingsHelper settingsHelper; public static boolean sessionVolumeFull = false; public static final MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + public static final DisplayMetrics displayMetrics = Resources.getSystem().getDisplayMetrics(); public static ClipboardManager clipboardManager; - public static DisplayMetrics displayMetrics = Resources.getSystem().getDisplayMetrics(); public static SimpleDateFormat datetimeParser; public static SimpleCache simpleCache; private static int statusBarHeight; @@ -73,9 +73,7 @@ public final class Utils { private static int defaultStatusBarColor; public static int convertDpToPx(final float dp) { - if (displayMetrics == null) - displayMetrics = Resources.getSystem().getDisplayMetrics(); - return Math.round((dp * displayMetrics.densityDpi) / 160.0f); + return Math.round((dp * displayMetrics.densityDpi) / DisplayMetrics.DENSITY_DEFAULT); } public static void copyText(@NonNull final Context context, final CharSequence string) { diff --git a/app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.java index 065d8bf1..36641e86 100644 --- a/app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.java +++ b/app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.java @@ -81,6 +81,7 @@ public class DirectThreadViewModel extends AndroidViewModel { private final MutableLiveData fetching = new MutableLiveData<>(false); private final MutableLiveData> users = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData> leftUsers = new MutableLiveData<>(new ArrayList<>()); + private final MutableLiveData replyToItem = new MutableLiveData<>(); private final DirectMessagesService service; private final ContentResolver contentResolver; @@ -285,7 +286,7 @@ public class DirectThreadViewModel extends AndroidViewModel { return index; } - private void updateItemSent(final String clientContext, final long timestamp) { + private void updateItemSent(final String clientContext, final long timestamp, final String itemId) { if (clientContext == null) return; List list = this.items.getValue(); list = list == null ? new LinkedList<>() : new LinkedList<>(list); @@ -297,6 +298,7 @@ public class DirectThreadViewModel extends AndroidViewModel { final DirectItem directItem = list.get(index); try { final DirectItem itemClone = (DirectItem) directItem.clone(); + itemClone.setItemId(itemId); itemClone.setPending(false); itemClone.setTimestamp(timestamp); list.set(index, itemClone); @@ -322,6 +324,10 @@ public class DirectThreadViewModel extends AndroidViewModel { return leftUsers; } + public LiveData getReplyToItem() { + return replyToItem; + } + public void fetchChats() { final Boolean isFetching = fetching.getValue(); if ((isFetching != null && isFetching) || !hasOlder) return; @@ -392,12 +398,21 @@ public class DirectThreadViewModel extends AndroidViewModel { final Long userId = handleCurrentUser(data); if (userId == null) return data; final String clientContext = UUID.randomUUID().toString(); - final DirectItem directItem = DirectItemFactory.createText(userId, clientContext, text); + final DirectItem replyToItemValue = replyToItem.getValue(); + final DirectItem directItem = DirectItemFactory.createText(userId, clientContext, text, replyToItemValue); // Log.d(TAG, "sendText: sending: itemId: " + directItem.getItemId()); directItem.setPending(true); addItems(0, Collections.singletonList(directItem)); data.postValue(Resource.loading(directItem)); - final Call request = service.broadcastText(clientContext, threadIdOrUserIds, text); + final String repliedToItemId = replyToItemValue != null ? replyToItemValue.getItemId() : null; + final String repliedToClientContext = replyToItemValue != null ? replyToItemValue.getClientContext() : null; + final Call request = service.broadcastText( + clientContext, + threadIdOrUserIds, + text, + repliedToItemId, + repliedToClientContext + ); enqueueRequest(request, data, directItem); return data; } @@ -848,6 +863,7 @@ public class DirectThreadViewModel extends AndroidViewModel { } final String payloadClientContext; final long timestamp; + final String itemId; final DirectThreadBroadcastResponsePayload payload = broadcastResponse.getPayload(); if (payload == null) { final List messageMetadata = broadcastResponse.getMessageMetadata(); @@ -857,12 +873,14 @@ public class DirectThreadViewModel extends AndroidViewModel { } final DirectThreadBroadcastResponseMessageMetadata metadata = messageMetadata.get(0); payloadClientContext = metadata.getClientContext(); + itemId = metadata.getItemId(); timestamp = metadata.getTimestamp(); } else { payloadClientContext = payload.getClientContext(); timestamp = payload.getTimestamp(); + itemId = payload.getItemId(); } - updateItemSent(payloadClientContext, timestamp); + updateItemSent(payloadClientContext, timestamp, itemId); data.postValue(Resource.success(directItem)); return; } @@ -987,8 +1005,13 @@ public class DirectThreadViewModel extends AndroidViewModel { private void forward(@NonNull final DirectThread thread, @NonNull final DirectItem itemToForward) { final DirectItemType itemType = itemToForward.getItemType(); + final String itemTypeName = itemType.getName(); + if (itemTypeName == null) { + Log.e(TAG, "forward: itemTypeName was null!"); + return; + } final Call request = service.forward(thread.getThreadId(), - itemType.getName(), + itemTypeName, threadId, itemToForward.getItemId()); request.enqueue(new Callback() { @@ -1019,4 +1042,9 @@ public class DirectThreadViewModel extends AndroidViewModel { } }); } + + public void setReplyToItem(final DirectItem item) { + // Log.d(TAG, "setReplyToItem: " + item); + replyToItem.postValue(item); + } } diff --git a/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java b/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java index 647137b7..9e504196 100644 --- a/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java +++ b/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java @@ -119,19 +119,29 @@ public class DirectMessagesService extends BaseService { public Call broadcastText(final String clientContext, final ThreadIdOrUserIds threadIdOrUserIds, - final String text) { + final String text, + final String repliedToItemId, + final String repliedToClientContext) { final List urls = TextUtils.extractUrls(text); if (!urls.isEmpty()) { - return broadcastLink(clientContext, threadIdOrUserIds, text, urls); + return broadcastLink(clientContext, threadIdOrUserIds, text, urls, repliedToItemId, repliedToClientContext); } - return broadcast(new TextBroadcastOptions(clientContext, threadIdOrUserIds, text)); + final TextBroadcastOptions broadcastOptions = new TextBroadcastOptions(clientContext, threadIdOrUserIds, text); + broadcastOptions.setRepliedToItemId(repliedToItemId); + broadcastOptions.setRepliedToClientContext(repliedToClientContext); + return broadcast(broadcastOptions); } public Call broadcastLink(final String clientContext, final ThreadIdOrUserIds threadIdOrUserIds, final String linkText, - final List urls) { - return broadcast(new LinkBroadcastOptions(clientContext, threadIdOrUserIds, linkText, urls)); + final List urls, + final String repliedToItemId, + final String repliedToClientContext) { + final LinkBroadcastOptions broadcastOptions = new LinkBroadcastOptions(clientContext, threadIdOrUserIds, linkText, urls); + broadcastOptions.setRepliedToItemId(repliedToItemId); + broadcastOptions.setRepliedToClientContext(repliedToClientContext); + return broadcast(broadcastOptions); } public Call broadcastPhoto(final String clientContext, @@ -187,6 +197,10 @@ public class DirectMessagesService extends BaseService { form.put("__uuid", deviceUuid); form.put("client_context", broadcastOptions.getClientContext()); form.put("mutation_token", broadcastOptions.getClientContext()); + if (!TextUtils.isEmpty(broadcastOptions.getRepliedToItemId()) && !TextUtils.isEmpty(broadcastOptions.getRepliedToClientContext())) { + form.put("replied_to_item_id", broadcastOptions.getRepliedToItemId()); + form.put("replied_to_client_context", broadcastOptions.getRepliedToClientContext()); + } form.putAll(broadcastOptions.getFormMap()); form.put("action", "send_item"); final Map signedForm = Utils.sign(form); diff --git a/app/src/main/res/drawable/ic_round_reply_24.xml b/app/src/main/res/drawable/ic_round_reply_24.xml new file mode 100644 index 00000000..6552e864 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_reply_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_direct_messages_thread.xml b/app/src/main/res/layout/fragment_direct_messages_thread.xml index b122f24b..d242a1b4 100644 --- a/app/src/main/res/layout/fragment_direct_messages_thread.xml +++ b/app/src/main/res/layout/fragment_direct_messages_thread.xml @@ -11,12 +11,98 @@ android:layout_width="0dp" android:layout_height="0dp" android:scrollbars="none" - app:layout_constraintBottom_toTopOf="@id/input" + app:layout_constraintBottom_toTopOf="@id/reply_info" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:listitem="@layout/layout_dm_base" /> + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1d6209c7..31c0d0d6 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -401,4 +401,10 @@ Forward Add Send + Replying to yourself + Replying to %s + Photo + Video + Voice message + Post