Merge branch 'develop' into feature/bma/noWarnings

This commit is contained in:
Benoit Marty 2023-07-21 12:17:50 +02:00 committed by GitHub
commit 59a66479ab
102 changed files with 1091 additions and 292 deletions

View file

@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons 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.LocationSearching
import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material3.ExperimentalMaterial3Api 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.centerBottomEdge
import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.MapDefaults 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.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.DayNightPreviews
@ -156,7 +156,11 @@ fun SendLocationView(
navigateUp() navigateUp()
}, },
leadingContent = { leadingContent = {
Icon(Icons.Default.LocationOn, null) Icon(
resourceId = R.drawable.pin_small,
contentDescription = null,
tint = Color.Unspecified,
)
}, },
) )
Spacer(modifier = Modifier.height(16.dp + navBarPadding)) Spacer(modifier = Modifier.height(16.dp + navBarPadding))

View file

@ -50,6 +50,13 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
isTrackMyLocation = false, isTrackMyLocation = false,
eventSink = {}, 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( ShowLocationState(
Location(1.23, 2.34, 4f), Location(1.23, 2.34, 4f),
description = "For some reason I decided to write a small essay in the location description. " + description = "For some reason I decided to write a small essay in the location description. " +

View 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="#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>

View 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>

View file

@ -83,7 +83,7 @@ internal fun AttachmentsBottomSheet(
onDismissRequest = { isVisible = false } onDismissRequest = { isVisible = false }
) { ) {
AttachmentSourcePickerMenu( AttachmentSourcePickerMenu(
eventSink = state.eventSink, state = state,
onSendLocationClicked = onSendLocationClicked, onSendLocationClicked = onSendLocationClicked,
) )
} }
@ -93,7 +93,7 @@ internal fun AttachmentsBottomSheet(
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
internal fun AttachmentSourcePickerMenu( internal fun AttachmentSourcePickerMenu(
eventSink: (MessageComposerEvents) -> Unit, state: MessageComposerState,
onSendLocationClicked: () -> Unit, onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -102,33 +102,35 @@ internal fun AttachmentSourcePickerMenu(
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044 // .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
) { ) {
ListItem( ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
icon = { Icon(Icons.Default.Collections, null) }, icon = { Icon(Icons.Default.Collections, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) }, text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
) )
ListItem( ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
icon = { Icon(Icons.Default.AttachFile, null) }, icon = { Icon(Icons.Default.AttachFile, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) }, text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
) )
ListItem( ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
icon = { Icon(Icons.Default.PhotoCamera, null) }, icon = { Icon(Icons.Default.PhotoCamera, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) }, text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
) )
ListItem( ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
icon = { Icon(Icons.Default.Videocam, null) }, icon = { Icon(Icons.Default.Videocam, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) }, text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
) )
ListItem( if (state.canShareLocation) {
modifier = Modifier.clickable { ListItem(
eventSink(MessageComposerEvents.PickAttachmentSource.Location) modifier = Modifier.clickable {
onSendLocationClicked() state.eventSink(MessageComposerEvents.PickAttachmentSource.Location)
}, onSendLocationClicked()
icon = { Icon(Icons.Default.LocationOn, null) }, },
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) }, icon = { Icon(Icons.Default.LocationOn, null) },
) text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
)
}
} }
} }
@ -136,7 +138,9 @@ internal fun AttachmentSourcePickerMenu(
@Composable @Composable
internal fun AttachmentSourcePickerMenuPreview() = ElementPreview { internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
AttachmentSourcePickerMenu( AttachmentSourcePickerMenu(
eventSink = {}, state = aMessageComposerState(
canShareLocation = true,
),
onSendLocationClicked = {}, onSendLocationClicked = {},
) )
} }

View file

@ -74,6 +74,11 @@ class MessageComposerPresenter @Inject constructor(
mutableStateOf<AttachmentsState>(AttachmentsState.None) mutableStateOf<AttachmentsState>(AttachmentsState.None)
} }
val canShareLocation = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
}
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(attachmentsState, uri, mimeType) handlePickedMedia(attachmentsState, uri, mimeType)
} }
@ -140,23 +145,23 @@ class MessageComposerPresenter @Inject constructor(
) )
) )
} }
MessageComposerEvents.AddAttachment -> localCoroutineScope.launchIfMediaPickerEnabled { MessageComposerEvents.AddAttachment -> localCoroutineScope.launch {
showAttachmentSourcePicker = true showAttachmentSourcePicker = true
} }
MessageComposerEvents.DismissAttachmentMenu -> showAttachmentSourcePicker = false MessageComposerEvents.DismissAttachmentMenu -> showAttachmentSourcePicker = false
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launchIfMediaPickerEnabled { MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
galleryMediaPicker.launch() galleryMediaPicker.launch()
} }
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launchIfMediaPickerEnabled { MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
filesPicker.launch() filesPicker.launch()
} }
MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled { MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
cameraPhotoPicker.launch() cameraPhotoPicker.launch()
} }
MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled { MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
cameraVideoPicker.launch() cameraVideoPicker.launch()
} }
@ -173,17 +178,12 @@ class MessageComposerPresenter @Inject constructor(
hasFocus = hasFocus.value, hasFocus = hasFocus.value,
mode = messageComposerContext.composerMode, mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker, showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation.value,
attachmentsState = attachmentsState.value, attachmentsState = attachmentsState.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.launchIfMediaPickerEnabled(action: suspend () -> Unit) = launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
action()
}
}
private fun CoroutineScope.sendMessage( private fun CoroutineScope.sendMessage(
text: String, text: String,
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit, updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,

View file

@ -28,6 +28,7 @@ data class MessageComposerState(
val hasFocus: Boolean, val hasFocus: Boolean,
val mode: MessageComposerMode, val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean, val showAttachmentSourcePicker: Boolean,
val canShareLocation: Boolean,
val attachmentsState: AttachmentsState, val attachmentsState: AttachmentsState,
val eventSink: (MessageComposerEvents) -> Unit val eventSink: (MessageComposerEvents) -> Unit
) { ) {

View file

@ -26,12 +26,21 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
) )
} }
fun aMessageComposerState() = MessageComposerState( fun aMessageComposerState(
text = "", text: String = "",
isFullScreen = false, isFullScreen: Boolean = false,
hasFocus = false, hasFocus: Boolean = false,
mode = MessageComposerMode.Normal(content = ""), mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
showAttachmentSourcePicker = false, showAttachmentSourcePicker: Boolean = false,
attachmentsState = AttachmentsState.None, canShareLocation: Boolean = true,
eventSink = {} attachmentsState: AttachmentsState = AttachmentsState.None,
) = MessageComposerState(
text = text,
isFullScreen = isFullScreen,
hasFocus = hasFocus,
mode = mode,
showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation,
attachmentsState = attachmentsState,
eventSink = {},
) )

View file

@ -117,6 +117,7 @@ private fun TextContent(
.height(reactionEmojiLineHeight.toDp()), .height(reactionEmojiLineHeight.toDp()),
text = text, text = text,
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.materialColors.primary
) )
@Composable @Composable
@ -126,7 +127,7 @@ private fun IconContent(
) = Icon( ) = Icon(
imageVector = imageVector, imageVector = imageVector,
contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction), contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction),
tint = MaterialTheme.colorScheme.secondary, tint = ElementTheme.materialColors.secondary,
modifier = modifier modifier = modifier
.size(reactionEmojiLineHeight.toDp()) .size(reactionEmojiLineHeight.toDp())
) )

View file

@ -291,7 +291,7 @@ private fun TimelineItemEventRowContent(
if (event.reactionsState.reactions.isNotEmpty()) { if (event.reactionsState.reactions.isNotEmpty()) {
TimelineItemReactions( TimelineItemReactions(
reactionsState = event.reactionsState, reactionsState = event.reactionsState,
mainAxisAlignment = if (event.isMine) FlowMainAxisAlignment.End else FlowMainAxisAlignment.Start, isOutgoing = event.isMine,
onReactionClicked = onReactionClicked, onReactionClicked = onReactionClicked,
onMoreReactionsClicked = { onMoreReactionsClicked(event) }, onMoreReactionsClicked = { onMoreReactionsClicked(event) },
modifier = Modifier modifier = Modifier

View file

@ -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 = {}
)
}
}
)
}

View file

@ -19,18 +19,16 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddReaction import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp 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.R
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction 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.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import kotlinx.collections.immutable.ImmutableList 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 @Composable
fun TimelineItemReactions( fun TimelineItemReactions(
reactionsState: TimelineItemReactions, reactionsState: TimelineItemReactions,
mainAxisAlignment: FlowMainAxisAlignment, isOutgoing: Boolean,
onReactionClicked: (emoji: String) -> Unit, onReactionClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: () -> Unit, onMoreReactionsClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var expanded: Boolean by rememberSaveable { mutableStateOf(false) } var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
val reactions by remember(reactionsState, expanded) { // In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL.
derivedStateOf { // For RTL languages it should be the opposite.
val numToDisplay = if (expanded) { val reactionsLayoutDirection = if (!isOutgoing) LocalLayoutDirection.current
reactionsState.reactions.count() else if (LocalLayoutDirection.current == LayoutDirection.Ltr)
} else { LayoutDirection.Rtl
COLLAPSE_ITEMS_THRESHOLD else
} LayoutDirection.Ltr
reactionsState.reactions.take(numToDisplay).toPersistentList()
} 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 @Composable
private fun TimelineItemReactionsView( private fun TimelineItemReactionsView(
reactions: ImmutableList<AggregatedReaction>, reactions: ImmutableList<AggregatedReaction>,
expandableState: ExpandableState, expanded: Boolean,
mainAxisAlignment: FlowMainAxisAlignment,
onReactionClick: (emoji: String) -> Unit, onReactionClick: (emoji: String) -> Unit,
onMoreReactionsClick: () -> Unit, onMoreReactionsClick: () -> Unit,
onExpandClick: () -> Unit, onToggleExpandClick: () -> Unit,
onCollapseClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) = ) = TimelineItemReactionsLayout(
FlowRow( modifier = modifier,
modifier = modifier, itemSpacing = 4.dp,
mainAxisSpacing = 4.dp, rowSpacing = 4.dp,
crossAxisSpacing = 4.dp, expanded = expanded,
mainAxisAlignment = mainAxisAlignment, 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 -> reactions.forEach { reaction ->
MessagesReactionButton( MessagesReactionButton(
content = MessagesReactionsButtonContent.Reaction(reaction = reaction), content = MessagesReactionsButtonContent.Reaction(reaction = reaction),
onClick = { onReactionClick(reaction.key) } 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 @DayNightPreviews
@Composable @Composable
fun TimelineItemReactionsViewPreview() = ElementPreview { fun TimelineItemReactionsViewPreview() = ElementPreview {
ContentToPreview( ContentToPreview(
reactions = aTimelineItemReactions(count = 1).reactions, reactions = aTimelineItemReactions(count = 1).reactions
expandableState = ExpandableState.None,
) )
} }
@DayNightPreviews @DayNightPreviews
@Composable @Composable
fun TimelineItemReactionsViewCollapsedPreview() = ElementPreview { fun TimelineItemReactionsViewFewPreview() = ElementPreview {
ContentToPreview( ContentToPreview(
reactions = aTimelineItemReactions(count = 3).reactions, reactions = aTimelineItemReactions(count = 3).reactions
expandableState = ExpandableState.Collapsed(hidden = 7),
) )
} }
@DayNightPreviews @DayNightPreviews
@Composable @Composable
fun TimelineItemReactionsViewExpandedPreview() = ElementPreview { fun TimelineItemReactionsViewIncomingPreview() = ElementPreview {
ContentToPreview( ContentToPreview(
reactions = aTimelineItemReactions(count = 10).reactions, reactions = aTimelineItemReactions(count = 18).reactions
expandableState = ExpandableState.Expanded, )
}
@DayNightPreviews
@Composable
fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview {
ContentToPreview(
reactions = aTimelineItemReactions(count = 18).reactions,
isOutgoing = true
) )
} }
@Composable @Composable
private fun ContentToPreview( private fun ContentToPreview(
reactions: ImmutableList<AggregatedReaction>, reactions: ImmutableList<AggregatedReaction>,
expandableState: ExpandableState isOutgoing: Boolean = false
) { ) {
TimelineItemReactionsView( TimelineItemReactions(
reactions = reactions, reactionsState = TimelineItemReactions(
expandableState = expandableState, reactions
mainAxisAlignment = FlowMainAxisAlignment.Center, ),
onReactionClick = {}, isOutgoing = isOutgoing,
onMoreReactionsClick = {}, onReactionClicked = {},
onExpandClick = {}, onMoreReactionsClicked = {},
onCollapseClick = {}
) )
} }

View file

@ -81,6 +81,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID) assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
} }
@ -94,6 +95,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
assertThat(room.myReactions.count()).isEqualTo(1) assertThat(room.myReactions.count()).isEqualTo(1)
@ -113,6 +115,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
assertThat(room.myReactions.count()).isEqualTo(1) assertThat(room.myReactions.count()).isEqualTo(1)
@ -129,6 +132,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent())) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
@ -144,6 +148,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, event)) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, event))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
@ -157,6 +162,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent())) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent()))
val finalState = awaitItem() val finalState = awaitItem()
@ -171,6 +177,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null))) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
@ -185,6 +192,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
val mediaMessage = aMessageEvent( val mediaMessage = aMessageEvent(
content = TimelineItemImageContent( content = TimelineItemImageContent(
@ -215,6 +223,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
val mediaMessage = aMessageEvent( val mediaMessage = aMessageEvent(
content = TimelineItemVideoContent( content = TimelineItemVideoContent(
@ -246,6 +255,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
val mediaMessage = aMessageEvent( val mediaMessage = aMessageEvent(
content = TimelineItemFileContent( content = TimelineItemFileContent(
@ -272,6 +282,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent())) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent()))
val finalState = awaitItem() val finalState = awaitItem()
@ -288,6 +299,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent())) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID) assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
@ -302,6 +314,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent())) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
@ -315,6 +328,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.Dismiss) initialState.eventSink.invoke(MessagesEvents.Dismiss)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
@ -328,6 +342,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent())) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
@ -342,6 +357,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
// Initially the composer doesn't have focus, so we don't show the alert // Initially the composer doesn't have focus, so we don't show the alert
assertThat(initialState.showReinvitePrompt).isFalse() assertThat(initialState.showReinvitePrompt).isFalse()
@ -363,6 +379,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showReinvitePrompt).isFalse() assertThat(initialState.showReinvitePrompt).isFalse()
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true)) initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
@ -378,6 +395,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showReinvitePrompt).isFalse() assertThat(initialState.showReinvitePrompt).isFalse()
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true)) initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
@ -401,6 +419,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1) skipItems(1)
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
@ -429,6 +448,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1) skipItems(1)
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
@ -449,6 +469,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1) skipItems(1)
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
@ -476,6 +497,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1) skipItems(1)
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
@ -495,6 +517,7 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
assertThat(awaitItem().userHasPermissionToSendMessage).isTrue() assertThat(awaitItem().userHasPermissionToSendMessage).isTrue()
} }
} }
@ -509,7 +532,7 @@ class MessagesPresenterTest {
}.test { }.test {
// Default value // Default value
assertThat(awaitItem().userHasPermissionToSendMessage).isTrue() assertThat(awaitItem().userHasPermissionToSendMessage).isTrue()
skipItems(1) skipItems(2)
assertThat(awaitItem().userHasPermissionToSendMessage).isFalse() assertThat(awaitItem().userHasPermissionToSendMessage).isFalse()
} }
} }

View file

@ -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 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( private val featureFlagService = FakeFeatureFlagService(
mapOf(FeatureFlags.ShowMediaUploadingFlow.key to true) mapOf(FeatureFlags.LocationSharing.key to true)
) )
private val mediaPreProcessor = FakeMediaPreProcessor() private val mediaPreProcessor = FakeMediaPreProcessor()
private val snackbarDispatcher = SnackbarDispatcher() private val snackbarDispatcher = SnackbarDispatcher()
@ -81,11 +81,13 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isFullScreen).isFalse() assertThat(initialState.isFullScreen).isFalse()
assertThat(initialState.text).isEqualTo("") assertThat(initialState.text).isEqualTo("")
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal("")) assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(initialState.showAttachmentSourcePicker).isFalse() assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.canShareLocation).isTrue()
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None) assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
assertThat(initialState.isSendButtonVisible).isFalse() assertThat(initialState.isSendButtonVisible).isFalse()
} }
@ -97,6 +99,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.ToggleFullScreenState) initialState.eventSink.invoke(MessageComposerEvents.ToggleFullScreenState)
val fullscreenState = awaitItem() val fullscreenState = awaitItem()
@ -113,6 +116,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE)) initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
val withMessageState = awaitItem() val withMessageState = awaitItem()
@ -131,6 +135,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
var state = awaitItem() var state = awaitItem()
val mode = anEditMode() val mode = anEditMode()
state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
@ -149,6 +154,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
var state = awaitItem() var state = awaitItem()
val mode = aReplyMode() val mode = aReplyMode()
state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
@ -166,6 +172,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
var state = awaitItem() var state = awaitItem()
val mode = aQuoteMode() val mode = aQuoteMode()
state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
@ -183,6 +190,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE)) initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
val withMessageState = awaitItem() val withMessageState = awaitItem()
@ -205,6 +213,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.text).isEqualTo("") assertThat(initialState.text).isEqualTo("")
val mode = anEditMode() val mode = anEditMode()
@ -236,6 +245,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.text).isEqualTo("") assertThat(initialState.text).isEqualTo("")
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID) val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
@ -267,6 +277,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.text).isEqualTo("") assertThat(initialState.text).isEqualTo("")
val mode = aReplyMode() val mode = aReplyMode()
@ -294,6 +305,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showAttachmentSourcePicker).isEqualTo(false) assertThat(initialState.showAttachmentSourcePicker).isEqualTo(false)
initialState.eventSink(MessageComposerEvents.AddAttachment) initialState.eventSink(MessageComposerEvents.AddAttachment)
@ -307,6 +319,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.AddAttachment) initialState.eventSink(MessageComposerEvents.AddAttachment)
skipItems(1) skipItems(1)
@ -341,6 +354,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
val previewingState = awaitItem() val previewingState = awaitItem()
@ -375,6 +389,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
val previewingState = awaitItem() val previewingState = awaitItem()
@ -393,6 +408,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
// No crashes here, otherwise it fails // No crashes here, otherwise it fails
@ -413,6 +429,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem() val sendingState = awaitItem()
@ -434,6 +451,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
val previewingState = awaitItem() val previewingState = awaitItem()
@ -450,6 +468,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
val previewingState = awaitItem() val previewingState = awaitItem()
@ -467,6 +486,7 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem() val sendingState = awaitItem()

View file

@ -6,7 +6,7 @@
android_gradle_plugin = "8.0.2" android_gradle_plugin = "8.0.2"
kotlin = "1.8.22" kotlin = "1.8.22"
ksp = "1.8.22-1.0.11" ksp = "1.8.22-1.0.11"
molecule = "1.0.0" molecule = "1.1.0"
# AndroidX # AndroidX
material = "1.9.0" material = "1.9.0"

View file

@ -22,16 +22,8 @@ enum class FeatureFlags(
override val description: String? = null, override val description: String? = null,
override val defaultValue: Boolean = true override val defaultValue: Boolean = true
) : Feature { ) : Feature {
CollapseRoomStateEvents( LocationSharing(
key = "feature.collapseroomstateevents", key = "feature.locationsharing",
title = "Collapse room state events", title = "Allow user to share location",
),
ShowStartChatFlow(
key = "feature.showstartchatflow",
title = "Show start chat flow",
),
ShowMediaUploadingFlow(
key = "feature.showmediauploadingflow",
title = "Show media uploading flow",
) )
} }

View file

@ -29,9 +29,7 @@ class BuildtimeFeatureFlagProvider @Inject constructor() :
override suspend fun isFeatureEnabled(feature: Feature): Boolean { override suspend fun isFeatureEnabled(feature: Feature): Boolean {
return if (feature is FeatureFlags) { return if (feature is FeatureFlags) {
when (feature) { when (feature) {
FeatureFlags.CollapseRoomStateEvents -> false FeatureFlags.LocationSharing -> true
FeatureFlags.ShowStartChatFlow -> false
FeatureFlags.ShowMediaUploadingFlow -> false
} }
} else { } else {
false false

View file

@ -26,14 +26,14 @@ class DefaultFeatureFlagServiceTest {
@Test @Test
fun `given service without provider when feature is checked then it returns the default value`() = runTest { fun `given service without provider when feature is checked then it returns the default value`() = runTest {
val featureFlagService = DefaultFeatureFlagService(emptySet()) val featureFlagService = DefaultFeatureFlagService(emptySet())
val isFeatureEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow) val isFeatureEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
assertThat(isFeatureEnabled).isEqualTo(FeatureFlags.ShowStartChatFlow.defaultValue) assertThat(isFeatureEnabled).isEqualTo(FeatureFlags.LocationSharing.defaultValue)
} }
@Test @Test
fun `given service without provider when set enabled feature is called then it returns false`() = runTest { fun `given service without provider when set enabled feature is called then it returns false`() = runTest {
val featureFlagService = DefaultFeatureFlagService(emptySet()) val featureFlagService = DefaultFeatureFlagService(emptySet())
val result = featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true) val result = featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, true)
assertThat(result).isEqualTo(false) 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 { fun `given service with a runtime provider when set enabled feature is called then it returns true`() = runTest {
val featureFlagProvider = FakeRuntimeFeatureFlagProvider(0) val featureFlagProvider = FakeRuntimeFeatureFlagProvider(0)
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider)) val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider))
val result = featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true) val result = featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, true)
assertThat(result).isEqualTo(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 { 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 featureFlagProvider = FakeRuntimeFeatureFlagProvider(0)
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider)) val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider))
featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true) featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, true)
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow)).isEqualTo(true) assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)).isEqualTo(true)
featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, false) featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, false)
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow)).isEqualTo(false) assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)).isEqualTo(false)
} }
@Test @Test
@ -60,8 +60,8 @@ class DefaultFeatureFlagServiceTest {
val lowPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(LOW_PRIORITY) val lowPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(LOW_PRIORITY)
val highPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(HIGH_PRIORITY) val highPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(HIGH_PRIORITY)
val featureFlagService = DefaultFeatureFlagService(setOf(lowPriorityfeatureFlagProvider, highPriorityfeatureFlagProvider)) val featureFlagService = DefaultFeatureFlagService(setOf(lowPriorityfeatureFlagProvider, highPriorityfeatureFlagProvider))
lowPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, false) lowPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, false)
highPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true) highPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, true)
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow)).isEqualTo(true) assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)).isEqualTo(true)
} }
} }

View file

@ -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.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.api.currentSessionId
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -74,9 +75,16 @@ class DefaultNotificationDrawerManager @Inject constructor(
} }
} }
private var currentAppNavigationState: NavigationState? = null
private fun onAppNavigationStateChange(navigationState: NavigationState) { private fun onAppNavigationStateChange(navigationState: NavigationState) {
when (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.Session -> {}
is NavigationState.Space -> {} is NavigationState.Space -> {}
is NavigationState.Room -> { is NavigationState.Room -> {
@ -91,6 +99,7 @@ class DefaultNotificationDrawerManager @Inject constructor(
) )
} }
} }
currentAppNavigationState = navigationState
} }
private fun createInitialNotificationState(): NotificationState { private fun createInitialNotificationState(): NotificationState {
@ -131,12 +140,21 @@ class DefaultNotificationDrawerManager @Inject constructor(
/** /**
* Clear all known events and refresh the notification drawer. * Clear all known events and refresh the notification drawer.
*/ */
fun clearAllEvents(sessionId: SessionId) { fun clearAllMessagesEvents(sessionId: SessionId) {
updateEvents { updateEvents {
it.clearMessagesForSession(sessionId) 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. * 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. * Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room.

View file

@ -52,7 +52,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId) defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
} }
actionIds.dismissSummary -> actionIds.dismissSummary ->
defaultNotificationDrawerManager.clearAllEvents(sessionId) defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId)
actionIds.dismissInvite -> if (roomId != null) { actionIds.dismissInvite -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
} }

View file

@ -154,6 +154,11 @@ data class NotificationEventQueue constructor(
queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId } 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) { fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
Timber.d("clearMessageEventOfRoom $sessionId, $roomId") Timber.d("clearMessageEventOfRoom $sessionId, $roomId")
queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId } queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId }

View file

@ -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_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_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_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_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">"To send a location, allow %1$s to access your location in the next dialog."</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_some_messages_have_not_been_sent">"Some messages have not been sent"</string>
<string name="error_unknown">"Sorry, an error occurred"</string> <string name="error_unknown">"Sorry, an error occurred"</string>
<string name="invite_friends_rich_title">"🔐️ Join me on %1$s"</string> <string name="invite_friends_rich_title">"🔐️ Join me on %1$s"</string>

View file

@ -17,10 +17,18 @@
import org.gradle.api.JavaVersion import org.gradle.api.JavaVersion
import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.jvm.toolchain.JavaLanguageVersion
object Versions { // Note: 2 digits max for each value
const val versionCode = 100200 private const val versionMajor = 0
const val versionName = "0.2.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 compileSdk = 33
const val targetSdk = 33 const val targetSdk = 33
const val minSdk = 23 const val minSdk = 23

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:69535debd585127a4ce8b490ef6682c2e6c3c4d16478e6b9e9687ee1c1133637 oid sha256:84581aac943c5065f1e5438465ee7d1845555e68aa005d8b596542ce3830dc83
size 20879 size 21258

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:356756de9f08042c3c2f3033d3f8a39cd9b49c5cfbcfbc274933c3efedd80d3d oid sha256:bb559f5cd8b391ab4406c903a59376061255142317b035bd084f153779036eb9
size 34534 size 36589

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:51ac3c4bb27d78419f73b99cb24327514ce56a64a03bf74ce41f158c2c3bd516 oid sha256:ae73b980357c9def721335ecd69ffb8d921963d422e38c84735f53b6f4a596f2
size 33605 size 35101

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:69535debd585127a4ce8b490ef6682c2e6c3c4d16478e6b9e9687ee1c1133637 oid sha256:84581aac943c5065f1e5438465ee7d1845555e68aa005d8b596542ce3830dc83
size 20879 size 21258

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1fa3b5aae9cee5e2fec8929697b0606b655c45b10a65bcd641da315e98c48e1e oid sha256:46ad7d3a46b54543f226e82fa4199e2e2de7e2e91748d663bed88dfb7afc5b61
size 20951 size 21350

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ce74fa2b0364763152e69dcfa4f8d598504b630fa1227f2fa685bc886ccd5afa oid sha256:6fb273e816484cb326ea4ba00948e749362542c1b964e23c5dc48b306b909136
size 19434 size 19843

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:12a78fbc2d2e84e93d40d1e075d8b46c5366f3d323dd85f27be286a64231f884 oid sha256:fac71b82a06f65e799ca345cc9b2e22af6c0000b9900bdcdc584ca6b0f6c2c4d
size 32120 size 34178

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a1bd4187a3713153d6e9ed94594385c3806f59c4d36cc6f2867a38d630551505 oid sha256:e66b4ae355ae37f7810db1923c4018a44b1056c6471a3ed675032f036d30ff34
size 31302 size 32685

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ce74fa2b0364763152e69dcfa4f8d598504b630fa1227f2fa685bc886ccd5afa oid sha256:6fb273e816484cb326ea4ba00948e749362542c1b964e23c5dc48b306b909136
size 19434 size 19843

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9390a3b8111ab0d31b3bbf3b6bf8794432d135130b37404ce4a646a74369d85b oid sha256:91378a7a126d1d9f9f234368930635ce85db3666721dc32b0646effa59d0fdee
size 19502 size 19991

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c936d2d804bc9e98fcc49430f11ddaa572b05fc8d3a0df93ad6521ee8e78f708 oid sha256:6d98aabf3bd99793367632e18d5cf678e75fb5ad872a1cb300a7e939ad0c2683
size 21806 size 19725

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c936d2d804bc9e98fcc49430f11ddaa572b05fc8d3a0df93ad6521ee8e78f708
size 21806

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0bb88c64bc68b10b1fb709135f445de5a5e4d78623448d0fef97504e025d5f6d oid sha256:94be795b626868e8afbcc26c3f0161ddcb946a122c02eeed7e823825c9aeba19
size 24551 size 22263

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0bb88c64bc68b10b1fb709135f445de5a5e4d78623448d0fef97504e025d5f6d
size 24551

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:724bc9b0db8f581119201a8db3c405c9a7e7261cccea45a354fd37a5e33fcb41 oid sha256:6c73a80923ec5b269bbb5f5108dde9617dac39e9c51f9199ab9229c4dd7ca2b0
size 11141 size 11532

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:109a1a9c6359a9fd7fe4258f022e845c9b8142f9e590a03e5c2efa9273cb572f oid sha256:9eee3a5873231554cc0335fe7b552a62459f35e4ffb8c43b91a9746b34314bdc
size 10742 size 11171

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:38c6d3f4a47ed89c3deb4150024b49c0a533a859f21a1592a82309b8c7316ea4 oid sha256:85f9960cebee2e04d09f3ef17bd81e9adf9f463b24edae73a56d6d0c0a09ce48
size 152242 size 152228

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f82840398e396e038ad68a5fe855394a0088c413e7aade339998b8ed63fb1c09 oid sha256:39226828ae899c8b765827cad36f9966ec80c0a39ed406c1257224a56255af9b
size 157273 size 157243

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7c5ef7519aef3badeda3983b2ec5474f31404bc06b4b46b9829848dc7884fe1d oid sha256:88bdceab1a4e44f971ce507b5b6aeb5513657dfe6c4d61ec5483e215d83254d5
size 81749 size 81534

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c9e9b769422e4714e87ace97058e5a8f064e3b927e3a7c6042494de8381d6b09 oid sha256:461683659d023323184049e122d02e4c63f217157e0bb3165f50be833d19ba7e
size 85856 size 85517

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4970830b042e7b6588c03bf632ae3ade5b39de7339e27618ee341cbd9861c0e9 oid sha256:c00bf46d1ef6337bad18f6454cf4601141ed2190c6a697ddc46d5a156d4997ca
size 129351 size 127950

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4aba48e03e8424e5ff131c22fd5a606280745dd318f5822e2167f3b39794ff41 oid sha256:88cd76c95c31061bf4e9e7528ed5d66a8b49813d868f21b2d2615e77318a9706
size 134569 size 133068

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c81a4a854304b62bd356ae9a6d588918d11bf1df0aaa209910fa2fa970f47cb8
size 26684

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f32e1f9f7c10672b3a35d6c0c3e3a9347b92ef3fe62be40bc378a5225da5d3d6
size 26285

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4e892d14d11fb576228418acd216468438f41a631a073e80638675ec48a91ed6
size 12334

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:64c2e18e97bba9ff6ae6bb24a17fe567352cd49f1b5905065b7de8854369e22f
size 12209

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d0d29cc0acc0d9009c6fd6aebccf72d6244cec8ec3e3bf3bd445e7cced52561
size 26004

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dbfe5166cb1df7be35f2d3f114f73fb4425a10f4eb5bd24a87cafe3ddd76e873
size 25719

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d5f4241f356099cc2f2f55e557218c8872cd0f25f582ba75db254c11613090db
size 26026

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cf149f6a8c5c8d5d4591b9f6cfe8612a1af2b1cb101b0d1f699945bd711890b4
size 25685

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:54c12970e3563de958f88e4c538dd368f9810266060627393256a91741f7c6cf oid sha256:cfc4f0cc8e252cdc9200286cf330c4bbdf3cd66ced2c23364cca680aca79954d
size 53340 size 53293

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3b7ae3084cb9d1ecee2e4db49c228516bdd7352683e797edf737c8d216922dec oid sha256:01c7f94b759eea738fb42baa4b8b835c27d48e3fbfdd40d0a925080db8196a80
size 65601 size 65563

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dd034d439c08793e0dfd59f6bd5dcd88c06ded6cbe98172bf3cf296888e6d575 oid sha256:e4360b6f5be55e5b468e735f83b6263ae3b0f1a3f7c0084e6dc3c07396723abd
size 51244 size 51206

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d9d835cb1a420117b4d967181e2ca0fad71ba243d6a17bea08b82cae41f6b8e2 oid sha256:0a58bb336bbf56b05584d73fa7fd3bd2e2fe97c82829a01988343d4c3b4c77ab
size 68760 size 68727

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4ad09278ae2ebb8171adba96d9f6e91d0cc4f120b0b2368087796dadb37eeb87 oid sha256:ff2c7a701945b72afc6731c9da722e0bf178879f0bd80bc40da7ebb1d6e90d28
size 58539 size 58505

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:082206122d4e6d9e6171b3b2444c576ff7bd47fb3946d8d98e2812654f39cd40 oid sha256:4591b08ec297c0b024870e60ac18b03aadeaa8ff20a726dd991b18a80ec353ef
size 73641 size 73607

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dc695bfc589e8e5a852eeb9b2828ce968d17519bb5be957cf2734a7d6d9cc356 oid sha256:6586d62bdbd3d4331105f561669c5383d578ad8af559270a26c7d9ff741b473b
size 89482 size 89412

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9a243d53d10ca2eaa249b22af8a9fddf1a8ccf60db4f3ad9374e31cf494fe878 oid sha256:8981ec8fd5e68e6e332f98ee2c95fc0f902b9b912a05201cad1c6a9feb5dbc89
size 54909 size 54868

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3fdd478be89b47fcaf9725ca14c1e775087f1ccf2a266f3476a537bbc2b29922 oid sha256:a5e787ce25a3542e398bec3aadd2b9b923c81dee4f3fbf8936c1c15e5aa1816b
size 67476 size 67450

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1a3b5bbcdd1593e81384b335045bdcb0b3e01782868993e9f6437a15ca39dbca oid sha256:1a8af5ff868170d946d03db115271e7ea9c0a15db35bf6815bf95cc4e4f7fa81
size 51380 size 51357

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7e26db0a0a8d767d6e732c1d2742cba3c3475d761b0c3017ff01cee7e3d362a5 oid sha256:438bc82c007c9cd72228f47681981e833645c591c0a48e8a5cb70bd51721d35a
size 62773 size 62756

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3d5cae7d73178aecc12fbf5f1f27c9e66608b90d462deec862a881b403469e93 oid sha256:a0926170ca4277e47f9ae49f5092c195cee2a84d67259ba8dc3aa3539d4faecf
size 49475 size 49454

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fe9456f4446104142221e28167a578a2b5cac772dc35aeb14352c0d422dd6fb0 oid sha256:68ce3ada02e8ed6cb1e257e49d8c703bd0ff3401bf0e520fa617ed1ea91c6756
size 65726 size 65691

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cad3b5ce023d890fd4a5ff0f5bbabb678bc6d5c76d3476e8053673c06f360c8b oid sha256:c902787fb2d84a0e92832020cdd447fc039264fc5add63abea32241251c92bf7
size 56102 size 56102

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:41a6c2dd81698696708802276b615a78ff22cc7cb8ae2d4b8d8cc862bfe44a24 oid sha256:bc956e4fdab7bf15d71e10585947f9f5c24bc1d9e4eaa071de9b747fae879f53
size 70856 size 70883

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:167f8368e0b94aa05e5d1cc30055e3b0c2c910752d719092ef2d34aae09dc832 oid sha256:fdf1b66ffe0b7042356d22aa835ceea2c5b697c4ce62287b5ec040f528210216
size 84649 size 84635

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:789ee5ccad8356198cdc0634b4e9a65ed44be2d26e7ce83a8662598c1bd8d4c2 oid sha256:e438a2e542780851a7b570407e02f125e191d23b9c5299d9457baad78523de84
size 52765 size 52740

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7b40a2e5d60a906d7c35c3ece1854671f5b319b00ad077322d094cbf906c07f7 oid sha256:17f11b2cb93b8736a2d1224b7ad70c7e3a1a45bcb7e5ceb60475dba734e6efec
size 64803 size 64787

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7c724bc77185a9ceec2cf092cb1d7865b13718d5320bd7ac4850bb85590f05b2 oid sha256:de7f21b5fcc1235a80ea2e7f9e26a93503ea2d0c0793a89db8657979afb33a7d
size 52294 size 52267

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a303894134ed06348b609e41cf109dcafcd994c3dffabc6d9ab436fe92605245 oid sha256:3d368f7cc84b5a8850577fac658aaeb89a1c83a2890b7fd393577c9cde919069
size 53710 size 53689

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7523ec0d6defd7074af0c804fdde64fb8d421f6cfa729bc1c4f9858bd87c42d0 oid sha256:56440b44cf610b0baaf7401bebb8ba2f4504b5dd36cb54fe1669d5deec6d1674
size 52554 size 52530

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1f48089147f7e089abfa64254143a80857ccc9f840aa60403e1aabc67e2b6d51 oid sha256:9387ab3e62a9e10721efdea3a6abcdf29db82a04ebdb3c4cefcad2a20ab9b9b8
size 55458 size 55386

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7ff682ee8363d450bb76db72ea06deea87fa47692ce319b7dff315d2a10dfb6a oid sha256:c0e3bd4e37bb665df997f189e5c2dc763c7f703b78384c63737675bd764fe7a8
size 51033 size 51174

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e84edf8adf1a89153dd45272d36e04561d66f2ea765ad9edecfd8d750ba99f97 oid sha256:ade57e690313eaaeddc7b21b9a2661026fca70e6af1b09aa331bba3d1b1bbaf3
size 54237 size 54240

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0bc9521bd1576d47ca6f643adf43ce3638d40328207d908ec863c72503d34f24 oid sha256:89520fd2999582229ace8fd9304644fc74ce01230a4024b90b47ce1cd61eb564
size 55682 size 55678

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c2209c3cc4e7de32ed92b3b0da4616b54d19091d43d21c0e4013728450d0d3f7 oid sha256:19420aad469fad8a218a0a8cd0f7cfbb9593d7ceb799478a7ad9b84d9c24602f
size 54595 size 54598

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d7bdd0ca39534b31c9d421e28337712b1cf2aaf841a766fcc6bdaf996c756bfa oid sha256:e224a21a1ae913066530e8be81760795b10be388cb7fa92fe399b1e11367d7af
size 57524 size 57423

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ba349f81d5c417c612cae1263504ea5b4e83dc606ec20c3942368e8992b87ad4 oid sha256:cac4b5bdb9f225c5313f36caef190e3f698b734db79707928572c942fed85603
size 52886 size 52960

19
tools/gitflow/gitflow-init.sh Executable file
View 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