Skip to content

Commit 06f4fb8

Browse files
Merge pull request #6560 from nextcloud/feat/noid/owner-moderator
👑 feat(owners): Allow to promote/demote users to/from owners
2 parents df219f1 + bb1e933 commit 06f4fb8

14 files changed

Lines changed: 879 additions & 106 deletions

File tree

app/src/main/java/com/nextcloud/talk/api/NcApi.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,15 +158,26 @@ Observable<GenericOverall> demoteModeratorToUser(@Header("Authorization") String
158158
@Url String url,
159159
@Query("participant") String participantId);
160160

161+
/**
162+
* @param participantType Target level, or null to only toggle the moderator level. Owner (1) and
163+
* moderator (2) when promoting, moderator (2) and user (3) when demoting.
164+
* Requires the "promote-demote-owner" capability.
165+
*/
161166
@POST
162167
Observable<GenericOverall> promoteAttendeeToModerator(@Header("Authorization") String authorization,
163168
@Url String url,
164-
@Query("attendeeId") Long attendeeId);
169+
@Query("attendeeId") Long attendeeId,
170+
@Query("participantType") Integer participantType);
165171

172+
/**
173+
* @param participantType Target level, or null to only toggle the moderator level. See
174+
* {@link #promoteAttendeeToModerator}.
175+
*/
166176
@DELETE
167177
Observable<GenericOverall> demoteAttendeeFromModerator(@Header("Authorization") String authorization,
168178
@Url String url,
169-
@Query("attendeeId") Long attendeeId);
179+
@Query("attendeeId") Long attendeeId,
180+
@Query("participantType") Integer participantType);
170181

171182
/*
172183
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken/participants/self

app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,8 @@ data class ChatMessage(
290290
GROUP_REMOVED,
291291
CIRCLE_ADDED,
292292
CIRCLE_REMOVED,
293+
OWNER_PROMOTED,
294+
OWNER_DEMOTED,
293295
MODERATOR_PROMOTED,
294296
MODERATOR_DEMOTED,
295297
GUEST_MODERATOR_PROMOTED,

app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt

Lines changed: 82 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import android.view.LayoutInflater
1414
import androidx.activity.compose.setContent
1515
import androidx.activity.result.ActivityResult
1616
import androidx.activity.result.contract.ActivityResultContracts
17-
import androidx.annotation.DrawableRes
1817
import androidx.appcompat.app.AlertDialog
1918
import androidx.compose.material3.MaterialTheme
2019
import androidx.compose.material3.SnackbarHostState
@@ -43,14 +42,14 @@ import com.nextcloud.talk.activities.BaseActivity
4342
import com.nextcloud.talk.activities.MainActivity
4443
import com.nextcloud.talk.api.NcApi
4544
import com.nextcloud.talk.application.NextcloudTalkApplication
46-
import com.nextcloud.talk.bottomsheet.items.BasicListItemWithImage
47-
import com.nextcloud.talk.bottomsheet.items.listItemsWithImage
4845
import com.nextcloud.talk.chat.ChatActivity
4946
import com.nextcloud.talk.components.ColoredStatusBar
5047
import com.nextcloud.talk.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS
5148
import com.nextcloud.talk.contacts.ContactsActivity
49+
import com.nextcloud.talk.conversationinfo.model.ParticipantModel
5250
import com.nextcloud.talk.conversationinfo.ui.ConversationInfoScreen
5351
import com.nextcloud.talk.conversationinfo.ui.ConversationInfoScreenCallbacks
52+
import com.nextcloud.talk.conversationinfo.ui.ParticipantOpsAction
5453
import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel
5554
import com.nextcloud.talk.conversationinfoedit.ConversationInfoEditActivity
5655
import com.nextcloud.talk.data.user.model.User
@@ -70,7 +69,6 @@ import com.nextcloud.talk.models.json.generic.GenericOverall
7069
import com.nextcloud.talk.models.json.participants.Participant
7170
import com.nextcloud.talk.models.json.participants.Participant.ActorType.CIRCLES
7271
import com.nextcloud.talk.models.json.participants.Participant.ActorType.GROUPS
73-
import com.nextcloud.talk.models.json.participants.Participant.ActorType.USERS
7472
import com.nextcloud.talk.models.json.upcomingEvents.UpcomingEvent
7573
import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
7674
import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity
@@ -91,6 +89,8 @@ import io.reactivex.schedulers.Schedulers
9189
import kotlinx.coroutines.launch
9290
import org.greenrobot.eventbus.Subscribe
9391
import org.greenrobot.eventbus.ThreadMode
92+
import org.json.JSONObject
93+
import retrofit2.HttpException
9494
import java.time.Instant
9595
import java.time.ZoneId
9696
import java.time.ZonedDateTime
@@ -312,7 +312,9 @@ class ConversationInfoActivity : BaseActivity() {
312312
onLockConversationClick = {
313313
conversationUser?.let { viewModel.toggleLock(it, conversationToken) }
314314
},
315-
onParticipantClick = { model -> handleParticipantClick(model.participant) },
315+
onParticipantClick = { model -> handleParticipantClick(model) },
316+
onParticipantOpsDismiss = { viewModel.setParticipantForOps(null) },
317+
onParticipantOpsAction = { action, model -> handleParticipantOpsAction(action, model) },
316318
onAddParticipantsClick = {
317319
startGroupChat = false
318320
selectParticipantsToAdd()
@@ -645,15 +647,17 @@ class ConversationInfoActivity : BaseActivity() {
645647
ncApi.demoteAttendeeFromModerator(
646648
credentials,
647649
ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken),
648-
participant.attendeeId
650+
participant.attendeeId,
651+
null
649652
)?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber)
650653
} else if (participant.type == Participant.ParticipantType.USER ||
651654
participant.type == Participant.ParticipantType.GUEST
652655
) {
653656
ncApi.promoteAttendeeToModerator(
654657
credentials,
655658
ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken),
656-
participant.attendeeId
659+
participant.attendeeId,
660+
null
657661
)?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber)
658662
}
659663
}
@@ -685,7 +689,8 @@ class ConversationInfoActivity : BaseActivity() {
685689

686690
@SuppressLint("LongLogTag")
687691
override fun onError(e: Throwable) {
688-
Log.e(TAG, "Error toggling moderator status", e)
692+
Log.e(TAG, "Error changing the participant type", e)
693+
showParticipantActionError(e)
689694
}
690695
override fun onComplete() { /* unused */ }
691696
}
@@ -722,113 +727,85 @@ class ConversationInfoActivity : BaseActivity() {
722727
conversationUser?.let { viewModel.banActor(it, conversationToken, actorType, actorId, internalNote) }
723728
}
724729

725-
@SuppressLint("CheckResult", "StringFormatInvalid")
730+
private fun handleParticipantClick(model: ParticipantModel) {
731+
viewModel.setParticipantForOps(model)
732+
}
733+
726734
@Suppress("ReturnCount")
727-
private fun handleParticipantClick(participant: Participant) {
735+
private fun handleParticipantOpsAction(action: ParticipantOpsAction, model: ParticipantModel) {
728736
val state = viewModel.uiState.value
729737
val conv = state.conversation ?: return
730738
val caps = state.spreedCapabilities ?: return
731739
if (!ConversationUtils.canModerate(conv, caps)) return
732740
val user = conversationUser ?: return
741+
val participant = model.participant
733742
val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1))
743+
when (action) {
744+
ParticipantOpsAction.PromoteToModerator,
745+
ParticipantOpsAction.DemoteFromModerator ->
746+
if (apiVersion >= ApiUtils.API_V4) {
747+
toggleModeratorStatus(apiVersion, participant)
748+
} else {
749+
toggleModeratorStatusLegacy(apiVersion, participant)
750+
}
734751

735-
if (participant.calculatedActorType == USERS && participant.calculatedActorId == user.userId) {
736-
if (participant.attendeePin?.isNotEmpty() == true) {
737-
launchRemoveAttendeeFromConversationDialog(
738-
participant,
739-
apiVersion,
740-
context.getString(R.string.nc_attendee_pin, participant.attendeePin),
741-
R.drawable.ic_lock_grey600_24px
742-
)
743-
}
744-
} else if (participant.type == Participant.ParticipantType.OWNER) {
745-
// Cannot moderate owner
746-
} else if (participant.calculatedActorType == GROUPS) {
747-
launchRemoveAttendeeFromConversationDialog(
748-
participant,
749-
apiVersion,
750-
context.getString(R.string.nc_remove_group_and_members)
751-
)
752-
} else if (participant.calculatedActorType == CIRCLES) {
753-
launchRemoveAttendeeFromConversationDialog(
754-
participant,
755-
apiVersion,
756-
context.getString(R.string.nc_remove_team_and_members)
757-
)
758-
} else {
759-
launchDefaultActions(participant, apiVersion)
752+
ParticipantOpsAction.PromoteToOwner ->
753+
changeParticipantType(apiVersion, participant, promote = true, PARTICIPANT_TYPE_OWNER)
754+
755+
ParticipantOpsAction.DemoteOwnerToModerator ->
756+
changeParticipantType(apiVersion, participant, promote = false, PARTICIPANT_TYPE_MODERATOR)
757+
758+
ParticipantOpsAction.DemoteOwnerToUser ->
759+
changeParticipantType(apiVersion, participant, promote = false, PARTICIPANT_TYPE_USER)
760+
761+
ParticipantOpsAction.RemoveFromConversation -> removeAttendeeFromConversation(apiVersion, participant)
762+
ParticipantOpsAction.Ban -> handleBan(participant)
760763
}
761764
}
762765

763-
@SuppressLint("CheckResult")
764-
@Suppress("CyclomaticComplexMethod")
765-
private fun launchDefaultActions(participant: Participant, apiVersion: Int) {
766-
val items = getDefaultActionItems(participant)
767-
if (CapabilitiesUtil.isBanningAvailable(conversationUser?.capabilities?.spreedCapability!!)) {
768-
items.add(BasicListItemWithImage(R.drawable.baseline_block_24, context.getString(R.string.ban_participant)))
769-
}
770-
when (participant.type) {
771-
Participant.ParticipantType.MODERATOR, Participant.ParticipantType.GUEST_MODERATOR -> items.removeAt(1)
772-
Participant.ParticipantType.USER, Participant.ParticipantType.GUEST -> items.removeAt(2)
773-
else -> {
774-
items.removeAt(2)
775-
items.removeAt(1)
776-
}
766+
private fun showParticipantActionError(e: Throwable) {
767+
val messageRes = if (participantActionErrorReason(e) == ERROR_LAST_MODERATOR) {
768+
R.string.nc_last_moderator_cannot_be_demoted
769+
} else {
770+
R.string.nc_participant_type_change_failed
777771
}
778-
if (participant.attendeePin == null || participant.attendeePin!!.isEmpty()) items.removeAt(0)
779-
if (items.isNotEmpty()) {
780-
MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show {
781-
cornerRadius(res = R.dimen.corner_radius)
782-
title(text = participant.displayName)
783-
listItemsWithImage(items = items) { _, index, _ ->
784-
var actionToTrigger = index
785-
if (participant.attendeePin == null || participant.attendeePin!!.isEmpty()) actionToTrigger++
786-
if (participant.type == Participant.ParticipantType.USER_FOLLOWING_LINK) actionToTrigger++
787-
when (actionToTrigger) {
788-
DEMOTE_OR_PROMOTE -> {
789-
if (apiVersion >= ApiUtils.API_V4) {
790-
toggleModeratorStatus(apiVersion, participant)
791-
} else {
792-
toggleModeratorStatusLegacy(apiVersion, participant)
793-
}
794-
}
795-
REMOVE_FROM_CONVERSATION -> removeAttendeeFromConversation(apiVersion, participant)
796-
BAN_FROM_CONVERSATION -> handleBan(participant)
797-
else -> { /* unused */ }
798-
}
799-
}
800-
}
772+
lifecycleScope.launch { viewModel.emitSnackbar(messageRes) }
773+
}
774+
775+
/**
776+
* The moderators endpoint reports why it refused in the OCS data as
777+
* `{"ocs":{"data":{"error":"<reason>"}}}`.
778+
*/
779+
@Suppress("Detekt.TooGenericExceptionCaught")
780+
private fun participantActionErrorReason(e: Throwable): String? =
781+
try {
782+
(e as? HttpException)?.response()?.errorBody()?.string()
783+
?.let { JSONObject(it) }
784+
?.optJSONObject("ocs")
785+
?.optJSONObject("data")
786+
?.optString("error")
787+
?.takeIf { it.isNotEmpty() }
788+
} catch (exception: Exception) {
789+
Log.w(TAG, "Could not read the participant action error", exception)
790+
null
801791
}
802-
}
803-
804-
@SuppressLint("StringFormatInvalid")
805-
private fun getDefaultActionItems(participant: Participant): MutableList<BasicListItemWithImage> =
806-
mutableListOf(
807-
BasicListItemWithImage(
808-
R.drawable.ic_lock_grey600_24px,
809-
context.getString(R.string.nc_attendee_pin, participant.attendeePin)
810-
),
811-
BasicListItemWithImage(R.drawable.ic_pencil_grey600_24dp, context.getString(R.string.nc_promote)),
812-
BasicListItemWithImage(R.drawable.ic_pencil_grey600_24dp, context.getString(R.string.nc_demote)),
813-
BasicListItemWithImage(R.drawable.ic_delete_grey600_24dp, context.getString(R.string.nc_remove_participant))
814-
)
815792

816-
@SuppressLint("CheckResult")
817-
private fun launchRemoveAttendeeFromConversationDialog(
818-
participant: Participant,
793+
private fun changeParticipantType(
819794
apiVersion: Int,
820-
itemText: String,
821-
@DrawableRes itemIcon: Int = R.drawable.ic_delete_grey600_24dp
795+
participant: Participant,
796+
promote: Boolean,
797+
participantType: Int
822798
) {
823-
MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show {
824-
cornerRadius(res = R.dimen.corner_radius)
825-
title(text = participant.displayName)
826-
listItemsWithImage(
827-
items = mutableListOf(BasicListItemWithImage(itemIcon, itemText))
828-
) { _, index, _ ->
829-
if (index == 0) removeAttendeeFromConversation(apiVersion, participant)
830-
}
799+
val user = conversationUser ?: return
800+
val url = ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken)
801+
val call = if (promote) {
802+
ncApi.promoteAttendeeToModerator(credentials, url, participant.attendeeId, participantType)
803+
} else {
804+
ncApi.demoteAttendeeFromModerator(credentials, url, participant.attendeeId, participantType)
831805
}
806+
call?.subscribeOn(Schedulers.io())
807+
?.observeOn(AndroidSchedulers.mainThread())
808+
?.subscribe(participantActionObserver())
832809
}
833810

834811
private fun handleBan(participant: Participant) {
@@ -861,8 +838,12 @@ class ConversationInfoActivity : BaseActivity() {
861838

862839
companion object {
863840
private val TAG = ConversationInfoActivity::class.java.simpleName
864-
private const val DEMOTE_OR_PROMOTE = 1
865-
private const val REMOVE_FROM_CONVERSATION = 2
866-
private const val BAN_FROM_CONVERSATION = 3
841+
842+
private const val ERROR_LAST_MODERATOR = "last-moderator"
843+
844+
// Participant types the moderators endpoint accepts as a target level
845+
private const val PARTICIPANT_TYPE_OWNER: Int = 1
846+
private const val PARTICIPANT_TYPE_MODERATOR: Int = 2
847+
private const val PARTICIPANT_TYPE_USER: Int = 3
867848
}
868849
}

app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoUiState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ data class ConversationInfoUiState(
7171
val showLockConversation: Boolean = false,
7272

7373
val participants: List<ParticipantModel> = emptyList(),
74+
val participantForOps: ParticipantModel? = null,
7475
val showParticipants: Boolean = false,
7576
val showAddParticipants: Boolean = false,
7677
val showStartGroupChat: Boolean = false,

app/src/main/java/com/nextcloud/talk/conversationinfo/model/ParticipantModel.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ import com.nextcloud.talk.utils.ParticipantRole
1313
data class ParticipantModel(
1414
val participant: Participant,
1515
val isOnline: Boolean,
16-
val role: ParticipantRole = ParticipantRole.NONE
16+
val role: ParticipantRole = ParticipantRole.NONE,
17+
val isSelf: Boolean = false
1718
)

app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
4444
import androidx.compose.material3.Icon
4545
import androidx.compose.material3.IconButton
4646
import androidx.compose.material3.MaterialTheme
47+
import androidx.compose.material3.ModalBottomSheet
4748
import androidx.compose.material3.Scaffold
4849
import androidx.compose.material3.Surface
4950
import androidx.compose.material3.Switch
@@ -52,6 +53,7 @@ import androidx.compose.material3.TopAppBar
5253
import androidx.compose.material3.TopAppBarDefaults
5354
import androidx.compose.material3.darkColorScheme
5455
import androidx.compose.material3.lightColorScheme
56+
import androidx.compose.material3.rememberModalBottomSheetState
5557
import androidx.compose.runtime.Composable
5658
import androidx.compose.runtime.getValue
5759
import androidx.compose.runtime.mutableStateOf
@@ -113,6 +115,8 @@ data class ConversationInfoScreenCallbacks(
113115
val onShareConversationClick: () -> Unit = {},
114116
val onLockConversationClick: () -> Unit = {},
115117
val onParticipantClick: (ParticipantModel) -> Unit = {},
118+
val onParticipantOpsDismiss: () -> Unit = {},
119+
val onParticipantOpsAction: (ParticipantOpsAction, ParticipantModel) -> Unit = { _, _ -> },
116120
val onAddParticipantsClick: () -> Unit = {},
117121
val onStartGroupChatClick: () -> Unit = {},
118122
val onListBansClick: () -> Unit = {},
@@ -129,6 +133,7 @@ fun ConversationInfoScreen(
129133
state: ConversationInfoUiState,
130134
callbacks: ConversationInfoScreenCallbacks = ConversationInfoScreenCallbacks()
131135
) {
136+
val participantOpsSheetState = rememberModalBottomSheetState()
132137
Scaffold(
133138
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
134139
topBar = {
@@ -247,6 +252,25 @@ fun ConversationInfoScreen(
247252
}
248253
}
249254
}
255+
256+
val participantForOps = state.participantForOps
257+
if (participantForOps != null) {
258+
ModalBottomSheet(
259+
onDismissRequest = callbacks.onParticipantOpsDismiss,
260+
sheetState = participantOpsSheetState,
261+
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
262+
) {
263+
ParticipantOperationsContent(
264+
model = participantForOps,
265+
conversation = state.conversation,
266+
spreedCapabilities = state.spreedCapabilities,
267+
onAction = { action ->
268+
callbacks.onParticipantOpsDismiss()
269+
callbacks.onParticipantOpsAction(action, participantForOps)
270+
}
271+
)
272+
}
273+
}
250274
}
251275

252276
@Composable

0 commit comments

Comments
 (0)