From a39d3772b92adef72b19d770f5ea45a61e802695 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 24 Aug 2026 13:26:55 +0200 Subject: [PATCH 1/7] refactor(conversationinfo): Migrate the participant sheet to ModalBottomSheet The participant actions sheet was the last MaterialDialog bottom sheet in the conversation info screen. Bring it in line with the conversation list, following ConversationOperationsSheet as the reference. This drops the old index arithmetic, which built a fixed item list, removed entries by position and then incremented the tapped index back over the removals to work out which action was meant - so any new row silently rewired the ones below it. Actions are now a typed ParticipantOpsAction and row visibility lives in computeVisibility(). The sheet is state-driven via ConversationInfoUiState.participantForOps instead of assembling dialogs. The rebuilt header also shows the participant's role under the display name. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Andy Scherzinger --- .../ConversationInfoActivity.kt | 125 ++----- .../ConversationInfoUiState.kt | 1 + .../model/ParticipantModel.kt | 3 +- .../ui/ConversationInfoScreen.kt | 23 ++ .../ui/ParticipantOperationsSheet.kt | 309 ++++++++++++++++++ .../ui/ParticipantOpsAction.kt | 15 + .../viewmodel/ConversationInfoViewModel.kt | 6 +- 7 files changed, 376 insertions(+), 106 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt create mode 100644 app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOpsAction.kt 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..09b2402738 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 @@ -312,7 +310,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() @@ -722,112 +722,32 @@ class ConversationInfoActivity : BaseActivity() { conversationUser?.let { viewModel.banActor(it, conversationToken, actorType, actorId, internalNote) } } - @SuppressLint("CheckResult", "StringFormatInvalid") @Suppress("ReturnCount") - private fun handleParticipantClick(participant: Participant) { + private fun handleParticipantClick(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 apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + if (model.participant.type == Participant.ParticipantType.OWNER && !model.isSelf) return - 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) - } + viewModel.setParticipantForOps(model) } - @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) - } - } - 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 */ } - } + private fun handleParticipantOpsAction(action: ParticipantOpsAction, model: ParticipantModel) { + 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) } - } - } - } - - @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, - apiVersion: Int, - itemText: String, - @DrawableRes itemIcon: Int = R.drawable.ic_delete_grey600_24dp - ) { - 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) - } + ParticipantOpsAction.RemoveFromConversation -> removeAttendeeFromConversation(apiVersion, participant) + ParticipantOpsAction.Ban -> handleBan(participant) } } @@ -861,8 +781,5 @@ 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 } } 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..efc8c89851 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,24 @@ fun ConversationInfoScreen( } } } + + val participantForOps = state.participantForOps + if (participantForOps != null) { + ModalBottomSheet( + onDismissRequest = callbacks.onParticipantOpsDismiss, + sheetState = participantOpsSheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + ParticipantOperationsContent( + model = participantForOps, + 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..01b000ef8b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt @@ -0,0 +1,309 @@ +/* + * 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.json.capabilities.SpreedCapability +import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.utils.CapabilitiesUtil +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?, + val showPromote: Boolean, + val showDemote: Boolean, + val remove: RemoveOption?, + val showBan: Boolean +) + +@Composable +private fun computeVisibility( + model: ParticipantModel, + spreedCapabilities: SpreedCapability? +): ParticipantOpsVisibility { + val participant = model.participant + val pin = participant.attendeePin?.takeIf { it.isNotEmpty() } + val deleteIcon = R.drawable.ic_delete_grey600_24dp + + return when { + model.isSelf -> ParticipantOpsVisibility( + infoPin = null, + showPromote = false, + showDemote = 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, + 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, + remove = RemoveOption(deleteIcon, stringResource(R.string.nc_remove_team_and_members)), + 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, + remove = RemoveOption(deleteIcon, stringResource(R.string.nc_remove_participant)), + showBan = spreedCapabilities != null && CapabilitiesUtil.isBanningAvailable(spreedCapabilities) + ) + } +} + +@Composable +fun ParticipantOperationsContent( + model: ParticipantModel, + spreedCapabilities: SpreedCapability?, + onAction: (ParticipantOpsAction) -> Unit +) { + val visibility = computeVisibility(model, spreedCapabilities) + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + ) { + ParticipantOpsHeader(model) + visibility.infoPin?.let { ParticipantOpsInfoRow(R.drawable.ic_lock_grey600_24px, it) } + if (visibility.showPromote) { + ParticipantOpsMenuItem(R.drawable.ic_pencil_grey600_24dp, stringResource(R.string.nc_promote)) { + onAction(ParticipantOpsAction.PromoteToModerator) + } + } + if (visibility.showDemote) { + ParticipantOpsMenuItem(R.drawable.ic_pencil_grey600_24dp, 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 + ), + 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" + ), + 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 + ), + 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..38533ee589 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOpsAction.kt @@ -0,0 +1,15 @@ +/* + * 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 PromoteToModerator : 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)) } From 50406a83f0567ffe5c5a9f5fb7a62271b957b392 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 24 Aug 2026 16:29:46 +0200 Subject: [PATCH 2/7] feat(moderators): Show the target rank on the promote and demote rows for the participant action bottom sheet Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Andy Scherzinger --- .../talk/conversationinfo/ui/ParticipantOperationsSheet.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 01b000ef8b..8e4bd1ed9e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt @@ -120,13 +120,14 @@ fun ParticipantOperationsContent( ) { 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.showPromote) { - ParticipantOpsMenuItem(R.drawable.ic_pencil_grey600_24dp, stringResource(R.string.nc_promote)) { + ParticipantOpsMenuItem(R.drawable.outline_shield_24, stringResource(R.string.nc_promote)) { onAction(ParticipantOpsAction.PromoteToModerator) } } if (visibility.showDemote) { - ParticipantOpsMenuItem(R.drawable.ic_pencil_grey600_24dp, stringResource(R.string.nc_demote)) { + ParticipantOpsMenuItem(R.drawable.ic_baseline_person_24, stringResource(R.string.nc_demote)) { onAction(ParticipantOpsAction.DemoteFromModerator) } } From b229a00abb986132a3c95279a6e257d31340eddf Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 24 Aug 2026 16:55:42 +0200 Subject: [PATCH 3/7] feat(moderators): Add the API plumbing for owner promotion and demotion Talk 23 lets owners hand the owner rank to another participant and take it back, through a participantType parameter on the existing moderators endpoint and two new system messages. See nextcloud/spreed#18924. Add the pieces the client needs before any of it can be offered in the UI: - the promote-demote-owner capability - an optional participantType query parameter on promoteAttendeeToModerator and demoteAttendeeFromModerator - OWNER_PROMOTED and OWNER_DEMOTED system message types Retrofit omits a null @Query, so the existing calls pass null and keep the legacy "just toggle the moderator level" behaviour against every server, including ones without the capability. Without the two system message types the server's owner_promoted and owner_demoted fell through to DUMMY. The messages always rendered, since their text comes from the server, so this is about not discarding the type rather than fixing a visible break. No behaviour change: nothing calls the new parameter yet. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../main/java/com/nextcloud/talk/api/NcApi.java | 15 +++++++++++++-- .../nextcloud/talk/chat/data/model/ChatMessage.kt | 2 ++ .../conversationinfo/ConversationInfoActivity.kt | 6 ++++-- .../converters/EnumSystemMessageTypeConverter.kt | 4 ++++ .../com/nextcloud/talk/utils/CapabilitiesUtil.kt | 3 ++- 5 files changed, 25 insertions(+), 5 deletions(-) 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 09b2402738..643328549b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -645,7 +645,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 +654,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) } } 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") From e1c81d7c6b689bae83f8d97e11e93b9c2e377d9a Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 24 Aug 2026 16:57:00 +0200 Subject: [PATCH 4/7] feat(moderators): Offer promoting to and demoting from owner Add the three ownership rows to the participant bottom sheet, matching the web client (nextcloud/spreed#18924): promote to owner, demote from owner to moderator, and demote from owner to participant. Icons follow the existing rule that a row shows the rank it leads to, so this is where the crown drawable finally gets used. All three sit behind canChangeOwnership(), which mirrors the server's own preconditions: the promote-demote-owner capability, the acting user being an owner, a group or public conversation whose object type does not imply an owner of its own, and a target that is a real user - guests, emails, federated users, phones and bots can never be owners. Self-demotion is deliberately asymmetric, as on the server: an owner may step down to moderator but not straight to participant, so they cannot lock themselves out of their own conversation. The participant.type == OWNER early return in handleParticipantClick had to go, or the sheet could never open for an owner at all. The rule it enforced - that an owner cannot be removed from the conversation - moves into the sheet's visibility rules, where the rest of the row logic already lives. Two Android specifics. USER_FOLLOWING_LINK is this client's name for the server's USER_SELF_JOINED, so it belongs in the promotable set. And ConversationEnums.ObjectType has no CLASSIFIED_PERSIST or EXTENDED_CONVERSATION and decodes unknown types to DEFAULT, so the client can offer a row the server will refuse; the following commit makes that refusal visible. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../ConversationInfoActivity.kt | 33 ++++++- .../ui/ConversationInfoScreen.kt | 1 + .../ui/ParticipantOperationsSheet.kt | 98 ++++++++++++++++++- .../ui/ParticipantOpsAction.kt | 3 + app/src/main/res/values/strings.xml | 6 ++ 5 files changed, 139 insertions(+), 2 deletions(-) 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 643328549b..ef105e3d95 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -730,7 +730,6 @@ class ConversationInfoActivity : BaseActivity() { val conv = state.conversation ?: return val caps = state.spreedCapabilities ?: return if (!ConversationUtils.canModerate(conv, caps)) return - if (model.participant.type == Participant.ParticipantType.OWNER && !model.isSelf) return viewModel.setParticipantForOps(model) } @@ -748,11 +747,38 @@ class ConversationInfoActivity : BaseActivity() { toggleModeratorStatusLegacy(apiVersion, participant) } + 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) } } + private fun changeParticipantType( + apiVersion: Int, + participant: Participant, + promote: Boolean, + participantType: Int + ) { + 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) { val user = conversationUser ?: return val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) @@ -783,5 +809,10 @@ class ConversationInfoActivity : BaseActivity() { companion object { private val TAG = ConversationInfoActivity::class.java.simpleName + + // 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/ui/ConversationInfoScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt index efc8c89851..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 @@ -262,6 +262,7 @@ fun ConversationInfoScreen( ) { ParticipantOperationsContent( model = participantForOps, + conversation = state.conversation, spreedCapabilities = state.spreedCapabilities, onAction = { action -> callbacks.onParticipantOpsDismiss() 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 index 8e4bd1ed9e..b58626890c 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt @@ -41,11 +41,14 @@ 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.conversations.ConversationEnums import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.ParticipantRole import com.nextcloud.talk.utils.ParticipantRoleUtils +import com.nextcloud.talk.utils.SpreedFeatures private data class RemoveOption(@DrawableRes val iconRes: Int, val label: String) @@ -53,24 +56,66 @@ private data class ParticipantOpsVisibility( val infoPin: String?, val showPromote: Boolean, val showDemote: Boolean, + val showPromoteToOwner: Boolean, + val showDemoteOwnerToModerator: Boolean, + val showDemoteOwnerToUser: Boolean, val remove: RemoveOption?, val showBan: Boolean ) +/** + * Conversation types and object types in which the owner rank can be handed out, mirroring the + * server. Everything else binds the conversation to an object that assumes a single owner. + */ +private val ownerChangeConversationTypes = setOf( + ConversationEnums.ConversationType.ROOM_GROUP_CALL, + ConversationEnums.ConversationType.ROOM_PUBLIC_CALL +) + +private val ownerChangeObjectTypes = setOf( + ConversationEnums.ObjectType.DEFAULT, + ConversationEnums.ObjectType.CLASSIFIED, + ConversationEnums.ObjectType.INSTANT_MEETING +) + +/** + * Only an owner can change ownership, only a user can hold it, and only in conversations that are + * not bound to an object with an implicit owner. The server enforces all of this again. + */ +private 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 ownerChangeConversationTypes && + conversation.objectType in ownerChangeObjectTypes && + participant.calculatedActorType == Participant.ActorType.USERS + @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 ownership = canChangeOwnership(participant, conversation, spreedCapabilities) + val isOwner = participant.type == Participant.ParticipantType.OWNER return when { 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 = ownership && isOwner, + showDemoteOwnerToUser = false, remove = pin?.let { RemoveOption(R.drawable.ic_lock_grey600_24px, stringResource(R.string.nc_attendee_pin, it)) }, @@ -81,6 +126,9 @@ private fun computeVisibility( 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 ) @@ -89,29 +137,53 @@ private fun computeVisibility( 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 = ownership, + showDemoteOwnerToUser = ownership, + 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 = ownership && participant.type in promotableToOwner, + showDemoteOwnerToModerator = false, + showDemoteOwnerToUser = false, remove = RemoveOption(deleteIcon, stringResource(R.string.nc_remove_participant)), showBan = spreedCapabilities != null && CapabilitiesUtil.isBanningAvailable(spreedCapabilities) ) } } +private val promotableToOwner = setOf( + Participant.ParticipantType.USER, + Participant.ParticipantType.USER_FOLLOWING_LINK, + Participant.ParticipantType.MODERATOR +) + @Composable fun ParticipantOperationsContent( model: ParticipantModel, + conversation: ConversationModel?, spreedCapabilities: SpreedCapability?, onAction: (ParticipantOpsAction) -> Unit ) { - val visibility = computeVisibility(model, spreedCapabilities) + val visibility = computeVisibility(model, conversation, spreedCapabilities) Column( modifier = Modifier .fillMaxWidth() @@ -121,11 +193,32 @@ fun ParticipantOperationsContent( 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) @@ -268,6 +361,7 @@ private fun ParticipantOperationsSheetModeratorPreview() { Participant.ParticipantType.MODERATOR, ParticipantRole.MODERATOR ), + conversation = null, spreedCapabilities = null, onAction = {} ) @@ -286,6 +380,7 @@ private fun ParticipantOperationsSheetUserWithPinPreview() { ParticipantRole.NONE, attendeePin = "123456" ), + conversation = null, spreedCapabilities = null, onAction = {} ) @@ -303,6 +398,7 @@ private fun ParticipantOperationsSheetOwnerPreview() { 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 index 38533ee589..ffce3c7f70 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOpsAction.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOpsAction.kt @@ -8,7 +8,10 @@ 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1337a70c92..af90c20496 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -460,6 +460,12 @@ How to translate with transifex: Demote from moderator Promote to moderator + + Promote to owner + + Demote from owner to moderator + + Demote from owner to participant Remove participant Remove team and members Remove group and members From b98240d2fecd834ec781c5c959fb412692554458 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 24 Aug 2026 16:58:43 +0200 Subject: [PATCH 5/7] feat(moderators): Tell the user when a participant type change is refused participantActionObserver logged the error and dropped it, so a refusal was indistinguishable from success: the sheet closed and nothing happened. That mattered little while the only actions were promote and demote to moderator, which a moderator can always perform. With the ownership rows it does: the client cannot fully reproduce the server's preconditions, so it can legitimately offer a row the server then refuses. Read the reason the moderators endpoint reports in the OCS data and show it. The last-moderator case gets its own message, since it is the only refusal a moderator can plausibly hit by accident. Every other reason - room-type, actor-type, participant-type - means the client offered a row it should not have, and a generic failure is the honest answer for a client bug. Parsing is defensive: a malformed, empty or missing body falls back to the generic message rather than throwing, and the read is guarded because errorBody().string() can fail as readily as the JSON parse. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../ConversationInfoActivity.kt | 34 ++++++++++++++++++- app/src/main/res/values/strings.xml | 5 +++ 2 files changed, 38 insertions(+), 1 deletion(-) 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 ef105e3d95..5e14a5f0d5 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -89,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 @@ -687,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 */ } } @@ -761,6 +764,33 @@ class ConversationInfoActivity : BaseActivity() { } } + 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 + } + 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 + } + private fun changeParticipantType( apiVersion: Int, participant: Participant, @@ -810,6 +840,8 @@ class ConversationInfoActivity : BaseActivity() { companion object { private val TAG = ConversationInfoActivity::class.java.simpleName + 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af90c20496..b4420036bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -466,6 +466,11 @@ How to translate with transifex: 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 From 22bcbe2a2218211a365951087e788fbd6ff5f6e3 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 24 Aug 2026 17:08:49 +0200 Subject: [PATCH 6/7] test(moderators): Cover the owner promotion preconditions The rules deciding whether ownership can be handed over were private helpers in the Compose sheet, so nothing tested them. They are the kind of thing that rots quietly: a conversation type added to one of the sets, or a precondition the server tightens, changes who sees a destructive action with nothing to catch it. Move them to ParticipantRoleUtils, next to roleOf(), and test them there. canChangeOwnership() is the gate; canBePromotedToOwner() and canBeDemotedFromOwner() add the target's own rank. The sheet keeps only the question of which row to draw. The tests walk each dimension of the matrix rather than sampling it, so a new conversation or object type has to be classified deliberately instead of inheriting whatever the enum ordering gives it: every rankless conversation type, every object type with an implicit owner, every actor type that can never hold the rank, and every participant rank on both sides of promotable. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../ui/ParticipantOperationsSheet.kt | 53 +---- .../talk/utils/ParticipantRoleUtils.kt | 67 ++++++ .../talk/utils/ParticipantRoleUtilsTest.kt | 222 ++++++++++++++++++ 3 files changed, 298 insertions(+), 44 deletions(-) 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 index b58626890c..4312402e4b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt @@ -43,12 +43,10 @@ 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.conversations.ConversationEnums import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.ParticipantRole import com.nextcloud.talk.utils.ParticipantRoleUtils -import com.nextcloud.talk.utils.SpreedFeatures private data class RemoveOption(@DrawableRes val iconRes: Int, val label: String) @@ -63,37 +61,6 @@ private data class ParticipantOpsVisibility( val showBan: Boolean ) -/** - * Conversation types and object types in which the owner rank can be handed out, mirroring the - * server. Everything else binds the conversation to an object that assumes a single owner. - */ -private val ownerChangeConversationTypes = setOf( - ConversationEnums.ConversationType.ROOM_GROUP_CALL, - ConversationEnums.ConversationType.ROOM_PUBLIC_CALL -) - -private val ownerChangeObjectTypes = setOf( - ConversationEnums.ObjectType.DEFAULT, - ConversationEnums.ObjectType.CLASSIFIED, - ConversationEnums.ObjectType.INSTANT_MEETING -) - -/** - * Only an owner can change ownership, only a user can hold it, and only in conversations that are - * not bound to an object with an implicit owner. The server enforces all of this again. - */ -private 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 ownerChangeConversationTypes && - conversation.objectType in ownerChangeObjectTypes && - participant.calculatedActorType == Participant.ActorType.USERS - @Composable @Suppress("LongMethod") private fun computeVisibility( @@ -104,7 +71,7 @@ private fun computeVisibility( val participant = model.participant val pin = participant.attendeePin?.takeIf { it.isNotEmpty() } val deleteIcon = R.drawable.ic_delete_grey600_24dp - val ownership = canChangeOwnership(participant, conversation, spreedCapabilities) + val canDemoteFromOwner = ParticipantRoleUtils.canBeDemotedFromOwner(participant, conversation, spreedCapabilities) val isOwner = participant.type == Participant.ParticipantType.OWNER return when { @@ -114,7 +81,7 @@ private fun computeVisibility( showDemote = false, showPromoteToOwner = false, // An owner may step down, but only as far as moderator, to avoid locking themselves out - showDemoteOwnerToModerator = ownership && isOwner, + showDemoteOwnerToModerator = canDemoteFromOwner, showDemoteOwnerToUser = false, remove = pin?.let { RemoveOption(R.drawable.ic_lock_grey600_24px, stringResource(R.string.nc_attendee_pin, it)) @@ -149,8 +116,8 @@ private fun computeVisibility( showPromote = false, showDemote = false, showPromoteToOwner = false, - showDemoteOwnerToModerator = ownership, - showDemoteOwnerToUser = ownership, + showDemoteOwnerToModerator = canDemoteFromOwner, + showDemoteOwnerToUser = canDemoteFromOwner, remove = null, showBan = false ) @@ -161,7 +128,11 @@ private fun computeVisibility( participant.type == Participant.ParticipantType.GUEST, showDemote = participant.type == Participant.ParticipantType.MODERATOR || participant.type == Participant.ParticipantType.GUEST_MODERATOR, - showPromoteToOwner = ownership && participant.type in promotableToOwner, + showPromoteToOwner = ParticipantRoleUtils.canBePromotedToOwner( + participant, + conversation, + spreedCapabilities + ), showDemoteOwnerToModerator = false, showDemoteOwnerToUser = false, remove = RemoveOption(deleteIcon, stringResource(R.string.nc_remove_participant)), @@ -170,12 +141,6 @@ private fun computeVisibility( } } -private val promotableToOwner = setOf( - Participant.ParticipantType.USER, - Participant.ParticipantType.USER_FOLLOWING_LINK, - Participant.ParticipantType.MODERATOR -) - @Composable fun ParticipantOperationsContent( model: ParticipantModel, 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/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 } From 91ffef784d505caeab86603cea8b7d02785dbc05 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 24 Aug 2026 17:45:14 +0200 Subject: [PATCH 7/7] feat(moderators): Show the participant sheet to non-moderators too The participants list carries owner and moderator as an icon only. Anyone without moderation rights had no way to read it: the row was clickable but inert, so the rank had no text form for them anywhere in the app. Open the sheet for everyone. Without moderation rights it renders the header - display name and role - and no action rows, which is exactly the information the icon was withholding. The attendee PIN stays hidden as well; it is a SIP credential the server only hands to moderators. Hiding rows is presentation, not access control, so the canModerate() check does not simply disappear into the sheet: it moves to handleParticipantOpsAction, where it guards the API call itself rather than the drawing of a button. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../ConversationInfoActivity.kt | 11 +++-- .../ui/ParticipantOperationsSheet.kt | 40 +++++++++++++++---- app/src/main/res/values/strings.xml | 3 +- 3 files changed, 38 insertions(+), 16 deletions(-) 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 5e14a5f0d5..6bd495101f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -727,17 +727,16 @@ class ConversationInfoActivity : BaseActivity() { conversationUser?.let { viewModel.banActor(it, conversationToken, actorType, actorId, internalNote) } } - @Suppress("ReturnCount") private fun handleParticipantClick(model: ParticipantModel) { - val state = viewModel.uiState.value - val conv = state.conversation ?: return - val caps = state.spreedCapabilities ?: return - if (!ConversationUtils.canModerate(conv, caps)) return - viewModel.setParticipantForOps(model) } + @Suppress("ReturnCount") 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)) 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 index 4312402e4b..2ddda7ff3e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ParticipantOperationsSheet.kt @@ -45,20 +45,21 @@ 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?, - val showPromote: Boolean, - val showDemote: Boolean, - val showPromoteToOwner: Boolean, - val showDemoteOwnerToModerator: Boolean, - val showDemoteOwnerToUser: Boolean, - val remove: RemoveOption?, - val showBan: Boolean + 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 @@ -73,8 +74,12 @@ private fun computeVisibility( 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, @@ -352,6 +357,25 @@ private fun ParticipantOperationsSheetUserWithPinPreview() { } } +/** 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4420036bb..4331310917 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -469,8 +469,7 @@ How to translate with transifex: Could not change the type of the participant - The last moderator of a conversation can not be - demoted + The last moderator of a conversation can not be demoted Remove participant Remove team and members Remove group and members