From 9a043b0feaeb50a79a456be6a6b2745787b340ab Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Tue, 4 Nov 2025 11:20:14 -0600 Subject: [PATCH 1/2] Added Pinned Messages Support - ChatMessage now has ChatMessageMetaData - Conversation now has updated fields from server - Added PinnedMessageOptionsDialog - API, viewmodel, class functions Signed-off-by: rapterjet2004 --- .../com/nextcloud/talk/api/NcApiCoroutines.kt | 14 + .../com/nextcloud/talk/chat/ChatActivity.kt | 166 +++++++++++- .../talk/chat/data/ChatMessageRepository.kt | 7 + .../talk/chat/data/model/ChatMessage.kt | 18 +- .../data/network/ChatNetworkDataSource.kt | 5 + .../network/OfflineFirstChatRepository.kt | 30 +++ .../chat/data/network/RetrofitChatNetwork.kt | 9 + .../talk/chat/viewmodels/ChatViewModel.kt | 54 +++- .../database/mappers/ChatMessageMapUtils.kt | 25 +- .../database/mappers/ConversationMapUtils.kt | 12 +- .../data/database/model/ChatMessageEntity.kt | 7 +- .../data/database/model/ConversationEntity.kt | 2 + .../talk/data/source/local/TalkDatabase.kt | 5 +- .../talk/models/domain/ConversationModel.kt | 6 +- .../talk/models/json/chat/ChatMessageJson.kt | 3 +- .../models/json/chat/ChatMessageMetaData.kt | 23 ++ .../models/json/conversations/Conversation.kt | 8 +- .../EnumSystemMessageTypeConverter.kt | 10 +- .../nextcloud/talk/ui/ComposeChatAdapter.kt | 93 ++++--- .../talk/ui/dialog/MessageActionsDialog.kt | 28 ++ .../ui/dialog/PinnedMessageOptionsDialog.kt | 255 ++++++++++++++++++ .../java/com/nextcloud/talk/utils/ApiUtils.kt | 3 + .../nextcloud/talk/utils/CapabilitiesUtil.kt | 3 +- app/src/main/res/drawable-mdpi/keep_24px.xml | 17 ++ .../main/res/drawable/all_inclusive_24px.xml | 16 ++ app/src/main/res/drawable/keep_off_24px.xml | 17 ++ app/src/main/res/layout/activity_chat.xml | 58 +++- .../res/layout/dialog_message_actions.xml | 33 +++ app/src/main/res/values/strings.xml | 9 + 29 files changed, 855 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageMetaData.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/dialog/PinnedMessageOptionsDialog.kt create mode 100644 app/src/main/res/drawable-mdpi/keep_24px.xml create mode 100644 app/src/main/res/drawable/all_inclusive_24px.xml create mode 100644 app/src/main/res/drawable/keep_off_24px.xml 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 f3f5324787b..7081c403695 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -323,4 +323,18 @@ interface NcApiCoroutines { @GET suspend fun status(@Header("Authorization") authorization: String, @Url url: String): StatusOverall + + @FormUrlEncoded + @POST + suspend fun pinMessage( + @Header("Authorization") authorization: String, + @Url url: String, + @Field("pinUntil") pinUntil: Int + ): ChatOverallSingleMessage + + @DELETE + suspend fun unPinMessage(@Header("Authorization") authorization: String, @Url url: String): ChatOverallSingleMessage + + @DELETE + suspend fun hidePinnedMessage(@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 7b6d6850b5d..588c1dfd141 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -34,6 +34,7 @@ import android.provider.MediaStore import android.provider.Settings import android.text.SpannableStringBuilder import android.text.TextUtils +import android.text.format.DateFormat import android.util.Log import android.view.Gravity import android.view.Menu @@ -59,11 +60,33 @@ import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper import androidx.cardview.widget.CardView +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +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.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.content.FileProvider @@ -167,12 +190,14 @@ import com.nextcloud.talk.signaling.SignalingMessageReceiver import com.nextcloud.talk.signaling.SignalingMessageSender import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity import com.nextcloud.talk.translate.ui.TranslateActivity +import com.nextcloud.talk.ui.ComposeChatAdapter import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.ui.PlaybackSpeedControl import com.nextcloud.talk.ui.StatusDrawable import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet import com.nextcloud.talk.ui.dialog.DateTimeCompose 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 @@ -250,7 +275,7 @@ import java.util.concurrent.ExecutionException import javax.inject.Inject import kotlin.math.roundToInt -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass", "LongMethod") @AutoInjector(NextcloudTalkApplication::class) class ChatActivity : BaseActivity(), @@ -663,6 +688,27 @@ class ChatActivity : } chatViewModel.getCapabilities(conversationUser!!, roomToken, currentConversation!!) + + if (conversationModel.lastPinnedId != null && + conversationModel.lastPinnedId != 0L && + conversationModel.lastPinnedId != conversationModel.hiddenPinnedId + ) { + chatViewModel + .getIndividualMessageFromServer( + credentials!!, + conversationUser?.baseUrl!!, + roomToken, + conversationModel.lastPinnedId.toString() + ) + .collect { message -> + binding.pinnedMessageContainer.visibility = View.VISIBLE + binding.pinnedMessageComposeView.setContent { + PinnedMessageView(message) + } + } + } else { + binding.pinnedMessageContainer.visibility = View.GONE + } }.collect() } @@ -1130,6 +1176,10 @@ class ChatActivity : val item = adapter?.items?.get(index)?.item item?.let { setMessageAsEdited(item as ChatMessage, newString) + + if (item.jsonMessageId.toLong() == currentConversation?.lastPinnedId) { + chatViewModel.getRoom(roomToken) + } } } @@ -1313,6 +1363,94 @@ class ChatActivity : } } + @Composable + private fun PinnedMessageView(message: ChatMessage) { + message.incoming = true + val pinnedBy = stringResource(R.string.pinned_by) + message.actorDisplayName = "${message.actorDisplayName}\n$pinnedBy ${message.pinnedActorDisplayName}" + val scrollState = rememberScrollState() + + val outgoingBubbleColor = remember { + val colorInt = viewThemeUtils.talk + .getOutgoingMessageBubbleColor(context, message.isDeleted, false) + + Color(colorInt) + } + + val incomingBubbleColor = remember { + val colorInt = resources + .getColor(R.color.bg_message_list_incoming_bubble, null) + + Color(colorInt) + } + + val isAllowed = remember { + ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!) + } + + Column( + verticalArrangement = Arrangement.spacedBy((-16).dp), + modifier = Modifier + ) { + Box( + modifier = Modifier + .shadow(4.dp, shape = RoundedCornerShape(16.dp)) + .background(incomingBubbleColor, RoundedCornerShape(16.dp)) + .padding(16.dp) + .verticalScroll(scrollState) + ) { + ComposeChatAdapter().GetComposableForMessage(message) + } + + Row( + modifier = Modifier + .padding(start = 16.dp) + .background(outgoingBubbleColor, RoundedCornerShape(16.dp)) + .padding(16.dp) + ) { + val hiddenEye = painterResource(R.drawable.ic_eye_off) + Icon( + hiddenEye, + "Hide pin", + modifier = Modifier + .size(16.dp) + .clickable { + hidePinnedMessage(message) + } + ) + + if (isAllowed) { + Spacer(modifier = Modifier.size(16.dp)) + val read = painterResource(R.drawable.keep_off_24px) + Icon( + read, + "Unpin", + modifier = Modifier + .size(16.dp) + .clickable { + unPinMessage(message) + } + ) + } + + val pinnedUntilStr = stringResource(R.string.pinned_until) + val pinnedIndefinitely = stringResource(R.string.pinned_indefinitely) + val pinnedText = message.pinnedUntil?.let { + val format = if (DateFormat.is24HourFormat(context)) "EEE, HH:mm" else "EEE, hh:mm a" + val localDateTime = Instant.ofEpochMilli(it) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + + val timeString = localDateTime.format(DateTimeFormatter.ofPattern(format)) + + "$pinnedUntilStr $timeString" + } ?: pinnedIndefinitely + + Text(pinnedText, modifier = Modifier.padding(start = 16.dp)) + } + } + } + private fun removeUnreadMessagesMarker() { removeMessageById(UNREAD_MESSAGES_MARKER_ID.toString()) } @@ -3915,6 +4053,32 @@ class ChatActivity : } } + fun hidePinnedMessage(message: ChatMessage) { + val url = ApiUtils.getUrlForChatMessagePinning(chatApiVersion, conversationUser?.baseUrl, roomToken, message.id) + chatViewModel.hidePinnedMessage(credentials!!, url) + } + + fun pinMessage(message: ChatMessage) { + val url = ApiUtils.getUrlForChatMessagePinning(chatApiVersion, conversationUser?.baseUrl, roomToken, message.id) + binding.genericComposeView.apply { + val shouldDismiss = mutableStateOf(false) + setContent { + GetPinnedOptionsDialog(shouldDismiss, context, viewThemeUtils) { zonedDateTime -> + zonedDateTime?.let { + chatViewModel.pinMessage(credentials!!, url, pinUntil = zonedDateTime.toEpochSecond().toInt()) + } ?: chatViewModel.pinMessage(credentials!!, url) + + shouldDismiss.value = true + } + } + } + } + + fun unPinMessage(message: ChatMessage) { + val url = ApiUtils.getUrlForChatMessagePinning(chatApiVersion, conversationUser?.baseUrl, roomToken, message.id) + chatViewModel.unPinMessage(credentials!!, url) + } + fun markAsUnread(message: IMessage?) { val chatMessage = message as ChatMessage? if (chatMessage!!.previousMessageId > NO_PREVIOUS_MESSAGE_ID) { diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt index 2b4399fae84..313830bdd98 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt @@ -15,6 +15,7 @@ import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +@Suppress("TooManyFunctions") interface ChatMessageRepository : LifecycleAwareManager { /** @@ -116,4 +117,10 @@ interface ChatMessageRepository : LifecycleAwareManager { suspend fun sendUnsentChatMessages(credentials: String, url: String) suspend fun deleteTempMessage(chatMessage: ChatMessage) + + suspend fun pinMessage(credentials: String, url: String, pinUntil: Int): Flow + + suspend fun unPinMessage(credentials: String, url: String): Flow + + suspend fun hidePinnedMessage(credentials: String, url: String): Flow } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt index 9fc6d36cfaa..5ba103a8fd3 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt @@ -94,6 +94,8 @@ data class ChatMessage( var lastEditTimestamp: Long? = 0, + var incoming: Boolean = false, + var isDownloadingVoiceMessage: Boolean = false, var resetVoiceMessage: Boolean = false, @@ -130,7 +132,17 @@ data class ChatMessage( var sendStatus: SendStatus? = null, - var silent: Boolean = false + var silent: Boolean = false, + + var pinnedActorType: String? = null, + + var pinnedActorId: String? = null, + + var pinnedActorDisplayName: String? = null, + + var pinnedAt: Long? = null, + + var pinnedUntil: Long? = null ) : MessageContentType, MessageContentType.Image { @@ -433,7 +445,9 @@ data class ChatMessage( FEDERATED_USER_ADDED, FEDERATED_USER_REMOVED, PHONE_ADDED, - THREAD_CREATED + THREAD_CREATED, + MESSAGE_PINNED, + MESSAGE_UNPINNED } companion object { diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt index 5dcdfe3b618..0940ef2a7dc 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt @@ -79,4 +79,9 @@ interface ChatNetworkDataSource { ): List suspend fun getOpenGraph(credentials: String, baseUrl: String, extractedLinkToPreview: String): Reference? suspend fun unbindRoom(credentials: String, baseUrl: String, roomToken: String): GenericOverall + suspend fun pinMessage(credentials: String, url: String, pinUntil: Int): ChatOverallSingleMessage + + suspend fun unPinMessage(credentials: String, url: String): ChatOverallSingleMessage + + suspend fun hidePinnedMessage(credentials: String, url: String): GenericOverall } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt index c5bcd517795..f45d49ab6e0 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt @@ -1020,6 +1020,36 @@ class OfflineFirstChatRepository @Inject constructor( _removeMessageFlow.emit(chatMessage) } + override suspend fun pinMessage(credentials: String, url: String, pinUntil: Int): Flow = + flow { + runCatching { + val overall = network.pinMessage(credentials, url, pinUntil) + emit(overall.ocs?.data?.asModel()) + }.getOrElse { throwable -> + Log.e(TAG, "Error in pinMessage: $throwable") + } + } + + override suspend fun unPinMessage(credentials: String, url: String): Flow = + flow { + runCatching { + val overall = network.unPinMessage(credentials, url) + emit(overall.ocs?.data?.asModel()) + }.getOrElse { throwable -> + Log.e(TAG, "Error in unPinMessage: $throwable") + } + } + + override suspend fun hidePinnedMessage(credentials: String, url: String): Flow = + flow { + runCatching { + network.hidePinnedMessage(credentials, url) + emit(true) + }.getOrElse { throwable -> + Log.e(TAG, "Error in hidePinnedMessage: $throwable") + } + } + @Suppress("Detekt.TooGenericExceptionCaught") override suspend fun addTemporaryMessage( message: CharSequence, diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt index 6bb6836cafe..7cda46f0c72 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt @@ -222,4 +222,13 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines: val url = ApiUtils.getUrlForUnbindingRoom(baseUrl, roomToken) return ncApiCoroutines.unbindRoom(credentials, url) } + + override suspend fun pinMessage(credentials: String, url: String, pinUntil: Int): ChatOverallSingleMessage = + ncApiCoroutines.pinMessage(credentials, url, pinUntil) + + override suspend fun unPinMessage(credentials: String, url: String): ChatOverallSingleMessage = + ncApiCoroutines.unPinMessage(credentials, url) + + override suspend fun hidePinnedMessage(credentials: String, url: String): GenericOverall = + ncApiCoroutines.hidePinnedMessage(credentials, url) } 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 5d05189f57c..e53424fceba 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 @@ -27,6 +27,7 @@ import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel.Companion.FOLLOWED_THREADS_EXIST +import com.nextcloud.talk.data.database.mappers.asModel import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero import com.nextcloud.talk.jobs.UploadAndShareFilesWorker @@ -54,8 +55,6 @@ import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -802,11 +801,31 @@ class ChatViewModel @Inject constructor( emit(message.first()) } + fun getIndividualMessageFromServer( + credentials: String, + baseUrl: String, + token: String, + messageId: String + ): Flow = + flow { + val messages = chatNetworkDataSource.getContextForChatMessage( + credentials = credentials, + baseUrl = baseUrl, + token = token, + messageId = messageId, + limit = 1, + threadId = null + ) + + val message = messages[0] + emit(message.asModel()) + } + suspend fun getNumberOfThreadReplies(threadId: Long): Int = chatRepository.getNumberOfThreadReplies(threadId) fun setPlayBack(speed: PlaybackSpeed) { mediaPlayerManager.setPlayBackSpeed(speed) - CoroutineScope(Dispatchers.Default).launch { + viewModelScope.launch { _voiceMessagePlayBackUIFlow.emit(speed) } } @@ -953,7 +972,7 @@ class ChatViewModel @Inject constructor( } fun saveMessageDraft() { - CoroutineScope(Dispatchers.IO).launch { + viewModelScope.launch { val model = conversationRepository.getLocallyStoredConversation(chatRoomToken) model?.let { it.messageDraft = messageDraft @@ -962,6 +981,33 @@ class ChatViewModel @Inject constructor( } } + fun pinMessage(credentials: String, url: String, pinUntil: Int = 0) { + viewModelScope.launch { + chatRepository.pinMessage(credentials, url, pinUntil).collect { + // UI is updated from room change observer + getRoom(chatRoomToken) + } + } + } + + fun unPinMessage(credentials: String, url: String) { + viewModelScope.launch { + chatRepository.unPinMessage(credentials, url).collect { + // This updates the room if there are other pinned messages we need to show + + getRoom(chatRoomToken) + } + } + } + + fun hidePinnedMessage(credentials: String, url: String) { + viewModelScope.launch { + chatRepository.hidePinnedMessage(credentials, url).collect { + getRoom(chatRoomToken) + } + } + } + fun clearThreadTitle() { messageDraft.threadTitle = "" saveMessageDraft() diff --git a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt index 13697401ba4..c20bf42351c 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt @@ -7,9 +7,9 @@ package com.nextcloud.talk.data.database.mappers -import com.nextcloud.talk.models.json.chat.ChatMessageJson -import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.database.model.ChatMessageEntity +import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.chat.ReadStatus fun ChatMessageJson.asEntity(accountId: Long) = @@ -44,7 +44,12 @@ fun ChatMessageJson.asEntity(accountId: Long) = referenceId = referenceId, silent = silent, threadTitle = threadTitle, - threadReplies = threadReplies + threadReplies = threadReplies, + pinnedActorType = metaData?.pinnedActorType, + pinnedActorId = metaData?.pinnedActorId, + pinnedActorDisplayName = metaData?.pinnedActorDisplayName, + pinnedAt = metaData?.pinnedAt, + pinnedUntil = metaData?.pinnedUntil ) fun ChatMessageEntity.asModel() = @@ -78,7 +83,12 @@ fun ChatMessageEntity.asModel() = readStatus = ReadStatus.NONE, silent = silent, threadTitle = threadTitle, - threadReplies = threadReplies + threadReplies = threadReplies, + pinnedActorType = pinnedActorType, + pinnedActorId = pinnedActorId, + pinnedActorDisplayName = pinnedActorDisplayName, + pinnedAt = pinnedAt, + pinnedUntil = pinnedUntil ) fun ChatMessageJson.asModel() = @@ -109,5 +119,10 @@ fun ChatMessageJson.asModel() = referenceId = referenceId, silent = silent, threadTitle = threadTitle, - threadReplies = threadReplies + threadReplies = threadReplies, + pinnedActorType = metaData?.pinnedActorType, + pinnedActorId = metaData?.pinnedActorId, + pinnedActorDisplayName = metaData?.pinnedActorDisplayName, + pinnedAt = metaData?.pinnedAt, + pinnedUntil = metaData?.pinnedUntil ) diff --git a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt index 0953376f728..7dad703069c 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt @@ -64,7 +64,9 @@ fun ConversationModel.asEntity() = hasArchived = hasArchived, hasSensitive = hasSensitive, hasImportant = hasImportant, - messageDraft = messageDraft + messageDraft = messageDraft, + hiddenPinnedId = hiddenPinnedId, + lastPinnedId = lastPinnedId ) fun ConversationEntity.asModel() = @@ -119,7 +121,9 @@ fun ConversationEntity.asModel() = hasArchived = hasArchived, hasSensitive = hasSensitive, hasImportant = hasImportant, - messageDraft = messageDraft + messageDraft = messageDraft, + hiddenPinnedId = hiddenPinnedId, + lastPinnedId = lastPinnedId ) fun Conversation.asEntity(accountId: Long) = @@ -172,5 +176,7 @@ fun Conversation.asEntity(accountId: Long) = remoteToken = remoteToken, hasArchived = hasArchived, hasSensitive = hasSensitive, - hasImportant = hasImportant + hasImportant = hasImportant, + hiddenPinnedId = hiddenPinnedId, + lastPinnedId = lastPinnedId ) diff --git a/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt b/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt index 3fc2bb00b13..97175eb3443 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt @@ -70,5 +70,10 @@ data class ChatMessageEntity( @ColumnInfo(name = "systemMessage") var systemMessageType: ChatMessage.SystemMessageType, @ColumnInfo(name = "threadTitle") var threadTitle: String? = null, @ColumnInfo(name = "threadReplies") var threadReplies: Int? = 0, - @ColumnInfo(name = "timestamp") var timestamp: Long = 0 + @ColumnInfo(name = "timestamp") var timestamp: Long = 0, + @ColumnInfo(name = "pinnedActorType") var pinnedActorType: String? = null, + @ColumnInfo(name = "pinnedActorId") var pinnedActorId: String? = null, + @ColumnInfo(name = "pinnedActorDisplayName") var pinnedActorDisplayName: String? = null, + @ColumnInfo(name = "pinnedAt") var pinnedAt: Long? = null, + @ColumnInfo(name = "pinnedUntil") var pinnedUntil: Long? = null ) diff --git a/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt b/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt index 8301b8c17ff..bdc56663813 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt @@ -98,6 +98,8 @@ data class ConversationEntity( @ColumnInfo(name = "hasArchived") var hasArchived: Boolean = false, @ColumnInfo(name = "hasSensitive") var hasSensitive: Boolean = false, @ColumnInfo(name = "hasImportant") var hasImportant: Boolean = false, + @ColumnInfo(name = "hiddenPinnedId") var hiddenPinnedId: Long? = null, + @ColumnInfo(name = "lastPinnedId") var lastPinnedId: Long? = null, @ColumnInfo(name = "messageDraft") var messageDraft: MessageDraft? = MessageDraft() // missing/not needed: attendeeId // missing/not needed: attendeePin diff --git a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt index dff97d1e449..38b0d251598 100644 --- a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt +++ b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt @@ -48,12 +48,13 @@ import java.util.Locale ChatMessageEntity::class, ChatBlockEntity::class ], - version = 21, + version = 22, autoMigrations = [ AutoMigration(from = 9, to = 10), AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class), AutoMigration(from = 19, to = 20), - AutoMigration(from = 20, to = 21) + AutoMigration(from = 20, to = 21), + AutoMigration(from = 21, to = 22) ], exportSchema = true ) diff --git a/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt b/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt index 76e00f70cb5..3361030abfd 100644 --- a/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt +++ b/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt @@ -64,6 +64,8 @@ data class ConversationModel( var hasArchived: Boolean = false, var hasSensitive: Boolean = false, var hasImportant: Boolean = false, + var lastPinnedId: Long? = null, + var hiddenPinnedId: Long? = null, // attributes that don't come from API. This should be changed?! var password: String? = null, @@ -131,7 +133,9 @@ data class ConversationModel( remoteToken = conversation.remoteToken, hasArchived = conversation.hasArchived, hasSensitive = conversation.hasSensitive, - hasImportant = conversation.hasImportant + hasImportant = conversation.hasImportant, + lastPinnedId = conversation.lastPinnedId, + hiddenPinnedId = conversation.hiddenPinnedId ) } } diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageJson.kt b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageJson.kt index a845b2a6a3d..71c4afc65ce 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageJson.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageJson.kt @@ -52,5 +52,6 @@ data class ChatMessageJson( @JsonField(name = ["referenceId"]) var referenceId: String? = null, @JsonField(name = ["silent"]) var silent: Boolean = false, @JsonField(name = ["threadTitle"]) var threadTitle: String? = null, - @JsonField(name = ["threadReplies"]) var threadReplies: Int? = 0 + @JsonField(name = ["threadReplies"]) var threadReplies: Int? = 0, + @JsonField(name = ["metaData"]) var metaData: ChatMessageMetaData? = null ) : Parcelable diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageMetaData.kt b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageMetaData.kt new file mode 100644 index 00000000000..6ee2045992a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageMetaData.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Julius Linus + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.chat + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class ChatMessageMetaData( + @JsonField(name = ["pinnedActorType"]) var pinnedActorType: String? = null, + @JsonField(name = ["pinnedActorId"]) var pinnedActorId: String? = null, + @JsonField(name = ["pinnedActorDisplayName"]) var pinnedActorDisplayName: String? = null, + @JsonField(name = ["pinnedAt"]) var pinnedAt: Long? = null, + @JsonField(name = ["pinnedUntil"]) var pinnedUntil: Long? = null +) : Parcelable diff --git a/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt b/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt index c6750a2ec88..d32230cf76a 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt @@ -171,5 +171,11 @@ data class Conversation( var hasSensitive: Boolean = false, @JsonField(name = ["isImportant"]) - var hasImportant: Boolean = false + var hasImportant: Boolean = false, + + @JsonField(name = ["lastPinnedId"]) + var lastPinnedId: Long? = null, + + @JsonField(name = ["hiddenPinnedId"]) + var hiddenPinnedId: Long? = null ) : Parcelable diff --git a/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt b/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt index bb3e72c7121..a328d561e42 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt @@ -54,11 +54,14 @@ import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MATTERBR import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MESSAGE_DELETED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MESSAGE_EXPIRATION_DISABLED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MESSAGE_EXPIRATION_ENABLED +import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MESSAGE_PINNED +import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MESSAGE_UNPINNED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MODERATOR_DEMOTED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MODERATOR_PROMOTED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.OBJECT_SHARED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.PASSWORD_REMOVED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.PASSWORD_SET +import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.PHONE_ADDED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.POLL_CLOSED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.POLL_VOTED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.REACTION @@ -69,10 +72,9 @@ import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.READ_ONL import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.RECORDING_FAILED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.RECORDING_STARTED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.RECORDING_STOPPED +import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.THREAD_CREATED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.USER_ADDED import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.USER_REMOVED -import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.PHONE_ADDED -import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.THREAD_CREATED /* * see https://nextcloud-talk.readthedocs.io/en/latest/chat/#system-messages @@ -145,6 +147,8 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter FEDERATED_USER_REMOVED "phone_added" -> PHONE_ADDED "thread_created" -> THREAD_CREATED + "message_pinned" -> MESSAGE_PINNED + "message_unpinned" -> MESSAGE_UNPINNED else -> DUMMY } @@ -215,6 +219,8 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter "federated_user_removed" PHONE_ADDED -> "phone_added" THREAD_CREATED -> "thread_created" + MESSAGE_PINNED -> "message_pinned" + MESSAGE_UNPINNED -> "message_unpinned" else -> "" } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt b/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt index 35d054d516b..08565eaf653 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt @@ -71,6 +71,7 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -167,7 +168,7 @@ class ComposeChatAdapter( } } - inner class ComposeChatAdapterPreviewViewModel( + class ComposeChatAdapterPreviewViewModel( override val viewThemeUtils: ViewThemeUtils, override val messageUtils: MessageUtils, override val contactsViewModel: ContactsViewModel, @@ -239,6 +240,50 @@ class ComposeChatAdapter( if (append) items.addAll(processedMessages) else items.addAll(0, processedMessages) } + @Composable + fun GetComposableForMessage(message: ChatMessage, isBlinkingState: MutableState = mutableStateOf(false)) { + message.activeUser = currentUser + when (val type = message.getCalculateMessageType()) { + ChatMessage.MessageType.SYSTEM_MESSAGE -> { + if (!message.shouldFilter()) { + SystemMessage(message) + } + } + + ChatMessage.MessageType.VOICE_MESSAGE -> { + VoiceMessage(message, isBlinkingState) + } + + ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> { + ImageMessage(message, isBlinkingState) + } + + ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> { + GeolocationMessage(message, isBlinkingState) + } + + ChatMessage.MessageType.POLL_MESSAGE -> { + PollMessage(message, isBlinkingState) + } + + ChatMessage.MessageType.DECK_CARD -> { + DeckMessage(message, isBlinkingState) + } + + ChatMessage.MessageType.REGULAR_TEXT_MESSAGE -> { + if (message.isLinkPreview()) { + LinkMessage(message, isBlinkingState) + } else { + TextMessage(message, isBlinkingState) + } + } + + else -> { + Log.d(TAG, "Unknown message type: $type") + } + } + } + @OptIn(ExperimentalFoundationApi::class) @Composable fun GetView() { @@ -264,7 +309,7 @@ class ComposeChatAdapter( val dateString = formatTime(timestamp * LONG_1000) val color = Color(highEmphasisColorInt) val backgroundColor = - LocalContext.current.resources.getColor(R.color.bg_message_list_incoming_bubble, null) + LocalResources.current.getColor(R.color.bg_message_list_incoming_bubble, null) Row( horizontalArrangement = Arrangement.Absolute.Center, verticalAlignment = Alignment.CenterVertically @@ -290,46 +335,8 @@ class ComposeChatAdapter( } items(items) { message -> - message.activeUser = currentUser - when (val type = message.getCalculateMessageType()) { - ChatMessage.MessageType.SYSTEM_MESSAGE -> { - if (!message.shouldFilter()) { - SystemMessage(message) - } - } - - ChatMessage.MessageType.VOICE_MESSAGE -> { - VoiceMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> { - ImageMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> { - GeolocationMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.POLL_MESSAGE -> { - PollMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.DECK_CARD -> { - DeckMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.REGULAR_TEXT_MESSAGE -> { - if (message.isLinkPreview()) { - LinkMessage(message, isBlinkingState) - } else { - TextMessage(message, isBlinkingState) - } - } - - else -> { - Log.d(TAG, "Unknown message type: $type") - } - } + message.incoming = message.actorId != currentUser.userId + GetComposableForMessage(message, isBlinkingState) } } @@ -441,7 +448,7 @@ class ComposeChatAdapter( !containsLinebreak } - val incoming = message.actorId != currentUser.userId + val incoming = message.incoming val color = if (incoming) { if (message.isDeleted) { getColorFromTheme(LocalContext.current, R.color.bg_message_list_incoming_bubble_deleted) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt index 5b83042bf02..b57fcb6cf68 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt @@ -18,6 +18,7 @@ import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -167,6 +168,12 @@ class MessageActionsDialog( hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.REMIND_ME_LATER) && isOnline ) + initMenuPinMessage( + !message.isDeleted && + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.PINNED_MESSAGES) && + isOnline && + isUserAllowedToEdit + ) initMenuMarkAsUnread( message.previousMessageId > NO_PREVIOUS_MESSAGE_ID && ChatMessage.MessageType.SYSTEM_MESSAGE != message.getCalculateMessageType() && @@ -388,6 +395,27 @@ class MessageActionsDialog( dialogMessageActionsBinding.menuNotifyMessage.visibility = getVisibility(visible) } + private fun initMenuPinMessage(visible: Boolean) { + if (visible) { + dialogMessageActionsBinding.menuPinMessage.setOnClickListener { + if (currentConversation?.lastPinnedId == message.jsonMessageId.toLong()) { + chatActivity.unPinMessage(message) + } else { + chatActivity.pinMessage(message) + } + dismiss() + } + + if (currentConversation?.lastPinnedId == message.jsonMessageId.toLong()) { + dialogMessageActionsBinding.menuPinMessageText.text = context.getString(R.string.unpin_message) + val unpinnedDrawable = AppCompatResources.getDrawable(context, R.drawable.keep_off_24px) + dialogMessageActionsBinding.menuPinMessageIcon.setImageDrawable(unpinnedDrawable) + } + } + + dialogMessageActionsBinding.menuPinMessage.visibility = getVisibility(visible) + } + private fun initMenuDeleteMessage(visible: Boolean) { if (visible) { dialogMessageActionsBinding.menuDeleteMessage.setOnClickListener { diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/PinnedMessageOptionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/PinnedMessageOptionsDialog.kt new file mode 100644 index 00000000000..f9121acbc31 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/PinnedMessageOptionsDialog.kt @@ -0,0 +1,255 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Julius Linus + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.dialog + +import android.content.Context +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.nextcloud.talk.R +import com.nextcloud.talk.ui.theme.ViewThemeUtils +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +const val VAL_24 = 24L +const val VAL_7 = 7L +const val VAL_30 = 30L + +@Composable +fun GetPinnedOptionsDialog( + shouldDismiss: MutableState, + context: Context, + viewThemeUtils: ViewThemeUtils, + onPin: (zonedDateTime: ZonedDateTime?) -> Unit +) { + if (shouldDismiss.value) { + return + } + + val colorScheme = viewThemeUtils.getColorScheme(context) + + MaterialTheme(colorScheme = colorScheme) { + Dialog( + onDismissRequest = { + shouldDismiss.value = true + }, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + Surface( + shape = RoundedCornerShape(32.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .padding(16.dp) + ) { + PinMessageOptions(onPin) + } + } + } + } +} + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PinMessageOptions(onPin: (zonedDateTime: ZonedDateTime?) -> Unit, modifier: Modifier = Modifier) { + var showDatePickerDialog by remember { mutableStateOf(false) } + var showTimePickerDialog by remember { mutableStateOf(false) } + + var tempSelectedDate by remember { mutableStateOf(null) } + + // Helper to format dates for subtitles + val formatter = remember { DateTimeFormatter.ofPattern("MMM d, h:mm a") } + fun getReadableDateTime(dateTime: ZonedDateTime): String = dateTime.format(formatter) + + Column(modifier = modifier) { + val pinUntil24h = ZonedDateTime.now().plusHours(VAL_24) + TextButton( + onClick = { + onPin(pinUntil24h) + }, + content = { Text(stringResource(R.string.pin_24hr)) } + ) + + val pinUntil7d = ZonedDateTime.now().plusDays(VAL_7) + TextButton( + onClick = { + onPin(pinUntil7d) + }, + content = { Text(stringResource(R.string.pin_7_days)) } + ) + + val pinUntil30d = ZonedDateTime.now().plusDays(VAL_30) + TextButton( + onClick = { + Log.d("Julius", "Pinned: $pinUntil30d") + onPin(pinUntil30d) + }, + content = { Text(stringResource(R.string.pin_30_days)) } + ) + + TextButton( + onClick = { + onPin(null) + }, + content = { Text(stringResource(R.string.pin_indefinitely)) } + ) + + HorizontalDivider() + + TextButton( + onClick = { + showDatePickerDialog = true // Start the custom-pick flow + }, + content = { Text(stringResource(R.string.custom)) } + ) + } + + val minDateTime = ZonedDateTime.now().plusMinutes(1) + val initialDateTime = ZonedDateTime.now().plusHours(1) + + if (showDatePickerDialog) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = initialDateTime.toInstant().toEpochMilli(), + // Ensure user can't select a date in the past + selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + val minDate = minDateTime.truncatedTo(ChronoUnit.DAYS) + return utcTimeMillis >= minDate.toInstant().toEpochMilli() + } + } + ) + + DatePickerDialog( + onDismissRequest = {}, + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + // Save the selected date and show the time picker + tempSelectedDate = Instant.ofEpochMilli(millis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + showTimePickerDialog = true + } + } + ) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = {}) { Text("Cancel") } + }, + modifier = Modifier.background(MaterialTheme.colorScheme.surface) + ) { + DatePicker(state = datePickerState) + } + } + + if (showTimePickerDialog) { + val timePickerState = rememberTimePickerState( + initialHour = initialDateTime.hour, + initialMinute = initialDateTime.minute, + is24Hour = false // Or true, based on system settings + ) + + TimePickerDialog( + onDismissRequest = {}, + confirmButton = { + TextButton( + onClick = { + val selectedTime = LocalTime.of(timePickerState.hour, timePickerState.minute) + val finalDateTime = ZonedDateTime.of( + tempSelectedDate ?: LocalDate.now(), // Fallback, though should never be null here + selectedTime, + ZoneId.systemDefault() + ) + + // Final validation: check if it's after the minimum time + if (finalDateTime.isAfter(minDateTime)) { + onPin(finalDateTime) + } + } + ) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = {}) { Text("Cancel") } + } + ) { + TimePicker(state = timePickerState) + } + } +} + +@Composable +fun TimePickerDialog( + onDismissRequest: () -> Unit, + confirmButton: @Composable () -> Unit, + dismissButton: @Composable () -> Unit, + content: @Composable () -> Unit +) { + Dialog(onDismissRequest = onDismissRequest) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier.padding(PaddingValues(all = 16.dp)), + horizontalAlignment = Alignment.CenterHorizontally + ) { + content() + + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.align(Alignment.CenterEnd) + ) { + dismissButton() + confirmButton() + } + } + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt index 848316f8cab..17eeac9a9ce 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt @@ -245,6 +245,9 @@ object ApiUtils { fun getUrlForChatSharedItemsOverview(version: Int, baseUrl: String?, token: String): String = getUrlForChatSharedItems(version, baseUrl, token) + "/overview" + fun getUrlForChatMessagePinning(version: Int, baseUrl: String?, token: String, messageId: String): String = + "${getUrlForChatMessage(version, baseUrl, token, messageId)}/pin" + fun getUrlForSignaling(version: Int, baseUrl: String?): String = getUrlForApi(version, baseUrl) + "/signaling" fun getUrlForTestPushNotifications(baseUrl: String): String = diff --git a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt index 4eda16d2ab5..648d5e09138 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt @@ -61,7 +61,8 @@ enum class SpreedFeatures(val value: String) { UNBIND_CONVERSATION("unbind-conversation"), SENSITIVE_CONVERSATIONS("sensitive-conversations"), IMPORTANT_CONVERSATIONS("important-conversations"), - THREADS("threads") + THREADS("threads"), + PINNED_MESSAGES("pinned-messages") } @Suppress("TooManyFunctions") diff --git a/app/src/main/res/drawable-mdpi/keep_24px.xml b/app/src/main/res/drawable-mdpi/keep_24px.xml new file mode 100644 index 00000000000..9c214d9088f --- /dev/null +++ b/app/src/main/res/drawable-mdpi/keep_24px.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/all_inclusive_24px.xml b/app/src/main/res/drawable/all_inclusive_24px.xml new file mode 100644 index 00000000000..d39849d1c23 --- /dev/null +++ b/app/src/main/res/drawable/all_inclusive_24px.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/keep_off_24px.xml b/app/src/main/res/drawable/keep_off_24px.xml new file mode 100644 index 00000000000..1009a5f0141 --- /dev/null +++ b/app/src/main/res/drawable/keep_off_24px.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index c55d38edd9d..46fd52520c4 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -128,19 +128,6 @@ - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + Turn on background blur Turn off background blur Please continue login in browser + Pin message + Unpin message + Pin for 24 hours + Pin for 7 days + Pin for 30 days + Pin indefinitely + Pinned indefinitely + Pinned until + Pinned by From 27d4daa2e27ad8b9690f70fbab892395dbe094fa Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Mon, 24 Nov 2025 10:53:44 -0600 Subject: [PATCH 2/2] hardening code in case of error Signed-off-by: rapterjet2004 --- .../java/com/nextcloud/talk/chat/ChatActivity.kt | 12 +++++++----- .../nextcloud/talk/chat/viewmodels/ChatViewModel.kt | 10 +++++++--- 2 files changed, 14 insertions(+), 8 deletions(-) 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 588c1dfd141..6c85f54e0af 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -673,7 +673,7 @@ class ChatActivity : this.lifecycleScope.launch { chatViewModel.getConversationFlow - .onEach { conversationModel -> + .collect { conversationModel -> currentConversation = conversationModel chatViewModel.updateConversation( currentConversation!! @@ -701,15 +701,17 @@ class ChatActivity : conversationModel.lastPinnedId.toString() ) .collect { message -> - binding.pinnedMessageContainer.visibility = View.VISIBLE - binding.pinnedMessageComposeView.setContent { - PinnedMessageView(message) + message?.let { + binding.pinnedMessageContainer.visibility = View.VISIBLE + binding.pinnedMessageComposeView.setContent { + PinnedMessageView(message) + } } } } else { binding.pinnedMessageContainer.visibility = View.GONE } - }.collect() + } } chatViewModel.getRoomViewState.observe(this) { state -> 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 e53424fceba..e09f9da97c2 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 @@ -806,7 +806,7 @@ class ChatViewModel @Inject constructor( baseUrl: String, token: String, messageId: String - ): Flow = + ): Flow = flow { val messages = chatNetworkDataSource.getContextForChatMessage( credentials = credentials, @@ -817,8 +817,12 @@ class ChatViewModel @Inject constructor( threadId = null ) - val message = messages[0] - emit(message.asModel()) + if (messages.isNotEmpty()) { + val message = messages[0] + emit(message.asModel()) + } else { + emit(null) + } } suspend fun getNumberOfThreadReplies(threadId: Long): Int = chatRepository.getNumberOfThreadReplies(threadId)