Support gallery messages (#6519)

* Support gallery messages

* Address review

* Remove unused function

* Fix indent

* Add "Gallery" message prefix

* Support galleries for image editing

* Fix tapping on a tile opens the last item

* Fix overflow count

* Add caption editing to gallery messages

* Use the new gallery string for prefix

* Lock sending galleries behind a feature flag

* Fix detekts

* Fix

* Ensure image edition is saved when navigating

* Fix sending media broken on edited gallery.

* Fix tests

* Order imports

* Remove unused parameters.

* Fix copyright header of new files.

* Fix Konsist test

* Extract new previews to a dedicated file.

* Sync strings

* Remove unused import

* Update screenshots

* Trigger CI

* Remove parameters with default value.

* More cleanup

* Restore sendAsFile behavior.

* Improve Preview.

* Improve Preview.

* Improve Preview.

* Fix gallery sending cancel and retry issue

* Ensure any previous job is cancelled.

* Fix issue in summary message

* Gallery feature is disabled by default.

* Kotlin convention

* Remove useless parenthesis

* Update screenshots

* Fix test

* List -> ImmutableList

* Remove useless code.

* Render formatted caption for attachment list.

* Replace set of Booleans by an enum

* Remove unused model for individual caption in a gallery Event.

* Fix tests

* Fix tests

* Rework MediaViewer entry point.

And ensure that the clicked image from the gallery is displayed first.

* Ensure gallery item can be click in the pinned message list
Improve the gallery item click handling code.

* Improve code and fix separator color
Closes #7101

* React on attachment item click
Improve code

* Improve code and support 0 items in gallery.

* Fix click on attachment item not rendering anything.

---------

Co-authored-by: Benoit Marty <benoitm@element.io>
Co-authored-by: Benoit Marty <benoit@matrix.org>
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
bxdxnn
2026-06-26 17:00:56 +03:00
committed by GitHub
parent 9e7ae34728
commit 87aff40b26
180 changed files with 3470 additions and 637 deletions
@@ -23,12 +23,23 @@ interface PickerProvider {
onResult: (Uri?) -> Unit
): PickerLauncher<PickVisualMediaRequest, Uri?>
@Composable
fun registerGalleryMultiPicker(
onResult: (uris: List<Uri>) -> Unit
): PickerLauncher<PickVisualMediaRequest, List<Uri>>
@Composable
fun registerFilePicker(
mimeType: String,
onResult: (uri: Uri?, mimeType: String?) -> Unit,
): PickerLauncher<String, Uri?>
@Composable
fun registerFileMultiPicker(
mimeType: String,
onResult: (uris: List<Uri>) -> Unit,
): PickerLauncher<Array<String>, List<Uri>>
@Composable
fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
@@ -15,6 +15,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Immutable
import io.element.android.libraries.core.mimetype.MimeTypes
// As per MSC4274, the recommended item cap is 60.
private const val MAX_GALLERY_ITEMS = 60
@Immutable
sealed interface PickerType<Input, Output> {
fun getContract(): ActivityResultContract<Input, Output>
@@ -34,6 +37,13 @@ sealed interface PickerType<Input, Output> {
}
}
data object ImageAndVideoMulti : PickerType<PickVisualMediaRequest, List<Uri>> {
override fun getContract() = ActivityResultContracts.PickMultipleVisualMedia(MAX_GALLERY_ITEMS)
override fun getDefaultRequest(): PickVisualMediaRequest {
return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
}
}
object Camera {
data class Photo(val destUri: Uri) : PickerType<Uri, Boolean> {
override fun getContract() = ActivityResultContracts.TakePicture()
@@ -56,4 +66,13 @@ sealed interface PickerType<Input, Output> {
return mimeType
}
}
data class FileMulti(val mimeType: String = MimeTypes.Any) : PickerType<Array<String>, List<Uri>> {
override fun getContract(): ActivityResultContract<Array<String>, List<Uri>> {
return ActivityResultContracts.OpenMultipleDocuments()
}
override fun getDefaultRequest(): Array<String> {
return arrayOf(mimeType)
}
}
}
@@ -80,6 +80,23 @@ class DefaultPickerProvider(
}
}
/**
* Remembers and returns a [PickerLauncher] for selecting multiple gallery items (images/videos).
* [onResult] will be called with the list of selected file [Uri]s.
*/
@Composable
override fun registerGalleryMultiPicker(
onResult: (uris: List<Uri>) -> Unit
): PickerLauncher<PickVisualMediaRequest, List<Uri>> {
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(emptyList()) }
} else {
rememberPickerLauncher(type = PickerType.ImageAndVideoMulti) { uris ->
onResult(uris)
}
}
}
/**
* Remembers and returns a [PickerLauncher] for a file of a certain [mimeType] (any type of file, by default).
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
@@ -100,6 +117,24 @@ class DefaultPickerProvider(
}
}
/**
* Remembers and returns a [PickerLauncher] for selecting multiple files of a certain [mimeType].
* [onResult] will be called with the list of selected file URIs.
*/
@Composable
override fun registerFileMultiPicker(
mimeType: String,
onResult: (uris: List<Uri>) -> Unit,
): PickerLauncher<Array<String>, List<Uri>> {
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(emptyList()) }
} else {
rememberPickerLauncher(type = PickerType.FileMulti(mimeType)) { uris ->
onResult(uris)
}
}
}
/**
* Remembers and returns a [PickerLauncher] for taking a photo with a camera app.
* @param [onResult] will be called with either the photo's [Uri] or `null` if nothing was selected.
@@ -113,7 +148,6 @@ class DefaultPickerProvider(
val tmpFile = remember { getTemporaryFile("photo.jpg") }
val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) }
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
// Execute callback
onResult(if (success) tmpFileUri else null)
}
}
@@ -125,7 +159,6 @@ class DefaultPickerProvider(
*/
@Composable
override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(null) }
} else {
@@ -30,11 +30,21 @@ class FakePickerProvider : PickerProvider {
return NoOpPickerLauncher { onResult(result) }
}
@Composable
override fun registerGalleryMultiPicker(onResult: (uris: List<Uri>) -> Unit): PickerLauncher<PickVisualMediaRequest, List<Uri>> {
return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) }
}
@Composable
override fun registerFilePicker(mimeType: String, onResult: (Uri?, String?) -> Unit): PickerLauncher<String, Uri?> {
return NoOpPickerLauncher { onResult(result, this.mimeType) }
}
@Composable
override fun registerFileMultiPicker(mimeType: String, onResult: (uris: List<Uri>) -> Unit): PickerLauncher<Array<String>, List<Uri>> {
return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) }
}
@Composable
override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
return NoOpPickerLauncher { onResult(result) }