diff --git a/core-api/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/api/dto/EditPrivateMessageForm.kt b/core-api/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/api/dto/EditPrivateMessageForm.kt new file mode 100644 index 000000000..c8cf3f506 --- /dev/null +++ b/core-api/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/api/dto/EditPrivateMessageForm.kt @@ -0,0 +1,14 @@ +package com.github.diegoberaldin.raccoonforlemmy.core.api.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EditPrivateMessageForm( + @SerialName("content") + val content: String, + @SerialName("private_message_id") + val privateMessageId: PrivateMessageId, + @SerialName("auth") + val auth: String, +) diff --git a/core-api/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/api/service/PrivateMessageService.kt b/core-api/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/api/service/PrivateMessageService.kt index 2de652500..8fb871942 100644 --- a/core-api/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/api/service/PrivateMessageService.kt +++ b/core-api/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/api/service/PrivateMessageService.kt @@ -1,7 +1,9 @@ package com.github.diegoberaldin.raccoonforlemmy.core.api.service import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.CreatePrivateMessageForm +import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.EditPrivateMessageForm import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.MarkPrivateMessageAsReadForm +import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.PersonId import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.PrivateMessageResponse import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.PrivateMessagesResponse import de.jensklingenberg.ktorfit.Response @@ -10,6 +12,7 @@ import de.jensklingenberg.ktorfit.http.GET import de.jensklingenberg.ktorfit.http.Header import de.jensklingenberg.ktorfit.http.Headers import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.PUT import de.jensklingenberg.ktorfit.http.Query interface PrivateMessageService { @@ -18,6 +21,7 @@ interface PrivateMessageService { @Header("Authorization") authHeader: String? = null, @Query("auth") auth: String? = null, @Query("page") page: Int? = null, + @Query("creator_id") creatorId: PersonId? = null, @Query("limit") limit: Int? = null, @Query("unread_only") unreadOnly: Boolean? = null, ): Response @@ -29,6 +33,13 @@ interface PrivateMessageService { @Body form: CreatePrivateMessageForm, ): Response + @PUT("private_message") + @Headers("Content-Type: application/json") + suspend fun editPrivateMessage( + @Header("Authorization") authHeader: String? = null, + @Body form: EditPrivateMessageForm, + ): Response + @POST("private_message/mark_as_read") @Headers("Content-Type: application/json") suspend fun markPrivateMessageAsRead( diff --git a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatMviModel.kt b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatMviModel.kt index 2a6d2fa19..7d2fed57b 100644 --- a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatMviModel.kt +++ b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatMviModel.kt @@ -29,10 +29,12 @@ interface InboxChatMviModel : } } + data class EditMessage(val value: Int) : Intent data class SubmitNewMessage(val value: String) : Intent } data class UiState( + val initial: Boolean = true, val refreshing: Boolean = false, val loading: Boolean = false, val canFetchMore: Boolean = true, @@ -41,6 +43,7 @@ interface InboxChatMviModel : val otherUserAvatar: String? = null, val messages: List = emptyList(), val autoLoadImages: Boolean = true, + val editedMessageId: Int? = null, ) sealed interface Effect { diff --git a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatScreen.kt b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatScreen.kt index 77548a1c4..0e3652b21 100644 --- a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatScreen.kt +++ b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatScreen.kt @@ -56,11 +56,14 @@ import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.toTypography import com.github.diegoberaldin.raccoonforlemmy.core.architecture.bindToLifecycle import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.CustomImage +import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.Option +import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.OptionId import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.TextFormattingBar import com.github.diegoberaldin.raccoonforlemmy.core.commonui.di.getInboxChatViewModel import com.github.diegoberaldin.raccoonforlemmy.core.navigation.di.getNavigationCoordinator import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.onClick import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.rememberCallback +import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.rememberCallbackArgs import com.github.diegoberaldin.raccoonforlemmy.core.utils.gallery.getGalleryHelper import com.github.diegoberaldin.raccoonforlemmy.resources.MR import dev.icerock.moko.resources.compose.stringResource @@ -158,7 +161,14 @@ class InboxChatScreen( ), label = { Text( - text = stringResource(MR.strings.inbox_chat_message), + text = buildString { + append(stringResource(MR.strings.inbox_chat_message)) + if (uiState.editedMessageId != null) { + append(" (") + append(stringResource(MR.strings.post_action_edit)) + append(")") + } + }, style = typography.bodyMedium, ) }, @@ -217,6 +227,11 @@ class InboxChatScreen( item { Spacer(modifier = Modifier.height(Spacing.s)) } + if (uiState.messages.isEmpty() && uiState.initial) { + items(10) { + MessageCardPlaceholder() + } + } items(uiState.messages) { message -> val isMyMessage = message.creator?.id == uiState.currentUserId val content = message.content.orEmpty() @@ -225,13 +240,37 @@ class InboxChatScreen( isMyMessage = isMyMessage, content = content, date = date, + options = buildList { + if (isMyMessage) { + this += Option( + OptionId.Edit, + stringResource(MR.strings.post_action_edit) + ) + } + }, + onOptionSelected = rememberCallbackArgs { optionId -> + when (optionId) { + OptionId.Edit -> { + model.reduce( + InboxChatMviModel.Intent.EditMessage( + message.id + ) + ) + message.content?.also { + textFieldValue = TextFieldValue(text = it) + } + } + + else -> Unit + } + } ) } item { - if (!uiState.loading && !uiState.refreshing && uiState.canFetchMore) { + if (!uiState.initial && !uiState.loading && !uiState.refreshing && uiState.canFetchMore) { model.reduce(InboxChatMviModel.Intent.LoadNextPage) } - if (uiState.loading && !uiState.refreshing) { + if (!uiState.initial && uiState.loading && !uiState.refreshing) { Box( modifier = Modifier.fillMaxWidth().padding(Spacing.xs), contentAlignment = Alignment.Center, diff --git a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatViewModel.kt b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatViewModel.kt index f194ff4a0..dfed33a37 100644 --- a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatViewModel.kt +++ b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/InboxChatViewModel.kt @@ -6,6 +6,7 @@ import com.github.diegoberaldin.raccoonforlemmy.core.notifications.NotificationC import com.github.diegoberaldin.raccoonforlemmy.core.notifications.NotificationCenterEvent import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.SettingsRepository import com.github.diegoberaldin.raccoonforlemmy.domain.identity.repository.IdentityRepository +import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PrivateMessageModel import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.PostRepository import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.PrivateMessageRepository import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.SiteRepository @@ -59,6 +60,10 @@ class InboxChatViewModel( otherUserAvatar = user?.avatar, ) } + + if (uiState.value.messages.isEmpty()) { + refresh(initial = true) + } } } } @@ -66,59 +71,80 @@ class InboxChatViewModel( override fun reduce(intent: InboxChatMviModel.Intent) { when (intent) { - InboxChatMviModel.Intent.LoadNextPage -> loadNextPage() - is InboxChatMviModel.Intent.SubmitNewMessage -> submitNewMessage(intent.value) - is InboxChatMviModel.Intent.ImageSelected -> loadImageAndAppendUrlInBody(intent.value) + InboxChatMviModel.Intent.LoadNextPage -> { + mvi.scope?.launch(Dispatchers.IO) { + loadNextPage() + } + } + + is InboxChatMviModel.Intent.SubmitNewMessage -> { + submitNewMessage(intent.value) + } + + is InboxChatMviModel.Intent.ImageSelected -> { + loadImageAndAppendUrlInBody(intent.value) + } + + is InboxChatMviModel.Intent.EditMessage -> { + uiState.value.messages.firstOrNull { it.id == intent.value }?.also { message -> + startEditingMessage(message) + } + } } } - private fun refresh() { + private suspend fun refresh(initial: Boolean = false) { currentPage = 1 - mvi.updateState { it.copy(canFetchMore = true, refreshing = true) } + mvi.updateState { + it.copy( + initial = initial, + canFetchMore = true, + refreshing = true + ) + } loadNextPage() } - private fun loadNextPage() { + private suspend fun loadNextPage() { val currentState = mvi.uiState.value if (!currentState.canFetchMore || currentState.loading) { mvi.updateState { it.copy(refreshing = false) } return } - mvi.scope?.launch(Dispatchers.IO) { - mvi.updateState { it.copy(loading = true) } - val auth = identityRepository.authToken.value - val refreshing = currentState.refreshing - val itemList = messageRepository.getAll( - auth = auth, - page = currentPage, - unreadOnly = false, - )?.filter { - it.creator?.id == otherUserId || it.recipient?.id == otherUserId - }?.onEach { - if (!it.read) { - launch { - markAsRead(true, it.id) - } - } - } - if (!itemList.isNullOrEmpty()) { - currentPage++ + mvi.updateState { it.copy(loading = true) } + val auth = identityRepository.authToken.value + val refreshing = currentState.refreshing + val itemList = messageRepository.getAll( + creatorId = otherUserId, + auth = auth, + page = currentPage, + unreadOnly = false, + )?.onEach { + if (!it.read) { + markAsRead(true, it.id) } + } + if (!itemList.isNullOrEmpty()) { + currentPage++ + } - mvi.updateState { - val newItems = if (refreshing) { - itemList.orEmpty() - } else { - it.messages + itemList.orEmpty() - } - it.copy( - messages = newItems, - loading = false, - canFetchMore = itemList?.isEmpty() != true, - refreshing = false, - ) + val itemsToAdd = itemList.orEmpty().filter { + it.creator?.id == otherUserId || it.recipient?.id == otherUserId + } + mvi.updateState { + val newItems = if (refreshing) { + itemsToAdd + } else { + it.messages + itemsToAdd } + it.copy( + messages = newItems, + loading = false, + canFetchMore = itemList?.isEmpty() != true, + refreshing = false, + initial = false, + ) } } @@ -153,15 +179,36 @@ class InboxChatViewModel( } } + private fun startEditingMessage(message: PrivateMessageModel) { + mvi.updateState { + it.copy( + editedMessageId = message.id, + ) + } + } + private fun submitNewMessage(text: String) { + val editedMessageId = uiState.value.editedMessageId if (text.isNotEmpty()) { mvi.scope?.launch { val auth = identityRepository.authToken.value - messageRepository.create( - message = text, - auth = auth, - recipiendId = otherUserId, - ) + if (editedMessageId == null) { + messageRepository.create( + message = text, + recipiendId = otherUserId, + auth = auth, + ) + } else { + messageRepository.edit( + messageId = editedMessageId, + message = text, + auth = auth, + ) + } + + mvi.updateState { + it.copy(editedMessageId = null) + } refresh() } } diff --git a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/MessageCard.kt b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/MessageCard.kt index 823a03d66..5f704ba6c 100644 --- a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/MessageCard.kt +++ b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/chat/MessageCard.kt @@ -12,27 +12,44 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.CornerSize import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.IconSize import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing +import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.CustomDropDown import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.CustomizedContent +import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.Option +import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.OptionId import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.PostCardBody +import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.onClick +import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.rememberCallback import com.github.diegoberaldin.raccoonforlemmy.core.utils.datetime.prettifyDate +import com.github.diegoberaldin.raccoonforlemmy.core.utils.toLocalDp @Composable internal fun MessageCard( isMyMessage: Boolean = false, content: String = "", date: String = "", + options: List