Skip to content
15 changes: 13 additions & 2 deletions app/src/main/java/com/nextcloud/talk/api/NcApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -158,15 +158,26 @@ Observable<GenericOverall> 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<GenericOverall> 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<GenericOverall> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ data class ChatMessage(
GROUP_REMOVED,
CIRCLE_ADDED,
CIRCLE_REMOVED,
OWNER_PROMOTED,
OWNER_DEMOTED,
MODERATOR_PROMOTED,
MODERATOR_DEMOTED,
GUEST_MODERATOR_PROMOTED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -91,6 +89,8 @@ import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.json.JSONObject
import retrofit2.HttpException
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
Expand Down Expand Up @@ -312,7 +312,9 @@ class ConversationInfoActivity : BaseActivity() {
onLockConversationClick = {
conversationUser?.let { viewModel.toggleLock(it, conversationToken) }
},
onParticipantClick = { model -> handleParticipantClick(model.participant) },
onParticipantClick = { model -> handleParticipantClick(model) },
onParticipantOpsDismiss = { viewModel.setParticipantForOps(null) },
onParticipantOpsAction = { action, model -> handleParticipantOpsAction(action, model) },
onAddParticipantsClick = {
startGroupChat = false
selectParticipantsToAdd()
Expand Down Expand Up @@ -645,15 +647,17 @@ 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
) {
ncApi.promoteAttendeeToModerator(
credentials,
ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken),
participant.attendeeId
participant.attendeeId,
null
)?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber)
}
}
Expand Down Expand Up @@ -685,7 +689,8 @@ class ConversationInfoActivity : BaseActivity() {

@SuppressLint("LongLogTag")
override fun onError(e: Throwable) {
Log.e(TAG, "Error toggling moderator status", e)
Log.e(TAG, "Error changing the participant type", e)
showParticipantActionError(e)
}
override fun onComplete() { /* unused */ }
}
Expand Down Expand Up @@ -722,113 +727,85 @@ class ConversationInfoActivity : BaseActivity() {
conversationUser?.let { viewModel.banActor(it, conversationToken, actorType, actorId, internalNote) }
}

@SuppressLint("CheckResult", "StringFormatInvalid")
private fun handleParticipantClick(model: ParticipantModel) {
viewModel.setParticipantForOps(model)
}

@Suppress("ReturnCount")
private fun handleParticipantClick(participant: Participant) {
private fun handleParticipantOpsAction(action: ParticipantOpsAction, model: ParticipantModel) {
val state = viewModel.uiState.value
val conv = state.conversation ?: return
val caps = state.spreedCapabilities ?: return
if (!ConversationUtils.canModerate(conv, caps)) return
val user = conversationUser ?: return
val participant = model.participant
val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1))
when (action) {
ParticipantOpsAction.PromoteToModerator,
ParticipantOpsAction.DemoteFromModerator ->
if (apiVersion >= ApiUtils.API_V4) {
toggleModeratorStatus(apiVersion, participant)
} else {
toggleModeratorStatusLegacy(apiVersion, participant)
}

if (participant.calculatedActorType == USERS && participant.calculatedActorId == user.userId) {
if (participant.attendeePin?.isNotEmpty() == true) {
launchRemoveAttendeeFromConversationDialog(
participant,
apiVersion,
context.getString(R.string.nc_attendee_pin, participant.attendeePin),
R.drawable.ic_lock_grey600_24px
)
}
} else if (participant.type == Participant.ParticipantType.OWNER) {
// Cannot moderate owner
} else if (participant.calculatedActorType == GROUPS) {
launchRemoveAttendeeFromConversationDialog(
participant,
apiVersion,
context.getString(R.string.nc_remove_group_and_members)
)
} else if (participant.calculatedActorType == CIRCLES) {
launchRemoveAttendeeFromConversationDialog(
participant,
apiVersion,
context.getString(R.string.nc_remove_team_and_members)
)
} else {
launchDefaultActions(participant, apiVersion)
ParticipantOpsAction.PromoteToOwner ->
changeParticipantType(apiVersion, participant, promote = true, PARTICIPANT_TYPE_OWNER)

ParticipantOpsAction.DemoteOwnerToModerator ->
changeParticipantType(apiVersion, participant, promote = false, PARTICIPANT_TYPE_MODERATOR)

ParticipantOpsAction.DemoteOwnerToUser ->
changeParticipantType(apiVersion, participant, promote = false, PARTICIPANT_TYPE_USER)

ParticipantOpsAction.RemoveFromConversation -> removeAttendeeFromConversation(apiVersion, participant)
ParticipantOpsAction.Ban -> handleBan(participant)
}
}

@SuppressLint("CheckResult")
@Suppress("CyclomaticComplexMethod")
private fun launchDefaultActions(participant: Participant, apiVersion: Int) {
val items = getDefaultActionItems(participant)
if (CapabilitiesUtil.isBanningAvailable(conversationUser?.capabilities?.spreedCapability!!)) {
items.add(BasicListItemWithImage(R.drawable.baseline_block_24, context.getString(R.string.ban_participant)))
}
when (participant.type) {
Participant.ParticipantType.MODERATOR, Participant.ParticipantType.GUEST_MODERATOR -> items.removeAt(1)
Participant.ParticipantType.USER, Participant.ParticipantType.GUEST -> items.removeAt(2)
else -> {
items.removeAt(2)
items.removeAt(1)
}
private fun showParticipantActionError(e: Throwable) {
val messageRes = if (participantActionErrorReason(e) == ERROR_LAST_MODERATOR) {
R.string.nc_last_moderator_cannot_be_demoted
} else {
R.string.nc_participant_type_change_failed
}
if (participant.attendeePin == null || participant.attendeePin!!.isEmpty()) items.removeAt(0)
if (items.isNotEmpty()) {
MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show {
cornerRadius(res = R.dimen.corner_radius)
title(text = participant.displayName)
listItemsWithImage(items = items) { _, index, _ ->
var actionToTrigger = index
if (participant.attendeePin == null || participant.attendeePin!!.isEmpty()) actionToTrigger++
if (participant.type == Participant.ParticipantType.USER_FOLLOWING_LINK) actionToTrigger++
when (actionToTrigger) {
DEMOTE_OR_PROMOTE -> {
if (apiVersion >= ApiUtils.API_V4) {
toggleModeratorStatus(apiVersion, participant)
} else {
toggleModeratorStatusLegacy(apiVersion, participant)
}
}
REMOVE_FROM_CONVERSATION -> removeAttendeeFromConversation(apiVersion, participant)
BAN_FROM_CONVERSATION -> handleBan(participant)
else -> { /* unused */ }
}
}
}
lifecycleScope.launch { viewModel.emitSnackbar(messageRes) }
}

/**
* The moderators endpoint reports why it refused in the OCS data as
* `{"ocs":{"data":{"error":"<reason>"}}}`.
*/
@Suppress("Detekt.TooGenericExceptionCaught")
private fun participantActionErrorReason(e: Throwable): String? =
try {
(e as? HttpException)?.response()?.errorBody()?.string()
?.let { JSONObject(it) }
?.optJSONObject("ocs")
?.optJSONObject("data")
?.optString("error")
?.takeIf { it.isNotEmpty() }
} catch (exception: Exception) {
Log.w(TAG, "Could not read the participant action error", exception)
null
}
}

@SuppressLint("StringFormatInvalid")
private fun getDefaultActionItems(participant: Participant): MutableList<BasicListItemWithImage> =
mutableListOf(
BasicListItemWithImage(
R.drawable.ic_lock_grey600_24px,
context.getString(R.string.nc_attendee_pin, participant.attendeePin)
),
BasicListItemWithImage(R.drawable.ic_pencil_grey600_24dp, context.getString(R.string.nc_promote)),
BasicListItemWithImage(R.drawable.ic_pencil_grey600_24dp, context.getString(R.string.nc_demote)),
BasicListItemWithImage(R.drawable.ic_delete_grey600_24dp, context.getString(R.string.nc_remove_participant))
)

@SuppressLint("CheckResult")
private fun launchRemoveAttendeeFromConversationDialog(
participant: Participant,
private fun changeParticipantType(
apiVersion: Int,
itemText: String,
@DrawableRes itemIcon: Int = R.drawable.ic_delete_grey600_24dp
participant: Participant,
promote: Boolean,
participantType: Int
) {
MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show {
cornerRadius(res = R.dimen.corner_radius)
title(text = participant.displayName)
listItemsWithImage(
items = mutableListOf(BasicListItemWithImage(itemIcon, itemText))
) { _, index, _ ->
if (index == 0) removeAttendeeFromConversation(apiVersion, participant)
}
val user = conversationUser ?: return
val url = ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken)
val call = if (promote) {
ncApi.promoteAttendeeToModerator(credentials, url, participant.attendeeId, participantType)
} else {
ncApi.demoteAttendeeFromModerator(credentials, url, participant.attendeeId, participantType)
}
call?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(participantActionObserver())
}

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

companion object {
private val TAG = ConversationInfoActivity::class.java.simpleName
private const val DEMOTE_OR_PROMOTE = 1
private const val REMOVE_FROM_CONVERSATION = 2
private const val BAN_FROM_CONVERSATION = 3

private const val ERROR_LAST_MODERATOR = "last-moderator"

// Participant types the moderators endpoint accepts as a target level
private const val PARTICIPANT_TYPE_OWNER: Int = 1
private const val PARTICIPANT_TYPE_MODERATOR: Int = 2
private const val PARTICIPANT_TYPE_USER: Int = 3
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ data class ConversationInfoUiState(
val showLockConversation: Boolean = false,

val participants: List<ParticipantModel> = emptyList(),
val participantForOps: ParticipantModel? = null,
val showParticipants: Boolean = false,
val showAddParticipants: Boolean = false,
val showStartGroupChat: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 = {},
Expand All @@ -129,6 +133,7 @@ fun ConversationInfoScreen(
state: ConversationInfoUiState,
callbacks: ConversationInfoScreenCallbacks = ConversationInfoScreenCallbacks()
) {
val participantOpsSheetState = rememberModalBottomSheetState()
Scaffold(
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
topBar = {
Expand Down Expand Up @@ -247,6 +252,25 @@ fun ConversationInfoScreen(
}
}
}

val participantForOps = state.participantForOps
if (participantForOps != null) {
ModalBottomSheet(
onDismissRequest = callbacks.onParticipantOpsDismiss,
sheetState = participantOpsSheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
) {
ParticipantOperationsContent(
model = participantForOps,
conversation = state.conversation,
spreedCapabilities = state.spreedCapabilities,
onAction = { action ->
callbacks.onParticipantOpsDismiss()
callbacks.onParticipantOpsAction(action, participantForOps)
}
)
}
}
}

@Composable
Expand Down
Loading
Loading