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
105 changes: 104 additions & 1 deletion app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ import com.nextcloud.talk.chat.ui.ShowReactionsModalBottomSheet
import com.nextcloud.talk.chat.ui.TempMessageActionsBottomSheet
import com.nextcloud.talk.chat.ui.TypingIndicatorBanner
import com.nextcloud.talk.chat.ui.buildMessageActionsState
import com.nextcloud.talk.chat.ui.isMessageEditable
import com.nextcloud.talk.chat.ui.model.MessageTypeContent
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel
Expand Down Expand Up @@ -949,7 +950,10 @@ class ChatActivity :
chatViewModel.toggleSystemMessageCollapse(messageId)
},
onAvatarClick = { messageId -> chatViewModel.showProfileSheet(messageId.toLong()) },
onCancelUpload = { referenceId -> chatViewModel.cancelUpload(referenceId) }
onCancelUpload = { referenceId -> chatViewModel.cancelUpload(referenceId) },
onMarkdownTaskToggle = { messageId, updatedMessage ->
updateMarkdownTaskMessage(messageId, updatedMessage)
}
)
),
listState = listState
Expand Down Expand Up @@ -1205,6 +1209,97 @@ class ChatActivity :
}
}

private fun updateMarkdownTaskMessage(messageId: Int, updatedMessage: String) {
if (credentials.isNullOrBlank() || conversationUser?.baseUrl.isNullOrBlank()) {
return
}

lifecycleScope.launch {
val message = chatViewModel.getMessageById(messageId.toLong()).first()
if (!canEditMarkdownTaskMessage(message)) {
return@launch
}

val originalMessageText = message.message.orEmpty()

// Apply the toggle to the local copy right away so the checkbox reflects the new
// state immediately, instead of waiting for the server round-trip to complete.
messageInputViewModel.editTempChatMessage(message, updatedMessage)

if (message.isTemporary) {
return@launch
}

pendingMarkdownTaskEdit = PendingMarkdownTaskEdit(messageId, originalMessageText)
val apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1))
messageInputViewModel.editChatMessage(
credentials!!,
ApiUtils.getUrlForChatMessage(
version = apiVersion,
baseUrl = conversationUser!!.baseUrl!!,
token = roomToken,
messageId = message.jsonMessageId.toString()
),
getMarkdownTaskEditText(message, updatedMessage)
)
}
}

private fun canEditMarkdownTaskMessage(message: ChatMessage): Boolean {
if (message.isTemporary) {
return true
}
if (message.getCalculateMessageType() != ChatMessage.MessageType.REGULAR_TEXT_MESSAGE) {
return false
}

val isUserAllowedByPrivileges = message.actorId == conversationUser?.userId ||
currentConversation?.let { ConversationUtils.canModerate(it, spreedCapabilities) } == true
val hasChatPermission = participantPermissionsFlow.value?.hasChatPermission() == true

return isMessageEditable(
message = message,
conversation = currentConversation,
hasChatPermission = hasChatPermission,
isUserAllowedByPrivileges = isUserAllowedByPrivileges,
spreedCapabilities = spreedCapabilities
)
}

// Tracks a checkbox toggle sent to the server so it can be rolled back locally if the
// edit is rejected (e.g. message became too old, or read-only conversation).
private data class PendingMarkdownTaskEdit(val messageId: Int, val originalMessage: String)
private var pendingMarkdownTaskEdit: PendingMarkdownTaskEdit? = null

private fun revertPendingMarkdownTaskEdit() {
val pending = pendingMarkdownTaskEdit ?: return
pendingMarkdownTaskEdit = null
lifecycleScope.launch {
val message = chatViewModel.getMessageById(pending.messageId.toLong()).first()
messageInputViewModel.editTempChatMessage(message, pending.originalMessage)
}
}

private fun getMarkdownTaskEditText(message: ChatMessage, updatedMessage: String): String {
val messageParameters = message.messageParameters ?: return updatedMessage
var result = updatedMessage
for ((key, params) in messageParameters) {
val token = "{$key}"
if (!result.contains(token)) {
continue
}

val replacement = when (params?.get("type")) {
"user", "guest", "email" -> "@${params["mention-id"]}"
"user-group", "circle" -> "@\"${params["mention-id"]}\""
"call" -> "@all"
else -> params?.get("name").orEmpty()
}
result = result.replace(token, replacement)
}
return result
}

@Composable
private fun LazyListState.visibleItemsWithThreshold(): List<String> =
remember(this) {
Expand Down Expand Up @@ -1789,6 +1884,7 @@ class ChatActivity :
is MessageInputViewModel.EditMessageSuccessState -> {
when (state.messageEdited.ocs?.meta?.statusCode) {
HTTP_BAD_REQUEST -> {
revertPendingMarkdownTaskEdit()
Snackbar.make(
binding.root,
getString(R.string.edit_error_24_hours_old_message),
Expand All @@ -1797,6 +1893,7 @@ class ChatActivity :
}

HTTP_FORBIDDEN -> {
revertPendingMarkdownTaskEdit()
Snackbar.make(
binding.root,
getString(R.string.conversation_is_read_only),
Expand All @@ -1805,16 +1902,22 @@ class ChatActivity :
}

HTTP_NOT_FOUND -> {
revertPendingMarkdownTaskEdit()
Snackbar.make(
binding.root,
"Conversation not found",
Snackbar.LENGTH_LONG
).show()
}

else -> {
pendingMarkdownTaskEdit = null
}
}
}

is MessageInputViewModel.EditMessageErrorState -> {
revertPendingMarkdownTaskEdit()
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,42 @@ data class MessageActionsState(
val showDelete: Boolean
)

/**
* Single source of truth for whether a message may be edited, shared by the "Edit" action in
* [buildMessageActionsState] and the markdown task-list checkbox toggle in ChatActivity, so both
* agree on time limits, note-to-self/bot exceptions and permissions.
*/
@Suppress("LongParameterList")
internal fun isMessageEditable(
message: ChatMessage,
conversation: ConversationModel?,
hasChatPermission: Boolean,
isUserAllowedByPrivileges: Boolean,
spreedCapabilities: SpreedCapability
): Boolean {
val messageType = message.getCalculateMessageType()
val messageHasFileAttachment = ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE == messageType
val messageHasRegularText = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE == messageType && !message.isDeleted
val messageHasCaptions = messageHasFileAttachment && message.message != "{file}" && !message.isDeleted

val isOlderThanTwentyFourHours = message.createdAt
.before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE))

val isNoTimeLimitOnNoteToSelf =
hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.EDIT_MESSAGES_NOTE_TO_SELF) &&
conversation?.type == ConversationEnums.ConversationType.NOTE_TO_SELF
val isMessageBotOneToOne = message.actorType == ACTOR_BOTS &&
(message.isOneToOneConversation || message.isFormerOneToOneConversation) &&
!isOlderThanTwentyFourHours
val messageIsEditable = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.EDIT_MESSAGES) &&
(messageHasRegularText || messageHasCaptions) &&
!isOlderThanTwentyFourHours &&
isUserAllowedByPrivileges &&
hasChatPermission

return isNoTimeLimitOnNoteToSelf || messageIsEditable || isMessageBotOneToOne
}

@Suppress("LongParameterList", "CyclomaticComplexMethod", "LongMethod")
internal fun buildMessageActionsState(
message: ChatMessage,
Expand All @@ -157,8 +193,6 @@ internal fun buildMessageActionsState(
val messageHasRegularText = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE == messageType && !message.isDeleted
val messageHasCaptions = messageHasFileAttachment && message.message != "{file}" && !message.isDeleted

val isOlderThanTwentyFourHours = message.createdAt
.before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE))
val isOlderThanSixHours = message.createdAt
.before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_DELETE_MESSAGE))

Expand All @@ -172,18 +206,13 @@ internal fun buildMessageActionsState(
false
}

val isNoTimeLimitOnNoteToSelf =
hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.EDIT_MESSAGES_NOTE_TO_SELF) &&
conversation?.type == ConversationEnums.ConversationType.NOTE_TO_SELF
val isMessageBotOneToOne = message.actorType == ACTOR_BOTS &&
(message.isOneToOneConversation || message.isFormerOneToOneConversation) &&
!isOlderThanTwentyFourHours
val messageIsEditable = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.EDIT_MESSAGES) &&
(messageHasRegularText || messageHasCaptions) &&
!isOlderThanTwentyFourHours &&
isUserAllowedByPrivileges &&
hasChatPermission
val isMessageEditable = isNoTimeLimitOnNoteToSelf || messageIsEditable || isMessageBotOneToOne
val isMessageEditable = isMessageEditable(
message = message,
conversation = conversation,
hasChatPermission = hasChatPermission,
isUserAllowedByPrivileges = isUserAllowedByPrivileges,
spreedCapabilities = spreedCapabilities
)

val hasDeleteMessagesUnlimitedCapability =
hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.DELETE_MESSAGES_UNLIMITED)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ internal val LocalMessageLongClickHandler = compositionLocalOf<(Int) -> Unit> {
internal val LocalHighlightSearchTerm = compositionLocalOf<String?> { null }
internal val LocalShowThreadButton = compositionLocalOf { true }
internal val LocalAvatarClickHandler = compositionLocalOf<(Int) -> Unit> { {} }
internal val LocalMarkdownTaskToggleHandler = compositionLocalOf<(Int, String) -> Unit> { { _, _ -> } }

private enum class MetadataLayoutMode {
CAPTION,
Expand Down
35 changes: 20 additions & 15 deletions app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
Expand All @@ -25,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.nextcloud.talk.R
import com.nextcloud.talk.chat.ui.model.ChatMessageUi
Expand Down Expand Up @@ -73,7 +75,8 @@ data class ChatMessageCallbacks(
val onQuotedMessageClick: (Int) -> Unit = {},
val onSystemMessageExpandClick: (Int) -> Unit = {},
val onAvatarClick: (Int) -> Unit = {},
val onCancelUpload: (String) -> Unit = {}
val onCancelUpload: (String) -> Unit = {},
val onMarkdownTaskToggle: (Int, String) -> Unit = { _, _ -> }
)

@Suppress("Detekt.LongParameterList", "Detekt.LongMethod", "Detekt.CyclomaticComplexMethod")
Expand Down Expand Up @@ -109,28 +112,30 @@ fun ChatMessageView(
LocalOpenThreadHandler provides callbacks.onOpenThreadClick,
LocalQuotedMessageClickHandler provides callbacks.onQuotedMessageClick,
LocalHighlightSearchTerm provides highlightSearchTerm,
LocalAvatarClickHandler provides callbacks.onAvatarClick
LocalAvatarClickHandler provides callbacks.onAvatarClick,
LocalMarkdownTaskToggleHandler provides callbacks.onMarkdownTaskToggle
) {
SwipeToReplyContainer(
replyable = message.replyable && context.hasChatPermission,
onSwipeReply = { callbacks.onSwipeReply?.invoke(message.id) }
) {
val messageGestureModifier = if (message.isExpandableParent) {
Modifier.combinedClickable(
interactionSource = interactionSource,
indication = ripple(),
onClick = { callbacks.onSystemMessageExpandClick(message.id) },
onLongClick = { callbacks.onLongClick?.invoke(message.id) }
)
} else {
Modifier.pointerInput(message.id) {
detectTapGestures(onLongPress = { callbacks.onLongClick?.invoke(message.id) })
}
}

Box(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
interactionSource = interactionSource,
indication = ripple(),
onClick = {
if (message.isExpandableParent) {
callbacks.onSystemMessageExpandClick(message.id)
} else {
callbacks.onLongClick?.invoke(message.id)
}
},
onDoubleClick = { callbacks.onLongClick?.invoke(message.id) },
onLongClick = { callbacks.onLongClick?.invoke(message.id) }
)
.then(messageGestureModifier)
) {
Box(modifier = Modifier.padding(horizontal = 12.dp)) {
when (val content = message.content) {
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,8 @@ fun ChatView(
onQuotedMessageClick = handleQuotedMessageClick,
onSystemMessageExpandClick = callbacks.messageCallbacks.onSystemMessageExpandClick,
onAvatarClick = callbacks.messageCallbacks.onAvatarClick,
onCancelUpload = callbacks.messageCallbacks.onCancelUpload
onCancelUpload = callbacks.messageCallbacks.onCancelUpload,
onMarkdownTaskToggle = callbacks.messageCallbacks.onMarkdownTaskToggle
)
)
}
Expand Down
Loading
Loading