From 46f944f527e33187825256f3dc3d3dc3a474a555 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 4 Sep 2026 18:52:03 +0200 Subject: [PATCH 1/2] refactor(conversations): read the conversation types from the server The conversation type cards were hardcoded, so the presets an installation offers, the parameters they apply and the values an administrator configured as the default were all ignored. They now come from the presets endpoint, and the grid renders whatever it reports. Parameters are applied in the documented order: the administrator configured defaults, then the selected type, then the parameters the user chose. Selecting a type drops the choices the user made for the parameters that type defines, so a type can still configure the visibility of a conversation. Assisted-by: Claude Code:claude-opus-5 Claude-Session: https://claude.ai/code/session_014YnDswTbCmRrVETnwE4twB Signed-off-by: Andy Scherzinger --- .../com/nextcloud/talk/api/NcApiCoroutines.kt | 7 + .../ConversationCreationActivity.kt | 153 +------------ .../ConversationCreator.kt | 13 +- .../ConversationPresetModel.kt | 74 ++++++ .../CreateConversationParams.kt | 85 ++++--- .../data/ConversationCreationRepository.kt | 3 + .../ConversationCreationRepositoryImpl.kt | 4 + .../ui/ConversationPresetCards.kt | 211 ++++++++++++++++++ .../ConversationCreationViewModel.kt | 103 ++++++--- .../json/conversations/ConversationPreset.kt | 31 +++ .../ConversationPresetsOverall.kt | 35 +++ .../java/com/nextcloud/talk/utils/ApiUtils.kt | 3 + .../talk/utils/preview/ComposePreviewUtils.kt | 7 +- app/src/main/res/values/strings.xml | 2 + .../CreateConversationParamsTest.kt | 198 +++++++++++++--- 15 files changed, 668 insertions(+), 261 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationPresetModel.kt create mode 100644 app/src/main/java/com/nextcloud/talk/conversationcreation/ui/ConversationPresetCards.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationPreset.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationPresetsOverall.kt diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt index 9f22d7f1bd..b18a79c827 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -17,6 +17,7 @@ import com.nextcloud.talk.models.json.chatpostattachment.ChatPostAttachmentOvera import com.nextcloud.talk.models.json.chatpostattachment.PostConversationAttachmentRequest import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentFolderOverall import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ProbeConversationAttachmentRequest +import com.nextcloud.talk.models.json.conversations.ConversationPresetsOverall import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.conversations.RoomsOverall import com.nextcloud.talk.models.json.generic.GenericOverall @@ -92,6 +93,12 @@ interface NcApiCoroutines { @FieldMap options: Map? ): RoomOverall + @GET + suspend fun getConversationPresets( + @Header("Authorization") authorization: String?, + @Url url: String + ): ConversationPresetsOverall + @POST suspend fun createRoomWithBody( @Header("Authorization") authorization: String?, diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index 687cf094a1..93b662613b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -21,7 +21,6 @@ import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -42,11 +41,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Campaign -import androidx.compose.material.icons.outlined.Chat -import androidx.compose.material.icons.outlined.Podcasts -import androidx.compose.material.icons.outlined.VolumeUp import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -59,7 +53,6 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -101,6 +94,7 @@ import com.nextcloud.talk.components.AvatarEditPanelState import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.loadImage +import com.nextcloud.talk.conversationcreation.ui.ConversationPresets import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser @@ -234,7 +228,7 @@ fun ConversationCreationScreen( }, onDeleteAvatar = { conversationCreationViewModel.updateSelectedImageUri(null) - conversationCreationViewModel.updateSelectedEmoji(null) + conversationCreationViewModel.updateSelectedEmojiAvatar(null) } ) } @@ -378,144 +372,6 @@ fun ConversationNameAndDescription(conversationCreationViewModel: ConversationCr ) } -@Suppress("LongMethod") -@SuppressLint("SuspiciousIndentation") -@Composable -fun ConversationPresets(conversationCreationViewModel: ConversationCreationViewModel) { - val preset by conversationCreationViewModel.conversationPreset - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - SelectableCard( - modifier = Modifier.weight(1f), - title = stringResource(R.string.default_room), - subtitle = stringResource(R.string.default_room_preset), - icon = Icons.Outlined.Chat, - isSelected = preset == ConversationPreset.DEFAULT, - onClick = { conversationCreationViewModel.updateConversationPreset(ConversationPreset.DEFAULT) } - ) - - if (conversationCreationViewModel.canCreateVoiceRoom) { - SelectableCard( - modifier = Modifier.weight(1f), - title = stringResource(R.string.voice_room), - subtitle = stringResource(R.string.voice_room_preset), - icon = Icons.Outlined.VolumeUp, - isSelected = preset == ConversationPreset.VOICE_ROOM, - onClick = { conversationCreationViewModel.updateConversationPreset(ConversationPreset.VOICE_ROOM) } - ) - } else { - Spacer(modifier = Modifier.weight(1f)) - } - } - - if (conversationCreationViewModel.canCreateChannel) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - SelectableCard( - modifier = Modifier.weight(1f), - title = stringResource(R.string.nc_channel), - subtitle = stringResource(R.string.nc_channel_description), - icon = Icons.Outlined.Podcasts, - isSelected = preset == ConversationPreset.CHANNEL, - onClick = { conversationCreationViewModel.updateConversationPreset(ConversationPreset.CHANNEL) } - ) - - if (conversationCreationViewModel.canCreateAnnouncement) { - SelectableCard( - modifier = Modifier.weight(1f), - title = stringResource(R.string.nc_announcement), - subtitle = stringResource(R.string.nc_announcement_description), - icon = Icons.Outlined.Campaign, - isSelected = preset == ConversationPreset.ANNOUNCEMENT, - onClick = { - conversationCreationViewModel.updateConversationPreset( - ConversationPreset.ANNOUNCEMENT - ) - } - ) - } else { - Spacer(modifier = Modifier.weight(1f)) - } - } - } - } -} - -@Suppress("LongParameterList") -@Composable -fun SelectableCard( - modifier: Modifier = Modifier, - title: String, - subtitle: String, - icon: ImageVector, - isSelected: Boolean, - onClick: () -> Unit, - badgeText: String? = null -) { - val borderColor = if (isSelected) Color.LightGray else Color.Transparent - val borderWidth = 1.dp - - Column( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .clickable { onClick() } - .border( - width = borderWidth, - color = borderColor, - shape = RoundedCornerShape(8.dp) - ) - .padding(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Text( - text = title, - fontWeight = FontWeight.Bold, - fontSize = 15.sp, - modifier = Modifier.weight(1f, fill = false) - ) - } - - if (badgeText != null) { - Surface( - color = MaterialTheme.colorScheme.primaryContainer, - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = badgeText, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - Text( - text = subtitle, - fontSize = 13.sp, - lineHeight = 18.sp - ) - } -} - @Suppress("LongMethod") @SuppressLint("SuspiciousIndentation") @Composable @@ -616,6 +472,7 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM val isOpenForGuestAppUsers = conversationCreationViewModel.isOpenForGuestAppUsers val isPasswordSet = conversationCreationViewModel.password.collectAsState().value.isNotEmpty() + val isListablePinned = ConversationParameter.LISTABLE in conversationCreationViewModel.pinnedParameters Text( text = stringResource(id = R.string.nc_new_conversation_visibility), @@ -657,6 +514,7 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM switch = { Switch( checked = isConversationAvailableForRegisteredUsers, + enabled = !isListablePinned, onCheckedChange = { conversationCreationViewModel.openConversationToRegisteredUsers(it) } ) }, @@ -669,6 +527,7 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM switch = { Switch( checked = isOpenForGuestAppUsers, + enabled = !isListablePinned, onCheckedChange = { conversationCreationViewModel.openConversationToGuestAppUsers(it) } ) }, @@ -859,7 +718,7 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo contentAlignment = Alignment.Center ) { Button( - enabled = !isCreatingRoom, + enabled = !isCreatingRoom && !conversationCreationViewModel.isLoadingPresets, onClick = { conversationCreationViewModel.createRoomAndAddParticipants { roomToken -> val bundle = Bundle() diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreator.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreator.kt index a2ea60759e..654145779d 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreator.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreator.kt @@ -46,6 +46,7 @@ data class NewConversation( */ class ConversationCreator @Inject constructor(private val repository: ConversationCreationRepository) { + @Suppress("Detekt.TooGenericExceptionCaught") suspend fun create(user: User, newConversation: NewConversation): Conversation? { val capabilities = user.capabilities?.spreedCapability val credentials = ApiUtils.getCredentials(user.username, user.token) @@ -67,7 +68,13 @@ class ConversationCreator @Inject constructor(private val repository: Conversati createWithFollowUpRequests(context, newConversation) } - conversation?.token?.let { saveAvatar(context, newConversation, it) } + conversation?.token?.let { token -> + try { + saveAvatar(context, newConversation, token) + } catch (e: Exception) { + Log.e(TAG, "Failed to set the avatar of the created conversation", e) + } + } return conversation } @@ -81,7 +88,7 @@ class ConversationCreator @Inject constructor(private val repository: Conversati val body = CreateRoomRequest().apply { roomType = params.roomType.toString() roomName = newConversation.name - preset = newConversation.preset.takeIf { it != ConversationPreset.DEFAULT } + preset = newConversation.preset.takeIf { it != ConversationPresetId.DEFAULT } description = newConversation.description readOnly = params.readOnly listable = params.listable @@ -116,7 +123,7 @@ class ConversationCreator @Inject constructor(private val repository: Conversati roomType = params.roomType.toString(), baseUrl = context.user.baseUrl, conversationName = newConversation.name, - preset = newConversation.preset.takeIf { it != ConversationPreset.DEFAULT } + preset = newConversation.preset.takeIf { it != ConversationPresetId.DEFAULT } ) val conversation = repository.createRoom(context.credentials, retrofitBucket).ocs?.data val token = conversation?.token?.takeIf { it.isNotEmpty() } ?: return conversation diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationPresetModel.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationPresetModel.kt new file mode 100644 index 0000000000..44ec1aa875 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationPresetModel.kt @@ -0,0 +1,74 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationcreation + +import com.nextcloud.talk.models.json.conversations.ConversationPreset + +/** + * A conversation type offered by the server, with the parameters it applies on creation. + */ +data class ConversationPresetModel( + val identifier: String, + val name: String, + val description: String, + val parameters: Map +) { + companion object { + fun mapToConversationPresetModel(preset: ConversationPreset): ConversationPresetModel? { + val identifier = preset.identifier ?: return null + return ConversationPresetModel( + identifier = identifier, + name = preset.name.orEmpty(), + description = preset.description.orEmpty(), + parameters = preset.parameters.orEmpty() + ) + } + } +} + +/** + * Identifiers of the conversation types the server offers. + */ +object ConversationPresetId { + const val DEFAULT = "default" + const val FORCED = "forced" + const val VOICE_ROOM = "voiceroom" + const val PRESENTATION = "presentation" + const val WEBINAR = "webinar" + const val CLASSIFIED = "classified" + const val CHANNEL = "channel" + const val ANNOUNCEMENT = "announcement" +} + +/** + * The presets that are offered for selection. The forced preset is applied by the server and is + * never a conversation type of its own. + */ +fun List.selectable(): List = + filterNot { it.identifier == ConversationPresetId.FORCED } + +/** + * The parameters of a single preset, empty when the server does not offer it. + */ +fun List.parametersOf(identifier: String): Map = + firstOrNull { it.identifier == identifier }?.parameters.orEmpty() + +/** + * The parameters a conversation of the given type is created with: the administrator configured + * defaults, then the values of the selected preset, then the parameters the user chose, and finally + * the ones an administrator pinned, which the server enforces on top of any request anyway. + */ +fun List.parametersFor( + identifier: String, + chosenByUser: Map = emptyMap() +): CreateConversationParams = + CreateConversationParams() + .withParameters(parametersOf(ConversationPresetId.DEFAULT)) + .withParameters(parametersOf(identifier)) + .withParameters(chosenByUser) + .withParameters(parametersOf(ConversationPresetId.FORCED)) diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/CreateConversationParams.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/CreateConversationParams.kt index b4bf3c72cb..7a327c5161 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/CreateConversationParams.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/CreateConversationParams.kt @@ -29,6 +29,7 @@ data class CreateConversationParams( const val ROOM_TYPE_PUBLIC = 3 const val READ_WRITE = 0 + const val READ_ONLY = 1 const val LISTABLE_NONE = 0 const val LISTABLE_USERS = 1 @@ -38,10 +39,15 @@ data class CreateConversationParams( const val MESSAGE_EXPIRATION_ONE_HOUR = 3600 const val LOBBY_DISABLED = 0 + const val LOBBY_MODERATORS_ONLY = 1 const val SIP_DISABLED = 0 + const val SIP_WITHOUT_PIN = 2 const val MENTION_PERMISSIONS_EVERYONE = 0 + const val MENTION_PERMISSIONS_MODERATORS = 1 + + const val PERMISSIONS_MAX = 511 } } @@ -61,53 +67,40 @@ object ConversationParameter { } /** - * Identifiers of the conversation presets the server offers. - */ -object ConversationPreset { - const val DEFAULT = "default" - const val VOICE_ROOM = "voiceroom" - const val CHANNEL = "channel" - const val ANNOUNCEMENT = "announcement" -} - -/** - * Parameters each preset applies, mirroring the definitions of the server. - */ -object ConversationPresetParameters { - - fun of(preset: String): Map = presets[preset].orEmpty() - - private val channel = mapOf( - ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_USERS, - ConversationParameter.PERMISSIONS to (ParticipantPermissions.CUSTOM or ParticipantPermissions.REACT) - ) - - private val presets = mapOf( - ConversationPreset.DEFAULT to emptyMap(), - ConversationPreset.VOICE_ROOM to mapOf( - ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_USERS, - ConversationParameter.MESSAGE_EXPIRATION to CreateConversationParams.MESSAGE_EXPIRATION_ONE_HOUR - ), - ConversationPreset.CHANNEL to channel, - ConversationPreset.ANNOUNCEMENT to channel + mapOf( - ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_NONE - ) - ) -} - -/** - * Returns these parameters with the given ones applied on top, leaving those the caller does not - * address untouched. + * Returns these parameters with the given ones applied on top, leaving untouched what the caller + * does not address and what the conversation creation endpoint would refuse. */ fun CreateConversationParams.withParameters(parameters: Map): CreateConversationParams = copy( - roomType = parameters[ConversationParameter.ROOM_TYPE] ?: roomType, - readOnly = parameters[ConversationParameter.READ_ONLY] ?: readOnly, - listable = parameters[ConversationParameter.LISTABLE] ?: listable, - messageExpiration = parameters[ConversationParameter.MESSAGE_EXPIRATION] ?: messageExpiration, - lobbyState = parameters[ConversationParameter.LOBBY_STATE] ?: lobbyState, - sipEnabled = parameters[ConversationParameter.SIP_ENABLED] ?: sipEnabled, - permissions = parameters[ConversationParameter.PERMISSIONS] ?: permissions, - recordingConsent = parameters[ConversationParameter.RECORDING_CONSENT] ?: recordingConsent, - mentionPermissions = parameters[ConversationParameter.MENTION_PERMISSIONS] ?: mentionPermissions + roomType = parameters.accepted(ConversationParameter.ROOM_TYPE, roomType) { it in roomTypes }, + readOnly = parameters.accepted(ConversationParameter.READ_ONLY, readOnly) { it in readOnlyStates }, + listable = parameters.accepted(ConversationParameter.LISTABLE, listable) { it in listableScopes }, + messageExpiration = parameters.accepted(ConversationParameter.MESSAGE_EXPIRATION, messageExpiration) { + it >= CreateConversationParams.MESSAGE_EXPIRATION_OFF + }, + lobbyState = parameters.accepted(ConversationParameter.LOBBY_STATE, lobbyState) { it in lobbyStates }, + sipEnabled = parameters.accepted(ConversationParameter.SIP_ENABLED, sipEnabled) { it in sipStates }, + permissions = parameters.accepted(ConversationParameter.PERMISSIONS, permissions) { + it in permissionsRange + }, + recordingConsent = parameters.accepted(ConversationParameter.RECORDING_CONSENT, recordingConsent) { + it in recordingConsentStates + }, + mentionPermissions = parameters.accepted(ConversationParameter.MENTION_PERMISSIONS, mentionPermissions) { + it in mentionPermissionsStates + } ) + +private fun Map.accepted(parameter: String, current: Int, isAccepted: (Int) -> Boolean): Int = + this[parameter]?.takeIf(isAccepted) ?: current + +private val roomTypes = setOf(CreateConversationParams.ROOM_TYPE_GROUP, CreateConversationParams.ROOM_TYPE_PUBLIC) +private val readOnlyStates = CreateConversationParams.READ_WRITE..CreateConversationParams.READ_ONLY +private val listableScopes = CreateConversationParams.LISTABLE_NONE..CreateConversationParams.LISTABLE_ALL +private val lobbyStates = CreateConversationParams.LOBBY_DISABLED..CreateConversationParams.LOBBY_MODERATORS_ONLY +private val sipStates = CreateConversationParams.SIP_DISABLED..CreateConversationParams.SIP_WITHOUT_PIN +private val permissionsRange = ParticipantPermissions.DEFAULT..CreateConversationParams.PERMISSIONS_MAX +private val recordingConsentStates = + CapabilitiesUtil.RECORDING_CONSENT_NOT_REQUIRED..CapabilitiesUtil.RECORDING_CONSENT_REQUIRED +private val mentionPermissionsStates = + CreateConversationParams.MENTION_PERMISSIONS_EVERYONE..CreateConversationParams.MENTION_PERMISSIONS_MODERATORS diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt index 7f3decfb7a..8bbe420631 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt @@ -11,6 +11,7 @@ import com.nextcloud.talk.conversationinfo.CreateRoomRequest import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.RetrofitBucket import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.conversations.ConversationPreset import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall @@ -18,6 +19,8 @@ import java.io.File interface ConversationCreationRepository { + suspend fun getConversationPresets(credentials: String?, url: String): List + suspend fun setConversationDescription( credentials: String?, url: String, diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt index a7cedd0cf9..b7e60ff9ea 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt @@ -12,6 +12,7 @@ import com.nextcloud.talk.conversationinfo.CreateRoomRequest import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.RetrofitBucket import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.conversations.ConversationPreset import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall @@ -25,6 +26,9 @@ import javax.inject.Inject class ConversationCreationRepositoryImpl @Inject constructor(private val ncApiCoroutines: NcApiCoroutines) : ConversationCreationRepository { + override suspend fun getConversationPresets(credentials: String?, url: String): List = + ncApiCoroutines.getConversationPresets(credentials, url).ocs?.data.orEmpty() + override suspend fun setConversationDescription( credentials: String?, url: String, diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/ConversationPresetCards.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/ConversationPresetCards.kt new file mode 100644 index 0000000000..4f5b2af546 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/ConversationPresetCards.kt @@ -0,0 +1,211 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationcreation.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.border +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Campaign +import androidx.compose.material.icons.automirrored.outlined.Chat +import androidx.compose.material.icons.outlined.Podcasts +import androidx.compose.material.icons.automirrored.outlined.VolumeUp +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.talk.R +import com.nextcloud.talk.conversationcreation.ConversationPresetId +import com.nextcloud.talk.conversationcreation.ConversationPresetModel +import com.nextcloud.talk.conversationcreation.selectable +import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel +import com.nextcloud.talk.conversationcreation.viewmodel.PresetsUiState + +/** + * The conversation types the server offers, as a grid of selectable cards. + */ +@Composable +fun ConversationPresets(conversationCreationViewModel: ConversationCreationViewModel) { + when (val state = conversationCreationViewModel.presets.value) { + is PresetsUiState.Loading -> PresetsLoading() + is PresetsUiState.Error -> PresetsError { conversationCreationViewModel.loadPresets() } + is PresetsUiState.Success -> PresetCards( + presets = state.presets.selectable(), + selected = conversationCreationViewModel.conversationPreset.value, + onSelect = { conversationCreationViewModel.updateConversationPreset(it) } + ) + } +} + +@Composable +private fun PresetsLoading() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } +} + +@Composable +private fun PresetsError(onRetry: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.nc_conversation_types_error), + fontSize = 13.sp, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onRetry) { + Text(text = stringResource(R.string.nc_retry)) + } + } +} + +@Composable +private fun PresetCards(presets: List, selected: String, onSelect: (String) -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + val rows = remember(presets) { presets.chunked(PRESET_COLUMNS) } + rows.forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + row.forEach { preset -> + val label = presetLabels[preset.identifier] + SelectableCard( + content = SelectableCardContent( + title = label?.let { stringResource(it.title) } ?: preset.name, + subtitle = label?.let { stringResource(it.description) } ?: preset.description, + icon = presetIcons[preset.identifier] ?: Icons.AutoMirrored.Outlined.Chat + ), + isSelected = preset.identifier == selected, + onClick = { onSelect(preset.identifier) }, + modifier = Modifier.weight(1f) + ) + } + repeat(PRESET_COLUMNS - row.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +/** + * What a selectable card shows. + */ +data class SelectableCardContent(val title: String, val subtitle: String, val icon: ImageVector) + +@Composable +fun SelectableCard( + content: SelectableCardContent, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent + val borderWidth = 1.dp + + Column( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .selectable(selected = isSelected, role = Role.RadioButton, onClick = onClick) + .border( + width = borderWidth, + color = borderColor, + shape = RoundedCornerShape(8.dp) + ) + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = content.icon, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Text( + text = content.title, + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + maxLines = TITLE_MAX_LINES, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = content.subtitle, + fontSize = 13.sp, + lineHeight = 18.sp, + maxLines = DESCRIPTION_MAX_LINES, + overflow = TextOverflow.Ellipsis + ) + } +} + +private const val PRESET_COLUMNS = 2 +private const val TITLE_MAX_LINES = 2 +private const val DESCRIPTION_MAX_LINES = 4 + +private data class PresetLabel(@StringRes val title: Int, @StringRes val description: Int) + +private val presetLabels = mapOf( + ConversationPresetId.DEFAULT to PresetLabel(R.string.default_room, R.string.default_room_preset), + ConversationPresetId.VOICE_ROOM to PresetLabel(R.string.voice_room, R.string.voice_room_preset), + ConversationPresetId.CHANNEL to PresetLabel(R.string.nc_channel, R.string.nc_channel_description), + ConversationPresetId.ANNOUNCEMENT to PresetLabel( + R.string.nc_announcement, + R.string.nc_announcement_description + ) +) + +private val presetIcons = mapOf( + ConversationPresetId.DEFAULT to Icons.AutoMirrored.Outlined.Chat, + ConversationPresetId.VOICE_ROOM to Icons.AutoMirrored.Outlined.VolumeUp, + ConversationPresetId.CHANNEL to Icons.Outlined.Podcasts, + ConversationPresetId.ANNOUNCEMENT to Icons.Outlined.Campaign +) diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt index 8c462b85c9..4ee5e7c814 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt @@ -9,15 +9,19 @@ package com.nextcloud.talk.conversationcreation.viewmodel import android.net.Uri import android.util.Log +import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.conversationcreation.ConversationCreator -import com.nextcloud.talk.conversationcreation.ConversationPreset -import com.nextcloud.talk.conversationcreation.ConversationPresetParameters +import com.nextcloud.talk.conversationcreation.ConversationParameter +import com.nextcloud.talk.conversationcreation.ConversationPresetId +import com.nextcloud.talk.conversationcreation.ConversationPresetModel import com.nextcloud.talk.conversationcreation.CreateConversationParams import com.nextcloud.talk.conversationcreation.NewConversation -import com.nextcloud.talk.conversationcreation.withParameters +import com.nextcloud.talk.conversationcreation.data.ConversationCreationRepository +import com.nextcloud.talk.conversationcreation.parametersFor +import com.nextcloud.talk.conversationcreation.parametersOf import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser import com.nextcloud.talk.models.json.conversations.Conversation @@ -25,12 +29,15 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject class ConversationCreationViewModel @Inject constructor( + private val repository: ConversationCreationRepository, private val conversationCreator: ConversationCreator, private val currentUserProvider: CurrentUserProviderOld ) : ViewModel() { @@ -60,15 +67,6 @@ class ConversationCreationViewModel @Inject constructor( SpreedFeatures.CONVERSATION_PRESETS ) - val canCreateVoiceRoom = spreedCapabilities != null && CapabilitiesUtil.isAbleToCall(spreedCapabilities) - - val canCreateChannel = CapabilitiesUtil.hasSpreedFeatureCapability( - spreedCapabilities, - SpreedFeatures.ANNOUNCEMENT_PRESET - ) - - val canCreateAnnouncement = canCreateChannel && CapabilitiesUtil.isAdmin(spreedCapabilities) - fun updateSelectedParticipants(participants: List) { _selectedParticipants.value = participants } @@ -81,31 +79,68 @@ class ConversationCreationViewModel @Inject constructor( } } - fun updateSelectedEmoji(emoji: String?) { + fun updateSelectedEmojiAvatar(emoji: String?, color: Int? = null) { _selectedEmoji.value = emoji + _selectedEmojiColor.value = color.takeIf { emoji != null } if (emoji != null) { _selectedImageUri.value = null - } else { - _selectedEmojiColor.value = null } } - fun updateSelectedEmojiAvatar(emoji: String, color: Int?) { - _selectedEmoji.value = emoji - _selectedEmojiColor.value = color - _selectedImageUri.value = null - } - private val _roomName = MutableStateFlow("") val roomName: StateFlow = _roomName private val _password = MutableStateFlow("") val password: StateFlow = _password private val _conversationDescription = MutableStateFlow("") val conversationDescription: StateFlow = _conversationDescription - val conversationPreset = mutableStateOf(ConversationPreset.DEFAULT) + val conversationPreset = mutableStateOf(ConversationPresetId.DEFAULT) private val conversationParams = mutableStateOf(CreateConversationParams()) + private val parametersChosenByUser = mutableStateOf>(emptyMap()) + + private val _presets = mutableStateOf(PresetsUiState.Loading) + val presets: State = _presets + + private var presetsJob: Job? = null + + val isLoadingPresets: Boolean + get() = showPresetSelection && _presets.value is PresetsUiState.Loading + + val pinnedParameters: Set + get() = (_presets.value as? PresetsUiState.Success) + ?.presets + ?.parametersOf(ConversationPresetId.FORCED) + ?.keys + .orEmpty() + + init { + if (showPresetSelection) { + loadPresets() + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun loadPresets() { + presetsJob?.cancel() + _presets.value = PresetsUiState.Loading + presetsJob = viewModelScope.launch { + try { + val presets = repository.getConversationPresets( + ApiUtils.getCredentials(_currentUser.username, _currentUser.token), + ApiUtils.getUrlForConversationPresets(_currentUser.baseUrl) + ).mapNotNull { ConversationPresetModel.mapToConversationPresetModel(it) } + _presets.value = PresetsUiState.Success(presets) + recomputeParams() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to load the conversation presets", e) + _presets.value = PresetsUiState.Error + } + } + } + fun updateRoomName(roomName: String) { _roomName.value = roomName } @@ -120,8 +155,9 @@ class ConversationCreationViewModel @Inject constructor( fun updateConversationPreset(preset: String) { conversationPreset.value = preset - val visibilityChosenByUser = CreateConversationParams(roomType = conversationParams.value.roomType) - updateParams(visibilityChosenByUser.withParameters(ConversationPresetParameters.of(preset))) + val loaded = (_presets.value as? PresetsUiState.Success)?.presets.orEmpty() + parametersChosenByUser.value -= loaded.parametersOf(preset).keys + recomputeParams() } val isGuestsAllowed: Boolean @@ -139,7 +175,8 @@ class ConversationCreationViewModel @Inject constructor( } else { CreateConversationParams.ROOM_TYPE_GROUP } - updateParams(conversationParams.value.copy(roomType = roomType)) + parametersChosenByUser.value += ConversationParameter.ROOM_TYPE to roomType + recomputeParams() } fun openConversationToRegisteredUsers(open: Boolean) { @@ -148,7 +185,8 @@ class ConversationCreationViewModel @Inject constructor( } else { CreateConversationParams.LISTABLE_NONE } - updateParams(conversationParams.value.copy(listable = listable)) + parametersChosenByUser.value += ConversationParameter.LISTABLE to listable + recomputeParams() } fun openConversationToGuestAppUsers(open: Boolean) { @@ -157,10 +195,13 @@ class ConversationCreationViewModel @Inject constructor( } else { CreateConversationParams.LISTABLE_USERS } - updateParams(conversationParams.value.copy(listable = listable)) + parametersChosenByUser.value += ConversationParameter.LISTABLE to listable + recomputeParams() } - private fun updateParams(params: CreateConversationParams) { + private fun recomputeParams() { + val loaded = (_presets.value as? PresetsUiState.Success)?.presets.orEmpty() + val params = loaded.parametersFor(conversationPreset.value, parametersChosenByUser.value) conversationParams.value = params } @@ -212,6 +253,12 @@ class ConversationCreationViewModel @Inject constructor( } } +sealed interface PresetsUiState { + data object Loading : PresetsUiState + data class Success(val presets: List) : PresetsUiState + data object Error : PresetsUiState +} + sealed class RoomUIState { data object None : RoomUIState() data class Success(val conversation: Conversation?) : RoomUIState() diff --git a/app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationPreset.kt b/app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationPreset.kt new file mode 100644 index 0000000000..7f20a094cc --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationPreset.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.conversations + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +/** + * A conversation type offered by the server, with the parameters it applies on creation. + */ +@Parcelize +@JsonObject +data class ConversationPreset( + @JsonField(name = ["identifier"]) + var identifier: String? = null, + @JsonField(name = ["name"]) + var name: String? = null, + @JsonField(name = ["description"]) + var description: String? = null, + @JsonField(name = ["parameters"]) + var parameters: HashMap? = null +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null, null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationPresetsOverall.kt b/app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationPresetsOverall.kt new file mode 100644 index 0000000000..c61ea5fc3a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationPresetsOverall.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.conversations + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import com.nextcloud.talk.models.json.generic.GenericMeta +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class ConversationPresetsOverall( + @JsonField(name = ["ocs"]) + var ocs: ConversationPresetsOCS? = null +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} + +@Parcelize +@JsonObject +data class ConversationPresetsOCS( + @JsonField(name = ["meta"]) + var meta: GenericMeta?, + @JsonField(name = ["data"]) + var data: List? = null +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt index a214027525..2f7132f48f 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt @@ -185,6 +185,9 @@ object ApiUtils { fun getUrlForNoteToSelf(version: Int, baseUrl: String?): String = getUrlForApi(version, baseUrl) + "/room/note-to-self" + fun getUrlForConversationPresets(baseUrl: String?): String = + "$baseUrl$OCS_API_VERSION$SPREED_API_VERSION/presets/room" + @JvmStatic fun getUrlForRoom(version: Int, baseUrl: String?, token: String?): String = getUrlForRooms(version, baseUrl) + "/" + token diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt index 8ab1dc681b..6da5e49c3b 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -244,8 +244,7 @@ class ComposePreviewUtils private constructor(context: Context) { get() = ContactsViewModel(contactsRepository, currentUserProvider) val conversationCreationViewModel: ConversationCreationViewModel - get() = ConversationCreationViewModel( - ConversationCreator(ConversationCreationRepositoryImpl(ncApiCoroutines)), - userProvider - ) + get() = ConversationCreationRepositoryImpl(ncApiCoroutines).let { repository -> + ConversationCreationViewModel(repository, ConversationCreator(repository), userProvider) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bbe92cd498..5f73cbd560 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -925,6 +925,8 @@ How to translate with transifex: Voice room Send messages, create threads, and start voice and video calls Directly join the call, ideal for catch-ups or spontaneous meetings + Conversation types could not be loaded + Retry Invitations diff --git a/app/src/test/java/com/nextcloud/talk/conversationcreation/CreateConversationParamsTest.kt b/app/src/test/java/com/nextcloud/talk/conversationcreation/CreateConversationParamsTest.kt index 55dd72f8c5..b1c928e3df 100644 --- a/app/src/test/java/com/nextcloud/talk/conversationcreation/CreateConversationParamsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/conversationcreation/CreateConversationParamsTest.kt @@ -11,75 +11,207 @@ import org.junit.Assert.assertEquals import org.junit.Test /** - * Preset parameters and how they fold onto the parameters of a new conversation. + * How the parameters the presets endpoint reports are folded onto the parameters of a new + * conversation. * - * The expected values mirror the preset definitions of the server in `lib/RoomPresets` of the - * `spreed` app, which the client has to apply itself: the creation endpoint derives only the - * conversation attributes from the preset identifier and stores whatever parameters the request - * carried. + * The client has to apply them itself: the creation endpoint derives only the conversation + * attributes from the preset identifier and stores whatever parameters the request carried. The + * documented order is default preset, then selected preset, then the user's own selection, and + * finally the forced preset, which the server applies on top by itself. */ class CreateConversationParamsTest { + private fun preset(identifier: String, parameters: Map) = + ConversationPresetModel(identifier = identifier, name = identifier, description = "", parameters = parameters) + @Test - fun `default preset applies no parameters of its own`() { - val params = CreateConversationParams().withParameters( - ConversationPresetParameters.of(ConversationPreset.DEFAULT) + fun `parameters the preset does not address are left untouched`() { + val chosenByUser = CreateConversationParams(roomType = CreateConversationParams.ROOM_TYPE_PUBLIC) + + val params = chosenByUser.withParameters( + mapOf(ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_USERS) ) - assertEquals(CreateConversationParams(), params) + assertEquals(CreateConversationParams.ROOM_TYPE_PUBLIC, params.roomType) + assertEquals(CreateConversationParams.LISTABLE_USERS, params.listable) } @Test - fun `voice room is listable for users and expires messages after an hour`() { + fun `every documented parameter is folded in`() { val params = CreateConversationParams().withParameters( - ConversationPresetParameters.of(ConversationPreset.VOICE_ROOM) + mapOf( + ConversationParameter.ROOM_TYPE to CreateConversationParams.ROOM_TYPE_PUBLIC, + ConversationParameter.READ_ONLY to 1, + ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_ALL, + ConversationParameter.MESSAGE_EXPIRATION to CreateConversationParams.MESSAGE_EXPIRATION_ONE_HOUR, + ConversationParameter.LOBBY_STATE to 1, + ConversationParameter.SIP_ENABLED to 1, + ConversationParameter.PERMISSIONS to ParticipantPermissions.CUSTOM, + ConversationParameter.RECORDING_CONSENT to 1, + ConversationParameter.MENTION_PERMISSIONS to 1 + ) + ) + + assertEquals( + CreateConversationParams( + roomType = CreateConversationParams.ROOM_TYPE_PUBLIC, + readOnly = 1, + listable = CreateConversationParams.LISTABLE_ALL, + messageExpiration = CreateConversationParams.MESSAGE_EXPIRATION_ONE_HOUR, + lobbyState = 1, + sipEnabled = 1, + permissions = ParticipantPermissions.CUSTOM, + recordingConsent = 1, + mentionPermissions = 1 + ), + params + ) + } + + @Test + fun `the selected preset overrides the administrator configured default`() { + val presets = listOf( + preset( + ConversationPresetId.DEFAULT, + mapOf( + ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_ALL, + ConversationParameter.MENTION_PERMISSIONS to 1 + ) + ), + preset( + ConversationPresetId.CHANNEL, + mapOf(ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_USERS) + ) ) + val params = presets.parametersFor(ConversationPresetId.CHANNEL) + assertEquals(CreateConversationParams.LISTABLE_USERS, params.listable) + assertEquals(1, params.mentionPermissions) + } + + @Test + fun `the default preset alone applies the administrator configured values`() { + val presets = listOf( + preset( + ConversationPresetId.DEFAULT, + mapOf(ConversationParameter.MESSAGE_EXPIRATION to CreateConversationParams.MESSAGE_EXPIRATION_ONE_HOUR) + ) + ) + + val params = presets.parametersFor(ConversationPresetId.DEFAULT) + assertEquals(CreateConversationParams.MESSAGE_EXPIRATION_ONE_HOUR, params.messageExpiration) } @Test - fun `channel grants reactions only and is listable for users`() { - val params = CreateConversationParams().withParameters( - ConversationPresetParameters.of(ConversationPreset.CHANNEL) + fun `what the user chose wins over both the default and the selected preset`() { + val presets = listOf( + preset( + ConversationPresetId.DEFAULT, + mapOf(ConversationParameter.ROOM_TYPE to CreateConversationParams.ROOM_TYPE_GROUP) + ), + preset( + ConversationPresetId.VOICE_ROOM, + mapOf(ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_USERS) + ) + ) + val chosenByUser = mapOf( + ConversationParameter.ROOM_TYPE to CreateConversationParams.ROOM_TYPE_PUBLIC, + ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_ALL ) - assertEquals( - ParticipantPermissions.CUSTOM or ParticipantPermissions.REACT, - params.permissions + val params = presets.parametersFor(ConversationPresetId.VOICE_ROOM, chosenByUser) + + assertEquals(CreateConversationParams.ROOM_TYPE_PUBLIC, params.roomType) + assertEquals(CreateConversationParams.LISTABLE_ALL, params.listable) + } + + @Test + fun `a preset overrides the default it does not agree with`() { + val presets = listOf( + preset( + ConversationPresetId.DEFAULT, + mapOf(ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_NONE) + ), + preset( + ConversationPresetId.CHANNEL, + mapOf(ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_USERS) + ) ) + + val params = presets.parametersFor(ConversationPresetId.CHANNEL) + assertEquals(CreateConversationParams.LISTABLE_USERS, params.listable) } @Test - fun `announcement is a channel that is not listable`() { - val channel = CreateConversationParams().withParameters( - ConversationPresetParameters.of(ConversationPreset.CHANNEL) - ) - val announcement = CreateConversationParams().withParameters( - ConversationPresetParameters.of(ConversationPreset.ANNOUNCEMENT) + fun `a preset the server does not offer leaves the parameters untouched`() { + val presets = listOf(preset(ConversationPresetId.DEFAULT, emptyMap())) + + val params = presets.parametersFor(ConversationPresetId.WEBINAR) + + assertEquals(CreateConversationParams(), params) + } + + @Test + fun `a value the creation endpoint would refuse is ignored`() { + val presets = listOf( + preset( + ConversationPresetId.DEFAULT, + mapOf( + ConversationParameter.RECORDING_CONSENT to 2, + ConversationParameter.ROOM_TYPE to 1, + ConversationParameter.MESSAGE_EXPIRATION to -1, + ConversationParameter.PERMISSIONS to 512 + ) + ) ) - assertEquals(channel.permissions, announcement.permissions) - assertEquals(CreateConversationParams.LISTABLE_NONE, announcement.listable) + val params = presets.parametersFor(ConversationPresetId.DEFAULT) + + assertEquals(CreateConversationParams(), params) } @Test - fun `parameters the preset does not address are left untouched`() { - val chosenByUser = CreateConversationParams(roomType = CreateConversationParams.ROOM_TYPE_PUBLIC) + fun `without any presets the parameters the user chose still apply`() { + val chosenByUser = mapOf(ConversationParameter.ROOM_TYPE to CreateConversationParams.ROOM_TYPE_PUBLIC) - val params = chosenByUser.withParameters( - ConversationPresetParameters.of(ConversationPreset.CHANNEL) - ) + val params = emptyList().parametersFor(ConversationPresetId.DEFAULT, chosenByUser) assertEquals(CreateConversationParams.ROOM_TYPE_PUBLIC, params.roomType) } @Test - fun `an unknown preset leaves the parameters at their defaults`() { - val params = CreateConversationParams().withParameters(ConversationPresetParameters.of("webinar")) + fun `what an administrator pinned wins over everything else`() { + val presets = listOf( + preset( + ConversationPresetId.FORCED, + mapOf(ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_NONE) + ), + preset( + ConversationPresetId.CHANNEL, + mapOf(ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_USERS) + ) + ) + val chosenByUser = mapOf(ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_ALL) - assertEquals(CreateConversationParams(), params) + val params = presets.parametersFor(ConversationPresetId.CHANNEL, chosenByUser) + + assertEquals(CreateConversationParams.LISTABLE_NONE, params.listable) + } + + @Test + fun `the forced preset is not offered as a conversation type`() { + val presets = listOf( + preset(ConversationPresetId.DEFAULT, emptyMap()), + preset(ConversationPresetId.FORCED, mapOf(ConversationParameter.LISTABLE to 0)), + preset(ConversationPresetId.CHANNEL, emptyMap()) + ) + + assertEquals( + listOf(ConversationPresetId.DEFAULT, ConversationPresetId.CHANNEL), + presets.selectable().map { it.identifier } + ) } } From 2b80ea9874ce2d0cf3c55a4e85f2e5d9dabcb795 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 5 Sep 2026 08:26:29 +0200 Subject: [PATCH 2/2] refactor(capabilities): remove the unused is-admin check CapabilitiesUtil.isAdmin read config => conversations => is-admin, a capability the server does not publish, so it always returned false. Its last caller is gone now that the presets endpoint decides which conversation types a user is offered. Assisted-by: Claude Code:claude-opus-5 Claude-Session: https://claude.ai/code/session_014YnDswTbCmRrVETnwE4twB Signed-off-by: Andy Scherzinger --- .../java/com/nextcloud/talk/utils/CapabilitiesUtil.kt | 10 ---------- 1 file changed, 10 deletions(-) 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 ac7635ba56..879b9a35eb 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt @@ -301,16 +301,6 @@ object CapabilitiesUtil { fun isBanningAvailable(spreedCapabilities: SpreedCapability): Boolean = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.BAN_V1) - fun isAdmin(spreedCapabilities: SpreedCapability?): Boolean { - if (spreedCapabilities?.config?.containsKey("conversations") == true) { - val map = spreedCapabilities.config!!["conversations"] - if (map?.containsKey("is-admin") == true) { - return map["is-admin"].toString().toBoolean() - } - } - return false - } - // endregion //region SpreedCapabilities that can't be used with federation as the settings for them are global