Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
26ca3eb
Show file upload progress and placeholder media message in chat.
anakin78z Apr 30, 2026
7db055f
fixes after resolving merge conflicts
mahibi Jun 9, 2026
b45db84
fix and improve upload progress in chat
mahibi Aug 8, 2026
ad108a2
set negative placeholderId (not tested)
mahibi Aug 17, 2026
6e607b4
fix: cancelling an upload could still share the file
mahibi Aug 19, 2026
6e5596a
backoff handling for fetchNewMessagesWithRetry
mahibi Aug 19, 2026
3126020
remove right corner loading spinner
mahibi Aug 19, 2026
1b71ecb
fix: media message flicker, padding/corner mismatch, and unread-marke…
mahibi Aug 19, 2026
fb0df2d
feat: shrink portrait media messages to 80% width
mahibi Aug 20, 2026
b1645b8
fix: uploaded files could appear out of the order they were sent in
mahibi Aug 20, 2026
22bc7f4
fix: upload placeholders could show out of send order before syncing
mahibi Aug 20, 2026
183d308
fix: chat order could flip while some batch-sent files were still upl…
mahibi Aug 20, 2026
49c0581
Revert "fix: chat order could flip while some batch-sent files were s…
mahibi Aug 20, 2026
5cfcfa3
fix: chat order could flip while some batch-sent files were still upl…
mahibi Aug 20, 2026
e75044c
refactor: apply reorderKnownSendSequence the same way as the grouping…
mahibi Aug 20, 2026
cc75dd6
fix: match uploading image placeholder height to its final aspect ratio
mahibi Aug 20, 2026
071b468
fix: confine upload placeholder blur within the image's clipped bounds
mahibi Aug 20, 2026
5c843ef
fix: media bubble collapses to zero height when server omits width/he…
mahibi Aug 20, 2026
742ba46
set min size for generic files
mahibi Aug 20, 2026
bdfd76e
style: correctly tint the package/folder icon
AndyScherzinger Aug 20, 2026
25a2596
chore: add Compose previews for ThumbnailStrip and use outline varian…
AndyScherzinger Aug 20, 2026
ad2cfbc
style: theme the attachment action tiles as Material3 tonal buttons
AndyScherzinger Aug 20, 2026
1814912
chore: add an RTL preview variant for the attachment preview screen
AndyScherzinger Aug 20, 2026
bf6fba5
style: match the attachment preview app bar to the M3 headline/sublin…
AndyScherzinger Aug 20, 2026
9f2ab88
fix: upload placeholder vanished mid-upload for large images
AndyScherzinger Aug 20, 2026
347f82d
ci(detekt): Bump score
AndyScherzinger Aug 20, 2026
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
3 changes: 2 additions & 1 deletion app/src/main/java/com/nextcloud/talk/api/NcApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,8 @@ Observable<GenericOverall> createRemoteShare(@Nullable @Header("Authorization")
@Field("path") String remotePath,
@Field("shareWith") String roomToken,
@Field("shareType") String shareType,
@Field("talkMetaData") String talkMetaData);
@Field("talkMetaData") String talkMetaData,
@Field("referenceId") String referenceId);

@FormUrlEncoded
@PUT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
Expand Down Expand Up @@ -58,6 +59,9 @@ import kotlinx.coroutines.launch

private const val MAX_ADD_MORE_FILES = 10

private const val APP_BAR_HEIGHT_DP = 64
private const val APP_BAR_HORIZONTAL_PADDING_DP = 4

/**
* Full-screen dialog content for reviewing, reordering and captioning files picked for upload,
* hosted by [FileAttachmentPreviewFragment]. [viewModel] owns the file list and its (IO-derived)
Expand Down Expand Up @@ -161,7 +165,8 @@ private fun PreviewTopBar(conversationName: String, onDismiss: () -> Unit) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.height(APP_BAR_HEIGHT_DP.dp)
.padding(horizontal = APP_BAR_HORIZONTAL_PADDING_DP.dp)
) {
IconButton(onClick = onDismiss) {
Icon(
Expand All @@ -170,16 +175,21 @@ private fun PreviewTopBar(conversationName: String, onDismiss: () -> Unit) {
)
}

Column(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = APP_BAR_HORIZONTAL_PADDING_DP.dp)
) {
Text(
text = conversationName,
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = stringResource(R.string.nc_add_file),
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
Expand Down Expand Up @@ -236,6 +246,7 @@ private fun rememberPreviewViewModel(files: List<String>): FileAttachmentPreview
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
)
@Preview(name = "RTL Arabic", showBackground = true, locale = "ar")
@Composable
private fun FileAttachmentPreviewContentPreview() {
val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.nextcloud.talk.R
import com.nextcloud.talk.ui.theme.mimetypeIconTint
import com.nextcloud.talk.utils.DrawableUtils

internal const val THUMBNAIL_CORNER_RADIUS_DP = 16
Expand Down Expand Up @@ -74,17 +75,19 @@ internal fun FileThumbnailImage(
}
}

MediaKind.OTHER ->
MediaKind.OTHER -> {
val mimetypeIcon = DrawableUtils.getDrawableResourceIdForMimeType(description.mimeType)
Box(
modifier = modifier.clip(shape).background(backgroundColor),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(DrawableUtils.getDrawableResourceIdForMimeType(description.mimeType)),
painter = painterResource(mimetypeIcon),
contentDescription = description.name,
tint = Color.Unspecified,
tint = mimetypeIconTint(mimetypeIcon),
modifier = Modifier.size(iconSize)
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
package com.nextcloud.talk.attachmentpreview

import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
Expand All @@ -25,9 +26,10 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.material.icons.outlined.Videocam
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
Expand All @@ -40,6 +42,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
Expand Down Expand Up @@ -107,9 +110,27 @@ internal fun ThumbnailStrip(
)
}
}
item { AddMoreTile(onClick = onAddMore) }
item { TakePhotoTile(onClick = onTakePhoto) }
item { TakeVideoTile(onClick = onTakeVideo) }
item {
ActionTile(
icon = Icons.Outlined.PhotoLibrary,
contentDescription = R.string.nc_add_more_files,
onClick = onAddMore
)
}
item {
ActionTile(
icon = Icons.Outlined.PhotoCamera,
contentDescription = R.string.take_photo,
onClick = onTakePhoto
)
}
item {
ActionTile(
icon = Icons.Outlined.Videocam,
contentDescription = R.string.nc_take_video,
onClick = onTakeVideo
)
}
}
}

Expand Down Expand Up @@ -270,7 +291,7 @@ private fun StripThumbnail(
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Delete,
imageVector = Icons.Outlined.Delete,
contentDescription = stringResource(R.string.nc_remove_file),
tint = Color.White,
modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp)
Expand All @@ -281,54 +302,19 @@ private fun StripThumbnail(
}

@Composable
private fun AddMoreTile(onClick: () -> Unit) {
Box(
modifier = Modifier
.size(STRIP_THUMBNAIL_SIZE_DP.dp)
.clip(RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(R.drawable.baseline_photo_library_24),
contentDescription = stringResource(R.string.nc_add_more_files),
modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp)
)
}
}

@Composable
private fun TakePhotoTile(onClick: () -> Unit) {
Box(
modifier = Modifier
.size(STRIP_THUMBNAIL_SIZE_DP.dp)
.clip(RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PhotoCamera,
contentDescription = stringResource(R.string.take_photo),
modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp)
)
}
}

@Composable
private fun TakeVideoTile(onClick: () -> Unit) {
private fun ActionTile(icon: ImageVector, @StringRes contentDescription: Int, onClick: () -> Unit) {
Box(
modifier = Modifier
.size(STRIP_THUMBNAIL_SIZE_DP.dp)
.clip(RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.background(MaterialTheme.colorScheme.secondaryContainer)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Videocam,
contentDescription = stringResource(R.string.nc_take_video),
imageVector = icon,
contentDescription = stringResource(contentDescription),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp)
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.attachmentpreview

import android.content.res.Configuration
import android.graphics.Bitmap
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.graphics.createBitmap

private const val PREVIEW_THUMBNAIL_PX = 64

// Coil can't load the image tiles' content in a preview, so only the video tile gets a stand-in
// bitmap - the flat color is enough to tell "has a thumbnail" apart from the icon fallback.
private fun previewVideoThumbnail(): Bitmap =
createBitmap(PREVIEW_THUMBNAIL_PX, PREVIEW_THUMBNAIL_PX)
.apply { eraseColor(android.graphics.Color.DKGRAY) }

private fun previewDescription(
uri: String,
name: String,
mimeType: String,
detail: String,
videoThumbnail: Bitmap? = null
) = FileDescription(
uri = uri,
name = name,
kind = mediaKind(mimeType),
mimeType = mimeType,
detail = detail,
videoThumbnail = videoThumbnail
)

private fun previewDescriptions() =
listOf(
previewDescription("file:///sdcard/DCIM/photo.jpg", "photo.jpg", "image/jpeg", "2048×1152, 210 kB"),
previewDescription(
uri = "file:///sdcard/DCIM/clip.mp4",
name = "clip.mp4",
mimeType = "video/mp4",
detail = "0:42, 8 MB",
videoThumbnail = previewVideoThumbnail()
),
previewDescription("file:///sdcard/Download/archive.zip", "archive.zip", "application/zip", "4 MB"),
previewDescription("file:///sdcard/Documents/report.pdf", "report.pdf", "application/pdf", "820 kB")
)

@Composable
private fun ThumbnailStripPreviewContainer(descriptions: List<FileDescription>, selectedIndex: Int) {
val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = colorScheme) {
Surface {
ThumbnailStrip(
descriptions = descriptions,
selectedIndex = selectedIndex,
onSelect = {},
onRemove = {},
onReorder = { _, _ -> },
onAddMore = {},
onTakePhoto = {},
onTakeVideo = {}
)
}
}
}

@Preview(name = "Light Mode", showBackground = true)
@Preview(
name = "Dark Mode",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
)
@Composable
private fun ThumbnailStripPreview() {
ThumbnailStripPreviewContainer(descriptions = previewDescriptions(), selectedIndex = 0)
}

/** The mimetype-icon tiles, where [mimetypeIconTint] decides whether an icon is themed or keeps its own colors. */
@Preview(name = "Documents Light", showBackground = true)
@Preview(
name = "Documents Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
)
@Composable
private fun ThumbnailStripDocumentsPreview() {
ThumbnailStripPreviewContainer(
descriptions = listOf(
previewDescription("file:///sdcard/Download/archive.zip", "archive.zip", "application/zip", "4 MB"),
previewDescription("file:///sdcard/Documents/report.pdf", "report.pdf", "application/pdf", "820 kB"),
previewDescription("file:///sdcard/Documents/notes.txt", "notes.txt", "text/plain", "2 kB"),
previewDescription("file:///sdcard/Documents/letter.doc", "letter.doc", "application/msword", "34 kB")
),
selectedIndex = 1
)
}

/** A single file shows no thumbnails at all - just the centered action tiles. */
@Preview(name = "Single File", showBackground = true)
@Composable
private fun ThumbnailStripSingleFilePreview() {
ThumbnailStripPreviewContainer(
descriptions = previewDescriptions().take(1),
selectedIndex = 0
)
}
12 changes: 10 additions & 2 deletions app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ import com.nextcloud.talk.ui.chat.ChatMessageCallbacks
import com.nextcloud.talk.ui.chat.ChatView
import com.nextcloud.talk.ui.chat.ChatViewCallbacks
import com.nextcloud.talk.ui.chat.ChatViewState
import com.nextcloud.talk.ui.chat.LocalUploadProgressProvider
import com.nextcloud.talk.ui.chat.LocalUploadedLocalPreviewProvider
import com.nextcloud.talk.ui.dialog.DateTimeCompose
import com.nextcloud.talk.ui.dialog.GetPinnedOptionsDialog
import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment
Expand Down Expand Up @@ -876,10 +878,15 @@ class ChatActivity :

SideEffect { chatListState = listState }

val uploadProgressMap by chatViewModel.uploadProgressMap.collectAsStateWithLifecycle()
val uploadedLocalPreviewMap by chatViewModel.uploadedLocalPreviewMap.collectAsStateWithLifecycle()

CompositionLocalProvider(
LocalViewThemeUtils provides viewThemeUtils,
LocalMessageUtils provides messageUtils,
LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) }
LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) },
LocalUploadProgressProvider provides { refId -> uploadProgressMap[refId] },
LocalUploadedLocalPreviewProvider provides { refId -> uploadedLocalPreviewMap[refId] }
) {
val isOneToOneConversation by remember { mutableStateOf(uiState.isOneToOneConversation) }
Log.d(TAG, "isOneToOneConversation=" + isOneToOneConversation)
Expand Down Expand Up @@ -938,7 +945,8 @@ class ChatActivity :
onSystemMessageExpandClick = { messageId ->
chatViewModel.toggleSystemMessageCollapse(messageId)
},
onAvatarClick = { messageId -> chatViewModel.showProfileSheet(messageId.toLong()) }
onAvatarClick = { messageId -> chatViewModel.showProfileSheet(messageId.toLong()) },
onCancelUpload = { referenceId -> chatViewModel.cancelUpload(referenceId) }
)
),
listState = listState
Expand Down
Loading
Loading