diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index 3efbce1444..c699236955 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -158,15 +158,26 @@ Observable demoteModeratorToUser(@Header("Authorization") String @Url String url, @Query("participant") String participantId); + /** + * @param participantType Target level, or null to only toggle the moderator level. Owner (1) and + * moderator (2) when promoting, moderator (2) and user (3) when demoting. + * Requires the "promote-demote-owner" capability. + */ @POST Observable promoteAttendeeToModerator(@Header("Authorization") String authorization, @Url String url, - @Query("attendeeId") Long attendeeId); + @Query("attendeeId") Long attendeeId, + @Query("participantType") Integer participantType); + /** + * @param participantType Target level, or null to only toggle the moderator level. See + * {@link #promoteAttendeeToModerator}. + */ @DELETE Observable demoteAttendeeFromModerator(@Header("Authorization") String authorization, @Url String url, - @Query("attendeeId") Long attendeeId); + @Query("attendeeId") Long attendeeId, + @Query("participantType") Integer participantType); /* Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken/participants/self diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt index 6d3f5f706d..7b58159836 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt @@ -290,6 +290,8 @@ data class ChatMessage( GROUP_REMOVED, CIRCLE_ADDED, CIRCLE_REMOVED, + OWNER_PROMOTED, + OWNER_DEMOTED, MODERATOR_PROMOTED, MODERATOR_DEMOTED, GUEST_MODERATOR_PROMOTED, diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index cd27ca78af..6bd495101f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -14,7 +14,6 @@ import android.view.LayoutInflater import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.DrawableRes import androidx.appcompat.app.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState @@ -43,14 +42,14 @@ import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.activities.MainActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.bottomsheet.items.BasicListItemWithImage -import com.nextcloud.talk.bottomsheet.items.listItemsWithImage import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS import com.nextcloud.talk.contacts.ContactsActivity +import com.nextcloud.talk.conversationinfo.model.ParticipantModel import com.nextcloud.talk.conversationinfo.ui.ConversationInfoScreen import com.nextcloud.talk.conversationinfo.ui.ConversationInfoScreenCallbacks +import com.nextcloud.talk.conversationinfo.ui.ParticipantOpsAction import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationinfoedit.ConversationInfoEditActivity import com.nextcloud.talk.data.user.model.User @@ -70,7 +69,6 @@ import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.models.json.participants.Participant.ActorType.CIRCLES import com.nextcloud.talk.models.json.participants.Participant.ActorType.GROUPS -import com.nextcloud.talk.models.json.participants.Participant.ActorType.USERS import com.nextcloud.talk.models.json.upcomingEvents.UpcomingEvent import com.nextcloud.talk.shareditems.activities.SharedItemsActivity import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity @@ -91,6 +89,8 @@ import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.launch import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.json.JSONObject +import retrofit2.HttpException import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime @@ -312,7 +312,9 @@ class ConversationInfoActivity : BaseActivity() { onLockConversationClick = { conversationUser?.let { viewModel.toggleLock(it, conversationToken) } }, - onParticipantClick = { model -> handleParticipantClick(model.participant) }, + onParticipantClick = { model -> handleParticipantClick(model) }, + onParticipantOpsDismiss = { viewModel.setParticipantForOps(null) }, + onParticipantOpsAction = { action, model -> handleParticipantOpsAction(action, model) }, onAddParticipantsClick = { startGroupChat = false selectParticipantsToAdd() @@ -645,7 +647,8 @@ class ConversationInfoActivity : BaseActivity() { ncApi.demoteAttendeeFromModerator( credentials, ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), - participant.attendeeId + participant.attendeeId, + null )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) } else if (participant.type == Participant.ParticipantType.USER || participant.type == Participant.ParticipantType.GUEST @@ -653,7 +656,8 @@ class ConversationInfoActivity : BaseActivity() { ncApi.promoteAttendeeToModerator( credentials, ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), - participant.attendeeId + participant.attendeeId, + null )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) } } @@ -685,7 +689,8 @@ class ConversationInfoActivity : BaseActivity() { @SuppressLint("LongLogTag") override fun onError(e: Throwable) { - Log.e(TAG, "Error toggling moderator status", e) + Log.e(TAG, "Error changing the participant type", e) + showParticipantActionError(e) } override fun onComplete() { /* unused */ } } @@ -722,113 +727,85 @@ class ConversationInfoActivity : BaseActivity() { conversationUser?.let { viewModel.banActor(it, conversationToken, actorType, actorId, internalNote) } } - @SuppressLint("CheckResult", "StringFormatInvalid") + private fun handleParticipantClick(model: ParticipantModel) { + viewModel.setParticipantForOps(model) + } + @Suppress("ReturnCount") - private fun handleParticipantClick(participant: Participant) { + private fun handleParticipantOpsAction(action: ParticipantOpsAction, model: ParticipantModel) { val state = viewModel.uiState.value val conv = state.conversation ?: return val caps = state.spreedCapabilities ?: return if (!ConversationUtils.canModerate(conv, caps)) return val user = conversationUser ?: return + val participant = model.participant val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + when (action) { + ParticipantOpsAction.PromoteToModerator, + ParticipantOpsAction.DemoteFromModerator -> + if (apiVersion >= ApiUtils.API_V4) { + toggleModeratorStatus(apiVersion, participant) + } else { + toggleModeratorStatusLegacy(apiVersion, participant) + } - if (participant.calculatedActorType == USERS && participant.calculatedActorId == user.userId) { - if (participant.attendeePin?.isNotEmpty() == true) { - launchRemoveAttendeeFromConversationDialog( - participant, - apiVersion, - context.getString(R.string.nc_attendee_pin, participant.attendeePin), - R.drawable.ic_lock_grey600_24px - ) - } - } else if (participant.type == Participant.ParticipantType.OWNER) { - // Cannot moderate owner - } else if (participant.calculatedActorType == GROUPS) { - launchRemoveAttendeeFromConversationDialog( - participant, - apiVersion, - context.getString(R.string.nc_remove_group_and_members) - ) - } else if (participant.calculatedActorType == CIRCLES) { - launchRemoveAttendeeFromConversationDialog( - participant, - apiVersion, - context.getString(R.string.nc_remove_team_and_members) - ) - } else { - launchDefaultActions(participant, apiVersion) + ParticipantOpsAction.PromoteToOwner -> + changeParticipantType(apiVersion, participant, promote = true, PARTICIPANT_TYPE_OWNER) + + ParticipantOpsAction.DemoteOwnerToModerator -> + changeParticipantType(apiVersion, participant, promote = false, PARTICIPANT_TYPE_MODERATOR) + + ParticipantOpsAction.DemoteOwnerToUser -> + changeParticipantType(apiVersion, participant, promote = false, PARTICIPANT_TYPE_USER) + + ParticipantOpsAction.RemoveFromConversation -> removeAttendeeFromConversation(apiVersion, participant) + ParticipantOpsAction.Ban -> handleBan(participant) } } - @SuppressLint("CheckResult") - @Suppress("CyclomaticComplexMethod") - private fun launchDefaultActions(participant: Participant, apiVersion: Int) { - val items = getDefaultActionItems(participant) - if (CapabilitiesUtil.isBanningAvailable(conversationUser?.capabilities?.spreedCapability!!)) { - items.add(BasicListItemWithImage(R.drawable.baseline_block_24, context.getString(R.string.ban_participant))) - } - when (participant.type) { - Participant.ParticipantType.MODERATOR, Participant.ParticipantType.GUEST_MODERATOR -> items.removeAt(1) - Participant.ParticipantType.USER, Participant.ParticipantType.GUEST -> items.removeAt(2) - else -> { - items.removeAt(2) - items.removeAt(1) - } + private fun showParticipantActionError(e: Throwable) { + val messageRes = if (participantActionErrorReason(e) == ERROR_LAST_MODERATOR) { + R.string.nc_last_moderator_cannot_be_demoted + } else { + R.string.nc_participant_type_change_failed } - if (participant.attendeePin == null || participant.attendeePin!!.isEmpty()) items.removeAt(0) - if (items.isNotEmpty()) { - MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show { - cornerRadius(res = R.dimen.corner_radius) - title(text = participant.displayName) - listItemsWithImage(items = items) { _, index, _ -> - var actionToTrigger = index - if (participant.attendeePin == null || participant.attendeePin!!.isEmpty()) actionToTrigger++ - if (participant.type == Participant.ParticipantType.USER_FOLLOWING_LINK) actionToTrigger++ - when (actionToTrigger) { - DEMOTE_OR_PROMOTE -> { - if (apiVersion >= ApiUtils.API_V4) { - toggleModeratorStatus(apiVersion, participant) - } else { - toggleModeratorStatusLegacy(apiVersion, participant) - } - } - REMOVE_FROM_CONVERSATION -> removeAttendeeFromConversation(apiVersion, participant) - BAN_FROM_CONVERSATION -> handleBan(participant) - else -> { /* unused */ } - } - } - } + lifecycleScope.launch { viewModel.emitSnackbar(messageRes) } + } + + /** + * The moderators endpoint reports why it refused in the OCS data as + * `{"ocs":{"data":{"error":""}}}`. + */ + @Suppress("Detekt.TooGenericExceptionCaught") + private fun participantActionErrorReason(e: Throwable): String? = + try { + (e as? HttpException)?.response()?.errorBody()?.string() + ?.let { JSONObject(it) } + ?.optJSONObject("ocs") + ?.optJSONObject("data") + ?.optString("error") + ?.takeIf { it.isNotEmpty() } + } catch (exception: Exception) { + Log.w(TAG, "Could not read the participant action error", exception) + null } - } - - @SuppressLint("StringFormatInvalid") - private fun getDefaultActionItems(participant: Participant): MutableList = - mutableListOf( - BasicListItemWithImage( - R.drawable.ic_lock_grey600_24px, - context.getString(R.string.nc_attendee_pin, participant.attendeePin) - ), - BasicListItemWithImage(R.drawable.ic_pencil_grey600_24dp, context.getString(R.string.nc_promote)), - BasicListItemWithImage(R.drawable.ic_pencil_grey600_24dp, context.getString(R.string.nc_demote)), - BasicListItemWithImage(R.drawable.ic_delete_grey600_24dp, context.getString(R.string.nc_remove_participant)) - ) - @SuppressLint("CheckResult") - private fun launchRemoveAttendeeFromConversationDialog( - participant: Participant, + private fun changeParticipantType( apiVersion: Int, - itemText: String, - @DrawableRes itemIcon: Int = R.drawable.ic_delete_grey600_24dp + participant: Participant, + promote: Boolean, + participantType: Int ) { - MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show { - cornerRadius(res = R.dimen.corner_radius) - title(text = participant.displayName) - listItemsWithImage( - items = mutableListOf(BasicListItemWithImage(itemIcon, itemText)) - ) { _, index, _ -> - if (index == 0) removeAttendeeFromConversation(apiVersion, participant) - } + val user = conversationUser ?: return + val url = ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken) + val call = if (promote) { + ncApi.promoteAttendeeToModerator(credentials, url, participant.attendeeId, participantType) + } else { + ncApi.demoteAttendeeFromModerator(credentials, url, participant.attendeeId, participantType) } + call?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(participantActionObserver()) } private fun handleBan(participant: Participant) { @@ -861,8 +838,12 @@ class ConversationInfoActivity : BaseActivity() { companion object { private val TAG = ConversationInfoActivity::class.java.simpleName - private const val DEMOTE_OR_PROMOTE = 1 - private const val REMOVE_FROM_CONVERSATION = 2 - private const val BAN_FROM_CONVERSATION = 3 + + private const val ERROR_LAST_MODERATOR = "last-moderator" + + // Participant types the moderators endpoint accepts as a target level + private const val PARTICIPANT_TYPE_OWNER: Int = 1 + private const val PARTICIPANT_TYPE_MODERATOR: Int = 2 + private const val PARTICIPANT_TYPE_USER: Int = 3 } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoUiState.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoUiState.kt index 60a63582d8..4324d82527 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoUiState.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoUiState.kt @@ -71,6 +71,7 @@ data class ConversationInfoUiState( val showLockConversation: Boolean = false, val participants: List = emptyList(), + val participantForOps: ParticipantModel? = null, val showParticipants: Boolean = false, val showAddParticipants: Boolean = false, val showStartGroupChat: Boolean = false, diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/model/ParticipantModel.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/model/ParticipantModel.kt index 2021dabe94..baa8d91cfb 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/model/ParticipantModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/model/ParticipantModel.kt @@ -13,5 +13,6 @@ import com.nextcloud.talk.utils.ParticipantRole data class ParticipantModel( val participant: Participant, val isOnline: Boolean, - val role: ParticipantRole = ParticipantRole.NONE + val role: ParticipantRole = ParticipantRole.NONE, + val isSelf: Boolean = false ) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt index 278ae10719..ae74495883 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch @@ -52,6 +53,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -113,6 +115,8 @@ data class ConversationInfoScreenCallbacks( val onShareConversationClick: () -> Unit = {}, val onLockConversationClick: () -> Unit = {}, val onParticipantClick: (ParticipantModel) -> Unit = {}, + val onParticipantOpsDismiss: () -> Unit = {}, + val onParticipantOpsAction: (ParticipantOpsAction, ParticipantModel) -> Unit = { _, _ -> }, val onAddParticipantsClick: () -> Unit = {}, val onStartGroupChatClick: () -> Unit = {}, val onListBansClick: () -> Unit = {}, @@ -129,6 +133,7 @@ fun ConversationInfoScreen( state: ConversationInfoUiState, callbacks: ConversationInfoScreenCallbacks = ConversationInfoScreenCallbacks() ) { + val participantOpsSheetState = rememberModalBottomSheetState() Scaffold( contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), topBar = { @@ -247,6 +252,25 @@ fun ConversationInfoScreen( } } } + + val participantForOps = state.participantForOps + if (participantForOps != null) { + ModalBottomSheet( + onDismissRequest = callbacks.onParticipantOpsDismiss, + sheetState = participantOpsSheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + ParticipantOperationsContent( + model = participantForOps, + conversation = state.conversation, + spreedCapabilities = state.spreedCapabilities, + onAction = { action -> + callbacks.onParticipantOpsDismiss() + callbacks.onParticipantOpsAction(action, participantForOps) + } + ) + } + } } @Composable diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt new file mode 100644 index 0000000000..2ddda7ff3e --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt @@ -0,0 +1,395 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationinfo.ui + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.talk.R +import com.nextcloud.talk.conversationinfo.model.ParticipantModel +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.capabilities.SpreedCapability +import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.utils.CapabilitiesUtil +import com.nextcloud.talk.utils.ConversationUtils +import com.nextcloud.talk.utils.ParticipantRole +import com.nextcloud.talk.utils.ParticipantRoleUtils + +private data class RemoveOption(@DrawableRes val iconRes: Int, val label: String) + +private data class ParticipantOpsVisibility( + val infoPin: String? = null, + val showPromote: Boolean = false, + val showDemote: Boolean = false, + val showPromoteToOwner: Boolean = false, + val showDemoteOwnerToModerator: Boolean = false, + val showDemoteOwnerToUser: Boolean = false, + val remove: RemoveOption? = null, + val showBan: Boolean = false +) + +@Composable +@Suppress("LongMethod") +private fun computeVisibility( + model: ParticipantModel, + conversation: ConversationModel?, + spreedCapabilities: SpreedCapability? +): ParticipantOpsVisibility { + val participant = model.participant + val pin = participant.attendeePin?.takeIf { it.isNotEmpty() } + val deleteIcon = R.drawable.ic_delete_grey600_24dp + val canDemoteFromOwner = ParticipantRoleUtils.canBeDemotedFromOwner(participant, conversation, spreedCapabilities) + val isOwner = participant.type == Participant.ParticipantType.OWNER + val canModerate = conversation != null && ConversationUtils.canModerate(conversation, spreedCapabilities) + + return when { + // Without moderation rights the sheet is informational: the header and nothing else + !canModerate -> ParticipantOpsVisibility() + + model.isSelf -> ParticipantOpsVisibility( + infoPin = null, + showPromote = false, + showDemote = false, + showPromoteToOwner = false, + // An owner may step down, but only as far as moderator, to avoid locking themselves out + showDemoteOwnerToModerator = canDemoteFromOwner, + showDemoteOwnerToUser = false, + remove = pin?.let { + RemoveOption(R.drawable.ic_lock_grey600_24px, stringResource(R.string.nc_attendee_pin, it)) + }, + showBan = false + ) + + participant.calculatedActorType == Participant.ActorType.GROUPS -> ParticipantOpsVisibility( + infoPin = null, + showPromote = false, + showDemote = false, + showPromoteToOwner = false, + showDemoteOwnerToModerator = false, + showDemoteOwnerToUser = false, + remove = RemoveOption(deleteIcon, stringResource(R.string.nc_remove_group_and_members)), + showBan = false + ) + + participant.calculatedActorType == Participant.ActorType.CIRCLES -> ParticipantOpsVisibility( + infoPin = null, + showPromote = false, + showDemote = false, + showPromoteToOwner = false, + showDemoteOwnerToModerator = false, + showDemoteOwnerToUser = false, + remove = RemoveOption(deleteIcon, stringResource(R.string.nc_remove_team_and_members)), + showBan = false + ) + + isOwner -> ParticipantOpsVisibility( + infoPin = pin, + showPromote = false, + showDemote = false, + showPromoteToOwner = false, + showDemoteOwnerToModerator = canDemoteFromOwner, + showDemoteOwnerToUser = canDemoteFromOwner, + remove = null, + showBan = false + ) + + else -> ParticipantOpsVisibility( + infoPin = pin, + showPromote = participant.type == Participant.ParticipantType.USER || + participant.type == Participant.ParticipantType.GUEST, + showDemote = participant.type == Participant.ParticipantType.MODERATOR || + participant.type == Participant.ParticipantType.GUEST_MODERATOR, + showPromoteToOwner = ParticipantRoleUtils.canBePromotedToOwner( + participant, + conversation, + spreedCapabilities + ), + showDemoteOwnerToModerator = false, + showDemoteOwnerToUser = false, + remove = RemoveOption(deleteIcon, stringResource(R.string.nc_remove_participant)), + showBan = spreedCapabilities != null && CapabilitiesUtil.isBanningAvailable(spreedCapabilities) + ) + } +} + +@Composable +fun ParticipantOperationsContent( + model: ParticipantModel, + conversation: ConversationModel?, + spreedCapabilities: SpreedCapability?, + onAction: (ParticipantOpsAction) -> Unit +) { + val visibility = computeVisibility(model, conversation, spreedCapabilities) + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + ) { + ParticipantOpsHeader(model) + visibility.infoPin?.let { ParticipantOpsInfoRow(R.drawable.ic_lock_grey600_24px, it) } + // The icon shows the rank the action leads to, not the action itself + if (visibility.showPromoteToOwner) { + ParticipantOpsMenuItem(R.drawable.outline_crown_24, stringResource(R.string.nc_promote_to_owner)) { + onAction(ParticipantOpsAction.PromoteToOwner) + } + } + if (visibility.showPromote) { + ParticipantOpsMenuItem(R.drawable.outline_shield_24, stringResource(R.string.nc_promote)) { + onAction(ParticipantOpsAction.PromoteToModerator) + } + } + if (visibility.showDemoteOwnerToModerator) { + ParticipantOpsMenuItem( + R.drawable.outline_shield_24, + stringResource(R.string.nc_demote_owner_to_moderator) + ) { + onAction(ParticipantOpsAction.DemoteOwnerToModerator) + } + } + if (visibility.showDemoteOwnerToUser) { + ParticipantOpsMenuItem( + R.drawable.ic_baseline_person_24, + stringResource(R.string.nc_demote_owner_to_participant) + ) { + onAction(ParticipantOpsAction.DemoteOwnerToUser) + } + } + if (visibility.showDemote) { + ParticipantOpsMenuItem(R.drawable.ic_baseline_person_24, stringResource(R.string.nc_demote)) { + onAction(ParticipantOpsAction.DemoteFromModerator) + } + } + visibility.remove?.let { remove -> + ParticipantOpsMenuItem(remove.iconRes, remove.label) { + onAction(ParticipantOpsAction.RemoveFromConversation) + } + } + if (visibility.showBan) { + ParticipantOpsMenuItem(R.drawable.baseline_block_24, stringResource(R.string.ban_participant)) { + onAction(ParticipantOpsAction.Ban) + } + } + } +} + +@Composable +private fun ParticipantOpsHeader(model: ParticipantModel) { + val displayName = model.participant.displayName?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.nc_guest) + val roleLabelRes = ParticipantRoleUtils.labelRes(model.role) + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.standard_dialog_padding), + vertical = dimensionResource(R.dimen.standard_half_padding) + ) + ) { + Text( + text = displayName, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + if (roleLabelRes != null) { + Text( + text = stringResource(roleLabelRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun ParticipantOpsInfoRow(@DrawableRes iconRes: Int, label: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(dimensionResource(R.dimen.bottom_sheet_item_height)) + .padding(horizontal = dimensionResource(R.dimen.standard_dialog_padding)), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_dialog_padding))) + Text( + text = label, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Start + ) + } +} + +@Composable +private fun ParticipantOpsMenuItem(@DrawableRes iconRes: Int, label: String, onClick: () -> Unit) { + TextButton( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(dimensionResource(R.dimen.bottom_sheet_item_height)), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.standard_dialog_padding)), + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface) + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_dialog_padding))) + Text( + text = label, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Start + ) + } +} + +private fun previewParticipant( + displayName: String, + type: Participant.ParticipantType, + role: ParticipantRole, + attendeePin: String? = null, + isSelf: Boolean = false +) = ParticipantModel( + participant = Participant( + actorType = Participant.ActorType.USERS, + actorId = displayName.lowercase(), + displayName = displayName, + type = type, + attendeePin = attendeePin + ), + isOnline = true, + role = role, + isSelf = isSelf +) + +@Composable +private fun ParticipantOpsPreviewWrapper(content: @Composable () -> Unit) { + val colors = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colors) { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { + Column { content() } + } + } +} + +@Preview(showBackground = true, name = "Light") +@Preview(showBackground = true, name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, name = "RTL Arabic", locale = "ar") +@Composable +private fun ParticipantOperationsSheetModeratorPreview() { + ParticipantOpsPreviewWrapper { + ParticipantOperationsContent( + model = previewParticipant( + "Bob Smith", + Participant.ParticipantType.MODERATOR, + ParticipantRole.MODERATOR + ), + conversation = null, + spreedCapabilities = null, + onAction = {} + ) + } +} + +@Preview(showBackground = true, name = "Light") +@Preview(showBackground = true, name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ParticipantOperationsSheetUserWithPinPreview() { + ParticipantOpsPreviewWrapper { + ParticipantOperationsContent( + model = previewParticipant( + "Carol Danvers", + Participant.ParticipantType.USER, + ParticipantRole.NONE, + attendeePin = "123456" + ), + conversation = null, + spreedCapabilities = null, + onAction = {} + ) + } +} + +/** Without moderation rights the sheet only names the participant and their rank. */ +@Preview(showBackground = true, name = "Light") +@Preview(showBackground = true, name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ParticipantOperationsSheetInformationalPreview() { + ParticipantOpsPreviewWrapper { + ParticipantOperationsContent( + model = previewParticipant( + "Alice Johnson", + Participant.ParticipantType.OWNER, + ParticipantRole.OWNER + ), + conversation = null, + spreedCapabilities = null, + onAction = {} + ) + } +} + +@Preview(showBackground = true, name = "Light") +@Preview(showBackground = true, name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ParticipantOperationsSheetOwnerPreview() { + ParticipantOpsPreviewWrapper { + ParticipantOperationsContent( + model = previewParticipant( + "Alice Johnson", + Participant.ParticipantType.OWNER, + ParticipantRole.OWNER + ), + conversation = null, + spreedCapabilities = null, + onAction = {} + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOpsAction.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOpsAction.kt new file mode 100644 index 0000000000..ffce3c7f70 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOpsAction.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationinfo.ui + +sealed class ParticipantOpsAction { + object PromoteToOwner : ParticipantOpsAction() + object PromoteToModerator : ParticipantOpsAction() + object DemoteOwnerToModerator : ParticipantOpsAction() + object DemoteOwnerToUser : ParticipantOpsAction() + object DemoteFromModerator : ParticipantOpsAction() + object RemoveFromConversation : ParticipantOpsAction() + object Ban : ParticipantOpsAction() +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt index d9e4eeb202..dae28789b4 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt @@ -157,7 +157,7 @@ class ConversationInfoViewModel @Inject constructor( val role = ParticipantRoleUtils.roleOf(participant, conversationType) if (participant.calculatedActorType == USERS && participant.calculatedActorId == userId) { participant.sessionId = "-1" - ownUiItem = ParticipantModel(participant, true, role) + ownUiItem = ParticipantModel(participant, true, role, isSelf = true) } else { uiItems.add(ParticipantModel(participant, isOnline, role)) } @@ -820,6 +820,10 @@ class ConversationInfoViewModel @Inject constructor( _uiState.update { it.copy(upcomingEventSummary = summary, upcomingEventTime = time) } } + fun setParticipantForOps(model: ParticipantModel?) { + _uiState.update { it.copy(participantForOps = model) } + } + suspend fun emitSnackbar(@androidx.annotation.StringRes resId: Int) { _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(resId)) } diff --git a/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt b/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt index a67891eea2..984bb53aa8 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt @@ -114,6 +114,8 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter GROUP_REMOVED "circle_added" -> CIRCLE_ADDED "circle_removed" -> CIRCLE_REMOVED + "owner_promoted" -> ChatMessage.SystemMessageType.OWNER_PROMOTED + "owner_demoted" -> ChatMessage.SystemMessageType.OWNER_DEMOTED "moderator_promoted" -> MODERATOR_PROMOTED "moderator_demoted" -> MODERATOR_DEMOTED "guest_moderator_promoted" -> GUEST_MODERATOR_PROMOTED @@ -187,6 +189,8 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter "group_removed" CIRCLE_ADDED -> "circle_added" CIRCLE_REMOVED -> "circle_removed" + ChatMessage.SystemMessageType.OWNER_PROMOTED -> "owner_promoted" + ChatMessage.SystemMessageType.OWNER_DEMOTED -> "owner_demoted" MODERATOR_PROMOTED -> "moderator_promoted" MODERATOR_DEMOTED -> "moderator_demoted" GUEST_MODERATOR_PROMOTED -> "guest_moderator_promoted" 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 1218e569d5..1d1961c130 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt @@ -69,7 +69,8 @@ enum class SpreedFeatures(val value: String) { CONVERSATION_PRESETS("conversation-presets"), CLASSIFIED_CONVERSATIONS("classified-conversations"), ANNOUNCEMENT_PRESET("announcement-preset"), - CONVERSATION_TAGS("conversation-tags") + CONVERSATION_TAGS("conversation-tags"), + PROMOTE_DEMOTE_OWNER("promote-demote-owner") } @Suppress("TooManyFunctions") diff --git a/app/src/main/java/com/nextcloud/talk/utils/ParticipantRoleUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ParticipantRoleUtils.kt index 1e2cc18714..399fb8fb73 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ParticipantRoleUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ParticipantRoleUtils.kt @@ -9,6 +9,8 @@ package com.nextcloud.talk.utils import androidx.annotation.DrawableRes import androidx.annotation.StringRes import com.nextcloud.talk.R +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.capabilities.SpreedCapability import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.participants.Participant @@ -66,4 +68,69 @@ object ParticipantRoleUtils { ParticipantRole.MODERATOR -> R.drawable.outline_shield_24 ParticipantRole.NONE -> null } + + /** + * Conversation types in which the owner rank can be handed out. One-to-one conversations already + * have two owners by design, and note-to-self and changelog conversations only ever have one + * participant. + */ + private val OWNER_CHANGE_CONVERSATION_TYPES = setOf( + ConversationEnums.ConversationType.ROOM_GROUP_CALL, + ConversationEnums.ConversationType.ROOM_PUBLIC_CALL + ) + + /** + * Object types in which the owner rank can be handed out. Everything else binds the conversation + * to an object that already implies an owner, such as a share, a calendar event or a parent + * conversation. + * + * The server also allows its `classified_persist` and `extended_conversation` object types, which + * [ConversationEnums.ObjectType] does not model; those decode to + * [ConversationEnums.ObjectType.DEFAULT] here. + */ + private val OWNER_CHANGE_OBJECT_TYPES = setOf( + ConversationEnums.ObjectType.DEFAULT, + ConversationEnums.ObjectType.CLASSIFIED, + ConversationEnums.ObjectType.INSTANT_MEETING + ) + + /** `USER_FOLLOWING_LINK` is this client's name for the server's `USER_SELF_JOINED`. */ + private val PROMOTABLE_TO_OWNER = setOf( + Participant.ParticipantType.USER, + Participant.ParticipantType.USER_FOLLOWING_LINK, + Participant.ParticipantType.MODERATOR + ) + + /** + * Whether ownership can be handed over at all: only an owner may do it, only a user may hold it, + * and only in a conversation that is not bound to an object with an implicit owner. Mirrors + * `ParticipantService::updateParticipantTypeByModerator`, which enforces all of it again. + */ + fun canChangeOwnership( + participant: Participant, + conversation: ConversationModel?, + spreedCapabilities: SpreedCapability? + ): Boolean = + CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.PROMOTE_DEMOTE_OWNER) && + conversation != null && + conversation.participantType == Participant.ParticipantType.OWNER && + conversation.type in OWNER_CHANGE_CONVERSATION_TYPES && + conversation.objectType in OWNER_CHANGE_OBJECT_TYPES && + participant.calculatedActorType == Participant.ActorType.USERS + + fun canBePromotedToOwner( + participant: Participant, + conversation: ConversationModel?, + spreedCapabilities: SpreedCapability? + ): Boolean = + canChangeOwnership(participant, conversation, spreedCapabilities) && + participant.type in PROMOTABLE_TO_OWNER + + fun canBeDemotedFromOwner( + participant: Participant, + conversation: ConversationModel?, + spreedCapabilities: SpreedCapability? + ): Boolean = + canChangeOwnership(participant, conversation, spreedCapabilities) && + participant.type == Participant.ParticipantType.OWNER } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1337a70c92..4331310917 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -460,6 +460,16 @@ How to translate with transifex: Demote from moderator Promote to moderator + + Promote to owner + + Demote from owner to moderator + + Demote from owner to participant + + Could not change the type of the participant + + The last moderator of a conversation can not be demoted Remove participant Remove team and members Remove group and members diff --git a/app/src/test/java/com/nextcloud/talk/utils/ParticipantRoleUtilsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/ParticipantRoleUtilsTest.kt index 16aa842b93..c3c3e2b936 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/ParticipantRoleUtilsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/ParticipantRoleUtilsTest.kt @@ -6,11 +6,14 @@ */ package com.nextcloud.talk.utils +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.capabilities.SpreedCapability import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.participants.Participant import org.junit.Assert import org.junit.Test +@Suppress("TooManyFunctions") class ParticipantRoleUtilsTest { private fun participant(type: Participant.ParticipantType): Participant = @@ -116,4 +119,223 @@ class ParticipantRoleUtilsTest { Assert.assertNull(ParticipantRoleUtils.labelRes(ParticipantRole.NONE)) Assert.assertNull(ParticipantRoleUtils.iconRes(ParticipantRole.NONE)) } + + // region canChangeOwnership + + private fun capabilities(vararg features: String) = + SpreedCapability(features = features.toList(), config = null, version = "") + + private val ownerCapability = capabilities(SpreedFeatures.PROMOTE_DEMOTE_OWNER.value) + + private fun conversation( + selfType: Participant.ParticipantType = Participant.ParticipantType.OWNER, + type: ConversationEnums.ConversationType = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + objectType: ConversationEnums.ObjectType = ConversationEnums.ObjectType.DEFAULT + ) = ConversationModel( + internalId = "1@token", + accountId = 1L, + token = "token", + name = "conversation", + displayName = "Conversation", + description = "", + type = type, + participantType = selfType, + sessionId = "", + actorId = "self", + actorType = "users", + objectType = objectType, + notificationLevel = ConversationEnums.NotificationLevel.DEFAULT, + conversationReadOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_WRITE, + lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS, + lobbyTimer = 0L, + canLeaveConversation = true, + canDeleteConversation = true, + unreadMentionDirect = false, + notificationCalls = 0, + avatarVersion = "", + hasCustomAvatar = false, + callStartTime = 0L + ) + + private fun actor( + type: Participant.ParticipantType = Participant.ParticipantType.USER, + actorType: Participant.ActorType = Participant.ActorType.USERS + ) = Participant(actorType = actorType, actorId = "alice", displayName = "Alice", type = type) + + @Test + fun testCanChangeOwnership_whenEverythingLinesUp_isTrue() { + Assert.assertTrue(ParticipantRoleUtils.canChangeOwnership(actor(), conversation(), ownerCapability)) + } + + @Test + fun testCanChangeOwnership_withoutTheCapability_isFalse() { + Assert.assertFalse(ParticipantRoleUtils.canChangeOwnership(actor(), conversation(), capabilities())) + Assert.assertFalse(ParticipantRoleUtils.canChangeOwnership(actor(), conversation(), null)) + } + + @Test + fun testCanChangeOwnership_whenSelfIsNotOwner_isFalse() { + val asModerator = conversation(selfType = Participant.ParticipantType.MODERATOR) + Assert.assertFalse(ParticipantRoleUtils.canChangeOwnership(actor(), asModerator, ownerCapability)) + } + + @Test + fun testCanChangeOwnership_withoutAConversation_isFalse() { + Assert.assertFalse(ParticipantRoleUtils.canChangeOwnership(actor(), null, ownerCapability)) + } + + @Test + fun testCanChangeOwnership_inPublicConversation_isTrue() { + val public = conversation(type = ConversationEnums.ConversationType.ROOM_PUBLIC_CALL) + Assert.assertTrue(ParticipantRoleUtils.canChangeOwnership(actor(), public, ownerCapability)) + } + + @Test + fun testCanChangeOwnership_inConversationTypesWithoutRanks_isFalse() { + val rankless = listOf( + ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + ConversationEnums.ConversationType.FORMER_ONE_TO_ONE, + ConversationEnums.ConversationType.ROOM_SYSTEM, + ConversationEnums.ConversationType.NOTE_TO_SELF + ) + rankless.forEach { type -> + Assert.assertFalse( + "owner change must not be offered in $type", + ParticipantRoleUtils.canChangeOwnership(actor(), conversation(type = type), ownerCapability) + ) + } + } + + @Test + fun testCanChangeOwnership_inAllowedObjectTypes_isTrue() { + val allowed = listOf( + ConversationEnums.ObjectType.DEFAULT, + ConversationEnums.ObjectType.CLASSIFIED, + ConversationEnums.ObjectType.INSTANT_MEETING + ) + allowed.forEach { objectType -> + Assert.assertTrue( + "owner change must be offered for $objectType", + ParticipantRoleUtils.canChangeOwnership( + actor(), + conversation(objectType = objectType), + ownerCapability + ) + ) + } + } + + @Test + fun testCanChangeOwnership_inObjectTypesWithAnImplicitOwner_isFalse() { + val bound = listOf( + ConversationEnums.ObjectType.SHARE_PASSWORD, + ConversationEnums.ObjectType.FILE, + ConversationEnums.ObjectType.ROOM, + ConversationEnums.ObjectType.EVENT, + ConversationEnums.ObjectType.PHONE_TEMPORARY, + ConversationEnums.ObjectType.PHONE_PERSIST + ) + bound.forEach { objectType -> + Assert.assertFalse( + "owner change must not be offered for $objectType", + ParticipantRoleUtils.canChangeOwnership( + actor(), + conversation(objectType = objectType), + ownerCapability + ) + ) + } + } + + @Test + fun testCanChangeOwnership_forActorsThatCanNeverBeOwners_isFalse() { + val nonUsers = listOf( + Participant.ActorType.GUESTS, + Participant.ActorType.EMAILS, + Participant.ActorType.GROUPS, + Participant.ActorType.CIRCLES, + Participant.ActorType.FEDERATED, + Participant.ActorType.PHONES + ) + nonUsers.forEach { actorType -> + Assert.assertFalse( + "$actorType can never hold the owner rank", + ParticipantRoleUtils.canChangeOwnership( + actor(actorType = actorType), + conversation(), + ownerCapability + ) + ) + } + } + + // endregion + + // region canBePromotedToOwner / canBeDemotedFromOwner + + @Test + fun testCanBePromotedToOwner_forPromotableRanks_isTrue() { + val promotable = listOf( + Participant.ParticipantType.USER, + Participant.ParticipantType.USER_FOLLOWING_LINK, + Participant.ParticipantType.MODERATOR + ) + promotable.forEach { type -> + Assert.assertTrue( + "$type should be promotable to owner", + ParticipantRoleUtils.canBePromotedToOwner(actor(type), conversation(), ownerCapability) + ) + } + } + + @Test + fun testCanBePromotedToOwner_forRanksThatCannotBe_isFalse() { + val notPromotable = listOf( + Participant.ParticipantType.OWNER, + Participant.ParticipantType.GUEST, + Participant.ParticipantType.GUEST_MODERATOR, + Participant.ParticipantType.DUMMY + ) + notPromotable.forEach { type -> + Assert.assertFalse( + "$type should not be promotable to owner", + ParticipantRoleUtils.canBePromotedToOwner(actor(type), conversation(), ownerCapability) + ) + } + } + + @Test + fun testCanBeDemotedFromOwner_onlyAppliesToOwners() { + Assert.assertTrue( + ParticipantRoleUtils.canBeDemotedFromOwner( + actor(Participant.ParticipantType.OWNER), + conversation(), + ownerCapability + ) + ) + Assert.assertFalse( + ParticipantRoleUtils.canBeDemotedFromOwner( + actor(Participant.ParticipantType.MODERATOR), + conversation(), + ownerCapability + ) + ) + } + + @Test + fun testOwnerActions_withoutTheCapability_areAllFalse() { + val noCapability = capabilities() + Assert.assertFalse( + ParticipantRoleUtils.canBePromotedToOwner(actor(), conversation(), noCapability) + ) + Assert.assertFalse( + ParticipantRoleUtils.canBeDemotedFromOwner( + actor(Participant.ParticipantType.OWNER), + conversation(), + noCapability + ) + ) + } + + // endregion }