diff --git a/app/src/main/java/awais/instagrabber/adapters/UserSearchResultsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/UserSearchResultsAdapter.java index 7363fc66..5a825e1a 100644 --- a/app/src/main/java/awais/instagrabber/adapters/UserSearchResultsAdapter.java +++ b/app/src/main/java/awais/instagrabber/adapters/UserSearchResultsAdapter.java @@ -6,80 +6,146 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; import awais.instagrabber.adapters.DirectUsersAdapter.OnDirectUserClickListener; import awais.instagrabber.adapters.viewholder.directmessages.DirectUserViewHolder; +import awais.instagrabber.adapters.viewholder.directmessages.RecipientThreadViewHolder; import awais.instagrabber.databinding.LayoutDmUserItemBinding; -import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; -public final class UserSearchResultsAdapter extends ListAdapter { - - private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { +public final class UserSearchResultsAdapter extends ListAdapter { + private static final int VIEW_TYPE_USER = 0; + private static final int VIEW_TYPE_THREAD = 1; + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override - public boolean areItemsTheSame(@NonNull final User oldItem, @NonNull final User newItem) { - return oldItem.getPk() == newItem.getPk(); + public boolean areItemsTheSame(@NonNull final RankedRecipient oldItem, @NonNull final RankedRecipient newItem) { + final boolean bothUsers = oldItem.getUser() != null && newItem.getUser() != null; + if (!bothUsers) return false; + final boolean bothThreads = oldItem.getThread() != null && newItem.getThread() != null; + if (!bothThreads) return false; + if (bothUsers) { + return oldItem.getUser().getPk() == newItem.getUser().getPk(); + } + return Objects.equals(oldItem.getThread().getThreadId(), newItem.getThread().getThreadId()); } @Override - public boolean areContentsTheSame(@NonNull final User oldItem, @NonNull final User newItem) { - return oldItem.getUsername().equals(newItem.getUsername()) && - oldItem.getFullName().equals(newItem.getFullName()); + public boolean areContentsTheSame(@NonNull final RankedRecipient oldItem, @NonNull final RankedRecipient newItem) { + final boolean bothUsers = oldItem.getUser() != null && newItem.getUser() != null; + if (bothUsers) { + return Objects.equals(oldItem.getUser().getUsername(), newItem.getUser().getUsername()) && + Objects.equals(oldItem.getUser().getFullName(), newItem.getUser().getFullName()); + } + return Objects.equals(oldItem.getThread().getThreadTitle(), newItem.getThread().getThreadTitle()); } }; + private final boolean showSelection; - private final Set selectedUserIds; + private final Set selectedRecipients; private final OnDirectUserClickListener onUserClickListener; + private final OnRecipientClickListener onRecipientClickListener; public UserSearchResultsAdapter(final boolean showSelection, - final OnDirectUserClickListener onUserClickListener) { + final OnRecipientClickListener onRecipientClickListener) { super(DIFF_CALLBACK); this.showSelection = showSelection; - selectedUserIds = showSelection ? new HashSet<>() : null; - this.onUserClickListener = onUserClickListener; + selectedRecipients = showSelection ? new HashSet<>() : null; + this.onRecipientClickListener = onRecipientClickListener; + this.onUserClickListener = (position, user, selected) -> { + if (onRecipientClickListener != null) { + onRecipientClickListener.onClick(position, RankedRecipient.of(user), selected); + } + }; setHasStableIds(true); } @NonNull @Override - public DirectUserViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final LayoutDmUserItemBinding binding = LayoutDmUserItemBinding.inflate(layoutInflater, parent, false); - return new DirectUserViewHolder(binding, onUserClickListener, null); - + if (viewType == VIEW_TYPE_USER) { + return new DirectUserViewHolder(binding, onUserClickListener, null); + } + return new RecipientThreadViewHolder(binding, onRecipientClickListener); } @Override - public void onBindViewHolder(@NonNull final DirectUserViewHolder holder, final int position) { - final User user = getItem(position); - boolean isSelected = selectedUserIds != null && selectedUserIds.contains(user.getPk()); - holder.bind(position, user, false, false, showSelection, isSelected); + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { + final RankedRecipient recipient = getItem(position); + final int itemViewType = getItemViewType(position); + if (itemViewType == VIEW_TYPE_USER) { + boolean isSelected = false; + if (selectedRecipients != null) { + isSelected = selectedRecipients.stream() + .anyMatch(rankedRecipient -> rankedRecipient.getUser() != null + && rankedRecipient.getUser().getPk() == recipient.getUser().getPk()); + } + ((DirectUserViewHolder) holder).bind(position, recipient.getUser(), false, false, showSelection, isSelected); + return; + } + boolean isSelected = false; + if (selectedRecipients != null) { + isSelected = selectedRecipients.stream() + .anyMatch(rankedRecipient -> rankedRecipient.getThread() != null + && Objects.equals(rankedRecipient.getThread().getThreadId(), recipient.getThread().getThreadId())); + } + ((RecipientThreadViewHolder) holder).bind(position, recipient.getThread(), showSelection, isSelected); } @Override public long getItemId(final int position) { - return getItem(position).getPk(); + final RankedRecipient recipient = getItem(position); + if (recipient.getUser() != null) { + return recipient.getUser().getPk(); + } + if (recipient.getThread() != null) { + return recipient.getThread().getThreadTitle().hashCode(); + } + return 0; } - public void setSelectedUser(final long userId, final boolean selected) { - if (selectedUserIds == null) return; + @Override + public int getItemViewType(final int position) { + final RankedRecipient recipient = getItem(position); + return recipient.getUser() != null ? VIEW_TYPE_USER : VIEW_TYPE_THREAD; + } + + public void setSelectedRecipient(final RankedRecipient recipient, final boolean selected) { + if (selectedRecipients == null || recipient == null || (recipient.getUser() == null && recipient.getThread() == null)) return; + final boolean isUser = recipient.getUser() != null; int position = -1; - final List currentList = getCurrentList(); + final List currentList = getCurrentList(); for (int i = 0; i < currentList.size(); i++) { - if (currentList.get(i).getPk() == userId) { + final RankedRecipient temp = currentList.get(i); + if (isUser) { + if (temp.getUser() != null && temp.getUser().getPk() == recipient.getUser().getPk()) { + position = i; + break; + } + continue; + } + if (temp.getThread() != null && Objects.equals(temp.getThread().getThreadId(), recipient.getThread().getThreadId())) { position = i; break; } } if (position < 0) return; if (selected) { - selectedUserIds.add(userId); + selectedRecipients.add(recipient); } else { - selectedUserIds.remove(userId); + selectedRecipients.remove(recipient); } notifyItemChanged(position); } + + public interface OnRecipientClickListener { + void onClick(int position, RankedRecipient recipient, final boolean isSelected); + } } \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLikeViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLikeViewHolder.java index e17314f7..39693f86 100644 --- a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLikeViewHolder.java +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLikeViewHolder.java @@ -22,4 +22,9 @@ public class DirectItemLikeViewHolder extends DirectItemViewHolder { @Override public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) {} + + @Override + protected boolean canForward() { + return false; + } } diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemRavenMediaViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemRavenMediaViewHolder.java index b20077ba..090bb689 100644 --- a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemRavenMediaViewHolder.java +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemRavenMediaViewHolder.java @@ -184,4 +184,9 @@ public class DirectItemRavenMediaViewHolder extends DirectItemViewHolder { final String thumbUrl = ResponseBodyUtils.getThumbUrl(imageVersions2); binding.preview.setImageURI(thumbUrl); } + + @Override + protected boolean allowLongClick() { + return false; // disabling until confirmed + } } diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemReelShareViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemReelShareViewHolder.java index 80fbce8e..9abcc8a7 100644 --- a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemReelShareViewHolder.java +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemReelShareViewHolder.java @@ -167,4 +167,9 @@ public class DirectItemReelShareViewHolder extends DirectItemViewHolder { final String thumbUrl = ResponseBodyUtils.getThumbUrl(imageVersions2); binding.preview.setImageURI(thumbUrl); } + + @Override + protected boolean canForward() { + return false; + } } 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 618b0e1a..682ed78a 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 @@ -105,4 +105,9 @@ public class DirectItemStoryShareViewHolder extends DirectItemViewHolder { binding.ivMediaPreview.setVisibility(View.GONE); binding.typeIcon.setVisibility(View.GONE); } + + @Override + protected boolean canForward() { + return false; + } } 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 6402a429..ebcda8f8 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 @@ -135,6 +135,9 @@ public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder { } setupReply(item, messageDirection); setReactions(item, position); + if (item.getRepliedToMessage() == null && item.showForwardAttribution()) { + setForwardInfo(messageDirection); + } } private void setBackground(final MessageDirection messageDirection) { @@ -316,6 +319,11 @@ public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder { return String.format("Replied to %s", repliedToUsername); } + private void setForwardInfo(final MessageDirection direction) { + binding.replyInfo.setVisibility(View.VISIBLE); + binding.replyInfo.setText(direction == MessageDirection.OUTGOING ? "You forwarded a message" : "Forwarded a message"); + } + private void setReplyGravity(final MessageDirection messageDirection) { final boolean isIncoming = messageDirection == MessageDirection.INCOMING; final ConstraintLayout.LayoutParams quoteLineLayoutParams = (ConstraintLayout.LayoutParams) binding.quoteLine.getLayoutParams(); @@ -426,6 +434,10 @@ public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder { return true; } + protected boolean canForward() { + return true; + } + protected List getLongClickOptions() { return null; } @@ -510,6 +522,9 @@ public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder { if (longClickOptions != null) { builder.addAll(longClickOptions); } + if (canForward()) { + builder.add(new DirectItemContextMenu.MenuItem(R.id.forward, R.string.forward)); + } if (messageDirection == MessageDirection.OUTGOING) { builder.add(new DirectItemContextMenu.MenuItem(R.id.unsend, R.string.dms_inbox_unsend)); } diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVoiceMediaViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVoiceMediaViewHolder.java index 3b6411ea..13399b06 100644 --- a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVoiceMediaViewHolder.java +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVoiceMediaViewHolder.java @@ -169,6 +169,11 @@ public class DirectItemVoiceMediaViewHolder extends DirectItemViewHolder { } } + @Override + protected boolean canForward() { + return false; + } + private static class AudioItemState { private boolean prepared; diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/RecipientThreadViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/RecipientThreadViewHolder.java new file mode 100644 index 00000000..aab59a58 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/RecipientThreadViewHolder.java @@ -0,0 +1,89 @@ +package awais.instagrabber.adapters.viewholder.directmessages; + +import android.content.res.Resources; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.UserSearchResultsAdapter.OnRecipientClickListener; +import awais.instagrabber.databinding.LayoutDmUserItemBinding; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; + +public class RecipientThreadViewHolder extends RecyclerView.ViewHolder { + private static final String TAG = RecipientThreadViewHolder.class.getSimpleName(); + + private final LayoutDmUserItemBinding binding; + private final OnRecipientClickListener onThreadClickListener; + private final float translateAmount; + + public RecipientThreadViewHolder(@NonNull final LayoutDmUserItemBinding binding, + final OnRecipientClickListener onThreadClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.onThreadClickListener = onThreadClickListener; + binding.info.setVisibility(View.GONE); + final Resources resources = itemView.getResources(); + final int avatarSize = resources.getDimensionPixelSize(R.dimen.dm_inbox_avatar_size); + translateAmount = ((float) avatarSize) / 7; + } + + public void bind(final int position, + final DirectThread thread, + final boolean showSelection, + final boolean isSelected) { + if (thread == null) return; + binding.getRoot().setOnClickListener(v -> { + if (onThreadClickListener == null) return; + onThreadClickListener.onClick(position, RankedRecipient.of(thread), isSelected); + }); + binding.fullName.setText(thread.getThreadTitle()); + setUsername(thread); + setProfilePic(thread); + setSelection(showSelection, isSelected); + } + + private void setProfilePic(final DirectThread thread) { + final List users = thread.getUsers(); + binding.profilePic.setImageURI(users.get(0).getProfilePicUrl()); + binding.profilePic.setScaleX(1); + binding.profilePic.setScaleY(1); + binding.profilePic.setTranslationX(0); + binding.profilePic.setTranslationY(0); + if (users.size() > 1) { + binding.profilePic2.setVisibility(View.VISIBLE); + binding.profilePic2.setImageURI(users.get(1).getProfilePicUrl()); + binding.profilePic2.setTranslationX(translateAmount); + binding.profilePic2.setTranslationY(translateAmount); + final float scaleAmount = 0.75f; + binding.profilePic2.setScaleX(scaleAmount); + binding.profilePic2.setScaleY(scaleAmount); + binding.profilePic.setScaleX(scaleAmount); + binding.profilePic.setScaleY(scaleAmount); + binding.profilePic.setTranslationX(-translateAmount); + binding.profilePic.setTranslationY(-translateAmount); + return; + } + binding.profilePic2.setVisibility(View.GONE); + } + + private void setUsername(final DirectThread thread) { + if (thread.isGroup()) { + binding.username.setVisibility(View.GONE); + return; + } + binding.username.setVisibility(View.VISIBLE); + // for a non-group thread, the thread title is the username so set the full name in the username text view + binding.username.setText(thread.getUsers().get(0).getFullName()); + } + + private void setSelection(final boolean showSelection, final boolean isSelected) { + binding.select.setVisibility(showSelection ? View.VISIBLE : View.GONE); + binding.getRoot().setSelected(isSelected); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/UserSearchFragment.java b/app/src/main/java/awais/instagrabber/fragments/UserSearchFragment.java index 68b2fe26..1513edbe 100644 --- a/app/src/main/java/awais/instagrabber/fragments/UserSearchFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/UserSearchFragment.java @@ -23,11 +23,14 @@ import androidx.transition.TransitionManager; import com.google.android.material.chip.Chip; import com.google.android.material.snackbar.Snackbar; +import java.util.Objects; +import java.util.Set; + import awais.instagrabber.activities.MainActivity; import awais.instagrabber.adapters.UserSearchResultsAdapter; import awais.instagrabber.customviews.helpers.TextWatcherAdapter; import awais.instagrabber.databinding.FragmentUserSearchBinding; -import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.utils.ViewUtils; @@ -46,7 +49,6 @@ public class UserSearchFragment extends Fragment { private String actionLabel; private String title; private boolean multiple; - private long[] hideUserIds; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { @@ -81,12 +83,17 @@ public class UserSearchFragment extends Fragment { actionLabel = fragmentArgs.getActionLabel(); title = fragmentArgs.getTitle(); multiple = fragmentArgs.getMultiple(); + viewModel.setHideThreadIds(fragmentArgs.getHideThreadIds()); viewModel.setHideUserIds(fragmentArgs.getHideUserIds()); + viewModel.setSearchMode(fragmentArgs.getSearchMode()); + viewModel.setShowGroups(fragmentArgs.getShowGroups()); } setupTitles(); setupInput(); setupResults(); setupObservers(); + // show cached results + viewModel.showCachedResults(); } private void setupTitles() { @@ -108,54 +115,64 @@ public class UserSearchFragment extends Fragment { final Context context = getContext(); if (context == null) return; binding.results.setLayoutManager(new LinearLayoutManager(context)); - resultsAdapter = new UserSearchResultsAdapter(multiple, (position, user, selected) -> { + resultsAdapter = new UserSearchResultsAdapter(multiple, (position, recipient, selected) -> { if (!multiple) { final NavController navController = NavHostFragment.findNavController(this); - final NavBackStackEntry navBackStackEntry = navController.getPreviousBackStackEntry(); - if (navBackStackEntry == null) return; - final SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle(); - savedStateHandle.set("result", user); + if (!setResult(navController, recipient)) return; navController.navigateUp(); return; } - viewModel.setSelectedUser(user, !selected); - resultsAdapter.setSelectedUser(user.getPk(), !selected); + viewModel.setSelectedRecipient(recipient, !selected); + resultsAdapter.setSelectedRecipient(recipient, !selected); if (!selected) { - createUserChip(user); + createChip(recipient); return; } - final View chip = findChip(user.getPk()); + final View chip = findChip(recipient); if (chip == null) return; - removeChip(chip); + removeChipFromGroup(chip); }); binding.results.setAdapter(resultsAdapter); binding.done.setOnClickListener(v -> { final NavController navController = NavHostFragment.findNavController(this); - final NavBackStackEntry navBackStackEntry = navController.getPreviousBackStackEntry(); - if (navBackStackEntry == null) return; - final SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle(); - savedStateHandle.set("result", viewModel.getSelectedUsers()); + if (!setResult(navController, viewModel.getSelectedRecipients())) return; navController.navigateUp(); }); } + private boolean setResult(@NonNull final NavController navController, final RankedRecipient rankedRecipient) { + final NavBackStackEntry navBackStackEntry = navController.getPreviousBackStackEntry(); + if (navBackStackEntry == null) return false; + final SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle(); + savedStateHandle.set("result", rankedRecipient); + return true; + } + + private boolean setResult(@NonNull final NavController navController, final Set rankedRecipients) { + final NavBackStackEntry navBackStackEntry = navController.getPreviousBackStackEntry(); + if (navBackStackEntry == null) return false; + final SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle(); + savedStateHandle.set("result", rankedRecipients); + return true; + } + private void setupInput() { binding.search.addTextChangedListener(new TextWatcherAdapter() { @Override public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { - if (TextUtils.isEmpty(s)) { - viewModel.cancelSearch(); - viewModel.clearResults(); - return; - } - viewModel.search(s.toString().trim()); + // if (TextUtils.isEmpty(s)) { + // viewModel.cancelSearch(); + // viewModel.clearResults(); + // return; + // } + viewModel.search(s == null ? null : s.toString().trim()); } }); binding.search.setOnKeyListener((v, keyCode, event) -> { if (event != null && event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_DEL) { final View chip = getLastChip(); if (chip == null) return false; - removeSelectedUser(chip); + removeChip(chip); } return false; }); @@ -175,32 +192,41 @@ public class UserSearchFragment extends Fragment { } private void setupObservers() { - viewModel.getUsers().observe(getViewLifecycleOwner(), results -> { + viewModel.getRecipients().observe(getViewLifecycleOwner(), results -> { if (results == null) return; switch (results.status) { case SUCCESS: - resultsAdapter.submitList(results.data); + if (results.data != null) { + resultsAdapter.submitList(results.data); + } break; case ERROR: if (results.message != null) { Snackbar.make(binding.getRoot(), results.message, Snackbar.LENGTH_LONG).show(); } + if (results.data != null) { + resultsAdapter.submitList(results.data); + } break; case LOADING: + //noinspection DuplicateBranchesInSwitch + if (results.data != null) { + resultsAdapter.submitList(results.data); + } break; } }); viewModel.showAction().observe(getViewLifecycleOwner(), showAction -> binding.done.setVisibility(showAction ? View.VISIBLE : View.GONE)); } - private void createUserChip(final User user) { + private void createChip(final RankedRecipient recipient) { final Context context = getContext(); if (context == null) return; final Chip chip = new Chip(context); - chip.setTag(user); - chip.setText(user.getFullName()); + chip.setTag(recipient); + chip.setText(getRecipientText(recipient)); chip.setCloseIconVisible(true); - chip.setOnCloseIconClickListener(v -> removeSelectedUser(chip)); + chip.setOnCloseIconClickListener(v -> removeChip(chip)); binding.group.post(() -> { final Pair measure = ViewUtils.measure(chip, binding.group); TransitionManager.beginDelayedTransition(binding.getRoot()); @@ -209,31 +235,44 @@ public class UserSearchFragment extends Fragment { }); } - private void removeSelectedUser(final View chip) { - final User user = (User) chip.getTag(); - if (user == null) return; - viewModel.setSelectedUser(user, false); - resultsAdapter.setSelectedUser(user.getPk(), false); - removeChip(chip); + private String getRecipientText(final RankedRecipient recipient) { + if (recipient == null) return null; + if (recipient.getUser() != null) { + return recipient.getUser().getFullName(); + } + if (recipient.getThread() != null) { + return recipient.getThread().getThreadTitle(); + } + return null; } - private View findChip(final long userId) { + private void removeChip(@NonNull final View chip) { + final RankedRecipient recipient = (RankedRecipient) chip.getTag(); + if (recipient == null) return; + viewModel.setSelectedRecipient(recipient, false); + resultsAdapter.setSelectedRecipient(recipient, false); + removeChipFromGroup(chip); + } + + private View findChip(final RankedRecipient recipient) { + if (recipient == null || recipient.getUser() == null && recipient.getThread() == null) return null; + boolean isUser = recipient.getUser() != null; final int childCount = binding.group.getChildCount(); - if (childCount == 0) { - return null; - } + if (childCount == 0) return null; for (int i = childCount - 1; i >= 0; i--) { final View child = binding.group.getChildAt(i); if (child == null) continue; - final User user = (User) child.getTag(); - if (user != null && user.getPk() == userId) { + final RankedRecipient tag = (RankedRecipient) child.getTag(); + if (tag == null || isUser && tag.getUser() == null || !isUser && tag.getThread() == null) continue; + if ((isUser && tag.getUser().getPk() == recipient.getUser().getPk()) + || (!isUser && Objects.equals(tag.getThread().getThreadId(), recipient.getThread().getThreadId()))) { return child; } } return null; } - private void removeChip(final View chip) { + private void removeChipFromGroup(final View chip) { binding.group.post(() -> { TransitionManager.beginDelayedTransition(binding.getRoot()); binding.group.removeView(chip); @@ -267,4 +306,20 @@ public class UserSearchFragment extends Fragment { } return null; } + + public enum SearchMode { + USER_SEARCH("user_name"), + RAVEN("raven"), + RESHARE("reshare"); + + private final String name; + + SearchMode(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + } } diff --git a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.java b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.java index fdac2d8c..52d1106d 100644 --- a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.java @@ -20,7 +20,6 @@ import androidx.lifecycle.ViewModelStoreOwner; import androidx.navigation.NavBackStackEntry; import androidx.navigation.NavController; import androidx.navigation.NavDestination; -import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; @@ -29,18 +28,24 @@ import com.google.android.material.snackbar.Snackbar; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import awais.instagrabber.R; +import awais.instagrabber.UserSearchNavGraphDirections; import awais.instagrabber.adapters.DirectUsersAdapter; import awais.instagrabber.customviews.helpers.TextWatcherAdapter; import awais.instagrabber.databinding.FragmentDirectMessagesSettingsBinding; import awais.instagrabber.dialogs.MultiOptionDialogFragment; import awais.instagrabber.dialogs.MultiOptionDialogFragment.Option; +import awais.instagrabber.fragments.UserSearchFragment; +import awais.instagrabber.fragments.UserSearchFragmentDirections; import awais.instagrabber.models.Resource; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; import awais.instagrabber.viewmodels.DirectInboxViewModel; import awais.instagrabber.viewmodels.DirectSettingsViewModel; @@ -155,6 +160,12 @@ public class DirectMessageSettingsFragment extends Fragment { setupObservers(); } + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + private void setupObservers() { viewModel.getUsers().observe(getViewLifecycleOwner(), users -> { if (usersAdapter == null) return; @@ -171,16 +182,27 @@ public class DirectMessageSettingsFragment extends Fragment { final MutableLiveData resultLiveData = backStackEntry.getSavedStateHandle().getLiveData("result"); resultLiveData.observe(getViewLifecycleOwner(), result -> { LiveData> detailsChangeResourceLiveData = null; - if ((result instanceof User)) { - // Log.d(TAG, "result: " + result); - detailsChangeResourceLiveData = viewModel.addMembers(Collections.singleton((User) result)); + if ((result instanceof RankedRecipient)) { + final RankedRecipient recipient = (RankedRecipient) result; + final User user = getUser(recipient); + // Log.d(TAG, "result: " + user); + if (user != null) { + detailsChangeResourceLiveData = viewModel.addMembers(Collections.singleton(recipient.getUser())); + } } else if ((result instanceof Set)) { try { - // Log.d(TAG, "result: " + result); //noinspection unchecked - detailsChangeResourceLiveData = viewModel.addMembers((Set) result); + final Set recipients = (Set) result; + final Set users = recipients.stream() + .filter(Objects::nonNull) + .map(this::getUser) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + // Log.d(TAG, "result: " + users); + detailsChangeResourceLiveData = viewModel.addMembers(users); } catch (Exception e) { Log.e(TAG, "search users result: ", e); + Snackbar.make(binding.getRoot(), e.getMessage(), Snackbar.LENGTH_LONG).show(); } } if (detailsChangeResourceLiveData != null) { @@ -190,6 +212,17 @@ public class DirectMessageSettingsFragment extends Fragment { } } + @Nullable + private User getUser(@NonNull final RankedRecipient recipient) { + User user = null; + if (recipient.getUser() != null) { + user = recipient.getUser(); + } else if (recipient.getThread() != null && !recipient.getThread().isGroup()) { + user = recipient.getThread().getUsers().get(0); + } + return user; + } + private void init() { setupSettings(); setupMembers(); @@ -232,13 +265,14 @@ public class DirectMessageSettingsFragment extends Fragment { } else { currentUserIds = new long[0]; } - final NavDirections directions = DirectMessageSettingsFragmentDirections.actionGlobalUserSearch( - true, - "Add users", - "Add", - currentUserIds - ); - navController.navigate(directions); + final UserSearchNavGraphDirections.ActionGlobalUserSearch actionGlobalUserSearch = UserSearchFragmentDirections + .actionGlobalUserSearch() + .setTitle(getString(R.string.add_members)) + .setActionLabel(getString(R.string.add)) + .setHideUserIds(currentUserIds) + .setSearchMode(UserSearchFragment.SearchMode.RAVEN) + .setMultiple(true); + navController.navigate(actionGlobalUserSearch); }); } 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 11708e0c..f0692017 100644 --- a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java @@ -34,6 +34,7 @@ import androidx.fragment.app.Fragment; import androidx.lifecycle.LiveData; import androidx.lifecycle.MediatorLiveData; import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelStoreOwner; import androidx.navigation.NavBackStackEntry; @@ -53,8 +54,10 @@ import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; import awais.instagrabber.R; +import awais.instagrabber.UserSearchNavGraphDirections; import awais.instagrabber.activities.CameraActivity; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.adapters.DirectItemsAdapter; @@ -75,6 +78,8 @@ import awais.instagrabber.databinding.FragmentDirectMessagesThreadBinding; import awais.instagrabber.dialogs.DirectItemReactionDialogFragment; import awais.instagrabber.dialogs.MediaPickerBottomDialogFragment; import awais.instagrabber.fragments.PostViewV2Fragment; +import awais.instagrabber.fragments.UserSearchFragment; +import awais.instagrabber.fragments.UserSearchFragmentDirections; import awais.instagrabber.models.Resource; import awais.instagrabber.repositories.requests.StoryViewerOptions; import awais.instagrabber.repositories.responses.Media; @@ -83,6 +88,7 @@ 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.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.PermissionUtils; import awais.instagrabber.utils.TextUtils; @@ -124,6 +130,9 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact private boolean isRecording; private boolean wasKbShowing; private int keyboardHeight = Utils.convertDpToPx(250); + private DirectItemReactionDialogFragment reactionDialogFragment; + private DirectItem itemToForward; + private MutableLiveData backStackSavedStateResultLiveData; private final AppExecutors appExecutors = AppExecutors.getInstance(); private final Animatable2Compat.AnimationCallback micToSendAnimationCallback = new Animatable2Compat.AnimationCallback() { @@ -229,13 +238,48 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact handleSentMessage(viewModel.unsend(item)); return; } + if (itemId == R.id.forward) { + itemToForward = item; + final UserSearchNavGraphDirections.ActionGlobalUserSearch actionGlobalUserSearch = UserSearchFragmentDirections + .actionGlobalUserSearch() + .setTitle(getString(R.string.forward)) + .setActionLabel(getString(R.string.send)) + .setShowGroups(true) + .setMultiple(true) + .setSearchMode(UserSearchFragment.SearchMode.RAVEN); + final NavController navController = NavHostFragment.findNavController(DirectMessageThreadFragment.this); + navController.navigate(actionGlobalUserSearch); + } } }; private final DirectItemLongClickListener directItemLongClickListener = position -> { // viewModel.setSelectedPosition(position); }; - private DirectItemReactionDialogFragment reactionDialogFragment; + private final Observer backStackSavedStateObserver = result -> { + if (result == null) return; + if (result instanceof Uri) { + final Uri uri = (Uri) result; + handleSentMessage(viewModel.sendUri(uri)); + } else if ((result instanceof RankedRecipient)) { + // Log.d(TAG, "result: " + result); + if (itemToForward != null) { + viewModel.forward((RankedRecipient) result, itemToForward); + } + } else if ((result instanceof Set)) { + try { + // Log.d(TAG, "result: " + result); + if (itemToForward != null) { + //noinspection unchecked + viewModel.forward((Set) result, itemToForward); + } + } catch (Exception e) { + Log.e(TAG, "forward result: ", e); + } + } + // clear result + backStackSavedStateResultLiveData.postValue(null); + }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { @@ -361,6 +405,7 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact binding.send.setX(initialSendX); } binding.send.stopScale(); + setupBackStackResultObserver(); } @Override @@ -587,17 +632,14 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact setupItemsAdapter(userThreadPair.first, userThreadPair.second); }); viewModel.getItems().observe(getViewLifecycleOwner(), this::submitItemsToAdapter); + } + + private void setupBackStackResultObserver() { final NavController navController = NavHostFragment.findNavController(this); final NavBackStackEntry backStackEntry = navController.getCurrentBackStackEntry(); if (backStackEntry != null) { - final MutableLiveData resultLiveData = backStackEntry.getSavedStateHandle().getLiveData("result"); - resultLiveData.observe(getViewLifecycleOwner(), result -> { - if (!(result instanceof Uri)) return; - final Uri uri = (Uri) result; - viewModel.sendUri(uri); - // clear result - resultLiveData.postValue(null); - }); + backStackSavedStateResultLiveData = backStackEntry.getSavedStateHandle().getLiveData("result"); + backStackSavedStateResultLiveData.observe(getViewLifecycleOwner(), backStackSavedStateObserver); } } 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 50562b36..fdf6ac2c 100755 --- a/app/src/main/java/awais/instagrabber/models/enums/DirectItemType.java +++ b/app/src/main/java/awais/instagrabber/models/enums/DirectItemType.java @@ -1,5 +1,7 @@ package awais.instagrabber.models.enums; +import androidx.annotation.Nullable; + import com.google.gson.annotations.SerializedName; import java.io.Serializable; @@ -62,4 +64,46 @@ public enum DirectItemType implements Serializable { public static DirectItemType valueOf(final int id) { return map.get(id); } + + @Nullable + public String getName() { + switch (this) { + case TEXT: + return "text"; + case LIKE: + return "like"; + case LINK: + return "link"; + case MEDIA: + return "media"; + case RAVEN_MEDIA: + return "raven_media"; + case PROFILE: + return "profile"; + case VIDEO_CALL_EVENT: + return "video_call_event"; + case ANIMATED_MEDIA: + return "animated_media"; + case VOICE_MEDIA: + return "voice_media"; + case MEDIA_SHARE: + return "media_share"; + case REEL_SHARE: + return "reel_share"; + case ACTION_LOG: + return "action_log"; + case PLACEHOLDER: + return "placeholder"; + case STORY_SHARE: + return "story_share"; + case CLIP: + return "clip"; + case FELIX_SHARE: + return "felix_share"; + case LOCATION: + return "location"; + default: + return null; + } + } } \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/DirectMessagesRepository.java b/app/src/main/java/awais/instagrabber/repositories/DirectMessagesRepository.java index ff52bf27..3bee824b 100644 --- a/app/src/main/java/awais/instagrabber/repositories/DirectMessagesRepository.java +++ b/app/src/main/java/awais/instagrabber/repositories/DirectMessagesRepository.java @@ -4,9 +4,11 @@ import java.util.Map; import awais.instagrabber.repositories.responses.directmessages.DirectBadgeCount; import awais.instagrabber.repositories.responses.directmessages.DirectInboxResponse; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.repositories.responses.directmessages.DirectThreadBroadcastResponse; import awais.instagrabber.repositories.responses.directmessages.DirectThreadDetailsChangeResponse; import awais.instagrabber.repositories.responses.directmessages.DirectThreadFeedResponse; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipientsResponse; import retrofit2.Call; import retrofit2.http.FieldMap; import retrofit2.http.FormUrlEncoded; @@ -62,4 +64,15 @@ public interface DirectMessagesRepository { Call deleteItem(@Path("threadId") String threadId, @Path("itemId") String itemId, @FieldMap final Map form); + + @GET("/api/v1/direct_v2/ranked_recipients/") + Call rankedRecipients(@QueryMap Map queryMap); + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/broadcast/forward/") + Call forward(@FieldMap final Map form); + + @FormUrlEncoded + @POST("/api/v1/direct_v2/create_group_thread/") + Call createThread(@FieldMap final Map signedForm); } 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 366aa82c..3d5524a6 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 @@ -39,6 +39,7 @@ public class DirectItem implements Cloneable { private final int hideInThread; private Date date; private boolean isPending; + private boolean showForwardAttribution; public DirectItem(final String itemId, final long userId, @@ -65,7 +66,8 @@ public class DirectItem implements Cloneable { final DirectItem repliedToMessage, final DirectItemVoiceMedia voiceMedia, final Location location, - final int hideInThread) { + final int hideInThread, + final boolean showForwardAttribution) { this.itemId = itemId; this.userId = userId; this.timestamp = timestamp; @@ -92,6 +94,7 @@ public class DirectItem implements Cloneable { this.voiceMedia = voiceMedia; this.location = location; this.hideInThread = hideInThread; + this.showForwardAttribution = showForwardAttribution; } public String getItemId() { @@ -222,6 +225,10 @@ public class DirectItem implements Cloneable { this.reactions = reactions; } + public boolean showForwardAttribution() { + return showForwardAttribution; + } + @NonNull @Override public Object clone() throws CloneNotSupportedException { diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.java b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.java index 3125a4ca..89f056d7 100644 --- a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.java +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.java @@ -2,13 +2,14 @@ package awais.instagrabber.repositories.responses.directmessages; import androidx.annotation.Nullable; +import java.io.Serializable; import java.util.List; import java.util.Map; import java.util.Objects; import awais.instagrabber.repositories.responses.User; -public class DirectThread { +public class DirectThread implements Serializable { private final String threadId; private final String threadV2Id; private final List users; diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipient.java b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipient.java new file mode 100644 index 00000000..cd36639f --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipient.java @@ -0,0 +1,46 @@ +package awais.instagrabber.repositories.responses.directmessages; + +import java.io.Serializable; +import java.util.Objects; + +import awais.instagrabber.repositories.responses.User; + +public class RankedRecipient implements Serializable { + private final User user; + private final DirectThread thread; + + public RankedRecipient(final User user, final DirectThread thread) { + this.user = user; + this.thread = thread; + } + + public User getUser() { + return user; + } + + public DirectThread getThread() { + return thread; + } + + public static RankedRecipient of(final User user) { + return new RankedRecipient(user, null); + } + + public static RankedRecipient of(final DirectThread thread) { + return new RankedRecipient(null, thread); + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final RankedRecipient that = (RankedRecipient) o; + return Objects.equals(user, that.user) && + Objects.equals(thread, that.thread); + } + + @Override + public int hashCode() { + return Objects.hash(user, thread); + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipientsResponse.java b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipientsResponse.java new file mode 100644 index 00000000..2c0b2885 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipientsResponse.java @@ -0,0 +1,50 @@ +package awais.instagrabber.repositories.responses.directmessages; + +import java.util.List; + +public class RankedRecipientsResponse { + private final List rankedRecipients; + private final long expires; + private final boolean filtered; + private final String requestId; + private final String rankToken; + private final String status; + + public RankedRecipientsResponse(final List rankedRecipients, + final long expires, + final boolean filtered, + final String requestId, + final String rankToken, + final String status) { + this.rankedRecipients = rankedRecipients; + this.expires = expires; + this.filtered = filtered; + this.requestId = requestId; + this.rankToken = rankToken; + this.status = status; + } + + public List getRankedRecipients() { + return rankedRecipients; + } + + public long getExpires() { + return expires; + } + + public boolean isFiltered() { + return filtered; + } + + public String getRequestId() { + return requestId; + } + + public String getRankToken() { + return rankToken; + } + + public String getStatus() { + return status; + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/DirectItemFactory.java b/app/src/main/java/awais/instagrabber/utils/DirectItemFactory.java index 31efa897..a959cdf0 100644 --- a/app/src/main/java/awais/instagrabber/utils/DirectItemFactory.java +++ b/app/src/main/java/awais/instagrabber/utils/DirectItemFactory.java @@ -47,8 +47,8 @@ public class DirectItemFactory { null, null, null, - 0 - ); + 0, + false); } public static DirectItem createImageOrVideo(final long userId, @@ -128,8 +128,8 @@ public class DirectItemFactory { null, null, null, - 0 - ); + 0, + false); } public static DirectItem createVoice(final long userId, @@ -209,7 +209,7 @@ public class DirectItemFactory { null, voiceMedia, null, - 0 - ); + 0, + false); } } diff --git a/app/src/main/java/awais/instagrabber/utils/RankedRecipientsCache.java b/app/src/main/java/awais/instagrabber/utils/RankedRecipientsCache.java new file mode 100644 index 00000000..72263971 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/RankedRecipientsCache.java @@ -0,0 +1,69 @@ +package awais.instagrabber.utils; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.List; + +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipientsResponse; + +public class RankedRecipientsCache { + private static final Object LOCK = new Object(); + + private static RankedRecipientsCache instance; + + public static RankedRecipientsCache getInstance() { + if (instance == null) { + synchronized (LOCK) { + if (instance == null) { + instance = new RankedRecipientsCache(); + } + } + } + return instance; + } + + private LocalDateTime lastUpdatedOn; + private RankedRecipientsResponse response; + private boolean updateInitiated = false; + private boolean failed = false; + + private RankedRecipientsCache() {} + + public List getRankedRecipients() { + if (response != null) { + return response.getRankedRecipients(); + } + return Collections.emptyList(); + } + + public void setRankedRecipientsResponse(final RankedRecipientsResponse response) { + this.response = response; + lastUpdatedOn = LocalDateTime.now(); + } + + public boolean isExpired() { + if (lastUpdatedOn == null || response == null) { + return true; + } + final long expiresInSecs = response.getExpires(); + return LocalDateTime.now().isAfter(lastUpdatedOn.plus(expiresInSecs, ChronoUnit.SECONDS)); + } + + public boolean isUpdateInitiated() { + return updateInitiated; + } + + public void setUpdateInitiated(final boolean updateInitiated) { + this.updateInitiated = updateInitiated; + } + + public boolean isFailed() { + return failed; + } + + public void setFailed(final boolean failed) { + this.failed = failed; + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.java index 5f0d93f0..065d8bf1 100644 --- a/app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.java +++ b/app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.java @@ -27,12 +27,14 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import awais.instagrabber.customviews.emoji.Emoji; import awais.instagrabber.models.Resource; import awais.instagrabber.models.UploadVideoOptions; +import awais.instagrabber.models.enums.DirectItemType; import awais.instagrabber.repositories.requests.UploadFinishOptions; import awais.instagrabber.repositories.requests.directmessages.BroadcastOptions.ThreadIdOrUserIds; import awais.instagrabber.repositories.responses.User; @@ -44,6 +46,7 @@ import awais.instagrabber.repositories.responses.directmessages.DirectThreadBroa import awais.instagrabber.repositories.responses.directmessages.DirectThreadBroadcastResponseMessageMetadata; import awais.instagrabber.repositories.responses.directmessages.DirectThreadBroadcastResponsePayload; import awais.instagrabber.repositories.responses.directmessages.DirectThreadFeedResponse; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; import awais.instagrabber.utils.BitmapUtils; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; @@ -926,4 +929,94 @@ public class DirectThreadViewModel extends AndroidViewModel { Log.e(TAG, "onResponse: ", e); } } + + public void forward(final Set recipients, final DirectItem itemToForward) { + if (recipients == null || itemToForward == null) return; + for (final RankedRecipient recipient : recipients) { + forward(recipient, itemToForward); + } + } + + public void forward(final RankedRecipient recipient, final DirectItem itemToForward) { + if (recipient == null || itemToForward == null) return; + if (recipient.getThread() == null && recipient.getUser() != null) { + // create thread and forward + final Call createThreadRequest = service.createThread(Collections.singletonList(recipient.getUser().getPk()), null); + createThreadRequest.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (!response.isSuccessful()) { + if (response.errorBody() != null) { + try { + final String string = response.errorBody().string(); + final String msg = String.format(Locale.US, + "onResponse: url: %s, responseCode: %d, errorBody: %s", + call.request().url().toString(), + response.code(), + string); + Log.e(TAG, msg); + } catch (IOException e) { + Log.e(TAG, "onResponse: ", e); + } + return; + } + Log.e(TAG, "onResponse: request was not successful and response error body was null"); + return; + } + final DirectThread thread = response.body(); + if (thread == null) { + Log.e(TAG, "onResponse: thread is null"); + return; + } + forward(thread, itemToForward); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + + } + }); + return; + } + if (recipient.getThread() != null) { + // just forward + final DirectThread thread = recipient.getThread(); + forward(thread, itemToForward); + } + } + + private void forward(@NonNull final DirectThread thread, @NonNull final DirectItem itemToForward) { + final DirectItemType itemType = itemToForward.getItemType(); + final Call request = service.forward(thread.getThreadId(), + itemType.getName(), + threadId, + itemToForward.getItemId()); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, + @NonNull final Response response) { + if (response.isSuccessful()) return; + if (response.errorBody() != null) { + try { + final String string = response.errorBody().string(); + final String msg = String.format(Locale.US, + "onResponse: url: %s, responseCode: %d, errorBody: %s", + call.request().url().toString(), + response.code(), + string); + Log.e(TAG, msg); + } catch (IOException e) { + Log.e(TAG, "onResponse: ", e); + } + return; + } + Log.e(TAG, "onResponse: request was not successful and response error body was null"); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + Log.e(TAG, "onFailure: ", t); + } + }); + } } diff --git a/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java index ab43a2ec..e74c36c6 100644 --- a/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java +++ b/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java @@ -3,51 +3,84 @@ package awais.instagrabber.viewmodels; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; +import com.google.common.collect.ImmutableList; + import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.Iterator; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import awais.instagrabber.fragments.UserSearchFragment; import awais.instagrabber.models.Resource; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.UserSearchResponse; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipientsResponse; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.Debouncer; +import awais.instagrabber.utils.RankedRecipientsCache; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.webservices.DirectMessagesService; import awais.instagrabber.webservices.UserService; import okhttp3.ResponseBody; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; +import static awais.instagrabber.utils.Utils.settingsHelper; + public class UserSearchViewModel extends ViewModel { private static final String TAG = UserSearchViewModel.class.getSimpleName(); public static final String DEBOUNCE_KEY = "search"; private String prevQuery; private String currentQuery; - private Call searchRequest; + private Call searchRequest; + private long[] hideUserIds; + private String[] hideThreadIds; + private UserSearchFragment.SearchMode searchMode; + private boolean showGroups; + private boolean waitingForCache; + private boolean showCachedResults; - private final MutableLiveData>> users = new MutableLiveData<>(); + private final MutableLiveData>> recipients = new MutableLiveData<>(); private final MutableLiveData showAction = new MutableLiveData<>(false); private final Debouncer searchDebouncer; - private final Set selectedUsers = new HashSet<>(); + private final Set selectedRecipients = new HashSet<>(); private final UserService userService; - private long[] hideUserIds; + private final DirectMessagesService directMessagesService; + private final RankedRecipientsCache rankedRecipientsCache; public UserSearchViewModel() { + final String cookie = settingsHelper.getString(Constants.COOKIE); + final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); + final long viewerId = CookieUtils.getUserIdFromCookie(cookie); + final String deviceUuid = settingsHelper.getString(Constants.DEVICE_UUID); + if (TextUtils.isEmpty(csrfToken) || viewerId <= 0 || TextUtils.isEmpty(deviceUuid)) { + throw new IllegalArgumentException("User is not logged in!"); + } userService = UserService.getInstance(); + directMessagesService = DirectMessagesService.getInstance(csrfToken, viewerId, deviceUuid); + rankedRecipientsCache = RankedRecipientsCache.getInstance(); + if ((rankedRecipientsCache.isFailed() || rankedRecipientsCache.isExpired()) && !rankedRecipientsCache.isUpdateInitiated()) { + updateRankedRecipientCache(); + } final Debouncer.Callback searchCallback = new Debouncer.Callback() { @Override public void call(final String key) { - if (userService == null || (currentQuery != null && currentQuery.equalsIgnoreCase(prevQuery))) return; - searchRequest = userService.search(currentQuery); - handleRequest(searchRequest); + if (currentQuery != null && currentQuery.equalsIgnoreCase(prevQuery)) return; + sendSearchRequest(); prevQuery = currentQuery; } @@ -59,53 +92,202 @@ public class UserSearchViewModel extends ViewModel { searchDebouncer = new Debouncer<>(searchCallback, 1000); } - public LiveData>> getUsers() { - return users; - } - - public void search(final String query) { - currentQuery = query; - users.postValue(Resource.loading(null)); - searchDebouncer.call(DEBOUNCE_KEY); - } - - public void cleanup() { - searchDebouncer.terminate(); - } - - private void handleRequest(final Call request) { - request.enqueue(new Callback() { + private void updateRankedRecipientCache() { + rankedRecipientsCache.setUpdateInitiated(true); + final Call request = directMessagesService.rankedRecipients(null, null, null); + request.enqueue(new Callback() { @Override - public void onResponse(@NonNull final Call call, @NonNull final Response response) { + public void onResponse(@NonNull final Call call, @NonNull final Response response) { if (!response.isSuccessful()) { - handleErrorResponse(response); + handleErrorResponse(response, false); + rankedRecipientsCache.setFailed(true); + rankedRecipientsCache.setUpdateInitiated(false); + continueSearchIfRequired(); return; } - final UserSearchResponse userSearchResponse = response.body(); - if (userSearchResponse == null) return; - handleResponse(userSearchResponse); + if (response.body() == null) { + Log.e(TAG, "onResponse: response body is null"); + rankedRecipientsCache.setUpdateInitiated(false); + rankedRecipientsCache.setFailed(true); + continueSearchIfRequired(); + return; + } + rankedRecipientsCache.setRankedRecipientsResponse(response.body()); + rankedRecipientsCache.setUpdateInitiated(false); + continueSearchIfRequired(); } @Override - public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { - + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + Log.e(TAG, "onFailure: ", t); + rankedRecipientsCache.setUpdateInitiated(false); + rankedRecipientsCache.setFailed(true); + continueSearchIfRequired(); } }); } - private void handleResponse(final UserSearchResponse userSearchResponse) { - users.postValue(Resource.success(userSearchResponse - .getUsers() - .stream() - .filter(directUser -> Arrays.binarySearch(hideUserIds, directUser.getPk()) < 0) - .collect(Collectors.toList()) - )); + private void continueSearchIfRequired() { + if (!waitingForCache) { + if (showCachedResults) { + recipients.postValue(Resource.success(getCachedRecipients())); + } + return; + } + waitingForCache = false; + sendSearchRequest(); } - private void handleErrorResponse(final Response response) { + public LiveData>> getRecipients() { + return recipients; + } + + public void search(@Nullable final String query) { + currentQuery = query; + if (TextUtils.isEmpty(query)) { + cancelSearch(); + if (showCachedResults) { + recipients.postValue(Resource.success(getCachedRecipients())); + } + return; + } + recipients.postValue(Resource.loading(getCachedRecipients())); + searchDebouncer.call(DEBOUNCE_KEY); + } + + private void sendSearchRequest() { + if (!rankedRecipientsCache.isFailed()) { // to avoid infinite loop in case of any network issues + if (rankedRecipientsCache.isUpdateInitiated()) { + // wait for cache first + waitingForCache = true; + return; + } + if (rankedRecipientsCache.isExpired()) { + // update cache first + updateRankedRecipientCache(); + waitingForCache = true; + return; + } + } + switch (searchMode) { + case RAVEN: + case RESHARE: + rankedRecipientSearch(); + break; + case USER_SEARCH: + default: + defaultUserSearch(); + break; + } + } + + private void defaultUserSearch() { + searchRequest = userService.search(currentQuery); + //noinspection unchecked + handleRequest((Call) searchRequest); + } + + private void rankedRecipientSearch() { + searchRequest = directMessagesService.rankedRecipients(searchMode.getName(), showGroups, currentQuery); + //noinspection unchecked + handleRankedRecipientRequest((Call) searchRequest); + } + + + private void handleRankedRecipientRequest(@NonNull final Call request) { + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (!response.isSuccessful()) { + handleErrorResponse(response, true); + searchRequest = null; + return; + } + final RankedRecipientsResponse rankedRecipientsResponse = response.body(); + if (rankedRecipientsResponse == null) { + recipients.postValue(Resource.error("Response is null!", getCachedRecipients())); + searchRequest = null; + return; + } + final List list = rankedRecipientsResponse.getRankedRecipients(); + recipients.postValue(Resource.success(mergeResponseWithCache(list))); + searchRequest = null; + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + Log.e(TAG, "onFailure: ", t); + recipients.postValue(Resource.error(t.getMessage(), getCachedRecipients())); + searchRequest = null; + } + }); + } + + private void handleRequest(@NonNull final Call request) { + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (!response.isSuccessful()) { + handleErrorResponse(response, true); + searchRequest = null; + return; + } + final UserSearchResponse userSearchResponse = response.body(); + if (userSearchResponse == null) { + recipients.postValue(Resource.error("Response is null!", getCachedRecipients())); + searchRequest = null; + return; + } + final List list = userSearchResponse + .getUsers() + .stream() + .map(RankedRecipient::of) + .collect(Collectors.toList()); + recipients.postValue(Resource.success(mergeResponseWithCache(list))); + searchRequest = null; + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + Log.e(TAG, "onFailure: ", t); + recipients.postValue(Resource.error(t.getMessage(), getCachedRecipients())); + searchRequest = null; + } + }); + } + + private List mergeResponseWithCache(@NonNull final List list) { + final Iterator iterator = list.stream() + .filter(Objects::nonNull) + .filter(this::filterValidRecipients) + .filter(this::filterOutGroups) + .filter(this::filterIdsToHide) + .iterator(); + return ImmutableList.builder() + .addAll(getCachedRecipients()) // add cached results first + .addAll(iterator) + .build(); + } + + @NonNull + private List getCachedRecipients() { + final List rankedRecipients = rankedRecipientsCache.getRankedRecipients(); + final List list = rankedRecipients != null ? rankedRecipients : Collections.emptyList(); + return list.stream() + .filter(Objects::nonNull) + .filter(this::filterValidRecipients) + .filter(this::filterOutGroups) + .filter(this::filterQuery) + .filter(this::filterIdsToHide) + .collect(Collectors.toList()); + } + + private void handleErrorResponse(final Response response, boolean updateResource) { final ResponseBody errorBody = response.errorBody(); if (errorBody == null) { - users.postValue(Resource.error("Request failed!", Collections.emptyList())); + if (updateResource) { + recipients.postValue(Resource.error("Request failed!", getCachedRecipients())); + } return; } String errorString; @@ -116,24 +298,30 @@ public class UserSearchViewModel extends ViewModel { Log.e(TAG, "handleErrorResponse: ", e); errorString = e.getMessage(); } - users.postValue(Resource.error(errorString, Collections.emptyList())); - } - - public void setSelectedUser(final User user, final boolean selected) { - if (selected) { - selectedUsers.add(user); - } else { - selectedUsers.remove(user); + if (updateResource) { + recipients.postValue(Resource.error(errorString, getCachedRecipients())); } - showAction.postValue(!selectedUsers.isEmpty()); } - public Set getSelectedUsers() { - return selectedUsers; + public void cleanup() { + searchDebouncer.terminate(); + } + + public void setSelectedRecipient(final RankedRecipient recipient, final boolean selected) { + if (selected) { + selectedRecipients.add(recipient); + } else { + selectedRecipients.remove(recipient); + } + showAction.postValue(!selectedRecipients.isEmpty()); + } + + public Set getSelectedRecipients() { + return selectedRecipients; } public void clearResults() { - users.postValue(Resource.success(Collections.emptyList())); + recipients.postValue(Resource.success(Collections.emptyList())); prevQuery = ""; } @@ -149,7 +337,78 @@ public class UserSearchViewModel extends ViewModel { return showAction; } + public void setSearchMode(final UserSearchFragment.SearchMode searchMode) { + this.searchMode = searchMode; + } + + public void setShowGroups(final boolean showGroups) { + this.showGroups = showGroups; + } + public void setHideUserIds(final long[] hideUserIds) { - this.hideUserIds = hideUserIds; + if (hideUserIds != null) { + final long[] copy = Arrays.copyOf(hideUserIds, hideUserIds.length); + Arrays.sort(copy); + this.hideUserIds = copy; + return; + } + this.hideUserIds = null; + } + + public void setHideThreadIds(final String[] hideThreadIds) { + if (hideThreadIds != null) { + final String[] copy = Arrays.copyOf(hideThreadIds, hideThreadIds.length); + Arrays.sort(copy); + this.hideThreadIds = copy; + return; + } + this.hideThreadIds = null; + } + + private boolean filterOutGroups(@NonNull RankedRecipient recipient) { + // if showGroups is false, remove groups from the list + if (showGroups || recipient.getThread() == null) { + return true; + } + return !recipient.getThread().isGroup(); + } + + private boolean filterValidRecipients(@NonNull RankedRecipient recipient) { + // check if both user and thread are null + return recipient.getUser() != null || recipient.getThread() != null; + } + + private boolean filterIdsToHide(@NonNull RankedRecipient recipient) { + if (hideThreadIds != null && recipient.getThread() != null) { + return Arrays.binarySearch(hideThreadIds, recipient.getThread().getThreadId()) < 0; + } + if (hideUserIds != null) { + long pk = -1; + if (recipient.getUser() != null) { + pk = recipient.getUser().getPk(); + } else if (recipient.getThread() != null && !recipient.getThread().isGroup()) { + final User user = recipient.getThread().getUsers().get(0); + pk = user.getPk(); + } + return Arrays.binarySearch(hideUserIds, pk) < 0; + } + return true; + } + + private boolean filterQuery(@NonNull RankedRecipient recipient) { + if (TextUtils.isEmpty(currentQuery)) { + return true; + } + if (recipient.getThread() != null) { + return recipient.getThread().getThreadTitle().toLowerCase().contains(currentQuery.toLowerCase()); + } + return recipient.getUser().getUsername().toLowerCase().contains(currentQuery.toLowerCase()) + || recipient.getUser().getFullName().toLowerCase().contains(currentQuery.toLowerCase()); + } + + public void showCachedResults() { + this.showCachedResults = true; + if (rankedRecipientsCache.isUpdateInitiated()) return; + recipients.postValue(Resource.success(getCachedRecipients())); } } diff --git a/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java b/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java index 48dd5863..647137b7 100644 --- a/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java +++ b/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java @@ -1,6 +1,7 @@ package awais.instagrabber.webservices; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.google.common.collect.ImmutableMap; @@ -13,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; +import java.util.stream.Collectors; import awais.instagrabber.repositories.DirectMessagesRepository; import awais.instagrabber.repositories.requests.directmessages.BroadcastOptions; @@ -26,9 +28,11 @@ import awais.instagrabber.repositories.requests.directmessages.VideoBroadcastOpt import awais.instagrabber.repositories.requests.directmessages.VoiceBroadcastOptions; import awais.instagrabber.repositories.responses.directmessages.DirectBadgeCount; import awais.instagrabber.repositories.responses.directmessages.DirectInboxResponse; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.repositories.responses.directmessages.DirectThreadBroadcastResponse; import awais.instagrabber.repositories.responses.directmessages.DirectThreadDetailsChangeResponse; import awais.instagrabber.repositories.responses.directmessages.DirectThreadFeedResponse; +import awais.instagrabber.repositories.responses.directmessages.RankedRecipientsResponse; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import retrofit2.Call; @@ -247,4 +251,56 @@ public class DirectMessagesService extends BaseService { ); return repository.deleteItem(threadId, itemId, form); } + + public Call rankedRecipients(@Nullable final String mode, + @Nullable final Boolean showThreads, + @Nullable final String query) { + // String correctedMode = mode; + // if (TextUtils.isEmpty(mode) || (!mode.equals("raven") && !mode.equals("reshare"))) { + // correctedMode = "raven"; + // } + final ImmutableMap.Builder builder = ImmutableMap.builder(); + if (mode != null) { + builder.put("mode", mode); + } + if (query != null) { + builder.put("query", query); + } + if (showThreads != null) { + builder.put("showThreads", String.valueOf(showThreads)); + } + return repository.rankedRecipients(builder.build()); + } + + public Call forward(@NonNull final String toThreadId, + @NonNull final String itemType, + @NonNull final String fromThreadId, + @NonNull final String itemId) { + final ImmutableMap form = ImmutableMap.of( + "action", "forward_item", + "thread_id", toThreadId, + "item_type", itemType, + "forwarded_from_thread_id", fromThreadId, + "forwarded_from_thread_item_id", itemId + ); + return repository.forward(form); + } + + public Call createThread(@NonNull final List userIds, + @Nullable final String threadTitle) { + final List userIdStringList = userIds.stream() + .filter(Objects::nonNull) + .map(String::valueOf) + .collect(Collectors.toList()); + final ImmutableMap.Builder formBuilder = ImmutableMap.builder() + .put("_csrftoken", csrfToken) + .put("_uuid", deviceUuid) + .put("_uid", userId) + .put("recipient_users", new JSONArray(userIdStringList).toString()); + if (threadTitle != null) { + formBuilder.put("thread_title", threadTitle); + } + final Map signedForm = Utils.sign(formBuilder.build()); + return repository.createThread(signedForm); + } } diff --git a/app/src/main/res/layout/layout_dm_base.xml b/app/src/main/res/layout/layout_dm_base.xml index 710a0458..0c389368 100644 --- a/app/src/main/res/layout/layout_dm_base.xml +++ b/app/src/main/res/layout/layout_dm_base.xml @@ -29,7 +29,7 @@ app:layout_constraintHorizontal_chainStyle="packed" app:layout_constraintStart_toEndOf="@id/ivProfilePic" app:layout_constraintTop_toTopOf="parent" - tools:visibility="visible" /> + tools:visibility="gone" /> + tools:visibility="gone" /> + + + tools:text="Admin" + tools:visibility="gone" /> + tools:text="username" + tools:visibility="gone" /> + tools:visibility="visible" /> - + + + - + + + + - + + + + - + + + + android:label="@string/search" + tools:layout="@layout/fragment_user_search"> + + + + + + android:defaultValue="@null" + app:argType="long[]" + app:nullable="true" /> + + + + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index d4069f8d..c5ed4d4c 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 448e3293..1d6209c7 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -398,4 +398,7 @@ Message Reply Tap to remove + Forward + Add + Send