From 3f16765d8a4fdaa4354a64f1b13e411630cfc0a7 Mon Sep 17 00:00:00 2001 From: M M Arif Date: Sat, 15 Mar 2025 18:05:05 +0000 Subject: [PATCH] User mentions in issues, prs and comments (#1418) Closes #1394 Reviewed-on: https://codeberg.org/gitnex/GitNex/pulls/1418 Co-authored-by: M M Arif Co-committed-by: M M Arif --- .../activities/CreateIssueActivity.java | 8 +- .../activities/CreatePullRequestActivity.java | 8 +- .../gitnex/activities/EditIssueActivity.java | 8 +- .../activities/IssueDetailActivity.java | 8 +- .../mian/gitnex/helpers/MentionHelper.java | 305 ++++++++++++++++++ .../main/res/layout/list_users_mention.xml | 35 ++ 6 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/org/mian/gitnex/helpers/MentionHelper.java create mode 100644 app/src/main/res/layout/list_users_mention.xml diff --git a/app/src/main/java/org/mian/gitnex/activities/CreateIssueActivity.java b/app/src/main/java/org/mian/gitnex/activities/CreateIssueActivity.java index 944adf1c..cb254dc4 100644 --- a/app/src/main/java/org/mian/gitnex/activities/CreateIssueActivity.java +++ b/app/src/main/java/org/mian/gitnex/activities/CreateIssueActivity.java @@ -60,6 +60,7 @@ import org.mian.gitnex.helpers.AlertDialogs; import org.mian.gitnex.helpers.AppDatabaseSettings; import org.mian.gitnex.helpers.Constants; import org.mian.gitnex.helpers.Markdown; +import org.mian.gitnex.helpers.MentionHelper; import org.mian.gitnex.helpers.SnackBar; import org.mian.gitnex.helpers.Toasty; import org.mian.gitnex.helpers.attachments.AttachmentUtils; @@ -98,6 +99,7 @@ public class CreateIssueActivity extends BaseActivity private NotesApi notesApi; private List notesList; public AlertDialog dialogNotes; + private MentionHelper mentionHelper; @SuppressLint("ClickableViewAccessibility") @Override @@ -152,6 +154,9 @@ public class CreateIssueActivity extends BaseActivity contentUri.clear(); }); + mentionHelper = new MentionHelper(this, viewBinding.newIssueDescription); + mentionHelper.setup(); + viewBinding.topAppBar.setOnMenuItemClickListener( menuItem -> { int id = menuItem.getItemId(); @@ -280,8 +285,9 @@ public class CreateIssueActivity extends BaseActivity }); public void onDestroy() { - AttachmentsAdapter.setAttachmentsReceiveListener(null); super.onDestroy(); + AttachmentsAdapter.setAttachmentsReceiveListener(null); + mentionHelper.dismissPopup(); } @Override diff --git a/app/src/main/java/org/mian/gitnex/activities/CreatePullRequestActivity.java b/app/src/main/java/org/mian/gitnex/activities/CreatePullRequestActivity.java index 4b22a05f..f8f1d9d0 100644 --- a/app/src/main/java/org/mian/gitnex/activities/CreatePullRequestActivity.java +++ b/app/src/main/java/org/mian/gitnex/activities/CreatePullRequestActivity.java @@ -61,6 +61,7 @@ import org.mian.gitnex.helpers.AlertDialogs; import org.mian.gitnex.helpers.AppDatabaseSettings; import org.mian.gitnex.helpers.Constants; import org.mian.gitnex.helpers.Markdown; +import org.mian.gitnex.helpers.MentionHelper; import org.mian.gitnex.helpers.SnackBar; import org.mian.gitnex.helpers.Toasty; import org.mian.gitnex.helpers.attachments.AttachmentUtils; @@ -97,6 +98,7 @@ public class CreatePullRequestActivity extends BaseActivity private NotesApi notesApi; private List notesList; public AlertDialog dialogNotes; + private MentionHelper mentionHelper; @SuppressLint("ClickableViewAccessibility") @Override @@ -123,6 +125,9 @@ public class CreatePullRequestActivity extends BaseActivity int resultLimit = Constants.getCurrentResultLimit(ctx); + mentionHelper = new MentionHelper(this, viewBinding.prBody); + mentionHelper.setup(); + viewBinding.prBody.setOnTouchListener( (touchView, motionEvent) -> { touchView.getParent().requestDisallowInterceptTouchEvent(true); @@ -290,8 +295,9 @@ public class CreatePullRequestActivity extends BaseActivity } public void onDestroy() { - AttachmentsAdapter.setAttachmentsReceiveListener(null); super.onDestroy(); + AttachmentsAdapter.setAttachmentsReceiveListener(null); + mentionHelper.dismissPopup(); } @Override diff --git a/app/src/main/java/org/mian/gitnex/activities/EditIssueActivity.java b/app/src/main/java/org/mian/gitnex/activities/EditIssueActivity.java index 143f2308..ba160d40 100644 --- a/app/src/main/java/org/mian/gitnex/activities/EditIssueActivity.java +++ b/app/src/main/java/org/mian/gitnex/activities/EditIssueActivity.java @@ -70,6 +70,7 @@ import org.mian.gitnex.helpers.AppDatabaseSettings; import org.mian.gitnex.helpers.AppUtil; import org.mian.gitnex.helpers.Constants; import org.mian.gitnex.helpers.Markdown; +import org.mian.gitnex.helpers.MentionHelper; import org.mian.gitnex.helpers.SnackBar; import org.mian.gitnex.helpers.attachments.AttachmentUtils; import org.mian.gitnex.helpers.attachments.AttachmentsModel; @@ -101,6 +102,7 @@ public class EditIssueActivity extends BaseActivity private static List attachmentsList; private static final List contentUri = new ArrayList<>(); private MenuItem create; + private MentionHelper mentionHelper; public ActivityResultLauncher downloadAttachmentLauncher = registerForActivityResult( @@ -229,8 +231,9 @@ public class EditIssueActivity extends BaseActivity }); public void onDestroy() { - AttachmentsAdapter.setAttachmentsReceiveListener(null); super.onDestroy(); + AttachmentsAdapter.setAttachmentsReceiveListener(null); + mentionHelper.dismissPopup(); } @SuppressLint("ClickableViewAccessibility") @@ -264,6 +267,9 @@ public class EditIssueActivity extends BaseActivity AttachmentsAdapter.setAttachmentsReceiveListener(this); + mentionHelper = new MentionHelper(this, binding.editIssueDescription); + mentionHelper.setup(); + create = binding.topAppBar.getMenu().getItem(2); create.setTitle(getString(R.string.saveButton)); diff --git a/app/src/main/java/org/mian/gitnex/activities/IssueDetailActivity.java b/app/src/main/java/org/mian/gitnex/activities/IssueDetailActivity.java index e5861185..825bec06 100644 --- a/app/src/main/java/org/mian/gitnex/activities/IssueDetailActivity.java +++ b/app/src/main/java/org/mian/gitnex/activities/IssueDetailActivity.java @@ -103,6 +103,7 @@ import org.mian.gitnex.helpers.ColorInverter; import org.mian.gitnex.helpers.Constants; import org.mian.gitnex.helpers.LabelWidthCalculator; import org.mian.gitnex.helpers.Markdown; +import org.mian.gitnex.helpers.MentionHelper; import org.mian.gitnex.helpers.SnackBar; import org.mian.gitnex.helpers.TimeHelper; import org.mian.gitnex.helpers.TinyDB; @@ -167,6 +168,7 @@ public class IssueDetailActivity extends BaseActivity private final float buttonAlphaStatDisabled = .5F; private final float buttonAlphaStatEnabled = 1F; private int loadingFinished = 0; + private MentionHelper mentionHelper; private enum Mode { EDIT, @@ -411,6 +413,9 @@ public class IssueDetailActivity extends BaseActivity startActivity(issue.getIntent(ctx, DiffActivity.class)); } + mentionHelper = new MentionHelper(this, viewBinding.commentReply); + mentionHelper.setup(); + viewBinding.commentReply.addTextChangedListener( new TextWatcher() { @Override @@ -545,8 +550,9 @@ public class IssueDetailActivity extends BaseActivity } public void onDestroy() { - AttachmentsAdapter.setAttachmentsReceiveListener(null); super.onDestroy(); + AttachmentsAdapter.setAttachmentsReceiveListener(null); + mentionHelper.dismissPopup(); } @Override diff --git a/app/src/main/java/org/mian/gitnex/helpers/MentionHelper.java b/app/src/main/java/org/mian/gitnex/helpers/MentionHelper.java new file mode 100644 index 00000000..06446aa0 --- /dev/null +++ b/app/src/main/java/org/mian/gitnex/helpers/MentionHelper.java @@ -0,0 +1,305 @@ +package org.mian.gitnex.helpers; + +import android.content.Context; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.Log; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.PopupWindow; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import java.util.ArrayList; +import java.util.List; +import org.gitnex.tea4j.v2.models.InlineResponse2001; +import org.gitnex.tea4j.v2.models.User; +import org.mian.gitnex.R; +import org.mian.gitnex.clients.RetrofitClient; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +/** + * @author mmarif + */ +public class MentionHelper { + + private final PopupWindow mentionPopup = new PopupWindow(); + private final MentionAdapter mentionAdapter; + private final List mentionSuggestions = new ArrayList<>(); + private final Context context; + private final EditText editText; + + public MentionHelper(Context context, EditText editText) { + + this.context = context; + this.editText = editText; + + RecyclerView mentionRecyclerView = new RecyclerView(context); + mentionRecyclerView.setLayoutManager(new LinearLayoutManager(context)); + + mentionAdapter = + new MentionAdapter( + context, + mentionSuggestions, + user -> { + String mention = "@" + user.getLogin(); + int cursorPos = editText.getSelectionStart(); + String text = editText.getText().toString(); + int mentionStart = text.lastIndexOf("@", cursorPos - 1); + if (mentionStart != -1) { + editText.getText().replace(mentionStart, cursorPos, mention + " "); + mentionPopup.dismiss(); + } + }); + mentionRecyclerView.setAdapter(mentionAdapter); + + int paddingDp = 4; + int sidePaddingDp = 12; + int paddingPx = (int) (paddingDp * context.getResources().getDisplayMetrics().density); + int sidePaddingPx = + (int) (sidePaddingDp * context.getResources().getDisplayMetrics().density); + mentionRecyclerView.setPadding(sidePaddingPx, paddingPx, sidePaddingPx, paddingPx); + + mentionPopup.setContentView(mentionRecyclerView); + mentionPopup.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); + mentionPopup.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mentionPopup.setFocusable(false); + mentionPopup.setBackgroundDrawable( + ContextCompat.getDrawable(context, R.drawable.shape_round_corners)); + mentionPopup.setElevation(8f); + } + + public void setup() { + + editText.addTextChangedListener( + new TextWatcher() { + @Override + public void beforeTextChanged( + CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + handleMentionInput(s); + } + + @Override + public void afterTextChanged(Editable s) {} + }); + } + + public void dismissPopup() { + if (mentionPopup.isShowing()) { + mentionPopup.dismiss(); + } + } + + private void handleMentionInput(CharSequence text) { + + int cursorPos = editText.getSelectionStart(); + + if (cursorPos > 0) { + + String beforeCursor = text.subSequence(0, cursorPos).toString(); + int atIndex = beforeCursor.lastIndexOf("@"); + + if (atIndex != -1 + && (cursorPos == atIndex + 1 + || !beforeCursor.substring(atIndex).contains(" "))) { + + String query = beforeCursor.substring(atIndex + 1); + if (!query.isEmpty()) { + fetchUserSuggestions(query); + } else { + int oldSize = mentionSuggestions.size(); + mentionSuggestions.clear(); + if (oldSize > 0) { + mentionAdapter.notifyItemRangeRemoved(0, oldSize); + } + mentionPopup.dismiss(); + } + } else { + int oldSize = mentionSuggestions.size(); + mentionSuggestions.clear(); + if (oldSize > 0) { + mentionAdapter.notifyItemRangeRemoved(0, oldSize); + } + mentionPopup.dismiss(); + } + } else { + int oldSize = mentionSuggestions.size(); + mentionSuggestions.clear(); + if (oldSize > 0) { + mentionAdapter.notifyItemRangeRemoved(0, oldSize); + } + mentionPopup.dismiss(); + } + } + + private void fetchUserSuggestions(String query) { + + Call call = + RetrofitClient.getApiInterface(context).userSearch(query, null, 1, 4); + + call.enqueue( + new Callback<>() { + @Override + public void onResponse( + @NonNull Call call, + @NonNull Response response) { + + if (response.isSuccessful() + && response.body() != null + && response.body().isOk()) { + int oldSize = mentionSuggestions.size(); + mentionSuggestions.clear(); + if (oldSize > 0) { + mentionAdapter.notifyItemRangeRemoved(0, oldSize); + } + List newData = response.body().getData(); + if (newData != null && !newData.isEmpty()) { + mentionSuggestions.addAll( + newData.subList(0, Math.min(newData.size(), 4))); + mentionAdapter.notifyItemRangeInserted( + 0, mentionSuggestions.size()); + showMentionPopup(); + } else { + mentionPopup.dismiss(); + } + } else { + int oldSize = mentionSuggestions.size(); + mentionSuggestions.clear(); + if (oldSize > 0) { + mentionAdapter.notifyItemRangeRemoved(0, oldSize); + } + mentionPopup.dismiss(); + } + } + + @Override + public void onFailure( + @NonNull Call call, @NonNull Throwable t) { + + int oldSize = mentionSuggestions.size(); + mentionSuggestions.clear(); + if (oldSize > 0) { + mentionAdapter.notifyItemRangeRemoved(0, oldSize); + } + mentionPopup.dismiss(); + Log.e("MentionHelper", "Failed to fetch users: " + t.getMessage()); + } + }); + } + + private void showMentionPopup() { + + int[] location = new int[2]; + editText.getLocationOnScreen(location); + int x = location[0]; + int y = location[1] - 24; + + int popupWidth = editText.getWidth(); + int popupHeight = calculatePopupHeight(); + + if (mentionPopup.isShowing()) { + mentionPopup.dismiss(); + } + + mentionPopup.setWidth(popupWidth); + mentionPopup.setHeight(popupHeight); + mentionPopup.showAtLocation(editText, Gravity.NO_GRAVITY, x, y - popupHeight); + } + + private int calculatePopupHeight() { + + int itemHeightDp = 48; + int paddingDp = 4; + int itemHeightPx = + (int) (itemHeightDp * context.getResources().getDisplayMetrics().density); + int paddingPx = (int) (paddingDp * context.getResources().getDisplayMetrics().density); + + int contentHeightPx = mentionSuggestions.size() * itemHeightPx; + int totalPaddingPx = paddingPx * 2; + int popupHeight = contentHeightPx + totalPaddingPx; + + int maxHeightPx = 4 * itemHeightPx + totalPaddingPx; + popupHeight = Math.min(popupHeight, maxHeightPx); + + if (mentionSuggestions.isEmpty()) { + popupHeight = itemHeightPx + totalPaddingPx; + } + + return popupHeight; + } + + private static class MentionAdapter + extends RecyclerView.Adapter { + private final Context context; + private final List users; + private final OnUserClickListener listener; + + public MentionAdapter(Context context, List users, OnUserClickListener listener) { + this.context = context; + this.users = users; + this.listener = listener; + } + + @NonNull @Override + public MentionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = + LayoutInflater.from(context) + .inflate(R.layout.list_users_mention, parent, false); + return new MentionViewHolder(view); + } + + @Override + public void onBindViewHolder(MentionViewHolder holder, int position) { + + User user = users.get(position); + String displayName = + (user.getFullName() != null && !user.getFullName().isEmpty()) + ? user.getFullName() + " (" + user.getLogin() + ")" + : user.getLogin(); + holder.username.setText(displayName); + + Glide.with(context) + .load(user.getAvatarUrl()) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .placeholder(R.drawable.loader_animated) + .centerCrop() + .into(holder.avatar); + + holder.itemView.setBackground(null); + holder.itemView.setOnClickListener(v -> listener.onUserClick(user)); + } + + @Override + public int getItemCount() { + return users.size(); + } + + static class MentionViewHolder extends RecyclerView.ViewHolder { + ImageView avatar; + TextView username; + + MentionViewHolder(View itemView) { + super(itemView); + avatar = itemView.findViewById(R.id.avatar); + username = itemView.findViewById(R.id.username); + } + } + + interface OnUserClickListener { + void onUserClick(User user); + } + } +} diff --git a/app/src/main/res/layout/list_users_mention.xml b/app/src/main/res/layout/list_users_mention.xml new file mode 100644 index 00000000..40ff30c6 --- /dev/null +++ b/app/src/main/res/layout/list_users_mention.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + +