diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index 529aaef49..acdc00d5c 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -2803,6 +2803,11 @@ import SwiftUI // MARK: - Reactions func addReaction(reaction: String, to message: NCChatMessage) { + if !self.room.canReact { + NotificationPresenter.shared().present(text: NSLocalizedString("You are not allowed to add or remove reactions in this conversation", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error) + return + } + if message.reactionsArray().contains(where: { $0.reaction == reaction && $0.userReacted }) { // We can't add reaction twice return @@ -2825,6 +2830,11 @@ import SwiftUI } func removeReaction(reaction: String, from message: NCChatMessage) { + if !self.room.canReact { + NotificationPresenter.shared().present(text: NSLocalizedString("You are not allowed to add or remove reactions in this conversation", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error) + return + } + self.setTemporaryReaction(reaction: reaction, withState: .removing, toMessage: message) NCAPIController.sharedInstance().removeReaction(reaction, fromMessage: message.messageId, inRoom: self.room.token, for: self.account) { _, error, _ in diff --git a/NextcloudTalk/Chat/ChatViewController.swift b/NextcloudTalk/Chat/ChatViewController.swift index 9f9127a49..a247bbb7f 100644 --- a/NextcloudTalk/Chat/ChatViewController.swift +++ b/NextcloudTalk/Chat/ChatViewController.swift @@ -775,7 +775,7 @@ import SwiftUI // Disable call buttons self.callOptionsButton.isEnabled = false - } else if NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityChatPermission, for: room), !room.permissions.contains(.chat) { + } else if !room.canChat { // Hide text input self.setTextInputbarHidden(true, animated: isVisible) } else if self.isTextInputbarHidden { @@ -2311,9 +2311,7 @@ import SwiftUI } override func getContextMenuAccessoryView(forMessage message: NCChatMessage, forIndexPath indexPath: IndexPath, withCellHeight cellHeight: CGFloat) -> UIView? { - let hasChatPermissions = !NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityChatPermission, for: room) || self.room.permissions.contains(.chat) - - guard hasChatPermissions && self.isMessageReactable(message: message) else { return nil } + guard self.room.canReact && self.isMessageReactable(message: message) else { return nil } let reactionViewPadding = 10 let emojiButtonPadding = 10 @@ -2432,7 +2430,6 @@ import SwiftUI var actions: [UIMenuElement] = [] var informationalActions: [UIMenuElement] = [] - let hasChatPermissions = !NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityChatPermission, for: room) || self.room.permissions.contains(.chat) // Show edit information if let lastEditActorDisplayName = message.lastEditActorDisplayName, message.lastEditTimestamp > 0 { @@ -2457,14 +2454,14 @@ import SwiftUI } // Reply option - if self.isMessageReplyable(message: message), hasChatPermissions, !self.textInputbar.isEditing { + if self.isMessageReplyable(message: message), self.room.canChat, !self.textInputbar.isEditing { actions.append(UIAction(title: NSLocalizedString("Reply", comment: ""), image: .init(systemName: "arrowshape.turn.up.left")) { _ in self.didPressReply(for: message) }) } // Show "Add reaction" when running on MacOS because we don't have an accessory view - if self.isMessageReactable(message: message), hasChatPermissions, NCUtils.isiOSAppOnMac() { + if self.isMessageReactable(message: message), self.room.canReact, NCUtils.isiOSAppOnMac() { actions.append(UIAction(title: NSLocalizedString("Add reaction", comment: ""), image: .init(systemName: "face.smiling")) { _ in self.didPressAddReaction(for: message, at: indexPath) }) @@ -2538,7 +2535,7 @@ import SwiftUI } // Re-send option - if (message.sendingFailed || message.isOfflineMessage) && hasChatPermissions { + if (message.sendingFailed || message.isOfflineMessage) && self.room.canChat { actions.append(UIAction(title: NSLocalizedString("Resend", comment: ""), image: .init(systemName: "arrow.clockwise")) { _ in self.didPressResend(for: message) }) @@ -2609,14 +2606,14 @@ import SwiftUI var destructiveMenuActions: [UIMenuElement] = [] // Edit option - if message.isEditable(for: self.account, in: self.room) && hasChatPermissions { + if message.isEditable(for: self.account, in: self.room) && self.room.canChat { destructiveMenuActions.append(UIAction(title: NSLocalizedString("Edit", comment: "Edit a message or room participants"), image: .init(systemName: "pencil")) { _ in self.didPressEdit(for: message) }) } // Delete option - if message.sendingFailed || message.isOfflineMessage || (message.isDeletable(for: self.account, in: self.room) && hasChatPermissions) { + if message.sendingFailed || message.isOfflineMessage || (message.isDeletable(for: self.account, in: self.room) && self.room.canChat) { destructiveMenuActions.append(UIAction(title: NSLocalizedString("Delete", comment: ""), image: .init(systemName: "trash"), attributes: .destructive) { _ in self.didPressDelete(for: message) }) diff --git a/NextcloudTalk/Database/NCDatabaseManager.h b/NextcloudTalk/Database/NCDatabaseManager.h index 652030687..c94799979 100644 --- a/NextcloudTalk/Database/NCDatabaseManager.h +++ b/NextcloudTalk/Database/NCDatabaseManager.h @@ -87,6 +87,7 @@ extern NSString * const kCapabilitySensitiveConversations; extern NSString * const kCapabilityThreads; extern NSString * const kCapabilityPinnedMessages; extern NSString * const kCapabilityScheduleMessages; +extern NSString * const kCapabilityReactPermission; extern NSString * const kNotificationsCapabilityExists; extern NSString * const kNotificationsCapabilityTestPush; diff --git a/NextcloudTalk/Database/NCDatabaseManager.m b/NextcloudTalk/Database/NCDatabaseManager.m index a73cf5c23..ed20f7c75 100644 --- a/NextcloudTalk/Database/NCDatabaseManager.m +++ b/NextcloudTalk/Database/NCDatabaseManager.m @@ -88,6 +88,7 @@ NSString * const kCapabilityThreads = @"threads"; NSString * const kCapabilityPinnedMessages = @"pinned-messages"; NSString * const kCapabilityScheduleMessages = @"scheduled-messages"; +NSString * const kCapabilityReactPermission = @"react-permission"; NSString * const kNotificationsCapabilityExists = @"exists"; NSString * const kNotificationsCapabilityTestPush = @"test-push"; diff --git a/NextcloudTalk/NCTypes.h b/NextcloudTalk/NCTypes.h index cec32d403..b16f697de 100644 --- a/NextcloudTalk/NCTypes.h +++ b/NextcloudTalk/NCTypes.h @@ -94,6 +94,7 @@ typedef NS_OPTIONS(NSInteger, NCPermission) { NCPermissionCanPublishVideo = 32, NCPermissionCanPublishScreen = 64, NCPermissionChat = 128, + NCPermissionReact = 256, }; typedef NS_ENUM(NSInteger, NCMessageExpiration) { diff --git a/NextcloudTalk/Rooms/NCRoom.swift b/NextcloudTalk/Rooms/NCRoom.swift index 6c0876e35..e0f32a969 100644 --- a/NextcloudTalk/Rooms/NCRoom.swift +++ b/NextcloudTalk/Rooms/NCRoom.swift @@ -348,5 +348,19 @@ import SwiftyAttributes return self.permissions.contains(.canPublishScreen) || !supportsConversationPermissions } + public var canChat: Bool { + // For very old servers without chat-permission capability, allow chat + return !NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityChatPermission, for: self) || self.permissions.contains(.chat) + } + + public var canReact: Bool { + // Check if server supports separate react permission (Talk 24+) + if NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityReactPermission, for: self) { + return self.permissions.contains(.react) + } + + // Fallback for older servers: reactions were tied to chat permission + return self.canChat + } } diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index 2b254cc0a..0002d97a1 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -2438,6 +2438,9 @@ /* No comment provided by engineer. */ "You are currently waiting in the lobby" = "You are currently waiting in the lobby"; +/* No comment provided by engineer. */ +"You are not allowed to add or remove reactions in this conversation" = "You are not allowed to add or remove reactions in this conversation"; + /* No comment provided by engineer. */ "You are not part of any conversation" = "You are not part of any conversation"; diff --git a/NextcloudTalkTests/UI/UIRoomTest.swift b/NextcloudTalkTests/UI/UIRoomTest.swift index 8786b48c4..00ac7e167 100644 --- a/NextcloudTalkTests/UI/UIRoomTest.swift +++ b/NextcloudTalkTests/UI/UIRoomTest.swift @@ -284,4 +284,43 @@ final class UIRoomTest: XCTestCase { let textView = toolbar.textViews["Write message, @ to mention someone …"] XCTAssert(!textView.exists) } + + func testReactOnlyPermission() throws { + let app = launchAndLogin() + + // ReactOnlyTest room is only created for Talk 24+ (main branch) + let reactOnlyCell = app.tables.cells.staticTexts["ReactOnlyTest"] + + // Skip test if the room doesn't exist (older server versions) + try XCTSkipUnless(reactOnlyCell.waitForExistence(timeout: TestConstants.timeoutShort), + "ReactOnlyTest room not found - skipping (requires Talk 24+)") + + reactOnlyCell.tap() + + let chatNavBar = app.navigationBars["NextcloudTalk.ChatView"] + XCTAssert(chatNavBar.waitForExistence(timeout: TestConstants.timeoutLong)) + + // Find the message from alice that we should react to + let messageText = app.tables.textViews["React to this message!"].firstMatch + XCTAssert(messageText.waitForExistence(timeout: TestConstants.timeoutShort)) + + // Open context menu by long-pressing on the message + messageText.press(forDuration: 2.0) + + // Tap the thumbs up reaction from the context menu (same pattern as testDeallocation) + let thumbsUpReaction = app.staticTexts["👍"] + XCTAssert(thumbsUpReaction.waitForExistence(timeout: TestConstants.timeoutShort)) + thumbsUpReaction.tap() + + // Verify the reaction was added - the reaction label shows "emoji count" format + let reactionLabel = app.staticTexts["👍 1"] + XCTAssert(reactionLabel.waitForExistence(timeout: TestConstants.timeoutShort), + "Reaction should be visible after adding it") + + // Verify that we cannot send messages (no chat permission) + // The toolbar/text input should not be present when user cannot chat + let toolbar = app.toolbars["Toolbar"] + let textView = toolbar.textViews["Write message, @ to mention someone …"] + XCTAssertFalse(textView.exists, "Text input should not exist when user cannot chat") + } } diff --git a/ShareExtension/ShareViewController.m b/ShareExtension/ShareViewController.m index 6eaf1b37f..81406c372 100644 --- a/ShareExtension/ShareViewController.m +++ b/ShareExtension/ShareViewController.m @@ -509,9 +509,7 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath return; } - BOOL hasChatPermission = ![[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityChatPermission] || (room.permissions & NCPermissionChat) != 0; - - if (!hasChatPermission || room.readOnlyState == NCRoomReadOnlyStateReadOnly) { + if (!room.canChat || room.readOnlyState == NCRoomReadOnlyStateReadOnly) { [self showChatPermissionAlert]; return; } diff --git a/ci-setup-rooms.sh b/ci-setup-rooms.sh index 552c55dd4..fff4f9eb3 100755 --- a/ci-setup-rooms.sh +++ b/ci-setup-rooms.sh @@ -5,8 +5,20 @@ # This script is intended to setup specific rooms that we want to test +SERVER_URL="http://localhost:8080" + +# Check if a Talk capability is available +# Usage: has_capability "capability-name" +has_capability() { + local capability="$1" + curl -s -u admin:admin "$SERVER_URL/ocs/v2.php/cloud/capabilities" \ + -H "OCS-APIRequest: true" \ + -H 'accept: application/json, text/plain, */*' \ + | jq -e ".ocs.data.capabilities.spreed.features | any(. == \"$capability\")" > /dev/null 2>&1 +} + # Setup a room with lobby enabled and add admin as a normal participant -response=$(curl -u alice:alice 'http://localhost:8080/ocs/v2.php/apps/spreed/api/v4/room' \ +response=$(curl -u alice:alice "$SERVER_URL/ocs/v2.php/apps/spreed/api/v4/room" \ -H "OCS-APIRequest: true" \ -H 'content-type: application/json' \ -H 'accept: application/json, text/plain, */*' \ @@ -18,15 +30,60 @@ token=$(echo $response | jq -r .ocs.data.token) echo $token -curl -u alice:alice "http://localhost:8080/ocs/v2.php/apps/spreed/api/v4/room/$token/webinar/lobby" \ +curl -u alice:alice "$SERVER_URL/ocs/v2.php/apps/spreed/api/v4/room/$token/webinar/lobby" \ -X 'PUT' \ -H "OCS-APIRequest: true" \ -H 'content-type: application/json' \ -H 'accept: application/json, text/plain, */*' \ --data-raw '{"state":1}' -curl -u alice:alice "http://localhost:8080/ocs/v2.php/apps/spreed/api/v4/room/$token/participants" \ +curl -u alice:alice "$SERVER_URL/ocs/v2.php/apps/spreed/api/v4/room/$token/participants" \ -H "OCS-APIRequest: true" \ -H 'content-type: application/json' \ -H 'accept: application/json, text/plain, */*' \ --data-raw '{"newParticipant":"admin","source":"users"}' + +# Setup a room with react-only permission (can react but cannot chat) +# Only available when server supports react-permission capability (Talk 24+) +if has_capability "react-permission"; then + echo "Setting up ReactOnlyTest room (react-permission capability detected)" + + # Permission values: CustomPermissions=1 (auto-added), JoinCall=4, React=256 + # Total: 260 (4 + 256, custom flag auto-added when non-zero) + response=$(curl -u alice:alice "$SERVER_URL/ocs/v2.php/apps/spreed/api/v4/room" \ + -H "OCS-APIRequest: true" \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/plain, */*' \ + --data-raw '{"roomType":2,"roomName":"ReactOnlyTest"}') + + echo $response + + token=$(echo $response | jq -r .ocs.data.token) + + echo "ReactOnlyTest token: $token" + + # Set default permissions to react-only (no chat permission) + # 260 = JoinCall(4) + React(256) + curl -u alice:alice "$SERVER_URL/ocs/v2.php/apps/spreed/api/v4/room/$token/permissions/default" \ + -X 'PUT' \ + -H "OCS-APIRequest: true" \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/plain, */*' \ + --data-raw '{"permissions":260}' + + # Add admin as a participant (will get the default react-only permissions) + curl -u alice:alice "$SERVER_URL/ocs/v2.php/apps/spreed/api/v4/room/$token/participants" \ + -H "OCS-APIRequest: true" \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/plain, */*' \ + --data-raw '{"newParticipant":"admin","source":"users"}' + + # Send a message from alice (moderator) so there's something to react to + curl -X POST -u alice:alice "$SERVER_URL/ocs/v2.php/apps/spreed/api/v1/chat/$token" \ + -H "OCS-APIRequest: true" \ + -H 'content-type: application/json' \ + -H 'accept: application/json' \ + --data-raw '{"message":"React to this message!"}' +else + echo "Skipping ReactOnlyTest room setup (react-permission capability not available)" +fi