Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ import com.nextcloud.talk.ui.PinnedMessageView
import com.nextcloud.talk.ui.PlaybackSpeed
import com.nextcloud.talk.ui.StatusDrawable
import com.nextcloud.talk.ui.chat.ChatView
import com.nextcloud.talk.ui.chat.ChatMessageCallbacks
import com.nextcloud.talk.ui.chat.ChatViewCallbacks
import com.nextcloud.talk.ui.chat.ChatViewState
import com.nextcloud.talk.ui.dialog.DateTimeCompose
Expand Down Expand Up @@ -625,17 +626,22 @@ class ChatActivity :
onLoadMore = { loadMoreMessagesCompose() },
advanceLocalLastReadMessageIfNeeded = { advanceLocalLastReadMessageIfNeeded(it) },
updateRemoteLastReadMessageIfNeeded = { updateRemoteLastReadMessageIfNeeded() },
onLongClick = { openMessageActionsDialog(it) },
onSwipeReply = { handleSwipeToReply(it) },
onFileClick = { downloadAndOpenFile(it) },
onPollClick = { pollId, pollName -> openPollDialog(pollId, pollName) },
onVoicePlayPauseClick = { onVoicePlayPauseClickCompose(it) },
onVoiceSeek = { _, progress -> chatViewModel.seekToMediaPlayer(progress) },
onVoiceSpeedClick = { onVoiceSpeedClickCompose(it) },
onReactionClick = { messageId, emoji -> handleReactionClick(messageId, emoji) },
onReactionLongClick = { messageId -> openReactionsDialog(messageId) },
onOpenThreadClick = { messageId -> openThread(messageId.toLong()) },
onLoadQuotedMessageClick = { messageId -> onLoadQuotedMessage(messageId) }
onLoadQuotedMessageClick = { messageId -> onLoadQuotedMessage(messageId) },
messageCallbacks = ChatMessageCallbacks(
onLongClick = { openMessageActionsDialog(it) },
onSwipeReply = { handleSwipeToReply(it) },
onFileClick = { downloadAndOpenFile(it) },
onPollClick = { pollId, pollName -> openPollDialog(pollId, pollName) },
onVoicePlayPauseClick = { onVoicePlayPauseClickCompose(it) },
onVoiceSeek = { _, progress -> chatViewModel.seekToMediaPlayer(progress) },
onVoiceSpeedClick = { onVoiceSpeedClickCompose(it) },
onReactionClick = { messageId, emoji -> handleReactionClick(messageId, emoji) },
onReactionLongClick = { messageId -> openReactionsDialog(messageId) },
onOpenThreadClick = { messageId -> openThread(messageId.toLong()) },
onSystemMessageExpandClick = { messageId ->
chatViewModel.toggleSystemMessageCollapse(messageId)
}
)
),
listState = listState
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import java.util.Date
data class ChatMessage(
var isGrouped: Boolean = false,

var isGroupedWithNext: Boolean = false,

var isOneToOneConversation: Boolean = false,

var isFormerOneToOneConversation: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ data class ChatMessageUi(
val reactions: List<MessageReactionUi> = emptyList(),
val isEdited: Boolean = false,
val parentMessage: ChatMessageUi? = null,
val replyable: Boolean = false
val replyable: Boolean = false,
val isGrouped: Boolean = false,
val isGroupedWithNext: Boolean = false,
val isSilent: Boolean = false,
val isExpandableParent: Boolean = false,
val expandableChildrenAmount: Int = 0,
val isHiddenByCollapse: Boolean = false,
val isExpanded: Boolean = false
)

data class MessageReactionUi(val emoji: String, val amount: Int, val isSelfReaction: Boolean)
Expand Down Expand Up @@ -125,7 +132,13 @@ fun ChatMessage.toUiModel(
lastCommonReadMessageId = 0,
parentMessage = null
),
replyable = replyable
replyable = replyable,
isGrouped = isGrouped,
isGroupedWithNext = isGroupedWithNext,
isSilent = silent,
isExpandableParent = expandableParent,
expandableChildrenAmount = expandableChildrenAmount,
isHiddenByCollapse = hiddenByCollapse
)

private fun ChatMessage.normalizeMessageParameters(): Map<String, Map<String, String>> =
Expand Down
127 changes: 119 additions & 8 deletions app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ import kotlinx.coroutines.withTimeoutOrNull
import retrofit2.HttpException
import java.io.File
import java.io.IOException
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import javax.inject.Inject

@Suppress("TooManyFunctions", "LongParameterList")
Expand Down Expand Up @@ -331,6 +333,14 @@ class ChatViewModel @AssistedInject constructor(

@Volatile private var oneOrMoreMessagesWereSent = false

private val expandedSystemMessageParents = MutableStateFlow<Set<Int>>(emptySet())

fun toggleSystemMessageCollapse(parentMessageId: Int) {
expandedSystemMessageParents.update { current ->
if (parentMessageId in current) current - parentMessageId else current + parentMessageId
}
}

// ------------------------------
// UI State. This should be the only UI state. Add more val here and update via copy whenever necessary.
// ------------------------------
Expand Down Expand Up @@ -622,7 +632,8 @@ class ChatViewModel @AssistedInject constructor(
val messages: List<ChatMessage>,
val lastCommonRead: Int,
val parentMap: Map<Long, ChatMessage>,
val conversationLastRead: Int
val conversationLastRead: Int,
val expandedParents: Set<Int> = emptySet()
)

private data class ProcessedMessages(val items: List<ChatItem>, val missingParentIds: List<Long>)
Expand All @@ -635,11 +646,12 @@ class ChatViewModel @AssistedInject constructor(
messagesFlow,
getLastCommonReadFlow.onStart { emit(0) },
parentMessagesFlow,
conversationFlow.map { it.lastReadMessage }
) { messages, lastCommonRead, parentMap, conversationLastRead ->
CombinedInput(messages, lastCommonRead, parentMap, conversationLastRead)
conversationFlow.map { it.lastReadMessage },
expandedSystemMessageParents
) { messages, lastCommonRead, parentMap, conversationLastRead, expandedParents ->
CombinedInput(messages, lastCommonRead, parentMap, conversationLastRead, expandedParents)
}
.map { (messages, lastCommonRead, parentMap, conversationLastRead) ->
.map { (messages, lastCommonRead, parentMap, conversationLastRead, expandedParents) ->
val messageMap: Map<Long, ChatMessage> = messages.associateBy { it.jsonMessageId.toLong() }
val combinedMap: Map<Long, ChatMessage> = messageMap + parentMap

Expand All @@ -649,6 +661,8 @@ class ChatViewModel @AssistedInject constructor(
.distinct()

val user = currentUserFlow.value
applyMessageGrouping(messages)
applySystemMessageGrouping(messages)
val uiMessages = messages.map { message ->
val parent: ChatMessage? = combinedMap[message.parentMessageId]
message.toUiModel(
Expand All @@ -659,7 +673,7 @@ class ChatViewModel @AssistedInject constructor(
)
}

val items = buildChatItems(uiMessages, conversationLastRead)
val items = buildChatItems(uiMessages, conversationLastRead, expandedParents)
ProcessedMessages(items = items, missingParentIds = missingParentIds)
}
.flowOn(Dispatchers.Default)
Expand All @@ -685,8 +699,13 @@ class ChatViewModel @AssistedInject constructor(
// ------------------------------
// Build chat items (pure)
// ------------------------------
private fun buildChatItems(uiMessages: List<ChatMessageUi>, lastReadMessage: Int): List<ChatItem> {
private fun buildChatItems(
uiMessages: List<ChatMessageUi>,
lastReadMessage: Int,
expandedParents: Set<Int> = emptySet()
): List<ChatItem> {
var lastDate: LocalDate? = null
var lastExpandableParentId: Int? = null

return buildList {
if (firstUnreadMessageId == null && lastReadMessage > 0) {
Expand All @@ -700,6 +719,14 @@ class ChatViewModel @AssistedInject constructor(
}

for (uiMessage in uiMessages) {
if (uiMessage.isExpandableParent) {
lastExpandableParentId = uiMessage.id
}

if (uiMessage.isHiddenByCollapse && lastExpandableParentId !in expandedParents) {
continue
}

val date = uiMessage.date

if (date != lastDate) {
Expand All @@ -711,11 +738,93 @@ class ChatViewModel @AssistedInject constructor(
add(ChatItem.UnreadMessagesMarkerItem(date))
}

add(ChatItem.MessageItem(uiMessage))
val adjustedMessage = if (uiMessage.isExpandableParent) {
uiMessage.copy(isExpanded = uiMessage.id in expandedParents)
} else {
uiMessage
}
add(ChatItem.MessageItem(adjustedMessage))
}
}.asReversed()
}

private fun applyMessageGrouping(messages: List<ChatMessage>) {
messages.forEachIndexed { index, message ->
message.isGrouped = index > 0 && shouldGroupMessage(message, messages[index - 1])
message.isGroupedWithNext = index < messages.size - 1 && shouldGroupMessage(messages[index + 1], message)
}
}

private fun applySystemMessageGrouping(messages: List<ChatMessage>) {
messages.forEach { message ->
message.expandableParent = false
message.lastItemOfExpandableGroup = 0
message.expandableChildrenAmount = 0
message.hiddenByCollapse = false
}

messages.forEachIndexed { index, currentMessage ->
if (!currentMessage.isSystemMessage || index == 0) return@forEachIndexed
val previousMessage = messages[index - 1]
if (previousMessage.isSystemMessage &&
previousMessage.systemMessageType == currentMessage.systemMessageType &&
isSameDayMessages(previousMessage, currentMessage)
) {
groupSystemMessages(previousMessage, currentMessage)
}
}

messages.forEach { message ->
if (isChildOfExpandableGroup(message)) {
message.hiddenByCollapse = true
}
}
}

private fun groupSystemMessages(previousMessage: ChatMessage, currentMessage: ChatMessage) {
previousMessage.expandableParent = true
currentMessage.expandableParent = false

if (currentMessage.lastItemOfExpandableGroup == 0) {
currentMessage.lastItemOfExpandableGroup = currentMessage.jsonMessageId
}

previousMessage.lastItemOfExpandableGroup = currentMessage.lastItemOfExpandableGroup
previousMessage.expandableChildrenAmount = currentMessage.expandableChildrenAmount + 1
}

private fun isChildOfExpandableGroup(message: ChatMessage): Boolean =
message.isSystemMessage && !message.expandableParent && message.lastItemOfExpandableGroup != 0

private fun isSameDayMessages(message1: ChatMessage, message2: ChatMessage): Boolean {
val date1 = Instant.ofEpochMilli(message1.timestamp * TIMESTAMP_TO_MILLIS)
.atZone(ZoneId.systemDefault()).toLocalDate()
val date2 = Instant.ofEpochMilli(message2.timestamp * TIMESTAMP_TO_MILLIS)
.atZone(ZoneId.systemDefault()).toLocalDate()
return date1 == date2
}

private fun shouldGroupMessage(current: ChatMessage, previous: ChatMessage): Boolean {
val sameMessageKind = current.isSystemMessage == previous.isSystemMessage
val notUnclassifiedBot = current.actorType != "bots" || current.actorId == "changelog"
val sameActor = current.isSystemMessage ||
(current.actorType == previous.actorType && current.actorId == previous.actorId)
val currentDate = Instant.ofEpochMilli(current.timestamp * TIMESTAMP_TO_MILLIS)
.atZone(ZoneId.systemDefault()).toLocalDate()
val previousDate = Instant.ofEpochMilli(previous.timestamp * TIMESTAMP_TO_MILLIS)
.atZone(ZoneId.systemDefault()).toLocalDate()
val timeDifference = kotlin.math.abs(current.timestamp - previous.timestamp)
val neitherEdited = (current.lastEditTimestamp ?: 0L) == 0L || (previous.lastEditTimestamp ?: 0L) == 0L

return sameMessageKind &&
notUnclassifiedBot &&
sameActor &&
currentDate == previousDate &&
current.actorId == previous.actorId &&
timeDifference <= GROUPING_TIME_WINDOW_SECONDS &&
neitherEdited
}

fun onMessageSent() {
oneOrMoreMessagesWereSent = true
}
Expand Down Expand Up @@ -1775,6 +1884,8 @@ class ChatViewModel @AssistedInject constructor(
private const val WEBSOCKET_CONNECT_TIMEOUT_MS = 3000L
private const val WEBSOCKET_POLL_INTERVAL_MS = 50L
private const val ROOM_REFRESH_DEBOUNCE_MS = 500L
private const val GROUPING_TIME_WINDOW_SECONDS = 300L
private const val TIMESTAMP_TO_MILLIS = 1000L
}

sealed class OutOfOfficeUIState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,22 @@ internal fun createLongBaseMessage(content: MessageTypeContent?): ChatMessageUi
content = content,
reactions = previewReactions
)

internal fun createMarkdownMessage(): ChatMessageUi =
ChatMessageUi(
id = 4,
message = "- Item 1\n- Item 2\n\n> A blockquote\n\n```kotlin\nval x = 1\n```",
plainMessage = "- Item 1\n- Item 2\n\n> A blockquote\n\n```kotlin\nval x = 1\n```",
renderMarkdown = true,
actorDisplayName = "Markdown Sender",
isThread = false,
threadTitle = "",
threadReplies = 0,
incoming = true,
isDeleted = false,
avatarUrl = null,
statusIcon = MessageStatusIcon.SENT,
timestamp = System.currentTimeMillis() / 1000,
date = LocalDate.now(),
content = MessageTypeContent.RegularText
)
Loading
Loading