Skip to content

Commit 0a1c0e8

Browse files
committed
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 <juliuslinus1@gmail.com>
1 parent ac25c85 commit 0a1c0e8

29 files changed

+863
-83
lines changed

app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,4 +323,18 @@ interface NcApiCoroutines {
323323

324324
@GET
325325
suspend fun status(@Header("Authorization") authorization: String, @Url url: String): StatusOverall
326+
327+
@FormUrlEncoded
328+
@POST
329+
suspend fun pinMessage(
330+
@Header("Authorization") authorization: String,
331+
@Url url: String,
332+
@Field("pinUntil") pinUntil: Int
333+
): ChatOverallSingleMessage
334+
335+
@DELETE
336+
suspend fun unPinMessage(@Header("Authorization") authorization: String, @Url url: String): ChatOverallSingleMessage
337+
338+
@DELETE
339+
suspend fun hidePinnedMessage(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
326340
}

app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import android.provider.MediaStore
3434
import android.provider.Settings
3535
import android.text.SpannableStringBuilder
3636
import android.text.TextUtils
37+
import android.text.format.DateFormat
3738
import android.util.Log
3839
import android.view.Gravity
3940
import android.view.Menu
@@ -59,11 +60,33 @@ import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
5960
import androidx.appcompat.app.AlertDialog
6061
import androidx.appcompat.view.ContextThemeWrapper
6162
import androidx.cardview.widget.CardView
63+
import androidx.compose.foundation.background
64+
import androidx.compose.foundation.clickable
65+
import androidx.compose.foundation.layout.Arrangement
66+
import androidx.compose.foundation.layout.Box
67+
import androidx.compose.foundation.layout.Column
68+
import androidx.compose.foundation.layout.Row
69+
import androidx.compose.foundation.layout.Spacer
70+
import androidx.compose.foundation.layout.padding
71+
import androidx.compose.foundation.layout.size
72+
import androidx.compose.foundation.rememberScrollState
73+
import androidx.compose.foundation.shape.RoundedCornerShape
74+
import androidx.compose.foundation.verticalScroll
75+
import androidx.compose.material3.Icon
6276
import androidx.compose.material3.MaterialTheme
77+
import androidx.compose.material3.Text
78+
import androidx.compose.runtime.Composable
6379
import androidx.compose.runtime.getValue
6480
import androidx.compose.runtime.mutableStateOf
81+
import androidx.compose.runtime.remember
6582
import androidx.compose.runtime.setValue
83+
import androidx.compose.ui.Modifier
84+
import androidx.compose.ui.draw.shadow
85+
import androidx.compose.ui.graphics.Color
6686
import androidx.compose.ui.platform.ComposeView
87+
import androidx.compose.ui.res.painterResource
88+
import androidx.compose.ui.res.stringResource
89+
import androidx.compose.ui.unit.dp
6790
import androidx.coordinatorlayout.widget.CoordinatorLayout
6891
import androidx.core.content.ContextCompat
6992
import androidx.core.content.FileProvider
@@ -167,12 +190,14 @@ import com.nextcloud.talk.signaling.SignalingMessageReceiver
167190
import com.nextcloud.talk.signaling.SignalingMessageSender
168191
import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity
169192
import com.nextcloud.talk.translate.ui.TranslateActivity
193+
import com.nextcloud.talk.ui.ComposeChatAdapter
170194
import com.nextcloud.talk.ui.PlaybackSpeed
171195
import com.nextcloud.talk.ui.PlaybackSpeedControl
172196
import com.nextcloud.talk.ui.StatusDrawable
173197
import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet
174198
import com.nextcloud.talk.ui.dialog.DateTimeCompose
175199
import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment
200+
import com.nextcloud.talk.ui.dialog.GetPinnedOptionsDialog
176201
import com.nextcloud.talk.ui.dialog.MessageActionsDialog
177202
import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment
178203
import com.nextcloud.talk.ui.dialog.ShowReactionsDialog
@@ -250,7 +275,7 @@ import java.util.concurrent.ExecutionException
250275
import javax.inject.Inject
251276
import kotlin.math.roundToInt
252277

253-
@Suppress("TooManyFunctions")
278+
@Suppress("TooManyFunctions", "LargeClass", "LongMethod")
254279
@AutoInjector(NextcloudTalkApplication::class)
255280
class ChatActivity :
256281
BaseActivity(),
@@ -648,7 +673,7 @@ class ChatActivity :
648673

649674
this.lifecycleScope.launch {
650675
chatViewModel.getConversationFlow
651-
.onEach { conversationModel ->
676+
.collect { conversationModel ->
652677
currentConversation = conversationModel
653678
chatViewModel.updateConversation(
654679
currentConversation!!
@@ -663,7 +688,30 @@ class ChatActivity :
663688
}
664689

665690
chatViewModel.getCapabilities(conversationUser!!, roomToken, currentConversation!!)
666-
}.collect()
691+
692+
if (conversationModel.lastPinnedId != null &&
693+
conversationModel.lastPinnedId != 0L &&
694+
conversationModel.lastPinnedId != conversationModel.hiddenPinnedId
695+
) {
696+
chatViewModel
697+
.getIndividualMessageFromServer(
698+
credentials!!,
699+
conversationUser?.baseUrl!!,
700+
roomToken,
701+
conversationModel.lastPinnedId.toString()
702+
)
703+
.collect { message ->
704+
message?.let {
705+
binding.pinnedMessageContainer.visibility = View.VISIBLE
706+
binding.pinnedMessageComposeView.setContent {
707+
PinnedMessageView(message)
708+
}
709+
}
710+
}
711+
} else {
712+
binding.pinnedMessageContainer.visibility = View.GONE
713+
}
714+
}
667715
}
668716

669717
chatViewModel.getRoomViewState.observe(this) { state ->
@@ -1130,6 +1178,10 @@ class ChatActivity :
11301178
val item = adapter?.items?.get(index)?.item
11311179
item?.let {
11321180
setMessageAsEdited(item as ChatMessage, newString)
1181+
1182+
if (item.jsonMessageId.toLong() == currentConversation?.lastPinnedId) {
1183+
chatViewModel.getRoom(roomToken)
1184+
}
11331185
}
11341186
}
11351187

@@ -1313,6 +1365,94 @@ class ChatActivity :
13131365
}
13141366
}
13151367

1368+
@Composable
1369+
private fun PinnedMessageView(message: ChatMessage) {
1370+
message.incoming = true
1371+
val pinnedBy = stringResource(R.string.pinned_by)
1372+
message.actorDisplayName = "${message.actorDisplayName}\n$pinnedBy ${message.pinnedActorDisplayName}"
1373+
val scrollState = rememberScrollState()
1374+
1375+
val outgoingBubbleColor = remember {
1376+
val colorInt = viewThemeUtils.talk
1377+
.getOutgoingMessageBubbleColor(context, message.isDeleted, false)
1378+
1379+
Color(colorInt)
1380+
}
1381+
1382+
val incomingBubbleColor = remember {
1383+
val colorInt = resources
1384+
.getColor(R.color.bg_message_list_incoming_bubble, null)
1385+
1386+
Color(colorInt)
1387+
}
1388+
1389+
val isAllowed = remember {
1390+
ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!)
1391+
}
1392+
1393+
Column(
1394+
verticalArrangement = Arrangement.spacedBy((-16).dp),
1395+
modifier = Modifier
1396+
) {
1397+
Box(
1398+
modifier = Modifier
1399+
.shadow(4.dp, shape = RoundedCornerShape(16.dp))
1400+
.background(incomingBubbleColor, RoundedCornerShape(16.dp))
1401+
.padding(16.dp)
1402+
.verticalScroll(scrollState)
1403+
) {
1404+
ComposeChatAdapter().GetComposableForMessage(message)
1405+
}
1406+
1407+
Row(
1408+
modifier = Modifier
1409+
.padding(start = 16.dp)
1410+
.background(outgoingBubbleColor, RoundedCornerShape(16.dp))
1411+
.padding(16.dp)
1412+
) {
1413+
val hiddenEye = painterResource(R.drawable.ic_eye_off)
1414+
Icon(
1415+
hiddenEye,
1416+
"Hide pin",
1417+
modifier = Modifier
1418+
.size(16.dp)
1419+
.clickable {
1420+
hidePinnedMessage(message)
1421+
}
1422+
)
1423+
1424+
if (isAllowed) {
1425+
Spacer(modifier = Modifier.size(16.dp))
1426+
val read = painterResource(R.drawable.keep_off_24px)
1427+
Icon(
1428+
read,
1429+
"Unpin",
1430+
modifier = Modifier
1431+
.size(16.dp)
1432+
.clickable {
1433+
unPinMessage(message)
1434+
}
1435+
)
1436+
}
1437+
1438+
val pinnedUntilStr = stringResource(R.string.pinned_until)
1439+
val pinnedIndefinitely = stringResource(R.string.pinned_indefinitely)
1440+
val pinnedText = message.pinnedUntil?.let {
1441+
val format = if (DateFormat.is24HourFormat(context)) "EEE, HH:mm" else "EEE, hh:mm a"
1442+
val localDateTime = Instant.ofEpochMilli(it)
1443+
.atZone(ZoneId.systemDefault())
1444+
.toLocalDateTime()
1445+
1446+
val timeString = localDateTime.format(DateTimeFormatter.ofPattern(format))
1447+
1448+
"$pinnedUntilStr $timeString"
1449+
} ?: pinnedIndefinitely
1450+
1451+
Text(pinnedText, modifier = Modifier.padding(start = 16.dp))
1452+
}
1453+
}
1454+
}
1455+
13161456
private fun removeUnreadMessagesMarker() {
13171457
removeMessageById(UNREAD_MESSAGES_MARKER_ID.toString())
13181458
}
@@ -3915,6 +4055,32 @@ class ChatActivity :
39154055
}
39164056
}
39174057

4058+
fun hidePinnedMessage(message: ChatMessage) {
4059+
val url = ApiUtils.getUrlForChatMessagePinning(chatApiVersion, conversationUser?.baseUrl, roomToken, message.id)
4060+
chatViewModel.hidePinnedMessage(credentials!!, url)
4061+
}
4062+
4063+
fun pinMessage(message: ChatMessage) {
4064+
val url = ApiUtils.getUrlForChatMessagePinning(chatApiVersion, conversationUser?.baseUrl, roomToken, message.id)
4065+
binding.genericComposeView.apply {
4066+
val shouldDismiss = mutableStateOf(false)
4067+
setContent {
4068+
GetPinnedOptionsDialog(shouldDismiss, context, viewThemeUtils) { zonedDateTime ->
4069+
zonedDateTime?.let {
4070+
chatViewModel.pinMessage(credentials!!, url, pinUntil = zonedDateTime.toEpochSecond().toInt())
4071+
} ?: chatViewModel.pinMessage(credentials!!, url)
4072+
4073+
shouldDismiss.value = true
4074+
}
4075+
}
4076+
}
4077+
}
4078+
4079+
fun unPinMessage(message: ChatMessage) {
4080+
val url = ApiUtils.getUrlForChatMessagePinning(chatApiVersion, conversationUser?.baseUrl, roomToken, message.id)
4081+
chatViewModel.unPinMessage(credentials!!, url)
4082+
}
4083+
39184084
fun markAsUnread(message: IMessage?) {
39194085
val chatMessage = message as ChatMessage?
39204086
if (chatMessage!!.previousMessageId > NO_PREVIOUS_MESSAGE_ID) {

app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
1515
import kotlinx.coroutines.Job
1616
import kotlinx.coroutines.flow.Flow
1717

18+
@Suppress("TooManyFunctions")
1819
interface ChatMessageRepository : LifecycleAwareManager {
1920

2021
/**
@@ -116,4 +117,10 @@ interface ChatMessageRepository : LifecycleAwareManager {
116117
suspend fun sendUnsentChatMessages(credentials: String, url: String)
117118

118119
suspend fun deleteTempMessage(chatMessage: ChatMessage)
120+
121+
suspend fun pinMessage(credentials: String, url: String, pinUntil: Int): Flow<ChatMessage?>
122+
123+
suspend fun unPinMessage(credentials: String, url: String): Flow<ChatMessage?>
124+
125+
suspend fun hidePinnedMessage(credentials: String, url: String): Flow<Boolean>
119126
}

app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ data class ChatMessage(
9494

9595
var lastEditTimestamp: Long? = 0,
9696

97+
var incoming: Boolean = false,
98+
9799
var isDownloadingVoiceMessage: Boolean = false,
98100

99101
var resetVoiceMessage: Boolean = false,
@@ -130,7 +132,17 @@ data class ChatMessage(
130132

131133
var sendStatus: SendStatus? = null,
132134

133-
var silent: Boolean = false
135+
var silent: Boolean = false,
136+
137+
var pinnedActorType: String? = null,
138+
139+
var pinnedActorId: String? = null,
140+
141+
var pinnedActorDisplayName: String? = null,
142+
143+
var pinnedAt: Long? = null,
144+
145+
var pinnedUntil: Long? = null
134146

135147
) : MessageContentType,
136148
MessageContentType.Image {
@@ -433,7 +445,9 @@ data class ChatMessage(
433445
FEDERATED_USER_ADDED,
434446
FEDERATED_USER_REMOVED,
435447
PHONE_ADDED,
436-
THREAD_CREATED
448+
THREAD_CREATED,
449+
MESSAGE_PINNED,
450+
MESSAGE_UNPINNED
437451
}
438452

439453
companion object {

app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,9 @@ interface ChatNetworkDataSource {
7979
): List<ChatMessageJson>
8080
suspend fun getOpenGraph(credentials: String, baseUrl: String, extractedLinkToPreview: String): Reference?
8181
suspend fun unbindRoom(credentials: String, baseUrl: String, roomToken: String): GenericOverall
82+
suspend fun pinMessage(credentials: String, url: String, pinUntil: Int): ChatOverallSingleMessage
83+
84+
suspend fun unPinMessage(credentials: String, url: String): ChatOverallSingleMessage
85+
86+
suspend fun hidePinnedMessage(credentials: String, url: String): GenericOverall
8287
}

app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,36 @@ class OfflineFirstChatRepository @Inject constructor(
10201020
_removeMessageFlow.emit(chatMessage)
10211021
}
10221022

1023+
override suspend fun pinMessage(credentials: String, url: String, pinUntil: Int): Flow<ChatMessage?> =
1024+
flow {
1025+
runCatching {
1026+
val overall = network.pinMessage(credentials, url, pinUntil)
1027+
emit(overall.ocs?.data?.asModel())
1028+
}.getOrElse { throwable ->
1029+
Log.e(TAG, "Error in pinMessage: $throwable")
1030+
}
1031+
}
1032+
1033+
override suspend fun unPinMessage(credentials: String, url: String): Flow<ChatMessage?> =
1034+
flow {
1035+
runCatching {
1036+
val overall = network.unPinMessage(credentials, url)
1037+
emit(overall.ocs?.data?.asModel())
1038+
}.getOrElse { throwable ->
1039+
Log.e(TAG, "Error in unPinMessage: $throwable")
1040+
}
1041+
}
1042+
1043+
override suspend fun hidePinnedMessage(credentials: String, url: String): Flow<Boolean> =
1044+
flow {
1045+
runCatching {
1046+
network.hidePinnedMessage(credentials, url)
1047+
emit(true)
1048+
}.getOrElse { throwable ->
1049+
Log.e(TAG, "Error in hidePinnedMessage: $throwable")
1050+
}
1051+
}
1052+
10231053
@Suppress("Detekt.TooGenericExceptionCaught")
10241054
override suspend fun addTemporaryMessage(
10251055
message: CharSequence,

app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,13 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines:
222222
val url = ApiUtils.getUrlForUnbindingRoom(baseUrl, roomToken)
223223
return ncApiCoroutines.unbindRoom(credentials, url)
224224
}
225+
226+
override suspend fun pinMessage(credentials: String, url: String, pinUntil: Int): ChatOverallSingleMessage =
227+
ncApiCoroutines.pinMessage(credentials, url, pinUntil)
228+
229+
override suspend fun unPinMessage(credentials: String, url: String): ChatOverallSingleMessage =
230+
ncApiCoroutines.unPinMessage(credentials, url)
231+
232+
override suspend fun hidePinnedMessage(credentials: String, url: String): GenericOverall =
233+
ncApiCoroutines.hidePinnedMessage(credentials, url)
225234
}

0 commit comments

Comments
 (0)