Skip to content

Commit 9ca7932

Browse files
committed
feat: reply to multipart (WPB-16897)
1 parent 2af90fb commit 9ca7932

22 files changed

+624
-173
lines changed

app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import com.wire.kalium.logic.feature.message.SendMultipartMessageUseCase
4949
import com.wire.kalium.logic.feature.message.SendTextMessageUseCase
5050
import com.wire.kalium.logic.feature.message.ToggleReactionUseCase
5151
import com.wire.kalium.logic.feature.message.composite.SendButtonActionMessageUseCase
52-
import com.wire.kalium.logic.feature.message.draft.GetMessageDraftUseCase
52+
import com.wire.kalium.logic.feature.message.draft.ObserveMessageDraftUseCase
5353
import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase
5454
import com.wire.kalium.logic.feature.message.draft.SaveMessageDraftUseCase
5555
import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase
@@ -218,8 +218,8 @@ class MessageModule {
218218

219219
@ViewModelScoped
220220
@Provides
221-
fun provideGetMessageDraftUseCase(messageScope: MessageScope): GetMessageDraftUseCase =
222-
messageScope.getMessageDraftUseCase
221+
fun provideObserveMessageDraftUseCase(messageScope: MessageScope): ObserveMessageDraftUseCase =
222+
messageScope.observeMessageDraftUseCase
223223

224224
@ViewModelScoped
225225
@Provides

app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,8 @@ fun ConversationScreen(
261261
messageComposerViewState = messageComposerViewState,
262262
draftMessageComposition = messageDraftViewModel.state.value,
263263
onClearDraft = messageDraftViewModel::clearDraft,
264-
onSaveDraft = messageComposerViewModel::saveDraft,
264+
onSaveDraft = messageDraftViewModel::saveDraft,
265+
onMessageTextUpdate = messageDraftViewModel::onMessageTextUpdate,
265266
onSearchMentionQueryChanged = messageComposerViewModel::searchMembersToMention,
266267
onTypingEvent = messageComposerViewModel::sendTypingEvent,
267268
onClearMentionSearchResult = messageComposerViewModel::clearMentionSearchResult
@@ -314,12 +315,13 @@ fun ConversationScreen(
314315
LaunchedEffect(messageDraftViewModel.state.value.editMessageId) {
315316
val compositionState = messageDraftViewModel.state.value
316317
if (compositionState.editMessageId != null) {
318+
messageDraftViewModel.clearDraft()
317319
messageComposerStateHolder.toEdit(
318320
messageId = compositionState.editMessageId,
319321
editMessageText = messageDraftViewModel.state.value.draftText,
320322
mentions = compositionState.selectedMentions.map {
321323
it.intoMessageMention()
322-
}
324+
},
323325
)
324326
}
325327
}
@@ -1618,6 +1620,7 @@ fun PreviewConversationScreen() = WireTheme {
16181620
draftMessageComposition = messageCompositionState.value,
16191621
onClearDraft = {},
16201622
onSaveDraft = {},
1623+
onMessageTextUpdate = {},
16211624
onTypingEvent = {},
16221625
onSearchMentionQueryChanged = {},
16231626
onClearMentionSearchResult = {},

app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,13 @@ import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode
4242
import com.wire.kalium.logic.data.conversation.InteractionAvailability
4343
import com.wire.kalium.logic.data.id.QualifiedID
4444
import com.wire.kalium.logic.data.message.SelfDeletionTimer
45-
import com.wire.kalium.logic.data.message.draft.MessageDraft
4645
import com.wire.kalium.logic.data.user.OtherUser
4746
import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase
4847
import com.wire.kalium.logic.feature.conversation.IsInteractionAvailableResult
4948
import com.wire.kalium.logic.feature.conversation.MembersToMentionUseCase
5049
import com.wire.kalium.logic.feature.conversation.ObserveConversationInteractionAvailabilityUseCase
5150
import com.wire.kalium.logic.feature.conversation.SendTypingEventUseCase
5251
import com.wire.kalium.logic.feature.conversation.UpdateConversationReadDateUseCase
53-
import com.wire.kalium.logic.feature.message.draft.SaveMessageDraftUseCase
5452
import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase
5553
import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase
5654
import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase
@@ -81,7 +79,6 @@ class MessageComposerViewModel @Inject constructor(
8179
private val enqueueMessageSelfDeletion: EnqueueMessageSelfDeletionUseCase,
8280
private val persistNewSelfDeletingStatus: PersistNewSelfDeletionTimerUseCase,
8381
private val sendTypingEvent: SendTypingEventUseCase,
84-
private val saveMessageDraft: SaveMessageDraftUseCase,
8582
private val fileManager: FileManager,
8683
private val kaliumFileSystem: KaliumFileSystem,
8784
private val currentSessionFlowUseCase: CurrentSessionFlowUseCase,
@@ -226,12 +223,6 @@ class MessageComposerViewModel @Inject constructor(
226223
}
227224
}
228225

229-
fun saveDraft(messageDraft: MessageDraft) {
230-
viewModelScope.launch {
231-
saveMessageDraft(messageDraft)
232-
}
233-
}
234-
235226
private fun observeCallState() = viewModelScope.launch {
236227
observeEstablishedCalls()
237228
.map { it.isNotEmpty() }

app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,18 @@ internal fun QuotedMessage(
170170
startContent = startContent,
171171
clickable = clickable
172172
)
173+
174+
is UIQuotedMessage.UIQuotedData.Multipart -> QuotedMultipartMessage(
175+
senderName = messageData.senderName,
176+
originalDateTimeText = messageData.originalMessageDateDescription,
177+
text = quotedContent.text?.asString(),
178+
attachments = quotedContent.attachments,
179+
accent = messageData.senderAccent,
180+
modifier = modifier,
181+
style = style,
182+
startContent = startContent,
183+
clickable = clickable
184+
)
173185
}
174186
}
175187

@@ -210,7 +222,7 @@ fun QuotedMessagePreview(
210222

211223
@Composable
212224
@Suppress("LongParameterList")
213-
private fun QuotedMessageContent(
225+
internal fun QuotedMessageContent(
214226
senderName: String?,
215227
style: QuotedMessageStyle,
216228
modifier: Modifier = Modifier,
@@ -411,7 +423,7 @@ private fun QuotedText(
411423
}
412424

413425
@Composable
414-
private fun QuotedMessageOriginalDate(
426+
internal fun QuotedMessageOriginalDate(
415427
originalDateTimeText: UIText,
416428
style: QuotedMessageStyle
417429
) {
@@ -602,14 +614,13 @@ fun QuotedAudioMessage(
602614
}
603615

604616
@Composable
605-
private fun MainMarkdownText(text: String, messageStyle: MessageStyle, accent: Accent, fontStyle: FontStyle = FontStyle.Normal) {
617+
internal fun MainMarkdownText(text: String, messageStyle: MessageStyle, accent: Accent, fontStyle: FontStyle = FontStyle.Normal) {
606618

607619
val color = when (messageStyle) {
608620
MessageStyle.BUBBLE_SELF -> colorsScheme().selfBubble.onSecondary
609621
MessageStyle.BUBBLE_OTHER -> colorsScheme().otherBubble.onSecondary
610622
MessageStyle.NORMAL -> colorsScheme().onSurfaceVariant
611623
}
612-
613624
val nodeData = NodeData(
614625
color = color,
615626
style = MaterialTheme.wireTypography.subline01.copy(fontStyle = fontStyle),
@@ -639,7 +650,7 @@ private fun MainMarkdownText(text: String, messageStyle: MessageStyle, accent: A
639650
}
640651

641652
@Composable
642-
private fun MainContentText(text: String, fontStyle: FontStyle = FontStyle.Normal) {
653+
internal fun MainContentText(text: String, fontStyle: FontStyle = FontStyle.Normal) {
643654
Text(
644655
text = text,
645656
style = typography().subline01,
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* Wire
3+
* Copyright (C) 2025 Wire Swiss GmbH
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see http://www.gnu.org/licenses/.
17+
*/
18+
package com.wire.android.ui.home.conversations.messages
19+
20+
import androidx.compose.foundation.Image
21+
import androidx.compose.foundation.border
22+
import androidx.compose.foundation.layout.Arrangement
23+
import androidx.compose.foundation.layout.Box
24+
import androidx.compose.foundation.layout.Column
25+
import androidx.compose.foundation.layout.Row
26+
import androidx.compose.foundation.layout.fillMaxSize
27+
import androidx.compose.foundation.layout.height
28+
import androidx.compose.foundation.layout.size
29+
import androidx.compose.foundation.layout.width
30+
import androidx.compose.foundation.shape.RoundedCornerShape
31+
import androidx.compose.material.Text
32+
import androidx.compose.material3.Icon
33+
import androidx.compose.material3.MaterialTheme
34+
import androidx.compose.runtime.Composable
35+
import androidx.compose.ui.Alignment
36+
import androidx.compose.ui.Modifier
37+
import androidx.compose.ui.draw.clip
38+
import androidx.compose.ui.layout.ContentScale
39+
import androidx.compose.ui.platform.LocalContext
40+
import androidx.compose.ui.res.painterResource
41+
import androidx.compose.ui.res.pluralStringResource
42+
import androidx.compose.ui.res.stringResource
43+
import coil.compose.AsyncImage
44+
import coil.decode.VideoFrameDecoder
45+
import coil.request.ImageRequest
46+
import com.wire.android.R
47+
import com.wire.android.feature.cells.domain.model.AttachmentFileType
48+
import com.wire.android.feature.cells.domain.model.AttachmentFileType.IMAGE
49+
import com.wire.android.feature.cells.domain.model.AttachmentFileType.VIDEO
50+
import com.wire.android.feature.cells.domain.model.icon
51+
import com.wire.android.model.Clickable
52+
import com.wire.android.ui.common.colorsScheme
53+
import com.wire.android.ui.common.dimensions
54+
import com.wire.android.ui.home.conversations.model.UIMultipartQuotedContent
55+
import com.wire.android.ui.theme.Accent
56+
import com.wire.android.ui.theme.wireColorScheme
57+
import com.wire.android.util.ui.UIText
58+
import com.wire.kalium.logic.util.fileExtension
59+
60+
@Composable
61+
fun QuotedMultipartMessage(
62+
senderName: UIText,
63+
originalDateTimeText: UIText,
64+
text: String?,
65+
attachments: List<UIMultipartQuotedContent>,
66+
style: QuotedMessageStyle,
67+
accent: Accent,
68+
clickable: Clickable?,
69+
modifier: Modifier = Modifier,
70+
startContent: @Composable () -> Unit = {}
71+
) {
72+
QuotedMessageContent(
73+
senderName = senderName.asString(),
74+
style = style,
75+
accent = accent,
76+
modifier = modifier,
77+
centerContent = {
78+
Column(
79+
modifier = Modifier.fillMaxSize(),
80+
verticalArrangement = Arrangement.spacedBy(
81+
dimensions().spacing4x,
82+
Alignment.CenterVertically
83+
),
84+
) {
85+
86+
// Message text or file name
87+
when {
88+
text?.isNotEmpty() == true -> MainMarkdownText(text, messageStyle = style.messageStyle)
89+
attachments.isSingleMediaAttachment() ->
90+
if (attachments.first().assetAvailable) {
91+
MainContentText(attachments.first().name)
92+
} else {
93+
MainContentText(stringResource(R.string.asset_message_failed_download_text))
94+
}
95+
}
96+
97+
when {
98+
attachments.size > 1 -> MultipleAttachmentsLabel(attachments.size)
99+
attachments.isSingleFileAttachment() -> FileIconAndNameRow(attachments.first())
100+
}
101+
}
102+
},
103+
startContent = {
104+
startContent()
105+
},
106+
endContent = {
107+
if (attachments.isSingleMediaAttachment()) {
108+
MediaAttachmentThumbnail(attachments.first())
109+
}
110+
},
111+
footerContent = { QuotedMessageOriginalDate(originalDateTimeText) },
112+
clickable = clickable
113+
)
114+
}
115+
116+
@Composable
117+
private fun MediaAttachmentThumbnail(attachment: UIMultipartQuotedContent) {
118+
if (attachment.assetAvailable) {
119+
Box(
120+
modifier = Modifier
121+
.width(dimensions().spacing40x)
122+
.height(dimensions().spacing40x)
123+
.border(
124+
width = dimensions().spacing1x,
125+
color = MaterialTheme.wireColorScheme.outline,
126+
shape = RoundedCornerShape(dimensions().spacing8x)
127+
)
128+
.clip(RoundedCornerShape(dimensions().spacing8x)),
129+
) {
130+
AsyncImage(
131+
model = attachment.imageModel(),
132+
alignment = Alignment.Center,
133+
contentScale = ContentScale.Crop,
134+
contentDescription = null,
135+
)
136+
137+
if (AttachmentFileType.fromMimeType(attachment.mimeType) == VIDEO) {
138+
Image(
139+
modifier = Modifier
140+
.size(dimensions().spacing24x)
141+
.align(Alignment.Center),
142+
painter = painterResource(id = R.drawable.ic_play_circle_filled),
143+
contentDescription = null,
144+
)
145+
}
146+
}
147+
} else {
148+
Image(
149+
modifier = Modifier.size(dimensions().spacing24x),
150+
painter = painterResource(id = R.drawable.ic_file_not_available),
151+
contentDescription = null,
152+
)
153+
}
154+
}
155+
156+
@Composable
157+
private fun MultipleAttachmentsLabel(count: Int) {
158+
Row(
159+
horizontalArrangement = Arrangement.spacedBy(dimensions().spacing4x)
160+
) {
161+
Icon(
162+
painter = painterResource(R.drawable.ic_multiple_files),
163+
contentDescription = null,
164+
tint = colorsScheme().secondaryText
165+
)
166+
Text(
167+
text = pluralStringResource(R.plurals.reply_multiple_files, count, count),
168+
color = colorsScheme().secondaryText
169+
)
170+
}
171+
}
172+
173+
@Composable
174+
private fun FileIconAndNameRow(file: UIMultipartQuotedContent) {
175+
176+
val name = file.name
177+
val attachmentFileType = name.fileExtension()?.let { AttachmentFileType.fromExtension(it) } ?: AttachmentFileType.OTHER
178+
179+
Row(
180+
horizontalArrangement = Arrangement.spacedBy(dimensions().spacing4x)
181+
) {
182+
Image(
183+
modifier = Modifier.size(dimensions().spacing16x),
184+
painter = if (file.assetAvailable) {
185+
painterResource(id = attachmentFileType.icon())
186+
} else {
187+
painterResource(id = R.drawable.ic_file_not_available)
188+
},
189+
contentDescription = null,
190+
)
191+
Text(
192+
text = if (file.assetAvailable) name else stringResource(R.string.asset_message_failed_download_text),
193+
color = colorsScheme().secondaryText
194+
)
195+
}
196+
}
197+
198+
@Composable
199+
private fun UIMultipartQuotedContent.imageModel(): ImageRequest {
200+
201+
val builder = ImageRequest.Builder(LocalContext.current)
202+
.diskCacheKey(previewUrl)
203+
.memoryCacheKey(previewUrl)
204+
.data(localPath ?: previewUrl)
205+
.crossfade(true)
206+
207+
if (localPath != null && AttachmentFileType.fromMimeType(mimeType) == VIDEO) {
208+
builder.decoderFactory { result, options, _ ->
209+
VideoFrameDecoder(result.source, options)
210+
}
211+
}
212+
213+
return builder.build()
214+
}
215+
216+
private fun UIMultipartQuotedContent.isMediaAttachment() =
217+
when (AttachmentFileType.fromMimeType(mimeType)) {
218+
IMAGE, VIDEO -> true
219+
else -> false
220+
}
221+
222+
private fun List<UIMultipartQuotedContent>.isSingleMediaAttachment() =
223+
size == 1 && first().isMediaAttachment()
224+
225+
private fun List<UIMultipartQuotedContent>.isSingleFileAttachment() =
226+
size == 1 && !first().isMediaAttachment()

0 commit comments

Comments
 (0)