1package eu.siacs.conversations.ui.util;
2
3import android.app.Activity;
4import android.preference.PreferenceManager;
5import android.text.SpannableString;
6import android.text.Spanned;
7import android.text.style.TypefaceSpan;
8import android.util.Pair;
9import android.view.ContextMenu;
10import android.view.Menu;
11import android.view.MenuItem;
12import android.view.View;
13import android.widget.Toast;
14
15import androidx.appcompat.app.AlertDialog;
16import androidx.databinding.DataBindingUtil;
17
18import java.util.ArrayList;
19
20import com.google.android.material.dialog.MaterialAlertDialogBuilder;
21import eu.siacs.conversations.Config;
22import eu.siacs.conversations.R;
23import eu.siacs.conversations.databinding.DialogQuickeditBinding;
24import eu.siacs.conversations.entities.Account;
25import eu.siacs.conversations.entities.Contact;
26import eu.siacs.conversations.entities.Conversation;
27import eu.siacs.conversations.entities.Message;
28import eu.siacs.conversations.entities.MucOptions;
29import eu.siacs.conversations.services.XmppConnectionService;
30import eu.siacs.conversations.ui.ConferenceDetailsActivity;
31import eu.siacs.conversations.ui.ConversationFragment;
32import eu.siacs.conversations.ui.ConversationsActivity;
33import eu.siacs.conversations.ui.MucUsersActivity;
34import eu.siacs.conversations.ui.XmppActivity;
35import eu.siacs.conversations.utils.UIHelper;
36import eu.siacs.conversations.xmpp.Jid;
37import eu.siacs.conversations.xml.Element;
38import im.conversations.android.xmpp.model.muc.Affiliation;
39import im.conversations.android.xmpp.model.muc.Role;
40
41public final class MucDetailsContextMenuHelper {
42 private static final int ACTION_BAN = 0;
43 private static final int ACTION_GRANT_MEMBERSHIP = 1;
44 private static final int ACTION_REMOVE_MEMBERSHIP = 2;
45 private static final int ACTION_GRANT_ADMIN = 3;
46 private static final int ACTION_REMOVE_ADMIN = 4;
47 private static final int ACTION_GRANT_OWNER = 5;
48 private static final int ACTION_REMOVE_OWNER = 6;
49
50 public static void onCreateContextMenu(ContextMenu menu, View v) {
51 final XmppActivity activity = XmppActivity.find(v);
52 final Object tag = v.getTag();
53 if (tag instanceof MucOptions.User user && activity != null) {
54 activity.getMenuInflater().inflate(R.menu.muc_details_context, menu);
55 String name;
56 final Contact contact = user.getContact();
57 if (contact != null && contact.showInContactList()) {
58 name = contact.getDisplayName();
59 } else if (user.getRealJid() != null) {
60 name = user.getRealJid().asBareJid().toString();
61 } else {
62 name = user.getNick();
63 }
64 menu.setHeaderTitle(name);
65 MucDetailsContextMenuHelper.configureMucDetailsContextMenu(
66 activity, menu, user.getConversation(), user);
67 }
68 }
69
70 public static Pair<CharSequence[], Integer[]> getPermissionsChoices(Activity activity, Conversation conversation, MucOptions.User user) {
71 ArrayList<CharSequence> items = new ArrayList<>();
72 ArrayList<Integer> actions = new ArrayList<>();
73 final MucOptions.User self = conversation.getMucOptions().getSelf();
74 final MucOptions mucOptions = conversation.getMucOptions();
75 final boolean isGroupChat = mucOptions.isPrivateAndNonAnonymous();
76 if ((self.ranks(Affiliation.ADMIN) && self.outranks(user.getAffiliation())) || self.getAffiliation() == Affiliation.OWNER) {
77 if (!Config.DISABLE_BAN && user.getAffiliation() != Affiliation.OUTCAST) {
78 items.add(activity.getString(isGroupChat ? R.string.ban_from_conference : R.string.ban_from_channel));
79 actions.add(ACTION_BAN);
80 } else if (!Config.DISABLE_BAN) {
81 items.add(isGroupChat ? "Unban from group chat" : "Unban from channel");
82 actions.add(ACTION_REMOVE_MEMBERSHIP);
83 }
84 if (!user.ranks(Affiliation.MEMBER)) {
85 items.add(activity.getString(R.string.grant_membership));
86 actions.add(ACTION_GRANT_MEMBERSHIP);
87 } else if (user.getAffiliation() == Affiliation.MEMBER) {
88 items.add(activity.getString(R.string.remove_membership));
89 actions.add(ACTION_REMOVE_MEMBERSHIP);
90 }
91 }
92 if (self.ranks(Affiliation.OWNER)) {
93 if (!user.ranks(Affiliation.ADMIN)) {
94 items.add(activity.getString(R.string.grant_admin_privileges));
95 actions.add(ACTION_GRANT_ADMIN);
96 } else if (user.getAffiliation() == Affiliation.ADMIN) {
97 items.add(activity.getString(R.string.remove_admin_privileges));
98 actions.add(ACTION_REMOVE_ADMIN);
99 }
100 if (!user.ranks(Affiliation.OWNER)) {
101 items.add(activity.getString(R.string.grant_owner_privileges));
102 actions.add(ACTION_GRANT_OWNER);
103 } else if (user.getAffiliation() == Affiliation.OWNER){
104 items.add(activity.getString(R.string.remove_owner_privileges));
105 actions.add(ACTION_REMOVE_OWNER);
106 }
107 }
108 return new Pair<>(items.toArray(new CharSequence[items.size()]), actions.toArray(new Integer[actions.size()]));
109 }
110
111 public static void configureMucDetailsContextMenu(
112 XmppActivity activity, Menu menu, Conversation conversation, MucOptions.User user) {
113 final MucOptions mucOptions = conversation.getMucOptions();
114 final boolean advancedMode =
115 PreferenceManager.getDefaultSharedPreferences(activity)
116 .getBoolean("advanced_muc_mode", false);
117 final boolean showMucPm = PreferenceManager.getDefaultSharedPreferences(activity).getBoolean("show_muc_pm", false);
118 final boolean isGroupChat = mucOptions.isPrivateAndNonAnonymous();
119 MenuItem sendPrivateMessage = menu.findItem(R.id.send_private_message);
120 MenuItem shareContactDetails = menu.findItem(R.id.share_contact_details);
121
122 MenuItem blockAvatar = menu.findItem(R.id.action_block_avatar);
123 if (user != null && user.getAvatar() != null) {
124 blockAvatar.setVisible(true);
125 }
126
127 MenuItem muteParticipant = menu.findItem(R.id.action_mute_participant);
128 MenuItem unmuteParticipant = menu.findItem(R.id.action_unmute_participant);
129 if (user != null && user.getOccupantId() != null) {
130 if (activity.xmppConnectionService.isMucUserMuted(user)) {
131 unmuteParticipant.setVisible(true);
132 } else {
133 muteParticipant.setVisible(true);
134 }
135 }
136
137 if (user != null && user.getRealJid() != null) {
138 MenuItem showContactDetails = menu.findItem(R.id.action_contact_details);
139 MenuItem startConversation = menu.findItem(R.id.start_conversation);
140 MenuItem removeFromRoom = menu.findItem(R.id.remove_from_room);
141 MenuItem managePermissions = menu.findItem(R.id.manage_permissions);
142 removeFromRoom.setTitle(
143 isGroupChat ? R.string.remove_from_room : R.string.remove_from_channel);
144 MenuItem invite = menu.findItem(R.id.invite);
145 startConversation.setVisible(true);
146 final Contact contact = user.getContact();
147 final MucOptions.User self = conversation.getMucOptions().getSelf();
148 if ((contact != null && contact.showInRoster())
149 || mucOptions.isPrivateAndNonAnonymous()) {
150 showContactDetails.setVisible(contact == null || !contact.isSelf());
151 }
152 if ((activity instanceof ConferenceDetailsActivity
153 || activity instanceof MucUsersActivity)
154 && user.getRole() == Role.NONE) {
155 invite.setVisible(true);
156 }
157 boolean managePermissionsVisible = false;
158 if ((self.ranks(Affiliation.ADMIN) && self.outranks(user.getAffiliation())) || self.getAffiliation() == Affiliation.OWNER) {
159 if (!user.ranks(Affiliation.MEMBER)) {
160 managePermissionsVisible = true;
161 } else if (user.getAffiliation() == Affiliation.MEMBER) {
162 managePermissionsVisible = true;
163 }
164 if (!Config.DISABLE_BAN) {
165 managePermissionsVisible = true;
166 }
167 }
168 if (self.ranks(Affiliation.OWNER)) {
169 if (!user.ranks(Affiliation.OWNER)) {
170 managePermissionsVisible = true;
171 } else if (user.getAffiliation() == Affiliation.OWNER){
172 managePermissionsVisible = true;
173 }
174 if (!user.ranks(Affiliation.ADMIN)) {
175 managePermissionsVisible = true;
176 } else if (user.getAffiliation() == Affiliation.ADMIN) {
177 managePermissionsVisible = true;
178 }
179 }
180 managePermissions.setVisible(managePermissionsVisible);
181 sendPrivateMessage.setVisible(showMucPm && user.isOnline() && !isGroupChat && mucOptions.allowPm() && user.ranks(Role.VISITOR));
182 shareContactDetails.setVisible(user.isOnline() && !isGroupChat && mucOptions.allowPm() && user.ranks(Role.VISITOR));
183 } else {
184 sendPrivateMessage.setVisible(showMucPm && user != null && user.isOnline());
185 sendPrivateMessage.setEnabled(user != null && mucOptions.allowPm() && user.ranks(Role.VISITOR));
186 shareContactDetails.setVisible(user != null && user.isOnline());
187 shareContactDetails.setEnabled(user != null && mucOptions.allowPm() && user.ranks(Role.VISITOR));
188 }
189 }
190
191 public static boolean onContextItemSelected(MenuItem item, MucOptions.User user, XmppActivity activity) {
192 return onContextItemSelected(item, user, activity, null);
193 }
194
195 public static void maybeModerateRecent(XmppActivity activity, Conversation conversation, MucOptions.User user) {
196 if (!conversation.getMucOptions().getSelf().ranks(Role.MODERATOR) || !conversation.getMucOptions().hasFeature("urn:xmpp:message-moderate:0")) return;
197
198 DialogQuickeditBinding binding = DataBindingUtil.inflate(activity.getLayoutInflater(), R.layout.dialog_quickedit, null, false);
199 binding.inputEditText.setText("Spam");
200 new AlertDialog.Builder(activity)
201 .setTitle(R.string.moderate_recent)
202 .setMessage("Do you want to moderate all recent messages from this user?")
203 .setView(binding.getRoot())
204 .setPositiveButton("Moderate", (dialog, whichButton) -> {
205 for (Message m : conversation.findMessagesBy(user)) {
206 activity.xmppConnectionService.moderateMessage(conversation.getAccount(), m, binding.inputEditText.getText().toString());
207 }
208 })
209 .setNegativeButton("Just ban", null).show();
210 }
211
212 public static boolean onContextItemSelected(
213 MenuItem item, MucOptions.User user, XmppActivity activity, final String fingerprint) {
214 final Conversation conversation = user.getConversation();
215 final XmppConnectionService.OnAffiliationChanged onAffiliationChanged =
216 activity instanceof XmppConnectionService.OnAffiliationChanged
217 ? (XmppConnectionService.OnAffiliationChanged) activity
218 : null;
219 Jid jid = user.getRealJid();
220 switch (item.getItemId()) {
221 case R.id.action_contact_details:
222 final Jid realJid = user.getRealJid();
223 final Account account = conversation.getAccount();
224 final Contact contact =
225 realJid == null ? null : account.getRoster().getContact(realJid);
226 if (contact != null) {
227 activity.switchToContactDetails(contact, fingerprint);
228 }
229 return true;
230 case R.id.action_block_avatar:
231 new AlertDialog.Builder(activity)
232 .setTitle(R.string.block_media)
233 .setMessage("Do you really want to block this avatar?")
234 .setPositiveButton(R.string.yes, (dialog, whichButton) -> {
235 activity.xmppConnectionService.blockMedia(
236 activity.xmppConnectionService.getFileBackend().getAvatarFile(user.getAvatar())
237 );
238 activity.xmppConnectionService.getFileBackend().getAvatarFile(user.getAvatar()).delete();
239 activity.avatarService().clear(user);
240 if (user.getContact() != null) activity.avatarService().clear(user.getContact());
241 user.setAvatar(null);
242 activity.xmppConnectionService.updateConversationUi();
243 })
244 .setNegativeButton(R.string.no, null).show();
245 return true;
246 case R.id.action_mute_participant:
247 if (activity.xmppConnectionService.muteMucUser(user)) {
248 activity.xmppConnectionService.updateConversationUi();
249 } else {
250 Toast.makeText(activity, "Failed to mute", Toast.LENGTH_SHORT).show();
251 }
252 return true;
253 case R.id.action_unmute_participant:
254 if (activity.xmppConnectionService.unmuteMucUser(user)) {
255 activity.xmppConnectionService.updateConversationUi();
256 } else {
257 Toast.makeText(activity, "Failed to unmute", Toast.LENGTH_SHORT).show();
258 }
259 return true;
260 case R.id.start_conversation:
261 startConversation(user, activity);
262 return true;
263 case R.id.manage_permissions:
264 Pair<CharSequence[], Integer[]> choices = getPermissionsChoices(activity, conversation, user);
265 int[] selected = new int[] { -1 };
266 new AlertDialog.Builder(activity)
267 .setTitle(activity.getString(R.string.manage_permission_with_nick, UIHelper.getDisplayName(user)))
268 .setSingleChoiceItems(choices.first, -1, (dialog, whichItem) -> {
269 selected[0] = whichItem;
270 })
271 .setPositiveButton(R.string.action_complete, (dialog, whichButton) -> {
272 switch (selected[0] >= 0 ? choices.second[selected[0]] : -1) {
273 case ACTION_BAN:
274 activity.xmppConnectionService.changeAffiliationInConference(conversation, jid, Affiliation.OUTCAST, onAffiliationChanged);
275 if (user.getRole() != Role.NONE) {
276 activity.xmppConnectionService.changeRoleInConference(conversation, user.getName(), Role.NONE);
277 }
278 maybeModerateRecent(activity, conversation, user);
279 break;
280 case ACTION_GRANT_MEMBERSHIP:
281 case ACTION_REMOVE_ADMIN:
282 case ACTION_REMOVE_OWNER:
283 activity.xmppConnectionService.changeAffiliationInConference(conversation, jid, Affiliation.MEMBER, onAffiliationChanged);
284 break;
285 case ACTION_GRANT_ADMIN:
286 activity.xmppConnectionService.changeAffiliationInConference(conversation, jid, Affiliation.ADMIN, onAffiliationChanged);
287 break;
288 case ACTION_GRANT_OWNER:
289 activity.xmppConnectionService.changeAffiliationInConference(conversation, jid, Affiliation.OWNER, onAffiliationChanged);
290 break;
291 case ACTION_REMOVE_MEMBERSHIP:
292 activity.xmppConnectionService.changeAffiliationInConference(conversation, jid, Affiliation.NONE, onAffiliationChanged);
293 break;
294 }
295 })
296 .setNeutralButton(R.string.cancel, null).show();
297 return true;
298 case R.id.remove_from_room:
299 removeFromRoom(user, activity, onAffiliationChanged);
300 return true;
301 case R.id.send_private_message:
302 if (activity instanceof ConversationsActivity) {
303 ConversationFragment conversationFragment = ConversationFragment.get(activity);
304 if (conversationFragment != null) {
305 conversationFragment.privateMessageWith(user.getFullJid());
306 return true;
307 }
308 }
309 activity.privateMsgInMuc(conversation, user.getName());
310 return true;
311 case R.id.share_contact_details:
312 final var message = new Message(conversation, "/me invites you to chat " + conversation.getAccount().getShareableUri(), conversation.getNextEncryption());
313 Message.configurePrivateMessage(message, user.getFullJid());
314 /* This triggers a gajim bug right now https://dev.gajim.org/gajim/gajim/-/issues/11900
315 final var rosterx = new Element("x", "http://jabber.org/protocol/rosterx");
316 final var ritem = rosterx.addChild("item");
317 ritem.setAttribute("action", "add");
318 ritem.setAttribute("name", conversation.getMucOptions().getSelf().getNick());
319 ritem.setAttribute("jid", conversation.getAccount().getJid().asBareJid().toEscapedString());
320 message.addPayload(rosterx);*/
321 activity.xmppConnectionService.sendMessage(message);
322 return true;
323 case R.id.invite:
324 // TODO use direct invites for public conferences
325 if (user.ranks(Affiliation.MEMBER)) {
326 activity.xmppConnectionService.directInvite(conversation, jid.asBareJid());
327 } else {
328 activity.xmppConnectionService.invite(conversation, jid);
329 }
330 return true;
331 default:
332 return false;
333 }
334 }
335
336 private static void removeFromRoom(
337 final MucOptions.User user,
338 XmppActivity activity,
339 XmppConnectionService.OnAffiliationChanged onAffiliationChanged) {
340 final Conversation conversation = user.getConversation();
341 if (conversation.getMucOptions().membersOnly()) {
342 activity.xmppConnectionService.changeAffiliationInConference(
343 conversation, user.getRealJid(), Affiliation.NONE, onAffiliationChanged);
344 if (user.getRole() != Role.NONE) {
345 activity.xmppConnectionService.changeRoleInConference(
346 conversation, user.getName(), Role.NONE);
347 }
348 } else {
349 final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity);
350 builder.setTitle(R.string.ban_from_conference);
351 String jid = user.getRealJid().asBareJid().toString();
352 SpannableString message =
353 new SpannableString(
354 activity.getString(R.string.removing_from_public_conference, jid));
355 int start = message.toString().indexOf(jid);
356 if (start >= 0) {
357 message.setSpan(
358 new TypefaceSpan("monospace"),
359 start,
360 start + jid.length(),
361 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
362 }
363 builder.setMessage(message);
364 builder.setNegativeButton(R.string.cancel, null);
365 builder.setPositiveButton(
366 R.string.ban_now,
367 (dialog, which) -> {
368 activity.xmppConnectionService.changeAffiliationInConference(
369 conversation,
370 user.getRealJid(),
371 Affiliation.OUTCAST,
372 onAffiliationChanged);
373 if (user.getRole() != Role.NONE) {
374 activity.xmppConnectionService.changeRoleInConference(
375 conversation, user.getName(), Role.NONE);
376 }
377 });
378 builder.create().show();
379 }
380 }
381
382 private static void startConversation(MucOptions.User user, XmppActivity activity) {
383 if (user.getRealJid() != null) {
384 Conversation newConversation =
385 activity.xmppConnectionService.findOrCreateConversation(
386 user.getAccount(), user.getRealJid().asBareJid(), false, true);
387 activity.switchToConversation(newConversation);
388 }
389 }
390}