feat: edit chat message (#310); closes #226, #202

* feat: add possibility to edit messages; closes #226

* fix: chat loading messages; closes #202
This commit is contained in:
Diego Beraldin 2023-12-16 14:17:36 +01:00 committed by GitHub
parent b878c3d0eb
commit 08c1490502
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 292 additions and 69 deletions

View File

@ -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,
)

View File

@ -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<PrivateMessagesResponse>
@ -29,6 +33,13 @@ interface PrivateMessageService {
@Body form: CreatePrivateMessageForm,
): Response<PrivateMessageResponse>
@PUT("private_message")
@Headers("Content-Type: application/json")
suspend fun editPrivateMessage(
@Header("Authorization") authHeader: String? = null,
@Body form: EditPrivateMessageForm,
): Response<PrivateMessageResponse>
@POST("private_message/mark_as_read")
@Headers("Content-Type: application/json")
suspend fun markPrivateMessageAsRead(

View File

@ -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<PrivateMessageModel> = emptyList(),
val autoLoadImages: Boolean = true,
val editedMessageId: Int? = null,
)
sealed interface Effect {

View File

@ -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,

View File

@ -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()
}
}

View File

@ -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<Option> = emptyList(),
onOptionSelected: ((OptionId) -> Unit)? = null,
) {
val color = if (isMyMessage) {
MaterialTheme.colorScheme.tertiaryContainer
@ -46,6 +63,10 @@ internal fun MessageCard(
}
val longDistance = Spacing.l
val mediumDistance = Spacing.s
val ancillaryColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f)
var optionsExpanded by remember { mutableStateOf(false) }
var optionsOffset by remember { mutableStateOf(Offset.Zero) }
Box {
Canvas(
modifier = Modifier.size(mediumDistance).let {
@ -95,30 +116,74 @@ internal fun MessageCard(
PostCardBody(
text = content,
)
Row(
horizontalArrangement = Arrangement.spacedBy(Spacing.xxxs),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = Modifier.weight(1f))
Box {
Row(
horizontalArrangement = Arrangement.spacedBy(Spacing.xxxs),
verticalAlignment = Alignment.CenterVertically,
) {
if (options.isNotEmpty()) {
Icon(
modifier = Modifier.size(IconSize.m)
.padding(Spacing.xs)
.onGloballyPositioned {
optionsOffset = it.positionInParent()
}
.onClick(
onClick = rememberCallback {
optionsExpanded = true
},
),
imageVector = Icons.Default.MoreHoriz,
contentDescription = null,
tint = ancillaryColor
)
}
Spacer(modifier = Modifier.weight(1f))
if (date.isNotEmpty()) {
val buttonModifier = Modifier.size(IconSize.m).padding(3.5.dp)
Icon(
modifier = buttonModifier,
imageVector = Icons.Default.Schedule,
contentDescription = null,
tint = textColor,
)
Text(
text = date.prettifyDate(),
style = MaterialTheme.typography.labelMedium,
color = textColor,
)
} else {
Text(
text = "",
style = MaterialTheme.typography.labelSmall,
)
if (date.isNotEmpty()) {
val buttonModifier = Modifier.size(IconSize.m).padding(3.5.dp)
Icon(
modifier = buttonModifier,
imageVector = Icons.Default.Schedule,
contentDescription = null,
tint = ancillaryColor,
)
Text(
text = date.prettifyDate(),
style = MaterialTheme.typography.labelMedium,
color = ancillaryColor,
)
} else {
Text(
text = "",
style = MaterialTheme.typography.labelSmall,
)
}
}
CustomDropDown(
expanded = optionsExpanded,
onDismiss = {
optionsExpanded = false
},
offset = DpOffset(
x = optionsOffset.x.toLocalDp(),
y = optionsOffset.y.toLocalDp(),
),
) {
options.forEach { option ->
Text(
modifier = Modifier.padding(
horizontal = Spacing.m,
vertical = Spacing.s,
).onClick(
onClick = rememberCallback {
optionsExpanded = false
onOptionSelected?.invoke(option.id)
},
),
text = option.text,
)
}
}
}
}

View File

@ -0,0 +1,23 @@
package com.github.diegoberaldin.raccoonforlemmy.core.commonui.chat
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.CornerSize
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.shimmerEffect
@Composable
fun MessageCardPlaceholder() {
Box(
modifier = Modifier
.height(100.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(CornerSize.s))
.shimmerEffect()
)
}

View File

@ -77,7 +77,6 @@ fun InboxReplySubtitle(
val defaultDownVoteColor = MaterialTheme.colorScheme.tertiary
var optionsExpanded by remember { mutableStateOf(false) }
var optionsOffset by remember { mutableStateOf(Offset.Zero) }
val fullColor = MaterialTheme.colorScheme.onBackground
val ancillaryColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f)
Column(

View File

@ -1,6 +1,7 @@
package com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository
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.provider.ServiceProvider
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PrivateMessageModel
@ -13,6 +14,7 @@ internal class DefaultPrivateMessageRepository(
override suspend fun getAll(
auth: String?,
creatorId: Int?,
page: Int,
limit: Int,
unreadOnly: Boolean,
@ -20,6 +22,7 @@ internal class DefaultPrivateMessageRepository(
val response = services.privateMessages.getPrivateMessages(
authHeader = auth.toAuthHeader(),
auth = auth,
creatorId = creatorId,
limit = limit,
page = page,
unreadOnly = unreadOnly,
@ -44,6 +47,18 @@ internal class DefaultPrivateMessageRepository(
)
}
override suspend fun edit(messageId: Int, message: String, auth: String?) {
val data = EditPrivateMessageForm(
content = message,
auth = auth.orEmpty(),
privateMessageId = messageId,
)
services.privateMessages.editPrivateMessage(
authHeader = auth.toAuthHeader(),
form = data,
)
}
override suspend fun markAsRead(
messageId: Int,
auth: String?,

View File

@ -10,6 +10,7 @@ interface PrivateMessageRepository {
suspend fun getAll(
auth: String? = null,
creatorId: Int? = null,
page: Int,
limit: Int = DEFAULT_PAGE_SIZE,
unreadOnly: Boolean = true,
@ -21,6 +22,12 @@ interface PrivateMessageRepository {
recipiendId: Int,
)
suspend fun edit(
messageId: Int,
message: String,
auth: String? = null,
)
suspend fun markAsRead(
messageId: Int,
auth: String? = null,