Skip to content

Commit edd87b3

Browse files
committed
feat: Show offline indicator for files in conversation (WPB-23968) (#4846)
(cherry picked from commit 835d75e)
1 parent c137bde commit edd87b3

7 files changed

Lines changed: 172 additions & 19 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import com.wire.kalium.cells.domain.usecase.GetConversationNameUseCase
6666
import com.wire.kalium.cells.domain.usecase.GetUserNameUseCase
6767
import com.wire.kalium.cells.domain.usecase.offline.DeleteOfflineFileUseCase
6868
import com.wire.kalium.cells.domain.usecase.offline.GetOfflineFileUseCase
69+
import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesByConversationUseCase
6970
import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesUseCase
7071
import com.wire.kalium.cells.domain.usecase.offline.SaveOfflineFileUseCase
7172
import com.wire.kalium.cells.paginatedConversationsFlowUseCase
@@ -279,6 +280,10 @@ class CellsModule {
279280
fun provideObserveOfflineFilesUseCase(cellsScope: CellsScope): ObserveOfflineFilesUseCase = cellsScope.observeOfflineFiles
280281

281282
@ViewModelScoped
283+
@Provides
284+
fun provideObserveOfflineFilesByConversationUseCase(cellsScope: CellsScope): ObserveOfflineFilesByConversationUseCase =
285+
cellsScope.observeOfflineFilesByConversation
286+
282287
@Provides
283288
fun provideGetOfflineFileUseCase(cellsScope: CellsScope): GetOfflineFileUseCase = cellsScope.getOfflineFile
284289

app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,19 @@ data class MultipartAttachmentUi(
4040
val transferStatus: AssetTransferStatus,
4141
val progress: Float? = null,
4242
val isEditSupported: Boolean = false,
43+
val isAvailableOffline: Boolean = false,
4344
)
4445

4546
enum class AssetSource {
4647
CELL, ASSET_STORAGE
4748
}
4849

49-
fun MessageAttachment.toUiModel(progress: Float? = null) = when (this) {
50-
is AssetContent -> this.toUiModel(progress)
51-
is CellAssetContent -> this.toUiModel(progress)
50+
fun MessageAttachment.toUiModel(progress: Float? = null, isAvailableOffline: Boolean = false) = when (this) {
51+
is AssetContent -> this.toUiModel(progress, isAvailableOffline)
52+
is CellAssetContent -> this.toUiModel(progress, isAvailableOffline)
5253
}
5354

54-
fun CellAssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi(
55+
fun CellAssetContent.toUiModel(progress: Float?, isAvailableOffline: Boolean = false) = MultipartAttachmentUi(
5556
uuid = this.id,
5657
source = AssetSource.CELL,
5758
fileName = this.assetPath?.substringAfterLast("/"),
@@ -67,9 +68,10 @@ fun CellAssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi(
6768
progress = progress,
6869
contentHash = contentHash,
6970
isEditSupported = isEditSupported,
71+
isAvailableOffline = isAvailableOffline,
7072
)
7173

72-
fun AssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi(
74+
fun AssetContent.toUiModel(progress: Float?, isAvailableOffline: Boolean = false) = MultipartAttachmentUi(
7375
uuid = this.remoteData.assetId,
7476
source = AssetSource.ASSET_STORAGE,
7577
fileName = this.name,
@@ -84,4 +86,5 @@ fun AssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi(
8486
contentHash = null,
8587
contentUrl = null,
8688
isEditSupported = false,
89+
isAvailableOffline = isAvailableOffline,
8790
)

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ import androidx.compose.foundation.lazy.grid.GridCells
2525
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
2626
import androidx.compose.foundation.lazy.grid.items
2727
import androidx.compose.runtime.Composable
28+
import androidx.compose.runtime.getValue
29+
import androidx.compose.runtime.remember
2830
import androidx.compose.ui.Modifier
2931
import androidx.compose.ui.layout.onVisibilityChanged
3032
import androidx.compose.ui.platform.LocalConfiguration
3133
import androidx.compose.ui.platform.LocalContext
3234
import androidx.compose.ui.platform.LocalInspectionMode
3335
import androidx.hilt.navigation.compose.hiltViewModel
36+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
3437
import coil3.decode.Decoder
3538
import coil3.request.ImageRequest
3639
import coil3.request.crossfade
@@ -62,10 +65,16 @@ fun MultipartAttachmentsView(
6265
else -> hiltViewModel<MultipartAttachmentsViewModelImpl>(key = conversationId.value)
6366
}
6467
) {
68+
// Collect to trigger recomposition when offline availability changes.
69+
val offlineAttachmentIds by viewModel.offlineAttachmentIds.collectAsStateWithLifecycle()
6570

6671
// TODO I found out that empty attachments list is not handled here and it shows empty message with no information
6772
if (attachments.size == 1) {
68-
attachments.first().toUiModel().let {
73+
val attachment = attachments.first()
74+
val item = remember(attachment, offlineAttachmentIds) {
75+
viewModel.mapAttachment(attachment)
76+
}
77+
item.let {
6978
AssetPreview(
7079
modifier = modifier
7180
.onVisibilityChanged { visible ->
@@ -86,7 +95,9 @@ fun MultipartAttachmentsView(
8695
)
8796
}
8897
} else {
89-
val groups = viewModel.mapAttachments(attachments)
98+
val groups = remember(attachments, offlineAttachmentIds) {
99+
viewModel.mapAttachments(attachments = attachments)
100+
}
90101

91102
Column(
92103
modifier = modifier

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,46 +32,67 @@ import com.wire.android.util.FileManager
3232
import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase
3333
import com.wire.kalium.cells.domain.usecase.GetWireCellConfigurationUseCase
3434
import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase
35+
import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesByConversationUseCase
3536
import com.wire.kalium.common.functional.onSuccess
3637
import com.wire.kalium.logic.data.asset.AssetTransferStatus
3738
import com.wire.kalium.logic.data.asset.KaliumFileSystem
3839
import com.wire.kalium.logic.data.featureConfig.CollaboraEdition
40+
import com.wire.kalium.logic.data.id.ConversationId
3941
import com.wire.kalium.logic.data.message.AssetContent
4042
import com.wire.kalium.logic.data.message.CellAssetContent
4143
import com.wire.kalium.logic.data.message.MessageAttachment
4244
import com.wire.kalium.logic.featureFlags.KaliumConfigs
4345
import dagger.hilt.android.lifecycle.HiltViewModel
4446
import kotlinx.collections.immutable.toImmutableList
47+
import kotlinx.coroutines.flow.SharingStarted
48+
import kotlinx.coroutines.flow.StateFlow
49+
import kotlinx.coroutines.flow.map
50+
import kotlinx.coroutines.flow.stateIn
51+
import kotlinx.coroutines.flow.MutableStateFlow
4552
import kotlinx.coroutines.launch
4653
import okio.Path.Companion.toPath
4754
import javax.inject.Inject
4855

4956
interface MultipartAttachmentsViewModel {
57+
val offlineAttachmentIds: StateFlow<Set<String>>
5058
fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit)
59+
fun mapAttachment(attachment: MessageAttachment): MultipartAttachmentUi {
60+
val isAvailableOffline = attachment.assetId() in offlineAttachmentIds.value
61+
return attachment.toUiModel(isAvailableOffline = isAvailableOffline)
62+
}
63+
5164
fun mapAttachments(
52-
attachments: List<MessageAttachment>
65+
attachments: List<MessageAttachment>,
5366
): List<MultipartAttachmentGroup> {
67+
val offlineIds = offlineAttachmentIds.value
5468

5569
val result = mutableListOf<MultipartAttachmentGroup>()
5670
var group: MultipartAttachmentGroup? = null
5771

5872
attachments.forEach {
73+
val isAvailableOffline = it.assetId() in offlineIds
5974
if (it.isMediaAttachment()) {
6075
group = when (group) {
61-
null -> MultipartAttachmentGroup.Media(listOf(it.toUiModel()))
62-
is MultipartAttachmentGroup.Media -> group.copy(group.attachments + it.toUiModel())
76+
null -> MultipartAttachmentGroup.Media(listOf(it.toUiModel(isAvailableOffline = isAvailableOffline)))
77+
is MultipartAttachmentGroup.Media -> {
78+
val newAttachment = it.toUiModel(isAvailableOffline = isAvailableOffline)
79+
group.copy(attachments = group.attachments + newAttachment)
80+
}
6381
else -> {
6482
result.add(group)
65-
MultipartAttachmentGroup.Media(listOf(it.toUiModel()))
83+
MultipartAttachmentGroup.Media(listOf(it.toUiModel(isAvailableOffline = isAvailableOffline)))
6684
}
6785
}
6886
} else {
6987
group = when (group) {
70-
null -> MultipartAttachmentGroup.Files(listOf(it.toUiModel()))
71-
is MultipartAttachmentGroup.Files -> group.copy(group.attachments + it.toUiModel())
88+
null -> MultipartAttachmentGroup.Files(listOf(it.toUiModel(isAvailableOffline = isAvailableOffline)))
89+
is MultipartAttachmentGroup.Files -> {
90+
val newAttachment = it.toUiModel(isAvailableOffline = isAvailableOffline)
91+
group.copy(attachments = group.attachments + newAttachment)
92+
}
7293
else -> {
7394
result.add(group)
74-
MultipartAttachmentGroup.Files(listOf(it.toUiModel()))
95+
MultipartAttachmentGroup.Files(listOf(it.toUiModel(isAvailableOffline = isAvailableOffline)))
7596
}
7697
}
7798
}
@@ -95,13 +116,16 @@ interface MultipartAttachmentsViewModel {
95116

96117
@Suppress("EmptyFunctionBlock")
97118
object MultipartAttachmentsViewModelPreview : MultipartAttachmentsViewModel {
119+
override val offlineAttachmentIds: StateFlow<Set<String>> = MutableStateFlow(emptySet<String>())
98120
override fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit) {}
99121
override fun onAttachmentsVisible(attachments: List<MessageAttachment>) {}
100122
override fun onAttachmentsHidden(attachments: List<MessageAttachment>) {}
101123
}
102124

103125
@HiltViewModel
126+
@Suppress("LongParameterList")
104127
class MultipartAttachmentsViewModelImpl @Inject constructor(
128+
private val conversationId: ConversationId,
105129
private val refreshHelper: CellAssetRefreshHelper,
106130
private val download: DownloadCellFileUseCase,
107131
private val getEditorUrl: GetEditorUrlUseCase,
@@ -110,17 +134,24 @@ class MultipartAttachmentsViewModelImpl @Inject constructor(
110134
private val kaliumFileSystem: KaliumFileSystem,
111135
private val featureFlags: KaliumConfigs,
112136
private val getWireCellsConfig: GetWireCellConfigurationUseCase,
137+
observeOfflineFilesByConversation: ObserveOfflineFilesByConversationUseCase,
113138
) : ViewModel(), MultipartAttachmentsViewModel {
114139

115140
private val uploadProgress = mutableStateMapOf<String, Float>()
141+
override val offlineAttachmentIds: StateFlow<Set<String>> = observeOfflineFilesByConversation(conversationId)
142+
.map { offlineFiles -> offlineFiles.mapTo(mutableSetOf()) { it.id } }
143+
.stateIn(viewModelScope, SharingStarted.Eagerly, emptySet())
116144

117145
private var isCollaboraEnabled: Boolean = false
118146

119147
init {
120148
loadWireCellConfig()
121149
}
122150

123-
override fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit) {
151+
override fun onClick(
152+
attachment: MultipartAttachmentUi,
153+
openInImageViewer: (String) -> Unit,
154+
) {
124155
when {
125156
attachment.isImage() && !attachment.fileNotFound() -> openInImageViewer(attachment.uuid)
126157
attachment.isEditSupported && isCollaboraEnabled && featureFlags.collaboraIntegration ->
@@ -174,7 +205,7 @@ class MultipartAttachmentsViewModelImpl @Inject constructor(
174205

175206
download(
176207
assetId = attachment.uuid,
177-
conversationId = null, // TODO to replace with real conversation id in next PR
208+
conversationId = conversationId.value,
178209
outFilePath = path,
179210
assetSize = attachment.assetSize ?: 0,
180211
) { progress ->
@@ -208,7 +239,7 @@ class MultipartAttachmentsViewModelImpl @Inject constructor(
208239
}
209240
}
210241

211-
private fun MessageAttachment.assetId() =
242+
internal fun MessageAttachment.assetId() =
212243
when (this) {
213244
is AssetContent -> remoteData.assetId
214245
is CellAssetContent -> id

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/grid/AssetGridPreview.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@ import androidx.compose.foundation.layout.padding
2828
import androidx.compose.foundation.layout.size
2929
import androidx.compose.foundation.shape.RoundedCornerShape
3030
import androidx.compose.material3.CircularProgressIndicator
31+
import androidx.compose.material3.Icon
3132
import androidx.compose.runtime.Composable
3233
import androidx.compose.ui.Alignment
3334
import androidx.compose.ui.Modifier
3435
import androidx.compose.ui.draw.clip
3536
import androidx.compose.ui.graphics.Color
37+
import androidx.compose.ui.res.painterResource
38+
import com.wire.android.feature.cells.R
3639
import com.wire.android.feature.cells.domain.model.AttachmentFileType
3740
import com.wire.android.ui.common.applyIf
3841
import com.wire.android.ui.common.colorsScheme
@@ -92,6 +95,20 @@ internal fun AssetGridPreview(
9295
}
9396
}
9497

98+
if (item.isAvailableOffline) {
99+
Icon(
100+
modifier = Modifier
101+
.padding(
102+
end = dimensions().spacing6x,
103+
top = dimensions().spacing6x
104+
)
105+
.align(Alignment.TopEnd),
106+
painter = painterResource(R.drawable.ic_downloaded),
107+
contentDescription = null,
108+
tint = colorsScheme().secondaryText,
109+
)
110+
}
111+
95112
item.progress?.let {
96113
CircularProgressIndicator(
97114
modifier = Modifier

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/AssetPreview.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,17 @@ import androidx.compose.foundation.background
2222
import androidx.compose.foundation.border
2323
import androidx.compose.foundation.clickable
2424
import androidx.compose.foundation.layout.Box
25+
import androidx.compose.foundation.layout.padding
2526
import androidx.compose.foundation.shape.RoundedCornerShape
27+
import androidx.compose.material3.Icon
2628
import androidx.compose.runtime.Composable
29+
import androidx.compose.ui.Alignment
2730
import androidx.compose.ui.Modifier
2831
import androidx.compose.ui.draw.clip
2932
import androidx.compose.ui.platform.LocalConfiguration
33+
import androidx.compose.ui.res.painterResource
3034
import androidx.compose.ui.unit.Dp
35+
import com.wire.android.feature.cells.R
3136
import com.wire.android.feature.cells.domain.model.AttachmentFileType
3237
import com.wire.android.ui.common.applyIf
3338
import com.wire.android.ui.common.colorsScheme
@@ -76,6 +81,19 @@ fun AssetPreview(
7681
item.isEditSupported -> EditableAssetPreview(item, messageStyle)
7782
else -> FileAssetPreview(item, messageStyle)
7883
}
84+
if (item.isAvailableOffline) {
85+
Icon(
86+
modifier = Modifier
87+
.padding(
88+
end = dimensions().spacing6x,
89+
top = dimensions().spacing6x
90+
)
91+
.align(Alignment.TopEnd),
92+
painter = painterResource(R.drawable.ic_downloaded),
93+
contentDescription = null,
94+
tint = colorsScheme().secondaryText,
95+
)
96+
}
7997
} else {
8098
AssetNotAvailablePreview(messageStyle = messageStyle)
8199
}

0 commit comments

Comments
 (0)