diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt index e20ee7b61b..98903df3db 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt @@ -44,13 +44,12 @@ class ContactsActivity : BaseActivity() { contactsViewModel.updateIsAddParticipants(isAddParticipants) contactsViewModel.hideAlreadyAddedParticipants(hideAlreadyAddedParticipants) if (isAddParticipants) { - contactsViewModel.updateShareTypes( - listOf( - ShareType.Group.shareType, - ShareType.Email.shareType, - ShareType.Circle.shareType - ) - ) + val onlyLocal = intent.getBooleanExtra(BundleKeys.KEY_ONLY_LOCAL_PARTICIPANTS, false) + val shareTypes = mutableListOf(ShareType.Group.shareType, ShareType.Circle.shareType) + if (!onlyLocal) { + shareTypes.add(ShareType.Email.shareType) + } + contactsViewModel.updateShareTypes(shareTypes) contactsViewModel.getContactsFromSearchParams() } val colorScheme = viewThemeUtils.getColorScheme(this) 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 93b662613b..be67235197 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -87,7 +87,6 @@ import coil.compose.AsyncImage import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.components.AvatarEditPanel import com.nextcloud.talk.components.AvatarEditPanelCallbacks import com.nextcloud.talk.components.AvatarEditPanelState @@ -95,9 +94,14 @@ 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.ui.CreationResultEffect +import com.nextcloud.talk.conversationcreation.ui.ShareCreatedConversation +import com.nextcloud.talk.conversationcreation.ui.openConversation import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser +import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.PickImage @@ -409,6 +413,10 @@ fun AddParticipants( ) intent.putExtra(BundleKeys.KEY_ADD_PARTICIPANTS, true) intent.putExtra("isAddParticipantsEdit", true) + intent.putExtra( + BundleKeys.KEY_ONLY_LOCAL_PARTICIPANTS, + conversationCreationViewModel.isLockedDown + ) launcher.launch(intent) }, textAlign = TextAlign.Right @@ -418,7 +426,7 @@ fun AddParticipants( participants.toSet().forEach { participant -> Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { val imageUri = participant.id?.let { - conversationCreationViewModel.getImageUri(it, true, DisplayUtils.isDarkModeOn(LocalContext.current)) + avatarUri(conversationCreationViewModel.currentUser, it, DisplayUtils.isDarkModeOn(context)) } val errorPlaceholderImage: Int = R.drawable.account_circle_96dp val loadedImage = loadImage(imageUri, context, errorPlaceholderImage) @@ -444,6 +452,10 @@ fun AddParticipants( .clickable { val intent = Intent(context, ContactsActivity::class.java) intent.putExtra(BundleKeys.KEY_ADD_PARTICIPANTS, true) + intent.putExtra( + BundleKeys.KEY_ONLY_LOCAL_PARTICIPANTS, + conversationCreationViewModel.isLockedDown + ) launcher.launch(intent) }, verticalAlignment = Alignment.CenterVertically @@ -472,7 +484,9 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM val isOpenForGuestAppUsers = conversationCreationViewModel.isOpenForGuestAppUsers val isPasswordSet = conversationCreationViewModel.password.collectAsState().value.isNotEmpty() - val isListablePinned = ConversationParameter.LISTABLE in conversationCreationViewModel.pinnedParameters + val isLockedDown = conversationCreationViewModel.isLockedDown + val isListableFixed = isLockedDown || + ConversationParameter.LISTABLE in conversationCreationViewModel.pinnedParameters Text( text = stringResource(id = R.string.nc_new_conversation_visibility), @@ -486,6 +500,7 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM switch = { Switch( checked = isGuestsAllowed, + enabled = !isLockedDown, onCheckedChange = { conversationCreationViewModel.allowGuests(it) } ) }, @@ -514,7 +529,7 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM switch = { Switch( checked = isConversationAvailableForRegisteredUsers, - enabled = !isListablePinned, + enabled = !isListableFixed, onCheckedChange = { conversationCreationViewModel.openConversationToRegisteredUsers(it) } ) }, @@ -527,7 +542,7 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM switch = { Switch( checked = isOpenForGuestAppUsers, - enabled = !isListablePinned, + enabled = !isListableFixed, onCheckedChange = { conversationCreationViewModel.openConversationToGuestAppUsers(it) } ) }, @@ -711,6 +726,34 @@ fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: Con @Composable fun CreateConversation(conversationCreationViewModel: ConversationCreationViewModel, context: Context) { val isCreatingRoom by conversationCreationViewModel.isCreatingRoom.collectAsState() + val creationState by conversationCreationViewModel.creationState.collectAsState() + var createdPublicConversation by rememberSaveable { mutableStateOf(null) } + var createdWithPassword by rememberSaveable { mutableStateOf(false) } + + CreationResultEffect( + creationState = creationState, + context = context, + onPublicConversation = { roomToken, hasPassword -> + createdPublicConversation = roomToken + createdWithPassword = hasPassword + }, + onHandled = { conversationCreationViewModel.clearCreationState() } + ) + + createdPublicConversation?.let { roomToken -> + ShareCreatedConversation( + roomToken = roomToken, + password = conversationCreationViewModel.password.value.takeIf { createdWithPassword }, + currentUser = conversationCreationViewModel.currentUser, + context = context, + onDismiss = { + createdPublicConversation = null + conversationCreationViewModel.clearCreationState() + openConversation(context, roomToken) + } + ) + } + Box( modifier = Modifier .fillMaxWidth() @@ -720,14 +763,7 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo Button( enabled = !isCreatingRoom && !conversationCreationViewModel.isLoadingPresets, onClick = { - conversationCreationViewModel.createRoomAndAddParticipants { roomToken -> - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) - chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - context.startActivity(chatIntent) - } + conversationCreationViewModel.createRoomAndAddParticipants() } ) { if (isCreatingRoom) { @@ -758,3 +794,6 @@ fun ConversationCreationScreenPreview() { ) } } + +private fun avatarUri(user: User, avatarId: String, isDarkMode: Boolean): String = + ApiUtils.getUrlForAvatar(user.baseUrl, avatarId, true, darkMode = isDarkMode) 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 654145779d..9dbae10f49 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreator.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreator.kt @@ -71,6 +71,8 @@ class ConversationCreator @Inject constructor(private val repository: Conversati conversation?.token?.let { token -> try { saveAvatar(context, newConversation, token) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "Failed to set the avatar of the created conversation", e) } @@ -133,7 +135,11 @@ class ConversationCreator @Inject constructor(private val repository: Conversati } else { applyAfterCreation(context, newConversation, token) } - addParticipants(context, newConversation.participants, token) + + val failed = addParticipants(context, newConversation.participants, token) + if (failed.isNotEmpty()) { + conversation.invalidParticipants = HashMap(failed) + } return conversation } @@ -187,9 +193,15 @@ class ConversationCreator @Inject constructor(private val repository: Conversati } @Suppress("Detekt.TooGenericExceptionCaught") - private suspend fun addParticipants(context: RequestContext, participants: List, token: String) { + private suspend fun addParticipants( + context: RequestContext, + participants: List, + token: String + ): Map> { + val failed = mutableMapOf>() participants.forEach { participant -> val participantId = participant.id ?: return@forEach + val source = participant.source ?: ParticipantSource.USERS try { repository.addParticipants( context.credentials, @@ -197,7 +209,7 @@ class ConversationCreator @Inject constructor(private val repository: Conversati context.apiVersion, context.user.baseUrl, token, - participant.source ?: SOURCE_USERS, + source, participantId ) ) @@ -205,8 +217,10 @@ class ConversationCreator @Inject constructor(private val repository: Conversati throw e } catch (e: Exception) { Log.e(TAG, "Failed to add a participant to the created conversation", e) + failed.getOrPut(source) { mutableListOf() }.add(participantId) } } + return failed } private fun participantsOf(autocompleteUsers: List): Participants { @@ -214,11 +228,11 @@ class ConversationCreator @Inject constructor(private val repository: Conversati autocompleteUsers.forEach { autocompleteUser -> val id = autocompleteUser.id ?: return@forEach when (autocompleteUser.source) { - SOURCE_GROUPS -> participants.groups.add(id) - SOURCE_EMAILS -> participants.emails.add(id) - SOURCE_CIRCLES -> participants.teams.add(id) - SOURCE_FEDERATED -> participants.federatedUsers.add(id) - SOURCE_PHONES -> participants.phones.add(id) + ParticipantSource.GROUPS -> participants.groups.add(id) + ParticipantSource.EMAILS -> participants.emails.add(id) + ParticipantSource.CIRCLES -> participants.teams.add(id) + ParticipantSource.FEDERATED -> participants.federatedUsers.add(id) + ParticipantSource.PHONES -> participants.phones.add(id) else -> participants.users.add(id) } } @@ -253,11 +267,5 @@ class ConversationCreator @Inject constructor(private val repository: Conversati companion object { private val TAG = ConversationCreator::class.simpleName private const val COLOR_HEX_MASK = 0xFFFFFF - private const val SOURCE_USERS = "users" - private const val SOURCE_GROUPS = "groups" - private const val SOURCE_EMAILS = "emails" - private const val SOURCE_CIRCLES = "circles" - private const val SOURCE_FEDERATED = "federated" - private const val SOURCE_PHONES = "phones" } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationPresetModel.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationPresetModel.kt index 44ec1aa875..610107b34d 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationPresetModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationPresetModel.kt @@ -43,6 +43,12 @@ object ConversationPresetId { const val CLASSIFIED = "classified" const val CHANNEL = "channel" const val ANNOUNCEMENT = "announcement" + + /** + * Types whose conversations are locked down: their visibility can not be changed and only local + * users, groups and teams can be invited. + */ + val lockedDown = setOf(CLASSIFIED) } /** @@ -68,7 +74,35 @@ fun List.parametersFor( chosenByUser: Map = emptyMap() ): CreateConversationParams = CreateConversationParams() - .withParameters(parametersOf(ConversationPresetId.DEFAULT)) - .withParameters(parametersOf(identifier)) + .withParameters(appliedParametersOf(identifier)) .withParameters(chosenByUser) .withParameters(parametersOf(ConversationPresetId.FORCED)) + +private fun List.appliedParametersOf(identifier: String): Map = + parametersOf(ConversationPresetId.DEFAULT) + parametersOf(identifier) + +/** + * The parameters of the given type that are worth showing to the user, leaving out those an + * administrator pinned for every conversation. + */ +fun List.parametersToPreviewFor(identifier: String): Map { + val forced = parametersOf(ConversationPresetId.FORCED).keys + return appliedParametersOf(identifier).filterKeys { it !in forced } +} + +/** + * Sources an autocomplete result can come from, as the participants API names them. + */ +object ParticipantSource { + const val USERS = "users" + const val GROUPS = "groups" + const val EMAILS = "emails" + const val CIRCLES = "circles" + const val FEDERATED = "federated" + const val PHONES = "phones" + + /** + * Sources that stay on this instance. Locked down conversations accept only these. + */ + val local = setOf(USERS, GROUPS, CIRCLES) +} 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 index 4f5b2af546..40d8f9eab3 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/ConversationPresetCards.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/ConversationPresetCards.kt @@ -23,7 +23,10 @@ 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.CoPresent +import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.Podcasts +import androidx.compose.material.icons.outlined.Shield import androidx.compose.material.icons.automirrored.outlined.VolumeUp import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -37,6 +40,7 @@ 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.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight @@ -44,8 +48,10 @@ 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.ConversationParameter import com.nextcloud.talk.conversationcreation.ConversationPresetId import com.nextcloud.talk.conversationcreation.ConversationPresetModel +import com.nextcloud.talk.conversationcreation.parametersToPreviewFor import com.nextcloud.talk.conversationcreation.selectable import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel import com.nextcloud.talk.conversationcreation.viewmodel.PresetsUiState @@ -58,11 +64,15 @@ fun ConversationPresets(conversationCreationViewModel: ConversationCreationViewM 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) } - ) + is PresetsUiState.Success -> { + val selected = conversationCreationViewModel.conversationPreset.value + PresetCards( + presets = state.presets.selectable(), + selected = selected, + onSelect = { conversationCreationViewModel.updateConversationPreset(it) } + ) + PresetSummary(state.presets.parametersToPreviewFor(selected)) + } } } @@ -135,6 +145,78 @@ private fun PresetCards(presets: List, selected: String */ data class SelectableCardContent(val title: String, val subtitle: String, val icon: ImageVector) +@Composable +private fun PresetSummary(parameters: Map) { + val lines = summaryLines(parameters) + if (lines.isEmpty()) { + return + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.nc_conversation_type_defaults), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + lines.forEach { line -> + Text( + text = "• $line", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = stringResource(R.string.nc_conversation_type_defaults_hint), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun summaryLines(parameters: Map): List = + buildList { + parameters[ConversationParameter.MESSAGE_EXPIRATION]?.takeIf { it > 0 }?.let { seconds -> + add(messageExpirationLine(seconds)) + } + if (parameters[ConversationParameter.READ_ONLY] == 1) { + add(stringResource(R.string.conversation_is_read_only)) + } + if (parameters[ConversationParameter.LOBBY_STATE] == 1) { + add(stringResource(R.string.nc_conversation_type_lobby)) + } + if (parameters[ConversationParameter.RECORDING_CONSENT] == 1) { + add(stringResource(R.string.recording_consent_for_conversation_description)) + } + if (parameters[ConversationParameter.MENTION_PERMISSIONS] == 1) { + add(stringResource(R.string.nc_conversation_type_mentions_moderators)) + } + val sip = parameters[ConversationParameter.SIP_ENABLED] ?: 0 + if (sip >= 1) { + add(stringResource(R.string.nc_conversation_type_sip)) + } + if (sip == 2) { + add(stringResource(R.string.nc_conversation_type_sip_no_pin)) + } + } + +@Composable +private fun messageExpirationLine(seconds: Int): String { + val values = stringArrayResource(R.array.message_expiring_values) + val descriptions = stringArrayResource(R.array.message_expiring_descriptions) + val index = values.indexOf("expire_$seconds") + return if (index in descriptions.indices) { + stringResource(R.string.nc_conversation_type_message_expiration, descriptions[index]) + } else { + stringResource(R.string.nc_conversation_type_message_expiration_custom) + } +} + +@Suppress("LongParameterList") @Composable fun SelectableCard( content: SelectableCardContent, @@ -200,6 +282,15 @@ private val presetLabels = mapOf( ConversationPresetId.ANNOUNCEMENT to PresetLabel( R.string.nc_announcement, R.string.nc_announcement_description + ), + ConversationPresetId.WEBINAR to PresetLabel(R.string.nc_webinar, R.string.nc_webinar_description), + ConversationPresetId.PRESENTATION to PresetLabel( + R.string.nc_presentation, + R.string.nc_presentation_description + ), + ConversationPresetId.CLASSIFIED to PresetLabel( + R.string.nc_classified_conversation, + R.string.nc_classified_conversation_description ) ) @@ -207,5 +298,8 @@ 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 + ConversationPresetId.ANNOUNCEMENT to Icons.Outlined.Campaign, + ConversationPresetId.WEBINAR to Icons.Outlined.DesktopWindows, + ConversationPresetId.PRESENTATION to Icons.Outlined.CoPresent, + ConversationPresetId.CLASSIFIED to Icons.Outlined.Shield ) diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt new file mode 100644 index 0000000000..f5701792cb --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt @@ -0,0 +1,147 @@ +/* + * 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.ui + +import android.app.Activity +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.PersistableBundle +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.res.stringResource +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.conversationcreation.viewmodel.RoomUIState +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.utils.CapabilitiesUtil +import com.nextcloud.talk.utils.ShareUtils +import com.nextcloud.talk.utils.bundle.BundleKeys + +/** + * Reacts to the outcome of creating a conversation: reports what could not be done, hands public + * conversations to the caller so their link can be shared, and opens every other one right away. + */ +@Composable +fun CreationResultEffect( + creationState: RoomUIState, + context: Context, + onPublicConversation: (token: String, hasPassword: Boolean) -> Unit, + onHandled: () -> Unit +) { + LaunchedEffect(creationState) { + when (val state = creationState) { + is RoomUIState.Error -> { + Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show() + onHandled() + } + + is RoomUIState.Success -> { + val conversation = state.conversation + val roomToken = conversation?.token + if (roomToken == null) { + onHandled() + } else { + if (conversation.invalidParticipants?.isNotEmpty() == true) { + Toast.makeText( + context, + R.string.nc_conversation_created_invalid_participants, + Toast.LENGTH_LONG + ).show() + } + if (conversation.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL) { + onPublicConversation(roomToken, conversation.hasPassword) + onHandled() + } else { + onHandled() + openConversation(context, roomToken) + } + } + } + + else -> Unit + } + } +} + +@Suppress("LongParameterList") +@Composable +fun ShareCreatedConversation( + roomToken: String, + password: String?, + currentUser: User, + context: Context, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.nc_conversation_created_title)) }, + text = { + Column { + Text(text = stringResource(R.string.nc_conversation_created_public)) + if (!password.isNullOrEmpty()) { + TextButton(onClick = { copyPassword(context, roomToken, password) }) { + Text(text = stringResource(R.string.nc_copy_password)) + } + } + } + }, + dismissButton = { + TextButton( + onClick = { + (context as? Activity)?.let { + ShareUtils.shareConversationLink( + it, + currentUser.baseUrl, + roomToken, + CapabilitiesUtil.canGeneratePrettyURL(currentUser) + ) + } + } + ) { + Text(text = stringResource(R.string.nc_share_link)) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.nc_open_conversation)) + } + } + ) +} + +fun openConversation(context: Context, roomToken: String) { + val bundle = Bundle() + bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken) + val chatIntent = Intent(context, ChatActivity::class.java) + chatIntent.putExtras(bundle) + chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + context.startActivity(chatIntent) +} + +private fun copyPassword(context: Context, roomToken: String, password: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(roomToken, password) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + clip.description.extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + clipboard.setPrimaryClip(clip) + Toast.makeText(context, context.getString(R.string.nc_password_copied), Toast.LENGTH_SHORT).show() +} 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 4ee5e7c814..33a8490f05 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 @@ -19,6 +19,7 @@ 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.ParticipantSource import com.nextcloud.talk.conversationcreation.data.ConversationCreationRepository import com.nextcloud.talk.conversationcreation.parametersFor import com.nextcloud.talk.conversationcreation.parametersOf @@ -44,6 +45,7 @@ class ConversationCreationViewModel @Inject constructor( private val _selectedParticipants = MutableStateFlow>(emptyList()) val selectedParticipants: StateFlow> = _selectedParticipants private val roomViewState = MutableStateFlow(RoomUIState.None) + val creationState: StateFlow = roomViewState private val _selectedImageUri = MutableStateFlow(null) val selectedImageUri: StateFlow = _selectedImageUri @@ -157,9 +159,18 @@ class ConversationCreationViewModel @Inject constructor( conversationPreset.value = preset val loaded = (_presets.value as? PresetsUiState.Success)?.presets.orEmpty() parametersChosenByUser.value -= loaded.parametersOf(preset).keys + if (isLockedDown) { + parametersChosenByUser.value -= setOf(ConversationParameter.ROOM_TYPE, ConversationParameter.LISTABLE) + _selectedParticipants.value = _selectedParticipants.value.filter { + (it.source ?: ParticipantSource.USERS) in ParticipantSource.local + } + } recomputeParams() } + val isLockedDown: Boolean + get() = conversationPreset.value in ConversationPresetId.lockedDown + val isGuestsAllowed: Boolean get() = conversationParams.value.roomType == CreateConversationParams.ROOM_TYPE_PUBLIC @@ -206,7 +217,7 @@ class ConversationCreationViewModel @Inject constructor( } @Suppress("Detekt.TooGenericExceptionCaught") - fun createRoomAndAddParticipants(onRoomCreated: (String) -> Unit) { + fun createRoomAndAddParticipants() { if (_isCreatingRoom.value) { return } @@ -232,7 +243,6 @@ class ConversationCreationViewModel @Inject constructor( val token = conversation?.token if (!token.isNullOrEmpty()) { roomViewState.value = RoomUIState.Success(conversation) - onRoomCreated(token) } else { roomViewState.value = RoomUIState.Error("Conversation is null") } @@ -245,8 +255,9 @@ class ConversationCreationViewModel @Inject constructor( } } - fun getImageUri(avatarId: String, requestBigSize: Boolean, isDarkMode: Boolean): String = - ApiUtils.getUrlForAvatar(_currentUser.baseUrl, avatarId, requestBigSize, darkMode = isDarkMode) + fun clearCreationState() { + roomViewState.value = RoomUIState.None + } companion object { private val TAG = ConversationCreationViewModel::class.simpleName diff --git a/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt b/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt index 07c3270af0..b9c5b00fb3 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt @@ -184,5 +184,8 @@ data class Conversation( // https://nextcloud-talk.readthedocs.io/en/latest/constants/#conversation-attributes) @JsonField(name = ["attributes"]) - var attributes: Int? = null + var attributes: Int? = null, + + @JsonField(name = ["invalidParticipants"]) + var invalidParticipants: HashMap>? = null ) : Parcelable diff --git a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt index 7d5e2f8d25..08981e9b88 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt @@ -26,6 +26,7 @@ object BundleKeys { const val KEY_ROOM_ONE_TO_ONE = "KEY_ROOM_ONE_TO_ONE" const val KEY_NEW_CONVERSATION = "KEY_NEW_CONVERSATION" const val KEY_ADD_PARTICIPANTS = "KEY_ADD_PARTICIPANTS" + const val KEY_ONLY_LOCAL_PARTICIPANTS = "KEY_ONLY_LOCAL_PARTICIPANTS" const val KEY_EXISTING_PARTICIPANTS = "KEY_EXISTING_PARTICIPANTS" const val KEY_CALL_URL = "KEY_CALL_URL" const val KEY_NEW_ROOM_NAME = "KEY_NEW_ROOM_NAME" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f73cbd560..97df98a3bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -926,6 +926,24 @@ How to translate with transifex: 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 + Restricted conversation with lobby enabled, ideal for public webinars + Presentation + Restricted conversation for internal presentations + Locked down conversation for confidential topics, with public access, guests, SIP and recordings disabled + Default settings of this conversation type: + These settings can be changed once the conversation is created + Messages expire after %1$s + Message expiration is enabled + Lobby enabled, restricting the conversation to moderators + Only moderators are allowed to mention everyone + Phone and SIP dial-in enabled + Dial-in without a PIN allowed + Conversation created + Guests can join this conversation with its link. Share it now or later from the conversation settings. + Copy password + Password copied + Open conversation + Some participants could not be added Retry 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 b1c928e3df..5779b18774 100644 --- a/app/src/test/java/com/nextcloud/talk/conversationcreation/CreateConversationParamsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/conversationcreation/CreateConversationParamsTest.kt @@ -163,7 +163,7 @@ class CreateConversationParamsTest { ConversationParameter.RECORDING_CONSENT to 2, ConversationParameter.ROOM_TYPE to 1, ConversationParameter.MESSAGE_EXPIRATION to -1, - ConversationParameter.PERMISSIONS to 512 + ConversationParameter.PERMISSIONS to CreateConversationParams.PERMISSIONS_MAX + 1 ) ) ) @@ -201,6 +201,51 @@ class CreateConversationParamsTest { assertEquals(CreateConversationParams.LISTABLE_NONE, params.listable) } + @Test + fun `the summary leaves out the parameters an administrator pinned`() { + val presets = listOf( + preset( + ConversationPresetId.FORCED, + mapOf(ConversationParameter.MESSAGE_EXPIRATION to CreateConversationParams.MESSAGE_EXPIRATION_ONE_HOUR) + ), + preset( + ConversationPresetId.VOICE_ROOM, + mapOf( + ConversationParameter.MESSAGE_EXPIRATION to CreateConversationParams.MESSAGE_EXPIRATION_ONE_HOUR, + ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_USERS + ) + ) + ) + + val preview = presets.parametersToPreviewFor(ConversationPresetId.VOICE_ROOM) + + assertEquals(mapOf(ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_USERS), preview) + } + + @Test + fun `the summary also lists what the administrator configured as the default`() { + val presets = listOf( + preset( + ConversationPresetId.DEFAULT, + mapOf(ConversationParameter.MESSAGE_EXPIRATION to CreateConversationParams.MESSAGE_EXPIRATION_ONE_HOUR) + ), + preset( + ConversationPresetId.WEBINAR, + mapOf(ConversationParameter.LOBBY_STATE to 1) + ) + ) + + val preview = presets.parametersToPreviewFor(ConversationPresetId.WEBINAR) + + assertEquals( + mapOf( + ConversationParameter.MESSAGE_EXPIRATION to CreateConversationParams.MESSAGE_EXPIRATION_ONE_HOUR, + ConversationParameter.LOBBY_STATE to 1 + ), + preview + ) + } + @Test fun `the forced preset is not offered as a conversation type`() { val presets = listOf(