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 90f20bce462..e0a7fd2e6cc 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,37 @@ 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.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +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.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton 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 +194,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 +279,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(), @@ -655,7 +684,7 @@ class ChatActivity : this.lifecycleScope.launch { chatViewModel.getConversationFlow - .onEach { conversationModel -> + .collect { conversationModel -> currentConversation = conversationModel chatViewModel.updateConversation( currentConversation!! @@ -670,7 +699,30 @@ class ChatActivity : } chatViewModel.getCapabilities(conversationUser!!, roomToken, currentConversation!!) - }.collect() + + if (conversationModel.lastPinnedId != null && + conversationModel.lastPinnedId != 0L && + conversationModel.lastPinnedId != conversationModel.hiddenPinnedId + ) { + chatViewModel + .getIndividualMessageFromServer( + credentials!!, + conversationUser?.baseUrl!!, + roomToken, + conversationModel.lastPinnedId.toString() + ) + .collect { message -> + message?.let { + binding.pinnedMessageContainer.visibility = View.VISIBLE + binding.pinnedMessageComposeView.setContent { + PinnedMessageView(message) + } + } + } + } else { + binding.pinnedMessageContainer.visibility = View.GONE + } + } } chatViewModel.getRoomViewState.observe(this) { state -> @@ -1137,6 +1189,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) + } } } @@ -1320,6 +1376,156 @@ class ChatActivity : } } + @Composable + private fun PinnedMessageView(message: ChatMessage) { + message.incoming = true + + val pinnedBy = stringResource(R.string.pinned_by) + + // FIXME this causes some problems with duplicate some times + message.actorDisplayName = remember(message.pinnedActorDisplayName) { + "${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 canPin = remember { + message.isOneToOneConversation || + 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) + } + + var expanded by remember { mutableStateOf(false) } + + val pinnedText = remember(message.pinnedUntil) { + val pinnedUntilStr = context.getString(R.string.pinned_until) + val pinnedIndefinitely = context.getString(R.string.pinned_indefinitely) + + message.pinnedUntil?.let { + val format = if (DateFormat.is24HourFormat(context)) { + "MMM dd yyyy, HH:mm" + } else { + "MMM dd yyyy, hh:mm a" + } + + val localDateTime = Instant.ofEpochSecond(it) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + + val timeString = localDateTime.format(DateTimeFormatter.ofPattern(format)) + + "$pinnedUntilStr $timeString" + } ?: pinnedIndefinitely + } + + Box( + modifier = Modifier + .offset(16.dp, 0.dp) + .background(outgoingBubbleColor, RoundedCornerShape(16.dp)) + ) { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Default.Menu, // Or use a Pin icon here + contentDescription = "Pinned Message Options" + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(outgoingBubbleColor) + ) { + DropdownMenuItem( + text = { + Text( + text = pinnedText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = { /* No-op or toggle expansion */ }, + enabled = false // Visually distinct as information, not action + ) + + Divider() + + DropdownMenuItem( + text = { Text("Go to message") }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.baseline_chat_bubble_outline_24), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + onClick = { + expanded = false + scrollToMessageWithId(message.id) + } + ) + + DropdownMenuItem( + text = { Text("Dismiss") }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.ic_eye_off), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + onClick = { + expanded = false + hidePinnedMessage(message) + } + ) + + if (canPin) { + DropdownMenuItem( + text = { Text("Unpin") }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.keep_off_24px), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + onClick = { + expanded = false + unPinMessage(message) + } + ) + } + } + } + } + } + private fun removeUnreadMessagesMarker() { removeMessageById(UNREAD_MESSAGES_MARKER_ID.toString()) } @@ -1419,10 +1625,7 @@ class ChatActivity : cancelNotificationsForCurrentConversation() - chatViewModel.getRoom( - conversationUser, - roomToken - ) + chatViewModel.getRoom(roomToken) actionBar?.show() @@ -1901,10 +2104,7 @@ class ChatActivity : } getRoomInfoTimerHandler?.postDelayed( { - chatViewModel.getRoom( - conversationUser, - roomToken - ) + chatViewModel.getRoom(roomToken) }, if (delay > 0) delay else delayForRecursiveCall ) @@ -3638,6 +3838,10 @@ class ChatActivity : SharedItemsActivity.KEY_USER_IS_OWNER_OR_MODERATOR, ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!) ) + intent.putExtra( + SharedItemsActivity.KEY_IS_ONE_2_ONE, + currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + ) startActivity(intent) } @@ -3931,6 +4135,32 @@ class ChatActivity : } } + fun hidePinnedMessage(message: ChatMessage) { + val url = ApiUtils.getUrlForChatMessageHiding(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 31ab7cb3854..1bedd025f9f 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 @@ -16,6 +16,7 @@ import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +@Suppress("TooManyFunctions") interface ChatMessageRepository : LifecycleAwareManager { /** @@ -117,4 +118,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 11fb8e44975..3b5b99d8bb0 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 @@ -1026,6 +1026,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 1ab528e5034..0898b283f60 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 @@ -305,9 +304,9 @@ class ChatViewModel @Inject constructor( chatRepository.updateConversation(currentConversation) } - fun getRoom(user: User, token: String) { + fun getRoom(token: String) { _getRoomViewState.value = GetRoomStartState - conversationRepository.getRoom(user, token) + conversationRepository.getRoom(currentUser, token) } fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) { @@ -839,11 +838,35 @@ 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 + ) + + if (messages.isNotEmpty()) { + val message = messages[0] + emit(message.asModel()) + } else { + emit(null) + } + } + 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) } } @@ -993,7 +1016,7 @@ class ChatViewModel @Inject constructor( } fun saveMessageDraft() { - CoroutineScope(Dispatchers.IO).launch { + viewModelScope.launch { val model = conversationRepository.getLocallyStoredConversation( currentUser, chatRoomToken @@ -1005,6 +1028,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/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index 2cd4e29b5af..d358dbd059f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -599,6 +599,10 @@ class ConversationInfoActivity : SharedItemsActivity.KEY_USER_IS_OWNER_OR_MODERATOR, ConversationUtils.isParticipantOwnerOrModerator(conversation!!) ) + intent.putExtra( + SharedItemsActivity.KEY_IS_ONE_2_ONE, + conversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + ) startActivity(intent) } 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/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index 472dddaa9b5..849256fa682 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -22,6 +22,7 @@ import com.google.android.material.tabs.TabLayout import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivitySharedItemsBinding import com.nextcloud.talk.shareditems.adapters.SharedItemsAdapter @@ -37,6 +38,9 @@ class SharedItemsActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory + @Inject + lateinit var chatViewModel: ChatViewModel + private lateinit var binding: ActivitySharedItemsBinding private lateinit var viewModel: SharedItemsViewModel @@ -50,6 +54,7 @@ class SharedItemsActivity : BaseActivity() { val user = currentUserProviderOld.currentUser.blockingGet() val isUserConversationOwnerOrModerator = intent.getBooleanExtra(KEY_USER_IS_OWNER_OR_MODERATOR, false) + val isOne2One = intent.getBooleanExtra(KEY_IS_ONE_2_ONE, false) binding = ActivitySharedItemsBinding.inflate(layoutInflater) setSupportActionBar(binding.sharedItemsToolbar) @@ -66,7 +71,7 @@ class SharedItemsActivity : BaseActivity() { viewModel = ViewModelProvider(this, viewModelFactory)[SharedItemsViewModel::class.java] viewModel.viewState.observe(this) { state -> - handleModelChange(state, user, roomToken, isUserConversationOwnerOrModerator) + handleModelChange(state, user, roomToken, isUserConversationOwnerOrModerator, isOne2One) } binding.imageRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { @@ -85,7 +90,8 @@ class SharedItemsActivity : BaseActivity() { state: SharedItemsViewModel.ViewState?, user: User, roomToken: String, - isUserConversationOwnerOrModerator: Boolean + isUserConversationOwnerOrModerator: Boolean, + isOne2One: Boolean ) { clearEmptyLoading() when (state) { @@ -111,9 +117,10 @@ class SharedItemsActivity : BaseActivity() { user, roomToken, isUserConversationOwnerOrModerator, + isOne2One, viewThemeUtils ).apply { - items = sharedMediaItems.items + items = sharedMediaItems.items.toMutableList() } binding.imageRecycler.adapter = adapter binding.imageRecycler.layoutManager = layoutManager @@ -188,6 +195,13 @@ class SharedItemsActivity : BaseActivity() { binding.sharedItemsTabs.addTab(tabVoice) } + if (sharedItemTypes.contains(SharedItemType.PINNED)) { + val tabPinned: TabLayout.Tab = binding.sharedItemsTabs.newTab() + tabPinned.tag = SharedItemType.PINNED + tabPinned.setText(R.string.pinned) + binding.sharedItemsTabs.addTab(tabPinned) + } + if (sharedItemTypes.contains(SharedItemType.LOCATION)) { val tabLocation: TabLayout.Tab = binding.sharedItemsTabs.newTab() tabLocation.tag = SharedItemType.LOCATION @@ -232,5 +246,6 @@ class SharedItemsActivity : BaseActivity() { private val TAG = SharedItemsActivity::class.simpleName const val SPAN_COUNT: Int = 4 const val KEY_USER_IS_OWNER_OR_MODERATOR = "userIsOwnerOrModerator" + const val KEY_IS_ONE_2_ONE = "KEY_IS_ONE_2_ONE" } } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt index f80bb85401c..5c4da103628 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt @@ -21,18 +21,22 @@ import com.nextcloud.talk.shareditems.model.SharedFileItem import com.nextcloud.talk.shareditems.model.SharedItem import com.nextcloud.talk.shareditems.model.SharedLocationItem import com.nextcloud.talk.shareditems.model.SharedOtherItem +import com.nextcloud.talk.shareditems.model.SharedPinnedItem import com.nextcloud.talk.shareditems.model.SharedPollItem import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.utils.ApiUtils +import java.util.Collections.emptyList class SharedItemsAdapter( private val showGrid: Boolean, private val user: User, private val roomToken: String, private val isUserConversationOwnerOrModerator: Boolean, + private val isOne2One: Boolean, private val viewThemeUtils: ViewThemeUtils ) : RecyclerView.Adapter() { - var items: List = emptyList() + var items: MutableList = emptyList() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SharedItemsViewHolder = if (showGrid) { @@ -64,6 +68,7 @@ class SharedItemsAdapter( is SharedLocationItem -> holder.onBind(item) is SharedOtherItem -> holder.onBind(item) is SharedDeckCardItem -> holder.onBind(item) + is SharedPinnedItem -> holder.onBind(item, ::unpinMessage) } } @@ -83,6 +88,21 @@ class SharedItemsAdapter( ) } + private fun unpinMessage(item: SharedItem, context: Context) { + val credentials = ApiUtils.getCredentials(user.username, user.token) + val url = ApiUtils.getUrlForChatMessagePinning(1, user.baseUrl, roomToken, item.id) + + val canPin = isOne2One || isUserConversationOwnerOrModerator + if (canPin) { + credentials?.let { + (context as SharedItemsActivity).chatViewModel.unPinMessage(credentials, url) + val index = items.indexOf(item) + items.remove(item) + this.notifyItemRemoved(index) + } + } + } + companion object { private val TAG = SharedItemsAdapter::class.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt index 232b0813cbd..abd7693b03e 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt @@ -23,6 +23,7 @@ import com.nextcloud.talk.shareditems.model.SharedFileItem import com.nextcloud.talk.shareditems.model.SharedItem import com.nextcloud.talk.shareditems.model.SharedLocationItem import com.nextcloud.talk.shareditems.model.SharedOtherItem +import com.nextcloud.talk.shareditems.model.SharedPinnedItem import com.nextcloud.talk.shareditems.model.SharedPollItem import com.nextcloud.talk.ui.theme.ViewThemeUtils @@ -127,4 +128,23 @@ class SharedItemsListViewHolder( it.context.startActivity(browserIntent) } } + + override fun onBind(item: SharedPinnedItem, unPinMessage: (item: SharedItem, context: Context) -> Unit) { + super.onBind(item, unPinMessage) + binding.fileName.text = item.name // actually the message of the chat item + binding.fileSize.visibility = View.GONE + binding.separator1.visibility = View.GONE + binding.fileDate.text = item.dateTime + binding.actor.text = item.actorName + + image.load(R.drawable.keep_off_24px) + image.setColorFilter( + ContextCompat.getColor(image.context, R.color.high_emphasis_menu_icon), + android.graphics.PorterDuff.Mode.SRC_IN + ) + + clickTarget.setOnClickListener { + unPinMessage(item, it.context) + } + } } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt index 75628caef96..18cc56b93d5 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt @@ -20,6 +20,7 @@ import com.nextcloud.talk.shareditems.model.SharedFileItem import com.nextcloud.talk.shareditems.model.SharedItem import com.nextcloud.talk.shareditems.model.SharedLocationItem import com.nextcloud.talk.shareditems.model.SharedOtherItem +import com.nextcloud.talk.shareditems.model.SharedPinnedItem import com.nextcloud.talk.shareditems.model.SharedPollItem import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.FileViewerUtils @@ -89,4 +90,6 @@ abstract class SharedItemsViewHolder( open fun onBind(item: SharedOtherItem) {} open fun onBind(item: SharedDeckCardItem) {} + + open fun onBind(item: SharedPinnedItem, unPinMessage: (item: SharedItem, context: Context) -> Unit) {} } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedItemType.kt b/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedItemType.kt index 2d900368d2d..44938f41c57 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedItemType.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedItemType.kt @@ -20,6 +20,7 @@ enum class SharedItemType { LOCATION, DECKCARD, OTHER, + PINNED, POLL; companion object { diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedPinnedItem.kt b/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedPinnedItem.kt new file mode 100644 index 00000000000..7df70c862b2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedPinnedItem.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Julius Linus + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.shareditems.model + +data class SharedPinnedItem( + override val id: String, + override val name: String, + override val actorId: String, + override val actorName: String, + override val dateTime: String +) : SharedItem diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt index 333520cf0a4..01eed88f83b 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt @@ -20,6 +20,7 @@ import com.nextcloud.talk.shareditems.model.SharedItemType import com.nextcloud.talk.shareditems.model.SharedItems import com.nextcloud.talk.shareditems.model.SharedLocationItem import com.nextcloud.talk.shareditems.model.SharedOtherItem +import com.nextcloud.talk.shareditems.model.SharedPinnedItem import com.nextcloud.talk.shareditems.model.SharedPollItem import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DateConstants @@ -66,13 +67,27 @@ class SharedItemsRepositoryImpl @Inject constructor(private val ncApi: NcApi, pr val mediaItems = response.body()!!.ocs!!.data if (mediaItems != null) { for (it in mediaItems) { - val actorParameters = it.value.messageParameters!!["actor"]!! + val metaData = it.value.metaData val dateTime = dateUtils.getLocalDateTimeStringFromTimestamp( it.value.timestamp * DateConstants.SECOND_DIVIDER ) - if (it.value.messageParameters?.containsKey("file") == true) { + if (metaData != null) { + val fileParameters = it.value.messageParameters?.get("file") + val message = it.value.message!! + val name = fileParameters?.get("name") ?: message + + val sharedItem = SharedPinnedItem( + it.value.id.toString(), + name, + it.value.actorId!!, + metaData.pinnedActorDisplayName!!, + dateTime + ) + items[it.value.id.toString()] = sharedItem + } else if (it.value.messageParameters?.containsKey("file") == true) { val fileParameters = it.value.messageParameters!!["file"]!! + val actorParameters = it.value.messageParameters!!["actor"]!! val previewAvailable = "yes".equals(fileParameters["preview-available"]!!, ignoreCase = true) @@ -92,6 +107,7 @@ class SharedItemsRepositoryImpl @Inject constructor(private val ncApi: NcApi, pr ) } else if (it.value.messageParameters?.containsKey("object") == true) { val objectParameters = it.value.messageParameters!!["object"]!! + val actorParameters = it.value.messageParameters!!["actor"]!! items[it.value.id.toString()] = itemFromObject(objectParameters, actorParameters, dateTime) } else { Log.w(TAG, "Item contains neither 'file' or 'object'.") 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 4321b7b6d44..80a3e6ace95 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 @@ -98,6 +99,8 @@ class MessageActionsDialog( .createdAt .before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE)) + private val canPin = message.isOneToOneConversation || + ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!) private val isUserAllowedToEdit = chatActivity.userAllowedByPrivilages(message) private var isNoTimeLimitOnNoteToSelf = hasSpreedFeatureCapability( spreedCapabilities, @@ -168,6 +171,12 @@ class MessageActionsDialog( hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.REMIND_ME_LATER) && isOnline ) + initMenuPinMessage( + !message.isDeleted && + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.PINNED_MESSAGES) && + isOnline && + canPin + ) initMenuMarkAsUnread( message.previousMessageId > NO_PREVIOUS_MESSAGE_ID && ChatMessage.MessageType.SYSTEM_MESSAGE != message.getCalculateMessageType() && @@ -389,6 +398,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..32e60bb3d69 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/PinnedMessageOptionsDialog.kt @@ -0,0 +1,232 @@ +/* + * 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.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +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.graphics.Color +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(shouldDismiss, onPin) + } + } + } + } +} + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PinMessageOptions( + shouldDismiss: MutableState, + onPin: (zonedDateTime: ZonedDateTime?) -> Unit, + modifier: Modifier = Modifier +) { + var showDateTimePickerDialog by remember { mutableStateOf(false) } + var showTimePicker 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 = { + showDateTimePickerDialog = true + }, + content = { Text(stringResource(R.string.custom)) } + ) + } + + val minDateTime = ZonedDateTime.now().plusMinutes(1) + val initialDateTime = ZonedDateTime.now().plusHours(1) + + if (showDateTimePickerDialog) { + 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() + } + } + ) + + val timePickerState = rememberTimePickerState( + initialHour = initialDateTime.hour, + initialMinute = initialDateTime.minute, + is24Hour = false // Or true, based on system settings + ) + + DatePickerDialog( + onDismissRequest = { + shouldDismiss.value = true + }, + confirmButton = { + TextButton( + onClick = { + if (showTimePicker) { + 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() + ) + + if (finalDateTime.isAfter(minDateTime)) { + onPin(finalDateTime) + } + } else { + datePickerState.selectedDateMillis?.let { millis -> + // This is NOT redundant + tempSelectedDate = Instant.ofEpochMilli(millis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + + showTimePicker = true + } + } + } + ) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { + shouldDismiss.value = true + }) { Text("Cancel") } + }, + modifier = Modifier.background(MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.padding(PaddingValues(all = 16.dp)), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (showTimePicker) { + TimePicker( + state = timePickerState, + modifier = Modifier + .offset(16.dp, 16.dp) + .background(Color.Transparent) + ) + } else { + DatePicker(state = datePickerState) + } + } + } + } +} 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 669b2da268e..b792df2255d 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,12 @@ 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 getUrlForChatMessageHiding(version: Int, baseUrl: String?, token: String, messageId: String): String = + "${getUrlForChatMessage(version, baseUrl, token, messageId)}/pin/self" + 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 @@ - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + Location services disabled Please enable location services (GPS) to use this feature 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 + Pinned