Merge branch 'develop' into feature/bma/noWarnings
This commit is contained in:
@@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.LocationSearching
|
||||
import androidx.compose.material.icons.filled.MyLocation
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -49,6 +48,7 @@ import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.api.internal.centerBottomEdge
|
||||
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
||||
import io.element.android.features.location.impl.MapDefaults
|
||||
import io.element.android.features.location.impl.R
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
@@ -156,7 +156,11 @@ fun SendLocationView(
|
||||
navigateUp()
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.LocationOn, null)
|
||||
Icon(
|
||||
resourceId = R.drawable.pin_small,
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp + navBarPadding))
|
||||
|
||||
@@ -50,6 +50,13 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
|
||||
isTrackMyLocation = false,
|
||||
eventSink = {},
|
||||
),
|
||||
ShowLocationState(
|
||||
Location(1.23, 2.34, 4f),
|
||||
description = "For some reason I decided to to write a small essay that wraps at just two lines!",
|
||||
hasLocationPermission = false,
|
||||
isTrackMyLocation = false,
|
||||
eventSink = {},
|
||||
),
|
||||
ShowLocationState(
|
||||
Location(1.23, 2.34, 4f),
|
||||
description = "For some reason I decided to write a small essay in the location description. " +
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="26dp"
|
||||
android:height="28dp"
|
||||
android:viewportWidth="26"
|
||||
android:viewportHeight="28">
|
||||
<path
|
||||
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
|
||||
android:fillColor="#EBEEF2"/>
|
||||
<path
|
||||
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
|
||||
android:fillColor="#EBEEF2"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M6.74,6.74h12.444v12.444h-12.444z"/>
|
||||
<path
|
||||
android:pathData="M12.962,6.74C10.554,6.74 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.74 12.962,6.74ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
|
||||
android:fillColor="#101317"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
features/location/impl/src/main/res/drawable/pin_small.xml
Normal file
19
features/location/impl/src/main/res/drawable/pin_small.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="26dp"
|
||||
android:height="28dp"
|
||||
android:viewportWidth="26"
|
||||
android:viewportHeight="28">
|
||||
<path
|
||||
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<path
|
||||
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M6.74,6.741h12.444v12.444h-12.444z"/>
|
||||
<path
|
||||
android:pathData="M12.962,6.741C10.554,6.741 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.741 12.962,6.741ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -83,7 +83,7 @@ internal fun AttachmentsBottomSheet(
|
||||
onDismissRequest = { isVisible = false }
|
||||
) {
|
||||
AttachmentSourcePickerMenu(
|
||||
eventSink = state.eventSink,
|
||||
state = state,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ internal fun AttachmentsBottomSheet(
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
internal fun AttachmentSourcePickerMenu(
|
||||
eventSink: (MessageComposerEvents) -> Unit,
|
||||
state: MessageComposerState,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -102,33 +102,35 @@ internal fun AttachmentSourcePickerMenu(
|
||||
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
||||
icon = { Icon(Icons.Default.Collections, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
||||
icon = { Icon(Icons.Default.AttachFile, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
||||
icon = { Icon(Icons.Default.PhotoCamera, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
||||
icon = { Icon(Icons.Default.Videocam, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
eventSink(MessageComposerEvents.PickAttachmentSource.Location)
|
||||
onSendLocationClicked()
|
||||
},
|
||||
icon = { Icon(Icons.Default.LocationOn, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
|
||||
)
|
||||
if (state.canShareLocation) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
state.eventSink(MessageComposerEvents.PickAttachmentSource.Location)
|
||||
onSendLocationClicked()
|
||||
},
|
||||
icon = { Icon(Icons.Default.LocationOn, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +138,9 @@ internal fun AttachmentSourcePickerMenu(
|
||||
@Composable
|
||||
internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
|
||||
AttachmentSourcePickerMenu(
|
||||
eventSink = {},
|
||||
state = aMessageComposerState(
|
||||
canShareLocation = true,
|
||||
),
|
||||
onSendLocationClicked = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,11 @@ class MessageComposerPresenter @Inject constructor(
|
||||
mutableStateOf<AttachmentsState>(AttachmentsState.None)
|
||||
}
|
||||
|
||||
val canShareLocation = remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
|
||||
}
|
||||
|
||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
|
||||
handlePickedMedia(attachmentsState, uri, mimeType)
|
||||
}
|
||||
@@ -140,23 +145,23 @@ class MessageComposerPresenter @Inject constructor(
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageComposerEvents.AddAttachment -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
MessageComposerEvents.AddAttachment -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = true
|
||||
}
|
||||
MessageComposerEvents.DismissAttachmentMenu -> showAttachmentSourcePicker = false
|
||||
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
galleryMediaPicker.launch()
|
||||
}
|
||||
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
filesPicker.launch()
|
||||
}
|
||||
MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
cameraPhotoPicker.launch()
|
||||
}
|
||||
MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
cameraVideoPicker.launch()
|
||||
}
|
||||
@@ -173,17 +178,12 @@ class MessageComposerPresenter @Inject constructor(
|
||||
hasFocus = hasFocus.value,
|
||||
mode = messageComposerContext.composerMode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
canShareLocation = canShareLocation.value,
|
||||
attachmentsState = attachmentsState.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.launchIfMediaPickerEnabled(action: suspend () -> Unit) = launch {
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendMessage(
|
||||
text: String,
|
||||
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
|
||||
|
||||
@@ -28,6 +28,7 @@ data class MessageComposerState(
|
||||
val hasFocus: Boolean,
|
||||
val mode: MessageComposerMode,
|
||||
val showAttachmentSourcePicker: Boolean,
|
||||
val canShareLocation: Boolean,
|
||||
val attachmentsState: AttachmentsState,
|
||||
val eventSink: (MessageComposerEvents) -> Unit
|
||||
) {
|
||||
|
||||
@@ -26,12 +26,21 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
||||
)
|
||||
}
|
||||
|
||||
fun aMessageComposerState() = MessageComposerState(
|
||||
text = "",
|
||||
isFullScreen = false,
|
||||
hasFocus = false,
|
||||
mode = MessageComposerMode.Normal(content = ""),
|
||||
showAttachmentSourcePicker = false,
|
||||
attachmentsState = AttachmentsState.None,
|
||||
eventSink = {}
|
||||
fun aMessageComposerState(
|
||||
text: String = "",
|
||||
isFullScreen: Boolean = false,
|
||||
hasFocus: Boolean = false,
|
||||
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
|
||||
showAttachmentSourcePicker: Boolean = false,
|
||||
canShareLocation: Boolean = true,
|
||||
attachmentsState: AttachmentsState = AttachmentsState.None,
|
||||
) = MessageComposerState(
|
||||
text = text,
|
||||
isFullScreen = isFullScreen,
|
||||
hasFocus = hasFocus,
|
||||
mode = mode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
canShareLocation = canShareLocation,
|
||||
attachmentsState = attachmentsState,
|
||||
eventSink = {},
|
||||
)
|
||||
|
||||
@@ -117,6 +117,7 @@ private fun TextContent(
|
||||
.height(reactionEmojiLineHeight.toDp()),
|
||||
text = text,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.materialColors.primary
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -126,7 +127,7 @@ private fun IconContent(
|
||||
) = Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
tint = ElementTheme.materialColors.secondary,
|
||||
modifier = modifier
|
||||
.size(reactionEmojiLineHeight.toDp())
|
||||
)
|
||||
|
||||
@@ -291,7 +291,7 @@ private fun TimelineItemEventRowContent(
|
||||
if (event.reactionsState.reactions.isNotEmpty()) {
|
||||
TimelineItemReactions(
|
||||
reactionsState = event.reactionsState,
|
||||
mainAxisAlignment = if (event.isMine) FlowMainAxisAlignment.End else FlowMainAxisAlignment.Start,
|
||||
isOutgoing = event.isMine,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onMoreReactionsClicked = { onMoreReactionsClicked(event) },
|
||||
modifier = Modifier
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
||||
/**
|
||||
* A flow layout for reactions that will show a collapse/expand button when the layout wraps over a defined number of rows.
|
||||
* It displays an add more button when there are greater than 0 reactions and always displays the reaction and add more button
|
||||
* on the same row (moving them both to a new row if necessary).
|
||||
* @param expandButton The expand button
|
||||
* @param addMoreButton The add more button
|
||||
* @param modifier The modifier to apply to this layout
|
||||
* @param itemSpacing The horizontal spacing between items
|
||||
* @param rowSpacing The vertical spacing between rows
|
||||
* @param expanded Whether the layout should display in expanded or collapsed state
|
||||
* @param rowsBeforeCollapsible The number of rows before the collapse/expand button is shown
|
||||
* @param reactions The reaction buttons
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineItemReactionsLayout(
|
||||
expandButton: @Composable () -> Unit,
|
||||
addMoreButton: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
itemSpacing: Dp = 0.dp,
|
||||
rowSpacing: Dp = 0.dp,
|
||||
expanded: Boolean = false,
|
||||
rowsBeforeCollapsible: Int? = 2,
|
||||
reactions: @Composable () -> Unit,
|
||||
) {
|
||||
SubcomposeLayout(modifier) { constraints ->
|
||||
// Given the placeables and returns a structure representing
|
||||
// how they should wrap on to multiple rows given the constraints max width.
|
||||
fun calculateRows(measurables: List<Placeable>): List<List<Placeable>> {
|
||||
val rows = mutableListOf<List<Placeable>>()
|
||||
var currentRow = mutableListOf<Placeable>()
|
||||
var rowX = 0
|
||||
|
||||
measurables.forEach { placeable ->
|
||||
val horizontalSpacing = if (currentRow.isEmpty()) 0 else itemSpacing.toPx().toInt()
|
||||
// If the current view does not fit on this row bump to the next
|
||||
if (rowX + placeable.width > constraints.maxWidth) {
|
||||
rows.add(currentRow)
|
||||
currentRow = mutableListOf()
|
||||
rowX = 0
|
||||
}
|
||||
rowX += horizontalSpacing + placeable.width
|
||||
currentRow.add(placeable)
|
||||
}
|
||||
// If there are items in the current row remember to append it to the returned value
|
||||
if (currentRow.size > 0) {
|
||||
rows.add(currentRow)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// Used to render the collapsed state, this takes the rows inputted and adds the extra button to the last row,
|
||||
// removing only as many trailing reactions as needed to make space for it.
|
||||
fun replaceTrailingItemsWithButtons(rowsIn: List<List<Placeable>>, expandButton: Placeable, addMoreButton: Placeable): List<List<Placeable>> {
|
||||
val rows = rowsIn.toMutableList()
|
||||
val lastRow = rows.last()
|
||||
val buttonsWidth = expandButton.width + itemSpacing.toPx().toInt() + addMoreButton.width
|
||||
var rowX = 0
|
||||
lastRow.forEachIndexed { i, placeable ->
|
||||
val horizontalSpacing = if (i == 0) 0 else itemSpacing.toPx().toInt()
|
||||
rowX += placeable.width + horizontalSpacing
|
||||
if (rowX > (constraints.maxWidth - (buttonsWidth + horizontalSpacing))) {
|
||||
val lastRowWithButton = lastRow.take(i) + listOf(expandButton, addMoreButton)
|
||||
rows[rows.size - 1] = lastRowWithButton
|
||||
return rows
|
||||
}
|
||||
}
|
||||
val lastRowWithButton = lastRow + listOf(expandButton, addMoreButton)
|
||||
rows[rows.size - 1] = lastRowWithButton
|
||||
return rows
|
||||
}
|
||||
|
||||
// To prevent the add more and expand buttons from wrapping on to separate lines.
|
||||
// If there is one item on the last line, it moves the expand button down.
|
||||
fun ensureCollapseAndAddMoreButtonsAreOnTheSameRow(rowsIn: List<List<Placeable>>): List<List<Placeable>> {
|
||||
val lastRow = rowsIn.last().toMutableList()
|
||||
if (lastRow.size != 1) {
|
||||
return rowsIn
|
||||
}
|
||||
val rows = rowsIn.toMutableList()
|
||||
val secondLastRow = rows[rows.size - 2].toMutableList()
|
||||
val expandButtonPlaceable = secondLastRow.removeLast()
|
||||
lastRow.add(0, expandButtonPlaceable)
|
||||
rows[rows.size - 2] = secondLastRow
|
||||
rows[rows.size - 1] = lastRow
|
||||
return rows
|
||||
}
|
||||
|
||||
/// Given a list of rows place them in the layout.
|
||||
fun layoutRows(rows: List<List<Placeable>>): MeasureResult {
|
||||
var width = 0
|
||||
var height = 0
|
||||
val placeables = rows.mapIndexed { i, row ->
|
||||
var rowX = 0
|
||||
var rowHeight = 0
|
||||
val verticalSpacing = if (i == 0) 0 else rowSpacing.toPx().toInt()
|
||||
val rowWithPoints = row.mapIndexed { j, placeable ->
|
||||
val horizontalSpacing = if (j == 0) 0 else itemSpacing.toPx().toInt()
|
||||
val point = IntOffset(rowX + horizontalSpacing, height + verticalSpacing)
|
||||
rowX += placeable.width + horizontalSpacing
|
||||
rowHeight = maxOf(rowHeight, placeable.height)
|
||||
Pair(placeable, point)
|
||||
}
|
||||
height += rowHeight + verticalSpacing
|
||||
width = maxOf(width, rowX)
|
||||
rowWithPoints
|
||||
}.flatten()
|
||||
|
||||
return layout(width = width, height = height) {
|
||||
placeables.forEach {
|
||||
val (placeable, origin) = it
|
||||
placeable.placeRelative(origin.x, origin.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val reactionsPlaceables = subcompose(0, reactions).map { it.measure(constraints) }
|
||||
if (reactionsPlaceables.isEmpty()) {
|
||||
return@SubcomposeLayout layoutRows(listOf())
|
||||
}
|
||||
val addMorePlaceable = subcompose(1, addMoreButton).first().measure(constraints)
|
||||
val expandPlaceable = subcompose(2, expandButton).first().measure(constraints)
|
||||
|
||||
// Calculate the layout of the rows with the reactions button and add more button
|
||||
val reactionsAndAddMore = calculateRows(reactionsPlaceables + listOf(addMorePlaceable))
|
||||
// If we have extended beyond the defined number of rows we are showing the expand/collapse ui
|
||||
if (rowsBeforeCollapsible?.let { reactionsAndAddMore.size > it } == true) {
|
||||
if (expanded) {
|
||||
// Show all subviews with the add more button at the end
|
||||
var reactionsAndButtons = calculateRows(reactionsPlaceables + listOf(expandPlaceable, addMorePlaceable))
|
||||
reactionsAndButtons = ensureCollapseAndAddMoreButtonsAreOnTheSameRow(reactionsAndButtons)
|
||||
layoutRows(reactionsAndButtons)
|
||||
} else {
|
||||
// Truncate to `rowsBeforeCollapsible` number of rows and replace the reactions at the end of the last row with the buttons
|
||||
val collapsedRows = reactionsAndAddMore.take(rowsBeforeCollapsible)
|
||||
val collapsedRowsWithButtons = replaceTrailingItemsWithButtons(collapsedRows, expandPlaceable, addMorePlaceable)
|
||||
layoutRows(collapsedRowsWithButtons)
|
||||
}
|
||||
} else {
|
||||
// Otherwise we are just showing all items without the expand button
|
||||
layoutRows(reactionsAndAddMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun TimelineItemReactionsLayoutPreview() = ElementPreview {
|
||||
TimelineItemReactionsLayout(
|
||||
expanded = false,
|
||||
expandButton = {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text(
|
||||
text = stringResource(id = R.string.screen_room_timeline_less_reactions)
|
||||
),
|
||||
onClick = { },
|
||||
)
|
||||
},
|
||||
addMoreButton = {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||
onClick = {}
|
||||
)
|
||||
},
|
||||
reactions = {
|
||||
io.element.android.features.messages.impl.timeline.aTimelineItemReactions(count = 18).reactions.forEach {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Reaction(
|
||||
it
|
||||
),
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -19,18 +19,16 @@ package io.element.android.features.messages.impl.timeline.components
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
@@ -38,162 +36,119 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
/**
|
||||
* The maximum number of items that can be displayed before some items will be hidden
|
||||
*
|
||||
* TODO The threshold should be based on the number of rows, rather than items.
|
||||
* Once items would spill onto a third row, they should be hidden.
|
||||
* Note this could be particularly worthwhile to handle reactions that are
|
||||
* longer than a single character (as annotation keys are free text).
|
||||
*/
|
||||
private const val COLLAPSE_ITEMS_THRESHOLD = 8
|
||||
|
||||
@Composable
|
||||
fun TimelineItemReactions(
|
||||
reactionsState: TimelineItemReactions,
|
||||
mainAxisAlignment: FlowMainAxisAlignment,
|
||||
isOutgoing: Boolean,
|
||||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val reactions by remember(reactionsState, expanded) {
|
||||
derivedStateOf {
|
||||
val numToDisplay = if (expanded) {
|
||||
reactionsState.reactions.count()
|
||||
} else {
|
||||
COLLAPSE_ITEMS_THRESHOLD
|
||||
}
|
||||
reactionsState.reactions.take(numToDisplay).toPersistentList()
|
||||
}
|
||||
// In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL.
|
||||
// For RTL languages it should be the opposite.
|
||||
val reactionsLayoutDirection = if (!isOutgoing) LocalLayoutDirection.current
|
||||
else if (LocalLayoutDirection.current == LayoutDirection.Ltr)
|
||||
LayoutDirection.Rtl
|
||||
else
|
||||
LayoutDirection.Ltr
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides reactionsLayoutDirection) {
|
||||
TimelineItemReactionsView(
|
||||
modifier = modifier,
|
||||
reactions = reactionsState.reactions,
|
||||
expanded = expanded,
|
||||
onReactionClick = onReactionClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onToggleExpandClick = { expanded = !expanded },
|
||||
)
|
||||
}
|
||||
|
||||
val expandableState by remember {
|
||||
derivedStateOf {
|
||||
if (expanded) {
|
||||
ExpandableState.Expanded
|
||||
} else {
|
||||
val hiddenItems = reactionsState.reactions.count() - reactions.count()
|
||||
if (hiddenItems > 0) {
|
||||
ExpandableState.Collapsed(hidden = hiddenItems)
|
||||
} else {
|
||||
ExpandableState.None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TimelineItemReactionsView(
|
||||
modifier = modifier,
|
||||
reactions = reactions,
|
||||
expandableState = expandableState,
|
||||
mainAxisAlignment = mainAxisAlignment,
|
||||
onReactionClick = onReactionClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onExpandClick = { expanded = true },
|
||||
onCollapseClick = { expanded = false }
|
||||
)
|
||||
}
|
||||
|
||||
private sealed class ExpandableState {
|
||||
object None: ExpandableState()
|
||||
data class Collapsed(val hidden: Int): ExpandableState()
|
||||
object Expanded : ExpandableState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineItemReactionsView(
|
||||
reactions: ImmutableList<AggregatedReaction>,
|
||||
expandableState: ExpandableState,
|
||||
mainAxisAlignment: FlowMainAxisAlignment,
|
||||
expanded: Boolean,
|
||||
onReactionClick: (emoji: String) -> Unit,
|
||||
onMoreReactionsClick: () -> Unit,
|
||||
onExpandClick: () -> Unit,
|
||||
onCollapseClick: () -> Unit,
|
||||
onToggleExpandClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) =
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
mainAxisSpacing = 4.dp,
|
||||
crossAxisSpacing = 4.dp,
|
||||
mainAxisAlignment = mainAxisAlignment,
|
||||
) {
|
||||
) = TimelineItemReactionsLayout(
|
||||
modifier = modifier,
|
||||
itemSpacing = 4.dp,
|
||||
rowSpacing = 4.dp,
|
||||
expanded = expanded,
|
||||
expandButton = {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text(
|
||||
text = stringResource(id = if (expanded) R.string.screen_room_reactions_show_less else R.string.screen_room_reactions_show_more)
|
||||
),
|
||||
onClick = onToggleExpandClick,
|
||||
)
|
||||
},
|
||||
addMoreButton = {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||
onClick = onMoreReactionsClick
|
||||
)
|
||||
},
|
||||
reactions = {
|
||||
reactions.forEach { reaction ->
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Reaction(reaction = reaction),
|
||||
onClick = { onReactionClick(reaction.key) }
|
||||
)
|
||||
}
|
||||
when (expandableState) {
|
||||
ExpandableState.Expanded ->
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text(
|
||||
text = stringResource(id = R.string.screen_room_timeline_less_reactions)
|
||||
),
|
||||
onClick = onCollapseClick,
|
||||
)
|
||||
is ExpandableState.Collapsed -> {
|
||||
val hidden = expandableState.hidden
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text(
|
||||
text = pluralStringResource(id = R.plurals.screen_room_timeline_more_reactions, hidden, hidden)
|
||||
),
|
||||
onClick = onExpandClick,
|
||||
)
|
||||
}
|
||||
ExpandableState.None -> {
|
||||
// No expand or collapse action available
|
||||
}
|
||||
}
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||
onClick = onMoreReactionsClick
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun TimelineItemReactionsViewPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
reactions = aTimelineItemReactions(count = 1).reactions,
|
||||
expandableState = ExpandableState.None,
|
||||
reactions = aTimelineItemReactions(count = 1).reactions
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun TimelineItemReactionsViewCollapsedPreview() = ElementPreview {
|
||||
fun TimelineItemReactionsViewFewPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
reactions = aTimelineItemReactions(count = 3).reactions,
|
||||
expandableState = ExpandableState.Collapsed(hidden = 7),
|
||||
reactions = aTimelineItemReactions(count = 3).reactions
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun TimelineItemReactionsViewExpandedPreview() = ElementPreview {
|
||||
fun TimelineItemReactionsViewIncomingPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
reactions = aTimelineItemReactions(count = 10).reactions,
|
||||
expandableState = ExpandableState.Expanded,
|
||||
reactions = aTimelineItemReactions(count = 18).reactions
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
reactions = aTimelineItemReactions(count = 18).reactions,
|
||||
isOutgoing = true
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(
|
||||
reactions: ImmutableList<AggregatedReaction>,
|
||||
expandableState: ExpandableState
|
||||
isOutgoing: Boolean = false
|
||||
) {
|
||||
TimelineItemReactionsView(
|
||||
reactions = reactions,
|
||||
expandableState = expandableState,
|
||||
mainAxisAlignment = FlowMainAxisAlignment.Center,
|
||||
onReactionClick = {},
|
||||
onMoreReactionsClick = {},
|
||||
onExpandClick = {},
|
||||
onCollapseClick = {}
|
||||
TimelineItemReactions(
|
||||
reactionsState = TimelineItemReactions(
|
||||
reactions
|
||||
),
|
||||
isOutgoing = isOutgoing,
|
||||
onReactionClicked = {},
|
||||
onMoreReactionsClicked = {},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
|
||||
}
|
||||
@@ -94,6 +95,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
||||
@@ -113,6 +115,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
||||
@@ -129,6 +132,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
@@ -144,6 +148,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, event))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
@@ -157,6 +162,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent()))
|
||||
val finalState = awaitItem()
|
||||
@@ -171,6 +177,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
@@ -185,6 +192,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemImageContent(
|
||||
@@ -215,6 +223,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemVideoContent(
|
||||
@@ -246,6 +255,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemFileContent(
|
||||
@@ -272,6 +282,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent()))
|
||||
val finalState = awaitItem()
|
||||
@@ -288,6 +299,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
|
||||
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
|
||||
@@ -302,6 +314,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
@@ -315,6 +328,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.Dismiss)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
@@ -328,6 +342,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
@@ -342,6 +357,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
// Initially the composer doesn't have focus, so we don't show the alert
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
@@ -363,6 +379,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
|
||||
@@ -378,6 +395,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
|
||||
@@ -401,6 +419,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
@@ -429,6 +448,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
@@ -449,6 +469,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
@@ -476,6 +497,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
@@ -495,6 +517,7 @@ class MessagesPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().userHasPermissionToSendMessage).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -509,7 +532,7 @@ class MessagesPresenterTest {
|
||||
}.test {
|
||||
// Default value
|
||||
assertThat(awaitItem().userHasPermissionToSendMessage).isTrue()
|
||||
skipItems(1)
|
||||
skipItems(2)
|
||||
assertThat(awaitItem().userHasPermissionToSendMessage).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class MessageComposerPresenterTest {
|
||||
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
|
||||
}
|
||||
private val featureFlagService = FakeFeatureFlagService(
|
||||
mapOf(FeatureFlags.ShowMediaUploadingFlow.key to true)
|
||||
mapOf(FeatureFlags.LocationSharing.key to true)
|
||||
)
|
||||
private val mediaPreProcessor = FakeMediaPreProcessor()
|
||||
private val snackbarDispatcher = SnackbarDispatcher()
|
||||
@@ -81,11 +81,13 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isFullScreen).isFalse()
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(initialState.canShareLocation).isTrue()
|
||||
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
|
||||
assertThat(initialState.isSendButtonVisible).isFalse()
|
||||
}
|
||||
@@ -97,6 +99,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.ToggleFullScreenState)
|
||||
val fullscreenState = awaitItem()
|
||||
@@ -113,6 +116,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
||||
val withMessageState = awaitItem()
|
||||
@@ -131,6 +135,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
var state = awaitItem()
|
||||
val mode = anEditMode()
|
||||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
@@ -149,6 +154,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
var state = awaitItem()
|
||||
val mode = aReplyMode()
|
||||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
@@ -166,6 +172,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
var state = awaitItem()
|
||||
val mode = aQuoteMode()
|
||||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
@@ -183,6 +190,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
||||
val withMessageState = awaitItem()
|
||||
@@ -205,6 +213,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
val mode = anEditMode()
|
||||
@@ -236,6 +245,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
||||
@@ -267,6 +277,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
val mode = aReplyMode()
|
||||
@@ -294,6 +305,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showAttachmentSourcePicker).isEqualTo(false)
|
||||
initialState.eventSink(MessageComposerEvents.AddAttachment)
|
||||
@@ -307,6 +319,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.AddAttachment)
|
||||
skipItems(1)
|
||||
@@ -341,6 +354,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||
val previewingState = awaitItem()
|
||||
@@ -375,6 +389,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||
val previewingState = awaitItem()
|
||||
@@ -393,6 +408,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||
// No crashes here, otherwise it fails
|
||||
@@ -413,6 +429,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
|
||||
val sendingState = awaitItem()
|
||||
@@ -434,6 +451,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
|
||||
val previewingState = awaitItem()
|
||||
@@ -450,6 +468,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
|
||||
val previewingState = awaitItem()
|
||||
@@ -467,6 +486,7 @@ class MessageComposerPresenterTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
|
||||
val sendingState = awaitItem()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
android_gradle_plugin = "8.0.2"
|
||||
kotlin = "1.8.22"
|
||||
ksp = "1.8.22-1.0.11"
|
||||
molecule = "1.0.0"
|
||||
molecule = "1.1.0"
|
||||
|
||||
# AndroidX
|
||||
material = "1.9.0"
|
||||
|
||||
@@ -22,16 +22,8 @@ enum class FeatureFlags(
|
||||
override val description: String? = null,
|
||||
override val defaultValue: Boolean = true
|
||||
) : Feature {
|
||||
CollapseRoomStateEvents(
|
||||
key = "feature.collapseroomstateevents",
|
||||
title = "Collapse room state events",
|
||||
),
|
||||
ShowStartChatFlow(
|
||||
key = "feature.showstartchatflow",
|
||||
title = "Show start chat flow",
|
||||
),
|
||||
ShowMediaUploadingFlow(
|
||||
key = "feature.showmediauploadingflow",
|
||||
title = "Show media uploading flow",
|
||||
LocationSharing(
|
||||
key = "feature.locationsharing",
|
||||
title = "Allow user to share location",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,9 +29,7 @@ class BuildtimeFeatureFlagProvider @Inject constructor() :
|
||||
override suspend fun isFeatureEnabled(feature: Feature): Boolean {
|
||||
return if (feature is FeatureFlags) {
|
||||
when (feature) {
|
||||
FeatureFlags.CollapseRoomStateEvents -> false
|
||||
FeatureFlags.ShowStartChatFlow -> false
|
||||
FeatureFlags.ShowMediaUploadingFlow -> false
|
||||
FeatureFlags.LocationSharing -> true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
|
||||
@@ -26,14 +26,14 @@ class DefaultFeatureFlagServiceTest {
|
||||
@Test
|
||||
fun `given service without provider when feature is checked then it returns the default value`() = runTest {
|
||||
val featureFlagService = DefaultFeatureFlagService(emptySet())
|
||||
val isFeatureEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow)
|
||||
assertThat(isFeatureEnabled).isEqualTo(FeatureFlags.ShowStartChatFlow.defaultValue)
|
||||
val isFeatureEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
|
||||
assertThat(isFeatureEnabled).isEqualTo(FeatureFlags.LocationSharing.defaultValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given service without provider when set enabled feature is called then it returns false`() = runTest {
|
||||
val featureFlagService = DefaultFeatureFlagService(emptySet())
|
||||
val result = featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true)
|
||||
val result = featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, true)
|
||||
assertThat(result).isEqualTo(false)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class DefaultFeatureFlagServiceTest {
|
||||
fun `given service with a runtime provider when set enabled feature is called then it returns true`() = runTest {
|
||||
val featureFlagProvider = FakeRuntimeFeatureFlagProvider(0)
|
||||
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider))
|
||||
val result = featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true)
|
||||
val result = featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, true)
|
||||
assertThat(result).isEqualTo(true)
|
||||
}
|
||||
|
||||
@@ -49,10 +49,10 @@ class DefaultFeatureFlagServiceTest {
|
||||
fun `given service with a runtime provider and feature enabled when feature is checked then it returns the correct value`() = runTest {
|
||||
val featureFlagProvider = FakeRuntimeFeatureFlagProvider(0)
|
||||
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider))
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true)
|
||||
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow)).isEqualTo(true)
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, false)
|
||||
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow)).isEqualTo(false)
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, true)
|
||||
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)).isEqualTo(true)
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, false)
|
||||
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,8 +60,8 @@ class DefaultFeatureFlagServiceTest {
|
||||
val lowPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(LOW_PRIORITY)
|
||||
val highPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(HIGH_PRIORITY)
|
||||
val featureFlagService = DefaultFeatureFlagService(setOf(lowPriorityfeatureFlagProvider, highPriorityfeatureFlagProvider))
|
||||
lowPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, false)
|
||||
highPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true)
|
||||
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow)).isEqualTo(true)
|
||||
lowPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, false)
|
||||
highPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, true)
|
||||
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)).isEqualTo(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import io.element.android.libraries.push.api.notifications.NotificationDrawerMan
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import io.element.android.services.appnavstate.api.NavigationState
|
||||
import io.element.android.services.appnavstate.api.currentSessionId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -74,9 +75,16 @@ class DefaultNotificationDrawerManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private var currentAppNavigationState: NavigationState? = null
|
||||
|
||||
private fun onAppNavigationStateChange(navigationState: NavigationState) {
|
||||
when (navigationState) {
|
||||
NavigationState.Root -> {}
|
||||
NavigationState.Root -> {
|
||||
currentAppNavigationState?.currentSessionId()?.let { sessionId ->
|
||||
// User signed out, clear all notifications related to the session.
|
||||
clearAllEvents(sessionId)
|
||||
}
|
||||
}
|
||||
is NavigationState.Session -> {}
|
||||
is NavigationState.Space -> {}
|
||||
is NavigationState.Room -> {
|
||||
@@ -91,6 +99,7 @@ class DefaultNotificationDrawerManager @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
currentAppNavigationState = navigationState
|
||||
}
|
||||
|
||||
private fun createInitialNotificationState(): NotificationState {
|
||||
@@ -131,12 +140,21 @@ class DefaultNotificationDrawerManager @Inject constructor(
|
||||
/**
|
||||
* Clear all known events and refresh the notification drawer.
|
||||
*/
|
||||
fun clearAllEvents(sessionId: SessionId) {
|
||||
fun clearAllMessagesEvents(sessionId: SessionId) {
|
||||
updateEvents {
|
||||
it.clearMessagesForSession(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all notifications related to the session and refresh the notification drawer.
|
||||
*/
|
||||
fun clearAllEvents(sessionId: SessionId) {
|
||||
updateEvents {
|
||||
it.clearAllForSession(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when the application is currently opened and showing timeline for the given roomId.
|
||||
* Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room.
|
||||
|
||||
@@ -52,7 +52,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
|
||||
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
|
||||
}
|
||||
actionIds.dismissSummary ->
|
||||
defaultNotificationDrawerManager.clearAllEvents(sessionId)
|
||||
defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId)
|
||||
actionIds.dismissInvite -> if (roomId != null) {
|
||||
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
|
||||
}
|
||||
|
||||
@@ -154,6 +154,11 @@ data class NotificationEventQueue constructor(
|
||||
queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId }
|
||||
}
|
||||
|
||||
fun clearAllForSession(sessionId: SessionId) {
|
||||
Timber.d("clearAllForSession $sessionId")
|
||||
queue.removeAll { it.sessionId == sessionId }
|
||||
}
|
||||
|
||||
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
|
||||
Timber.d("clearMessageEventOfRoom $sessionId, $roomId")
|
||||
queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId }
|
||||
|
||||
@@ -143,8 +143,8 @@
|
||||
<string name="error_failed_loading_map">"%1$s could not load the map. Please try again later."</string>
|
||||
<string name="error_failed_loading_messages">"Failed loading messages"</string>
|
||||
<string name="error_failed_locating_user">"%1$s could not access your location. Please try again later."</string>
|
||||
<string name="error_missing_location_auth_android">"To send a location, allow %1$s to access your location from its settings screen."</string>
|
||||
<string name="error_missing_location_rationale_android">"To send a location, allow %1$s to access your location in the next dialog."</string>
|
||||
<string name="error_missing_location_auth_android">"%1$s does not have permission to access your location. You can enable access in Settings."</string>
|
||||
<string name="error_missing_location_rationale_android">"%1$s does not have permission to access your location. Enable access below."</string>
|
||||
<string name="error_some_messages_have_not_been_sent">"Some messages have not been sent"</string>
|
||||
<string name="error_unknown">"Sorry, an error occurred"</string>
|
||||
<string name="invite_friends_rich_title">"🔐️ Join me on %1$s"</string>
|
||||
|
||||
@@ -17,10 +17,18 @@
|
||||
import org.gradle.api.JavaVersion
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
|
||||
object Versions {
|
||||
const val versionCode = 100200
|
||||
const val versionName = "0.2.0"
|
||||
// Note: 2 digits max for each value
|
||||
private const val versionMajor = 0
|
||||
private const val versionMinor = 1
|
||||
|
||||
// Note: even values are reserved for regular release, odd values for hotfix release.
|
||||
// When creating a hotfix, you should decrease the value, since the current value
|
||||
// is the value for the next regular release.
|
||||
private const val versionPatch = 2
|
||||
|
||||
object Versions {
|
||||
val versionCode = (versionMajor * 1_00_00 + versionMinor * 1_00 + versionPatch) * 10
|
||||
val versionName = "$versionMajor.$versionMinor.$versionPatch"
|
||||
const val compileSdk = 33
|
||||
const val targetSdk = 33
|
||||
const val minSdk = 23
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
19
tools/gitflow/gitflow-init.sh
Executable file
19
tools/gitflow/gitflow-init.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
#
|
||||
# Copyright (c) 2023 New Vector Ltd
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
git flow init -d
|
||||
git config gitflow.prefix.versiontag v
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user