Track occupant ID and allow local muting

Stephen Paul Weber created

Change summary

src/main/java/eu/siacs/conversations/entities/Conversation.java               |  4 
src/main/java/eu/siacs/conversations/entities/Message.java                    | 22 
src/main/java/eu/siacs/conversations/entities/MucOptions.java                 | 32 
src/main/java/eu/siacs/conversations/parser/AbstractParser.java               |  6 
src/main/java/eu/siacs/conversations/parser/MessageParser.java                |  2 
src/main/java/eu/siacs/conversations/parser/PresenceParser.java               |  5 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java         | 54 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java      | 21 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java             | 15 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java           | 12 
src/main/java/eu/siacs/conversations/ui/util/MucDetailsContextMenuHelper.java | 27 
src/main/res/menu/muc_details_context.xml                                     |  8 
12 files changed, 186 insertions(+), 22 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/entities/Conversation.java 🔗

@@ -653,7 +653,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         }
     }
 
-    public void populateWithMessages(final List<Message> messages) {
+    public void populateWithMessages(final List<Message> messages, XmppConnectionService xmppConnectionService) {
         synchronized (this.messages) {
             messages.clear();
             messages.addAll(this.messages);
@@ -676,7 +676,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                     thread.first = m;
                 }
             }
-            if (m.wasMergedIntoPrevious() || (m.getSubject() != null && !m.isOOb() && (m.getRawBody() == null || m.getRawBody().length() == 0)) || (getLockThread() && !extraIds.contains(m.replyId()) && (mthread == null || !mthread.getContent().equals(getThread() == null ? "" : getThread().getContent())))) {
+            if (m.wasMergedIntoPrevious(xmppConnectionService) || (m.getSubject() != null && !m.isOOb() && (m.getRawBody() == null || m.getRawBody().length() == 0)) || (getLockThread() && !extraIds.contains(m.replyId()) && (mthread == null || !mthread.getContent().equals(getThread() == null ? "" : getThread().getContent())))) {
                 iterator.remove();
             } else if (getLockThread() && mthread != null) {
                 Element reply = m.getReply();

src/main/java/eu/siacs/conversations/entities/Message.java 🔗

@@ -59,6 +59,7 @@ import eu.siacs.conversations.utils.MessageUtils;
 import eu.siacs.conversations.utils.MimeUtils;
 import eu.siacs.conversations.utils.StringUtils;
 import eu.siacs.conversations.utils.UIHelper;
+import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
@@ -125,6 +126,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     protected String conversationUuid;
     protected Jid counterpart;
     protected Jid trueCounterpart;
+    protected String occupantId = null;
     protected String body;
     protected SpannableStringBuilder spannableBody = null;
     protected String subject;
@@ -273,7 +275,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             }
         }
 
-        return new Message(conversation,
+        Message m = new Message(conversation,
                 cursor.getString(cursor.getColumnIndex(UUID)),
                 cursor.getString(cursor.getColumnIndex(CONVERSATION)),
                 fromString(cursor.getString(cursor.getColumnIndex(COUNTERPART))),
@@ -301,6 +303,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
                 cursor.getString(cursor.getColumnIndex("fileParams")),
                 payloads
         );
+        m.setOccupantId(cursor.getString(cursor.getColumnIndex("occupant_id")));
+        return m;
     }
 
     private static Jid fromString(String value) {
@@ -344,6 +348,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             }
         }
         values.put("payloads", payloads.size() < 1 ? null : payloads.stream().map(Object::toString).collect(Collectors.joining()));
+        values.put("occupant_id", occupantId);
         return values;
     }
 
@@ -640,8 +645,17 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         addPayload(thread);
     }
 
+    public void setOccupantId(final String id) {
+        occupantId = id;
+    }
+
+    public String getOccupantId() {
+        return occupantId;
+    }
+
     public void setMucUser(MucOptions.User user) {
         this.user = new WeakReference<>(user);
+        if (user != null && user.getOccupantId() != null) setOccupantId(user.getOccupantId());
     }
 
     public boolean sameMucUser(Message otherMessage) {
@@ -1096,9 +1110,13 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         return time;
     }
 
-    public boolean wasMergedIntoPrevious() {
+    public boolean wasMergedIntoPrevious(XmppConnectionService xmppConnectionService) {
         Message prev = this.prev();
         if (prev != null && getModerated() != null && prev.getModerated() != null) return true;
+        if (getOccupantId() != null && xmppConnectionService != null) {
+            final boolean muted = getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), getOccupantId(), null, null));
+            if (prev != null && muted && getOccupantId().equals(prev.getOccupantId())) return true;
+        }
         return prev != null && prev.mergeable(this);
     }
 

src/main/java/eu/siacs/conversations/entities/MucOptions.java 🔗

@@ -56,7 +56,7 @@ public class MucOptions {
         this.account = conversation.getAccount();
         this.conversation = conversation;
         final String nick = getProposedNick(conversation.getAttribute("mucNick"));
-        this.self = new User(this, createJoinJid(nick), nick, new HashSet<>());
+        this.self = new User(this, createJoinJid(nick), null, nick, new HashSet<>());
         this.self.affiliation = Affiliation.of(conversation.getAttribute("affiliation"));
         this.self.role = Role.of(conversation.getAttribute("role"));
     }
@@ -342,10 +342,24 @@ public class MucOptions {
         return null;
     }
 
+    public User findUserByOccupantId(final String id) {
+        if (id == null) {
+            return null;
+        }
+        synchronized (users) {
+            for (User user : users) {
+                if (id.equals(user.getOccupantId())) {
+                    return user;
+                }
+            }
+        }
+        return null;
+    }
+
     public User findOrCreateUserByRealJid(Jid jid, Jid fullJid) {
         User user = findUserByRealJid(jid);
         if (user == null) {
-            user = new User(this, fullJid, null, new HashSet<>());
+            user = new User(this, fullJid, null, null, new HashSet<>());
             user.setRealJid(jid);
         }
         return user;
@@ -544,7 +558,7 @@ public class MucOptions {
     private List<User> getFallbackUsersFromCryptoTargets() {
         List<User> users = new ArrayList<>();
         for (Jid jid : conversation.getAcceptedCryptoTargets()) {
-            User user = new User(this, null, null, new HashSet<>());
+            User user = new User(this, null, null, null, new HashSet<>());
             user.setRealJid(jid);
             users.add(user);
         }
@@ -832,10 +846,12 @@ public class MucOptions {
         private final MucOptions options;
         private ChatState chatState = Config.DEFAULT_CHAT_STATE;
         protected Set<Hat> hats;
+        protected String occupantId;
 
-        public User(MucOptions options, Jid fullJid, final String nick, final Set<Hat> hats) {
+        public User(MucOptions options, Jid fullJid, final String occupantId, final String nick, final Set<Hat> hats) {
             this.options = options;
             this.fullJid = fullJid;
+            this.occupantId = occupantId;
             this.nick = nick;
             this.hats = hats;
         }
@@ -844,6 +860,14 @@ public class MucOptions {
             return fullJid == null ? null : fullJid.getResource();
         }
 
+        public Jid getMuc() {
+            return fullJid == null ? null : fullJid.asBareJid();
+        }
+
+        public String getOccupantId() {
+            return occupantId;
+        }
+
         public String getNick() {
             return nick == null ? getName() : nick;
         }

src/main/java/eu/siacs/conversations/parser/AbstractParser.java 🔗

@@ -134,10 +134,10 @@ public abstract class AbstractParser {
 	}
 
 	public static MucOptions.User parseItem(Conversation conference, Element item) {
-		return parseItem(conference,item,null,null,new Element("hats", "urn:xmpp:hats:0"));
+		return parseItem(conference,item,null,null,null,new Element("hats", "urn:xmpp:hats:0"));
 	}
 
-	public static MucOptions.User parseItem(Conversation conference, Element item, Jid fullJid, final String nicknameIn, final Element hatsEl) {
+	public static MucOptions.User parseItem(Conversation conference, Element item, Jid fullJid, final Element occupantId, final String nicknameIn, final Element hatsEl) {
 		final String local = conference.getJid().getLocal();
 		final String domain = conference.getJid().getDomain().toEscapedString();
 		String affiliation = item.getAttribute("affiliation");
@@ -165,7 +165,7 @@ public abstract class AbstractParser {
 				hats.add(new MucOptions.Hat(hat));
 			}
 		}
-		MucOptions.User user = new MucOptions.User(conference.getMucOptions(), fullJid, nickname, hatsEl == null ? null : hats);
+		MucOptions.User user = new MucOptions.User(conference.getMucOptions(), fullJid, occupantId == null ? null : occupantId.getAttribute("id"), nickname, hatsEl == null ? null : hats);
 		if (InvalidJid.isValid(realJid)) {
 			user.setRealJid(realJid);
 		}

src/main/java/eu/siacs/conversations/parser/MessageParser.java 🔗

@@ -728,6 +728,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
             }
             if (conversationMultiMode) {
                 message.setMucUser(conversation.getMucOptions().findUserByFullJid(counterpart));
+                final Element occupantId = packet.findChild("occupant-id", "urn:xmpp:occupant-id:0");
+                if (occupantId != null) message.setOccupantId(occupantId.getAttribute("id"));
                 final Jid fallback = conversation.getMucOptions().getTrueCounterpart(counterpart);
                 Jid trueCounterpart;
                 if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {

src/main/java/eu/siacs/conversations/parser/PresenceParser.java 🔗

@@ -69,6 +69,7 @@ public class PresenceParser extends AbstractParser implements
 				hats = packet.findChild("hats", "xmpp:prosody.im/protocol/hats:1");
 			}
 			if (hats == null) hats = new Element("hats", "urn:xmpp:hats:0");
+			final Element occupantId = packet.findChild("occupant-id", "urn:xmpp:occupant-id:0");
 			Avatar avatar = Avatar.parsePresence(packet.findChild("x", "vcard-temp:x:update"));
 			final List<String> codes = getStatusCodes(x);
 			if (type == null) {
@@ -76,7 +77,7 @@ public class PresenceParser extends AbstractParser implements
 					Element item = x.findChild("item");
 					if (item != null && !from.isBareJid()) {
 						mucOptions.setError(MucOptions.Error.NONE);
-						MucOptions.User user = parseItem(conversation, item, from, nick == null ? null : nick.getContent(), hats);
+						MucOptions.User user = parseItem(conversation, item, from, occupantId, nick == null ? null : nick.getContent(), hats);
 						if (codes.contains(MucOptions.STATUS_CODE_SELF_PRESENCE) || (codes.contains(MucOptions.STATUS_CODE_ROOM_CREATED) && jid.equals(InvalidJid.getNullForInvalid(item.getAttributeAsJid("jid"))))) {
 							if (mucOptions.setOnline()) {
 								mXmppConnectionService.getAvatarService().clear(mucOptions);
@@ -180,7 +181,7 @@ public class PresenceParser extends AbstractParser implements
 				} else if (!from.isBareJid()){
 					Element item = x.findChild("item");
 					if (item != null) {
-						mucOptions.updateUser(parseItem(conversation, item, from, nick == null ? null : nick.getContent(), hats));
+						mucOptions.updateUser(parseItem(conversation, item, from, occupantId, nick == null ? null : nick.getContent(), hats));
 					}
 					MucOptions.User user = mucOptions.deleteUser(from);
 					if (user != null) {

src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java 🔗

@@ -14,6 +14,8 @@ import android.util.Log;
 import com.cheogram.android.WebxdcUpdate;
 
 import com.google.common.base.Stopwatch;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.HashMultimap;
 
 import org.json.JSONException;
 import org.json.JSONObject;
@@ -52,6 +54,7 @@ import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.entities.PresenceTemplate;
 import eu.siacs.conversations.entities.Roster;
 import eu.siacs.conversations.entities.ServiceDiscoveryResult;
@@ -320,6 +323,22 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                 db.execSQL("PRAGMA cheogram.user_version = 9");
             }
 
+            if(cheogramVersion < 10) {
+                db.execSQL(
+                    "CREATE TABLE cheogram.muted_participants (" +
+                    "muc_jid TEXT NOT NULL, " +
+                    "occupant_id TEXT NOT NULL, " +
+                    "nick TEXT NOT NULL," +
+                    "PRIMARY KEY (muc_jid, occupant_id)" +
+                    ")"
+                );
+                db.execSQL(
+                    "ALTER TABLE cheogram." + Message.TABLENAME + " " +
+                    "ADD COLUMN occupant_id TEXT"
+                );
+                db.execSQL("PRAGMA cheogram.user_version = 10");
+            }
+
             db.setTransactionSuccessful();
         } finally {
             db.endTransaction();
@@ -863,6 +882,41 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         db.execSQL("DELETE FROM cheogram.blocked_media");
     }
 
+    public Multimap<String, String> loadMutedMucUsers() {
+        Multimap<String, String> result = HashMultimap.create();
+        SQLiteDatabase db = this.getReadableDatabase();
+        Cursor cursor = db.query("cheogram.muted_participants", new String[]{"muc_jid", "occupant_id"}, null, null, null, null, null);
+        while (cursor.moveToNext()) {
+            result.put(cursor.getString(0), cursor.getString(1));
+        }
+        cursor.close();
+        return result;
+    }
+
+    public boolean muteMucUser(MucOptions.User user) {
+        if (user.getMuc() == null || user.getOccupantId() == null) return false;
+
+        SQLiteDatabase db = this.getWritableDatabase();
+        ContentValues cv = new ContentValues();
+        cv.put("muc_jid", user.getMuc().toString());
+        cv.put("occupant_id", user.getOccupantId());
+        cv.put("nick", user.getNick());
+        db.insertWithOnConflict("cheogram.muted_participants", null, cv, SQLiteDatabase.CONFLICT_REPLACE);
+
+        return true;
+    }
+
+    public boolean unmuteMucUser(MucOptions.User user) {
+        if (user.getMuc() == null || user.getOccupantId() == null) return false;
+
+        SQLiteDatabase db = this.getWritableDatabase();
+        String where = "muc_jid=? AND occupant_id=?";
+        String[] whereArgs = {user.getMuc().toString(), user.getOccupantId()};
+        db.delete("cheogram.muted_participants", where, whereArgs);
+
+        return true;
+    }
+
     public void insertWebxdcUpdate(final WebxdcUpdate update) {
         SQLiteDatabase db = this.getWritableDatabase();
         db.insertWithOnConflict("cheogram.webxdc_updates", null, update.getContentValues(), SQLiteDatabase.CONFLICT_IGNORE);

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -65,6 +65,7 @@ import com.cheogram.android.WebxdcUpdate;
 import com.google.common.base.Objects;
 import com.google.common.base.Optional;
 import com.google.common.base.Strings;
+import com.google.common.collect.Multimap;
 import com.google.common.io.Files;
 
 import com.kedia.ogparser.JsoupProxy;
@@ -258,6 +259,7 @@ public class XmppConnectionService extends Service {
         }
     };
     public DatabaseBackend databaseBackend;
+    private Multimap<String, String> mutedMucUsers;
     private final ReplacingSerialSingleThreadExecutor mContactMergerExecutor = new ReplacingSerialSingleThreadExecutor("ContactMerger");
     private final ReplacingSerialSingleThreadExecutor mStickerScanExecutor = new ReplacingSerialSingleThreadExecutor("StickerScan");
     private long mLastActivity = 0;
@@ -621,6 +623,24 @@ public class XmppConnectionService extends Service {
         this.databaseBackend.saveCid(cid, file, url);
     }
 
+    public boolean muteMucUser(MucOptions.User user) {
+        boolean muted = databaseBackend.muteMucUser(user);
+        if (!muted) return false;
+        mutedMucUsers.put(user.getMuc().toString(), user.getOccupantId());
+        return true;
+    }
+
+    public boolean unmuteMucUser(MucOptions.User user) {
+        boolean unmuted = databaseBackend.unmuteMucUser(user);
+        if (!unmuted) return false;
+        mutedMucUsers.remove(user.getMuc().toString(), user.getOccupantId());
+        return true;
+    }
+
+    public boolean isMucUserMuted(MucOptions.User user) {
+        return mutedMucUsers.containsEntry("" + user.getMuc(), user.getOccupantId());
+    }
+
     public void blockMedia(File f) {
         try {
             Cid[] cids = getFileBackend().calculateCids(new FileInputStream(f));
@@ -2408,6 +2428,7 @@ public class XmppConnectionService extends Service {
                 if (DatabaseBackend.requiresMessageIndexRebuild()) {
                     DatabaseBackend.getInstance(this).rebuildMessagesIndex();
                 }
+                mutedMucUsers = databaseBackend.loadMutedMucUsers();
                 final long deletionDate = getAutomaticMessageDeletionDate();
                 mLastExpiryRun.set(SystemClock.elapsedRealtime());
                 if (deletionDate > 0) {

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -387,7 +387,7 @@ public class ConversationFragment extends XmppFragment
                                                                     .populateWithMessages(
                                                                             ConversationFragment
                                                                                     .this
-                                                                                    .messageList);
+                                                                                    .messageList, activity.xmppConnectionService);
                                                             try {
                                                                 updateStatusMessages();
                                                             } catch (IllegalStateException e) {
@@ -772,7 +772,7 @@ public class ConversationFragment extends XmppFragment
                 return i;
             } else {
                 Message next = messages.get(i);
-                while (next != null && next.wasMergedIntoPrevious()) {
+                while (next != null && next.wasMergedIntoPrevious(activity.xmppConnectionService)) {
                     if (uuid.equals(next.getUuid())) {
                         return i;
                     }
@@ -2657,7 +2657,7 @@ public class ConversationFragment extends XmppFragment
                     }
                 }
                 if (message != null) {
-                    while (message.next() != null && message.next().wasMergedIntoPrevious()) {
+                    while (message.next() != null && message.next().wasMergedIntoPrevious(activity.xmppConnectionService)) {
                         message = message.next();
                     }
                     return message;
@@ -3567,7 +3567,7 @@ public class ConversationFragment extends XmppFragment
                 if (messageListAdapter.hasSelection()) {
                     if (notifyConversationRead) binding.messagesView.postDelayed(this::refresh, 1000L);
                 } else {
-                    conversation.populateWithMessages(this.messageList);
+                    conversation.populateWithMessages(this.messageList, activity.xmppConnectionService);
                     updateStatusMessages();
                     this.messageListAdapter.notifyDataSetChanged();
                 }
@@ -4293,10 +4293,15 @@ public class ConversationFragment extends XmppFragment
                         tcp != null
                                 ? conversation.getMucOptions().findOrCreateUserByRealJid(tcp, cp)
                                 : null;
+                final String occupantId = message.getOccupantId();
+                final User userByOccupantId =
+                        occupantId != null
+                                ? conversation.getMucOptions().findUserByOccupantId(occupantId)
+                                : null;
                 final User user =
                         userByRealJid != null
                                 ? userByRealJid
-                                : conversation.getMucOptions().findUserByFullJid(cp);
+                                : (userByOccupantId != null ? userByOccupantId : conversation.getMucOptions().findUserByFullJid(cp));
                 if (user == null) return;
                 popupMenu.inflate(R.menu.muc_details_context);
                 final Menu menu = popupMenu.getMenu();

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -79,6 +79,7 @@ import eu.siacs.conversations.entities.Conversational;
 import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message.FileParams;
 import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.entities.Roster;
 import eu.siacs.conversations.entities.RtpSessionStatus;
 import eu.siacs.conversations.entities.Transferable;
@@ -1081,7 +1082,12 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
         final Transferable transferable = message.getTransferable();
         final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
-        if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
+
+        final boolean muted = message.getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && activity.xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), message.getOccupantId(), null, null));
+        if (muted) {
+            // Muted MUC participant
+            displayInfoMessage(viewHolder, "Muted", darkBackground);
+        } else if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
             if (unInitiatedButKnownSize || (message.isDeleted() && message.getModerated() == null) || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
                 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), darkBackground, type);
             } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
@@ -1152,7 +1158,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         viewHolder.status_line.setLayoutParams(statusParams);
 
         if (type == RECEIVED) {
-            if (commands != null && conversation instanceof Conversation) {
+            if (!muted && commands != null && conversation instanceof Conversation) {
                 CommandButtonAdapter adapter = new CommandButtonAdapter(activity);
                 adapter.addAll(commands);
                 viewHolder.commands_list.setAdapter(adapter);
@@ -1194,7 +1200,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             if (subject == null && message.getThread() != null) {
                 subject = ((Conversation) message.getConversation()).getThread(message.getThread().getContent()).getSubject();
             }
-            if (subject == null) {
+            if (muted || subject == null) {
                 viewHolder.subject.setVisibility(View.GONE);
             } else {
                 viewHolder.subject.setVisibility(View.VISIBLE);

src/main/java/eu/siacs/conversations/ui/util/MucDetailsContextMenuHelper.java 🔗

@@ -10,6 +10,7 @@ import android.view.ContextMenu;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
+import android.widget.Toast;
 
 import androidx.appcompat.app.AlertDialog;
 import androidx.databinding.DataBindingUtil;
@@ -102,7 +103,7 @@ public final class MucDetailsContextMenuHelper {
         return new Pair<>(items.toArray(new CharSequence[items.size()]), actions.toArray(new Integer[actions.size()]));
     }
 
-    public static void configureMucDetailsContextMenu(Activity activity, Menu menu, Conversation conversation, User user) {
+    public static void configureMucDetailsContextMenu(XmppActivity activity, Menu menu, Conversation conversation, User user) {
         final MucOptions mucOptions = conversation.getMucOptions();
         final boolean advancedMode = PreferenceManager.getDefaultSharedPreferences(activity).getBoolean("advanced_muc_mode", false);
         final boolean isGroupChat = mucOptions.isPrivateAndNonAnonymous();
@@ -113,6 +114,16 @@ public final class MucDetailsContextMenuHelper {
             blockAvatar.setVisible(true);
         }
 
+        MenuItem muteParticipant = menu.findItem(R.id.action_mute_participant);
+        MenuItem unmuteParticipant = menu.findItem(R.id.action_unmute_participant);
+        if (user != null && user.getOccupantId() != null) {
+            if (activity.xmppConnectionService.isMucUserMuted(user)) {
+                unmuteParticipant.setVisible(true);
+            } else {
+                muteParticipant.setVisible(true);
+            }
+        }
+
         if (user != null && user.getRealJid() != null) {
             MenuItem showContactDetails = menu.findItem(R.id.action_contact_details);
             MenuItem startConversation = menu.findItem(R.id.start_conversation);
@@ -210,6 +221,20 @@ public final class MucDetailsContextMenuHelper {
                     })
                     .setNegativeButton(R.string.no, null).show();
                 return true;
+            case R.id.action_mute_participant:
+                if (activity.xmppConnectionService.muteMucUser(user)) {
+                    activity.xmppConnectionService.updateConversationUi();
+                } else {
+                    Toast.makeText(activity, "Failed to mute", Toast.LENGTH_SHORT).show();
+                }
+                return true;
+            case R.id.action_unmute_participant:
+                if (activity.xmppConnectionService.unmuteMucUser(user)) {
+                    activity.xmppConnectionService.updateConversationUi();
+                } else {
+                    Toast.makeText(activity, "Failed to unmute", Toast.LENGTH_SHORT).show();
+                }
+                return true;
             case R.id.start_conversation:
                 startConversation(user, activity);
                 return true;

src/main/res/menu/muc_details_context.xml 🔗

@@ -12,6 +12,14 @@
         android:id="@+id/action_block_avatar"
         android:title="Block Avatar"
         android:visible="false" />
+    <item
+        android:id="@+id/action_mute_participant"
+        android:title="Mute Locally"
+        android:visible="false" />
+    <item
+        android:id="@+id/action_unmute_participant"
+        android:title="Unmute Locally"
+        android:visible="false" />
     <item
         android:id="@+id/invite"
         android:title="@string/invite_again"