From 3e2019e656316de98d99b93148265ad7ae1225b7 Mon Sep 17 00:00:00 2001 From: Florian Ludwig Date: Sat, 24 Jan 2026 15:40:23 +0100 Subject: [PATCH 1/5] feat(permissions): Add support for separate REACT permission Adds compatibility with nextcloud/spreed#16835 which splits the combined CHAT permission into separate CHAT (128) and REACT (256) permissions. This enables "announcement channels" where only moderators can post messages, but all users can still react. Changes: - Add REACT (256) permission constant to ParticipantPermissions - Add hasReactPermission() method with backward compatibility fallback - Update MessageActionsDialog to use react permission for emoji bar - Add permission check in onClickReaction() for toggling reactions - Add unit tests for new permission handling Signed-off-by: Florian Ludwig --- .../com/nextcloud/talk/chat/ChatActivity.kt | 5 +++ .../talk/ui/dialog/MessageActionsDialog.kt | 11 ++--- .../talk/utils/ParticipantPermissions.kt | 13 ++++++ .../talk/utils/ParticipantPermissionsTest.kt | 45 +++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 949e97231fe..3f6bf4e6312 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -3975,6 +3975,10 @@ class ChatActivity : } override fun onClickReaction(chatMessage: ChatMessage, emoji: String) { + if (!participantPermissions.hasReactPermission()) { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return + } VibrationUtils.vibrateShort(context) if (chatMessage.reactionsSelf?.contains(emoji) == true) { chatViewModel.deleteReaction(roomToken, chatMessage, emoji) @@ -4028,6 +4032,7 @@ class ChatActivity : currentConversation, isShowMessageDeletionButton(message), participantPermissions.hasChatPermission(), + participantPermissions.hasReactPermission(), spreedCapabilities ).show() } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt index 7f9c07c178a..bac17371469 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt @@ -69,6 +69,7 @@ class MessageActionsDialog( private val currentConversation: ConversationModel?, private val showMessageDeletionButton: Boolean, private val hasChatPermission: Boolean, + private val hasReactPermission: Boolean, private val spreedCapabilities: SpreedCapability ) : BottomSheetDialog(chatActivity) { @@ -138,7 +139,7 @@ class MessageActionsDialog( viewThemeUtils.material.colorBottomSheetBackground(dialogMessageActionsBinding.root) viewThemeUtils.material.colorBottomSheetDragHandle(dialogMessageActionsBinding.bottomSheetDragHandle) - initEmojiBar(hasChatPermission) + initEmojiBar(hasReactPermission) initMenuItemCopy(!message.isDeleted) initMenuItems(networkMonitor.isOnline.value) } @@ -264,9 +265,9 @@ class MessageActionsDialog( } } - private fun initEmojiBar(hasChatPermission: Boolean) { + private fun initEmojiBar(hasReactPermission: Boolean) { if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.REACTIONS) && - isPermitted(hasChatPermission) && + isPermitted(hasReactPermission) && isReactableMessageType(message) ) { val recentEmojiManager = RecentEmojiManager(context, MAX_RECENTS) @@ -353,8 +354,8 @@ class MessageActionsDialog( } } - private fun isPermitted(hasChatPermission: Boolean): Boolean = - hasChatPermission && + private fun isPermitted(hasPermission: Boolean): Boolean = + hasPermission && ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY != currentConversation?.conversationReadOnlyState diff --git a/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt b/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt index 4e57300f951..46c8f36736d 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt @@ -26,6 +26,7 @@ class ParticipantPermissions( private val canPublishVideo = (conversation.permissions and PUBLISH_VIDEO) == PUBLISH_VIDEO val canPublishScreen = (conversation.permissions and PUBLISH_SCREEN) == PUBLISH_SCREEN private val hasChatPermission = (conversation.permissions and CHAT) == CHAT + private val hasReactPermission = (conversation.permissions and REACT) == REACT private fun hasConversationPermissions(): Boolean = CapabilitiesUtil.hasSpreedFeatureCapability( @@ -70,6 +71,17 @@ class ParticipantPermissions( return true } + fun hasReactPermission(): Boolean { + if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.CHAT_PERMISSION)) { + // Server supports granular permissions - check REACT bit + // Fall back to CHAT permission for backward compatibility with servers + // that have chat-permission capability but not yet the REACT permission split + return hasReactPermission || hasChatPermission + } + // if capability is not available then the spreed version doesn't support to restrict this + return true + } + companion object { val TAG = ParticipantPermissions::class.simpleName @@ -82,5 +94,6 @@ class ParticipantPermissions( const val PUBLISH_VIDEO = 32 const val PUBLISH_SCREEN = 64 const val CHAT = 128 + const val REACT = 256 } } diff --git a/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt index 76b08b68a82..305f32ad19a 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt @@ -47,6 +47,51 @@ class ParticipantPermissionsTest : TestCase() { assertTrue(attendeePermissions.canPublishVideo()) } + @Test + fun test_reactPermissionSet() { + val spreedCapability = SpreedCapability() + spreedCapability.features = listOf("chat-permission") + val conversation = createConversation() + + conversation.permissions = ParticipantPermissions.REACT or + ParticipantPermissions.CUSTOM + + val user = User() + user.id = 1 + + val attendeePermissions = + ParticipantPermissions( + spreedCapability, + ConversationModel.mapToConversationModel(conversation, user) + ) + + assertTrue(attendeePermissions.hasReactPermission()) + assertFalse(attendeePermissions.hasChatPermission()) + } + + @Test + fun test_reactPermissionFallbackToChat() { + val spreedCapability = SpreedCapability() + spreedCapability.features = listOf("chat-permission") + val conversation = createConversation() + + // Only CHAT permission set, no REACT - should still allow reactions for backward compatibility + conversation.permissions = ParticipantPermissions.CHAT or + ParticipantPermissions.CUSTOM + + val user = User() + user.id = 1 + + val attendeePermissions = + ParticipantPermissions( + spreedCapability, + ConversationModel.mapToConversationModel(conversation, user) + ) + + assertTrue(attendeePermissions.hasReactPermission()) + assertTrue(attendeePermissions.hasChatPermission()) + } + private fun createConversation() = Conversation( token = "test", From 1a873f94107a90976f7e41f0524f800f0a2efceb Mon Sep 17 00:00:00 2001 From: Florian Ludwig Date: Mon, 26 Jan 2026 13:26:33 +0100 Subject: [PATCH 2/5] fix(permissions): Use react-permission capability per migration guide - Add REACT_PERMISSION feature to SpreedFeatures enum - Update hasReactPermission() to check react-permission capability first - Fall back to chat-permission/CHAT bit for older servers - Expand unit tests for all capability scenarios Signed-off-by: Florian Ludwig --- .../nextcloud/talk/utils/CapabilitiesUtil.kt | 1 + .../talk/utils/ParticipantPermissions.kt | 11 ++-- .../talk/utils/ParticipantPermissionsTest.kt | 56 +++++++++++++++++-- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt index ac21acb5663..7e8f030873e 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt @@ -64,6 +64,7 @@ enum class SpreedFeatures(val value: String) { THREADS("threads"), PINNED_MESSAGES("pinned-messages"), SCHEDULED_MESSAGES("scheduled-messages") + REACT_PERMISSION("react-permission") } @Suppress("TooManyFunctions") diff --git a/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt b/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt index 46c8f36736d..1c0642a2f01 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt @@ -72,11 +72,14 @@ class ParticipantPermissions( } fun hasReactPermission(): Boolean { + if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.REACT_PERMISSION)) { + // Server supports separate react permission - check REACT bit + return hasReactPermission + } if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.CHAT_PERMISSION)) { - // Server supports granular permissions - check REACT bit - // Fall back to CHAT permission for backward compatibility with servers - // that have chat-permission capability but not yet the REACT permission split - return hasReactPermission || hasChatPermission + // Older server without react-permission capability - fall back to CHAT permission + // as that's what controlled reactions before the split + return hasChatPermission } // if capability is not available then the spreed version doesn't support to restrict this return true diff --git a/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt index 305f32ad19a..79e86e2df63 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt @@ -48,11 +48,12 @@ class ParticipantPermissionsTest : TestCase() { } @Test - fun test_reactPermissionSet() { + fun test_reactPermissionWithReactCapability() { val spreedCapability = SpreedCapability() - spreedCapability.features = listOf("chat-permission") + spreedCapability.features = listOf("react-permission") val conversation = createConversation() + // With react-permission capability, only REACT bit matters conversation.permissions = ParticipantPermissions.REACT or ParticipantPermissions.CUSTOM @@ -70,12 +71,36 @@ class ParticipantPermissionsTest : TestCase() { } @Test - fun test_reactPermissionFallbackToChat() { + fun test_reactPermissionDeniedWithReactCapability() { + val spreedCapability = SpreedCapability() + spreedCapability.features = listOf("react-permission") + val conversation = createConversation() + + // With react-permission capability, only CHAT but no REACT - should NOT allow reactions + conversation.permissions = ParticipantPermissions.CHAT or + ParticipantPermissions.CUSTOM + + val user = User() + user.id = 1 + + val attendeePermissions = + ParticipantPermissions( + spreedCapability, + ConversationModel.mapToConversationModel(conversation, user) + ) + + assertFalse(attendeePermissions.hasReactPermission()) + assertTrue(attendeePermissions.hasChatPermission()) + } + + @Test + fun test_reactPermissionFallbackToChatOnOlderServer() { val spreedCapability = SpreedCapability() + // Older server without react-permission capability but with chat-permission spreedCapability.features = listOf("chat-permission") val conversation = createConversation() - // Only CHAT permission set, no REACT - should still allow reactions for backward compatibility + // Only CHAT permission set - should allow reactions as fallback for older servers conversation.permissions = ParticipantPermissions.CHAT or ParticipantPermissions.CUSTOM @@ -92,6 +117,29 @@ class ParticipantPermissionsTest : TestCase() { assertTrue(attendeePermissions.hasChatPermission()) } + @Test + fun test_reactPermissionDeniedOnOlderServerWithoutChatPermission() { + val spreedCapability = SpreedCapability() + // Older server without react-permission capability but with chat-permission + spreedCapability.features = listOf("chat-permission") + val conversation = createConversation() + + // No CHAT permission - should deny reactions on older servers + conversation.permissions = ParticipantPermissions.CUSTOM + + val user = User() + user.id = 1 + + val attendeePermissions = + ParticipantPermissions( + spreedCapability, + ConversationModel.mapToConversationModel(conversation, user) + ) + + assertFalse(attendeePermissions.hasReactPermission()) + assertFalse(attendeePermissions.hasChatPermission()) + } + private fun createConversation() = Conversation( token = "test", From 18629f7336cf7577647994f0205f2bb987f9ba09 Mon Sep 17 00:00:00 2001 From: Florian Ludwig Date: Sat, 31 Jan 2026 16:59:25 +0100 Subject: [PATCH 3/5] feat(permissions): Update reaction permissions handling and add corresponding error message Signed-off-by: Florian Ludwig --- .../main/java/com/nextcloud/talk/chat/ChatActivity.kt | 4 ++-- .../com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt | 4 ++-- .../com/nextcloud/talk/utils/ParticipantPermissions.kt | 9 ++------- app/src/main/res/values/strings.xml | 1 + 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 3f6bf4e6312..37675da7832 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -3976,7 +3976,7 @@ class ChatActivity : override fun onClickReaction(chatMessage: ChatMessage, emoji: String) { if (!participantPermissions.hasReactPermission()) { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + Snackbar.make(binding.root, R.string.reaction_forbidden, Snackbar.LENGTH_LONG).show() return } VibrationUtils.vibrateShort(context) @@ -3997,7 +3997,7 @@ class ChatActivity : roomToken, chatMessage, conversationUser, - participantPermissions.hasChatPermission(), + participantPermissions.hasReactPermission(), ncApi ).show() } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt index 4c9c727af90..bd2370a5318 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt @@ -47,7 +47,7 @@ class ShowReactionsDialog( private val roomToken: String, private val chatMessage: ChatMessage, private val user: User?, - private val hasChatPermission: Boolean, + private val hasReactPermission: Boolean, private val ncApi: NcApi ) : BottomSheetDialog(activity), ReactionItemClickListener { @@ -185,7 +185,7 @@ class ShowReactionsDialog( } override fun onClick(reactionItem: ReactionItem) { - if (hasChatPermission && reactionItem.reactionVoter.actorId?.equals(user?.userId) == true) { + if (hasReactPermission && reactionItem.reactionVoter.actorId?.equals(user?.userId) == true) { deleteReaction(chatMessage, reactionItem.reaction!!) adapter?.list?.remove(reactionItem) dismiss() diff --git a/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt b/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt index 1c0642a2f01..536d99281ed 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt @@ -76,13 +76,8 @@ class ParticipantPermissions( // Server supports separate react permission - check REACT bit return hasReactPermission } - if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.CHAT_PERMISSION)) { - // Older server without react-permission capability - fall back to CHAT permission - // as that's what controlled reactions before the split - return hasChatPermission - } - // if capability is not available then the spreed version doesn't support to restrict this - return true + // Older server without react-permission capability - fall back to chat permission + return hasChatPermission() } companion object { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8dd0ea77f50..7b7d68a75ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -550,6 +550,7 @@ How to translate with transifex: Sharing files from storage is not possible without permissions Open in Files app You are not allowed to share content to this chat + You are not allowed to add or remove reactions in this conversation is typing … are typing … From 7d9ba974f07b391a09bbe046dec04149a629c94e Mon Sep 17 00:00:00 2001 From: Florian Ludwig Date: Sat, 31 Jan 2026 17:21:20 +0100 Subject: [PATCH 4/5] fix: better readable function name + syntax error Signed-off-by: Florian Ludwig --- .../com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt | 8 ++++---- .../java/com/nextcloud/talk/utils/CapabilitiesUtil.kt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt index bac17371469..6a03d67bdb3 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt @@ -267,7 +267,8 @@ class MessageActionsDialog( private fun initEmojiBar(hasReactPermission: Boolean) { if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.REACTIONS) && - isPermitted(hasReactPermission) && + hasReactPermission && + isConversationWritable() && isReactableMessageType(message) ) { val recentEmojiManager = RecentEmojiManager(context, MAX_RECENTS) @@ -354,9 +355,8 @@ class MessageActionsDialog( } } - private fun isPermitted(hasPermission: Boolean): Boolean = - hasPermission && - ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY != + private fun isConversationWritable(): Boolean = + ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY != currentConversation?.conversationReadOnlyState private fun isReactableMessageType(message: ChatMessage): Boolean = diff --git a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt index 7e8f030873e..67b2e9a4a40 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt @@ -63,7 +63,7 @@ enum class SpreedFeatures(val value: String) { IMPORTANT_CONVERSATIONS("important-conversations"), THREADS("threads"), PINNED_MESSAGES("pinned-messages"), - SCHEDULED_MESSAGES("scheduled-messages") + SCHEDULED_MESSAGES("scheduled-messages"), REACT_PERMISSION("react-permission") } From 4661e53c6e67e1a74aeed8e048ab55c286801640 Mon Sep 17 00:00:00 2001 From: Florian Ludwig Date: Sun, 1 Feb 2026 10:00:29 +0100 Subject: [PATCH 5/5] fix(test): Include chat-permission capability in react-permission tests Servers that support react-permission also support chat-permission since it is an older capability. Update test setup to reflect this realistic server configuration. Signed-off-by: Florian Ludwig --- .../com/nextcloud/talk/utils/ParticipantPermissionsTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt index 79e86e2df63..d66f51f4404 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt @@ -50,7 +50,8 @@ class ParticipantPermissionsTest : TestCase() { @Test fun test_reactPermissionWithReactCapability() { val spreedCapability = SpreedCapability() - spreedCapability.features = listOf("react-permission") + // Server with react-permission also supports chat-permission + spreedCapability.features = listOf("chat-permission", "react-permission") val conversation = createConversation() // With react-permission capability, only REACT bit matters @@ -73,7 +74,8 @@ class ParticipantPermissionsTest : TestCase() { @Test fun test_reactPermissionDeniedWithReactCapability() { val spreedCapability = SpreedCapability() - spreedCapability.features = listOf("react-permission") + // Server with react-permission also supports chat-permission + spreedCapability.features = listOf("chat-permission", "react-permission") val conversation = createConversation() // With react-permission capability, only CHAT but no REACT - should NOT allow reactions