diff --git a/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt b/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt index 2ea7c61298..e43a6a0f54 100644 --- a/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt +++ b/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt @@ -19,8 +19,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.sp import coil.compose.AsyncImage +import com.nextcloud.talk.R import com.nextcloud.talk.activities.ParticipantUiState import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.utils.ApiUtils @@ -40,7 +42,7 @@ fun AvatarWithFallback(participant: ParticipantUiState, displayName: String, mod if (avatarUrl.isNotEmpty()) { AsyncImage( model = avatarUrl, - contentDescription = "Avatar", + contentDescription = stringResource(R.string.avatar), contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/nextcloud/talk/call/components/ParticipantTile.kt b/app/src/main/java/com/nextcloud/talk/call/components/ParticipantTile.kt index 752959c168..30e5ee38da 100644 --- a/app/src/main/java/com/nextcloud/talk/call/components/ParticipantTile.kt +++ b/app/src/main/java/com/nextcloud/talk/call/components/ParticipantTile.kt @@ -92,7 +92,7 @@ fun ParticipantTile( if (participantUiState.raisedHand) { Icon( painter = painterResource(id = R.drawable.ic_hand_back_left), - contentDescription = "Raised Hand", + contentDescription = stringResource(R.string.nc_call_raised_hand, displayName), modifier = Modifier .align(Alignment.TopEnd) .padding(6.dp) diff --git a/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt b/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt index ddaf6ee1bb..8e9bf7a070 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt @@ -90,7 +90,7 @@ fun ContactItemRow(contact: AutocompleteUser, contactsViewModel: ContactsViewMod Spacer(modifier = Modifier.weight(1f)) Icon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_check_circle), - contentDescription = "Selected", + contentDescription = stringResource(R.string.selected_list_item), tint = Color.Blue, modifier = Modifier.padding(end = 8.dp) ) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index 99ae1c4c14..e4e50450d7 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -9,22 +9,22 @@ package com.nextcloud.talk.conversationinfo import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle -import android.text.TextUtils import android.util.Log -import android.view.Menu -import android.view.MenuItem -import android.view.View.GONE -import android.view.View.VISIBLE +import android.view.LayoutInflater +import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes import androidx.appcompat.app.AlertDialog -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.drawable.toDrawable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.fragment.app.FragmentTransaction import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest @@ -37,8 +37,6 @@ import com.afollestad.materialdialogs.bottomsheets.BottomSheet import com.afollestad.materialdialogs.datetime.datePicker import com.afollestad.materialdialogs.datetime.timePicker import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.activities.MainActivity @@ -47,53 +45,43 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.bottomsheet.items.BasicListItemWithImage import com.nextcloud.talk.bottomsheet.items.listItemsWithImage import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS import com.nextcloud.talk.contacts.ContactsActivity -import com.nextcloud.talk.conversationinfo.model.ParticipantModel +import com.nextcloud.talk.conversationinfo.ui.ConversationInfoScreen +import com.nextcloud.talk.conversationinfo.ui.ConversationInfoScreenCallbacks import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationinfoedit.ConversationInfoEditActivity import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.ActivityConversationInfoBinding import com.nextcloud.talk.databinding.DialogBanParticipantBinding +import com.nextcloud.talk.databinding.DialogPasswordBinding import com.nextcloud.talk.events.EventStatus import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.extensions.getParcelableExtraProvider -import com.nextcloud.talk.extensions.loadConversationAvatar -import com.nextcloud.talk.extensions.loadNoteToSelfAvatar -import com.nextcloud.talk.extensions.loadSystemAvatar import com.nextcloud.talk.extensions.loadUserAvatar import com.nextcloud.talk.jobs.AddParticipantsToConversationWorker import com.nextcloud.talk.jobs.DeleteConversationWorker import com.nextcloud.talk.jobs.LeaveConversationWorker -import com.nextcloud.talk.models.domain.ConversationModel -import com.nextcloud.talk.models.domain.converters.DomainEnumNotificationLevelConverter import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser -import com.nextcloud.talk.models.json.capabilities.SpreedCapability import com.nextcloud.talk.models.json.conversations.ConversationEnums -import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter -import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.models.json.participants.Participant.ActorType.CIRCLES import com.nextcloud.talk.models.json.participants.Participant.ActorType.GROUPS import com.nextcloud.talk.models.json.participants.Participant.ActorType.USERS -import com.nextcloud.talk.models.json.participants.ParticipantsOverall -import com.nextcloud.talk.models.json.profile.Profile +import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.upcomingEvents.UpcomingEvent -import com.nextcloud.talk.repositories.conversations.ConversationsRepository import com.nextcloud.talk.shareditems.activities.SharedItemsActivity import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity import com.nextcloud.talk.ui.dialog.DialogBanListFragment import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil -import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.DateConstants import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.ShareUtils -import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN -import com.nextcloud.talk.utils.preferences.preferencestorage.DatabaseStorageModule +import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -103,17 +91,13 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import java.time.Instant import java.time.ZoneId -import java.time.ZoneOffset import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle import java.util.Calendar -import java.util.Locale import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) +@Suppress("LargeClass", "TooManyFunctions") class ConversationInfoActivity : BaseActivity() { - private lateinit var binding: ActivityConversationInfoBinding @Inject lateinit var viewModelFactory: ViewModelProvider.Factory @@ -121,54 +105,42 @@ class ConversationInfoActivity : BaseActivity() { @Inject lateinit var ncApi: NcApi - @Inject - lateinit var conversationsRepository: ConversationsRepository - @Inject lateinit var dateUtils: DateUtils lateinit var viewModel: ConversationInfoViewModel - private lateinit var spreedCapabilities: SpreedCapability - private lateinit var conversationToken: String - private lateinit var conversationUser: User - private var hasAvatarSpacing: Boolean = false + private var conversationUser: User? = null private lateinit var credentials: String - private var participantsDisposable: Disposable? = null - - private var databaseStorageModule: DatabaseStorageModule? = null - private var conversation: ConversationModel? = null - - private var participantAdapter: ParticipantItemAdapter? = null private var startGroupChat: Boolean = false - private lateinit var optionsMenu: Menu - private val workerData: Data? get() { - if (!TextUtils.isEmpty(conversationToken)) { - val data = Data.Builder() - data.putString(KEY_ROOM_TOKEN, conversationToken) - data.putLong(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id!!) - return data.build() + val user = conversationUser ?: return null + return if (conversationToken.isNotEmpty()) { + Data.Builder() + .putString(KEY_ROOM_TOKEN, conversationToken) + .putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) + .build() + } else { + null } - return null } private val addParticipantsResult = registerForActivityResult( ActivityResultContracts.StartActivityForResult() - ) { it -> - executeIfResultOk(it) { intent -> + ) { result -> + executeIfResultOk(result) { intent -> val selectedAutocompleteUsers = intent?.getParcelableArrayListExtraProvider("selectedParticipants") ?: emptyList() - + val user = conversationUser ?: return@executeIfResultOk if (startGroupChat) { viewModel.createRoomFromOneToOne( - conversationUser, - participantAdapter?.currentList?.map { it.participant } ?: emptyList(), + user, + viewModel.uiState.value.participants.map { it.participant }, selectedAutocompleteUsers, conversationToken ) @@ -182,7 +154,7 @@ class ConversationInfoActivity : BaseActivity() { ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == RESULT_OK) { - viewModel.getRoom(conversationUser, conversationToken) + conversationUser?.let { viewModel.getRoom(it, conversationToken) } } } @@ -190,29 +162,16 @@ class ConversationInfoActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - binding = ActivityConversationInfoBinding.inflate(layoutInflater) - setContentView(binding.root) - - setupActionBar() - initSystemBars() - conversationToken = requireNotNull( intent.getStringExtra(KEY_ROOM_TOKEN) ) { "Missing room token" } - hasAvatarSpacing = intent.getBooleanExtra(BundleKeys.KEY_ROOM_ONE_TO_ONE, false) - val upcomingEvent = intent.getParcelableExtraProvider(BundleKeys.KEY_UPCOMING_EVENT) - if (upcomingEvent != null && (upcomingEvent.summary != null || upcomingEvent.start != null)) { - binding.upcomingEventCard.visibility = VISIBLE - viewThemeUtils.material.themeCardView(binding.upcomingEventCard) - binding.upcomingEventContainer.upcomingEventSummary.text = upcomingEvent.summary - upcomingEvent.start?.let { start -> - val startDateTime = Instant.ofEpochSecond(start).atZone(ZoneId.systemDefault()) - val currentTime = ZonedDateTime.now(ZoneId.systemDefault()) - binding.upcomingEventContainer.upcomingEventTime.text = - DateUtils(this).getStringForMeetingStartDateTime(startDateTime, currentTime) - } + val upcomingEventSummary = upcomingEvent?.summary + val upcomingEventTime = upcomingEvent?.start?.let { start -> + val startDateTime = Instant.ofEpochSecond(start).atZone(ZoneId.systemDefault()) + val currentTime = ZonedDateTime.now(ZoneId.systemDefault()) + dateUtils.getStringForMeetingStartDateTime(startDateTime, currentTime) } viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java] @@ -222,16 +181,18 @@ class ConversationInfoActivity : BaseActivity() { .onSuccess { user -> conversationUser = user credentials = ApiUtils.getCredentials(user.username, user.token)!! - databaseStorageModule = DatabaseStorageModule(user, conversationToken) viewModel.getRoom(user, conversationToken) - initObservers() + if (upcomingEventSummary != null || upcomingEventTime != null) { + viewModel.setUpcomingEvent(upcomingEventSummary, upcomingEventTime) + } } .onFailure { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() Log.e(TAG, "Failed to get current user") finish() } } + + setupCompose() } override fun onStart() { @@ -244,430 +205,181 @@ class ConversationInfoActivity : BaseActivity() { this.lifecycle.removeObserver(ConversationInfoViewModel.LifeCycleObserver) } - override fun onResume() { - super.onResume() - - binding.deleteConversationAction.setOnClickListener { showDeleteConversationDialog() } - binding.leaveConversationAction.setOnClickListener { leaveConversation() } - binding.clearConversationHistory.setOnClickListener { showClearHistoryDialog() } - binding.addParticipantsAction.setOnClickListener { - startGroupChat = false - selectParticipantsToAdd() - } - binding.startGroupChat.setOnClickListener { - startGroupChat = true - selectParticipantsToAdd() - } - binding.listBansButton.setOnClickListener { listBans() } - - themeTextViews() - themeSwitchPreferences() - - binding.addParticipantsAction.visibility = GONE - - binding.progressBar.let { viewThemeUtils.platform.colorCircularProgressBar(it, ColorRole.PRIMARY) } - } - - private fun initObservers() { - initViewStateObserver() - initCapabilitiesObersver() - initRoomOberserver() - initBanActorObserver() - initConversationReadOnlyObserver() - initClearChatHistoryObserver() - initMarkConversationAsSensitiveObserver() - initMarkConversationAsInsensitiveObserver() - initMarkConversationAsImportantObserver() - initMarkConversationAsUnimportantObserver() - } - - private fun initMarkConversationAsSensitiveObserver() { - viewModel.markAsSensitiveResult.observe(this) { uiState -> - when (uiState) { - is ConversationInfoViewModel.MarkConversationAsSensitiveViewState.Success -> { - Snackbar.make( - binding.root, - context.getString(R.string.nc_mark_conversation_as_sensitive), - Snackbar.LENGTH_LONG - ).show() - } - is ConversationInfoViewModel.MarkConversationAsSensitiveViewState.Error -> { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - Log.e(TAG, "failed to mark conversation as insensitive", uiState.exception) - } - else -> { - } - } - } - } - - private fun initMarkConversationAsInsensitiveObserver() { - viewModel.markAsInsensitiveResult.observe(this) { uiState -> - when (uiState) { - is ConversationInfoViewModel.MarkConversationAsInsensitiveViewState.Success -> { - Snackbar.make( - binding.root, - context.getString(R.string.nc_mark_conversation_as_insensitive), - Snackbar.LENGTH_LONG - ).show() - } - is ConversationInfoViewModel.MarkConversationAsInsensitiveViewState.Error -> { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - Log.e(TAG, "failed to mark conversation as sensitive", uiState.exception) - } - else -> { - } - } - } - } - private fun initClearChatHistoryObserver() { - viewModel.clearChatHistoryViewState.observe(this) { uiState -> - when (uiState) { - is ConversationInfoViewModel.ClearChatHistoryViewState.None -> { - } - - is ConversationInfoViewModel.ClearChatHistoryViewState.Success -> { - Snackbar.make( - binding.root, - context.getString(R.string.nc_clear_history_success), - Snackbar.LENGTH_LONG - ).show() - } - - is ConversationInfoViewModel.ClearChatHistoryViewState.Error -> { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - Log.e(TAG, "failed to clear chat history", uiState.exception) - } - } - } - } - - private fun initConversationReadOnlyObserver() { - viewModel.getConversationReadOnlyState.observe(this) { state -> - when (state) { - is ConversationInfoViewModel.SetConversationReadOnlyViewState.Success -> { - } - - is ConversationInfoViewModel.SetConversationReadOnlyViewState.Error -> { - Snackbar.make(binding.root, R.string.conversation_read_only_failed, Snackbar.LENGTH_LONG).show() - } - - is ConversationInfoViewModel.SetConversationReadOnlyViewState.None -> { - } - } - } - } - - private fun initBanActorObserver() { - viewModel.getBanActorState.observe(this) { state -> - when (state) { - is ConversationInfoViewModel.BanActorSuccessState -> { - getListOfParticipants() // Refresh the list of participants - } - - ConversationInfoViewModel.BanActorErrorState -> { - Snackbar.make(binding.root, "Error banning actor", Snackbar.LENGTH_SHORT).show() - } - - else -> {} - } - } - } - - private fun initRoomOberserver() { - viewModel.createRoomViewState.observe(this) { state -> - when (state) { - is ConversationInfoViewModel.CreateRoomUIState.Success -> { - state.room.ocs?.data?.token?.let { token -> - val chatIntent = Intent(context, ChatActivity::class.java).apply { - putExtra(KEY_ROOM_TOKEN, token) + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(eventStatus: EventStatus) { + conversationUser?.let { viewModel.loadParticipants(it, conversationToken) } + } + + private fun setupCompose() { + val colorScheme = viewThemeUtils.getColorScheme(this) + setContent { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + viewModel.uiEvent.collect { event -> + val user = conversationUser ?: return@collect + when (event) { + is ConversationInfoUiEvent.ShowSnackbar -> + snackbarHostState.showSnackbar(getString(event.resId)) + is ConversationInfoUiEvent.ShowSnackbarText -> + snackbarHostState.showSnackbar(event.text) + is ConversationInfoUiEvent.NavigateToChat -> { + startActivity( + Intent(this@ConversationInfoActivity, ChatActivity::class.java).apply { + putExtra(KEY_ROOM_TOKEN, event.token) + } + ) } - startActivity(chatIntent) + ConversationInfoUiEvent.RefreshParticipants -> + viewModel.loadParticipants(user, conversationToken) } } - - is ConversationInfoViewModel.CreateRoomUIState.Error -> { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } - - else -> {} - } - } - } - - private fun initCapabilitiesObersver() { - viewModel.getCapabilitiesViewState.observe(this) { state -> - when (state) { - is ConversationInfoViewModel.GetCapabilitiesSuccessState -> { - spreedCapabilities = state.spreedCapabilities - handleConversation() - } - - else -> {} - } - } - } - - private fun initMarkConversationAsImportantObserver() { - viewModel.markAsImportantResult.observe(this) { uiState -> - when (uiState) { - is ConversationInfoViewModel.MarkConversationAsImportantViewState.Success -> { - Snackbar.make( - binding.root, - context.getString(R.string.nc_mark_conversation_as_important), - Snackbar.LENGTH_LONG - ).show() - } - is ConversationInfoViewModel.MarkConversationAsImportantViewState.Error -> { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - Log.e(TAG, "failed to mark conversation as important", uiState.exception) - } - else -> { - } } - } - } - private fun initMarkConversationAsUnimportantObserver() { - viewModel.markAsUnimportantResult.observe(this) { uiState -> - when (uiState) { - is ConversationInfoViewModel.MarkConversationAsUnimportantViewState.Success -> { - Snackbar.make( - binding.root, - context.getString(R.string.nc_mark_conversation_as_unimportant), - Snackbar.LENGTH_LONG - ).show() - } - is ConversationInfoViewModel.MarkConversationAsUnimportantViewState.Error -> { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - Log.e(TAG, "failed to mark conversation as unimportant", uiState.exception) - } - else -> { - } + MaterialTheme(colorScheme = colorScheme) { + ColoredStatusBar() + ConversationInfoScreen( + state = uiState, + callbacks = buildCallbacks() + ) } } } - @Suppress("Detekt.TooGenericExceptionCaught") - private fun initViewStateObserver() { - viewModel.viewState.observe(this) { state -> - when (state) { - is ConversationInfoViewModel.GetRoomSuccessState -> handleRoomSuccess(state) - is ConversationInfoViewModel.GetRoomErrorState -> { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } - else -> {} - } - } - - viewModel.getProfileViewState.observe(this) { state -> - when (state) { - is ConversationInfoViewModel.GetProfileSuccessState -> { - try { - handleProfileSuccess(state.profile) - } catch (e: Exception) { - Log.e(TAG, "Exception getting profile information", e) - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + @Suppress("LongMethod", "CyclomaticComplexMethod") + private fun buildCallbacks() = + ConversationInfoScreenCallbacks( + onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, + onEditConversation = { + editConversationResult.launch( + Intent(this, ConversationInfoEditActivity::class.java).apply { + putExtra(KEY_ROOM_TOKEN, conversationToken) } - } - is ConversationInfoViewModel.GetProfileErrorState -> { - Log.e(TAG, "Network error occurred getting profile information") - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } - else -> {} - } - } - } - - private fun handleRoomSuccess(state: ConversationInfoViewModel.GetRoomSuccessState) { - conversation = state.conversationModel - viewModel.getCapabilities(conversationUser, conversationToken, conversation!!) - if (ConversationUtils.isNoteToSelfConversation(conversation)) { - binding.shareConversationButton.visibility = GONE - } - val canGeneratePrettyURL = CapabilitiesUtil.canGeneratePrettyURL(conversationUser) - binding.shareConversationButton.setOnClickListener { - ShareUtils.shareConversationLink( - this, - conversationUser.baseUrl, - conversation?.token, - conversation?.name, - canGeneratePrettyURL - ) - } - conversation?.let { - if (it.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) { - viewModel.getProfileData(conversationUser, it.name) - } - } - } - - private fun handleProfileSuccess(profile: Profile) { - val pronouns = profile.pronouns ?: "" - binding.pronouns.text = pronouns - - val concat1 = if (profile.role != null && profile.company != null) " @ " else "" - val professionCompanyText = "${profile.role ?: ""}$concat1${profile.company ?: ""}" - binding.professionCompany.text = professionCompanyText - - val secondsToAdd = profile.timezoneOffset?.toLong() ?: 0 - val localTime = ZonedDateTime.ofInstant(Instant.now().plusSeconds(secondsToAdd), ZoneOffset.ofTotalSeconds(0)) - val localTimeString = - localTime.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())) - val concat2 = if (profile.address != null) " ยท " else "" - val localTimeLocation = "$localTimeString$concat2${profile.address ?: ""}" - binding.locationTime.text = resources.getString(R.string.local_time, localTimeLocation) - - binding.pronouns.visibility = VISIBLE - binding.professionCompany.visibility = if (professionCompanyText.isNotEmpty()) VISIBLE else GONE - binding.locationTime.visibility = VISIBLE - } - - private fun setupActionBar() { - setSupportActionBar(binding.conversationInfoToolbar) - binding.conversationInfoToolbar.setNavigationOnClickListener { - onBackPressedDispatcher.onBackPressed() - } - supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setIcon(resources!!.getColor(android.R.color.transparent, null).toDrawable()) - supportActionBar?.title = if (hasAvatarSpacing) { - " " + resources!!.getString(R.string.nc_conversation_menu_conversation_info) - } else { - resources!!.getString(R.string.nc_conversation_menu_conversation_info) - } - viewThemeUtils.material.themeToolbar(binding.conversationInfoToolbar) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - super.onCreateOptionsMenu(menu) - optionsMenu = menu - return true - } - - fun showOptionsMenu() { - if (::optionsMenu.isInitialized) { - optionsMenu.clear() - if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.AVATAR)) { - menuInflater.inflate(R.menu.menu_conversation_info, optionsMenu) - } - } - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - super.onPrepareOptionsMenu(menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.edit) { - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, conversationToken) - - val intent = Intent(this, ConversationInfoEditActivity::class.java) - intent.putExtras(bundle) - editConversationResult.launch(intent) - } - return true - } - - private fun themeSwitchPreferences() { - binding.run { - listOf( - binding.webinarInfoView.lobbySwitch, - binding.notificationSettingsView.callNotificationsSwitch, - binding.notificationSettingsView.importantConversationSwitch, - binding.guestAccessView.allowGuestsSwitch, - binding.guestAccessView.passwordProtectionSwitch, - binding.recordingConsentView.recordingConsentForConversationSwitch, - binding.lockConversationSwitch, - binding.notificationSettingsView.sensitiveConversationSwitch - ).forEach(viewThemeUtils.talk::colorSwitch) - } - } - - private fun themeTextViews() { - binding.run { - listOf( - binding.notificationSettingsView.notificationSettingsCategory, - binding.webinarInfoView.webinarSettingsCategory, - binding.guestAccessView.guestAccessSettingsCategory, - binding.sharedItemsTitle, - binding.recordingConsentView.recordingConsentSettingsCategory, - binding.conversationSettingsTitle, - binding.participantsListCategory - ) - }.forEach(viewThemeUtils.platform::colorTextView) - } + ) + }, + onMessageNotificationLevelClick = { showNotificationLevelDialog() }, + onCallNotificationsClick = { viewModel.toggleCallNotifications() }, + onImportantConversationClick = { + val user = conversationUser ?: return@ConversationInfoScreenCallbacks + viewModel.toggleImportantConversation(credentials, user.baseUrl!!, conversationToken) + }, + onSensitiveConversationClick = { + val user = conversationUser ?: return@ConversationInfoScreenCallbacks + viewModel.toggleSensitiveConversation(credentials, user.baseUrl!!, conversationToken) + }, + onLobbyClick = { conversationUser?.let { viewModel.toggleLobby(it, conversationToken) } }, + onLobbyTimerClick = { showLobbyTimerDialog() }, + onAllowGuestsClick = { + val user = conversationUser ?: return@ConversationInfoScreenCallbacks + viewModel.allowGuests(user, conversationToken, !viewModel.uiState.value.guestsAllowed) + }, + onPasswordProtectionClick = { + val user = conversationUser ?: return@ConversationInfoScreenCallbacks + if (viewModel.uiState.value.hasPassword) { + val apiVersion = + ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) + viewModel.setPassword( + user = user, + url = ApiUtils.getUrlForRoomPassword(apiVersion, user.baseUrl!!, conversationToken), + password = "" + ) + } else { + showPasswordDialog(conversationToken) + } + }, + onResendInvitationsClick = { + conversationUser?.let { viewModel.resendInvitations(it, conversationToken) } + }, + onSharedItemsClick = { showSharedItems() }, + onThreadsClick = { openThreadsOverview() }, + onRecordingConsentClick = { + conversationUser?.let { viewModel.toggleRecordingConsent(it, conversationToken) } + }, + onMessageExpirationClick = { showMessageExpirationDialog() }, + onShareConversationClick = { + val user = conversationUser ?: return@ConversationInfoScreenCallbacks + val state = viewModel.uiState.value + ShareUtils.shareConversationLink( + this, + user.baseUrl, + state.conversationToken, + state.conversation?.name, + CapabilitiesUtil.canGeneratePrettyURL(user) + ) + }, + onLockConversationClick = { + conversationUser?.let { viewModel.toggleLock(it, conversationToken) } + }, + onParticipantClick = { model -> handleParticipantClick(model.participant) }, + onAddParticipantsClick = { + startGroupChat = false + selectParticipantsToAdd() + }, + onStartGroupChatClick = { + startGroupChat = true + selectParticipantsToAdd() + }, + onListBansClick = { listBans() }, + onArchiveClick = { conversationUser?.let { viewModel.toggleArchive(it, conversationToken) } }, + onLeaveConversationClick = { leaveConversation() }, + onClearHistoryClick = { showClearHistoryDialog() }, + onDeleteConversationClick = { showDeleteConversationDialog() } + ) private fun showSharedItems() { - val intent = Intent(this, SharedItemsActivity::class.java) - intent.putExtra(BundleKeys.KEY_CONVERSATION_NAME, conversation?.displayName) - intent.putExtra(KEY_ROOM_TOKEN, conversationToken) - intent.putExtra( - SharedItemsActivity.KEY_USER_IS_OWNER_OR_MODERATOR, - ConversationUtils.isParticipantOwnerOrModerator(conversation!!) - ) - intent.putExtra( - SharedItemsActivity.KEY_IS_ONE_2_ONE, - conversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + val conv = viewModel.uiState.value.conversation ?: return + startActivity( + Intent(this, SharedItemsActivity::class.java).apply { + putExtra(BundleKeys.KEY_CONVERSATION_NAME, conv.displayName) + putExtra(KEY_ROOM_TOKEN, conversationToken) + putExtra( + SharedItemsActivity.KEY_USER_IS_OWNER_OR_MODERATOR, + ConversationUtils.isParticipantOwnerOrModerator(conv) + ) + putExtra( + SharedItemsActivity.KEY_IS_ONE_2_ONE, + conv.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + ) + } ) - startActivity(intent) } fun openThreadsOverview() { + val user = conversationUser ?: return val threadsUrl = ApiUtils.getUrlForRecentThreads( version = 1, - baseUrl = conversationUser.baseUrl, + baseUrl = user.baseUrl, token = conversationToken ) - - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, conversationToken) - bundle.putString(ThreadsOverviewActivity.KEY_APPBAR_TITLE, getString(R.string.recent_threads)) - bundle.putString(ThreadsOverviewActivity.KEY_THREADS_SOURCE_URL, threadsUrl) - val threadsOverviewIntent = Intent(context, ThreadsOverviewActivity::class.java) - threadsOverviewIntent.putExtras(bundle) - startActivity(threadsOverviewIntent) + startActivity( + Intent(context, ThreadsOverviewActivity::class.java).apply { + putExtra(KEY_ROOM_TOKEN, conversationToken) + putExtra(ThreadsOverviewActivity.KEY_APPBAR_TITLE, getString(R.string.recent_threads)) + putExtra(ThreadsOverviewActivity.KEY_THREADS_SOURCE_URL, threadsUrl) + } + ) } - private fun setupWebinaryView() { - if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.WEBINARY_LOBBY) && - webinaryRoomType(conversation!!) && - ConversationUtils.canModerate(conversation!!, spreedCapabilities) - ) { - binding.webinarInfoView.webinarSettings.visibility = VISIBLE - - val isLobbyOpenToModeratorsOnly = - conversation!!.lobbyState == ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY - binding.webinarInfoView.lobbySwitch.isChecked = isLobbyOpenToModeratorsOnly - - reconfigureLobbyTimerView() - - binding.webinarInfoView.startTimeButton.setOnClickListener { - MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show { - val currentTimeCalendar = Calendar.getInstance() - if (conversation!!.lobbyTimer != 0L) { - currentTimeCalendar.timeInMillis = conversation!!.lobbyTimer * DateConstants.SECOND_DIVIDER - } - - datePicker { _, date -> - showTimePicker(date) - } + private fun showLobbyTimerDialog() { + val user = conversationUser ?: return + val currentLobbyTimer = viewModel.uiState.value.conversation?.lobbyTimer ?: 0L + MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show { + val cal = Calendar.getInstance() + if (currentLobbyTimer != 0L) cal.timeInMillis = currentLobbyTimer * DateConstants.SECOND_DIVIDER + datePicker { _, date -> + showTimePicker(date) { selectedDate -> + viewModel.setLobbyTimerAndSubmit( + user, + conversationToken, + selectedDate.timeInMillis / DateConstants.SECOND_DIVIDER + ) } } - - binding.webinarInfoView.webinarSettingsLobby.setOnClickListener { - binding.webinarInfoView.lobbySwitch.isChecked = !binding.webinarInfoView.lobbySwitch.isChecked - reconfigureLobbyTimerView() - submitLobbyChanges() - } - } else { - binding.webinarInfoView.webinarSettings.visibility = GONE } } - private fun showTimePicker(selectedDate: Calendar) { + private fun showTimePicker(selectedDate: Calendar, onTimeSelected: (Calendar) -> Unit) { val currentTime = Calendar.getInstance() MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show { cancelable(false) @@ -681,234 +393,53 @@ class ConversationInfoActivity : BaseActivity() { selectedDate.set(Calendar.HOUR_OF_DAY, currentTime.get(Calendar.HOUR_OF_DAY)) selectedDate.set(Calendar.MINUTE, currentTime.get(Calendar.MINUTE)) } - reconfigureLobbyTimerView(selectedDate) - submitLobbyChanges() + onTimeSelected(selectedDate) } ) } } - private fun webinaryRoomType(conversation: ConversationModel): Boolean = - conversation.type == ConversationEnums.ConversationType.ROOM_GROUP_CALL || - conversation.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL - - private fun reconfigureLobbyTimerView(dateTime: Calendar? = null) { - val isChecked = binding.webinarInfoView.lobbySwitch.isChecked - - if (dateTime != null && isChecked) { - conversation!!.lobbyTimer = ( - dateTime.timeInMillis - (dateTime.time.seconds * DateConstants.SECOND_DIVIDER) - ) / DateConstants.SECOND_DIVIDER - } else if (!isChecked) { - conversation!!.lobbyTimer = 0 - } - - conversation!!.lobbyState = if (isChecked) { - ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY - } else { - ConversationEnums.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS - } - - if ( - conversation!!.lobbyTimer != null && - conversation!!.lobbyTimer != Long.MIN_VALUE && - conversation!!.lobbyTimer != 0L - ) { - binding.webinarInfoView.startTimeButtonSummary.text = ( - dateUtils.getLocalDateTimeStringFromTimestamp( - conversation!!.lobbyTimer * DateConstants.SECOND_DIVIDER - ) - ) - } else { - binding.webinarInfoView.startTimeButtonSummary.setText(R.string.nc_manual) - } - - if (isChecked) { - binding.webinarInfoView.startTimeButton.visibility = VISIBLE - } else { - binding.webinarInfoView.startTimeButton.visibility = GONE - } - } - - private fun submitLobbyChanges() { - val state = if (binding.webinarInfoView.lobbySwitch.isChecked) { - 1 - } else { - 0 - } - - val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) - - ncApi.setLobbyForConversation( - ApiUtils.getCredentials(conversationUser.username, conversationUser.token), - ApiUtils.getUrlForRoomWebinaryLobby(apiVersion, conversationUser.baseUrl!!, conversation!!.token), - state, - conversation!!.lobbyTimer - ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onComplete() { - // unused atm - } - - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(t: GenericOverall) { - // unused atm - } - - override fun onError(e: Throwable) { - Log.e(TAG, "Failed to setLobbyForConversation", e) - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } - }) + private fun showNotificationLevelDialog() { + val descriptions = resources.getStringArray(R.array.message_notification_levels) + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setTitle(R.string.nc_plain_old_messages) + .setItems(descriptions) { _, position -> viewModel.saveNotificationLevel(position) } + .setNegativeButton(R.string.nc_cancel, null) + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons(dialog.getButton(AlertDialog.BUTTON_NEGATIVE)) } - @Subscribe(threadMode = ThreadMode.MAIN) - fun onMessageEvent(eventStatus: EventStatus) { - getListOfParticipants() + private fun showMessageExpirationDialog() { + val descriptions = resources.getStringArray(R.array.message_expiring_descriptions) + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setTitle(R.string.nc_expire_messages) + .setItems(descriptions) { _, position -> viewModel.saveMessageExpiration(position) } + .setNegativeButton(R.string.nc_cancel, null) + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons(dialog.getButton(AlertDialog.BUTTON_NEGATIVE)) } private fun showDeleteConversationDialog() { - binding.conversationInfoName.let { - val dialogBuilder = MaterialAlertDialogBuilder(it.context) - .setIcon( - viewThemeUtils.dialog.colorMaterialAlertDialogIcon( - context, - R.drawable.ic_delete_black_24dp - ) - ) - .setTitle(R.string.nc_delete_call) - .setMessage(R.string.nc_delete_conversation_more) - .setPositiveButton(R.string.nc_delete) { _, _ -> - deleteConversation() - } - .setNegativeButton(R.string.nc_cancel) { _, _ -> - // unused atm - } - - viewThemeUtils.dialog - .colorMaterialAlertDialogBackground(it.context, dialogBuilder) - val dialog = dialogBuilder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE) - ) - } - } - - private fun setupAdapter() { - if (participantAdapter == null) { - participantAdapter = ParticipantItemAdapter( - this, - conversationUser, - viewThemeUtils, - conversation!! - ) { uiModel -> - handleParticipantClick(uiModel.participant) - } - } - binding.recyclerView.layoutManager = LinearLayoutManager(this) - binding.recyclerView.setHasFixedSize(false) - binding.recyclerView.adapter = participantAdapter - binding.recyclerView.isNestedScrollingEnabled = false - } - - private fun handleParticipants(participants: List) { - val uiItems: MutableList = ArrayList() - var ownUiItem: ParticipantModel? = null - - for (participant in participants) { - val isOnline = if (participant.sessionId != null) { - !participant.sessionId.equals("0") - } else { - participant.sessionIds.isNotEmpty() - } - - if (participant.calculatedActorType == USERS && - participant.calculatedActorId == conversationUser.userId - ) { - participant.sessionId = "-1" - ownUiItem = ParticipantModel(participant, true) - } else { - uiItems.add(ParticipantModel(participant, isOnline)) - } - } - - // sort by group/circle, online-status, moderator-status and display name - uiItems.sortWith( - compareBy( - { it.participant.actorType == GROUPS || it.participant.actorType == CIRCLES }, - { !it.isOnline }, - { - it.participant.type !in listOf( - Participant.ParticipantType.MODERATOR, - Participant.ParticipantType.OWNER, - Participant.ParticipantType.GUEST_MODERATOR - ) - }, - { it.participant.displayName!!.lowercase(Locale.ROOT) } - ) - ) - - if (ownUiItem != null) { - uiItems.add(0, ownUiItem) - } - - setupAdapter() - - binding.participants.visibility = VISIBLE - participantAdapter!!.submitList(uiItems) - } - - private fun getListOfParticipants() { - // FIXME Fix API checking with guests? - val apiVersion: Int = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) - - val fieldMap = HashMap() - fieldMap["includeStatus"] = true - - ncApi.getPeersForCall( - credentials, - ApiUtils.getUrlForParticipants( - apiVersion, - conversationUser.baseUrl!!, - conversationToken - ), - fieldMap + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setIcon(viewThemeUtils.dialog.colorMaterialAlertDialogIcon(context, R.drawable.ic_delete_black_24dp)) + .setTitle(R.string.nc_delete_call) + .setMessage(R.string.nc_delete_conversation_more) + .setPositiveButton(R.string.nc_delete) { _, _ -> deleteConversation() } + .setNegativeButton(R.string.nc_cancel, null) + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE) ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - participantsDisposable = d - } - - @Suppress("Detekt.TooGenericExceptionCaught") - override fun onNext(participantsOverall: ParticipantsOverall) { - handleParticipants(participantsOverall.ocs!!.data!!) - } - - override fun onError(e: Throwable) { - // unused atm - } - - override fun onComplete() { - participantsDisposable!!.dispose() - } - }) } private fun listBans() { - val fragmentManager = supportFragmentManager - val newFragment = DialogBanListFragment(conversationToken) - val transaction = fragmentManager.beginTransaction() + val transaction = supportFragmentManager.beginTransaction() transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - transaction - .add(android.R.id.content, newFragment) + transaction.add(android.R.id.content, DialogBanListFragment(conversationToken)) .addToBackStack(null) .commit() } @@ -922,75 +453,58 @@ class ConversationInfoActivity : BaseActivity() { } private fun selectParticipantsToAdd() { - val bundle = Bundle() val existingParticipants = ArrayList() - for (uiModel in participantAdapter?.currentList ?: emptyList()) { - val user = AutocompleteUser( - uiModel.participant.calculatedActorId!!, - uiModel.participant.displayName, - uiModel.participant.calculatedActorType.name.lowercase() + for (model in viewModel.uiState.value.participants) { + existingParticipants.add( + AutocompleteUser( + model.participant.calculatedActorId!!, + model.participant.displayName, + model.participant.calculatedActorType.name.lowercase() + ) ) - existingParticipants.add(user) } - - bundle.putBoolean(BundleKeys.KEY_ADD_PARTICIPANTS, true) - bundle.putParcelableArrayList("selectedParticipants", existingParticipants) - bundle.putBoolean(KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS, true) - bundle.putString(BundleKeys.KEY_TOKEN, conversation!!.token) - - val intent = Intent(this, ContactsActivity::class.java) - intent.putExtras(bundle) - - addParticipantsResult.launch(intent) + addParticipantsResult.launch( + Intent(this, ContactsActivity::class.java).apply { + putExtra(BundleKeys.KEY_ADD_PARTICIPANTS, true) + putParcelableArrayListExtra("selectedParticipants", existingParticipants) + putExtra(KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS, true) + putExtra(BundleKeys.KEY_TOKEN, conversationToken) + } + ) } private fun addParticipantsToConversation(autocompleteUsers: List) { - val groupIdsArray: MutableSet = HashSet() - val emailIdsArray: MutableSet = HashSet() - val circleIdsArray: MutableSet = HashSet() - val userIdsArray: MutableSet = HashSet() + val user = conversationUser ?: return + val groupIds = mutableSetOf() + val emailIds = mutableSetOf() + val circleIds = mutableSetOf() + val userIds = mutableSetOf() autocompleteUsers.forEach { participant -> when (participant.source) { - GROUPS.name.lowercase() -> groupIdsArray.add(participant.id!!) - Participant.ActorType.EMAILS.name.lowercase() -> emailIdsArray.add(participant.id!!) - CIRCLES.name.lowercase() -> circleIdsArray.add(participant.id!!) - else -> userIdsArray.add(participant.id!!) + GROUPS.name.lowercase() -> groupIds.add(participant.id!!) + Participant.ActorType.EMAILS.name.lowercase() -> emailIds.add(participant.id!!) + CIRCLES.name.lowercase() -> circleIds.add(participant.id!!) + else -> userIds.add(participant.id!!) } } val data = Data.Builder() - data.putLong(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id!!) - data.putString(BundleKeys.KEY_TOKEN, conversationToken) - data.putStringArray(BundleKeys.KEY_SELECTED_USERS, userIdsArray.toTypedArray()) - data.putStringArray(BundleKeys.KEY_SELECTED_GROUPS, groupIdsArray.toTypedArray()) - data.putStringArray(BundleKeys.KEY_SELECTED_EMAILS, emailIdsArray.toTypedArray()) - data.putStringArray(BundleKeys.KEY_SELECTED_CIRCLES, circleIdsArray.toTypedArray()) - val addParticipantsToConversationWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder( - AddParticipantsToConversationWorker::class.java - ).setInputData(data.build()).build() - WorkManager.getInstance().enqueue(addParticipantsToConversationWorker) - - WorkManager.getInstance(context).getWorkInfoByIdLiveData(addParticipantsToConversationWorker.id) + .putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) + .putString(BundleKeys.KEY_TOKEN, conversationToken) + .putStringArray(BundleKeys.KEY_SELECTED_USERS, userIds.toTypedArray()) + .putStringArray(BundleKeys.KEY_SELECTED_GROUPS, groupIds.toTypedArray()) + .putStringArray(BundleKeys.KEY_SELECTED_EMAILS, emailIds.toTypedArray()) + .putStringArray(BundleKeys.KEY_SELECTED_CIRCLES, circleIds.toTypedArray()) + .build() + + val addParticipantsWorker = + OneTimeWorkRequest.Builder(AddParticipantsToConversationWorker::class.java).setInputData(data).build() + WorkManager.getInstance().enqueue(addParticipantsWorker) + WorkManager.getInstance(context).getWorkInfoByIdLiveData(addParticipantsWorker.id) .observeForever { workInfo: WorkInfo? -> - if (workInfo != null) { - when (workInfo.state) { - WorkInfo.State.RUNNING -> { - Log.d(TAG, "running AddParticipantsToConversation") - } - - WorkInfo.State.SUCCEEDED -> { - Log.d(TAG, "success AddParticipantsToConversation") - getListOfParticipants() - } - - WorkInfo.State.FAILED -> { - Log.d(TAG, "failed AddParticipantsToConversation") - } - - else -> { - } - } + if (workInfo?.state == WorkInfo.State.SUCCEEDED) { + viewModel.loadParticipants(user, conversationToken) } } } @@ -1000,745 +514,198 @@ class ConversationInfoActivity : BaseActivity() { val workRequest = OneTimeWorkRequest.Builder(LeaveConversationWorker::class.java) .setInputData(data) .build() - - WorkManager.getInstance(context) - .enqueueUniqueWork( - "leave_conversation_work", - ExistingWorkPolicy.REPLACE, - workRequest - ) - + WorkManager.getInstance(context).enqueueUniqueWork( + "leave_conversation_work", + ExistingWorkPolicy.REPLACE, + workRequest + ) WorkManager.getInstance(context).getWorkInfoByIdLiveData(workRequest.id) - .observe(this, { workInfo: WorkInfo? -> + .observe(this) { workInfo: WorkInfo? -> if (workInfo != null) { when (workInfo.state) { WorkInfo.State.SUCCEEDED -> { - val intent = Intent(context, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(intent) + startActivity( + Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + ) } - WorkInfo.State.FAILED -> { val errorType = workInfo.outputData.getString("error_type") - if (errorType == LeaveConversationWorker.ERROR_NO_OTHER_MODERATORS_OR_OWNERS_LEFT) { - Snackbar.make( - binding.root, - R.string.nc_last_moderator_leaving_room_warning, - Snackbar.LENGTH_LONG - ).show() - } else { - Snackbar.make( - binding.root, - R.string.nc_common_error_sorry, - Snackbar.LENGTH_LONG - ).show() + lifecycleScope.launch { + viewModel.emitSnackbar( + if (errorType == + LeaveConversationWorker.ERROR_NO_OTHER_MODERATORS_OR_OWNERS_LEFT + ) { + R.string.nc_last_moderator_leaving_room_warning + } else { + R.string.nc_common_error_sorry + } + ) } } - - else -> { - } + else -> { /* unused */ } } } - }) + } } } private fun showClearHistoryDialog() { - binding.conversationInfoName.context.let { - val dialogBuilder = MaterialAlertDialogBuilder(it) - .setIcon( - viewThemeUtils.dialog.colorMaterialAlertDialogIcon( - context, - R.drawable.ic_delete_black_24dp - ) - ) - .setTitle(R.string.nc_clear_history) - .setMessage(R.string.nc_clear_history_warning) - .setPositiveButton(R.string.nc_delete_all) { _, _ -> - clearHistory() - } - .setNegativeButton(R.string.nc_cancel) { _, _ -> - // unused atm - } - - viewThemeUtils.dialog - .colorMaterialAlertDialogBackground(it, dialogBuilder) - val dialog = dialogBuilder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE) - ) - } + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setIcon(viewThemeUtils.dialog.colorMaterialAlertDialogIcon(context, R.drawable.ic_delete_black_24dp)) + .setTitle(R.string.nc_clear_history) + .setMessage(R.string.nc_clear_history_warning) + .setPositiveButton(R.string.nc_delete_all) { _, _ -> clearHistory() } + .setNegativeButton(R.string.nc_cancel, null) + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE) + ) } private fun clearHistory() { - val apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) - val url = ApiUtils.getUrlForChat(apiVersion, conversationUser.baseUrl!!, conversationToken) - - viewModel.clearChatHistory( - conversationUser, - url - ) + val user = conversationUser ?: return + val caps = viewModel.uiState.value.spreedCapabilities ?: return + val apiVersion = ApiUtils.getChatApiVersion(caps, intArrayOf(1)) + viewModel.clearChatHistory(user, ApiUtils.getUrlForChat(apiVersion, user.baseUrl!!, conversationToken)) } private fun deleteConversation() { workerData?.let { WorkManager.getInstance(context).enqueue( - OneTimeWorkRequest.Builder( - DeleteConversationWorker::class.java - ).setInputData(it).build() + OneTimeWorkRequest.Builder(DeleteConversationWorker::class.java).setInputData(it).build() ) - val intent = Intent(context, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(intent) - } - } - - @Suppress("LongMethod") - private fun handleConversation() { - val conversationCopy = conversation ?: return - setUpNotificationSettings(databaseStorageModule!!) - - if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.RICH_OBJECT_LIST_MEDIA) && - conversationCopy.remoteServer.isNullOrEmpty() - ) { - binding.sharedItemsButton.setOnClickListener { showSharedItems() } - } else { - binding.sharedItemsButton.visibility = GONE - binding.sharedItems.visibility = GONE - } - - if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS)) { - binding.sharedItems.visibility = VISIBLE - binding.showThreadsButton.setOnClickListener { openThreadsOverview() } - } else { - binding.showThreadsButton.visibility = GONE - } - - if (conversation!!.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL && - hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.CONVERSATION_CREATION_ALL) - ) { - binding.addParticipantsAction.visibility = GONE - binding.startGroupChat.visibility = VISIBLE - showDeleteAllMessagesOption(conversationCopy) - } else if (ConversationUtils.canModerate(conversationCopy, spreedCapabilities)) { - binding.addParticipantsAction.visibility = VISIBLE - showDeleteAllMessagesOption(conversationCopy) - showOptionsMenu() - } else { - binding.addParticipantsAction.visibility = GONE - if (ConversationUtils.isNoteToSelfConversation(conversation)) { - binding.notificationSettingsView.notificationSettings.visibility = VISIBLE - } else { - binding.clearConversationHistory.visibility = GONE - } - } - - binding.notificationSettingsView.importantConversationSwitch.isChecked = conversation!!.hasImportant - - binding.notificationSettingsView.notificationSettingsImportantConversation.setOnClickListener { - val isChecked = binding.notificationSettingsView.importantConversationSwitch.isChecked - binding.notificationSettingsView.importantConversationSwitch.isChecked = !isChecked - if (!isChecked) { - viewModel.markConversationAsImportant( - credentials, - conversationUser.baseUrl!!, - conversation?.token!! - ) - } else { - viewModel.markConversationAsUnimportant( - credentials, - conversationUser.baseUrl!!, - conversation?.token!! - ) - } - } - if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.IMPORTANT_CONVERSATIONS)) { - binding.notificationSettingsView.notificationSettingsImportantConversation.visibility = VISIBLE - } else { - binding.notificationSettingsView.notificationSettingsImportantConversation.visibility = GONE - } - - if (!hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.ARCHIVE_CONVERSATIONS)) { - binding.archiveConversationBtn.visibility = GONE - binding.archiveConversationTextHint.visibility = GONE - } - - binding.archiveConversationBtn.setOnClickListener { - this.lifecycleScope.launch { - if (conversation!!.hasArchived) { - viewModel.unarchiveConversation(conversationUser, conversationToken) - binding.archiveConversationIcon - .setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.outline_archive_24, null)) - binding.archiveConversationText.text = resources.getString(R.string.archive_conversation) - binding.archiveConversationTextHint.text = resources.getString(R.string.archive_hint) - } else { - viewModel.archiveConversation(conversationUser, conversationToken) - binding.archiveConversationIcon - .setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_unarchive_24px, null)) - binding.archiveConversationText.text = resources.getString(R.string.unarchive_conversation) - binding.archiveConversationTextHint.text = resources.getString(R.string.unarchive_hint) - } - } - viewModel.getRoom(conversationUser, conversationToken) - } - - if (conversation!!.hasArchived) { - binding.archiveConversationIcon - .setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_unarchive_24px, null)) - binding.archiveConversationText.text = resources.getString(R.string.unarchive_conversation) - binding.archiveConversationTextHint.text = resources.getString(R.string.unarchive_hint) - } else { - binding.archiveConversationIcon - .setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.outline_archive_24, null)) - binding.archiveConversationText.text = resources.getString(R.string.archive_conversation) - binding.archiveConversationTextHint.text = resources.getString(R.string.archive_hint) - } - - binding.notificationSettingsView.sensitiveConversationSwitch.isChecked = conversation!!.hasSensitive - - binding.notificationSettingsView.notificationSettingsSensitiveConversation.setOnClickListener { - val isChecked = !binding.notificationSettingsView.sensitiveConversationSwitch.isChecked - binding.notificationSettingsView.sensitiveConversationSwitch.isChecked = isChecked - if (isChecked) { - viewModel.markConversationAsSensitive( - credentials, - conversationUser.baseUrl!!, - conversation?.token!! - ) - } else { - viewModel.markConversationAsInsensitive( - credentials, - conversationUser.baseUrl!!, - conversation?.token!! - ) - } - } - if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.SENSITIVE_CONVERSATIONS)) { - binding.notificationSettingsView.notificationSettingsSensitiveConversation.visibility = VISIBLE - } else { - binding.notificationSettingsView.notificationSettingsSensitiveConversation.visibility = GONE - } - if (ConversationUtils.isConversationReadOnlyAvailable(conversationCopy, spreedCapabilities)) { - binding.lockConversation.visibility = VISIBLE - binding.lockConversationSwitch.isChecked = databaseStorageModule!!.getBoolean("lock_switch", false) - - binding.lockConversation.setOnClickListener { - val isLocked = binding.lockConversationSwitch.isChecked - binding.lockConversationSwitch.isChecked = !isLocked - lifecycleScope.launch { - databaseStorageModule!!.saveBoolean("lock_switch", !isLocked) - } - val state = if (isLocked) 0 else 1 - makeConversationReadOnly(conversationToken, state) - } - } else { - binding.lockConversation.visibility = GONE - } - - if (!isDestroyed) { - binding.dangerZoneOptions.visibility = VISIBLE - - setupWebinaryView() - - if (!conversation!!.canLeaveConversation) { - binding.leaveConversationAction.visibility = GONE - } else { - binding.leaveConversationAction.visibility = VISIBLE - } - - if (!conversation!!.canDeleteConversation) { - binding.deleteConversationAction.visibility = GONE - } else { - binding.deleteConversationAction.visibility = VISIBLE - } - - if (ConversationEnums.ConversationType.ROOM_SYSTEM == conversation!!.type) { - binding.notificationSettingsView.callNotificationsSwitch.visibility = GONE - } - - binding.listBansButton.visibility = - if (ConversationUtils.canModerate(conversationCopy, spreedCapabilities) && - ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL != conversation!!.type - ) { - VISIBLE - } else { - GONE - } - - if (conversation!!.notificationCalls === null) { - binding.notificationSettingsView.callNotificationsSwitch.visibility = GONE - } else { - binding.notificationSettingsView.callNotificationsSwitch.isChecked = - (conversationCopy.notificationCalls == 1) - } - - getListOfParticipants() - - binding.progressBar.visibility = GONE - - binding.conversationInfoName.visibility = VISIBLE - - binding.displayNameText.text = conversation!!.displayName - - if (conversation!!.description != null && conversation!!.description.isNotEmpty()) { - binding.descriptionText.text = conversation!!.description - binding.conversationDescription.visibility = VISIBLE - } - - loadConversationAvatar() - adjustNotificationLevelUI() - initRecordingConsentOption() - initExpiringMessageOption() - - binding.let { - GuestAccessHelper( - this@ConversationInfoActivity, - it, - conversation!!, - spreedCapabilities, - conversationUser, - viewModel, - this - ).setupGuestAccess() - } - if (ConversationUtils.isNoteToSelfConversation(conversation!!)) { - binding.notificationSettingsView.notificationSettings.visibility = GONE - } else { - binding.notificationSettingsView.notificationSettings.visibility = VISIBLE - } - } - } - - private fun makeConversationReadOnly(roomToken: String, state: Int) { - viewModel.setConversationReadOnly( - conversationUser, - roomToken, - state - ) - } - - private fun initRecordingConsentOption() { - fun hide() { - binding.recordingConsentView.recordingConsentSettingsCategory.visibility = GONE - binding.recordingConsentView.recordingConsentForConversation.visibility = GONE - binding.recordingConsentView.recordingConsentAll.visibility = GONE - } - - fun showAlwaysRequiredInfo() { - binding.recordingConsentView.recordingConsentForConversation.visibility = GONE - binding.recordingConsentView.recordingConsentAll.visibility = VISIBLE - } - - fun showSwitch() { - binding.recordingConsentView.recordingConsentForConversation.visibility = VISIBLE - binding.recordingConsentView.recordingConsentAll.visibility = GONE - - if (conversation!!.hasCall) { - binding.recordingConsentView.recordingConsentForConversation.isEnabled = false - binding.recordingConsentView.recordingConsentForConversation.alpha = LOW_EMPHASIS_OPACITY - } else { - binding.recordingConsentView.recordingConsentForConversationSwitch.isChecked = - conversation!!.recordingConsentRequired == RECORDING_CONSENT_REQUIRED_FOR_CONVERSATION - - binding.recordingConsentView.recordingConsentForConversation.setOnClickListener { - binding.recordingConsentView.recordingConsentForConversationSwitch.isChecked = - !binding.recordingConsentView.recordingConsentForConversationSwitch.isChecked - submitRecordingConsentChanges() - } - } - } - - if (ConversationUtils.isParticipantOwnerOrModerator(conversation!!) && - !ConversationUtils.isNoteToSelfConversation(conversation!!) - ) { - when (CapabilitiesUtil.getRecordingConsentType(spreedCapabilities)) { - CapabilitiesUtil.RECORDING_CONSENT_NOT_REQUIRED -> hide() - CapabilitiesUtil.RECORDING_CONSENT_REQUIRED -> showAlwaysRequiredInfo() - CapabilitiesUtil.RECORDING_CONSENT_DEPEND_ON_CONVERSATION -> showSwitch() - } - } else { - hide() - } - } - - fun showDeleteAllMessagesOption(conversationCopy: ConversationModel) { - if (hasSpreedFeatureCapability( - spreedCapabilities, - SpreedFeatures.CLEAR_HISTORY - ) && - conversationCopy.canDeleteConversation - ) { - binding.clearConversationHistory.visibility = VISIBLE - } else { - binding.clearConversationHistory.visibility = GONE - } - } - - private fun submitRecordingConsentChanges() { - val state = if (binding.recordingConsentView.recordingConsentForConversationSwitch.isChecked) { - RECORDING_CONSENT_REQUIRED_FOR_CONVERSATION - } else { - RECORDING_CONSENT_NOT_REQUIRED_FOR_CONVERSATION - } - - val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) - - ncApi.setRecordingConsent( - ApiUtils.getCredentials(conversationUser.username, conversationUser.token), - ApiUtils.getUrlForRecordingConsent(apiVersion, conversationUser.baseUrl!!, conversation!!.token), - state - ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onComplete() { - // unused atm - } - - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(t: GenericOverall) { - // unused atm - } - - override fun onError(e: Throwable) { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - Log.e(TAG, "Error when setting recording consent option for conversation", e) - } - }) - } - - private fun initExpiringMessageOption() { - if (ConversationUtils.isParticipantOwnerOrModerator(conversation!!) && - hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MESSAGE_EXPIRATION) - ) { - databaseStorageModule?.setMessageExpiration(conversation!!.messageExpiration) - val value = databaseStorageModule!!.getString("conversation_settings_dropdown", "") - val pos = resources.getStringArray(R.array.message_expiring_values).indexOf(value) - val text = resources.getStringArray(R.array.message_expiring_descriptions)[pos] - binding.conversationSettingsDropdown.setText(text) - binding.conversationSettingsDropdown - .setSimpleItems(resources.getStringArray(R.array.message_expiring_descriptions)) - binding.conversationSettingsDropdown.setOnItemClickListener { _, _, position, _ -> - val v: String = resources.getStringArray(R.array.message_expiring_values)[position] - lifecycleScope.launch { - databaseStorageModule!!.saveString("conversation_settings_dropdown", v) - } - } - binding.messageExpirationSettings.visibility = VISIBLE - } else { - binding.messageExpirationSettings.visibility = GONE - } - } - - private fun adjustNotificationLevelUI() { - if (conversation != null) { - if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.NOTIFICATION_LEVELS)) { - binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown.isEnabled = true - binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown.alpha = 1.0f - - if (conversation!!.notificationLevel != ConversationEnums.NotificationLevel.DEFAULT) { - val stringValue: String = - when ( - DomainEnumNotificationLevelConverter() - .convertToInt(conversation!!.notificationLevel) - ) { - NOTIFICATION_LEVEL_ALWAYS -> resources.getString(R.string.nc_notify_me_always) - NOTIFICATION_LEVEL_MENTION -> resources.getString(R.string.nc_notify_me_mention) - NOTIFICATION_LEVEL_NEVER -> resources.getString(R.string.nc_notify_me_never) - else -> resources.getString(R.string.nc_notify_me_mention) - } - - binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown.setText( - stringValue - ) - } else { - setProperNotificationValue(conversation) - } - } else { - binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown.isEnabled = false - binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown.alpha = - LOW_EMPHASIS_OPACITY - setProperNotificationValue(conversation) - } - binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown - .setSimpleItems(resources.getStringArray(R.array.message_notification_levels)) - } - } - - private fun setProperNotificationValue(conversation: ConversationModel?) { - if (conversation!!.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) { - if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG)) { - binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown.setText( - resources.getString(R.string.nc_notify_me_always) - ) - } else { - binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown.setText( - resources.getString(R.string.nc_notify_me_mention) - ) - } - } else { - binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown.setText( - resources.getString(R.string.nc_notify_me_mention) + startActivity( + Intent(context, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } ) } } - private fun loadConversationAvatar() { - when (conversation!!.type) { - ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> if (!TextUtils.isEmpty( - conversation!!.name - ) - ) { - conversation!!.name.let { - binding.avatarImage.loadUserAvatar( - conversationUser, - it, - true, - false - ) - } - } - - ConversationEnums.ConversationType.ROOM_GROUP_CALL, ConversationEnums.ConversationType.ROOM_PUBLIC_CALL -> { - binding.avatarImage.loadConversationAvatar( - conversationUser, - conversation!!, - false, - viewThemeUtils + private fun showPasswordDialog(token: String) { + val user = conversationUser ?: return + val dialogPassword = DialogPasswordBinding.inflate(LayoutInflater.from(this)) + viewThemeUtils.platform.colorEditText(dialogPassword.password) + val builder = MaterialAlertDialogBuilder(this) + .setView(dialogPassword.root) + .setTitle(R.string.nc_guest_access_password_dialog_title) + .setPositiveButton(R.string.nc_ok) { _, _ -> + val apiVersion = + ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) + viewModel.setPassword( + user = user, + url = ApiUtils.getUrlForRoomPassword(apiVersion, user.baseUrl!!, token), + password = dialogPassword.password.text.toString() ) } - - ConversationEnums.ConversationType.ROOM_SYSTEM -> { - binding.avatarImage.loadSystemAvatar() - } - - ConversationEnums.ConversationType.NOTE_TO_SELF -> { - binding.avatarImage.loadNoteToSelfAvatar() - } - - else -> { - // unused atm - } - } + .setNegativeButton(R.string.nc_cancel, null) + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, builder) + val dialog = builder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE) + ) } private fun toggleModeratorStatus(apiVersion: Int, participant: Participant) { - val subscriber = object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(genericOverall: GenericOverall) { - getListOfParticipants() - } - - @SuppressLint("LongLogTag") - override fun onError(e: Throwable) { - Log.e(TAG, "Error toggling moderator status", e) - } - - override fun onComplete() { - // unused atm - } - } - + val user = conversationUser ?: return + val subscriber = participantActionObserver() if (participant.type == Participant.ParticipantType.MODERATOR || participant.type == Participant.ParticipantType.GUEST_MODERATOR ) { ncApi.demoteAttendeeFromModerator( credentials, - ApiUtils.getUrlForRoomModerators( - apiVersion, - conversationUser.baseUrl!!, - conversation!!.token - ), + ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), participant.attendeeId - ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(subscriber) + )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) } else if (participant.type == Participant.ParticipantType.USER || participant.type == Participant.ParticipantType.GUEST ) { ncApi.promoteAttendeeToModerator( credentials, - ApiUtils.getUrlForRoomModerators( - apiVersion, - conversationUser.baseUrl!!, - conversation!!.token - ), + ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), participant.attendeeId - ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(subscriber) + )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) } } private fun toggleModeratorStatusLegacy(apiVersion: Int, participant: Participant) { - val subscriber = object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(genericOverall: GenericOverall) { - getListOfParticipants() - } - - @SuppressLint("LongLogTag") - override fun onError(e: Throwable) { - Log.e(TAG, "Error toggling moderator status (legacy)", e) - } - - override fun onComplete() { - // unused atm - } - } - + val user = conversationUser ?: return + val subscriber = participantActionObserver() if (participant.type == Participant.ParticipantType.MODERATOR) { ncApi.demoteModeratorToUser( credentials, - ApiUtils.getUrlForRoomModerators( - apiVersion, - conversationUser.baseUrl!!, - conversation!!.token - ), + ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), participant.userId - ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(subscriber) + )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) } else if (participant.type == Participant.ParticipantType.USER) { ncApi.promoteUserToModerator( credentials, - ApiUtils.getUrlForRoomModerators( - apiVersion, - conversationUser.baseUrl!!, - conversation!!.token - ), + ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), participant.userId - ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(subscriber) + )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) } } - private fun banActor(actorType: String, actorId: String, internalNote: String) { - viewModel.banActor(conversationUser, conversationToken, actorType, actorId, internalNote) - } + private fun participantActionObserver(): Observer = + object : Observer { + override fun onSubscribe(d: Disposable) { /* unused */ } + override fun onNext(genericOverall: GenericOverall) { + conversationUser?.let { viewModel.loadParticipants(it, conversationToken) } + } + + @SuppressLint("LongLogTag") + override fun onError(e: Throwable) { + Log.e(TAG, "Error toggling moderator status", e) + } + override fun onComplete() { /* unused */ } + } private fun removeAttendeeFromConversation(apiVersion: Int, participant: Participant) { + val user = conversationUser ?: return + val observer = participantActionObserver() if (apiVersion >= ApiUtils.API_V4) { ncApi.removeAttendeeFromConversation( credentials, - ApiUtils.getUrlForAttendees( - apiVersion, - conversationUser.baseUrl!!, - conversation!!.token - ), + ApiUtils.getUrlForAttendees(apiVersion, user.baseUrl!!, conversationToken), participant.attendeeId - ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(genericOverall: GenericOverall) { - getListOfParticipants() - } - - @SuppressLint("LongLogTag") - override fun onError(e: Throwable) { - Log.e(TAG, "Error removing attendee from conversation", e) - } - - override fun onComplete() { - // unused atm - } - }) + )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(observer) } else { if (participant.type == Participant.ParticipantType.GUEST || participant.type == Participant.ParticipantType.USER_FOLLOWING_LINK ) { ncApi.removeParticipantFromConversation( credentials, - ApiUtils.getUrlForRemovingParticipantFromConversation( - conversationUser.baseUrl!!, - conversation!!.token, - true - ), + ApiUtils.getUrlForRemovingParticipantFromConversation(user.baseUrl!!, conversationToken, true), participant.sessionId - ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(genericOverall: GenericOverall) { - getListOfParticipants() - } - - @SuppressLint("LongLogTag") - override fun onError(e: Throwable) { - Log.e(TAG, "Error removing guest from conversation", e) - } - - override fun onComplete() { - // unused atm - } - }) + )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(observer) } else { ncApi.removeParticipantFromConversation( credentials, - ApiUtils.getUrlForRemovingParticipantFromConversation( - conversationUser.baseUrl!!, - conversation!!.token, - false - ), + ApiUtils.getUrlForRemovingParticipantFromConversation(user.baseUrl!!, conversationToken, false), participant.userId - ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(genericOverall: GenericOverall) { - getListOfParticipants() - } - - @SuppressLint("LongLogTag") - override fun onError(e: Throwable) { - Log.e(TAG, "Error removing user from conversation", e) - } - - override fun onComplete() { - // unused atm - } - }) + )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(observer) } } } + private fun banActor(actorType: String, actorId: String, internalNote: String) { + conversationUser?.let { viewModel.banActor(it, conversationToken, actorType, actorId, internalNote) } + } + @SuppressLint("CheckResult", "StringFormatInvalid") + @Suppress("ReturnCount") private fun handleParticipantClick(participant: Participant) { - if (!ConversationUtils.canModerate(conversation!!, spreedCapabilities)) { - return - } - - val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) - - if (participant.calculatedActorType == USERS && participant.calculatedActorId == conversationUser.userId) { + val state = viewModel.uiState.value + val conv = state.conversation ?: return + val caps = state.spreedCapabilities ?: return + if (!ConversationUtils.canModerate(conv, caps)) return + val user = conversationUser ?: return + val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + + if (participant.calculatedActorType == USERS && participant.calculatedActorId == user.userId) { if (participant.attendeePin?.isNotEmpty() == true) { launchRemoveAttendeeFromConversationDialog( participant, @@ -1748,7 +715,7 @@ class ConversationInfoActivity : BaseActivity() { ) } } else if (participant.type == Participant.ParticipantType.OWNER) { - // Can not moderate owner + // Cannot moderate owner } else if (participant.calculatedActorType == GROUPS) { launchRemoveAttendeeFromConversationDialog( participant, @@ -1767,47 +734,29 @@ class ConversationInfoActivity : BaseActivity() { } @SuppressLint("CheckResult") + @Suppress("CyclomaticComplexMethod") private fun launchDefaultActions(participant: Participant, apiVersion: Int) { val items = getDefaultActionItems(participant) - - if (CapabilitiesUtil.isBanningAvailable(conversationUser.capabilities?.spreedCapability!!)) { + if (CapabilitiesUtil.isBanningAvailable(conversationUser?.capabilities?.spreedCapability!!)) { items.add(BasicListItemWithImage(R.drawable.baseline_block_24, context.getString(R.string.ban_participant))) } - when (participant.type) { - Participant.ParticipantType.MODERATOR, Participant.ParticipantType.GUEST_MODERATOR -> { - items.removeAt(1) - } - - Participant.ParticipantType.USER, Participant.ParticipantType.GUEST -> { - items.removeAt(2) - } - + Participant.ParticipantType.MODERATOR, Participant.ParticipantType.GUEST_MODERATOR -> items.removeAt(1) + Participant.ParticipantType.USER, Participant.ParticipantType.GUEST -> items.removeAt(2) else -> { - // Self joined users can not be promoted nor demoted items.removeAt(2) items.removeAt(1) } } - - if (participant.attendeePin == null || participant.attendeePin!!.isEmpty()) { - items.removeAt(0) - } - + if (participant.attendeePin == null || participant.attendeePin!!.isEmpty()) items.removeAt(0) if (items.isNotEmpty()) { MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show { cornerRadius(res = R.dimen.corner_radius) - title(text = participant.displayName) listItemsWithImage(items = items) { _, index, _ -> var actionToTrigger = index - if (participant.attendeePin == null || participant.attendeePin!!.isEmpty()) { - actionToTrigger++ - } - if (participant.type == Participant.ParticipantType.USER_FOLLOWING_LINK) { - actionToTrigger++ - } - + if (participant.attendeePin == null || participant.attendeePin!!.isEmpty()) actionToTrigger++ + if (participant.type == Participant.ParticipantType.USER_FOLLOWING_LINK) actionToTrigger++ when (actionToTrigger) { DEMOTE_OR_PROMOTE -> { if (apiVersion >= ApiUtils.API_V4) { @@ -1816,16 +765,9 @@ class ConversationInfoActivity : BaseActivity() { toggleModeratorStatusLegacy(apiVersion, participant) } } - - REMOVE_FROM_CONVERSATION -> { - removeAttendeeFromConversation(apiVersion, participant) - } - - BAN_FROM_CONVERSATION -> { - handleBan(participant) - } - - else -> {} + REMOVE_FROM_CONVERSATION -> removeAttendeeFromConversation(apiVersion, participant) + BAN_FROM_CONVERSATION -> handleBan(participant) + else -> { /* unused */ } } } } @@ -1833,27 +775,16 @@ class ConversationInfoActivity : BaseActivity() { } @SuppressLint("StringFormatInvalid") - private fun getDefaultActionItems(participant: Participant): MutableList { - val items = mutableListOf( + private fun getDefaultActionItems(participant: Participant): MutableList = + mutableListOf( BasicListItemWithImage( R.drawable.ic_lock_grey600_24px, context.getString(R.string.nc_attendee_pin, participant.attendeePin) ), - BasicListItemWithImage( - R.drawable.ic_pencil_grey600_24dp, - context.getString(R.string.nc_promote) - ), - BasicListItemWithImage( - R.drawable.ic_pencil_grey600_24dp, - context.getString(R.string.nc_demote) - ), - BasicListItemWithImage( - R.drawable.ic_delete_grey600_24dp, - context.getString(R.string.nc_remove_participant) - ) + BasicListItemWithImage(R.drawable.ic_pencil_grey600_24dp, context.getString(R.string.nc_promote)), + BasicListItemWithImage(R.drawable.ic_pencil_grey600_24dp, context.getString(R.string.nc_demote)), + BasicListItemWithImage(R.drawable.ic_delete_grey600_24dp, context.getString(R.string.nc_remove_participant)) ) - return items - } @SuppressLint("CheckResult") private fun launchRemoveAttendeeFromConversationDialog( @@ -1862,82 +793,47 @@ class ConversationInfoActivity : BaseActivity() { itemText: String, @DrawableRes itemIcon: Int = R.drawable.ic_delete_grey600_24dp ) { - val items = mutableListOf(BasicListItemWithImage(itemIcon, itemText)) MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show { cornerRadius(res = R.dimen.corner_radius) - title(text = participant.displayName) - listItemsWithImage(items = items) { _, index, _ -> - if (index == 0) { - removeAttendeeFromConversation(apiVersion, participant) - } + listItemsWithImage( + items = mutableListOf(BasicListItemWithImage(itemIcon, itemText)) + ) { _, index, _ -> + if (index == 0) removeAttendeeFromConversation(apiVersion, participant) } } } - private fun MaterialDialog.handleBan(participant: Participant) { - val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) - val binding = DialogBanParticipantBinding.inflate(layoutInflater) - val actorTypeConverter = EnumActorTypeConverter() - val dialog = MaterialAlertDialogBuilder(context).setView(binding.root).create() - binding.avatarImage.loadUserAvatar( - conversationUser, + private fun handleBan(participant: Participant) { + val user = conversationUser ?: return + val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + val dialogBinding = DialogBanParticipantBinding.inflate(layoutInflater) + val dialog = MaterialAlertDialogBuilder(this).setView(dialogBinding.root).create() + dialogBinding.avatarImage.loadUserAvatar( + user, participant.actorId!!, requestBigSize = true, ignoreCache = false ) - binding.displayNameText.text = participant.actorId - binding.buttonBan.setOnClickListener { + dialogBinding.displayNameText.text = participant.actorId + dialogBinding.buttonBan.setOnClickListener { banActor( - actorTypeConverter.convertToString(participant.actorType!!), + EnumActorTypeConverter().convertToString(participant.actorType!!), participant.actorId!!, - binding.banParticipantEdit.text.toString() + dialogBinding.banParticipantEdit.text.toString() ) removeAttendeeFromConversation(apiVersion, participant) dialog.dismiss() } - binding.buttonClose.setOnClickListener { dialog.dismiss() } - viewThemeUtils.material.colorTextInputLayout(binding.banParticipantEditLayout) - viewThemeUtils.material.colorMaterialButtonPrimaryFilled(binding.buttonBan) - viewThemeUtils.material.colorMaterialButtonText(binding.buttonClose) + dialogBinding.buttonClose.setOnClickListener { dialog.dismiss() } + viewThemeUtils.material.colorTextInputLayout(dialogBinding.banParticipantEditLayout) + viewThemeUtils.material.colorMaterialButtonPrimaryFilled(dialogBinding.buttonBan) + viewThemeUtils.material.colorMaterialButtonText(dialogBinding.buttonClose) dialog.show() } - private fun setUpNotificationSettings(module: DatabaseStorageModule) { - binding.notificationSettingsView.notificationSettingsCallNotifications.setOnClickListener { - val isChecked = binding.notificationSettingsView.callNotificationsSwitch.isChecked - binding.notificationSettingsView.callNotificationsSwitch.isChecked = !isChecked - lifecycleScope.launch { - module.saveBoolean("call_notifications_switch", !isChecked) - } - } - - binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown - .setOnItemClickListener { _, _, position, _ -> - val value = resources.getStringArray(R.array.message_notification_levels_entry_values)[position] - Log.i(TAG, "saved $value to module from $position") - lifecycleScope.launch { - module.saveString("conversation_info_message_notifications_dropdown", value) - } - } - - if (conversation!!.remoteServer.isNullOrEmpty()) { - binding.notificationSettingsView.notificationSettingsCallNotifications.visibility = VISIBLE - binding.notificationSettingsView.callNotificationsSwitch.isChecked = module - .getBoolean("call_notifications_switch", true) - } else { - binding.notificationSettingsView.notificationSettingsCallNotifications.visibility = GONE - } - } - companion object { private val TAG = ConversationInfoActivity::class.java.simpleName - private const val NOTIFICATION_LEVEL_ALWAYS: Int = 1 - private const val NOTIFICATION_LEVEL_MENTION: Int = 2 - private const val NOTIFICATION_LEVEL_NEVER: Int = 3 - private const val LOW_EMPHASIS_OPACITY: Float = 0.38f - private const val RECORDING_CONSENT_NOT_REQUIRED_FOR_CONVERSATION: Int = 0 - private const val RECORDING_CONSENT_REQUIRED_FOR_CONVERSATION: Int = 1 private const val DEMOTE_OR_PROMOTE = 1 private const val REMOVE_FROM_CONVERSATION = 2 private const val BAN_FROM_CONVERSATION = 3 diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoUiEvent.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoUiEvent.kt new file mode 100644 index 0000000000..094000a8d5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoUiEvent.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationinfo + +import androidx.annotation.StringRes + +sealed class ConversationInfoUiEvent { + data class ShowSnackbar(@StringRes val resId: Int) : ConversationInfoUiEvent() + data class ShowSnackbarText(val text: String) : ConversationInfoUiEvent() + data class NavigateToChat(val token: String) : ConversationInfoUiEvent() + data object RefreshParticipants : ConversationInfoUiEvent() +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoUiState.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoUiState.kt new file mode 100644 index 0000000000..37f21a3da1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoUiState.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationinfo + +import com.nextcloud.talk.conversationinfo.model.ParticipantModel +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.capabilities.SpreedCapability +import com.nextcloud.talk.models.json.conversations.ConversationEnums + +data class ConversationInfoUiState( + val isLoading: Boolean = true, + val spreedCapabilities: SpreedCapability? = null, + val capabilitiesVersion: Int = 0, + val profileDataAvailable: Boolean = false, + + val conversation: ConversationModel? = null, + val displayName: String = "", + val description: String = "", + val avatarUrl: String? = null, + val conversationType: ConversationEnums.ConversationType? = null, + val serverBaseUrl: String = "", + val credentials: String = "", + val conversationToken: String = "", + + val pronouns: String = "", + val professionCompany: String = "", + val localTimeLocation: String = "", + + val upcomingEventSummary: String? = null, + val upcomingEventTime: String? = null, + + val notificationLevel: String = "", + val callNotificationsEnabled: Boolean = true, + val showCallNotifications: Boolean = true, + val importantConversation: Boolean = false, + val showImportantConversation: Boolean = false, + val sensitiveConversation: Boolean = false, + val showSensitiveConversation: Boolean = false, + + val lobbyEnabled: Boolean = false, + val showWebinarSettings: Boolean = false, + val lobbyTimerLabel: String = "", + val showLobbyTimer: Boolean = false, + + val guestsAllowed: Boolean = false, + val showGuestAccess: Boolean = false, + val hasPassword: Boolean = false, + val showPasswordProtection: Boolean = false, + val showResendInvitations: Boolean = false, + + val showSharedItems: Boolean = true, + val showThreadsButton: Boolean = false, + + val showRecordingConsent: Boolean = false, + val recordingConsentForConversation: Boolean = false, + val showRecordingConsentSwitch: Boolean = false, + val showRecordingConsentAll: Boolean = false, + val recordingConsentEnabled: Boolean = true, + + val messageExpirationLabel: String = "", + val showMessageExpiration: Boolean = false, + val showShareConversationButton: Boolean = true, + + val isConversationLocked: Boolean = false, + val showLockConversation: Boolean = false, + + val participants: List = emptyList(), + val showParticipants: Boolean = false, + val showAddParticipants: Boolean = false, + val showStartGroupChat: Boolean = false, + val showListBans: Boolean = false, + + val showArchiveConversation: Boolean = false, + val isArchived: Boolean = false, + val canLeave: Boolean = true, + val canDelete: Boolean = false, + val showClearHistory: Boolean = false, + + val showEditButton: Boolean = false +) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt deleted file mode 100644 index 769293e195..0000000000 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2024 Marcel Hibbe - * SPDX-FileCopyrightText: 2023 Julius Linus - * SPDX-FileCopyrightText: 2022 Tim Krรผger - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.conversationinfo - -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import androidx.appcompat.app.AlertDialog -import androidx.lifecycle.LifecycleOwner -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.nextcloud.talk.R -import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.ActivityConversationInfoBinding -import com.nextcloud.talk.databinding.DialogPasswordBinding -import com.nextcloud.talk.models.domain.ConversationModel -import com.nextcloud.talk.models.json.capabilities.SpreedCapability -import com.nextcloud.talk.models.json.conversations.ConversationEnums -import com.nextcloud.talk.repositories.conversations.ConversationsRepository -import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.ConversationUtils -import io.reactivex.Observer -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers - -class GuestAccessHelper( - private val activity: ConversationInfoActivity, - private val binding: ActivityConversationInfoBinding, - private val conversation: ConversationModel, - private val spreedCapabilities: SpreedCapability, - private val conversationUser: User, - private val viewModel: ConversationInfoViewModel, - private val lifecycleOwner: LifecycleOwner -) { - private val conversationsRepository = activity.conversationsRepository - private val viewThemeUtils = activity.viewThemeUtils - private val context = activity.context - - fun setupGuestAccess() { - if (ConversationUtils.canModerate(conversation, spreedCapabilities)) { - binding.guestAccessView.guestAccessSettings.visibility = View.VISIBLE - } else { - binding.guestAccessView.guestAccessSettings.visibility = View.GONE - } - - if (conversation.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL) { - binding.guestAccessView.allowGuestsSwitch.isChecked = true - showAllOptions() - if (conversation.hasPassword) { - binding.guestAccessView.passwordProtectionSwitch.isChecked = true - } - } else { - binding.guestAccessView.allowGuestsSwitch.isChecked = false - hideAllOptions() - } - - binding.guestAccessView.guestAccessSettingsAllowGuest.setOnClickListener { - val isChecked = binding.guestAccessView.allowGuestsSwitch.isChecked - binding.guestAccessView.allowGuestsSwitch.isChecked = !isChecked - viewModel.allowGuests( - conversationUser, - conversation.token, - !isChecked - ) - viewModel.allowGuestsViewState.observe(lifecycleOwner) { uiState -> - when (uiState) { - is ConversationInfoViewModel.AllowGuestsUIState.Success -> { - binding.guestAccessView.allowGuestsSwitch.isChecked = uiState.allow - if (uiState.allow) { - showAllOptions() - } else { - hideAllOptions() - } - } - is ConversationInfoViewModel.AllowGuestsUIState.Error -> { - val exception = uiState.exception - val message = context.getString(R.string.nc_guest_access_allow_failed) - Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() - Log.e(TAG, message, exception) - } - ConversationInfoViewModel.AllowGuestsUIState.None -> { - } - } - } - } - - binding.guestAccessView.guestAccessSettingsPasswordProtection.setOnClickListener { - val isChecked = binding.guestAccessView.passwordProtectionSwitch.isChecked - binding.guestAccessView.passwordProtectionSwitch.isChecked = !isChecked - if (isChecked) { - val apiVersion = ApiUtils.getConversationApiVersion( - conversationUser, - intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) - ) - val url = ApiUtils.getUrlForRoomPassword( - apiVersion, - conversationUser.baseUrl!!, - conversation.token - ) - viewModel.setPassword( - user = conversationUser, - url = url, - password = "" - ) - passwordObserver() - } else { - showPasswordDialog() - } - } - - binding.guestAccessView.resendInvitationsButton.setOnClickListener { - val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4)) - val url = ApiUtils.getUrlForParticipantsResendInvitations( - apiVersion, - conversationUser.baseUrl!!, - conversation.token - ) - - conversationsRepository.resendInvitations( - user = conversationUser, - url = url - ).subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()).subscribe(ResendInvitationsObserver()) - } - } - - private fun passwordObserver() { - viewModel.passwordViewState.observe(lifecycleOwner) { uiState -> - when (uiState) { - is ConversationInfoViewModel.PasswordUiState.Success -> { - // unused atm - } - is ConversationInfoViewModel.PasswordUiState.Error -> { - val exception = uiState.exception - val message = context.getString(R.string.nc_guest_access_password_failed) - Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() - Log.e(TAG, message, exception) - } - is ConversationInfoViewModel.PasswordUiState.None -> { - // unused atm - } - } - } - } - - private fun showPasswordDialog() { - val builder = MaterialAlertDialogBuilder(activity) - builder.apply { - val dialogPassword = DialogPasswordBinding.inflate(LayoutInflater.from(context)) - viewThemeUtils.platform.colorEditText(dialogPassword.password) - setView(dialogPassword.root) - setTitle(R.string.nc_guest_access_password_dialog_title) - setPositiveButton(R.string.nc_ok) { _, _ -> - val apiVersion = ApiUtils.getConversationApiVersion( - conversationUser, - intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) - ) - val url = ApiUtils.getUrlForRoomPassword( - apiVersion, - conversationUser.baseUrl!!, - conversation.token - ) - val password = dialogPassword.password.text.toString() - viewModel.setPassword( - user = conversationUser, - url = url, - password = password - ) - } - setNegativeButton(R.string.nc_cancel) { _, _ -> - binding.guestAccessView.passwordProtectionSwitch.isChecked = false - } - } - createDialog(builder) - passwordObserver() - } - - private fun createDialog(builder: MaterialAlertDialogBuilder) { - builder.create() - viewThemeUtils.dialog.colorMaterialAlertDialogBackground(binding.conversationInfoName.context, builder) - val dialog = builder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE) - ) - } - - inner class ResendInvitationsObserver : Observer { - - private lateinit var resendInvitationsResult: ConversationsRepository.ResendInvitationsResult - - override fun onSubscribe(d: Disposable) = Unit - - override fun onNext(t: ConversationsRepository.ResendInvitationsResult) { - resendInvitationsResult = t - } - - override fun onError(e: Throwable) { - val message = context.getString(R.string.nc_guest_access_resend_invitations_failed) - Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show() - Log.e(TAG, message, e) - } - - override fun onComplete() { - if (resendInvitationsResult.successful) { - Snackbar.make( - binding.root, - R.string.nc_guest_access_resend_invitations_successful, - Snackbar.LENGTH_SHORT - ).show() - } - } - } - - private fun showAllOptions() { - binding.guestAccessView.guestAccessSettingsPasswordProtection.visibility = View.VISIBLE - if (conversationUser.capabilities?.spreedCapability?.features?.contains("sip-support") == true) { - binding.guestAccessView.resendInvitationsButton.visibility = View.VISIBLE - } - } - - private fun hideAllOptions() { - binding.guestAccessView.guestAccessSettingsPasswordProtection.visibility = View.GONE - binding.guestAccessView.resendInvitationsButton.visibility = View.GONE - } - - companion object { - private val TAG = GuestAccessHelper::class.simpleName - } -} diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt new file mode 100644 index 0000000000..eaf406d071 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt @@ -0,0 +1,1314 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +@file:Suppress("TooManyFunctions", "MatchingDeclarationName") + +package com.nextcloud.talk.conversationinfo.ui + +import android.content.res.Configuration +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation +import com.nextcloud.talk.R +import com.nextcloud.talk.conversationinfo.ConversationInfoUiState +import com.nextcloud.talk.conversationinfo.model.ParticipantModel +import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.models.json.status.StatusType +import com.nextcloud.talk.ui.StatusDrawable +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.DisplayUtils + +data class ConversationInfoScreenCallbacks( + val onNavigateBack: () -> Unit = {}, + val onEditConversation: () -> Unit = {}, + val onMessageNotificationLevelClick: () -> Unit = {}, + val onCallNotificationsClick: () -> Unit = {}, + val onImportantConversationClick: () -> Unit = {}, + val onSensitiveConversationClick: () -> Unit = {}, + val onLobbyClick: () -> Unit = {}, + val onLobbyTimerClick: () -> Unit = {}, + val onAllowGuestsClick: () -> Unit = {}, + val onPasswordProtectionClick: () -> Unit = {}, + val onResendInvitationsClick: () -> Unit = {}, + val onSharedItemsClick: () -> Unit = {}, + val onThreadsClick: () -> Unit = {}, + val onRecordingConsentClick: () -> Unit = {}, + val onMessageExpirationClick: () -> Unit = {}, + val onShareConversationClick: () -> Unit = {}, + val onLockConversationClick: () -> Unit = {}, + val onParticipantClick: (ParticipantModel) -> Unit = {}, + val onAddParticipantsClick: () -> Unit = {}, + val onStartGroupChatClick: () -> Unit = {}, + val onListBansClick: () -> Unit = {}, + val onArchiveClick: () -> Unit = {}, + val onLeaveConversationClick: () -> Unit = {}, + val onClearHistoryClick: () -> Unit = {}, + val onDeleteConversationClick: () -> Unit = {} +) + +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") +@Composable +fun ConversationInfoScreen( + state: ConversationInfoUiState, + callbacks: ConversationInfoScreenCallbacks = ConversationInfoScreenCallbacks() +) { + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.nc_conversation_menu_conversation_info), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = callbacks.onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.back_button) + ) + } + }, + actions = { + if (state.showEditButton) { + IconButton(onClick = callbacks.onEditConversation) { + Icon( + painter = painterResource(R.drawable.ic_pencil_grey600_24dp), + contentDescription = stringResource(R.string.edit) + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurface + ) + ) + } + ) { paddingValues -> + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = WindowInsets.navigationBars.asPaddingValues() + ) { + item { ConversationInfoHeader(state) } + + if (state.description.isNotEmpty()) { + item { ConversationDescriptionSection(state.description) } + } + + if (state.upcomingEventSummary != null || state.upcomingEventTime != null) { + item { UpcomingEventCard(state) } + } + + item { NotificationSettingsSection(state, callbacks) } + + if (state.showWebinarSettings) { + item { WebinarSettingsSection(state, callbacks) } + } + + if (state.showGuestAccess) { + item { GuestAccessSection(state, callbacks) } + } + + if (state.showSharedItems) { + item { SharedItemsSection(state, callbacks) } + } + + item { RecordingConsentSection(state, callbacks) } + + item { ConversationSettingsSection(state, callbacks) } + + if (state.showLockConversation) { + item { LockConversationRow(state, callbacks) } + } + + if (state.showParticipants) { + item { ParticipantsSectionHeader(state, callbacks) } + items( + items = state.participants, + key = { p -> + "${p.participant.calculatedActorType}#${p.participant.calculatedActorId}" + } + ) { participant -> + ParticipantItemRow( + model = participant, + baseUrl = state.serverBaseUrl, + credentials = state.credentials, + conversationToken = state.conversationToken, + onItemClick = callbacks.onParticipantClick + ) + } + } + + if (state.showListBans) { + item { + ClickableIconRow( + title = stringResource(R.string.show_banned_participants), + iconRes = R.drawable.baseline_block_24, + onClick = callbacks.onListBansClick + ) + } + } + + item { DangerZoneSection(state, callbacks) } + } + } + } +} + +@Composable +private fun ConversationInfoHeader(state: ConversationInfoUiState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + HeaderAvatar(avatarUrl = state.avatarUrl) + Spacer(modifier = Modifier.height(8.dp)) + HeaderUserInfo(state = state) + } +} + +@Composable +private fun HeaderAvatar(avatarUrl: String?) { + if (LocalInspectionMode.current) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.account_circle_48dp), + contentDescription = stringResource(R.string.avatar), + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + AsyncImage( + model = avatarUrl, + contentDescription = stringResource(R.string.avatar), + modifier = Modifier + .size(80.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + error = painterResource(R.drawable.account_circle_48dp), + placeholder = painterResource(R.drawable.account_circle_48dp) + ) + } +} + +@Composable +private fun HeaderUserInfo(state: ConversationInfoUiState) { + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = state.displayName, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + if (state.pronouns.isNotEmpty()) { + Text( + text = " ${state.pronouns}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 2.dp) + ) + } + } + if (state.professionCompany.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = state.professionCompany, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + if (state.localTimeLocation.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = state.localTimeLocation, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun ConversationDescriptionSection(description: String) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) +} + +@Composable +private fun UpcomingEventCard(state: ConversationInfoUiState) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_event_24px), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Column { + if (state.upcomingEventSummary != null) { + Text( + text = state.upcomingEventSummary, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold + ) + } + if (state.upcomingEventTime != null) { + Text( + text = state.upcomingEventTime, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) +} + +@Composable +private fun SettingsRow( + title: String, + subtitle: String? = null, + @DrawableRes iconRes: Int? = null, + checked: Boolean? = null, + onClick: (() -> Unit)? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (iconRes != null) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (checked != null) { + Switch(checked = checked, onCheckedChange = null) + } + } +} + +@Composable +private fun ClickableIconRow( + title: String, + iconRes: Int, + onClick: () -> Unit, + titleColor: Color = Color.Unspecified, + iconTint: Color = Color.Unspecified +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (iconTint != Color.Unspecified) iconTint else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.size(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = if (titleColor != Color.Unspecified) titleColor else MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +private fun NotificationSettingsSection(state: ConversationInfoUiState, callbacks: ConversationInfoScreenCallbacks) { + SectionHeader(title = stringResource(R.string.nc_notification_settings)) + if (state.showImportantConversation) { + SettingsRow( + title = stringResource(R.string.nc_important_conversation), + subtitle = stringResource(R.string.nc_important_conversation_desc), + checked = state.importantConversation, + onClick = callbacks.onImportantConversationClick + ) + } + SettingsRow( + title = stringResource(R.string.nc_plain_old_messages), + subtitle = state.notificationLevel.ifEmpty { null }, + onClick = callbacks.onMessageNotificationLevelClick + ) + if (state.showCallNotifications) { + SettingsRow( + title = stringResource(R.string.nc_call_notifications), + checked = state.callNotificationsEnabled, + onClick = callbacks.onCallNotificationsClick + ) + } + if (state.showSensitiveConversation) { + SettingsRow( + title = stringResource(R.string.nc_sensitive_conversation), + subtitle = stringResource(R.string.nc_sensitive_conversation_hint), + checked = state.sensitiveConversation, + onClick = callbacks.onSensitiveConversationClick + ) + } +} + +@Composable +private fun WebinarSettingsSection(state: ConversationInfoUiState, callbacks: ConversationInfoScreenCallbacks) { + SectionHeader(title = stringResource(R.string.nc_webinar)) + SettingsRow( + title = stringResource(R.string.nc_lobby), + iconRes = R.drawable.ic_room_service_black_24dp, + checked = state.lobbyEnabled, + onClick = callbacks.onLobbyClick + ) + if (state.showLobbyTimer) { + SettingsRow( + title = stringResource(R.string.nc_start_time), + subtitle = state.lobbyTimerLabel.ifEmpty { stringResource(R.string.nc_manual) }, + iconRes = R.drawable.ic_timer_black_24dp, + onClick = callbacks.onLobbyTimerClick + ) + } +} + +@Composable +private fun GuestAccessSection(state: ConversationInfoUiState, callbacks: ConversationInfoScreenCallbacks) { + SectionHeader(title = stringResource(R.string.nc_guest_access)) + SettingsRow( + title = stringResource(R.string.nc_guest_access_allow_title), + subtitle = stringResource(R.string.nc_guest_access_allow_summary), + checked = state.guestsAllowed, + onClick = callbacks.onAllowGuestsClick + ) + if (state.showPasswordProtection) { + SettingsRow( + title = stringResource(R.string.nc_guest_access_password_title), + subtitle = stringResource(R.string.nc_guest_access_password_summary), + checked = state.hasPassword, + onClick = callbacks.onPasswordProtectionClick + ) + } + if (state.showResendInvitations) { + ClickableIconRow( + title = stringResource(R.string.nc_guest_access_resend_invitations), + iconRes = R.drawable.ic_email, + onClick = callbacks.onResendInvitationsClick + ) + } +} + +@Composable +private fun SharedItemsSection(state: ConversationInfoUiState, callbacks: ConversationInfoScreenCallbacks) { + SectionHeader(title = stringResource(R.string.nc_shared_items)) + ClickableIconRow( + title = stringResource(R.string.nc_shared_items_description), + iconRes = R.drawable.ic_folder_multiple_image, + onClick = callbacks.onSharedItemsClick + ) + if (state.showThreadsButton) { + ClickableIconRow( + title = stringResource(R.string.recent_threads), + iconRes = R.drawable.outline_forum_24, + onClick = callbacks.onThreadsClick + ) + } +} + +@Composable +private fun RecordingConsentSection(state: ConversationInfoUiState, callbacks: ConversationInfoScreenCallbacks) { + if (!state.showRecordingConsent) return + SectionHeader(title = stringResource(R.string.recording_settings_title)) + if (state.showRecordingConsentSwitch) { + SettingsRow( + title = stringResource(R.string.recording_consent_for_conversation_title), + subtitle = stringResource(R.string.recording_consent_for_conversation_description), + checked = state.recordingConsentForConversation, + onClick = if (state.recordingConsentEnabled) callbacks.onRecordingConsentClick else null + ) + } + if (state.showRecordingConsentAll) { + Text( + text = stringResource(R.string.recording_consent_all), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + } +} + +@Composable +private fun ConversationSettingsSection(state: ConversationInfoUiState, callbacks: ConversationInfoScreenCallbacks) { + SectionHeader(title = stringResource(R.string.nc_conversation_settings)) + if (state.showMessageExpiration) { + SettingsRow( + title = stringResource(R.string.nc_expire_messages), + subtitle = state.messageExpirationLabel.ifEmpty { null }, + onClick = callbacks.onMessageExpirationClick + ) + Text( + text = stringResource(R.string.nc_expire_messages_explanation), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + if (state.showShareConversationButton) { + ClickableIconRow( + title = stringResource(R.string.nc_guest_access_share_link), + iconRes = R.drawable.ic_share_variant, + onClick = callbacks.onShareConversationClick + ) + } +} + +@Composable +private fun LockConversationRow(state: ConversationInfoUiState, callbacks: ConversationInfoScreenCallbacks) { + SettingsRow( + title = stringResource(R.string.lock_conversation), + iconRes = R.drawable.ic_lock_white_24px, + checked = state.isConversationLocked, + onClick = callbacks.onLockConversationClick + ) +} + +@Composable +private fun ParticipantsSectionHeader(state: ConversationInfoUiState, callbacks: ConversationInfoScreenCallbacks) { + SectionHeader(title = stringResource(R.string.nc_participants)) + if (state.showAddParticipants) { + ClickableIconRow( + title = stringResource(R.string.nc_participants_add), + iconRes = R.drawable.ic_account_plus, + onClick = callbacks.onAddParticipantsClick + ) + } + if (state.showStartGroupChat) { + ClickableIconRow( + title = stringResource(R.string.nc_start_group_chat), + iconRes = R.drawable.ic_people_group_black_24px, + onClick = callbacks.onStartGroupChatClick + ) + } +} + +private sealed class ParticipantAvatarContent { + data class Url(val url: String) : ParticipantAvatarContent() + data class Res(@DrawableRes val resId: Int) : ParticipantAvatarContent() + data class FirstLetter(val letter: String) : ParticipantAvatarContent() +} + +private fun buildParticipantAvatarContent( + participant: Participant, + baseUrl: String, + conversationToken: String, + isDark: Boolean +): ParticipantAvatarContent = + when (participant.calculatedActorType) { + Participant.ActorType.USERS -> + ParticipantAvatarContent.Url( + ApiUtils.getUrlForAvatar(baseUrl, participant.calculatedActorId, true, isDark) + ) + Participant.ActorType.FEDERATED -> + ParticipantAvatarContent.Url( + ApiUtils.getUrlForFederatedAvatar( + baseUrl, + conversationToken, + participant.actorId ?: "", + if (isDark) 1 else 0, + true + ) + ) + Participant.ActorType.GROUPS -> ParticipantAvatarContent.Res(R.drawable.ic_avatar_group) + Participant.ActorType.CIRCLES -> ParticipantAvatarContent.Res(R.drawable.ic_avatar_team_small) + Participant.ActorType.PHONES -> ParticipantAvatarContent.Res(R.drawable.ic_phone_small) + Participant.ActorType.GUESTS, Participant.ActorType.EMAILS -> { + val name = participant.displayName + if (!name.isNullOrBlank()) { + ParticipantAvatarContent.FirstLetter(name.trimStart().first().uppercase()) + } else { + ParticipantAvatarContent.Res(R.drawable.account_circle_48dp) + } + } + else -> ParticipantAvatarContent.Res(R.drawable.account_circle_48dp) + } + +@Composable +@Suppress("LongMethod") +private fun ParticipantAvatarImage( + participant: Participant, + baseUrl: String, + credentials: String, + conversationToken: String, + modifier: Modifier = Modifier +) { + val isInPreview = LocalInspectionMode.current + val context = LocalContext.current + val isDark = LocalConfiguration.current.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + + if (isInPreview) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.account_circle_48dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxSize() + ) + } + return + } + + val avatarContent = remember(participant.calculatedActorType, participant.calculatedActorId, isDark) { + buildParticipantAvatarContent(participant, baseUrl, conversationToken, isDark) + } + + when (avatarContent) { + is ParticipantAvatarContent.Url -> { + val request = remember(avatarContent.url, credentials) { + ImageRequest.Builder(context) + .data(avatarContent.url) + .addHeader("Authorization", credentials) + .crossfade(true) + .transformations(CircleCropTransformation()) + .build() + } + AsyncImage( + model = request, + contentDescription = stringResource(R.string.avatar), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.account_circle_48dp), + error = painterResource(R.drawable.account_circle_48dp), + modifier = modifier.clip(CircleShape) + ) + } + is ParticipantAvatarContent.Res -> { + AsyncImage( + model = avatarContent.resId, + contentDescription = stringResource(R.string.avatar), + contentScale = ContentScale.Crop, + modifier = modifier.clip(CircleShape) + ) + } + is ParticipantAvatarContent.FirstLetter -> { + Box( + modifier = modifier + .clip(CircleShape) + .background(colorResource(R.color.grey_600)), + contentAlignment = Alignment.Center + ) { + Text( + text = avatarContent.letter, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +private const val PARTICIPANT_STATUS_SIZE_DP = 18f +private const val PARTICIPANT_OFFLINE_ALPHA = 0.38f +private const val PARTICIPANT_STATUS_EMOJI_SCALE = 0.8f + +@Composable +private fun ParticipantStatusOverlay(status: String?, modifier: Modifier = Modifier) { + if (!status.isNullOrEmpty()) { + if (LocalInspectionMode.current) { + val drawableRes = when (status) { + StatusType.ONLINE.string -> R.drawable.online_status + StatusType.AWAY.string -> R.drawable.ic_user_status_away + StatusType.BUSY.string -> R.drawable.ic_user_status_busy + StatusType.DND.string -> R.drawable.ic_user_status_dnd + else -> null + } ?: return + Icon( + painter = painterResource(drawableRes), + contentDescription = null, + tint = Color.Unspecified, + modifier = modifier.clipToBounds() + ) + } else { + val context = LocalContext.current + val surfaceArgb = MaterialTheme.colorScheme.surface.toArgb() + AndroidView( + factory = { ctx -> + ImageView(ctx).apply { + val radiusPx = DisplayUtils.convertDpToPixel(PARTICIPANT_STATUS_SIZE_DP / 2f, ctx) + setImageDrawable(StatusDrawable(status, "", radiusPx, surfaceArgb, ctx)) + } + }, + update = { imageView -> + val radiusPx = DisplayUtils.convertDpToPixel(PARTICIPANT_STATUS_SIZE_DP / 2f, context) + imageView.setImageDrawable(StatusDrawable(status, "", radiusPx, surfaceArgb, context)) + }, + modifier = modifier.clipToBounds() + ) + } + } +} + +@Composable +private fun participantRoleLabel(participant: Participant): String = + when (participant.type) { + Participant.ParticipantType.OWNER, + Participant.ParticipantType.MODERATOR, + Participant.ParticipantType.GUEST_MODERATOR -> stringResource(R.string.nc_moderator) + Participant.ParticipantType.USER -> when (participant.calculatedActorType) { + Participant.ActorType.GROUPS -> stringResource(R.string.nc_group) + Participant.ActorType.CIRCLES -> stringResource(R.string.nc_team) + else -> "" + } + Participant.ParticipantType.GUEST -> stringResource(R.string.nc_guest) + Participant.ParticipantType.USER_FOLLOWING_LINK -> stringResource(R.string.nc_following_link) + else -> "" + } + +@Composable +private fun participantEffectiveStatus(participant: Participant): String { + val explicit = participant.statusMessage + if (!explicit.isNullOrEmpty()) return explicit + return when (participant.status) { + StatusType.DND.string -> stringResource(R.string.dnd) + StatusType.BUSY.string -> stringResource(R.string.busy) + StatusType.AWAY.string -> stringResource(R.string.away) + else -> "" + } +} + +@Composable +private fun ParticipantNameRow(displayName: String, roleLabel: String, nameColor: Color) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = displayName, + style = MaterialTheme.typography.bodyLarge, + color = nameColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (roleLabel.isNotEmpty()) { + Text( + text = " ($roleLabel)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun ParticipantStatusRow(statusEmoji: String?, statusText: String) { + if (statusEmoji == null && statusText.isEmpty()) return + Row(verticalAlignment = Alignment.CenterVertically) { + if (statusEmoji != null) { + val fontSize = with(LocalDensity.current) { + (PARTICIPANT_STATUS_SIZE_DP * PARTICIPANT_STATUS_EMOJI_SCALE).dp.toSp() + } + Text( + text = statusEmoji, + fontSize = fontSize, + lineHeight = fontSize, + maxLines = 1 + ) + if (statusText.isNotEmpty()) { + Spacer(modifier = Modifier.width(4.dp)) + } + } + if (statusText.isNotEmpty()) { + Text( + text = statusText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +@Suppress("LongMethod") +private fun ParticipantItemRow( + model: ParticipantModel, + baseUrl: String, + credentials: String, + conversationToken: String, + onItemClick: (ParticipantModel) -> Unit +) { + val participant = model.participant + val nameColor = if (model.isOnline) { + colorResource(R.color.high_emphasis_text) + } else { + colorResource(R.color.medium_emphasis_text) + } + val displayName = if (!participant.displayName.isNullOrBlank()) { + participant.displayName!! + } else { + stringResource(R.string.nc_guest) + } + val roleLabel = participantRoleLabel(participant) + val statusText = participantEffectiveStatus(participant) + val statusEmoji = participant.statusIcon?.takeIf { it.isNotEmpty() } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onItemClick(model) } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.size(40.dp)) { + ParticipantAvatarImage( + participant = participant, + baseUrl = baseUrl, + credentials = credentials, + conversationToken = conversationToken, + modifier = Modifier + .size(40.dp) + .alpha(if (model.isOnline) 1f else PARTICIPANT_OFFLINE_ALPHA) + ) + ParticipantStatusOverlay( + status = participant.status, + modifier = Modifier + .size(PARTICIPANT_STATUS_SIZE_DP.dp) + .align(Alignment.BottomEnd) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + ParticipantNameRow(displayName = displayName, roleLabel = roleLabel, nameColor = nameColor) + ParticipantStatusRow(statusEmoji = statusEmoji, statusText = statusText) + } + val inCallIconRes = when { + participant.inCall and Participant.InCallFlags.WITH_PHONE.toLong() > 0L -> + R.drawable.ic_call_grey_600_24dp + participant.inCall and Participant.InCallFlags.WITH_VIDEO.toLong() > 0L -> + R.drawable.ic_videocam_grey_600_24dp + participant.inCall > Participant.InCallFlags.DISCONNECTED.toLong() -> + R.drawable.ic_mic_grey_600_24dp + else -> null + } + if (inCallIconRes != null) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(inCallIconRes), + contentDescription = displayName, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + } +} + +@Composable +@Suppress("LongMethod") +private fun DangerZoneSection(state: ConversationInfoUiState, callbacks: ConversationInfoScreenCallbacks) { + val errorColor = MaterialTheme.colorScheme.error + Text( + text = stringResource(R.string.danger_zone), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = errorColor, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + + if (state.showArchiveConversation) { + val archiveTitle = if (state.isArchived) { + stringResource(R.string.unarchive_conversation) + } else { + stringResource(R.string.archive_conversation) + } + val archiveIcon = if (state.isArchived) R.drawable.ic_unarchive_24px else R.drawable.outline_archive_24 + ClickableIconRow( + title = archiveTitle, + iconRes = archiveIcon, + onClick = callbacks.onArchiveClick + ) + Text( + text = if (state.isArchived) { + stringResource(R.string.unarchive_hint) + } else { + stringResource(R.string.archive_hint) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + + if (state.canLeave) { + ClickableIconRow( + title = stringResource(R.string.nc_leave), + iconRes = R.drawable.ic_exit_to_app_black_24dp, + iconTint = errorColor, + titleColor = errorColor, + onClick = callbacks.onLeaveConversationClick + ) + } + + if (state.showClearHistory) { + ClickableIconRow( + title = stringResource(R.string.nc_clear_history), + iconRes = R.drawable.ic_delete_black_24dp, + iconTint = errorColor, + titleColor = errorColor, + onClick = callbacks.onClearHistoryClick + ) + } + + if (state.canDelete) { + ClickableIconRow( + title = stringResource(R.string.nc_delete_call), + iconRes = R.drawable.ic_delete_black_24dp, + iconTint = errorColor, + titleColor = errorColor, + onClick = callbacks.onDeleteConversationClick + ) + } +} + +@Suppress("LongMethod") +private fun previewState(): ConversationInfoUiState { + val alice = ParticipantModel( + participant = Participant( + actorType = Participant.ActorType.USERS, + actorId = "alice", + displayName = "Alice Johnson", + type = Participant.ParticipantType.OWNER, + status = StatusType.ONLINE.string + ), + isOnline = true + ) + val bob = ParticipantModel( + participant = Participant( + actorType = Participant.ActorType.USERS, + actorId = "bob", + displayName = "Bob Smith", + type = Participant.ParticipantType.MODERATOR, + status = StatusType.AWAY.string, + statusMessage = "In a meeting", + inCall = Participant.InCallFlags.WITH_VIDEO.toLong() + ), + isOnline = true + ) + val carol = ParticipantModel( + participant = Participant( + actorType = Participant.ActorType.GROUPS, + actorId = "dev-team", + displayName = "Dev Team", + type = Participant.ParticipantType.USER + ), + isOnline = false + ) + return ConversationInfoUiState( + isLoading = false, + displayName = "Jane Doe", + pronouns = "she/her", + professionCompany = "Marketing Manager @ Nextcloud GmbH", + localTimeLocation = "10:03 PM ยท London", + description = "This is a sample conversation description.", + upcomingEventSummary = "Mgmt Coordination Call", + upcomingEventTime = "Apr 15, 2026, 2:00 PM", + notificationLevel = "Always", + callNotificationsEnabled = true, + showCallNotifications = true, + importantConversation = false, + showImportantConversation = true, + sensitiveConversation = false, + showSensitiveConversation = true, + lobbyEnabled = true, + showWebinarSettings = true, + lobbyTimerLabel = "Apr 15, 2026, 10:00 AM", + showLobbyTimer = true, + guestsAllowed = true, + showGuestAccess = true, + hasPassword = false, + showPasswordProtection = true, + showResendInvitations = true, + showSharedItems = true, + showThreadsButton = true, + showRecordingConsent = true, + recordingConsentForConversation = false, + showRecordingConsentSwitch = true, + messageExpirationLabel = "1 week", + showMessageExpiration = true, + showShareConversationButton = true, + isConversationLocked = false, + showLockConversation = true, + showParticipants = true, + participants = listOf(alice, bob, carol), + showAddParticipants = true, + showListBans = true, + showArchiveConversation = true, + isArchived = false, + canLeave = true, + canDelete = true, + showClearHistory = true + ) +} + +@Composable +private fun PreviewWrapper(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface { + Column { + content() + } + } + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun SectionHeaderPreview() { + PreviewWrapper { + SectionHeader(title = "Notification Settings") + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun SettingsRowPreview() { + PreviewWrapper { + SettingsRow(title = "Plain setting", onClick = {}) + SettingsRow(title = "With subtitle", subtitle = "Some detail text", onClick = {}) + SettingsRow(title = "Toggle on", checked = true, onClick = {}) + SettingsRow(title = "Toggle off", checked = false, onClick = {}) + SettingsRow( + title = "With icon and toggle", + iconRes = R.drawable.ic_room_service_black_24dp, + checked = true, + onClick = {} + ) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun ClickableIconRowPreview() { + PreviewWrapper { + ClickableIconRow( + title = "Share conversation", + iconRes = R.drawable.ic_share_variant, + onClick = {} + ) + ClickableIconRow( + title = "Delete conversation", + iconRes = R.drawable.ic_delete_black_24dp, + iconTint = MaterialTheme.colorScheme.error, + titleColor = MaterialTheme.colorScheme.error, + onClick = {} + ) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun ConversationInfoHeaderPreview() { + PreviewWrapper { + ConversationInfoHeader(state = previewState()) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun UpcomingEventCardPreview() { + PreviewWrapper { + UpcomingEventCard(state = previewState()) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun ConversationDescriptionSectionPreview() { + PreviewWrapper { + ConversationDescriptionSection(description = "This is a sample conversation description.") + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun NotificationSettingsSectionPreview() { + PreviewWrapper { + NotificationSettingsSection( + state = previewState(), + callbacks = ConversationInfoScreenCallbacks() + ) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun WebinarSettingsSectionPreview() { + PreviewWrapper { + WebinarSettingsSection( + state = previewState(), + callbacks = ConversationInfoScreenCallbacks() + ) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun GuestAccessSectionPreview() { + PreviewWrapper { + GuestAccessSection( + state = previewState(), + callbacks = ConversationInfoScreenCallbacks() + ) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun SharedItemsSectionPreview() { + PreviewWrapper { + SharedItemsSection( + state = previewState(), + callbacks = ConversationInfoScreenCallbacks() + ) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun RecordingConsentSectionPreview() { + PreviewWrapper { + RecordingConsentSection( + state = previewState(), + callbacks = ConversationInfoScreenCallbacks() + ) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun ConversationSettingsSectionPreview() { + PreviewWrapper { + ConversationSettingsSection( + state = previewState(), + callbacks = ConversationInfoScreenCallbacks() + ) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun LockConversationRowPreview() { + PreviewWrapper { + LockConversationRow( + state = previewState(), + callbacks = ConversationInfoScreenCallbacks() + ) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun ParticipantsSectionHeaderPreview() { + PreviewWrapper { + ParticipantsSectionHeader( + state = previewState(), + callbacks = ConversationInfoScreenCallbacks() + ) + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun ParticipantItemRowPreview() { + val state = previewState() + PreviewWrapper { + state.participants.forEach { participant -> + ParticipantItemRow( + model = participant, + baseUrl = state.serverBaseUrl, + credentials = state.credentials, + conversationToken = state.conversationToken, + onItemClick = {} + ) + } + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun DangerZoneSectionPreview() { + PreviewWrapper { + DangerZoneSection( + state = previewState(), + callbacks = ConversationInfoScreenCallbacks() + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt index 638ac3b557..927c9a943a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt @@ -1,12 +1,10 @@ -/* +๏ปฟ/* * Nextcloud Talk - Android Client * * SPDX-FileCopyrightText: 2023 Marcel Hibbe * SPDX-License-Identifier: GPL-3.0-or-later */ package com.nextcloud.talk.conversationinfo.viewmodel - -import android.annotation.SuppressLint import android.util.Log import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -14,52 +12,80 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nextcloud.talk.R +import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource +import com.nextcloud.talk.conversationinfo.ConversationInfoUiEvent +import com.nextcloud.talk.conversationinfo.ConversationInfoUiState import com.nextcloud.talk.conversationinfo.CreateRoomRequest import com.nextcloud.talk.conversationinfo.Participants +import com.nextcloud.talk.conversationinfo.model.ParticipantModel import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.domain.converters.DomainEnumNotificationLevelConverter import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser import com.nextcloud.talk.models.json.capabilities.SpreedCapability -import com.nextcloud.talk.models.json.conversations.RoomOverall +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.models.json.participants.Participant.ActorType.CIRCLES import com.nextcloud.talk.models.json.participants.Participant.ActorType.EMAILS import com.nextcloud.talk.models.json.participants.Participant.ActorType.FEDERATED import com.nextcloud.talk.models.json.participants.Participant.ActorType.GROUPS +import com.nextcloud.talk.models.json.participants.Participant.ActorType.USERS +import com.nextcloud.talk.models.json.participants.ParticipantsOverall import com.nextcloud.talk.models.json.participants.TalkBan import com.nextcloud.talk.models.json.profile.Profile import com.nextcloud.talk.repositories.conversations.ConversationsRepository +import com.nextcloud.talk.repositories.conversations.ConversationsRepository.ResendInvitationsResult import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils.getUrlForRooms +import com.nextcloud.talk.utils.CapabilitiesUtil +import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability +import com.nextcloud.talk.utils.ConversationUtils +import com.nextcloud.talk.utils.DateConstants import com.nextcloud.talk.utils.DisplayUtils +import com.nextcloud.talk.utils.SpreedFeatures +import com.nextcloud.talk.utils.preferences.preferencestorage.DatabaseStorageModule import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.text.DateFormat +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Date +import java.util.Locale import javax.inject.Inject - -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") class ConversationInfoViewModel @Inject constructor( private val chatNetworkDataSource: ChatNetworkDataSource, - private val conversationsRepository: ConversationsRepository + private val conversationsRepository: ConversationsRepository, + private val ncApi: NcApi ) : ViewModel() { - object LifeCycleObserver : DefaultLifecycleObserver { enum class LifeCycleFlag { PAUSED, RESUMED } - lateinit var currentLifeCycleFlag: LifeCycleFlag val disposableSet = mutableSetOf() - override fun onResume(owner: LifecycleOwner) { super.onResume(owner) currentLifeCycleFlag = LifeCycleFlag.RESUMED } - override fun onPause(owner: LifecycleOwner) { super.onPause(owner) currentLifeCycleFlag = LifeCycleFlag.PAUSED @@ -67,100 +93,98 @@ class ConversationInfoViewModel @Inject constructor( disposableSet.clear() } } - sealed interface ViewState - class ListBansSuccessState(val talkBans: List) : ViewState object ListBansErrorState : ViewState - private val _getTalkBanState: MutableLiveData = MutableLiveData() val getTalkBanState: LiveData get() = _getTalkBanState - - class BanActorSuccessState(val talkBan: TalkBan) : ViewState - object BanActorErrorState : ViewState - - private val _getBanActorState: MutableLiveData = MutableLiveData() - val getBanActorState: LiveData - get() = _getBanActorState - object UnBanActorSuccessState : ViewState object UnBanActorErrorState : ViewState - private val _getUnBanActorState: MutableLiveData = MutableLiveData() val getUnBanActorState: LiveData get() = _getUnBanActorState + private var currentUser: User? = null + private var currentToken: String = "" + private var databaseStorageModule: DatabaseStorageModule? = null + private val _uiState = MutableStateFlow(ConversationInfoUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private val _uiEvent = MutableSharedFlow(extraBufferCapacity = 1) + val uiEvent: SharedFlow = _uiEvent.asSharedFlow() + fun loadParticipants(user: User, token: String) { + val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + val credentials = ApiUtils.getCredentials(user.username, user.token)!! + val fieldMap = HashMap() + fieldMap["includeStatus"] = true + ncApi.getPeersForCall( + credentials, + ApiUtils.getUrlForParticipants(apiVersion, user.baseUrl!!, token), + fieldMap + ) + ?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + LifeCycleObserver.disposableSet.add(d) + } - object GetRoomStartState : ViewState - object GetRoomErrorState : ViewState - open class GetRoomSuccessState(val conversationModel: ConversationModel) : ViewState - - private val _viewState: MutableLiveData = MutableLiveData(GetRoomStartState) - val viewState: LiveData - get() = _viewState - - object GetCapabilitiesStartState : ViewState - object GetCapabilitiesErrorState : ViewState - open class GetCapabilitiesSuccessState(val spreedCapabilities: SpreedCapability) : ViewState - - private val _allowGuestsViewState = MutableLiveData(AllowGuestsUIState.None) - val allowGuestsViewState: LiveData - get() = _allowGuestsViewState - - private val _passwordViewState = MutableLiveData(PasswordUiState.None) - val passwordViewState: LiveData - get() = _passwordViewState - - private val _getCapabilitiesViewState: MutableLiveData = MutableLiveData(GetCapabilitiesStartState) - val getCapabilitiesViewState: LiveData - get() = _getCapabilitiesViewState - - private val _clearChatHistoryViewState: MutableLiveData = - MutableLiveData(ClearChatHistoryViewState.None) - val clearChatHistoryViewState: LiveData - get() = _clearChatHistoryViewState - - private val _getConversationReadOnlyState: MutableLiveData = - MutableLiveData(SetConversationReadOnlyViewState.None) - val getConversationReadOnlyState: LiveData - get() = _getConversationReadOnlyState - - @Suppress("PropertyName") - private val _markConversationAsImportantResult = - MutableLiveData(MarkConversationAsImportantViewState.None) - val markAsImportantResult: LiveData - get() = _markConversationAsImportantResult - - @Suppress("PropertyName") - private val _markConversationAsUnimportantResult = - MutableLiveData(MarkConversationAsUnimportantViewState.None) - val markAsUnimportantResult: LiveData - get() = _markConversationAsUnimportantResult - - private val _createRoomViewState = MutableLiveData(CreateRoomUIState.None) - val createRoomViewState: LiveData - get() = _createRoomViewState - - object GetProfileErrorState : ViewState - class GetProfileSuccessState(val profile: Profile) : ViewState - private val _getProfileViewState = MutableLiveData() - val getProfileViewState: LiveData - get() = _getProfileViewState - - @Suppress("PropertyName") - private val _markConversationAsSensitiveResult = - MutableLiveData(MarkConversationAsSensitiveViewState.None) - val markAsSensitiveResult: LiveData - get() = _markConversationAsSensitiveResult - - @Suppress("PropertyName") - private val _markConversationAsInsensitiveResult = - MutableLiveData(MarkConversationAsInsensitiveViewState.None) - val markAsInsensitiveResult: LiveData - get() = _markConversationAsInsensitiveResult + @Suppress("Detekt.TooGenericExceptionCaught") + override fun onNext(participantsOverall: ParticipantsOverall) { + val participants = processParticipants(participantsOverall.ocs!!.data!!, user.userId) + _uiState.update { it.copy(participants = participants, showParticipants = true) } + } + override fun onError(e: Throwable) { + Log.e(TAG, "Error loading participants", e) + } + override fun onComplete() { + // unused atm + } + }) + } + @Suppress("DEPRECATION") + private fun processParticipants(participants: List, userId: String?): List { + val uiItems: MutableList = ArrayList() + var ownUiItem: ParticipantModel? = null + for (participant in participants) { + val isOnline = if (participant.sessionId != null) { + !participant.sessionId.equals("0") + } else { + participant.sessionIds.isNotEmpty() + } + if (participant.calculatedActorType == USERS && participant.calculatedActorId == userId) { + participant.sessionId = "-1" + ownUiItem = ParticipantModel(participant, true) + } else { + uiItems.add(ParticipantModel(participant, isOnline)) + } + } + uiItems.sortWith( + compareBy( + { it.participant.actorType == GROUPS || it.participant.actorType == CIRCLES }, + { !it.isOnline }, + { + it.participant.type !in listOf( + Participant.ParticipantType.MODERATOR, + Participant.ParticipantType.OWNER, + Participant.ParticipantType.GUEST_MODERATOR + ) + }, + { it.participant.displayName!!.lowercase(Locale.ROOT) } + ) + ) + if (ownUiItem != null) { + uiItems.add(0, ownUiItem) + } + return uiItems + } fun getRoom(user: User, token: String) { - _viewState.value = GetRoomStartState + currentUser = user + currentToken = token + if (databaseStorageModule == null) { + databaseStorageModule = DatabaseStorageModule(user, token) + } + _uiState.update { it.copy(isLoading = true) } chatNetworkDataSource.getRoom(user, token) .subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) @@ -177,9 +201,7 @@ class ConversationInfoViewModel @Inject constructor( val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) val url = getUrlForRooms(apiVersion, user.baseUrl!!) val credentials = ApiUtils.getCredentials(user.username, user.token)!! - val participantsBody = convertAutocompleteUserToParticipant(autocompleteUsers) - val body = CreateRoomRequest( roomName = createConversationNameByParticipants( userItems.map { it.displayName }, @@ -197,25 +219,23 @@ class ConversationInfoViewModel @Inject constructor( objectType = EXTENDED_CONVERSATION, objectId = roomToken ) - viewModelScope.launch { try { - val roomOverall = conversationsRepository.createRoom( - credentials, - url, - body - ) - _createRoomViewState.value = CreateRoomUIState.Success(roomOverall) + val roomOverall = conversationsRepository.createRoom(credentials, url, body) + val token = roomOverall.ocs?.data?.token + if (token != null) { + _uiEvent.emit(ConversationInfoUiEvent.NavigateToChat(token)) + } else { + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_common_error_sorry)) + } } catch (e: Exception) { Log.e(TAG, "Failed to create room", e) - _createRoomViewState.value = CreateRoomUIState.Error(e) + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_common_error_sorry)) } } } - private fun convertAutocompleteUserToParticipant(autocompleteUsers: List): Participants { val participants = Participants() - autocompleteUsers.forEach { autocompleteUser -> when (autocompleteUser.source) { GROUPS.name.lowercase() -> participants.groups.add(autocompleteUser.id!!) @@ -226,15 +246,11 @@ class ConversationInfoViewModel @Inject constructor( else -> participants.users.add(autocompleteUser.id!!) } } - return participants } - - fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) { - _getCapabilitiesViewState.value = GetCapabilitiesStartState - + private fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) { if (conversationModel.remoteServer.isNullOrEmpty()) { - _getCapabilitiesViewState.value = GetCapabilitiesSuccessState(user.capabilities!!.spreedCapability!!) + handleCapabilitiesSuccess(user.capabilities!!.spreedCapability!!, conversationModel) } else { chatNetworkDataSource.getCapabilities(user, token) .subscribeOn(Schedulers.io()) @@ -243,16 +259,13 @@ class ConversationInfoViewModel @Inject constructor( override fun onSubscribe(d: Disposable) { LifeCycleObserver.disposableSet.add(d) } - override fun onNext(spreedCapabilities: SpreedCapability) { - _getCapabilitiesViewState.value = GetCapabilitiesSuccessState(spreedCapabilities) + handleCapabilitiesSuccess(spreedCapabilities, conversationModel) } - override fun onError(e: Throwable) { Log.e(TAG, "Error when fetching spreed capabilities", e) - _getCapabilitiesViewState.value = GetCapabilitiesErrorState + _uiState.update { it.copy(isLoading = false) } } - override fun onComplete() { // unused atm } @@ -260,6 +273,207 @@ class ConversationInfoViewModel @Inject constructor( } } + @Suppress("LongMethod", "ComplexMethod") + private fun handleCapabilitiesSuccess(spreedCapabilities: SpreedCapability, conversationModel: ConversationModel) { + val res = NextcloudTalkApplication.sharedApplication!!.resources + val user = currentUser ?: return + val token = currentToken + val dbModule = databaseStorageModule ?: DatabaseStorageModule(user, token).also { databaseStorageModule = it } + + val isOne2One = conversationModel.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + val isNoteToSelf = ConversationUtils.isNoteToSelfConversation(conversationModel) + val isPublic = conversationModel.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL + val isGroup = conversationModel.type == ConversationEnums.ConversationType.ROOM_GROUP_CALL + val isSystem = conversationModel.type == ConversationEnums.ConversationType.ROOM_SYSTEM + val canModerate = ConversationUtils.canModerate(conversationModel, spreedCapabilities) + val isModerator = ConversationUtils.isParticipantOwnerOrModerator(conversationModel) + + val avatarUrl: String? = when (conversationModel.type) { + ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> + if (conversationModel.name.isNotEmpty()) { + ApiUtils.getUrlForAvatar(user.baseUrl, conversationModel.name, true) + } else { + null + } + ConversationEnums.ConversationType.ROOM_GROUP_CALL, + ConversationEnums.ConversationType.ROOM_PUBLIC_CALL -> + ApiUtils.getUrlForConversationAvatar(1, user.baseUrl, token) + else -> null + } + + val notifLevel = conversationModel.notificationLevel + val notificationLevelStr = when { + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.NOTIFICATION_LEVELS) && + notifLevel != ConversationEnums.NotificationLevel.DEFAULT -> { + when (DomainEnumNotificationLevelConverter().convertToInt(notifLevel)) { + NOTIFICATION_LEVEL_ALWAYS -> res.getString(R.string.nc_notify_me_always) + NOTIFICATION_LEVEL_MENTION -> res.getString(R.string.nc_notify_me_mention) + NOTIFICATION_LEVEL_NEVER -> res.getString(R.string.nc_notify_me_never) + else -> res.getString(R.string.nc_notify_me_mention) + } + } + else -> { + if (isOne2One && hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG)) { + res.getString(R.string.nc_notify_me_always) + } else { + res.getString(R.string.nc_notify_me_mention) + } + } + } + + dbModule.setMessageExpiration(conversationModel.messageExpiration) + val expirationValue = dbModule.getString("conversation_settings_dropdown", "") ?: "" + val expirationValues = res.getStringArray(R.array.message_expiring_values) + val expirationDescriptions = res.getStringArray(R.array.message_expiring_descriptions) + val expirationPos = expirationValues.indexOf(expirationValue).coerceAtLeast(0) + val messageExpirationLabel = + if (expirationPos < expirationDescriptions.size) expirationDescriptions[expirationPos] else "" + + val isLobbyEnabled = + conversationModel.lobbyState == ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY + val lobbyTimerMs = (conversationModel.lobbyTimer ?: 0L) * DateConstants.SECOND_DIVIDER + val lobbyTimerLabel = if (conversationModel.lobbyTimer != null && + conversationModel.lobbyTimer != 0L && + conversationModel.lobbyTimer != Long.MIN_VALUE + ) { + DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT).format(Date(lobbyTimerMs)) + } else { + "" + } + + val recordingConsentType = CapabilitiesUtil.getRecordingConsentType(spreedCapabilities) + val showRecordingConsent = isModerator && + !isNoteToSelf && + recordingConsentType != CapabilitiesUtil.RECORDING_CONSENT_NOT_REQUIRED + val showRecordingConsentSwitch = + showRecordingConsent && recordingConsentType == CapabilitiesUtil.RECORDING_CONSENT_DEPEND_ON_CONVERSATION + val showRecordingConsentAll = + showRecordingConsent && recordingConsentType == CapabilitiesUtil.RECORDING_CONSENT_REQUIRED + val recordingConsentForConversation = + conversationModel.recordingConsentRequired == RECORDING_CONSENT_REQUIRED_FOR_CONVERSATION + val recordingConsentEnabled = !conversationModel.hasCall + + val showCallNotifications = !isSystem && + conversationModel.notificationCalls != null && + conversationModel.remoteServer.isNullOrEmpty() + val callNotificationsEnabled = dbModule.getBoolean("call_notifications_switch", true) + + val showLockConversation = ConversationUtils.isConversationReadOnlyAvailable( + conversationModel, + spreedCapabilities + ) + val isConversationLocked = dbModule.getBoolean("lock_switch", false) + + val canLeave = conversationModel.canLeaveConversation + val canDelete = conversationModel.canDeleteConversation + + val showGuestAccess = canModerate + val guestsAllowed = isPublic + val hasPassword = guestsAllowed && conversationModel.hasPassword + val showPasswordProtection = guestsAllowed + val showResendInvitations = guestsAllowed && + user.capabilities?.spreedCapability?.features?.contains("sip-support") == true + + val showSharedItems = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.RICH_OBJECT_LIST_MEDIA) && + conversationModel.remoteServer.isNullOrEmpty() + val showThreadsButton = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS) + + val isWebinarRoom = isGroup || isPublic + val showWebinarSettings = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.WEBINARY_LOBBY) && + isWebinarRoom && + canModerate + + val showImportantConversation = + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.IMPORTANT_CONVERSATIONS) + val importantConversation = conversationModel.hasImportant + val showSensitiveConversation = + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.SENSITIVE_CONVERSATIONS) + val sensitiveConversation = conversationModel.hasSensitive + + val showArchiveConversation = + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.ARCHIVE_CONVERSATIONS) + val isArchived = conversationModel.hasArchived + + val showAddParticipants: Boolean + val showStartGroupChat: Boolean + val showClearHistory: Boolean + if (isOne2One && hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.CONVERSATION_CREATION_ALL)) { + showStartGroupChat = true + showAddParticipants = false + showClearHistory = canDelete && hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.CLEAR_HISTORY) + } else if (canModerate) { + showAddParticipants = true + showStartGroupChat = false + showClearHistory = canDelete && hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.CLEAR_HISTORY) + } else { + showAddParticipants = false + showStartGroupChat = false + showClearHistory = false + } + + val showEditButton = canModerate && hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.AVATAR) + val showShareConversationButton = !isNoteToSelf + val showListBans = canModerate && !isOne2One + val showMessageExpiration = isModerator && + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MESSAGE_EXPIRATION) + + val credentials = ApiUtils.getCredentials(user.username, user.token) ?: "" + + _uiState.update { state -> + state.copy( + isLoading = false, + spreedCapabilities = spreedCapabilities, + capabilitiesVersion = state.capabilitiesVersion + 1, + displayName = conversationModel.displayName, + description = conversationModel.description, + avatarUrl = avatarUrl, + conversationType = conversationModel.type, + serverBaseUrl = user.baseUrl ?: "", + credentials = credentials, + conversationToken = token, + notificationLevel = notificationLevelStr, + callNotificationsEnabled = callNotificationsEnabled, + showCallNotifications = showCallNotifications, + importantConversation = importantConversation, + showImportantConversation = showImportantConversation, + sensitiveConversation = sensitiveConversation, + showSensitiveConversation = showSensitiveConversation, + lobbyEnabled = isLobbyEnabled, + showWebinarSettings = showWebinarSettings, + lobbyTimerLabel = lobbyTimerLabel, + showLobbyTimer = isLobbyEnabled, + guestsAllowed = guestsAllowed, + showGuestAccess = showGuestAccess, + hasPassword = hasPassword, + showPasswordProtection = showPasswordProtection, + showResendInvitations = showResendInvitations, + showSharedItems = showSharedItems, + showThreadsButton = showThreadsButton, + showRecordingConsent = showRecordingConsent, + recordingConsentForConversation = recordingConsentForConversation, + showRecordingConsentSwitch = showRecordingConsentSwitch, + showRecordingConsentAll = showRecordingConsentAll, + recordingConsentEnabled = recordingConsentEnabled, + messageExpirationLabel = messageExpirationLabel, + showMessageExpiration = showMessageExpiration, + showShareConversationButton = showShareConversationButton, + isConversationLocked = isConversationLocked, + showLockConversation = showLockConversation, + showAddParticipants = showAddParticipants, + showStartGroupChat = showStartGroupChat, + showListBans = showListBans, + showArchiveConversation = showArchiveConversation, + isArchived = isArchived, + canLeave = canLeave, + canDelete = canDelete, + showClearHistory = showClearHistory, + showEditButton = showEditButton + ) + } + + loadParticipants(user, token) + } + @Suppress("Detekt.TooGenericExceptionCaught") fun listBans(user: User, token: String) { val url = ApiUtils.getUrlForBans(user.baseUrl!!, token) @@ -279,47 +493,15 @@ class ConversationInfoViewModel @Inject constructor( val url = ApiUtils.getUrlForBans(user.baseUrl!!, token) viewModelScope.launch { try { - val talkBan = conversationsRepository.banActor( - user.getCredentials(), - url, - actorType, - actorId, - internalNote - ) - _getBanActorState.value = BanActorSuccessState(talkBan) + conversationsRepository.banActor(user.getCredentials(), url, actorType, actorId, internalNote) + _uiEvent.emit(ConversationInfoUiEvent.RefreshParticipants) } catch (exception: Exception) { - _getBanActorState.value = BanActorErrorState + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbarText("Error banning actor")) Log.e(TAG, "Error banning a participant", exception) } } } - @Suppress("Detekt.TooGenericExceptionCaught") - fun setConversationReadOnly(user: User, roomToken: String, state: Int) { - viewModelScope.launch { - try { - val apiVersion = ApiUtils.getConversationApiVersion( - user, - intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) - ) - val url = ApiUtils.getUrlForConversationReadOnly( - apiVersion, - user.baseUrl!!, - roomToken - ) - - conversationsRepository.setConversationReadOnly( - user = user, - url = url, - state = state - ) - _getConversationReadOnlyState.value = SetConversationReadOnlyViewState.Success - } catch (exception: Exception) { - _getConversationReadOnlyState.value = SetConversationReadOnlyViewState.Error(exception) - } - } - } - @Suppress("Detekt.TooGenericExceptionCaught") fun unbanActor(user: User, token: String, banId: Int) { val url = ApiUtils.getUrlForUnban(user.baseUrl!!, token, banId) @@ -341,60 +523,111 @@ class ConversationInfoViewModel @Inject constructor( try { val profile = conversationsRepository.getProfile(user.getCredentials(), url) if (profile != null) { - _getProfileViewState.value = GetProfileSuccessState(profile) - } else { - _getProfileViewState.value = GetProfileErrorState + processProfileData(profile) } } catch (e: Exception) { Log.w(TAG, "Failed to get profile data (if not supported there wil be http405)", e) } } } + private fun processProfileData(profile: Profile) { + val pronouns = profile.pronouns ?: "" + val concat1 = if (profile.role != null && profile.company != null) " @ " else "" + val professionCompany = "${profile.role ?: ""}$concat1${profile.company ?: ""}" + val secondsToAdd = profile.timezoneOffset?.toLong() ?: 0 + val localTime = ZonedDateTime.ofInstant(Instant.now().plusSeconds(secondsToAdd), ZoneOffset.ofTotalSeconds(0)) + val localTimeString = + localTime.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())) + val concat2 = if (profile.address != null) " : " else "" + val localTimeLocation = "$localTimeString$concat2${profile.address ?: ""}" + _uiState.update { + it.copy( + pronouns = pronouns, + professionCompany = professionCompany, + localTimeLocation = localTimeLocation, + profileDataAvailable = true + ) + } + } @Suppress("Detekt.TooGenericExceptionCaught") fun allowGuests(user: User, token: String, allow: Boolean) { + val previous = _uiState.value.guestsAllowed + _uiState.update { it.copy(guestsAllowed = allow) } viewModelScope.launch { try { val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) - - val url = ApiUtils.getUrlForRoomPublic( - apiVersion, - user.baseUrl!!, - token - ) - - conversationsRepository.allowGuests( - user = user, - url = url, - token = token, - allow = allow - ) - _allowGuestsViewState.value = AllowGuestsUIState.Success(allow) + val url = ApiUtils.getUrlForRoomPublic(apiVersion, user.baseUrl!!, token) + conversationsRepository.allowGuests(user = user, url = url, token = token, allow = allow) } catch (exception: Exception) { - _allowGuestsViewState.value = AllowGuestsUIState.Error(exception) + _uiState.update { it.copy(guestsAllowed = previous) } + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_guest_access_allow_failed)) + Log.e(TAG, "Error allowing guests", exception) } } } @Suppress("Detekt.TooGenericExceptionCaught") - @SuppressLint("SuspiciousIndentation") fun setPassword(user: User, url: String, password: String) { + val previousHasPassword = _uiState.value.hasPassword + val newHasPassword = password.isNotEmpty() + _uiState.update { it.copy(hasPassword = newHasPassword) } viewModelScope.launch { try { conversationsRepository.setPassword(user, url, password) - _passwordViewState.value = PasswordUiState.Success } catch (exception: Exception) { - _passwordViewState.value = PasswordUiState.Error(exception) + _uiState.update { it.copy(hasPassword = previousHasPassword) } + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_guest_access_password_failed)) + Log.e(TAG, "Error setting password", exception) } } } + fun resendInvitations(user: User, token: String) { + val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4)) + val url = ApiUtils.getUrlForParticipantsResendInvitations(apiVersion, user.baseUrl!!, token) + conversationsRepository.resendInvitations(user = user, url = url) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + LifeCycleObserver.disposableSet.add(d) + } + + override fun onNext(result: ResendInvitationsResult) { + if (result.successful) { + viewModelScope.launch { + _uiEvent.emit( + ConversationInfoUiEvent.ShowSnackbar( + R.string.nc_guest_access_resend_invitations_successful + ) + ) + } + } + } + + override fun onError(e: Throwable) { + Log.e(TAG, "Error resending invitations", e) + viewModelScope.launch { + _uiEvent.emit( + ConversationInfoUiEvent.ShowSnackbar( + R.string.nc_guest_access_resend_invitations_failed + ) + ) + } + } + + override fun onComplete() { + // unused atm + } + }) + } + suspend fun archiveConversation(user: User, token: String) { val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) val url = ApiUtils.getUrlForArchive(apiVersion, user.baseUrl, token) conversationsRepository.archiveConversation(user.getCredentials(), url) } - suspend fun unarchiveConversation(user: User, token: String) { val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) val url = ApiUtils.getUrlForArchive(apiVersion, user.baseUrl, token) @@ -402,72 +635,237 @@ class ConversationInfoViewModel @Inject constructor( } @Suppress("Detekt.TooGenericExceptionCaught") - fun markConversationAsImportant(credentials: String, baseUrl: String, roomToken: String) { + fun toggleArchive(user: User, token: String) { viewModelScope.launch { try { - val response = conversationsRepository.markConversationAsImportant(credentials, baseUrl, roomToken) - _markConversationAsImportantResult.value = - MarkConversationAsImportantViewState.Success(response.ocs?.meta?.statusCode!!) - } catch (exception: Exception) { - _markConversationAsImportantResult.value = - MarkConversationAsImportantViewState.Error(exception) + val isArchived = _uiState.value.isArchived + if (isArchived) { + unarchiveConversation(user, token) + } else { + archiveConversation(user, token) + } + getRoom(user, token) + } catch (e: Exception) { + Log.e(TAG, "Failed to toggle archive state", e) + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_common_error_sorry)) } } } + fun toggleLobby(user: User, token: String) { + val previousLobbyEnabled = _uiState.value.lobbyEnabled + val previousShowLobbyTimer = _uiState.value.showLobbyTimer + val previousLobbyTimerLabel = _uiState.value.lobbyTimerLabel + val newLobbyEnabled = !previousLobbyEnabled + val newLobbyState = if (newLobbyEnabled) 1 else 0 + val lobbyTimer = if (newLobbyEnabled) (_uiState.value.conversation?.lobbyTimer ?: 0L) else 0L + _uiState.update { + it.copy( + lobbyEnabled = newLobbyEnabled, + showLobbyTimer = newLobbyEnabled, + lobbyTimerLabel = if (!newLobbyEnabled) "" else it.lobbyTimerLabel + ) + } + val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + ncApi.setLobbyForConversation( + ApiUtils.getCredentials(user.username, user.token), + ApiUtils.getUrlForRoomWebinaryLobby(apiVersion, user.baseUrl!!, token), + newLobbyState, + lobbyTimer + )?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + LifeCycleObserver.disposableSet.add(d) + } + override fun onNext(t: GenericOverall) { /* unused */ } + override fun onError(e: Throwable) { + Log.e(TAG, "Failed to set lobby state", e) + _uiState.update { + it.copy( + lobbyEnabled = previousLobbyEnabled, + showLobbyTimer = previousShowLobbyTimer, + lobbyTimerLabel = previousLobbyTimerLabel + ) + } + viewModelScope.launch { + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_common_error_sorry)) + } + } + override fun onComplete() { /* unused */ } + }) + } + + fun setLobbyTimerAndSubmit(user: User, token: String, timestampSeconds: Long) { + val label = if (timestampSeconds != 0L) { + DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT) + .format(Date(timestampSeconds * DateConstants.SECOND_DIVIDER)) + } else { + "" + } + _uiState.update { it.copy(lobbyTimerLabel = label) } + val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + ncApi.setLobbyForConversation( + ApiUtils.getCredentials(user.username, user.token), + ApiUtils.getUrlForRoomWebinaryLobby(apiVersion, user.baseUrl!!, token), + 1, + timestampSeconds + )?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + LifeCycleObserver.disposableSet.add(d) + } + override fun onNext(t: GenericOverall) { /* unused */ } + override fun onError(e: Throwable) { + Log.e(TAG, "Failed to set lobby timer", e) + viewModelScope.launch { + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_common_error_sorry)) + } + } + override fun onComplete() { /* unused */ } + }) + } + + fun toggleRecordingConsent(user: User, token: String) { + val previousConsent = _uiState.value.recordingConsentForConversation + val newConsent = !previousConsent + _uiState.update { it.copy(recordingConsentForConversation = newConsent) } + val state = if (newConsent) RECORDING_CONSENT_REQUIRED_FOR_CONVERSATION else 0 + val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + ncApi.setRecordingConsent( + ApiUtils.getCredentials(user.username, user.token), + ApiUtils.getUrlForRecordingConsent(apiVersion, user.baseUrl!!, token), + state + )?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + LifeCycleObserver.disposableSet.add(d) + } + override fun onNext(t: GenericOverall) { /* unused */ } + override fun onError(e: Throwable) { + Log.e(TAG, "Error setting recording consent", e) + _uiState.update { it.copy(recordingConsentForConversation = previousConsent) } + viewModelScope.launch { + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_common_error_sorry)) + } + } + override fun onComplete() { /* unused */ } + }) + } + @Suppress("Detekt.TooGenericExceptionCaught") - fun markConversationAsUnimportant(credentials: String, baseUrl: String, roomToken: String) { + fun toggleLock(user: User, token: String) { + val previousLocked = _uiState.value.isConversationLocked + val newLocked = !previousLocked + _uiState.update { it.copy(isConversationLocked = newLocked) } viewModelScope.launch { + databaseStorageModule?.saveBoolean("lock_switch", newLocked) try { - val response = conversationsRepository.markConversationAsUnImportant(credentials, baseUrl, roomToken) - _markConversationAsUnimportantResult.value = - MarkConversationAsUnimportantViewState.Success(response.ocs?.meta?.statusCode!!) + val apiVersion = ApiUtils.getConversationApiVersion( + user, + intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) + ) + val url = ApiUtils.getUrlForConversationReadOnly(apiVersion, user.baseUrl!!, token) + conversationsRepository.setConversationReadOnly(user = user, url = url, state = if (newLocked) 1 else 0) } catch (exception: Exception) { - _markConversationAsUnimportantResult.value = - MarkConversationAsUnimportantViewState.Error(exception) + _uiState.update { it.copy(isConversationLocked = previousLocked) } + databaseStorageModule?.saveBoolean("lock_switch", previousLocked) + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.conversation_read_only_failed)) + } + } + } + + fun toggleCallNotifications() { + val newEnabled = !_uiState.value.callNotificationsEnabled + _uiState.update { it.copy(callNotificationsEnabled = newEnabled) } + viewModelScope.launch { + databaseStorageModule?.saveBoolean("call_notifications_switch", newEnabled) + } + } + + fun saveNotificationLevel(position: Int) { + val res = NextcloudTalkApplication.sharedApplication!!.resources + val values = res.getStringArray(R.array.message_notification_levels_entry_values) + val descriptions = res.getStringArray(R.array.message_notification_levels) + if (position in values.indices && position in descriptions.indices) { + _uiState.update { it.copy(notificationLevel = descriptions[position]) } + viewModelScope.launch { + databaseStorageModule?.saveString("conversation_info_message_notifications_dropdown", values[position]) + } + } + } + + fun saveMessageExpiration(position: Int) { + val res = NextcloudTalkApplication.sharedApplication!!.resources + val values = res.getStringArray(R.array.message_expiring_values) + val descriptions = res.getStringArray(R.array.message_expiring_descriptions) + if (position in values.indices && position in descriptions.indices) { + _uiState.update { it.copy(messageExpirationLabel = descriptions[position]) } + viewModelScope.launch { + databaseStorageModule?.saveString("conversation_settings_dropdown", values[position]) } } } + fun setUpcomingEvent(summary: String?, time: String?) { + _uiState.update { it.copy(upcomingEventSummary = summary, upcomingEventTime = time) } + } + + suspend fun emitSnackbar(@androidx.annotation.StringRes resId: Int) { + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(resId)) + } + @Suppress("Detekt.TooGenericExceptionCaught") - fun clearChatHistory(user: User, url: String) { + fun toggleImportantConversation(credentials: String, baseUrl: String, roomToken: String) { + val previousValue = _uiState.value.importantConversation + val newValue = !previousValue + _uiState.update { it.copy(importantConversation = newValue) } viewModelScope.launch { try { - conversationsRepository.clearChatHistory( - user, - url - ) - _clearChatHistoryViewState.value = ClearChatHistoryViewState.Success + if (newValue) { + conversationsRepository.markConversationAsImportant(credentials, baseUrl, roomToken) + } else { + conversationsRepository.markConversationAsUnImportant(credentials, baseUrl, roomToken) + } } catch (exception: Exception) { - _clearChatHistoryViewState.value = ClearChatHistoryViewState.Error(exception) + _uiState.update { it.copy(importantConversation = previousValue) } + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_common_error_sorry)) + Log.e(TAG, "failed to toggle important conversation state", exception) } } } @Suppress("Detekt.TooGenericExceptionCaught") - fun markConversationAsSensitive(credentials: String, baseUrl: String, roomToken: String) { + fun toggleSensitiveConversation(credentials: String, baseUrl: String, roomToken: String) { + val previousValue = _uiState.value.sensitiveConversation + val newValue = !previousValue + _uiState.update { it.copy(sensitiveConversation = newValue) } viewModelScope.launch { try { - val response = conversationsRepository.markConversationAsSensitive(credentials, baseUrl, roomToken) - _markConversationAsSensitiveResult.value = - MarkConversationAsSensitiveViewState.Success(response.ocs?.meta?.statusCode!!) + if (newValue) { + conversationsRepository.markConversationAsSensitive(credentials, baseUrl, roomToken) + } else { + conversationsRepository.markConversationAsInsensitive(credentials, baseUrl, roomToken) + } } catch (exception: Exception) { - _markConversationAsSensitiveResult.value = - MarkConversationAsSensitiveViewState.Error(exception) + _uiState.update { it.copy(sensitiveConversation = previousValue) } + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_common_error_sorry)) + Log.e(TAG, "failed to toggle sensitive conversation state", exception) } } } @Suppress("Detekt.TooGenericExceptionCaught") - fun markConversationAsInsensitive(credentials: String, baseUrl: String, roomToken: String) { + fun clearChatHistory(user: User, url: String) { viewModelScope.launch { try { - val response = conversationsRepository.markConversationAsInsensitive(credentials, baseUrl, roomToken) - _markConversationAsInsensitiveResult.value = - MarkConversationAsInsensitiveViewState.Success(response.ocs?.meta?.statusCode!!) + conversationsRepository.clearChatHistory(user, url) + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_clear_history_success)) } catch (exception: Exception) { - _markConversationAsInsensitiveResult.value = - MarkConversationAsInsensitiveViewState.Error(exception) + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_common_error_sorry)) + Log.e(TAG, "failed to clear chat history", exception) } } } @@ -476,28 +874,31 @@ class ConversationInfoViewModel @Inject constructor( override fun onSubscribe(d: Disposable) { // unused atm } - override fun onNext(conversationModel: ConversationModel) { - _viewState.value = GetRoomSuccessState(conversationModel) + _uiState.update { it.copy(conversation = conversationModel) } + currentUser?.let { getCapabilities(it, currentToken, conversationModel) } } - override fun onError(e: Throwable) { Log.e(TAG, "Error when fetching room") - _viewState.value = GetRoomErrorState + _uiState.update { it.copy(isLoading = false) } + viewModelScope.launch { + _uiEvent.emit(ConversationInfoUiEvent.ShowSnackbar(R.string.nc_common_error_sorry)) + } } - override fun onComplete() { // unused atm } } - companion object { private val TAG = ConversationInfoViewModel::class.simpleName private const val NEW_CONVERSATION_PARTICIPANTS_SEPARATOR = ", " private const val EXTENDED_CONVERSATION = "extended_conversation" private const val GROUP_CONVERSATION_TYPE = "2" private const val MAX_ROOM_NAME_LENGTH = 255 - + private const val NOTIFICATION_LEVEL_ALWAYS: Int = 1 + private const val NOTIFICATION_LEVEL_MENTION: Int = 2 + private const val NOTIFICATION_LEVEL_NEVER: Int = 3 + private const val RECORDING_CONSENT_REQUIRED_FOR_CONVERSATION: Int = 1 fun createConversationNameByParticipants( originalParticipants: List, allParticipants: List @@ -505,67 +906,11 @@ class ConversationInfoViewModel @Inject constructor( fun List.sortedJoined() = sortedBy { it?.lowercase() } .joinToString(NEW_CONVERSATION_PARTICIPANTS_SEPARATOR) - val addedParticipants = allParticipants - originalParticipants.toSet() val conversationName = originalParticipants.mapNotNull { it }.sortedJoined() + NEW_CONVERSATION_PARTICIPANTS_SEPARATOR + addedParticipants.mapNotNull { it }.sortedJoined() - return DisplayUtils.ellipsize(conversationName, MAX_ROOM_NAME_LENGTH) } } - - sealed class ClearChatHistoryViewState { - data object None : ClearChatHistoryViewState() - data object Success : ClearChatHistoryViewState() - data class Error(val exception: Exception) : ClearChatHistoryViewState() - } - - sealed class MarkConversationAsSensitiveViewState { - data object None : MarkConversationAsSensitiveViewState() - data class Success(val statusCode: Int) : MarkConversationAsSensitiveViewState() - data class Error(val exception: Exception) : MarkConversationAsSensitiveViewState() - } - - sealed class MarkConversationAsInsensitiveViewState { - data object None : MarkConversationAsInsensitiveViewState() - data class Success(val statusCode: Int) : MarkConversationAsInsensitiveViewState() - data class Error(val exception: Exception) : MarkConversationAsInsensitiveViewState() - } - - sealed class SetConversationReadOnlyViewState { - data object None : SetConversationReadOnlyViewState() - data object Success : SetConversationReadOnlyViewState() - data class Error(val exception: Exception) : SetConversationReadOnlyViewState() - } - - sealed class AllowGuestsUIState { - data object None : AllowGuestsUIState() - data class Success(val allow: Boolean) : AllowGuestsUIState() - data class Error(val exception: Exception) : AllowGuestsUIState() - } - - sealed class CreateRoomUIState { - data object None : CreateRoomUIState() - data class Success(val room: RoomOverall) : CreateRoomUIState() - data class Error(val exception: Exception) : CreateRoomUIState() - } - - sealed class PasswordUiState { - data object None : PasswordUiState() - data object Success : PasswordUiState() - data class Error(val exception: Exception) : PasswordUiState() - } - - sealed class MarkConversationAsImportantViewState { - data object None : MarkConversationAsImportantViewState() - data class Success(val statusCode: Int) : MarkConversationAsImportantViewState() - data class Error(val exception: Exception) : MarkConversationAsImportantViewState() - } - - sealed class MarkConversationAsUnimportantViewState { - data object None : MarkConversationAsUnimportantViewState() - data class Success(val statusCode: Int) : MarkConversationAsUnimportantViewState() - data class Error(val exception: Exception) : MarkConversationAsUnimportantViewState() - } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ui/ConversationInfoEditScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ui/ConversationInfoEditScreen.kt index b8153afdc0..0a0d5c8e6c 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ui/ConversationInfoEditScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ui/ConversationInfoEditScreen.kt @@ -5,6 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +@file:Suppress("TooManyFunctions") + package com.nextcloud.talk.conversationinfoedit.ui import android.content.res.Configuration @@ -59,6 +61,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -114,7 +117,8 @@ private fun ConversationInfoEditTopBar(uiState: ConversationInfoEditUiState, cal title = { Text( text = stringResource(R.string.nc_conversation_menu_conversation_info), - maxLines = 1 + maxLines = 1, + overflow = TextOverflow.Ellipsis ) }, colors = TopAppBarDefaults.topAppBarColors( diff --git a/app/src/main/java/com/nextcloud/talk/ui/BackgroundVoiceMessageCard.kt b/app/src/main/java/com/nextcloud/talk/ui/BackgroundVoiceMessageCard.kt index 270da440c4..604aecfac6 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/BackgroundVoiceMessageCard.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/BackgroundVoiceMessageCard.kt @@ -183,7 +183,7 @@ fun BackgroundVoiceMessageCardContent( IconButton(onClick = onClosed) { Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_baseline_close_24), - contentDescription = "contentDescription", + contentDescription = stringResource(R.string.close), modifier = Modifier .size(24.dp) .padding(2.dp), diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt index a5b813b88b..02f6f52c12 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt @@ -721,10 +721,16 @@ fun ReadStatus(message: ChatMessageUi, color: Color = colorScheme.onSurfaceVaria MessageStatusIcon.READ -> painterResource(R.drawable.ic_check_all) MessageStatusIcon.SENT -> painterResource(R.drawable.ic_check) } + val contentDescription = when (message.statusIcon) { + MessageStatusIcon.FAILED -> stringResource(R.string.nc_message_failed) + MessageStatusIcon.SENDING -> stringResource(R.string.nc_message_sending) + MessageStatusIcon.READ -> stringResource(R.string.nc_message_read) + MessageStatusIcon.SENT -> stringResource(R.string.nc_message_sent) + } Icon( painter = icon, - contentDescription = "", + contentDescription = contentDescription, modifier = Modifier .padding(start = 4.dp) .size(16.dp), diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt index 59c7741900..2b9cae3abe 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -392,7 +392,7 @@ fun ChatView( ) { Icon( imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = "Scroll to newest", + contentDescription = stringResource(R.string.scroll_to_bottom), modifier = Modifier .size(44.dp) .padding(8.dp), diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt index 15db092e0d..49f8d4c91f 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt @@ -51,7 +51,7 @@ fun DeckMessage( Icon( painter = painterResource(R.drawable.deck), tint = colorScheme.onSurface, - contentDescription = "" + contentDescription = null ) Spacer(modifier = Modifier.padding(start = 8.dp)) Text( diff --git a/app/src/main/res/layout/activity_conversation_info.xml b/app/src/main/res/layout/activity_conversation_info.xml deleted file mode 100644 index 06f2e32676..0000000000 --- a/app/src/main/res/layout/activity_conversation_info.xml +++ /dev/null @@ -1,674 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_guest_access_settings.xml b/app/src/main/res/layout/item_guest_access_settings.xml deleted file mode 100644 index 41d81aa6f5..0000000000 --- a/app/src/main/res/layout/item_guest_access_settings.xml +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_notification_settings.xml b/app/src/main/res/layout/item_notification_settings.xml deleted file mode 100644 index b7bc918bed..0000000000 --- a/app/src/main/res/layout/item_notification_settings.xml +++ /dev/null @@ -1,158 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_recording_consent.xml b/app/src/main/res/layout/item_recording_consent.xml deleted file mode 100644 index c4334db275..0000000000 --- a/app/src/main/res/layout/item_recording_consent.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_webinar_info.xml b/app/src/main/res/layout/item_webinar_info.xml deleted file mode 100644 index 57af8f29d3..0000000000 --- a/app/src/main/res/layout/item_webinar_info.xml +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_conversation_info.xml b/app/src/main/res/menu/menu_conversation_info.xml deleted file mode 100644 index 41676fb77c..0000000000 --- a/app/src/main/res/menu/menu_conversation_info.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 69413192ef..ca9a240d29 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -68,7 +68,6 @@ 150dp 40dp - 30dp 16dp 24dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9ac8fb75d..227253be91 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -259,10 +259,6 @@ How to translate with transifex: Delete all messages Do you really want to delete all messages in this conversation? All messages were deleted - Conversation marked as sensitive - Conversation unmarked as sensitive - Conversation marked as important - Conversation unmarked as important Rename conversation Rename Delete conversation @@ -465,6 +461,8 @@ How to translate with transifex: You: %1$s Draft: %1$s + Message read + Message sending Message sent Message added to notes Failed diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys index 6f524fbd78..9567f70ab7 100644 --- a/gradle/verification-keyring.keys +++ b/gradle/verification-keyring.keys @@ -6479,6 +6479,170 @@ Glydu4Dtzwfkmu43CTGp =8KZg -----END PGP PUBLIC KEY BLOCK----- +pub 652C6F2DC3E06408 +uid Zenichi Amano + +sub DCFEB40A18A974C6 +sub 9D8A424C2F8BF85D +sub 608D8FEA3AC55BF6 +sub AE2BA561C92D1045 +sub 5D371DEB77341EC8 +sub ACE027F0072F8029 +sub 90051F70056B0253 +sub 9DF53A22702A71CB +sub 11FBB7552A08E49F +sub D694BBF977C51E52 +sub A020597F309CEB64 +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +Tky9VthoZQdJTLH/QYXHZRI0HRXfo3AVtf8= +=PL93 +-----END PGP PUBLIC KEY BLOCK----- + pub 6601E5C08DCCBB96 uid Popma Remko diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a3a2a8588b..db26dad848 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -18,7 +18,6 @@ - @@ -20352,6 +20351,11 @@ + + + + + @@ -20459,6 +20463,11 @@ + + + + + @@ -24065,6 +24074,11 @@ + + + + + @@ -25395,6 +25409,11 @@ + + + + + @@ -29189,6 +29208,11 @@ + + + + +