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 <mmarif@swatian.com>
Co-committed-by: M M Arif <mmarif@swatian.com>
This commit is contained in:
M M Arif
2025-03-15 18:05:05 +00:00
committed by M M Arif
parent 3b1acfe82c
commit 3f16765d8a
6 changed files with 368 additions and 4 deletions

View File

@@ -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<Notes> 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

View File

@@ -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<Notes> 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

View File

@@ -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<AttachmentsModel> attachmentsList;
private static final List<Uri> contentUri = new ArrayList<>();
private MenuItem create;
private MentionHelper mentionHelper;
public ActivityResultLauncher<Intent> 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));

View File

@@ -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

View File

@@ -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<User> 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<InlineResponse2001> call =
RetrofitClient.getApiInterface(context).userSearch(query, null, 1, 4);
call.enqueue(
new Callback<>() {
@Override
public void onResponse(
@NonNull Call<InlineResponse2001> call,
@NonNull Response<InlineResponse2001> response) {
if (response.isSuccessful()
&& response.body() != null
&& response.body().isOk()) {
int oldSize = mentionSuggestions.size();
mentionSuggestions.clear();
if (oldSize > 0) {
mentionAdapter.notifyItemRangeRemoved(0, oldSize);
}
List<User> 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<InlineResponse2001> 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<MentionAdapter.MentionViewHolder> {
private final Context context;
private final List<User> users;
private final OnUserClickListener listener;
public MentionAdapter(Context context, List<User> 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);
}
}
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/dimen48dp"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="horizontal">
<com.google.android.material.card.MaterialCardView
android:layout_width="@dimen/dimen32dp"
android:layout_height="@dimen/dimen32dp"
style="?attr/materialCardViewElevatedStyle"
android:backgroundTint="@android:color/transparent"
android:layout_gravity="center"
app:cardElevation="@dimen/dimen0dp"
app:cardCornerRadius="@dimen/dimen12dp">
<ImageView
android:id="@+id/avatar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/userAvatar"
android:src="@drawable/ic_person"/>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="@dimen/dimen12dp"
android:textColor="?attr/primaryTextColor"
android:textSize="@dimen/dimen14sp" />
</LinearLayout>