diff --git a/app/src/main/java/com/nextcloud/talk/adapters/ReactionItemClickListener.kt b/app/src/main/java/com/nextcloud/talk/adapters/ReactionItemClickListener.kt deleted file mode 100644 index 02357b112cc..00000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/ReactionItemClickListener.kt +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Andy Scherzinger - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.adapters - -interface ReactionItemClickListener { - fun onClick(reactionItem: ReactionItem) -} diff --git a/app/src/main/java/com/nextcloud/talk/adapters/ReactionsAdapter.kt b/app/src/main/java/com/nextcloud/talk/adapters/ReactionsAdapter.kt deleted file mode 100644 index 5f35a97160d..00000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/ReactionsAdapter.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Andy Scherzinger - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.ReactionItemBinding - -class ReactionsAdapter(private val clickListener: ReactionItemClickListener, private val user: User?) : - RecyclerView.Adapter() { - internal var list: MutableList = ArrayList() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReactionsViewHolder { - val itemBinding = ReactionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ReactionsViewHolder(itemBinding, user) - } - - override fun onBindViewHolder(holder: ReactionsViewHolder, position: Int) { - holder.bind(list[position], clickListener) - } - - override fun getItemCount(): Int = list.size -} diff --git a/app/src/main/java/com/nextcloud/talk/adapters/ReactionsViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/ReactionsViewHolder.kt deleted file mode 100644 index 43b12e6e7fd..00000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/ReactionsViewHolder.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Andy Scherzinger - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.adapters - -import android.text.TextUtils -import androidx.recyclerview.widget.RecyclerView -import com.nextcloud.talk.R -import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.ReactionItemBinding -import com.nextcloud.talk.extensions.loadGuestAvatar -import com.nextcloud.talk.extensions.loadUserAvatar -import com.nextcloud.talk.models.json.reactions.ReactionVoter - -class ReactionsViewHolder(private val binding: ReactionItemBinding, private val user: User?) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(reactionItem: ReactionItem, clickListener: ReactionItemClickListener) { - binding.root.setOnClickListener { clickListener.onClick(reactionItem) } - binding.reaction.text = reactionItem.reaction - binding.name.text = reactionItem.reactionVoter.actorDisplayName - - if (user != null && user.baseUrl?.isNotEmpty() == true) { - loadAvatar(reactionItem) - } - } - - private fun loadAvatar(reactionItem: ReactionItem) { - if (reactionItem.reactionVoter.actorType == ReactionVoter.ReactionActorType.GUESTS) { - var displayName = sharedApplication?.resources?.getString(R.string.nc_guest) - if (!TextUtils.isEmpty(reactionItem.reactionVoter.actorDisplayName)) { - displayName = reactionItem.reactionVoter.actorDisplayName!! - } - binding.avatar.loadGuestAvatar(user!!.baseUrl!!, displayName!!, false) - } else if (reactionItem.reactionVoter.actorType == ReactionVoter.ReactionActorType.USERS) { - binding.avatar.loadUserAvatar( - user!!, - reactionItem.reactionVoter.actorId!!, - false, - false - ) - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt index 9014fd085d1..a22947e5249 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -22,6 +22,7 @@ import com.nextcloud.talk.models.json.participants.TalkBanOverall import com.nextcloud.talk.models.json.profile.ProfileOverall import com.nextcloud.talk.models.json.reactions.ReactionsOverall import com.nextcloud.talk.models.json.status.StatusOverall +import com.nextcloud.talk.models.json.status.predefined.PredefinedStatusOverall import com.nextcloud.talk.models.json.testNotification.TestNotificationOverall import com.nextcloud.talk.models.json.threads.ThreadOverall import com.nextcloud.talk.models.json.threads.ThreadsOverall @@ -333,6 +334,48 @@ interface NcApiCoroutines { @GET suspend fun status(@Header("Authorization") authorization: String, @Url url: String): StatusOverall + @FormUrlEncoded + @PUT + suspend fun setStatusType( + @Header("Authorization") authorization: String, + @Url url: String, + @Field("statusType") statusType: String + ): GenericOverall + + @GET + suspend fun getPredefinedStatuses( + @Header("Authorization") authorization: String, + @Url url: String + ): PredefinedStatusOverall + + @GET + suspend fun backupStatus(@Header("Authorization") authorization: String, @Url url: String): StatusOverall + + @DELETE + suspend fun statusDeleteMessage(@Header("Authorization") authorization: String, @Url url: String): GenericOverall + + @FormUrlEncoded + @PUT + suspend fun setPredefinedStatusMessage( + @Header("Authorization") authorization: String, + @Url url: String, + @Field("messageId") messageId: String, + @Field("clearAt") clearAt: Long? + ): GenericOverall + + @FormUrlEncoded + @PUT + suspend fun setCustomStatusMessage( + @Header("Authorization") authorization: String, + @Url url: String, + @Field("statusIcon") statusIcon: String?, + @Field("message") message: String, + @Field("clearAt") clearAt: Long? + ): GenericOverall + + @DELETE + suspend fun revertStatus(@Header("Authorization") authorization: String, @Url url: String): GenericOverall + @FormUrlEncoded @POST suspend fun pinMessage( @@ -404,4 +447,17 @@ interface NcApiCoroutines { @Url url: String, @Query("reaction") reaction: String? ): ReactionsOverall + + // Url is: /api/{apiVersion}/chat/{token}/read + @FormUrlEncoded + @POST + suspend fun setChatReadMarker( + @Header("Authorization") authorization: String, + @Url url: String, + @Field("lastReadMessage") lastReadMessage: Int? + ): GenericOverall + + // Url is: /api/{apiVersion}/chat/{token}/read + @DELETE + suspend fun markRoomAsUnread(@Header("Authorization") authorization: String, @Url url: String): GenericOverall } diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index c4f24784dbe..cd169819b61 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -68,6 +68,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.coordinatorlayout.widget.CoordinatorLayout @@ -159,7 +160,7 @@ import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment import com.nextcloud.talk.ui.dialog.GetPinnedOptionsDialog import com.nextcloud.talk.ui.dialog.MessageActionsDialog import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment -import com.nextcloud.talk.ui.dialog.ShowReactionsDialog +import com.nextcloud.talk.chat.ui.ShowReactionsModalBottomSheet import com.nextcloud.talk.ui.dialog.TempMessageActionsDialog import com.nextcloud.talk.ui.theme.LocalMessageUtils import com.nextcloud.talk.ui.theme.LocalOpenGraphFetcher @@ -630,6 +631,24 @@ class ChatActivity : listState = listState ) } + + val reactionsSheetMessageId by chatViewModel.reactionsSheetMessageId.collectAsStateWithLifecycle() + val reactionsSheetMessage by produceState(null, reactionsSheetMessageId) { + value = reactionsSheetMessageId?.let { id -> chatViewModel.getMessageById(id).first() } + } + reactionsSheetMessage?.let { msg -> + conversationUser?.let { user -> + ShowReactionsModalBottomSheet( + chatMessage = msg, + user = user, + roomToken = roomToken, + hasReactPermission = participantPermissions.hasReactPermission(), + ncApiCoroutines = ncApiCoroutines, + onDeleteReaction = { emoji -> chatViewModel.deleteReaction(roomToken, msg, emoji) }, + onDismiss = { chatViewModel.dismissReactionsSheet() } + ) + } + } } } } @@ -724,10 +743,7 @@ class ChatActivity : } private fun openReactionsDialog(messageId: Int) { - lifecycleScope.launch { - val chatMessage = chatViewModel.getMessageById(messageId.toLong()).first() - onLongClickReactions(chatMessage) - } + chatViewModel.showReactionsSheet(messageId.toLong()) } private fun getMessageInputFragment(): MessageInputFragment { @@ -3237,17 +3253,6 @@ class ChatActivity : openThread(chatMessage.jsonMessageId.toLong()) } - fun onLongClickReactions(chatMessage: ChatMessage) { - ShowReactionsDialog( - this, - roomToken, - chatMessage, - conversationUser, - participantPermissions.hasReactPermission(), - ncApiCoroutines - ).show() - } - fun onMessageClick(message: ChatMessage) { val now = SystemClock.elapsedRealtime() if (now - lastMessageClickTime < ViewConfiguration.getDoubleTapTimeout() && diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/ShowReactionsSheet.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/ShowReactionsSheet.kt new file mode 100644 index 00000000000..bdc138b7463 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/ShowReactionsSheet.kt @@ -0,0 +1,389 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.ui + +import android.content.res.Configuration +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation +import com.nextcloud.talk.R +import com.nextcloud.talk.adapters.ReactionItem +import com.nextcloud.talk.api.NcApiCoroutines +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.json.reactions.ReactionVoter +import com.nextcloud.talk.utils.ApiUtils +import java.util.Collections + +private const val TAG = "ShowReactionsSheet" + +@Suppress("LongParameterList") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShowReactionsModalBottomSheet( + chatMessage: ChatMessage, + user: User, + roomToken: String, + hasReactPermission: Boolean, + ncApiCoroutines: NcApiCoroutines, + onDeleteReaction: (String) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + ReactionsSheetContent( + chatMessage = chatMessage, + user = user, + roomToken = roomToken, + hasReactPermission = hasReactPermission, + ncApiCoroutines = ncApiCoroutines, + onDeleteReaction = onDeleteReaction, + onDismiss = onDismiss + ) + } +} + +@Suppress("LongMethod", "LongParameterList", "TooGenericExceptionCaught") +@Composable +internal fun ReactionsSheetContent( + chatMessage: ChatMessage, + user: User, + roomToken: String, + hasReactPermission: Boolean, + ncApiCoroutines: NcApiCoroutines, + onDeleteReaction: (String) -> Unit, + onDismiss: () -> Unit +) { + val allReactions = chatMessage.reactions ?: return + val reactions = LinkedHashMap(allReactions.filter { it.value > 0 }) + if (reactions.isEmpty()) return + + val emojiList = reactions.keys.toList() + val tabs: List = listOf(null) + emojiList + var selectedTabIndex by remember { mutableIntStateOf(0) } + var reactionItems by remember { mutableStateOf>(emptyList()) } + val selectedEmoji = tabs[selectedTabIndex] + + val credentials = remember(user) { ApiUtils.getCredentials(user.username, user.token) } + val reactionApiUrl = remember(user, roomToken, chatMessage) { + ApiUtils.getUrlForMessageReaction( + baseUrl = user.baseUrl!!, + roomToken = roomToken, + messageId = chatMessage.jsonMessageId.toString() + ) + } + + LaunchedEffect(selectedEmoji) { + try { + val reactionsOverall = ncApiCoroutines.getReactions(credentials, reactionApiUrl, selectedEmoji) + val voters = buildList { + val map = reactionsOverall.ocs?.data ?: return@buildList + for (key in map.keys) { + for (voter in map[key]!!) { + add(ReactionItem(voter, key)) + } + } + } + reactionItems = ArrayList(voters).also { Collections.sort(it, ReactionComparator(user.userId)) } + } catch (e: Exception) { + Log.e(TAG, "Failed to load reactions", e) + } + } + + ReactionsSheetLayout( + reactions = reactions, + reactionItems = reactionItems, + selectedTabIndex = selectedTabIndex, + onTabSelected = { selectedTabIndex = it }, + user = user, + credentials = credentials, + hasReactPermission = hasReactPermission, + onDeleteReaction = onDeleteReaction, + onDismiss = onDismiss + ) +} + +@Suppress("LongParameterList") +@Composable +internal fun ReactionsSheetLayout( + reactions: LinkedHashMap, + reactionItems: List, + selectedTabIndex: Int, + onTabSelected: (Int) -> Unit, + user: User, + credentials: String?, + hasReactPermission: Boolean, + onDeleteReaction: (String) -> Unit, + onDismiss: () -> Unit +) { + val tabs: List = listOf(null) + reactions.keys.toList() + + Column( + modifier = Modifier.Companion + .fillMaxWidth() + .padding(bottom = 24.dp) + ) { + val reactionsTotal = reactions.values.sum() + PrimaryScrollableTabRow( + selectedTabIndex = selectedTabIndex, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + edgePadding = 0.dp + ) { + tabs.forEachIndexed { index, emoji -> + val count = if (emoji == null) reactionsTotal else reactions[emoji] ?: 0 + val label = if (emoji == null) { + "${stringResource(R.string.reactions_tab_all)} $count" + } else { + "$emoji $count" + } + Tab( + selected = selectedTabIndex == index, + onClick = { onTabSelected(index) }, + text = { Text(label) } + ) + } + } + LazyColumn( + modifier = Modifier.Companion + .fillMaxWidth() + .heightIn(max = 288.dp) + ) { + items(reactionItems) { reactionItem -> + ReactionVoterRow( + reactionItem = reactionItem, + user = user, + credentials = credentials, + hasReactPermission = hasReactPermission, + onDeleteReaction = { emoji -> + onDeleteReaction(emoji) + onDismiss() + } + ) + } + } + } +} + +@Composable +private fun ReactionVoterRow( + reactionItem: ReactionItem, + user: User, + credentials: String?, + hasReactPermission: Boolean, + onDeleteReaction: (String) -> Unit +) { + val context = LocalContext.current + val isDark = isSystemInDarkTheme() + val canDelete = hasReactPermission && reactionItem.reactionVoter.actorId == user.userId + val guestLabel = stringResource(R.string.nc_guest) + + val avatarUrl = remember(reactionItem, isDark) { + when (reactionItem.reactionVoter.actorType) { + ReactionVoter.ReactionActorType.GUESTS -> { + val displayName = reactionItem.reactionVoter.actorDisplayName + ?.takeIf { it.isNotEmpty() } + ?: guestLabel + ApiUtils.getUrlForGuestAvatar(user.baseUrl, displayName, false) + } + + ReactionVoter.ReactionActorType.USERS -> { + ApiUtils.getUrlForAvatar(user.baseUrl, reactionItem.reactionVoter.actorId, false, isDark) + } + + else -> null + } + } + + val avatarRequest = remember(avatarUrl, credentials) { + avatarUrl?.let { + ImageRequest.Builder(context) + .data(it) + .transformations(CircleCropTransformation()) + .addHeader("Authorization", credentials ?: "") + .build() + } + } + + Row( + modifier = Modifier.Companion + .fillMaxWidth() + .clickable(enabled = canDelete && reactionItem.reaction != null) { + reactionItem.reaction?.let(onDeleteReaction) + } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.Companion.CenterVertically + ) { + AsyncImage( + model = avatarRequest ?: R.drawable.account_circle_96dp, + contentDescription = null, + placeholder = painterResource(R.drawable.account_circle_96dp), + error = painterResource(R.drawable.account_circle_96dp), + modifier = Modifier.Companion.size(40.dp) + ) + Spacer(modifier = Modifier.Companion.width(16.dp)) + Text( + text = reactionItem.reactionVoter.actorDisplayName ?: "", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.Companion.weight(1f) + ) + Text( + text = reactionItem.reaction ?: "", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.Companion.padding(start = 8.dp) + ) + } +} + +private class ReactionComparator(private val activeUser: String?) : Comparator { + @Suppress("ReturnCount") + override fun compare(item1: ReactionItem?, item2: ReactionItem?): Int { + if (item1 == null && item2 == null) return 0 + if (item1 == null) return -1 + if (item2 == null) return 1 + + val reaction = compareNullableStrings(item1.reaction, item2.reaction) + if (reaction != 0) return reaction + + val ownAccount = compareOwnAccount(item1.reactionVoter.actorId, item2.reactionVoter.actorId) + if (ownAccount != 0) return ownAccount + + val displayName = compareNullableStrings( + item1.reactionVoter.actorDisplayName, + item2.reactionVoter.actorDisplayName + ) + if (displayName != 0) return displayName + + val timestamp = compareNullableLongs(item1.reactionVoter.timestamp, item2.reactionVoter.timestamp) + if (timestamp != 0) return timestamp + + return compareNullableStrings(item1.reactionVoter.actorId, item2.reactionVoter.actorId) + } + + @Suppress("ReturnCount") + private fun compareOwnAccount(actorId1: String?, actorId2: String?): Int { + val vote1Active = activeUser == actorId1 + val vote2Active = activeUser == actorId2 + if (vote1Active == vote2Active) return 0 + if (activeUser == null) return 0 + return if (vote1Active) 1 else -1 + } + + @Suppress("ReturnCount") + private fun compareNullableStrings(s1: String?, s2: String?): Int { + if (s1 == null && s2 == null) return 0 + if (s1 == null) return -1 + if (s2 == null) return 1 + return s1.lowercase().compareTo(s2.lowercase()) + } + + @Suppress("ReturnCount") + private fun compareNullableLongs(l1: Long?, l2: Long?): Int { + if (l1 == null && l2 == null) return 0 + if (l1 == null) return -1 + if (l2 == null) return 1 + return l1.compareTo(l2) + } +} + +private val previewUser = User( + id = 1, + userId = "alice", + username = "alice", + baseUrl = "https://nextcloud.example.com", + displayName = "Alice" +) + +@Suppress("MagicNumber") +private val previewReactions = linkedMapOf("๐Ÿ‘" to 3, "โค๏ธ" to 2, "๐Ÿ˜‚" to 1) + +private val previewReactionItems = listOf( + ReactionItem( + ReactionVoter(ReactionVoter.ReactionActorType.USERS, "alice", "Alice", 0), + "๐Ÿ‘" + ), + ReactionItem( + ReactionVoter(ReactionVoter.ReactionActorType.USERS, "bob", "Bob", 0), + "๐Ÿ‘" + ), + ReactionItem( + ReactionVoter(ReactionVoter.ReactionActorType.USERS, "carol", "Carol", 0), + "๐Ÿ‘" + ), + ReactionItem( + ReactionVoter(ReactionVoter.ReactionActorType.USERS, "dave", "Dave", 0), + "โค๏ธ" + ), + ReactionItem( + ReactionVoter(ReactionVoter.ReactionActorType.GUESTS, "guest1", "ู…ุฑูˆุฉ", 0), + "๐Ÿ˜‚" + ) +) + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL ยท Arabic", locale = "ar") +@Composable +private fun PreviewReactionsSheet() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + ReactionsSheetLayout( + reactions = previewReactions, + reactionItems = previewReactionItems, + selectedTabIndex = 0, + onTabSelected = {}, + user = previewUser, + credentials = null, + hasReactPermission = true, + onDeleteReaction = {}, + onDismiss = {} + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index a54dd4f7629..42775b17b62 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -231,6 +231,17 @@ class ChatViewModel @AssistedInject constructor( private val _threadRetrieveState = MutableStateFlow(ThreadRetrieveUiState.None) val threadRetrieveState: StateFlow = _threadRetrieveState + private val _reactionsSheetMessageId = MutableStateFlow(null) + val reactionsSheetMessageId: StateFlow = _reactionsSheetMessageId + + fun showReactionsSheet(messageId: Long) { + _reactionsSheetMessageId.value = messageId + } + + fun dismissReactionsSheet() { + _reactionsSheetMessageId.value = null + } + val getLastCommonReadFlow = chatRepository.lastCommonReadFlow sealed interface ViewState diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/ChooseAccountDialogCompose.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt similarity index 85% rename from app/src/main/java/com/nextcloud/talk/ui/dialog/ChooseAccountDialogCompose.kt rename to app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt index 9d8b1872e1b..91e5f5e6185 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/ChooseAccountDialogCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt @@ -1,11 +1,11 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2025 Sowjanya Kota + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ -package com.nextcloud.talk.ui.dialog +package com.nextcloud.talk.chooseaccount import android.app.Activity import android.content.Context @@ -42,11 +42,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -64,7 +64,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.core.net.toUri -import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import coil.compose.AsyncImage import com.nextcloud.android.common.core.utils.ecosystem.EcosystemApp @@ -74,14 +74,18 @@ import com.nextcloud.talk.R import com.nextcloud.talk.account.ServerSelectionActivity import com.nextcloud.talk.account.data.model.AccountItem import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.chooseaccount.StatusUiState -import com.nextcloud.talk.chooseaccount.StatusViewModel +import com.nextcloud.talk.chooseaccount.ui.OnlineStatusModalBottomSheet +import com.nextcloud.talk.chooseaccount.ui.StatusMessageModalBottomSheet +import com.nextcloud.talk.chooseaccount.viewmodel.StatusMessageViewModel +import com.nextcloud.talk.chooseaccount.viewmodel.StatusUiState +import com.nextcloud.talk.chooseaccount.viewmodel.StatusViewModel import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel import com.nextcloud.talk.models.json.status.Status +import com.nextcloud.talk.models.json.status.StatusType import com.nextcloud.talk.settings.SettingsActivity import com.nextcloud.talk.ui.StatusDrawable import com.nextcloud.talk.ui.theme.ViewThemeUtils @@ -118,6 +122,9 @@ class ChooseAccountDialogCompose { @Inject lateinit var statusViewModel: StatusViewModel + @Inject + lateinit var statusMessageViewModel: StatusMessageViewModel + @Inject lateinit var networkMonitor: NetworkMonitor @@ -131,10 +138,12 @@ class ChooseAccountDialogCompose { if (shouldDismiss.value) return val colorScheme = viewThemeUtils.getColorScheme(activity) val status = remember { mutableStateOf(null) } + val showOnlineStatusSheet = rememberSaveable { mutableStateOf(false) } + val showStatusMessageSheet = rememberSaveable { mutableStateOf(false) } val context = LocalContext.current - val statusViewState by statusViewModel.statusViewState.collectAsState() - val invitationsState by invitationsViewModel.getInvitationsViewState.collectAsState() - val isOnline by networkMonitor.isOnline.collectAsState() + val statusViewState by statusViewModel.statusViewState.collectAsStateWithLifecycle() + val invitationsState by invitationsViewModel.getInvitationsViewState.collectAsStateWithLifecycle() + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() val currentUser = currentUserProvider.currentUser.blockingGet()!! val isStatusAvailable = CapabilitiesUtil.isUserStatusAvailable(currentUser) ecosystemManager = EcosystemManager(activity) @@ -166,12 +175,11 @@ class ChooseAccountDialogCompose { accountItems = userItems, onCurrentUserClick = { shouldDismiss.value = true }, onSetOnlineStatusClick = { - shouldDismiss.value = true - openSetOnlineStatusFragment(status.value, activity) + showOnlineStatusSheet.value = true }, onSetStatusMessageClick = { - shouldDismiss.value = true - openSetStatusMessageFragment(status.value, activity) + statusMessageViewModel.resetDismissed() + showStatusMessageSheet.value = true }, onAddAccountClick = { shouldDismiss.value = true @@ -202,6 +210,28 @@ class ChooseAccountDialogCompose { showEcosystem = showEcosystem, context = context ) + if (showOnlineStatusSheet.value) { + val currentStatusType = StatusType.entries.firstOrNull { it.string == status.value?.status } + OnlineStatusModalBottomSheet( + currentStatusType = currentStatusType, + onStatusSelected = { statusType -> + statusViewModel.setStatusType(statusType) + }, + onDismiss = { showOnlineStatusSheet.value = false } + ) + } + if (showStatusMessageSheet.value) { + status.value?.let { currentStatus -> + StatusMessageModalBottomSheet( + currentStatus = currentStatus, + viewModel = statusMessageViewModel, + onDismiss = { + showStatusMessageSheet.value = false + statusViewModel.getStatus() + } + ) + } + } } } @@ -229,22 +259,6 @@ class ChooseAccountDialogCompose { } } - private fun openSetOnlineStatusFragment(status: Status?, activity: Activity) { - val fragmentActivity = activity as FragmentActivity - status?.let { - val setStatusDialog = OnlineStatusBottomDialogFragment.newInstance(it) - setStatusDialog.show(fragmentActivity.supportFragmentManager, "fragment_set_status") - } ?: Log.w(TAG, "status was null") - } - - private fun openSetStatusMessageFragment(status: Status?, activity: Activity) { - val fragmentActivity = activity as FragmentActivity - status?.let { - val setStatusDialog = StatusMessageBottomDialogFragment.newInstance(it) - setStatusDialog.show(fragmentActivity.supportFragmentManager, "fragment_set_status") - } ?: Log.w(TAG, "status was null") - } - private fun addAccount(activity: Activity) { val intent = Intent(activity, ServerSelectionActivity::class.java) intent.putExtra(BundleKeys.ADD_ADDITIONAL_ACCOUNT, true) @@ -457,54 +471,14 @@ private fun CurrentUserSection( .clickable { onCurrentUserClick() }, verticalAlignment = Alignment.CenterVertically ) { - Box { - val avatarUrl = ApiUtils.getUrlForAvatar( - currentUser.baseUrl, - currentUser.userId, - true, - darkMode = DisplayUtils.isDarkModeOn(context) - ) - val request = loadImage( - avatarUrl, - context, - R.drawable.account_circle_96dp - ) - AsyncImage( - model = request, - contentDescription = stringResource(R.string.user_avatar), - modifier = Modifier.size(48.dp) - ) - statusIndicator(Modifier.align(Alignment.BottomEnd)) - } - Column( + UserAvatarWithStatus(currentUser = currentUser, context = context, statusIndicator = statusIndicator) + CurrentUserInfo( + currentUser = currentUser, + status = status, modifier = Modifier .padding(start = 12.dp) .weight(1f) - ) { - Text(text = currentUser.displayName ?: currentUser.username ?: "") - status?.let { - Column { - if (!it.message.isNullOrEmpty()) { - Text( - text = it.message!!, - style = MaterialTheme.typography.bodySmall, - color = colorResource(id = R.color.low_emphasis_text), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Text( - currentUser.baseUrl!!.toUri().host ?: "", - modifier = Modifier.padding(top = 2.dp), - style = MaterialTheme.typography.bodySmall, - color = colorResource(id = R.color.low_emphasis_text), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - + ) Spacer(modifier = Modifier.padding(end = 8.dp)) Icon( painterResource(id = R.drawable.ic_check_circle), @@ -515,6 +489,53 @@ private fun CurrentUserSection( } } +@Composable +private fun UserAvatarWithStatus(currentUser: User, context: Context, statusIndicator: @Composable (Modifier) -> Unit) { + Box { + AsyncImage( + model = loadImage( + ApiUtils.getUrlForAvatar( + currentUser.baseUrl, + currentUser.userId, + true, + DisplayUtils.isDarkModeOn(context) + ), + context, + R.drawable.account_circle_96dp + ), + contentDescription = stringResource(R.string.user_avatar), + modifier = Modifier.size(48.dp) + ) + statusIndicator(Modifier.align(Alignment.BottomEnd)) + } +} + +@Composable +private fun CurrentUserInfo(currentUser: User, status: Status?, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Text(text = currentUser.displayName ?: currentUser.username ?: "") + status?.let { + if (!it.message.isNullOrEmpty()) { + Text( + text = it.message!!, + style = MaterialTheme.typography.bodySmall, + color = colorResource(id = R.color.low_emphasis_text), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = currentUser.baseUrl!!.toUri().host ?: "", + modifier = Modifier.padding(top = 2.dp), + style = MaterialTheme.typography.bodySmall, + color = colorResource(id = R.color.low_emphasis_text), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + @Composable private fun EcosystemAppsSection(onFilesClick: () -> Unit, onNotesClick: () -> Unit, onMoreClick: () -> Unit) { Row( diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/StatusRepository.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/StatusRepository.kt deleted file mode 100644 index 062394f06ef..00000000000 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/StatusRepository.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2025 Sowjanya Kota - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package com.nextcloud.talk.chooseaccount - -import com.nextcloud.talk.models.json.status.StatusOverall - -interface StatusRepository { - suspend fun setStatus(credentials: String, url: String): StatusOverall -} diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/StatusRepositoryImplementation.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/StatusRepositoryImplementation.kt deleted file mode 100644 index b502bf06af7..00000000000 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/StatusRepositoryImplementation.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2025 Sowjanya Kota - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package com.nextcloud.talk.chooseaccount - -import com.nextcloud.talk.api.NcApiCoroutines -import com.nextcloud.talk.models.json.status.StatusOverall -import javax.inject.Inject - -class StatusRepositoryImplementation @Inject constructor(private val ncApiCoroutines: NcApiCoroutines) : - StatusRepository { - - override suspend fun setStatus(credentials: String, url: String): StatusOverall { - val statusOverall = ncApiCoroutines.status(credentials, url) - return statusOverall - } -} diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/data/StatusRepository.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/data/StatusRepository.kt new file mode 100644 index 00000000000..cc62a369d7c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/data/StatusRepository.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chooseaccount.data + +import com.nextcloud.talk.models.json.generic.GenericOverall +import com.nextcloud.talk.models.json.status.StatusOverall +import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus + +interface StatusRepository { + suspend fun setStatus(credentials: String, url: String): StatusOverall + suspend fun setStatusType(credentials: String, url: String, statusType: String): GenericOverall + suspend fun getPredefinedStatuses(credentials: String, url: String): List + suspend fun getBackupStatus(credentials: String, url: String): StatusOverall + suspend fun clearStatusMessage(credentials: String, url: String): GenericOverall + suspend fun setPredefinedStatusMessage( + credentials: String, + url: String, + messageId: String, + clearAt: Long? + ): GenericOverall + suspend fun setCustomStatusMessage( + credentials: String, + url: String, + statusIcon: String?, + message: String, + clearAt: Long? + ): GenericOverall + suspend fun revertStatus(credentials: String, url: String): GenericOverall +} diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/data/StatusRepositoryImplementation.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/data/StatusRepositoryImplementation.kt new file mode 100644 index 00000000000..172ed4571e7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/data/StatusRepositoryImplementation.kt @@ -0,0 +1,51 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chooseaccount.data + +import com.nextcloud.talk.api.NcApiCoroutines +import com.nextcloud.talk.models.json.generic.GenericOverall +import com.nextcloud.talk.models.json.status.StatusOverall +import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus +import javax.inject.Inject + +class StatusRepositoryImplementation @Inject constructor(private val ncApiCoroutines: NcApiCoroutines) : + StatusRepository { + + override suspend fun setStatus(credentials: String, url: String): StatusOverall = + ncApiCoroutines.status(credentials, url) + + override suspend fun setStatusType(credentials: String, url: String, statusType: String): GenericOverall = + ncApiCoroutines.setStatusType(credentials, url, statusType) + + override suspend fun getPredefinedStatuses(credentials: String, url: String): List = + ncApiCoroutines.getPredefinedStatuses(credentials, url).ocs?.data.orEmpty() + + override suspend fun getBackupStatus(credentials: String, url: String): StatusOverall = + ncApiCoroutines.backupStatus(credentials, url) + + override suspend fun clearStatusMessage(credentials: String, url: String): GenericOverall = + ncApiCoroutines.statusDeleteMessage(credentials, url) + + override suspend fun setPredefinedStatusMessage( + credentials: String, + url: String, + messageId: String, + clearAt: Long? + ): GenericOverall = ncApiCoroutines.setPredefinedStatusMessage(credentials, url, messageId, clearAt) + + override suspend fun setCustomStatusMessage( + credentials: String, + url: String, + statusIcon: String?, + message: String, + clearAt: Long? + ): GenericOverall = ncApiCoroutines.setCustomStatusMessage(credentials, url, statusIcon, message, clearAt) + + override suspend fun revertStatus(credentials: String, url: String): GenericOverall = + ncApiCoroutines.revertStatus(credentials, url) +} diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/OnlineStatusSheet.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/OnlineStatusSheet.kt new file mode 100644 index 00000000000..51851af8b18 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/OnlineStatusSheet.kt @@ -0,0 +1,193 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chooseaccount.ui + +import android.content.res.Configuration +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.R +import com.nextcloud.talk.models.json.status.StatusType + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OnlineStatusModalBottomSheet( + currentStatusType: StatusType?, + onStatusSelected: (StatusType) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState() + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + OnlineStatusSheetContent( + currentStatusType = currentStatusType, + onStatusSelected = { statusType -> + onStatusSelected(statusType) + onDismiss() + } + ) + } +} + +@Composable +fun OnlineStatusSheetContent( + currentStatusType: StatusType?, + modifier: Modifier = Modifier, + onStatusSelected: (StatusType) -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, bottom = 24.dp) + ) { + Text( + text = stringResource(R.string.online_status), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 16.dp) + ) + OnlineStatusRow( + iconRes = R.drawable.online_status, + headline = stringResource(R.string.online), + subtitle = null, + selected = currentStatusType == StatusType.ONLINE, + onClick = { onStatusSelected(StatusType.ONLINE) } + ) + OnlineStatusRow( + iconRes = R.drawable.ic_user_status_away, + headline = stringResource(R.string.away), + subtitle = null, + selected = currentStatusType == StatusType.AWAY, + onClick = { onStatusSelected(StatusType.AWAY) } + ) + OnlineStatusRow( + iconRes = R.drawable.ic_user_status_busy, + headline = stringResource(R.string.busy), + subtitle = null, + selected = currentStatusType == StatusType.BUSY, + onClick = { onStatusSelected(StatusType.BUSY) } + ) + OnlineStatusRow( + iconRes = R.drawable.ic_user_status_dnd, + headline = stringResource(R.string.dnd), + subtitle = stringResource(R.string.mute_all_notifications), + selected = currentStatusType == StatusType.DND, + onClick = { onStatusSelected(StatusType.DND) } + ) + OnlineStatusRow( + iconRes = R.drawable.ic_user_status_invisible, + headline = stringResource(R.string.invisible), + subtitle = stringResource(R.string.appear_offline), + selected = currentStatusType == StatusType.INVISIBLE, + onClick = { onStatusSelected(StatusType.INVISIBLE) } + ) + } +} + +@Composable +private fun OnlineStatusRow(iconRes: Int, headline: String, subtitle: String?, selected: Boolean, onClick: () -> Unit) { + val backgroundColor = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val contentColor = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = dimensionResource(R.dimen.standard_half_padding)), + shape = RoundedCornerShape(dimensionResource(R.dimen.status_corner_radius)), + colors = CardDefaults.cardColors(containerColor = backgroundColor), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.standard_padding), vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_margin))) + Column(modifier = Modifier.weight(1f)) { + Text( + text = headline, + style = MaterialTheme.typography.bodyLarge, + color = contentColor + ) + if (!subtitle.isNullOrEmpty()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL ยท Arabic", locale = "ar") +@Composable +private fun PreviewOnlineStatusSheet() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + ModalBottomSheet( + onDismissRequest = {}, + sheetState = rememberModalBottomSheetState() + ) { + OnlineStatusSheetContent( + currentStatusType = StatusType.ONLINE, + onStatusSelected = {} + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageInputComponents.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageInputComponents.kt new file mode 100644 index 00000000000..7f5baa21251 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageInputComponents.kt @@ -0,0 +1,224 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chooseaccount.ui + +import android.view.Gravity +import android.view.inputmethod.InputMethodManager +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.getSystemService +import com.nextcloud.talk.R +import com.vanniktech.emoji.EmojiEditText +import com.vanniktech.emoji.EmojiPopup +import com.vanniktech.emoji.installDisableKeyboardInput +import com.vanniktech.emoji.installForceSingleEmoji + +@Composable +internal fun EmojiAndMessageRow( + emoji: String, + message: String, + onEmojiSelected: (String) -> Unit, + onMessageChanged: (String) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + EmojiButton(emoji = emoji, onEmojiSelected = onEmojiSelected) + Spacer(modifier = Modifier.width(8.dp)) + OutlinedTextField( + value = message, + onValueChange = onMessageChanged, + modifier = Modifier.weight(1f), + label = { Text(stringResource(R.string.whats_your_status)) }, + singleLine = true, + shape = RoundedCornerShape(12.dp) + ) + } +} + +@Composable +private fun EmojiButton(emoji: String, onEmojiSelected: (String) -> Unit) { + val isPreview = LocalInspectionMode.current + val rootView = LocalView.current + var emojiPopup by remember { mutableStateOf(null) } + val displayEmoji = emoji.ifEmpty { stringResource(R.string.default_emoji) } + + Card( + shape = CircleShape, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + modifier = Modifier + .size(56.dp) + .clickable { emojiPopup?.show() } + ) { + if (isPreview) { + Text( + text = displayEmoji, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .size(56.dp) + .padding(14.dp) + ) + } else { + AndroidView( + modifier = Modifier.size(56.dp), + factory = { ctx -> + EmojiEditText(ctx).apply { + setText(displayEmoji) + gravity = Gravity.CENTER + textSize = EMOJI_TEXT_SIZE_SP + background = null + isCursorVisible = false + val popup = EmojiPopup( + rootView = rootView, + editText = this, + onEmojiClickListener = { + onEmojiSelected(text.toString()) + emojiPopup?.dismiss() + clearFocus() + ctx.getSystemService() + ?.hideSoftInputFromWindow(windowToken, 0) + } + ) + installDisableKeyboardInput(popup) + installForceSingleEmoji() + emojiPopup = popup + } + }, + update = { view -> + if (view.text.toString() != displayEmoji) { + view.setText(displayEmoji) + } + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ClearAfterDropdown(selectedPosition: Int, onPositionSelected: (Int) -> Unit) { + val options = listOf( + stringResource(R.string.dontClear), + stringResource(R.string.fifteenMinutes), + stringResource(R.string.thirtyMinutes), + stringResource(R.string.oneHour), + stringResource(R.string.fourHours), + stringResource(R.string.today), + stringResource(R.string.thisWeek) + ) + var expanded by remember { mutableStateOf(false) } + + Column { + Text( + text = stringResource(R.string.clear_status_message_after), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 4.dp) + ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = options[selectedPosition], + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + shape = RoundedCornerShape(12.dp) + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEachIndexed { index, label -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + onPositionSelected(index) + expanded = false + } + ) + } + } + } + } +} + +@Composable +internal fun ActionButtons(onClear: () -> Unit, onSet: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onClear, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) { + Text( + text = stringResource(R.string.clear_status_message), + textAlign = TextAlign.Center + ) + } + Button( + onClick = onSet, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) { + Text( + text = stringResource(R.string.set_status_message), + textAlign = TextAlign.Center + ) + } + } +} + +private const val EMOJI_TEXT_SIZE_SP = 24f diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessagePredefinedStatus.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessagePredefinedStatus.kt new file mode 100644 index 00000000000..19f6acecc9d --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessagePredefinedStatus.kt @@ -0,0 +1,165 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chooseaccount.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.R +import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus + +@Composable +internal fun PredefinedStatusList( + statuses: List, + isBackupStatusAvailable: Boolean, + onRevertStatus: () -> Unit, + onSelectStatus: (PredefinedStatus) -> Unit, + modifier: Modifier = Modifier +) { + if (statuses.isEmpty()) return + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(8.dp)) + if (isBackupStatusAvailable) { + Text( + text = stringResource(R.string.automatic_status_set), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + statuses.forEachIndexed { index, status -> + PredefinedStatusRow( + status = status, + isBackupEntry = isBackupStatusAvailable && index == 0, + onRevertStatus = onRevertStatus, + onClick = { onSelectStatus(status) } + ) + } + } +} + +@Composable +private fun PredefinedStatusRow( + status: PredefinedStatus, + isBackupEntry: Boolean, + onRevertStatus: () -> Unit, + onClick: () -> Unit +) { + val context = LocalContext.current + val clearAtLabel = remember(status) { resolveClearAtLabel(status, context) } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = status.icon ?: "", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.width(42.dp) + ) + if (isBackupEntry) { + BackupStatusContent(message = status.message, onRevertStatus = onRevertStatus) + } else { + StandardStatusContent(message = status.message, clearAtLabel = clearAtLabel) + } + } +} + +@Composable +private fun RowScope.BackupStatusContent(message: String, onRevertStatus: () -> Unit) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.previously_set), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(8.dp)) + FilledTonalButton( + onClick = onRevertStatus, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + Text(stringResource(R.string.reset_status)) + } +} + +@Composable +private fun RowScope.StandardStatusContent(message: String, clearAtLabel: String) { + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (clearAtLabel.isNotEmpty()) { + Text( + text = stringResource(R.string.divider), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp) + ) + Text( + text = clearAtLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +private fun resolveClearAtLabel(status: PredefinedStatus, context: android.content.Context): String { + val clearAt = status.clearAt ?: return context.getString(R.string.dontClear) + return when (clearAt.type) { + "period" -> when (clearAt.time) { + "900" -> context.getString(R.string.fifteenMinutes) + "1800" -> context.getString(R.string.thirtyMinutes) + "3600" -> context.getString(R.string.oneHour) + "14400" -> context.getString(R.string.fourHours) + else -> "" + } + "end-of" -> if (clearAt.time == "day") { + context.getString(R.string.today) + } else { + context.getString(R.string.thisWeek) + } + else -> "" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt new file mode 100644 index 00000000000..c57ae6d7cd7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt @@ -0,0 +1,237 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chooseaccount.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.R +import com.nextcloud.talk.chooseaccount.viewmodel.StatusMessageViewModel +import com.nextcloud.talk.models.json.status.Status +import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatusMessageModalBottomSheet(currentStatus: Status, viewModel: StatusMessageViewModel, onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val isDismissed by viewModel.isDismissed.collectAsState() + + LaunchedEffect(currentStatus) { + viewModel.init(currentStatus) + viewModel.checkBackupStatus() + viewModel.fetchPredefinedStatuses() + } + + LaunchedEffect(isDismissed) { + if (isDismissed) onDismiss() + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + StatusMessageSheetContent(viewModel = viewModel) + } +} + +@Composable +fun StatusMessageSheetContent(modifier: Modifier = Modifier, viewModel: StatusMessageViewModel) { + val emoji by viewModel.emoji.collectAsState() + val message by viewModel.message.collectAsState() + val clearAtPosition by viewModel.clearAtPosition.collectAsState() + val predefinedStatuses by viewModel.predefinedStatuses.collectAsState() + val isBackupStatusAvailable by viewModel.isBackupStatusAvailable.collectAsState() + + StatusMessageSheetContentStateless( + modifier = modifier, + emoji = emoji, + message = message, + clearAtPosition = clearAtPosition, + predefinedStatuses = predefinedStatuses, + isBackupStatusAvailable = isBackupStatusAvailable, + onEmojiSelected = { viewModel.updateEmoji(it) }, + onMessageChanged = { viewModel.updateMessage(it) }, + onClearAtPositionSelected = { viewModel.updateClearAtPosition(it) }, + onRevertStatus = { viewModel.revertStatus() }, + onSelectStatus = { viewModel.selectPredefinedStatus(it) }, + onClear = { viewModel.clearStatus() }, + onSet = { viewModel.setStatus() } + ) +} + +@Suppress("LongParameterList") +@Composable +internal fun StatusMessageSheetContentStateless( + modifier: Modifier = Modifier, + emoji: String, + message: String, + clearAtPosition: Int, + predefinedStatuses: List, + isBackupStatusAvailable: Boolean, + onEmojiSelected: (String) -> Unit, + onMessageChanged: (String) -> Unit, + onClearAtPositionSelected: (Int) -> Unit, + onRevertStatus: () -> Unit, + onSelectStatus: (PredefinedStatus) -> Unit, + onClear: () -> Unit, + onSet: () -> Unit +) { + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + if (isLandscape) { + LandscapeSheetContent( + modifier = modifier, + emoji = emoji, + message = message, + clearAtPosition = clearAtPosition, + predefinedStatuses = predefinedStatuses, + isBackupStatusAvailable = isBackupStatusAvailable, + onEmojiSelected = onEmojiSelected, + onMessageChanged = onMessageChanged, + onClearAtPositionSelected = onClearAtPositionSelected, + onRevertStatus = onRevertStatus, + onSelectStatus = onSelectStatus, + onClear = onClear, + onSet = onSet + ) + } else { + PortraitSheetContent( + modifier = modifier, + emoji = emoji, + message = message, + clearAtPosition = clearAtPosition, + predefinedStatuses = predefinedStatuses, + isBackupStatusAvailable = isBackupStatusAvailable, + onEmojiSelected = onEmojiSelected, + onMessageChanged = onMessageChanged, + onClearAtPositionSelected = onClearAtPositionSelected, + onRevertStatus = onRevertStatus, + onSelectStatus = onSelectStatus, + onClear = onClear, + onSet = onSet + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun LandscapeSheetContent( + modifier: Modifier, + emoji: String, + message: String, + clearAtPosition: Int, + predefinedStatuses: List, + isBackupStatusAvailable: Boolean, + onEmojiSelected: (String) -> Unit, + onMessageChanged: (String) -> Unit, + onClearAtPositionSelected: (Int) -> Unit, + onRevertStatus: () -> Unit, + onSelectStatus: (PredefinedStatus) -> Unit, + onClear: () -> Unit, + onSet: () -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + StatusMessageTitle() + EmojiAndMessageRow( + emoji = emoji, + message = message, + onEmojiSelected = onEmojiSelected, + onMessageChanged = onMessageChanged + ) + Spacer(modifier = Modifier.height(12.dp)) + ClearAfterDropdown(selectedPosition = clearAtPosition, onPositionSelected = onClearAtPositionSelected) + Spacer(modifier = Modifier.height(16.dp)) + ActionButtons(onClear = onClear, onSet = onSet) + } + PredefinedStatusList( + statuses = predefinedStatuses, + isBackupStatusAvailable = isBackupStatusAvailable, + onRevertStatus = onRevertStatus, + onSelectStatus = onSelectStatus, + modifier = Modifier.weight(1f) + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun PortraitSheetContent( + modifier: Modifier, + emoji: String, + message: String, + clearAtPosition: Int, + predefinedStatuses: List, + isBackupStatusAvailable: Boolean, + onEmojiSelected: (String) -> Unit, + onMessageChanged: (String) -> Unit, + onClearAtPositionSelected: (Int) -> Unit, + onRevertStatus: () -> Unit, + onSelectStatus: (PredefinedStatus) -> Unit, + onClear: () -> Unit, + onSet: () -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 24.dp) + ) { + StatusMessageTitle() + EmojiAndMessageRow( + emoji = emoji, + message = message, + onEmojiSelected = onEmojiSelected, + onMessageChanged = onMessageChanged + ) + PredefinedStatusList( + statuses = predefinedStatuses, + isBackupStatusAvailable = isBackupStatusAvailable, + onRevertStatus = onRevertStatus, + onSelectStatus = onSelectStatus, + modifier = Modifier.weight(1f, fill = false) + ) + Spacer(modifier = Modifier.height(12.dp)) + ClearAfterDropdown(selectedPosition = clearAtPosition, onPositionSelected = onClearAtPositionSelected) + Spacer(modifier = Modifier.height(16.dp)) + ActionButtons(onClear = onClear, onSet = onSet) + } +} + +@Composable +private fun StatusMessageTitle() { + Text( + text = stringResource(R.string.status_message), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 12.dp) + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheetPreview.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheetPreview.kt new file mode 100644 index 00000000000..1d060ee8853 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheetPreview.kt @@ -0,0 +1,169 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chooseaccount.ui + +import android.content.res.Configuration +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.talk.models.json.status.ClearAt +import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview( + name = "Light ยท Landscape", + device = "spec:width=411dp,height=891dp,orientation=landscape" +) +@Preview( + name = "Dark ยท Landscape", + uiMode = Configuration.UI_MODE_NIGHT_YES, + device = "spec:width=411dp,height=891dp,orientation=landscape" +) +@Composable +private fun PreviewStatusMessageSheet() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + ModalBottomSheet( + onDismissRequest = {}, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + StatusMessageSheetContentStateless( + emoji = "๐Ÿ–๏ธ", + message = "On vacation", + clearAtPosition = 3, + predefinedStatuses = previewPredefinedStatuses(), + isBackupStatusAvailable = false, + onEmojiSelected = {}, + onMessageChanged = {}, + onClearAtPositionSelected = {}, + onRevertStatus = {}, + onSelectStatus = {}, + onClear = {}, + onSet = {} + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "RTL ยท Arabic", showBackground = true, locale = "ar") +@Preview( + name = "RTL ยท Arabic ยท Landscape", + locale = "ar", + device = "spec:width=411dp,height=891dp,orientation=landscape" +) +@Composable +private fun PreviewStatusMessageSheetRtl() { + MaterialTheme(colorScheme = lightColorScheme()) { + ModalBottomSheet( + onDismissRequest = {}, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + StatusMessageSheetContentStateless( + emoji = "๐Ÿ“†", + message = "In a meeting", + clearAtPosition = 1, + predefinedStatuses = previewPredefinedStatuses(), + isBackupStatusAvailable = false, + onEmojiSelected = {}, + onMessageChanged = {}, + onClearAtPositionSelected = {}, + onRevertStatus = {}, + onSelectStatus = {}, + onClear = {}, + onSet = {} + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "With backup status ยท Light") +@Preview(name = "With backup status ยท Dark ยท German", uiMode = Configuration.UI_MODE_NIGHT_YES, locale = "de") +@Preview(name = "With backup status ยท RTL ยท Arabic", locale = "ar") +@Preview( + name = "With backup status ยท Light ยท Landscape", + device = "spec:width=411dp,height=891dp,orientation=landscape" +) +@Preview( + name = "With backup status ยท Dark ยท German ยท Landscape", + uiMode = Configuration.UI_MODE_NIGHT_YES, + locale = "de", + device = "spec:width=411dp,height=891dp,orientation=landscape" +) +@Preview( + name = "With backup status ยท RTL ยท Arabic ยท Landscape", + locale = "ar", + device = "spec:width=411dp,height=891dp,orientation=landscape" +) +@Composable +private fun PreviewStatusMessageSheetWithBackup() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + ModalBottomSheet( + onDismissRequest = {}, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + StatusMessageSheetContentStateless( + emoji = "๐Ÿ–๏ธ", + message = "On vacation", + clearAtPosition = 0, + predefinedStatuses = previewPredefinedStatusesWithBackup(), + isBackupStatusAvailable = true, + onEmojiSelected = {}, + onMessageChanged = {}, + onClearAtPositionSelected = {}, + onRevertStatus = {}, + onSelectStatus = {}, + onClear = {}, + onSet = {} + ) + } + } +} + +private fun previewPredefinedStatuses() = + listOf( + PredefinedStatus( + id = "meeting", + icon = "๐Ÿ“†", + message = "In a meeting", + clearAt = ClearAt(type = "period", time = "3600") + ), + PredefinedStatus( + id = "commuting", + icon = "๐ŸšŒ", + message = "Commuting", + clearAt = ClearAt(type = "period", time = "1800") + ), + PredefinedStatus( + id = "remote", + icon = "๐Ÿก", + message = "Working remotely", + clearAt = ClearAt(type = "end-of", time = "day") + ), + PredefinedStatus( + id = "sick", + icon = "๐Ÿค’", + message = "Out sick", + clearAt = ClearAt(type = "end-of", time = "day") + ), + PredefinedStatus(id = "vacation", icon = "๐Ÿ–๏ธ", message = "On vacation", clearAt = null) + ) + +private fun previewPredefinedStatusesWithBackup() = + listOf(PredefinedStatus(id = "backup", icon = "โŒ›", message = "Be right back", clearAt = null)) + + previewPredefinedStatuses() diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt new file mode 100644 index 00000000000..43455ab7686 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt @@ -0,0 +1,296 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chooseaccount.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.talk.chooseaccount.data.StatusRepository +import com.nextcloud.talk.models.json.status.ClearAt +import com.nextcloud.talk.models.json.status.Status +import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CapabilitiesUtil +import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.util.Calendar +import javax.inject.Inject + +private const val ONE_SECOND_IN_MILLIS = 1000L +private const val ONE_MINUTE_IN_SECONDS = 60L +private const val FIFTEEN_MINUTES = 15L +private const val THIRTY_MINUTES = 30L +private const val FOUR_HOURS = 4L +private const val LAST_HOUR_OF_DAY = 23 +private const val LAST_MINUTE_OF_HOUR = 59 +private const val LAST_SECOND_OF_MINUTE = 59 +private const val HTTP_STATUS_CODE_OK = 200 +private const val HTTP_STATUS_CODE_NOT_FOUND = 404 + +class StatusMessageViewModel @Inject constructor( + private val repository: StatusRepository, + private val currentUserProvider: CurrentUserProviderOld +) : ViewModel() { + + private val currentUser = currentUserProvider.currentUser.blockingGet() + private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! + + private val _emoji = MutableStateFlow("") + val emoji: StateFlow = _emoji + + private val _message = MutableStateFlow("") + val message: StateFlow = _message + + private val _clearAtPosition = MutableStateFlow(0) + val clearAtPosition: StateFlow = _clearAtPosition + + private val _predefinedStatuses = MutableStateFlow>(emptyList()) + val predefinedStatuses: StateFlow> = _predefinedStatuses + + private val _selectedPredefinedStatus = MutableStateFlow(null) + val selectedPredefinedStatus: StateFlow = _selectedPredefinedStatus + + private val _isBackupStatusAvailable = MutableStateFlow(false) + val isBackupStatusAvailable: StateFlow = _isBackupStatusAvailable + + private val _isDismissed = MutableStateFlow(false) + val isDismissed: StateFlow = _isDismissed + + private var clearAt: Long? = null + private var currentStatusMessageId: String? = null + + fun init(currentStatus: Status) { + _emoji.value = currentStatus.icon ?: "" + _message.value = currentStatus.message?.trim() ?: "" + currentStatusMessageId = currentStatus.messageId + clearAt = if (currentStatus.clearAt > 0) currentStatus.clearAt else null + _isDismissed.value = false + _predefinedStatuses.value = emptyList() + _isBackupStatusAvailable.value = false + _selectedPredefinedStatus.value = null + } + + fun resetDismissed() { + _isDismissed.value = false + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun fetchPredefinedStatuses() { + viewModelScope.launch { + try { + val statuses = repository.getPredefinedStatuses( + credentials, + ApiUtils.getUrlForPredefinedStatuses(currentUser.baseUrl!!) + ) + _predefinedStatuses.value = statuses + + if (_selectedPredefinedStatus.value == null && currentStatusMessageId?.isNotEmpty() == true) { + _selectedPredefinedStatus.value = statuses.firstOrNull { it.id == currentStatusMessageId } + } + } catch (e: Exception) { + Log.e(TAG, "Error while fetching predefined statuses", e) + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun checkBackupStatus() { + if (!CapabilitiesUtil.isRestoreStatusAvailable(currentUser)) return + viewModelScope.launch { + try { + val statusOverall = repository.getBackupStatus( + credentials, + ApiUtils.getUrlForBackupStatus(currentUser.baseUrl!!, currentUser.userId!!) + ) + if (statusOverall.ocs?.meta?.statusCode == HTTP_STATUS_CODE_OK) { + val backupStatus = statusOverall.ocs?.data ?: return@launch + if (backupStatus.message != null) { + val backupPredefined = PredefinedStatus( + id = backupStatus.userId!!, + icon = backupStatus.icon, + message = backupStatus.message!!, + clearAt = ClearAt(type = "period", time = backupStatus.clearAt.toString()) + ) + val updated = listOf(backupPredefined) + _predefinedStatuses.value + _predefinedStatuses.value = updated + _isBackupStatusAvailable.value = true + } + } + } catch (e: Exception) { + val isNotFound = e.message?.contains(HTTP_STATUS_CODE_NOT_FOUND.toString()) == true + if (isNotFound) { + Log.d(TAG, "User does not have a backup status set") + } else { + Log.e(TAG, "Error while getting user backup status", e) + } + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun clearStatus() { + viewModelScope.launch { + try { + repository.clearStatusMessage( + credentials, + ApiUtils.getUrlForStatusMessage(currentUser.baseUrl!!) + ) + _isDismissed.value = true + } catch (e: Exception) { + Log.e(TAG, "Failed to clear status", e) + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught", "ComplexMethod") + fun setStatus() { + val inputText = _message.value.ifEmpty { "" } + val statusIcon = _emoji.value.ifEmpty { null } + val selected = _selectedPredefinedStatus.value + + viewModelScope.launch { + try { + if (selected == null || selected.message != inputText || selected.icon != _emoji.value) { + repository.setCustomStatusMessage( + credentials, + ApiUtils.getUrlForSetCustomStatus(currentUser.baseUrl!!), + statusIcon, + inputText, + clearAt + ) + } else { + repository.setPredefinedStatusMessage( + credentials, + ApiUtils.getUrlForSetPredefinedStatus(currentUser.baseUrl!!), + selected.id, + clearAt + ) + } + _isDismissed.value = true + } catch (e: Exception) { + Log.e(TAG, "Failed to set status message", e) + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun revertStatus() { + viewModelScope.launch { + try { + repository.revertStatus( + credentials, + ApiUtils.getUrlForRevertStatus(currentUser.baseUrl!!, currentStatusMessageId) + ) + _isBackupStatusAvailable.value = false + val updated = _predefinedStatuses.value.drop(1) + _predefinedStatuses.value = updated + _isDismissed.value = true + } catch (e: Exception) { + Log.e(TAG, "Failed to revert status", e) + } + } + } + + fun selectPredefinedStatus(status: PredefinedStatus) { + _selectedPredefinedStatus.value = status + _emoji.value = status.icon ?: "" + _message.value = status.message + clearAt = clearAtToUnixTime(status.clearAt) + _clearAtPosition.value = clearAtToClearAtPosition(status.clearAt) + } + + fun updateEmoji(newEmoji: String) { + _emoji.value = newEmoji + } + + fun updateMessage(newMessage: String) { + _message.value = newMessage + } + + fun updateClearAtPosition(position: Int) { + _clearAtPosition.value = position + clearAt = statusMessageClearAtFromPosition(position) + } + + companion object { + private val TAG = StatusMessageViewModel::class.simpleName + const val CLEAR_AT_POS_DONT_CLEAR = 0 + const val CLEAR_AT_POS_FIFTEEN_MINUTES = 1 + const val CLEAR_AT_POS_HALF_AN_HOUR = 2 + const val CLEAR_AT_POS_AN_HOUR = 3 + const val CLEAR_AT_POS_FOUR_HOURS = 4 + const val CLEAR_AT_POS_TODAY = 5 + const val CLEAR_AT_POS_END_OF_WEEK = 6 + } +} + +internal fun statusMessageClearAtFromPosition(item: Int): Long? { + val currentTime = System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + return when (item) { + StatusMessageViewModel.CLEAR_AT_POS_DONT_CLEAR -> null + StatusMessageViewModel.CLEAR_AT_POS_FIFTEEN_MINUTES -> currentTime + FIFTEEN_MINUTES * ONE_MINUTE_IN_SECONDS + StatusMessageViewModel.CLEAR_AT_POS_HALF_AN_HOUR -> currentTime + THIRTY_MINUTES * ONE_MINUTE_IN_SECONDS + StatusMessageViewModel.CLEAR_AT_POS_AN_HOUR -> currentTime + ONE_MINUTE_IN_SECONDS * ONE_MINUTE_IN_SECONDS + StatusMessageViewModel.CLEAR_AT_POS_FOUR_HOURS -> + currentTime + FOUR_HOURS * ONE_MINUTE_IN_SECONDS * ONE_MINUTE_IN_SECONDS + StatusMessageViewModel.CLEAR_AT_POS_TODAY -> statusMessageEndOfDay() + StatusMessageViewModel.CLEAR_AT_POS_END_OF_WEEK -> statusMessageEndOfWeek() + else -> null + } +} + +internal fun statusMessageEndOfDay(): Long { + val date = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, LAST_HOUR_OF_DAY) + set(Calendar.MINUTE, LAST_MINUTE_OF_HOUR) + set(Calendar.SECOND, LAST_SECOND_OF_MINUTE) + } + return date.timeInMillis / ONE_SECOND_IN_MILLIS +} + +internal fun statusMessageEndOfWeek(): Long { + val date = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, LAST_HOUR_OF_DAY) + set(Calendar.MINUTE, LAST_MINUTE_OF_HOUR) + set(Calendar.SECOND, LAST_SECOND_OF_MINUTE) + } + while (date.get(Calendar.DAY_OF_WEEK) != Calendar.SUNDAY) { + date.add(Calendar.DAY_OF_YEAR, 1) + } + return date.timeInMillis / ONE_SECOND_IN_MILLIS +} + +internal fun clearAtToUnixTime(clearAt: ClearAt?): Long? { + clearAt ?: return null + return when (clearAt.type) { + "period" -> System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + clearAt.time.toLong() + "end-of" -> if (clearAt.time == "day") statusMessageEndOfDay() else null + else -> null + } +} + +internal fun clearAtToClearAtPosition(clearAt: ClearAt?): Int { + clearAt ?: return StatusMessageViewModel.CLEAR_AT_POS_DONT_CLEAR + return when { + clearAt.type == "period" -> when (clearAt.time) { + "900" -> StatusMessageViewModel.CLEAR_AT_POS_FIFTEEN_MINUTES + "1800" -> StatusMessageViewModel.CLEAR_AT_POS_HALF_AN_HOUR + "3600" -> StatusMessageViewModel.CLEAR_AT_POS_AN_HOUR + "14400" -> StatusMessageViewModel.CLEAR_AT_POS_FOUR_HOURS + else -> StatusMessageViewModel.CLEAR_AT_POS_DONT_CLEAR + } + clearAt.type == "end-of" -> when (clearAt.time) { + "day" -> StatusMessageViewModel.CLEAR_AT_POS_TODAY + "week" -> StatusMessageViewModel.CLEAR_AT_POS_END_OF_WEEK + else -> StatusMessageViewModel.CLEAR_AT_POS_DONT_CLEAR + } + else -> StatusMessageViewModel.CLEAR_AT_POS_DONT_CLEAR + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/StatusViewModel.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt similarity index 67% rename from app/src/main/java/com/nextcloud/talk/chooseaccount/StatusViewModel.kt rename to app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt index 728d366c297..b8d70ecd19f 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/StatusViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt @@ -1,15 +1,18 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2025 Sowjanya Kota + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ -package com.nextcloud.talk.chooseaccount +package com.nextcloud.talk.chooseaccount.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nextcloud.talk.chooseaccount.data.StatusRepository import com.nextcloud.talk.models.json.status.StatusOverall +import com.nextcloud.talk.models.json.status.StatusType import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import kotlinx.coroutines.flow.MutableStateFlow @@ -43,6 +46,23 @@ class StatusViewModel @Inject constructor( } } } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun setStatusType(statusType: StatusType) { + viewModelScope.launch { + try { + val url = ApiUtils.getUrlForSetStatusType(currentUser.baseUrl!!) + repository.setStatusType(credentials!!, url, statusType.string) + getStatus() + } catch (exception: Exception) { + Log.e(TAG, "Failed to set statusType", exception) + } + } + } + + companion object { + private val TAG = StatusViewModel::class.simpleName + } } sealed class StatusUiState { diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 1f755c90eb3..f0b21622d9f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -13,7 +13,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import android.os.Handler import android.provider.Settings import android.util.Log import android.widget.Toast @@ -39,11 +38,14 @@ import com.nextcloud.talk.account.ServerSelectionActivity import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.activities.CallActivity import com.nextcloud.talk.activities.MainActivity +import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.conversation.RenameConversationDialogFragment import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contextchat.ContextChatViewModel +import com.nextcloud.talk.conversationlist.ui.ConversationOpsAction import com.nextcloud.talk.conversationlist.ui.ConversationsListScreen import com.nextcloud.talk.conversationlist.ui.ConversationsListScreenCallbacks import com.nextcloud.talk.conversationlist.ui.ConversationsListScreenState @@ -56,6 +58,7 @@ import com.nextcloud.talk.invitation.InvitationsActivity import com.nextcloud.talk.jobs.AccountRemovalWorker import com.nextcloud.talk.jobs.ContactAddressBookWorker.Companion.run import com.nextcloud.talk.jobs.DeleteConversationWorker +import com.nextcloud.talk.jobs.LeaveConversationWorker import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.SearchMessageEntry @@ -63,7 +66,6 @@ import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.settings.SettingsActivity import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity import com.nextcloud.talk.ui.chooseaccount.ChooseAccountShareToDialogFragment -import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog import com.nextcloud.talk.ui.dialog.FilterConversationFragment import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.MENTION @@ -71,6 +73,7 @@ import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.UNREAD import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.BrandingUtils +import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.CapabilitiesUtil.isServerEOL import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -79,6 +82,7 @@ import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ParticipantPermissions +import com.nextcloud.talk.utils.ShareUtils import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.bundle.BundleKeys @@ -93,10 +97,13 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SHARED_TEXT import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.power.PowerManagerUtils import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder +import android.text.TextUtils +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import retrofit2.HttpException @@ -111,6 +118,9 @@ class ConversationsListActivity : BaseActivity() { @Inject lateinit var userManager: UserManager + @Inject + lateinit var ncApiCoroutines: NcApiCoroutines + @Inject lateinit var platformPermissionUtil: PlatformPermissionUtil @@ -152,7 +162,6 @@ class ConversationsListActivity : BaseActivity() { private var selectedConversation: ConversationModel? = null private var textToPaste: String? = "" private var selectedMessageId: String? = null - private var conversationsListBottomDialog: ConversationsListBottomDialog? = null private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -175,6 +184,9 @@ class ConversationsListActivity : BaseActivity() { setSupportActionBar(null) forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) + if (savedInstanceState != null) { + showAccountDialogState.value = savedInstanceState.getBoolean(KEY_ACCOUNT_DIALOG_VISIBLE, false) + } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) setContent { @@ -188,6 +200,11 @@ class ConversationsListActivity : BaseActivity() { initObservers() } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(KEY_ACCOUNT_DIALOG_VISIBLE, showAccountDialogState.value) + } + private fun buildScreenState() = ConversationsListScreenState( currentUser = currentUser, @@ -205,7 +222,8 @@ class ConversationsListActivity : BaseActivity() { showShareToFlow = showShareToScreenState, forwardMessageFlow = forwardMessageState, hasMultipleAccountsFlow = hasMultipleAccountsState, - showAccountDialogFlow = showAccountDialogState + showAccountDialogFlow = showAccountDialogState, + selectedConversationForOpsFlow = conversationsListViewModel.selectedConversationForOps ) @Suppress("LongMethod") @@ -275,7 +293,9 @@ class ConversationsListActivity : BaseActivity() { .show(supportFragmentManager, ChooseAccountShareToDialogFragment.TAG) }, onNewConversation = { showNewConversationsScreen() }, - onAccountDialogDismiss = { showAccountDialogState.value = false } + onAccountDialogDismiss = { showAccountDialogState.value = false }, + onConversationOpsDismiss = { conversationsListViewModel.setSelectedConversationForOps(null) }, + onConversationOpsAction = { action, conversation -> handleConversationOpsAction(action, conversation) } ) override fun onPostCreate(savedInstanceState: Bundle?) { @@ -395,6 +415,24 @@ class ConversationsListActivity : BaseActivity() { if (filterState[ARCHIVE] == true) showUnreadBubbleState.value = false } } + + lifecycleScope.launch { + conversationsListViewModel.readUnreadState.collect { state -> + when (state) { + is ConversationsListViewModel.ConversationReadUnreadUiState.Success -> { + fetchRooms() + val resId = if (state.isMarkedRead) R.string.marked_as_read else R.string.marked_as_unread + showSnackbar(String.format(resources.getString(resId), state.conversationDisplayName)) + conversationsListViewModel.resetReadUnreadState() + } + is ConversationsListViewModel.ConversationReadUnreadUiState.Error -> { + showSnackbar(resources.getString(R.string.nc_common_error_sorry)) + conversationsListViewModel.resetReadUnreadState() + } + ConversationsListViewModel.ConversationReadUnreadUiState.None -> { /* no-op */ } + } + } + } } private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) { @@ -433,15 +471,8 @@ class ConversationsListActivity : BaseActivity() { } private fun handleConversationLongClick(model: ConversationModel) { - lifecycleScope.launch { - if (!showShareToScreen && networkMonitor.isOnline.value) { - conversationsListBottomDialog = ConversationsListBottomDialog( - this@ConversationsListActivity, - currentUser!!, - model - ) - conversationsListBottomDialog!!.show() - } + if (!showShareToScreen && networkMonitor.isOnline.value) { + conversationsListViewModel.setSelectedConversationForOps(model) } } @@ -890,11 +921,129 @@ class ConversationsListActivity : BaseActivity() { @Subscribe(threadMode = ThreadMode.MAIN) fun onMessageEvent(conversationsListFetchDataEvent: ConversationsListFetchDataEvent?) { fetchRooms() - Handler().postDelayed({ - if (conversationsListBottomDialog!!.isShowing) { - conversationsListBottomDialog!!.dismiss() + conversationsListViewModel.clearSelectedConversationForOpsWithDelay(BOTTOM_SHEET_DELAY) + } + + private fun handleConversationOpsAction(action: ConversationOpsAction, conversation: ConversationModel) { + when (action) { + is ConversationOpsAction.AddToFavorites -> addConversationToFavorites(conversation) + is ConversationOpsAction.RemoveFromFavorites -> removeConversationFromFavorites(conversation) + is ConversationOpsAction.MarkAsRead -> markConversationAsRead(conversation) + is ConversationOpsAction.MarkAsUnread -> markConversationAsUnread(conversation) + is ConversationOpsAction.ShareLink -> shareConversationLink(conversation) + is ConversationOpsAction.Rename -> renameConversation(conversation) + is ConversationOpsAction.ToggleArchive -> handleArchiving(conversation) + is ConversationOpsAction.Leave -> leaveConversation(conversation) + is ConversationOpsAction.Delete -> showDeleteConversationDialog(conversation) + } + } + + private fun shareConversationLink(conversation: ConversationModel) { + val canGeneratePrettyURL = CapabilitiesUtil.canGeneratePrettyURL(currentUser!!) + ShareUtils.shareConversationLink( + this, + currentUser?.baseUrl, + conversation.token, + conversation.name, + canGeneratePrettyURL + ) + } + + @Suppress("Detekt.TooGenericExceptionCaught", "TooGenericExceptionCaught") + private fun handleArchiving(conversation: ConversationModel) { + val apiVersion = ApiUtils.getConversationApiVersion(currentUser!!, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) + val url = ApiUtils.getUrlForArchive(apiVersion, currentUser?.baseUrl, conversation.token) + lifecycleScope.launch { + try { + if (conversation.hasArchived) { + withContext(Dispatchers.IO) { ncApiCoroutines.unarchiveConversation(credentials!!, url) } + fetchRooms() + showSnackbar( + String.format(resources.getString(R.string.unarchived_conversation), conversation.displayName) + ) + } else { + withContext(Dispatchers.IO) { ncApiCoroutines.archiveConversation(credentials!!, url) } + fetchRooms() + showSnackbar( + String.format(resources.getString(R.string.archived_conversation), conversation.displayName) + ) + } + } catch (e: Exception) { + showSnackbar(resources.getString(R.string.nc_common_error_sorry)) } - }, BOTTOM_SHEET_DELAY) + } + } + + @Suppress("Detekt.TooGenericExceptionCaught", "TooGenericExceptionCaught") + private fun addConversationToFavorites(conversation: ConversationModel) { + val apiVersion = ApiUtils.getConversationApiVersion(currentUser!!, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) + val url = ApiUtils.getUrlForRoomFavorite(apiVersion, currentUser?.baseUrl!!, conversation.token) + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { ncApiCoroutines.addConversationToFavorites(credentials!!, url) } + fetchRooms() + showSnackbar( + String.format(resources.getString(R.string.added_to_favorites), conversation.displayName) + ) + } catch (e: Exception) { + showSnackbar(resources.getString(R.string.nc_common_error_sorry)) + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught", "TooGenericExceptionCaught") + private fun removeConversationFromFavorites(conversation: ConversationModel) { + val apiVersion = ApiUtils.getConversationApiVersion(currentUser!!, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) + val url = ApiUtils.getUrlForRoomFavorite(apiVersion, currentUser?.baseUrl!!, conversation.token) + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { ncApiCoroutines.removeConversationFromFavorites(credentials!!, url) } + fetchRooms() + showSnackbar( + String.format(resources.getString(R.string.removed_from_favorites), conversation.displayName) + ) + } catch (e: Exception) { + showSnackbar(resources.getString(R.string.nc_common_error_sorry)) + } + } + } + + private fun markConversationAsUnread(conversation: ConversationModel) { + conversationsListViewModel.markConversationAsUnread(conversation) + } + + private fun markConversationAsRead(conversation: ConversationModel) { + conversationsListViewModel.markConversationAsRead(conversation) + } + + private fun renameConversation(conversation: ConversationModel) { + if (!TextUtils.isEmpty(conversation.token)) { + RenameConversationDialogFragment + .newInstance(conversation.token!!, conversation.displayName!!) + .show(supportFragmentManager, RenameConversationDialogFragment::class.simpleName) + } + } + + @SuppressLint("StringFormatInvalid") + private fun leaveConversation(conversation: ConversationModel) { + val data = Data.Builder() + .putString(KEY_ROOM_TOKEN, conversation.token) + .putLong(KEY_INTERNAL_USER_ID, currentUser?.id!!) + .build() + val worker = OneTimeWorkRequest.Builder(LeaveConversationWorker::class.java).setInputData(data).build() + WorkManager.getInstance().enqueue(worker) + WorkManager.getInstance(this).getWorkInfoByIdLiveData(worker.id).observeForever { workInfo -> + when (workInfo?.state) { + WorkInfo.State.SUCCEEDED -> { + showSnackbar( + String.format(resources.getString(R.string.left_conversation), conversation.displayName) + ) + startActivity(Intent(this, MainActivity::class.java)) + } + WorkInfo.State.FAILED -> showSnackbar(resources.getString(R.string.nc_common_error_sorry)) + else -> {} + } + } } fun showDeleteConversationDialog(conversation: ConversationModel) { @@ -1147,5 +1296,6 @@ class ConversationsListActivity : BaseActivity() { const val NOTIFICATION_WARNING_DATE_NOT_SET = 0L const val ROOM_TYPE_ONE_ONE = "1" private const val NOTE_TO_SELF_SHORTCUT_ID = "NOTE_TO_SELF_SHORTCUT_ID" + private const val KEY_ACCOUNT_DIALOG_VISIBLE = "account_dialog_visible" } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOperationsSheet.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOperationsSheet.kt new file mode 100644 index 00000000000..915014948a8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOperationsSheet.kt @@ -0,0 +1,264 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.talk.R +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.utils.CapabilitiesUtil +import com.nextcloud.talk.utils.ConversationUtils +import com.nextcloud.talk.utils.SpreedFeatures + +private data class ConversationOpsVisibility( + val showRemoveFromFavorites: Boolean, + val showAddToFavorites: Boolean, + val showMarkAsRead: Boolean, + val showMarkAsUnread: Boolean, + val showShareLink: Boolean, + val showRename: Boolean, + val isArchived: Boolean, + val showLeave: Boolean, + val showDelete: Boolean +) + +private fun computeVisibility(conversation: ConversationModel, user: User): ConversationOpsVisibility { + val spreedCap = user.capabilities?.spreedCapability + val hasFavorites = CapabilitiesUtil.hasSpreedFeatureCapability(spreedCap, SpreedFeatures.FAVORITES) + val hasReadMarker = CapabilitiesUtil.hasSpreedFeatureCapability(spreedCap, SpreedFeatures.CHAT_READ_MARKER) + val hasUnread = CapabilitiesUtil.hasSpreedFeatureCapability(spreedCap, SpreedFeatures.CHAT_UNREAD) + return ConversationOpsVisibility( + showRemoveFromFavorites = hasFavorites && conversation.favorite, + showAddToFavorites = hasFavorites && !conversation.favorite, + showMarkAsRead = conversation.unreadMessages > 0 && hasReadMarker, + showMarkAsUnread = conversation.unreadMessages <= 0 && hasUnread, + showShareLink = !ConversationUtils.isNoteToSelfConversation(conversation), + showRename = spreedCap != null && ConversationUtils.isNameEditable(conversation, spreedCap), + isArchived = conversation.hasArchived, + showLeave = conversation.canLeaveConversation, + showDelete = conversation.canDeleteConversation + ) +} + +@Composable +fun ConversationOperationsContent( + conversation: ConversationModel, + user: User, + onAction: (ConversationOpsAction) -> Unit +) { + val visibility = computeVisibility(conversation, user) + val headerText = conversation.displayName.takeIf { it.isNotEmpty() } ?: conversation.name + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + ) { + Text( + text = headerText, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.standard_dialog_padding), + vertical = dimensionResource(R.dimen.standard_half_padding) + ), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + ConversationOpsFavoritesGroup(visibility, onAction) + ConversationOpsReadGroup(visibility, onAction) + ConversationOpsManageGroup(visibility, onAction) + } +} + +@Composable +private fun ConversationOpsFavoritesGroup( + visibility: ConversationOpsVisibility, + onAction: (ConversationOpsAction) -> Unit +) { + if (visibility.showRemoveFromFavorites) { + ConversationOpsMenuItem( + R.drawable.ic_star_black_24dp, + stringResource(R.string.nc_remove_from_favorites) + ) { onAction(ConversationOpsAction.RemoveFromFavorites) } + } + if (visibility.showAddToFavorites) { + ConversationOpsMenuItem( + R.drawable.ic_star_border_black_24dp, + stringResource(R.string.nc_add_to_favorites) + ) { onAction(ConversationOpsAction.AddToFavorites) } + } +} + +@Composable +private fun ConversationOpsReadGroup(visibility: ConversationOpsVisibility, onAction: (ConversationOpsAction) -> Unit) { + if (visibility.showMarkAsRead) { + ConversationOpsMenuItem( + R.drawable.ic_mark_chat_read_24px, + stringResource(R.string.nc_mark_as_read) + ) { onAction(ConversationOpsAction.MarkAsRead) } + } + if (visibility.showMarkAsUnread) { + ConversationOpsMenuItem( + R.drawable.ic_mark_chat_unread_24px, + stringResource(R.string.nc_mark_as_unread) + ) { onAction(ConversationOpsAction.MarkAsUnread) } + } +} + +@Composable +private fun ConversationOpsManageGroup( + visibility: ConversationOpsVisibility, + onAction: (ConversationOpsAction) -> Unit +) { + if (visibility.showShareLink) { + ConversationOpsMenuItem( + R.drawable.ic_share_action, + stringResource(R.string.nc_share_link) + ) { onAction(ConversationOpsAction.ShareLink) } + } + if (visibility.showRename) { + ConversationOpsMenuItem( + R.drawable.ic_pencil_grey600_24dp, + stringResource(R.string.nc_rename) + ) { onAction(ConversationOpsAction.Rename) } + } + val archiveIcon = if (visibility.isArchived) R.drawable.ic_unarchive_24px else R.drawable.outline_archive_24 + val archiveLabel = if (visibility.isArchived) { + stringResource(R.string.unarchive_conversation) + } else { + stringResource(R.string.archive_conversation) + } + ConversationOpsMenuItem(archiveIcon, archiveLabel) { onAction(ConversationOpsAction.ToggleArchive) } + if (visibility.showLeave) { + ConversationOpsMenuItem( + R.drawable.ic_exit_to_app_black_24dp, + stringResource(R.string.nc_leave) + ) { onAction(ConversationOpsAction.Leave) } + } + if (visibility.showDelete) { + ConversationOpsMenuItem( + R.drawable.ic_delete_grey600_24dp, + stringResource(R.string.nc_delete_call) + ) { onAction(ConversationOpsAction.Delete) } + } +} + +@Composable +private fun ConversationOpsMenuItem(@DrawableRes iconRes: Int, label: String, onClick: () -> Unit) { + TextButton( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(dimensionResource(R.dimen.bottom_sheet_item_height)), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.standard_dialog_padding)), + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface) + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_dialog_padding))) + Text( + text = label, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Start + ) + } +} + +private fun previewConversation() = + ConversationModel( + internalId = "1@tok", + accountId = 1L, + token = "tok", + name = "alice", + displayName = "Alice ๐ŸŽ‰", + description = "", + type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + participantType = Participant.ParticipantType.USER, + sessionId = "", + actorId = "user1", + actorType = "users", + objectType = ConversationEnums.ObjectType.DEFAULT, + notificationLevel = ConversationEnums.NotificationLevel.DEFAULT, + conversationReadOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_WRITE, + lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS, + lobbyTimer = 0L, + canLeaveConversation = true, + canDeleteConversation = true, + unreadMentionDirect = false, + notificationCalls = 0, + avatarVersion = "", + hasCustomAvatar = false, + callStartTime = 0L, + unreadMessages = 3, + favorite = false, + hasArchived = false + ) + +private fun previewUser() = + User( + id = 1L, + userId = "user1", + username = "user1", + baseUrl = "https://cloud.example.com", + token = "token", + displayName = "Test User", + capabilities = null + ) + +@Preview(showBackground = true, name = "Light") +@Preview(showBackground = true, name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, name = "RTL Arabic", locale = "ar") +@Composable +private fun ConversationOperationsSheetPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { + ConversationOperationsContent( + conversation = previewConversation(), + user = previewUser(), + onAction = {} + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOpsAction.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOpsAction.kt new file mode 100644 index 00000000000..6e8cfc7e37b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOpsAction.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +sealed class ConversationOpsAction { + object AddToFavorites : ConversationOpsAction() + object RemoveFromFavorites : ConversationOpsAction() + object MarkAsRead : ConversationOpsAction() + object MarkAsUnread : ConversationOpsAction() + object ShareLink : ConversationOpsAction() + object Rename : ConversationOpsAction() + object ToggleArchive : ConversationOpsAction() + object Leave : ConversationOpsAction() + object Delete : ConversationOpsAction() +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt index 4e73fa6c56b..04f6455645b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt @@ -24,13 +24,16 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -55,7 +58,7 @@ import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.participants.Participant -import com.nextcloud.talk.ui.dialog.ChooseAccountDialogCompose +import com.nextcloud.talk.chooseaccount.ChooseAccountDialogCompose import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.DEFAULT import com.nextcloud.talk.ui.theme.ViewThemeUtils @@ -85,7 +88,8 @@ data class ConversationsListScreenState( val showShareToFlow: StateFlow, val forwardMessageFlow: StateFlow, val hasMultipleAccountsFlow: StateFlow, - val showAccountDialogFlow: StateFlow + val showAccountDialogFlow: StateFlow, + val selectedConversationForOpsFlow: StateFlow ) @Suppress("LongParameterList") @@ -110,9 +114,12 @@ data class ConversationsListScreenCallbacks( val onNavigateBack: () -> Unit, val onAccountChooserClick: () -> Unit, val onNewConversation: () -> Unit, - val onAccountDialogDismiss: () -> Unit + val onAccountDialogDismiss: () -> Unit, + val onConversationOpsDismiss: () -> Unit, + val onConversationOpsAction: (ConversationOpsAction, ConversationModel) -> Unit ) +@OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun ConversationsListScreen( @@ -148,6 +155,8 @@ fun ConversationsListScreen( val isForward by state.forwardMessageFlow.collectAsStateWithLifecycle() val hasMultipleAccounts by state.hasMultipleAccountsFlow.collectAsStateWithLifecycle() val showAccountDialog by state.showAccountDialogFlow.collectAsStateWithLifecycle() + val selectedConversationForOps by state.selectedConversationForOpsFlow.collectAsStateWithLifecycle() + val opsSheetState = rememberModalBottomSheetState() // Derived state val isArchivedFilterActive = filterState[ARCHIVE] == true @@ -341,6 +350,25 @@ fun ConversationsListScreen( } dialog.GetChooseAccountDialog(shouldDismiss, activity, state.isShowEcosystem) } + + // Conversation-operations bottom sheet + val conv = selectedConversationForOps + if (conv != null && state.currentUser != null) { + ModalBottomSheet( + onDismissRequest = callbacks.onConversationOpsDismiss, + sheetState = opsSheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + ConversationOperationsContent( + conversation = conv, + user = state.currentUser, + onAction = { action -> + callbacks.onConversationOpsDismiss() + callbacks.onConversationOpsAction(action, conv) + } + ) + } + } } } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index a2d288cdeb5..81fad96df07 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.R +import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.contacts.ContactsRepository import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository @@ -40,6 +41,7 @@ import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import com.nextcloud.talk.utils.withRetry import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -73,7 +75,8 @@ class ConversationsListViewModel @Inject constructor( private val unifiedSearchRepository: UnifiedSearchRepository, private val invitationsRepository: InvitationsRepository, private val arbitraryStorageManager: ArbitraryStorageManager, - var userManager: UserManager + var userManager: UserManager, + private val ncApiCoroutines: NcApiCoroutines ) : ViewModel() { private val _currentUser = currentUserProvider.currentUser.blockingGet() @@ -102,6 +105,16 @@ class ConversationsListViewModel @Inject constructor( private val _openConversationsState = MutableStateFlow(OpenConversationsUiState.None) val openConversationsState: StateFlow = _openConversationsState + sealed class ConversationReadUnreadUiState { + data object None : ConversationReadUnreadUiState() + data class Success(val conversationDisplayName: String, val isMarkedRead: Boolean) : + ConversationReadUnreadUiState() + data object Error : ConversationReadUnreadUiState() + } + + private val _readUnreadState = MutableStateFlow(ConversationReadUnreadUiState.None) + val readUnreadState: StateFlow = _readUnreadState.asStateFlow() + object GetRoomsStartState : ViewState class GetRoomsErrorState(val throwable: Throwable) : ViewState open class GetRoomsSuccessState(val listIsNotEmpty: Boolean) : ViewState @@ -172,6 +185,20 @@ class ConversationsListViewModel @Inject constructor( private val _isSearchLoadingFlow = MutableStateFlow(false) val isSearchLoadingFlow: StateFlow = _isSearchLoadingFlow.asStateFlow() + private val _selectedConversationForOps = MutableStateFlow(null) + val selectedConversationForOps: StateFlow = _selectedConversationForOps.asStateFlow() + + fun setSelectedConversationForOps(model: ConversationModel?) { + _selectedConversationForOps.value = model + } + + fun clearSelectedConversationForOpsWithDelay(delayMs: Long) { + viewModelScope.launch { + kotlinx.coroutines.delay(delayMs) + _selectedConversationForOps.value = null + } + } + private val hideRoomToken = MutableStateFlow(null) /** @@ -542,6 +569,49 @@ class ConversationsListViewModel @Inject constructor( (eventTimeStart - currentTimeStampInSeconds) > SIXTEEN_HOURS_IN_SECONDS } + fun resetReadUnreadState() { + _readUnreadState.value = ConversationReadUnreadUiState.None + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun markConversationAsRead(conversation: ConversationModel) { + val messageId = if (conversation.remoteServer.isNullOrEmpty()) conversation.lastMessage?.id?.toInt() else null + val apiVersion = ApiUtils.getChatApiVersion( + currentUser.capabilities?.spreedCapability!!, + intArrayOf(ApiUtils.API_V1) + ) + val url = ApiUtils.getUrlForChatReadMarker(apiVersion, currentUser.baseUrl, conversation.token) + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + withRetry(1) { ncApiCoroutines.setChatReadMarker(credentials, url, messageId) } + } + _readUnreadState.value = ConversationReadUnreadUiState.Success(conversation.displayName, true) + } catch (e: Exception) { + _readUnreadState.value = ConversationReadUnreadUiState.Error + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun markConversationAsUnread(conversation: ConversationModel) { + val apiVersion = ApiUtils.getChatApiVersion( + currentUser.capabilities?.spreedCapability!!, + intArrayOf(ApiUtils.API_V1) + ) + val url = ApiUtils.getUrlForChatReadMarker(apiVersion, currentUser.baseUrl, conversation.token) + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + withRetry(1) { ncApiCoroutines.markRoomAsUnread(credentials, url) } + } + _readUnreadState.value = ConversationReadUnreadUiState.Success(conversation.displayName, false) + } catch (e: Exception) { + _readUnreadState.value = ConversationReadUnreadUiState.Error + } + } + } + inner class FederatedInvitationsObserver : Observer { override fun onSubscribe(d: Disposable) { // unused atm diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt index 393e8b35123..7d056b47526 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt @@ -19,8 +19,8 @@ import com.nextcloud.talk.chat.data.ChatMessageRepository import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.chat.data.network.OfflineFirstChatRepository import com.nextcloud.talk.chat.data.network.RetrofitChatNetwork -import com.nextcloud.talk.chooseaccount.StatusRepository -import com.nextcloud.talk.chooseaccount.StatusRepositoryImplementation +import com.nextcloud.talk.chooseaccount.data.StatusRepository +import com.nextcloud.talk.chooseaccount.data.StatusRepositoryImplementation import com.nextcloud.talk.contacts.ContactsRepository import com.nextcloud.talk.contacts.ContactsRepositoryImpl import com.nextcloud.talk.conversationcreation.data.ConversationCreationRepository diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 4d03b920392..7ee7765dca5 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -12,7 +12,8 @@ import androidx.lifecycle.ViewModelProvider import com.nextcloud.talk.account.viewmodels.BrowserLoginActivityViewModel import com.nextcloud.talk.activities.CallViewModel import com.nextcloud.talk.chat.viewmodels.ScheduledMessagesViewModel -import com.nextcloud.talk.chooseaccount.StatusViewModel +import com.nextcloud.talk.chooseaccount.viewmodel.StatusMessageViewModel +import com.nextcloud.talk.chooseaccount.viewmodel.StatusViewModel import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel @@ -194,6 +195,11 @@ abstract class ViewModelModule { @ViewModelKey(StatusViewModel::class) abstract fun statusRepositoryViewModel(viewModel: StatusViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(StatusMessageViewModel::class) + abstract fun statusMessageViewModel(viewModel: StatusMessageViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(ScheduledMessagesViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt index 28354171bbf..e743d326e13 100644 --- a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt @@ -1,10 +1,7 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2023 Ezhil Shanmugham - * SPDX-FileCopyrightText: 2022 Andy Scherzinger - * SPDX-FileCopyrightText: 2022 Tim Krรผger - * SPDX-FileCopyrightText: 2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package com.nextcloud.talk.profile @@ -38,7 +35,7 @@ import com.nextcloud.talk.models.json.userprofile.Scope import com.nextcloud.talk.models.json.userprofile.UserProfileData import com.nextcloud.talk.models.json.userprofile.UserProfileFieldsOverall import com.nextcloud.talk.models.json.userprofile.UserProfileOverall -import com.nextcloud.talk.ui.dialog.ScopeDialog +import com.nextcloud.talk.ui.dialog.ScopeModalBottomSheet import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -73,6 +70,9 @@ class ProfileActivity : BaseActivity() { /** Single source of truth that drives the Compose UI. */ private var profileUiState by mutableStateOf(ProfileUiState()) + private data class ScopeSheetRequest(val position: Int, val field: Field) + private var scopeSheetRequest by mutableStateOf(null) + private val profileItems: MutableList = mutableListOf() private lateinit var pickImage: PickImage @@ -105,6 +105,10 @@ class ProfileActivity : BaseActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putBoolean(KEY_EDIT_MODE, profileUiState.isEditMode) + scopeSheetRequest?.let { req -> + outState.putInt(KEY_SCOPE_SHEET_POSITION, req.position) + outState.putString(KEY_SCOPE_SHEET_FIELD, req.field.name) + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -113,6 +117,14 @@ class ProfileActivity : BaseActivity() { val restoredEdit = savedInstanceState?.getBoolean(KEY_EDIT_MODE) ?: false profileUiState = profileUiState.copy(isEditMode = restoredEdit) + if (savedInstanceState != null && savedInstanceState.containsKey(KEY_SCOPE_SHEET_POSITION)) { + val position = savedInstanceState.getInt(KEY_SCOPE_SHEET_POSITION) + val fieldName = savedInstanceState.getString(KEY_SCOPE_SHEET_FIELD) + val field = fieldName?.let { name -> Field.entries.find { it.name == name } } + if (field != null) { + scopeSheetRequest = ScopeSheetRequest(position, field) + } + } val colorScheme = viewThemeUtils.getColorScheme(this) setContent { @@ -142,14 +154,17 @@ class ProfileActivity : BaseActivity() { profileItems.getOrNull(position)?.text = newText }, onScopeClick = { position, field -> - ScopeDialog( - con = this, - showPrivate = field != Field.DISPLAYNAME && field != Field.EMAIL, - onScopeSelected = { scope -> updateItemScope(position, scope) } - ).show() + scopeSheetRequest = ScopeSheetRequest(position, field) } ) ) + scopeSheetRequest?.let { req -> + ScopeModalBottomSheet( + showPrivate = req.field != Field.DISPLAYNAME && req.field != Field.EMAIL, + onScopeSelected = { scope -> updateItemScope(req.position, scope) }, + onDismiss = { scopeSheetRequest = null } + ) + } } } } @@ -703,5 +718,7 @@ class ProfileActivity : BaseActivity() { private val TAG = ProfileActivity::class.java.simpleName private const val DEFAULT_RETRIES: Long = 3 private const val KEY_EDIT_MODE = "edit_mode" + private const val KEY_SCOPE_SHEET_POSITION = "scope_sheet_position" + private const val KEY_SCOPE_SHEET_FIELD = "scope_sheet_field" } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/ConversationsListBottomDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/ConversationsListBottomDialog.kt deleted file mode 100644 index e4c567ddad3..00000000000 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/ConversationsListBottomDialog.kt +++ /dev/null @@ -1,459 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Marcel Hibbe - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.ui.dialog - -import android.annotation.SuppressLint -import android.content.Intent -import android.os.Bundle -import android.text.TextUtils -import android.view.View -import android.view.ViewGroup -import androidx.lifecycle.lifecycleScope -import androidx.work.Data -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkInfo -import androidx.work.WorkManager -import autodagger.AutoInjector -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.nextcloud.talk.R -import com.nextcloud.talk.activities.MainActivity -import com.nextcloud.talk.api.NcApi -import com.nextcloud.talk.api.NcApiCoroutines -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.conversation.RenameConversationDialogFragment -import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel -import com.nextcloud.talk.conversationlist.ConversationsListActivity -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.DialogConversationOperationsBinding -import com.nextcloud.talk.jobs.LeaveConversationWorker -import com.nextcloud.talk.models.domain.ConversationModel -import com.nextcloud.talk.models.json.generic.GenericOverall -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.CapabilitiesUtil -import com.nextcloud.talk.utils.ConversationUtils -import com.nextcloud.talk.utils.ShareUtils -import com.nextcloud.talk.utils.SpreedFeatures -import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID -import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld -import io.reactivex.Observer -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -@AutoInjector(NextcloudTalkApplication::class) -class ConversationsListBottomDialog( - val activity: ConversationsListActivity, - val currentUser: User, - val conversation: ConversationModel -) : BottomSheetDialog(activity) { - - private lateinit var binding: DialogConversationOperationsBinding - - @Inject - lateinit var ncApi: NcApi - - @Inject - lateinit var ncApiCoroutines: NcApiCoroutines - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - @Inject - lateinit var conversationInfoViewModel: ConversationInfoViewModel - - @Inject - lateinit var userManager: UserManager - - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - - lateinit var credentials: String - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) - - binding = DialogConversationOperationsBinding.inflate(layoutInflater) - setContentView(binding.root) - window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - - viewThemeUtils.material.colorBottomSheetBackground(binding.root) - viewThemeUtils.material.colorBottomSheetDragHandle(binding.bottomSheetDragHandle) - initHeaderDescription() - initItemsVisibility() - initClickListeners() - - credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! - } - - override fun onStart() { - super.onStart() - val bottomSheet = findViewById(R.id.design_bottom_sheet) - val behavior = BottomSheetBehavior.from(bottomSheet as View) - behavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - - private fun initHeaderDescription() { - if (!TextUtils.isEmpty(conversation.displayName)) { - binding.conversationOperationHeader.text = conversation.displayName - } else if (!TextUtils.isEmpty(conversation.name)) { - binding.conversationOperationHeader.text = conversation.name - } - } - - private fun initItemsVisibility() { - val hasFavoritesCapability = CapabilitiesUtil.hasSpreedFeatureCapability( - currentUser.capabilities?.spreedCapability!!, - SpreedFeatures.FAVORITES - ) - - binding.conversationRemoveFromFavorites.visibility = setVisibleIf( - hasFavoritesCapability && conversation.favorite - ) - binding.conversationAddToFavorites.visibility = setVisibleIf( - hasFavoritesCapability && !conversation.favorite - ) - - binding.conversationMarkAsRead.visibility = setVisibleIf( - conversation.unreadMessages > 0 && - CapabilitiesUtil.hasSpreedFeatureCapability( - currentUser.capabilities?.spreedCapability!!, - SpreedFeatures.CHAT_READ_MARKER - ) - ) - - binding.conversationMarkAsUnread.visibility = setVisibleIf( - conversation.unreadMessages <= 0 && - CapabilitiesUtil.hasSpreedFeatureCapability( - currentUser.capabilities?.spreedCapability!!, - SpreedFeatures.CHAT_UNREAD - ) - ) - - binding.conversationOperationRename.visibility = setVisibleIf( - ConversationUtils.isNameEditable(conversation, currentUser.capabilities!!.spreedCapability!!) - ) - binding.conversationLinkShare.visibility = setVisibleIf( - !ConversationUtils.isNoteToSelfConversation(conversation) - ) - - binding.conversationOperationDelete.visibility = setVisibleIf( - conversation.canDeleteConversation - ) - - binding.conversationOperationLeave.visibility = setVisibleIf( - conversation.canLeaveConversation - ) - } - - private fun setVisibleIf(boolean: Boolean): Int = - if (boolean) { - View.VISIBLE - } else { - View.GONE - } - - private fun initClickListeners() { - binding.conversationAddToFavorites.setOnClickListener { - addConversationToFavorites() - } - - binding.conversationRemoveFromFavorites.setOnClickListener { - removeConversationFromFavorites() - } - - binding.conversationMarkAsRead.setOnClickListener { - markConversationAsRead() - } - - binding.conversationMarkAsUnread.setOnClickListener { - markConversationAsUnread() - } - - binding.conversationLinkShare.setOnClickListener { - val canGeneratePrettyURL = CapabilitiesUtil.canGeneratePrettyURL(currentUser) - ShareUtils.shareConversationLink( - activity, - currentUser.baseUrl, - conversation.token, - conversation.name, - canGeneratePrettyURL - ) - dismiss() - } - - if (conversation.hasArchived) { - binding.conversationArchiveText.setText(R.string.unarchive_conversation) - binding.conversationArchiveIcon.setImageResource(R.drawable.ic_unarchive_24px) - } else { - binding.conversationArchiveText.setText(R.string.archive_conversation) - binding.conversationArchiveIcon.setImageResource(R.drawable.outline_archive_24) - } - - binding.conversationArchive.setOnClickListener { - handleArchiving() - } - - binding.conversationOperationRename.setOnClickListener { - renameConversation() - } - - binding.conversationOperationLeave.setOnClickListener { - leaveConversation() - } - - binding.conversationOperationDelete.setOnClickListener { - deleteConversation() - } - } - - private fun handleArchiving() { - val currentUser = currentUserProvider.currentUser.blockingGet() - val token = conversation.token - lifecycleScope.launch { - if (conversation.hasArchived) { - conversationInfoViewModel.unarchiveConversation(currentUser, token) - activity.showSnackbar( - String.format( - context.resources.getString(R.string.unarchived_conversation), - conversation.displayName - ) - ) - dismiss() - } else { - conversationInfoViewModel.archiveConversation(currentUser, token) - activity.showSnackbar( - String.format( - context.resources.getString(R.string.archived_conversation), - conversation.displayName - ) - ) - dismiss() - } - } - activity.fetchRooms() - } - - @Suppress("Detekt.TooGenericExceptionCaught") - @SuppressLint("StringFormatInvalid", "TooGenericExceptionCaught") - private fun addConversationToFavorites() { - val apiVersion = ApiUtils.getConversationApiVersion(currentUser, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) - val url = ApiUtils.getUrlForRoomFavorite(apiVersion, currentUser.baseUrl!!, conversation.token) - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - ncApiCoroutines.addConversationToFavorites(credentials, url) - } - activity.fetchRooms() - activity.showSnackbar( - String.format( - context.resources.getString(R.string.added_to_favorites), - conversation.displayName - ) - ) - dismiss() - } catch (e: Exception) { - withContext(Dispatchers.Main) { - activity.showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) - dismiss() - } - } - } - } - - @Suppress("Detekt.TooGenericExceptionCaught") - @SuppressLint("StringFormatInvalid", "TooGenericExceptionCaught") - private fun removeConversationFromFavorites() { - val apiVersion = ApiUtils.getConversationApiVersion(currentUser, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) - val url = ApiUtils.getUrlForRoomFavorite(apiVersion, currentUser.baseUrl!!, conversation.token) - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - ncApiCoroutines.removeConversationFromFavorites(credentials, url) - } - activity.fetchRooms() - activity.showSnackbar( - String.format( - context.resources.getString(R.string.removed_from_favorites), - conversation.displayName - ) - ) - dismiss() - } catch (e: Exception) { - withContext(Dispatchers.Main) { - activity.showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) - dismiss() - } - } - } - } - - private fun markConversationAsUnread() { - ncApi.markRoomAsUnread( - credentials, - ApiUtils.getUrlForChatReadMarker( - chatApiVersion(), - currentUser.baseUrl!!, - conversation.token!! - ) - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .retry(1) - .subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - @SuppressLint("StringFormatInvalid") - override fun onNext(genericOverall: GenericOverall) { - activity.fetchRooms() - activity.showSnackbar( - String.format( - context.resources.getString(R.string.marked_as_unread), - conversation.displayName - ) - ) - dismiss() - } - - override fun onError(e: Throwable) { - activity.showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) - dismiss() - } - - override fun onComplete() { - // unused atm - } - }) - } - - private fun markConversationAsRead() { - val messageId = if (conversation.remoteServer.isNullOrEmpty()) { - conversation.lastMessage?.id - } else { - null - } - - ncApi.setChatReadMarker( - credentials, - ApiUtils.getUrlForChatReadMarker( - chatApiVersion(), - currentUser.baseUrl!!, - conversation.token!! - ), - messageId?.toInt() - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .retry(1) - .subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - @SuppressLint("StringFormatInvalid") - override fun onNext(genericOverall: GenericOverall) { - activity.fetchRooms() - activity.showSnackbar( - String.format( - context.resources.getString(R.string.marked_as_read), - conversation.displayName - ) - ) - dismiss() - } - - override fun onError(e: Throwable) { - activity.showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) - dismiss() - } - - override fun onComplete() { - // unused atm - } - }) - } - - private fun renameConversation() { - if (!TextUtils.isEmpty(conversation.token)) { - dismiss() - val conversationDialog = RenameConversationDialogFragment.newInstance( - conversation.token!!, - conversation.displayName!! - ) - conversationDialog.show( - activity.supportFragmentManager, - TAG - ) - } - } - - @SuppressLint("StringFormatInvalid") - private fun leaveConversation() { - val dataBuilder = Data.Builder() - dataBuilder.putString(KEY_ROOM_TOKEN, conversation.token) - dataBuilder.putLong(KEY_INTERNAL_USER_ID, currentUser.id!!) - val data = dataBuilder.build() - - val leaveConversationWorker = - OneTimeWorkRequest.Builder(LeaveConversationWorker::class.java).setInputData( - data - ).build() - WorkManager.getInstance().enqueue(leaveConversationWorker) - - WorkManager.getInstance(context).getWorkInfoByIdLiveData(leaveConversationWorker.id) - .observeForever { workInfo: WorkInfo? -> - if (workInfo != null) { - when (workInfo.state) { - WorkInfo.State.SUCCEEDED -> { - activity.showSnackbar( - String.format( - context.resources.getString(R.string.left_conversation), - conversation.displayName - ) - ) - val intent = Intent(context, MainActivity::class.java) - context.startActivity(intent) - } - - WorkInfo.State.FAILED -> { - activity.showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) - } - - else -> { - } - } - } - } - - dismiss() - } - - private fun deleteConversation() { - if (!TextUtils.isEmpty(conversation.token)) { - activity.showDeleteConversationDialog(conversation) - } - - dismiss() - } - - private fun chatApiVersion(): Int = - ApiUtils.getChatApiVersion(currentUser.capabilities!!.spreedCapability!!, intArrayOf(ApiUtils.API_V1)) - - companion object { - val TAG = ConversationsListBottomDialog::class.simpleName - } -} diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/OnlineStatusBottomDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/OnlineStatusBottomDialogFragment.kt deleted file mode 100644 index fde1ba21d8a..00000000000 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/OnlineStatusBottomDialogFragment.kt +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2025 Sowjanya Kota - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.ui.dialog - -import android.content.res.Configuration -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.core.view.WindowInsetsControllerCompat -import autodagger.AutoInjector -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.card.MaterialCardView -import com.nextcloud.android.common.ui.theme.utils.ColorRole -import com.nextcloud.talk.R -import com.nextcloud.talk.api.NcApi -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.DialogSetOnlineStatusBinding -import com.nextcloud.talk.models.json.generic.GenericOverall -import com.nextcloud.talk.models.json.status.Status -import com.nextcloud.talk.models.json.status.StatusType -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld -import io.reactivex.Observer -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers -import java.util.Locale -import javax.inject.Inject - -@AutoInjector(NextcloudTalkApplication::class) -class OnlineStatusBottomDialogFragment : BottomSheetDialogFragment() { - private lateinit var binding: DialogSetOnlineStatusBinding - private var currentUser: User? = null - private var currentStatus: Status? = null - private val disposables: MutableList = ArrayList() - - @Inject lateinit var ncApi: NcApi - - @Inject lateinit var viewThemeUtils: ViewThemeUtils - - var currentUserProvider: CurrentUserProviderOld? = null - @Inject set - - lateinit var credentials: String - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - - arguments?.let { - currentUser = currentUserProvider?.currentUser?.blockingGet() - currentStatus = it.getParcelable(ARG_CURRENT_STATUS_PARAM) - credentials = ApiUtils.getCredentials(currentUser?.username, currentUser?.token)!! - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = DialogSetOnlineStatusBinding.inflate(inflater, container, false) - viewThemeUtils.platform.themeDialog(binding.root) - viewThemeUtils.material.themeDragHandleView(binding.dragHandle) - - dialog?.window?.let { window -> - window.navigationBarColor = ContextCompat.getColor(requireContext(), R.color.bg_default) - val inLightMode = resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK != Configuration.UI_MODE_NIGHT_YES - WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars = inLightMode - } - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - setupGeneralStatusOptions() - currentStatus?.let { visualizeStatus(it.status) } - } - - private fun setupGeneralStatusOptions() { - binding.onlineStatus.setOnClickListener { setStatus(StatusType.ONLINE) } - binding.dndStatus.setOnClickListener { setStatus(StatusType.DND) } - binding.busyStatus.setOnClickListener { setStatus(StatusType.BUSY) } - binding.awayStatus.setOnClickListener { setStatus(StatusType.AWAY) } - binding.invisibleStatus.setOnClickListener { setStatus(StatusType.INVISIBLE) } - - viewThemeUtils.talk.themeStatusCardView(binding.onlineStatus) - viewThemeUtils.talk.themeStatusCardView(binding.dndStatus) - viewThemeUtils.talk.themeStatusCardView(binding.busyStatus) - viewThemeUtils.talk.themeStatusCardView(binding.awayStatus) - viewThemeUtils.talk.themeStatusCardView(binding.invisibleStatus) - } - - private fun setStatus(statusType: StatusType) { - visualizeStatus(statusType) - - ncApi.setStatusType(credentials, ApiUtils.getUrlForSetStatusType(currentUser?.baseUrl!!), statusType.string) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - disposables.add(d) - } - override fun onNext(t: GenericOverall) { - dismiss() - } - override fun onError(e: Throwable) { - Log.e(TAG, "Failed to set statusType", e) - } - override fun onComplete() { - // unused atm - } - }) - } - - private fun visualizeStatus(statusType: String) { - StatusType.entries.firstOrNull { it.name == statusType.uppercase(Locale.ROOT) }?.let { visualizeStatus(it) } - } - - private fun visualizeStatus(statusType: StatusType) { - clearTopStatus() - val views: Triple = when (statusType) { - StatusType.ONLINE -> Triple(binding.onlineStatus, binding.onlineHeadline, binding.onlineIcon) - StatusType.BUSY -> Triple(binding.busyStatus, binding.busyHeadline, binding.busyIcon) - StatusType.AWAY -> Triple(binding.awayStatus, binding.awayHeadline, binding.awayIcon) - StatusType.DND -> Triple(binding.dndStatus, binding.dndHeadline, binding.dndIcon) - StatusType.INVISIBLE -> Triple(binding.invisibleStatus, binding.invisibleHeadline, binding.invisibleIcon) - else -> return - } - views.first.isChecked = true - viewThemeUtils.platform.colorTextView(views.second, ColorRole.ON_SECONDARY_CONTAINER) - } - - private fun clearTopStatus() { - context?.let { - binding.onlineHeadline.setTextColor(resources.getColor(R.color.high_emphasis_text, null)) - binding.awayHeadline.setTextColor(resources.getColor(R.color.high_emphasis_text, null)) - binding.dndHeadline.setTextColor(resources.getColor(R.color.high_emphasis_text, null)) - binding.busyHeadline.setTextColor(resources.getColor(R.color.high_emphasis_text, null)) - binding.invisibleHeadline.setTextColor(resources.getColor(R.color.high_emphasis_text, null)) - - binding.onlineIcon.imageTintList = null - binding.awayIcon.imageTintList = null - binding.dndIcon.imageTintList = null - binding.busyIcon.imageTintList = null - binding.invisibleIcon.imageTintList = null - - binding.onlineStatus.isChecked = false - binding.awayStatus.isChecked = false - binding.dndStatus.isChecked = false - binding.busyStatus.isChecked = false - binding.invisibleStatus.isChecked = false - } - } - - override fun onDestroyView() { - super.onDestroyView() - disposables.forEach { if (!it.isDisposed) it.dispose() } - disposables.clear() - } - - companion object { - private const val ARG_CURRENT_STATUS_PARAM = "currentStatus" - private val TAG = OnlineStatusBottomDialogFragment::class.simpleName - - @JvmStatic - fun newInstance(status: Status): OnlineStatusBottomDialogFragment { - val args = Bundle() - args.putParcelable(ARG_CURRENT_STATUS_PARAM, status) - - val fragment = OnlineStatusBottomDialogFragment() - fragment.arguments = args - return fragment - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/ScopeBottomSheet.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/ScopeBottomSheet.kt index edf479cdb04..a63ff6d5437 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/ScopeBottomSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/ScopeBottomSheet.kt @@ -10,20 +10,22 @@ import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,14 +38,30 @@ import com.nextcloud.talk.R import com.nextcloud.talk.models.json.userprofile.Scope @OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScopeModalBottomSheet(showPrivate: Boolean = true, onScopeSelected: (Scope) -> Unit, onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState() + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + ScopeBottomSheetContent( + showPrivate = showPrivate, + onScopeSelected = { scope -> + onScopeSelected(scope) + onDismiss() + } + ) + } +} + @Composable fun ScopeBottomSheetContent( modifier: Modifier = Modifier, showPrivate: Boolean = true, onScopeSelected: (Scope) -> Unit ) { - Column(modifier = modifier.fillMaxWidth().padding(bottom = 8.dp)) { - BottomSheetDefaults.DragHandle(modifier = Modifier.align(Alignment.CenterHorizontally)) + Column(modifier = modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) { if (showPrivate) { ScopeOption( iconRes = R.drawable.ic_cellphone, @@ -111,23 +129,18 @@ private fun ScopeOption( } } -@Preview(name = "Light", showBackground = true) -@Preview(name = "Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL ยท Arabic", locale = "ar") @Composable private fun PreviewScopeBottomSheet() { val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() MaterialTheme(colorScheme = colorScheme) { - Surface { - ScopeBottomSheetContent(onScopeSelected = {}) - } - } -} - -@Preview(name = "RTL ยท Arabic", showBackground = true, locale = "ar") -@Composable -private fun PreviewScopeBottomSheetRtl() { - MaterialTheme(colorScheme = lightColorScheme()) { - Surface { + ModalBottomSheet( + onDismissRequest = {}, + sheetState = rememberModalBottomSheetState() + ) { ScopeBottomSheetContent(onScopeSelected = {}) } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/ScopeDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/ScopeDialog.kt deleted file mode 100644 index 4defeb42cc6..00000000000 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/ScopeDialog.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2021 Tobias Kaminsky - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.ui.dialog - -import android.content.Context -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import autodagger.AutoInjector -import androidx.compose.material3.MaterialTheme -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.nextcloud.talk.R -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.models.json.userprofile.Scope -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import javax.inject.Inject - -@AutoInjector(NextcloudTalkApplication::class) -class ScopeDialog(con: Context, private val showPrivate: Boolean, private val onScopeSelected: (Scope) -> Unit) : - BottomSheetDialog(con) { - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) - - val colorScheme = viewThemeUtils.getColorScheme(context) - - val composeView = ComposeView(context).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - MaterialTheme(colorScheme = colorScheme) { - ScopeBottomSheetContent( - showPrivate = showPrivate, - onScopeSelected = { scope -> - onScopeSelected(scope) - dismiss() - } - ) - } - } - } - - setContentView(composeView) - window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - } - - override fun onStart() { - super.onStart() - val bottomSheet = findViewById(R.id.design_bottom_sheet) - val behavior = BottomSheetBehavior.from(bottomSheet as View) - behavior.state = BottomSheetBehavior.STATE_COLLAPSED - } -} diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt deleted file mode 100644 index 88107428773..00000000000 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt +++ /dev/null @@ -1,329 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Marcel Hibbe - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2017 Mario Danic - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.ui.dialog - -import android.app.Activity -import android.os.Bundle -import android.util.Log -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.LinearLayoutManager -import autodagger.AutoInjector -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayout.OnTabSelectedListener -import com.nextcloud.talk.R -import com.nextcloud.talk.adapters.ReactionItem -import com.nextcloud.talk.adapters.ReactionItemClickListener -import com.nextcloud.talk.adapters.ReactionsAdapter -import com.nextcloud.talk.api.NcApiCoroutines -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.chat.data.model.ChatMessage -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.DialogMessageReactionsBinding -import com.nextcloud.talk.databinding.ItemReactionsTabBinding -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.ApiUtils -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch -import java.util.Collections -import java.util.Locale -import javax.inject.Inject - -@AutoInjector(NextcloudTalkApplication::class) -class ShowReactionsDialog( - val activity: Activity, - private val roomToken: String, - private val chatMessage: ChatMessage, - private val user: User, - private val hasReactPermission: Boolean, - private val ncApiCoroutines: NcApiCoroutines -) : BottomSheetDialog(activity), - ReactionItemClickListener { - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - private lateinit var binding: DialogMessageReactionsBinding - - private var adapter: ReactionsAdapter? = null - - private val tagAll: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) - - binding = DialogMessageReactionsBinding.inflate(layoutInflater) - setContentView(binding.root) - window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - viewThemeUtils.material.colorBottomSheetBackground(binding.root) - viewThemeUtils.material.colorBottomSheetDragHandle(binding.bottomSheetDragHandle) - adapter = ReactionsAdapter(this, user) - binding.reactionsList.adapter = adapter - binding.reactionsList.layoutManager = LinearLayoutManager(context) - initEmojiReactions() - } - - override fun onStart() { - super.onStart() - val bottomSheet = findViewById(R.id.design_bottom_sheet) - val behavior = BottomSheetBehavior.from(bottomSheet as View) - behavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - - private fun initEmojiReactions() { - adapter?.list?.clear() - if (chatMessage.reactions != null && chatMessage.reactions!!.isNotEmpty()) { - var reactionsTotal = 0 - for ((emoji, amount) in chatMessage.reactions!!) { - reactionsTotal = reactionsTotal.plus(amount) - val tab: TabLayout.Tab = binding.emojiReactionsTabs.newTab() // Create a new Tab names "First Tab" - - val itemBinding = ItemReactionsTabBinding.inflate(layoutInflater) - itemBinding.reactionTab.tag = emoji - itemBinding.reactionIcon.text = emoji - itemBinding.reactionCount.text = String.format(Locale.getDefault(), "%d", amount) - tab.customView = itemBinding.root - - binding.emojiReactionsTabs.addTab(tab) - } - - val tab: TabLayout.Tab = binding.emojiReactionsTabs.newTab() // Create a new Tab names "First Tab" - - val itemBinding = ItemReactionsTabBinding.inflate(layoutInflater) - itemBinding.reactionTab.tag = tagAll - itemBinding.reactionIcon.text = context.getString(R.string.reactions_tab_all) - itemBinding.reactionCount.text = String.format(Locale.getDefault(), "%d", reactionsTotal) - tab.customView = itemBinding.root - - binding.emojiReactionsTabs.addTab(tab, 0) - - binding.emojiReactionsTabs.getTabAt(0)?.select() - - binding.emojiReactionsTabs.addOnTabSelectedListener(object : OnTabSelectedListener { - override fun onTabSelected(tab: TabLayout.Tab) { - // called when a tab is reselected - updateParticipantsForEmoji(chatMessage, tab.customView?.tag as String?) - } - - override fun onTabUnselected(tab: TabLayout.Tab) { - // called when a tab is reselected - } - - override fun onTabReselected(tab: TabLayout.Tab) { - // called when a tab is reselected - } - }) - - viewThemeUtils.material.themeTabLayoutOnSurface(binding.emojiReactionsTabs) - - updateParticipantsForEmoji(chatMessage, tagAll) - } - adapter?.notifyDataSetChanged() - } - - @Suppress("Detekt.TooGenericExceptionCaught") - private fun updateParticipantsForEmoji(chatMessage: ChatMessage, emoji: String?) { - adapter?.list?.clear() - - val credentials = ApiUtils.getCredentials(user.username, user.token) - - (activity as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope?.launch { - try { - val reactionsOverall = ncApiCoroutines.getReactions( - credentials, - ApiUtils.getUrlForMessageReaction( - baseUrl = user.baseUrl!!, - roomToken = roomToken, - messageId = chatMessage.jsonMessageId.toString() - ), - emoji - ) - val reactionVoters: ArrayList = ArrayList() - if (reactionsOverall.ocs?.data != null) { - val map = reactionsOverall.ocs?.data - for (key in map!!.keys) { - for (reactionVoter in reactionsOverall.ocs?.data!![key]!!) { - reactionVoters.add(ReactionItem(reactionVoter, key)) - } - } - Collections.sort(reactionVoters, ReactionComparator(user.userId)) - adapter?.list?.addAll(reactionVoters) - adapter?.notifyDataSetChanged() - } else { - Log.e(TAG, "no voters for this reaction") - } - } catch (e: Exception) { - Log.e(TAG, "failed to retrieve list of reaction voters") - } - } - } - - override fun onClick(reactionItem: ReactionItem) { - if (hasReactPermission && reactionItem.reactionVoter.actorId?.equals(user.userId) == true) { - deleteReaction(chatMessage, reactionItem.reaction!!) - dismiss() - } - } - - private fun deleteReaction(message: ChatMessage, emoji: String) { - val credentials = ApiUtils.getCredentials(user.username, user.token) - - (activity as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope?.launch { - try { - ncApiCoroutines.deleteReaction( - credentials, - ApiUtils.getUrlForMessageReaction( - baseUrl = user.baseUrl!!, - roomToken = roomToken, - messageId = message.jsonMessageId.toString() - ), - emoji - ) - Log.d(TAG, "deleted reaction: $emoji") - } catch (e: Exception) { - Log.e(TAG, "error while deleting reaction: $emoji") - } finally { - dismiss() - } - } - } - - companion object { - const val TAG = "ShowReactionsDialog" - } - - class ReactionComparator(val activeUser: String?) : Comparator { - @Suppress("ReturnCount") - override fun compare(reactionItem1: ReactionItem?, reactionItem2: ReactionItem?): Int { - // sort by emoji, own account, display-name, timestamp, actor-id - - if (reactionItem1 == null && reactionItem2 == null) { - return 0 - } - if (reactionItem1 == null) { - return -1 - } - if (reactionItem2 == null) { - return 1 - } - - // emoji - val reaction = StringComparator().compare(reactionItem1.reaction, reactionItem2.reaction) - if (reaction != 0) { - return reaction - } - - // own account - val ownAccount = compareOwnAccount( - activeUser, - reactionItem1.reactionVoter.actorId, - reactionItem2.reactionVoter.actorId - ) - - if (ownAccount != 0) { - return ownAccount - } - - // display-name - val displayName = StringComparator() - .compare( - reactionItem1.reactionVoter.actorDisplayName, - reactionItem2.reactionVoter.actorDisplayName - ) - - if (displayName != 0) { - return displayName - } - - // timestamp - val timestamp = LongComparator() - .compare( - reactionItem1.reactionVoter.timestamp, - reactionItem2.reactionVoter.timestamp - ) - - if (timestamp != 0) { - return timestamp - } - - // actor-id - val actorId = StringComparator() - .compare( - reactionItem1.reactionVoter.actorId, - reactionItem2.reactionVoter.actorId - ) - - if (actorId != 0) { - return actorId - } - - return 0 - } - - @Suppress("ReturnCount") - fun compareOwnAccount(activeUser: String?, actorId1: String?, actorId2: String?): Int { - val reactionVote1Active = activeUser == actorId1 - val reactionVote2Active = activeUser == actorId2 - - if (reactionVote1Active == reactionVote2Active) { - return 0 - } - - if (activeUser == null) { - return 0 - } - - if (reactionVote1Active) { - return 1 - } - if (reactionVote2Active) { - return -1 - } - - return 0 - } - - internal class StringComparator : Comparator { - @Suppress("ReturnCount") - override fun compare(obj1: String?, obj2: String?): Int { - if (obj1 === obj2) { - return 0 - } - if (obj1 == null) { - return -1 - } - return if (obj2 == null) { - 1 - } else { - obj1.lowercase().compareTo(obj2.lowercase()) - } - } - } - - internal class LongComparator : Comparator { - @Suppress("ReturnCount") - override fun compare(obj1: Long?, obj2: Long?): Int { - if (obj1 === obj2) { - return 0 - } - if (obj1 == null) { - return -1 - } - return if (obj2 == null) { - 1 - } else { - obj1.compareTo(obj2) - } - } - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/StatusMessageBottomDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/StatusMessageBottomDialogFragment.kt deleted file mode 100644 index 926d693e640..00000000000 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/StatusMessageBottomDialogFragment.kt +++ /dev/null @@ -1,600 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022-2023 Marcel Hibbe - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH - * SPDX-FileCopyrightText: 2020 Tobias Kaminsky - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.ui.dialog - -import android.annotation.SuppressLint -import android.content.Context -import android.content.res.Configuration -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.AdapterView -import android.widget.AdapterView.OnItemSelectedListener -import android.widget.ArrayAdapter -import androidx.core.content.ContextCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.recyclerview.widget.LinearLayoutManager -import autodagger.AutoInjector -import com.bluelinelabs.logansquare.LoganSquare -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.nextcloud.talk.R -import com.nextcloud.talk.adapters.PredefinedStatusClickListener -import com.nextcloud.talk.adapters.PredefinedStatusListAdapter -import com.nextcloud.talk.api.NcApi -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.DialogSetStatusMessageBinding -import com.nextcloud.talk.models.json.generic.GenericOverall -import com.nextcloud.talk.models.json.status.ClearAt -import com.nextcloud.talk.models.json.status.Status -import com.nextcloud.talk.models.json.status.StatusOverall -import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus -import com.nextcloud.talk.models.json.status.predefined.PredefinedStatusOverall -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.CapabilitiesUtil.isRestoreStatusAvailable -import com.nextcloud.talk.utils.DisplayUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld -import com.vanniktech.emoji.EmojiPopup -import com.vanniktech.emoji.installDisableKeyboardInput -import com.vanniktech.emoji.installForceSingleEmoji -import io.reactivex.Observer -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers -import okhttp3.ResponseBody -import retrofit2.HttpException -import java.util.Calendar -import java.util.Locale -import javax.inject.Inject - -private const val ARG_CURRENT_STATUS_PARAM = "currentStatus" - -private const val POS_DONT_CLEAR = 0 -private const val POS_FIFTEEN_MINUTES = 1 -private const val POS_HALF_AN_HOUR = 2 -private const val POS_AN_HOUR = 3 -private const val POS_FOUR_HOURS = 4 -private const val POS_TODAY = 5 -private const val POS_END_OF_WEEK = 6 - -private const val ONE_SECOND_IN_MILLIS = 1000 -private const val ONE_MINUTE_IN_SECONDS = 60 -private const val THIRTY_MINUTES = 30 -private const val FIFTEEN_MINUTES = 15 -private const val FOUR_HOURS = 4 -private const val LAST_HOUR_OF_DAY = 23 -private const val LAST_MINUTE_OF_HOUR = 59 -private const val LAST_SECOND_OF_MINUTE = 59 - -@AutoInjector(NextcloudTalkApplication::class) -class StatusMessageBottomDialogFragment : - BottomSheetDialogFragment(), - PredefinedStatusClickListener { - - private var selectedPredefinedStatus: PredefinedStatus? = null - - private lateinit var binding: DialogSetStatusMessageBinding - - private var currentUser: User? = null - private var currentStatus: Status? = null - private lateinit var backupStatus: Status - - val predefinedStatusesList = ArrayList() - - private val disposables: MutableList = ArrayList() - - private lateinit var adapter: PredefinedStatusListAdapter - private var clearAt: Long? = null - private lateinit var popup: EmojiPopup - private var isBackupStatusAvailable = false - - @Inject - lateinit var ncApi: NcApi - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - var currentUserProvider: CurrentUserProviderOld? = null - @Inject set - - lateinit var credentials: String - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - - arguments?.let { - currentUser = currentUserProvider?.currentUser?.blockingGet() - currentStatus = it.getParcelable(ARG_CURRENT_STATUS_PARAM) - - credentials = ApiUtils.getCredentials(currentUser?.username, currentUser?.token)!! - if (isRestoreStatusAvailable(currentUser!!)) { - checkBackupStatus() - } - fetchPredefinedStatuses() - } - } - - private fun fetchPredefinedStatuses() { - ncApi.getPredefinedStatuses(credentials, ApiUtils.getUrlForPredefinedStatuses(currentUser?.baseUrl!!)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - disposables.add(d) - } - - @SuppressLint("NotifyDataSetChanged") - override fun onNext(responseBody: ResponseBody) { - val predefinedStatusOverall: PredefinedStatusOverall = LoganSquare.parse( - responseBody.string(), - PredefinedStatusOverall::class.java - ) - predefinedStatusOverall.ocs?.data?.let { predefinedStatusesList.addAll(it) } - - if (currentStatus?.messageIsPredefined == true && currentStatus?.messageId?.isNotEmpty() == true) { - val messageId = currentStatus!!.messageId - selectedPredefinedStatus = predefinedStatusesList.firstOrNull { ps -> messageId == ps.id } - } - - adapter.notifyDataSetChanged() - } - - override fun onError(e: Throwable) { - Log.e(TAG, "Error while fetching predefined statuses", e) - } - - override fun onComplete() { - // unused atm - } - }) - } - - private fun checkBackupStatus() { - ncApi.backupStatus(credentials, ApiUtils.getUrlForBackupStatus(currentUser?.baseUrl!!, currentUser?.userId!!)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(object : Observer { - - override fun onSubscribe(d: Disposable) { - disposables.add(d) - } - - @SuppressLint("NotifyDataSetChanged") - override fun onNext(statusOverall: StatusOverall) { - if (statusOverall.ocs?.meta?.statusCode == HTTP_STATUS_CODE_OK) { - statusOverall.ocs?.data?.let { status -> - backupStatus = status - if (backupStatus.message != null) { - isBackupStatusAvailable = true - val backupPredefinedStatus = PredefinedStatus( - backupStatus.userId!!, - backupStatus.icon, - backupStatus.message!!, - ClearAt(type = "period", time = backupStatus.clearAt.toString()) - ) - binding.automaticStatus.visibility = View.VISIBLE - adapter.isBackupStatusAvailable = true - predefinedStatusesList.add(0, backupPredefinedStatus) - adapter.notifyDataSetChanged() - } - } - } - } - - override fun onError(e: Throwable) { - if (e is HttpException && e.code() == HTTP_STATUS_CODE_NOT_FOUND) { - Log.d(TAG, "User does not have a backup status set") - } else { - Log.e(TAG, "Error while getting user backup status", e) - } - } - - override fun onComplete() { - // unused atm - } - }) - } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = DialogSetStatusMessageBinding.inflate(inflater, container, false) - viewThemeUtils.platform.themeDialog(binding.root) - viewThemeUtils.material.themeDragHandleView(binding.dragHandle) - dialog?.window?.let { window -> - window.navigationBarColor = ContextCompat.getColor(requireContext(), R.color.bg_default) - val inLightMode = resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK != Configuration.UI_MODE_NIGHT_YES - WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars = inLightMode - } - return binding.root - } - - @SuppressLint("DefaultLocale") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupCurrentStatus() - - adapter = PredefinedStatusListAdapter(this, requireContext(), isBackupStatusAvailable) - adapter.list = predefinedStatusesList - - binding.predefinedStatusList.adapter = adapter - binding.predefinedStatusList.layoutManager = LinearLayoutManager(context) - - if (currentStatus?.icon == null) { - binding.emoji.setText(getString(R.string.default_emoji)) - } - - binding.clearStatus.setOnClickListener { clearStatus() } - binding.setStatus.setOnClickListener { setStatusMessage() } - binding.emoji.setOnClickListener { openEmojiPopup() } - popup = EmojiPopup( - rootView = view, - editText = binding.emoji, - onEmojiClickListener = { - popup.dismiss() - binding.emoji.clearFocus() - val imm: InputMethodManager = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as - InputMethodManager - imm.hideSoftInputFromWindow(binding.emoji.windowToken, 0) - } - ) - binding.emoji.installDisableKeyboardInput(popup) - binding.emoji.installForceSingleEmoji() - - binding.clearStatusAfterSpinner.apply { - this.adapter = createClearTimesArrayAdapter() - onItemSelectedListener = object : OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { - view?.let { - setClearStatusAfterValue(position) - } - } - override fun onNothingSelected(parent: AdapterView<*>?) { - // nothing to do - } - } - } - - viewThemeUtils.platform.themeDialog(binding.root) - - viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(binding.clearStatus) - viewThemeUtils.material.colorMaterialButtonPrimaryTonal(binding.setStatus) - - viewThemeUtils.material.colorTextInputLayout(binding.customStatusInputContainer) - } - - private fun clearStatus() { - val credentials = ApiUtils.getCredentials(currentUser?.username, currentUser?.token) - ncApi.statusDeleteMessage(credentials, ApiUtils.getUrlForStatusMessage(currentUser?.baseUrl!!)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()).subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - disposables.add(d) - } - - override fun onNext(statusOverall: GenericOverall) { - // unused atm - } - - override fun onError(e: Throwable) { - Log.e(TAG, "Failed to clear status", e) - } - - override fun onComplete() { - dismiss() - } - }) - } - - private fun setupCurrentStatus() { - currentStatus?.let { - binding.emoji.setText(it.icon) - binding.customStatusInput.text?.clear() - binding.customStatusInput.setText(it.message?.trim()) - - if (it.clearAt > 0) { - binding.clearStatusAfterSpinner.visibility = View.GONE - binding.remainingClearTime.apply { - binding.clearStatusMessageTextView.text = getString(R.string.clear_status_message) - visibility = View.VISIBLE - text = DisplayUtils.getRelativeTimestamp(context, it.clearAt * ONE_SECOND_IN_MILLIS, true) - .toString() - .decapitalize(Locale.getDefault()) - setOnClickListener { - visibility = View.GONE - binding.clearStatusAfterSpinner.visibility = View.VISIBLE - binding.clearStatusMessageTextView.text = getString(R.string.clear_status_message_after) - } - } - clearAt = it.clearAt - } else { - clearAt = null - } - } - } - - override fun revertStatus() { - if (isRestoreStatusAvailable(currentUser!!)) { - ncApi.revertStatus( - credentials, - ApiUtils.getUrlForRevertStatus(currentUser?.baseUrl!!, currentStatus?.messageId) - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(object : Observer { - - override fun onSubscribe(d: Disposable) { - disposables.add(d) - } - - @SuppressLint("NotifyDataSetChanged") - override fun onNext(genericOverall: GenericOverall) { - Log.d(TAG, "$genericOverall") - if (genericOverall.ocs?.meta?.statusCode == HTTP_STATUS_CODE_OK) { - binding.automaticStatus.visibility = View.GONE - adapter.isBackupStatusAvailable = false - predefinedStatusesList.removeAt(0) - adapter.notifyDataSetChanged() - currentStatus = backupStatus - setupCurrentStatus() - dismiss() - } - } - override fun onError(e: Throwable) { - Log.e(TAG, "Failed to revert user status", e) - } - - override fun onComplete() { - // unused atm - } - }) - } - } - - private fun createClearTimesArrayAdapter(): ArrayAdapter { - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - adapter.add(getString(R.string.dontClear)) - adapter.add(getString(R.string.fifteenMinutes)) - adapter.add(getString(R.string.thirtyMinutes)) - adapter.add(getString(R.string.oneHour)) - adapter.add(getString(R.string.fourHours)) - adapter.add(getString(R.string.today)) - adapter.add(getString(R.string.thisWeek)) - return adapter - } - - @Suppress("ComplexMethod") - private fun setClearStatusAfterValue(item: Int) { - val currentTime = System.currentTimeMillis() / ONE_SECOND_IN_MILLIS - - when (item) { - POS_DONT_CLEAR -> { - // don't clear - clearAt = null - } - - POS_FIFTEEN_MINUTES -> { - clearAt = currentTime + FIFTEEN_MINUTES * ONE_MINUTE_IN_SECONDS - } - - POS_HALF_AN_HOUR -> { - // 30 minutes - clearAt = currentTime + THIRTY_MINUTES * ONE_MINUTE_IN_SECONDS - } - - POS_AN_HOUR -> { - // one hour - clearAt = currentTime + ONE_MINUTE_IN_SECONDS * ONE_MINUTE_IN_SECONDS - } - - POS_FOUR_HOURS -> { - // four hours - clearAt = currentTime + FOUR_HOURS * ONE_MINUTE_IN_SECONDS * ONE_MINUTE_IN_SECONDS - } - - POS_TODAY -> { - // today - val date = Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, LAST_HOUR_OF_DAY) - set(Calendar.MINUTE, LAST_MINUTE_OF_HOUR) - set(Calendar.SECOND, LAST_SECOND_OF_MINUTE) - } - clearAt = date.timeInMillis / ONE_SECOND_IN_MILLIS - } - - POS_END_OF_WEEK -> { - // end of week - val date = Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, LAST_HOUR_OF_DAY) - set(Calendar.MINUTE, LAST_MINUTE_OF_HOUR) - set(Calendar.SECOND, LAST_SECOND_OF_MINUTE) - } - - while (date.get(Calendar.DAY_OF_WEEK) != Calendar.SUNDAY) { - date.add(Calendar.DAY_OF_YEAR, 1) - } - - clearAt = date.timeInMillis / ONE_SECOND_IN_MILLIS - } - } - } - - private fun clearAtToUnixTime(clearAt: ClearAt?): Long { - var returnValue = -1L - - if (clearAt != null) { - if (clearAt.type == "period") { - returnValue = System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + clearAt.time.toLong() - } else if (clearAt.type == "end-of") { - returnValue = clearAtToUnixTimeTypeEndOf(clearAt) - } - } - - return returnValue - } - - private fun clearAtToUnixTimeTypeEndOf(clearAt: ClearAt): Long { - var returnValue = -1L - if (clearAt.time == "day") { - val date = Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, LAST_HOUR_OF_DAY) - set(Calendar.MINUTE, LAST_MINUTE_OF_HOUR) - set(Calendar.SECOND, LAST_SECOND_OF_MINUTE) - } - returnValue = date.timeInMillis / ONE_SECOND_IN_MILLIS - } - return returnValue - } - - private fun openEmojiPopup() { - popup.show() - } - - private fun setStatusMessage() { - val inputText = binding.customStatusInput.text.toString().ifEmpty { "" } - // The endpoint '/message/custom' expects a valid emoji as string or null - val statusIcon = binding.emoji.text.toString().ifEmpty { null } - - if (selectedPredefinedStatus == null || - selectedPredefinedStatus!!.message != inputText || - selectedPredefinedStatus!!.icon != binding.emoji.text.toString() - ) { - ncApi.setCustomStatusMessage( - credentials, - ApiUtils.getUrlForSetCustomStatus(currentUser?.baseUrl!!), - statusIcon, - inputText, - clearAt - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(t: GenericOverall) { - Log.d(TAG, "CustomStatusMessage successfully set") - dismiss() - } - - override fun onError(e: Throwable) { - Log.e(TAG, "failed to set CustomStatusMessage", e) - } - - override fun onComplete() { - // unused atm - } - }) - } else { - ncApi.setPredefinedStatusMessage( - credentials, - ApiUtils.getUrlForSetPredefinedStatus(currentUser?.baseUrl!!), - selectedPredefinedStatus!!.id, - clearAt - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread())?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - disposables.add(d) - } - - override fun onNext(t: GenericOverall) { - Log.d(TAG, "PredefinedStatusMessage successfully set") - dismiss() - } - - override fun onError(e: Throwable) { - Log.e(TAG, "failed to set PredefinedStatusMessage", e) - } - - override fun onComplete() { - // unused atm - } - }) - } - } - - override fun onClick(predefinedStatus: PredefinedStatus) { - selectedPredefinedStatus = predefinedStatus - - clearAt = clearAtToUnixTime(predefinedStatus.clearAt) - binding.emoji.setText(predefinedStatus.icon) - binding.customStatusInput.text?.clear() - binding.customStatusInput.text?.append(predefinedStatus.message) - - binding.remainingClearTime.visibility = View.GONE - binding.clearStatusAfterSpinner.visibility = View.VISIBLE - binding.clearStatusMessageTextView.text = getString(R.string.clear_status_message_after) - - if (predefinedStatus.clearAt == null) { - binding.clearStatusAfterSpinner.setSelection(0) - } else { - setClearAt(predefinedStatus.clearAt!!) - } - setClearStatusAfterValue(binding.clearStatusAfterSpinner.selectedItemPosition) - } - - private fun setClearAt(clearAt: ClearAt) { - if (clearAt.type == "period") { - when (clearAt.time) { - "900" -> binding.clearStatusAfterSpinner.setSelection(POS_FIFTEEN_MINUTES) - "1800" -> binding.clearStatusAfterSpinner.setSelection(POS_HALF_AN_HOUR) - "3600" -> binding.clearStatusAfterSpinner.setSelection(POS_AN_HOUR) - "14400" -> binding.clearStatusAfterSpinner.setSelection(POS_FOUR_HOURS) - else -> binding.clearStatusAfterSpinner.setSelection(POS_DONT_CLEAR) - } - } else if (clearAt.type == "end-of") { - when (clearAt.time) { - "day" -> binding.clearStatusAfterSpinner.setSelection(POS_TODAY) - "week" -> binding.clearStatusAfterSpinner.setSelection(POS_END_OF_WEEK) - else -> binding.clearStatusAfterSpinner.setSelection(POS_DONT_CLEAR) - } - } - } - - private fun dispose() { - for (i in disposables.indices) { - if (!disposables[i].isDisposed) { - disposables[i].dispose() - } - } - } - - override fun onDestroy() { - dispose() - super.onDestroy() - } - - /** - * Fragment creator - */ - companion object { - private val TAG = StatusMessageBottomDialogFragment::class.simpleName - private const val HTTP_STATUS_CODE_OK = 200 - private const val HTTP_STATUS_CODE_NOT_FOUND = 404 - - @JvmStatic - fun newInstance(status: Status): StatusMessageBottomDialogFragment { - val args = Bundle() - args.putParcelable(ARG_CURRENT_STATUS_PARAM, status) - - val dialogFragment = StatusMessageBottomDialogFragment() - dialogFragment.arguments = args - return dialogFragment - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/CoroutineUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/CoroutineUtils.kt new file mode 100644 index 00000000000..c46382a4877 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/CoroutineUtils.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.utils + +/** + * Executes [block] and, if it throws, retries up to [retries] additional times. + * Equivalent to RxJava's `.retry(retries)`. + * The last exception is rethrown if all attempts fail. + */ +@Suppress("TooGenericExceptionCaught") +suspend fun withRetry(retries: Int = 1, block: suspend () -> T): T { + var attempt = 0 + while (true) { + try { + return block() + } catch (e: Exception) { + if (attempt >= retries) throw e + attempt++ + } + } +} diff --git a/app/src/main/res/layout/dialog_conversation_operations.xml b/app/src/main/res/layout/dialog_conversation_operations.xml deleted file mode 100644 index d8847227c0d..00000000000 --- a/app/src/main/res/layout/dialog_conversation_operations.xml +++ /dev/null @@ -1,325 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_message_reactions.xml b/app/src/main/res/layout/dialog_message_reactions.xml deleted file mode 100644 index 7a65b463b32..00000000000 --- a/app/src/main/res/layout/dialog_message_reactions.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_set_online_status.xml b/app/src/main/res/layout/dialog_set_online_status.xml deleted file mode 100644 index ea2498afe71..00000000000 --- a/app/src/main/res/layout/dialog_set_online_status.xml +++ /dev/null @@ -1,379 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_set_status_message.xml b/app/src/main/res/layout/dialog_set_status_message.xml deleted file mode 100644 index 84b3e168ed2..00000000000 --- a/app/src/main/res/layout/dialog_set_status_message.xml +++ /dev/null @@ -1,157 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_reactions_tab.xml b/app/src/main/res/layout/item_reactions_tab.xml deleted file mode 100644 index 3ef06f93649..00000000000 --- a/app/src/main/res/layout/item_reactions_tab.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/reaction_item.xml b/app/src/main/res/layout/reaction_item.xml deleted file mode 100644 index 443c0d452c1..00000000000 --- a/app/src/main/res/layout/reaction_item.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index c0435186423..b5d282c9b48 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -53,8 +53,6 @@ #4B4B4B - #818181 - #353535 #99FFFFFF diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 304826457d9..69816679830 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -83,8 +83,6 @@ #99121212 - #EEEEEE - #FFFFFF diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 641921c6c50..296d39943e3 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -55,9 +55,7 @@ 110dp 0dp - 48dp 4dp - 16sp 48dp 40dp 2dp diff --git a/app/src/test/java/com/nextcloud/talk/utils/CoroutineUtilsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/CoroutineUtilsTest.kt new file mode 100644 index 00000000000..983d4322a06 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/CoroutineUtilsTest.kt @@ -0,0 +1,79 @@ +๏ปฟ/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.utils + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +@Suppress("TooGenericExceptionThrown") +class CoroutineUtilsTest { + + @Test + fun `withRetry returns result on first success`() = + runTest { + var callCount = 0 + val result = withRetry(retries = 1) { + callCount++ + "success" + } + assertEquals("success", result) + assertEquals(1, callCount) + } + + @Test + fun `withRetry retries once and returns result on second attempt`() = + runTest { + var callCount = 0 + val result = withRetry(retries = 1) { + callCount++ + if (callCount < 2) throw RuntimeException("transient error") + "success after retry" + } + assertEquals("success after retry", result) + assertEquals(2, callCount) + } + + @Test(expected = RuntimeException::class) + fun `withRetry rethrows exception when all attempts fail`() = + runTest { + withRetry(retries = 1) { + throw RuntimeException("permanent error") + } + } + + @Test + fun `withRetry makes exactly retries plus one attempts before failing`() = + runTest { + var callCount = 0 + val expectedException = RuntimeException("permanent") + val thrownException = runCatching { + withRetry(retries = 2) { + callCount++ + throw expectedException + } + }.exceptionOrNull() + + assertEquals(3, callCount) + assertSame(expectedException, thrownException) + } + + @Test + fun `withRetry with zero retries does not retry`() = + runTest { + var callCount = 0 + runCatching { + withRetry(retries = 0) { + callCount++ + throw RuntimeException("error") + } + } + assertEquals(1, callCount) + } +} diff --git a/detekt.yml b/detekt.yml index a4246622606..b20ff256ed1 100644 --- a/detekt.yml +++ b/detekt.yml @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: GPL-3.0-or-later build: - maxIssues: 97 + maxIssues: 96 weights: # complexity: 2 # LongParameterList: 1 diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt index 2d5fbc7a1d6..24702d5bcb1 100644 --- a/scripts/analysis/lint-results.txt +++ b/scripts/analysis/lint-results.txt @@ -1,2 +1,2 @@ DO NOT TOUCH; GENERATED BY DRONE - Lint Report: 90 warnings + Lint Report: 88 warnings