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/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 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 } + ) } }