Merge branch 'develop' into renovate/compose.bom

This commit is contained in:
ganfra 2024-11-15 11:31:39 +01:00 committed by GitHub
commit c4557db9ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 583 additions and 850 deletions

View file

@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
variant: [debug, release, nightly, samples]
variant: [debug, release, nightly]
fail-fast: false
# Allow all jobs on develop. Just one per PR.
concurrency:
@ -82,6 +82,3 @@ jobs:
- name: Compile nightly sources
if: ${{ matrix.variant == 'nightly' }}
run: ./gradlew compileGplayNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile samples minimal
if: ${{ matrix.variant == 'samples' }}
run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES

View file

@ -49,8 +49,6 @@ Please ensure that you're using the project formatting rules (which are in the p
This project should compile without any special action. Just clone it and open it with Android Studio, or compile from command line using `gradlew`.
Note: please make sure that the configuration is `app` and not `samples.minimal`.
## Strings
The strings of the project are managed externally using [https://localazy.com](https://localazy.com) and shared with Element X iOS.

View file

@ -40,7 +40,7 @@ We want:
The CI checks that:
1. The code is compiling, without any warnings, for all the app build types and variants and for the minimal app
1. The code is compiling, without any warnings, for all the app build types and variants
2. The tests are passing
3. The code quality is good (detekt, ktlint, lint)
4. The code is running and smoke tests are passing (maestro)

View file

@ -19,6 +19,4 @@ data class CreateRoomConfig(
val avatarUri: Uri? = null,
val invites: ImmutableList<MatrixUser> = persistentListOf(),
val roomVisibility: RoomVisibilityState = RoomVisibilityState.Private,
) {
val isValid = roomName.isNullOrEmpty().not() && roomVisibility.isValid()
}
)

View file

@ -11,13 +11,13 @@ import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomAccess
import io.element.android.features.createroom.impl.configureroom.RoomAccessItem
import io.element.android.features.createroom.impl.configureroom.RoomAddress
import io.element.android.features.createroom.impl.configureroom.RoomAddressErrorState
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityItem
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -29,6 +29,7 @@ import javax.inject.Inject
@SingleIn(CreateRoomScope::class)
class CreateRoomDataStore @Inject constructor(
val selectedUserListDataStore: UserListDataStore,
private val roomAliasHelper: RoomAliasHelper,
) {
private val createRoomConfigFlow: MutableStateFlow<CreateRoomConfig> = MutableStateFlow(CreateRoomConfig())
private var cachedAvatarUri: Uri? = null
@ -46,13 +47,13 @@ class CreateRoomDataStore @Inject constructor(
fun setRoomName(roomName: String) {
createRoomConfigFlow.getAndUpdate { config ->
/*
val newVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> {
val roomAddress = config.roomVisibility.roomAddress
if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(roomName)
config.roomVisibility.copy(
roomAddress = RoomAddress.AutoFilled(roomName),
roomAddress = RoomAddress.AutoFilled(roomAliasName),
)
} else {
config.roomVisibility
@ -60,9 +61,9 @@ class CreateRoomDataStore @Inject constructor(
}
else -> config.roomVisibility
}
*/
config.copy(
roomName = roomName.takeIf { it.isNotEmpty() },
roomVisibility = newVisibility,
)
}
}
@ -85,11 +86,13 @@ class CreateRoomDataStore @Inject constructor(
config.copy(
roomVisibility = when (visibility) {
RoomVisibilityItem.Private -> RoomVisibilityState.Private
RoomVisibilityItem.Public -> RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(config.roomName.orEmpty()),
roomAddressErrorState = RoomAddressErrorState.None,
roomAccess = RoomAccess.Anyone,
)
RoomVisibilityItem.Public -> {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty())
RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasName),
roomAccess = RoomAccess.Anyone,
)
}
}
)
}

View file

@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
@ -31,6 +32,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.roomAliasFromName
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
@ -39,9 +42,12 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Optional
import javax.inject.Inject
import kotlin.jvm.optionals.getOrNull
class ConfigureRoomPresenter @Inject constructor(
private val dataStore: CreateRoomDataStore,
@ -51,6 +57,7 @@ class ConfigureRoomPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val featureFlagService: FeatureFlagService,
private val roomAliasHelper: RoomAliasHelper,
) : Presenter<ConfigureRoomState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
@ -58,9 +65,12 @@ class ConfigureRoomPresenter @Inject constructor(
@Composable
override fun present(): ConfigureRoomState {
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig = dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
val homeserverName = remember { matrixClient.userIdServerName() }
val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false)
val roomAddressValidity = remember {
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) },
@ -69,12 +79,12 @@ class ConfigureRoomPresenter @Inject constructor(
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri) }
)
val avatarActions by remember(createRoomConfig.value.avatarUri) {
val avatarActions by remember(createRoomConfig.avatarUri) {
derivedStateOf {
listOfNotNull(
AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto,
AvatarAction.Remove.takeIf { createRoomConfig.value.avatarUri != null },
AvatarAction.Remove.takeIf { createRoomConfig.avatarUri != null },
).toImmutableList()
}
}
@ -86,6 +96,10 @@ class ConfigureRoomPresenter @Inject constructor(
}
}
RoomAddressValidityEffect(createRoomConfig.roomVisibility.roomAddress()) { newRoomAddressValidity ->
roomAddressValidity.value = newRoomAddressValidity
}
val localCoroutineScope = rememberCoroutineScope()
val createRoomAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
@ -102,7 +116,7 @@ class ConfigureRoomPresenter @Inject constructor(
is ConfigureRoomEvents.RemoveUserFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess)
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig.value)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig)
is ConfigureRoomEvents.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
@ -122,15 +136,49 @@ class ConfigureRoomPresenter @Inject constructor(
return ConfigureRoomState(
isKnockFeatureEnabled = isKnockFeatureEnabled,
config = createRoomConfig.value,
config = createRoomConfig,
avatarActions = avatarActions,
createRoomAction = createRoomAction.value,
cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity.value,
eventSink = ::handleEvents,
)
}
@Composable
private fun RoomAddressValidityEffect(
roomAddress: Optional<String>,
onRoomAddressValidityChange: (RoomAddressValidity) -> Unit,
) {
val onChange by rememberUpdatedState(onRoomAddressValidityChange)
LaunchedEffect(roomAddress) {
val roomAliasName = roomAddress.getOrNull().orEmpty()
if (roomAliasName.isEmpty()) {
onChange(RoomAddressValidity.Unknown)
return@LaunchedEffect
}
// debounce the room address validation
delay(300)
val roomAlias = matrixClient.roomAliasFromName(roomAliasName).getOrNull()
if (roomAlias == null || !roomAliasHelper.isRoomAliasValid(roomAlias)) {
onChange(RoomAddressValidity.InvalidSymbols)
} else {
matrixClient.resolveRoomAlias(roomAlias)
.onSuccess { resolved ->
if (resolved.isPresent) {
onChange(RoomAddressValidity.NotAvailable)
} else {
onChange(RoomAddressValidity.Valid)
}
}
.onFailure {
onChange(RoomAddressValidity.Valid)
}
}
}
}
private fun CoroutineScope.createRoom(
config: CreateRoomConfig,
createRoomAction: MutableState<AsyncAction<RoomId>>
@ -148,7 +196,7 @@ class ConfigureRoomPresenter @Inject constructor(
preset = RoomPreset.PUBLIC_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
canonicalAlias = config.roomVisibility.roomAddress()
roomAliasName = config.roomVisibility.roomAddress()
)
} else {
CreateRoomParameters(

View file

@ -20,6 +20,10 @@ data class ConfigureRoomState(
val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: AsyncAction<RoomId>,
val cameraPermissionState: PermissionsState,
val roomAddressValidity: RoomAddressValidity,
val homeserverName: String,
val eventSink: (ConfigureRoomEvents) -> Unit
)
) {
val isValid: Boolean = config.roomName?.isNotEmpty() == true &&
(config.roomVisibility is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid)
}

View file

@ -28,9 +28,8 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aMatrixUserList().toImmutableList(),
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room 101"),
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
roomAddressErrorState = RoomAddressErrorState.None,
),
),
),
@ -40,12 +39,44 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aMatrixUserList().toImmutableList(),
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room 101"),
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
roomAddressErrorState = RoomAddressErrorState.None,
),
),
),
aConfigureRoomState(
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
),
),
roomAddressValidity = RoomAddressValidity.NotAvailable,
),
aConfigureRoomState(
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
),
),
roomAddressValidity = RoomAddressValidity.InvalidSymbols,
),
aConfigureRoomState(
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
),
),
roomAddressValidity = RoomAddressValidity.Valid,
),
)
}
@ -56,6 +87,7 @@ fun aConfigureRoomState(
createRoomAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
homeserverName: String = "matrix.org",
roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid,
eventSink: (ConfigureRoomEvents) -> Unit = { },
) = ConfigureRoomState(
config = config,
@ -64,5 +96,6 @@ fun aConfigureRoomState(
createRoomAction = createRoomAction,
cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity,
eventSink = eventSink,
)

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
@ -79,7 +80,7 @@ fun ConfigureRoomView(
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
ConfigureRoomToolbar(
isNextActionEnabled = state.config.isValid,
isNextActionEnabled = state.isValid,
onBackClick = onBackClick,
onNextClick = {
focusManager.clearFocus()
@ -143,8 +144,10 @@ fun ConfigureRoomView(
modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.roomVisibility.roomAddress,
homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
)
Spacer(Modifier)
}
}
}
@ -319,6 +322,7 @@ private fun RoomAccessOptions(
private fun RoomAddressField(
address: RoomAddress,
homeserverName: String,
addressValidity: RoomAddressValidity,
onAddressChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
@ -340,7 +344,16 @@ private fun RoomAddressField(
color = ElementTheme.colors.textSecondary,
)
},
supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
supportingText = when (addressValidity) {
RoomAddressValidity.InvalidSymbols -> {
stringResource(R.string.screen_create_room_room_address_invalid_symbols_error_description)
}
RoomAddressValidity.NotAvailable -> {
stringResource(R.string.screen_create_room_room_address_not_available_error_description)
}
else -> stringResource(R.string.screen_create_room_room_address_section_footer)
},
isError = addressValidity.isError(),
onValueChange = onAddressChange,
singleLine = true,
)

View file

@ -1,17 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
/**
* Represents the error state of a room address.
*/
sealed interface RoomAddressErrorState {
data object InvalidCharacters : RoomAddressErrorState
data object AlreadyExists : RoomAddressErrorState
data object None : RoomAddressErrorState
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import androidx.compose.runtime.Immutable
/**
* Represents the validity state of a room address.
* ie. whether it contains invalid characters, is already taken, or is valid.
*/
@Immutable
sealed interface RoomAddressValidity {
data object Unknown : RoomAddressValidity
data object InvalidSymbols : RoomAddressValidity
data object NotAvailable : RoomAddressValidity
data object Valid : RoomAddressValidity
fun isError(): Boolean {
return this is InvalidSymbols || this is NotAvailable
}
}

View file

@ -14,7 +14,6 @@ sealed interface RoomVisibilityState {
data class Public(
val roomAddress: RoomAddress,
val roomAddressErrorState: RoomAddressErrorState,
val roomAccess: RoomAccess,
) : RoomVisibilityState
@ -24,11 +23,4 @@ sealed interface RoomVisibilityState {
is Public -> Optional.of(roomAddress.value)
}
}
fun isValid(): Boolean {
return when (this) {
is Private -> true
is Public -> roomAddressErrorState is RoomAddressErrorState.None && roomAddress.value.isNotEmpty()
}
}
}

View file

@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
@ -32,7 +33,7 @@ class AddPeoplePresenterTest {
presenter = AddPeoplePresenter(
FakeUserListPresenterFactory(),
FakeUserRepository(),
CreateRoomDataStore(UserListDataStore())
CreateRoomDataStore(UserListDataStore(), FakeRoomAliasHelper())
)
}

View file

@ -19,11 +19,15 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
@ -44,6 +48,8 @@ import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
@ -52,6 +58,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.util.Optional
private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2"
@ -95,21 +102,21 @@ class ConfigureRoomPresenterTest {
presenter.test {
val initialState = initialState()
var config = initialState.config
assertThat(initialState.config.isValid).isFalse()
assertThat(initialState.isValid).isFalse()
// Room name not empty
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
var newState: ConfigureRoomState = awaitItem()
config = config.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(config)
assertThat(newState.config.isValid).isTrue()
assertThat(newState.isValid).isTrue()
// Clear room name
newState.eventSink(ConfigureRoomEvents.RoomNameChanged(""))
newState = awaitItem()
config = config.copy(roomName = null)
assertThat(newState.config).isEqualTo(config)
assertThat(newState.config.isValid).isFalse()
assertThat(newState.isValid).isFalse()
}
}
@ -118,8 +125,9 @@ class ConfigureRoomPresenterTest {
val userListDataStore = UserListDataStore()
val pickerProvider = FakePickerProvider()
val permissionsPresenter = FakePermissionsPresenter()
val roomAliasHelper = FakeRoomAliasHelper()
val presenter = createConfigureRoomPresenter(
createRoomDataStore = CreateRoomDataStore(userListDataStore),
createRoomDataStore = CreateRoomDataStore(userListDataStore, roomAliasHelper),
pickerProvider = pickerProvider,
permissionsPresenter = permissionsPresenter,
)
@ -191,8 +199,7 @@ class ConfigureRoomPresenterTest {
newState = awaitItem()
expectedConfig = expectedConfig.copy(
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(expectedConfig.roomName ?: ""),
roomAddressErrorState = RoomAddressErrorState.None,
roomAddress = RoomAddress.AutoFilled(roomAliasHelper.roomAliasNameFromRoomDisplayName(expectedConfig.roomName ?: "")),
roomAccess = RoomAccess.Anyone,
)
)
@ -254,7 +261,7 @@ class ConfigureRoomPresenterTest {
val matrixClient = createMatrixClient()
val analyticsService = FakeAnalyticsService()
val mediaPreProcessor = FakeMediaPreProcessor()
val createRoomDataStore = CreateRoomDataStore(UserListDataStore())
val createRoomDataStore = CreateRoomDataStore(UserListDataStore(), FakeRoomAliasHelper())
val presenter = createConfigureRoomPresenter(
createRoomDataStore = createRoomDataStore,
mediaPreProcessor = mediaPreProcessor,
@ -315,17 +322,88 @@ class ConfigureRoomPresenterTest {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - address is invalid when format is invalid`() = runTest {
val aliasHelper = FakeRoomAliasHelper(
isRoomAliasValidLambda = { false }
)
val presenter = createConfigureRoomPresenter(
roomAliasHelper = aliasHelper
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address"))
skipItems(1)
advanceUntilIdle()
awaitItem().also { state ->
assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.InvalidSymbols)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - address is not available when alias is not available`() = runTest {
val fakeMatrixClient = createMatrixClient(isAliasAvailable = false)
val presenter = createConfigureRoomPresenter(
matrixClient = fakeMatrixClient,
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
skipItems(1)
advanceUntilIdle()
awaitItem().also { state ->
assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.NotAvailable)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - address is valid when alias is available and format is valid`() = runTest {
val fakeMatrixClient = createMatrixClient(isAliasAvailable = true)
val presenter = createConfigureRoomPresenter(
matrixClient = fakeMatrixClient,
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
skipItems(1)
advanceUntilIdle()
awaitItem().also { state ->
assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.Valid)
}
}
}
private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1)
return awaitItem()
}
private fun createMatrixClient() = FakeMatrixClient(
private fun createMatrixClient(isAliasAvailable: Boolean = true) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = {
val resolvedRoomAlias = if (isAliasAvailable) {
Optional.empty()
} else {
Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
}
Result.success(resolvedRoomAlias)
}
)
private fun createConfigureRoomPresenter(
createRoomDataStore: CreateRoomDataStore = CreateRoomDataStore(UserListDataStore()),
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
createRoomDataStore: CreateRoomDataStore = CreateRoomDataStore(UserListDataStore(), roomAliasHelper),
matrixClient: MatrixClient = createMatrixClient(),
pickerProvider: PickerProvider = FakePickerProvider(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
@ -339,6 +417,7 @@ class ConfigureRoomPresenterTest {
mediaPreProcessor = mediaPreProcessor,
analyticsService = analyticsService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
roomAliasHelper = roomAliasHelper,
featureFlagService = FakeFeatureFlagService(
mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
)

View file

@ -212,7 +212,7 @@ class MessagesNode @AssistedInject constructor(
state = state,
onBackClick = this::navigateUp,
onRoomDetailsClick = this::onRoomDetailsClick,
onEventClick = this::onEventClick,
onEventContentClick = this::onEventClick,
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClick = this::onUserDataClick,
onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) },

View file

@ -114,7 +114,7 @@ fun MessagesView(
state: MessagesState,
onBackClick: () -> Unit,
onRoomDetailsClick: () -> Unit,
onEventClick: (event: TimelineItem.Event) -> Boolean,
onEventContentClick: (event: TimelineItem.Event) -> Boolean,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
@ -142,9 +142,9 @@ fun MessagesView(
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
val localView = LocalView.current
fun onMessageClick(event: TimelineItem.Event) {
fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
val hideKeyboard = onEventClick(event)
val hideKeyboard = onEventContentClick(event)
if (hideKeyboard) {
localView.hideKeyboard()
}
@ -206,7 +206,7 @@ fun MessagesView(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
onMessageClick = ::onMessageClick,
onContentClick = ::onContentClick,
onMessageLongClick = ::onMessageLongClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
@ -306,7 +306,7 @@ private fun AttachmentStateView(
@Composable
private fun MessagesViewContent(
state: MessagesState,
onMessageClick: (TimelineItem.Event) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
@ -382,7 +382,7 @@ private fun MessagesViewContent(
timelineProtectionState = state.timelineProtectionState,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onMessageClick = onMessageClick,
onContentClick = onContentClick,
onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick,
@ -568,7 +568,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
state = state,
onBackClick = {},
onRoomDetailsClick = {},
onEventClick = { false },
onEventContentClick = { false },
onUserDataClick = {},
onLinkClick = {},
onPreviewAttachments = {},

View file

@ -33,7 +33,7 @@ internal fun MessagesViewWithIdentityChangePreview(
),
onBackClick = {},
onRoomDetailsClick = {},
onEventClick = { false },
onEventContentClick = { false },
onUserDataClick = {},
onLinkClick = {},
onPreviewAttachments = {},

View file

@ -216,7 +216,7 @@ private fun PinnedMessagesListLoaded(
focusedEventId = null,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onClick = onEventClick,
onContentClick = onEventClick,
onLongClick = ::onMessageLongClick,
inReplyToClick = {},
onReactionClick = { _, _ -> },
@ -230,6 +230,7 @@ private fun PinnedMessagesListLoaded(
TimelineItemEventContentViewWrapper(
event = event,
timelineProtectionState = state.timelineProtectionState,
onContentClick = { onEventClick(event) },
onLinkClick = onLinkClick,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
@ -244,6 +245,7 @@ private fun PinnedMessagesListLoaded(
private fun TimelineItemEventContentViewWrapper(
event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState,
onContentClick: () -> Unit,
onLinkClick: (String) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
@ -258,10 +260,11 @@ private fun TimelineItemEventContentViewWrapper(
TimelineItemEventContentView(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
eventSink = { },
modifier = modifier,
onContentClick = onContentClick,
onContentLayoutChange = onContentLayoutChange
)
}

View file

@ -76,7 +76,7 @@ fun TimelineView(
timelineProtectionState: TimelineProtectionState,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onMessageClick: (TimelineItem.Event) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, TimelineItem.Event) -> Unit,
@ -141,7 +141,7 @@ fun TimelineView(
focusedEventId = state.focusedEventId,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onClick = onMessageClick,
onContentClick = onContentClick,
onLongClick = onMessageLongClick,
inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick,
@ -322,7 +322,7 @@ internal fun TimelineViewPreview(
timelineProtectionState = aTimelineProtectionState(),
onUserDataClick = {},
onLinkClick = {},
onMessageClick = {},
onContentClick = {},
onMessageLongClick = {},
onSwipeToReply = {},
onReactionClick = { _, _ -> },

View file

@ -41,7 +41,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
timelineProtectionState = aTimelineProtectionState(),
onUserDataClick = {},
onLinkClick = {},
onMessageClick = {},
onContentClick = {},
onMessageLongClick = {},
onSwipeToReply = {},
onReactionClick = { _, _ -> },

View file

@ -30,7 +30,7 @@ internal fun ATimelineItemEventRow(
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted,
onClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onUserDataClick = {},

View file

@ -114,7 +114,7 @@ fun TimelineItemEventRow(
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean,
onClick: () -> Unit,
onContentClick: () -> Unit,
onLongClick: () -> Unit,
onLinkClick: (String) -> Unit,
onUserDataClick: (UserId) -> Unit,
@ -130,7 +130,8 @@ fun TimelineItemEventRow(
TimelineItemEventContentView(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onContentClick = onContentClick,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
eventSink = eventSink,
modifier = contentModifier,
@ -150,6 +151,12 @@ fun TimelineItemEventRow(
inReplyToClick(inReplyToEventId)
}
val onWholeItemClick = if (event.isWholeContentClickable) {
onContentClick
} else {
{}
}
Column(modifier = modifier.fillMaxWidth()) {
if (event.groupPosition.isNew()) {
Spacer(modifier = Modifier.height(16.dp))
@ -173,7 +180,7 @@ fun TimelineItemEventRow(
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
onClick = onClick,
onContentClick = onWholeItemClick,
onLongClick = onLongClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
@ -207,7 +214,7 @@ fun TimelineItemEventRow(
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
onClick = onClick,
onContentClick = onWholeItemClick,
onLongClick = onLongClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
@ -263,7 +270,7 @@ private fun TimelineItemEventRowContent(
isHighlighted: Boolean,
timelineRoomInfo: TimelineRoomInfo,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
onContentClick: () -> Unit,
onLongClick: () -> Unit,
inReplyToClick: () -> Unit,
onUserDataClick: () -> Unit,
@ -340,7 +347,7 @@ private fun TimelineItemEventRowContent(
},
state = bubbleState,
interactionSource = interactionSource,
onClick = onClick,
onClick = onContentClick,
onLongClick = onLongClick,
) {
MessageEventBubbleContent(

View file

@ -57,10 +57,11 @@ fun TimelineItemGroupedEventsRow(
TimelineItemEventContentView(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
eventSink = eventSink,
modifier = contentModifier,
onContentClick = {},
onContentLayoutChange = onContentLayoutChange
)
},
@ -121,10 +122,11 @@ private fun TimelineItemGroupedEventsRowContent(
TimelineItemEventContentView(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
eventSink = eventSink,
modifier = contentModifier,
onContentClick = {},
onContentLayoutChange = onContentLayoutChange
)
},
@ -152,7 +154,7 @@ private fun TimelineItemGroupedEventsRowContent(
focusedEventId = focusedEventId,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onClick = onClick,
onContentClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,

View file

@ -28,7 +28,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
import io.element.android.libraries.matrix.api.core.EventId
@ -44,7 +43,7 @@ internal fun TimelineItemRow(
focusedEventId: EventId?,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
@ -60,7 +59,8 @@ internal fun TimelineItemRow(
TimelineItemEventContentView(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onContentClick = { onContentClick(event) },
onLinkClick = onLinkClick,
eventSink = eventSink,
modifier = contentModifier,
@ -95,7 +95,7 @@ internal fun TimelineItemRow(
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = timelineItem.isEvent(focusedEventId),
onClick = { onClick(timelineItem) },
onClick = { onContentClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick,
onLongClick = { onLongClick(timelineItem) },
eventSink = eventSink,
@ -118,11 +118,7 @@ internal fun TimelineItemRow(
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = timelineItem.isEvent(focusedEventId),
onClick = if (timelineProtectionState.hideMediaContent(timelineItem.eventId) && timelineItem.mustBeProtected()) {
{}
} else {
{ onClick(timelineItem) }
},
onContentClick = { onContentClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onLinkClick = onLinkClick,
onUserDataClick = onUserDataClick,
@ -148,7 +144,7 @@ internal fun TimelineItemRow(
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId,
onClick = onClick,
onClick = onContentClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,

View file

@ -72,8 +72,9 @@ fun TimelineItemStateEventRow(
content = event.content,
onLinkClick = {},
hideMediaContent = false,
onShowClick = {},
onShowContentClick = {},
eventSink = eventSink,
onContentClick = {},
modifier = Modifier.defaultTimelineContentPadding()
)
}

View file

@ -36,7 +36,8 @@ import io.element.android.libraries.architecture.Presenter
fun TimelineItemEventContentView(
content: TimelineItemEventContent,
hideMediaContent: Boolean,
onShowClick: () -> Unit,
onContentClick: () -> Unit,
onShowContentClick: () -> Unit,
onLinkClick: (url: String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier,
@ -67,25 +68,31 @@ fun TimelineItemEventContentView(
)
is TimelineItemLocationContent -> TimelineItemLocationView(
content = content,
onContentClick = onContentClick,
modifier = modifier
)
is TimelineItemImageContent -> TimelineItemImageView(
content = content,
hideMediaContent = hideMediaContent,
onShowClick = onShowClick,
onContentClick = onContentClick,
onShowContentClick = onShowContentClick,
onLinkClick = onLinkClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
)
is TimelineItemStickerContent -> TimelineItemStickerView(
content = content,
hideMediaContent = hideMediaContent,
onShowClick = onShowClick,
onContentClick = onContentClick,
onShowClick = onShowContentClick,
modifier = modifier,
)
is TimelineItemVideoContent -> TimelineItemVideoView(
content = content,
hideMediaContent = hideMediaContent,
onShowClick = onShowClick,
onContentClick = onContentClick,
onShowContentClick = onShowContentClick,
onLinkClick = onLinkClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)

View file

@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -50,7 +51,6 @@ import io.element.android.features.messages.impl.timeline.protection.ProtectedVi
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
@ -59,7 +59,9 @@ import io.element.android.wysiwyg.compose.EditorStyledText
fun TimelineItemImageView(
content: TimelineItemImageContent,
hideMediaContent: Boolean,
onShowClick: () -> Unit,
onContentClick: () -> Unit,
onLinkClick: (String) -> Unit,
onShowContentClick: () -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
@ -78,13 +80,14 @@ fun TimelineItemImageView(
) {
ProtectedView(
hideContent = hideMediaContent,
onShowClick = onShowClick,
onShowClick = onShowContentClick,
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.clickable(onClick = onContentClick),
model = content.thumbnailMediaRequestData,
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
@ -99,9 +102,7 @@ fun TimelineItemImageView(
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formattedCaption?.body
?.takeIf { content.formattedCaption.format == MessageFormat.HTML }
?: SpannedString(content.caption)
content.formattedCaption ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
@ -114,6 +115,7 @@ fun TimelineItemImageView(
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
@ -128,7 +130,9 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
TimelineItemImageView(
content = content,
hideMediaContent = false,
onShowClick = {},
onShowContentClick = {},
onContentClick = {},
onLinkClick = {},
onContentLayoutChange = {},
)
}
@ -139,7 +143,9 @@ internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
TimelineItemImageView(
content = aTimelineItemImageContent(),
hideMediaContent = true,
onShowClick = {},
onShowContentClick = {},
onContentClick = {},
onLinkClick = {},
onContentLayoutChange = {},
)
}

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
@ -25,9 +26,10 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemLocationView(
content: TimelineItemLocationContent,
onContentClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
Column(modifier = modifier.clickable(onClick = onContentClick).fillMaxWidth()) {
content.description?.let {
Text(
text = it,
@ -51,5 +53,8 @@ fun TimelineItemLocationView(
@Composable
internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
ElementPreview {
TimelineItemLocationView(content)
TimelineItemLocationView(
content = content,
onContentClick = {},
)
}

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@ -40,6 +41,7 @@ private const val STICKER_SIZE_IN_DP = 128
fun TimelineItemStickerView(
content: TimelineItemStickerContent,
hideMediaContent: Boolean,
onContentClick: () -> Unit,
onShowClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -61,7 +63,8 @@ fun TimelineItemStickerView(
AsyncImage(
modifier = Modifier
.fillMaxSize()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.clickable(onClick = onContentClick),
model = MediaRequestData(
source = content.preferredMediaSource,
kind = MediaRequestData.Kind.File(
@ -85,6 +88,7 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke
TimelineItemStickerView(
content = content,
hideMediaContent = false,
onContentClick = {},
onShowClick = {},
)
}

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@ -56,7 +57,6 @@ import io.element.android.libraries.designsystem.components.blurhash.blurHashBac
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData
@ -68,7 +68,9 @@ import io.element.android.wysiwyg.compose.EditorStyledText
fun TimelineItemVideoView(
content: TimelineItemVideoContent,
hideMediaContent: Boolean,
onShowClick: () -> Unit,
onContentClick: () -> Unit,
onShowContentClick: () -> Unit,
onLinkClick: (String) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
@ -90,13 +92,14 @@ fun TimelineItemVideoView(
) {
ProtectedView(
hideContent = hideMediaContent,
onShowClick = onShowClick,
onShowClick = onShowContentClick,
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.clickable(onClick = onContentClick),
model = MediaRequestData(
source = content.thumbnailSource,
kind = MediaRequestData.Kind.Thumbnail(
@ -128,9 +131,7 @@ fun TimelineItemVideoView(
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formattedCaption?.body
?.takeIf { content.formattedCaption.format == MessageFormat.HTML }
?: SpannedString(content.caption)
content.formattedCaption ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
@ -142,6 +143,7 @@ fun TimelineItemVideoView(
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption,
onLinkClickedListener = onLinkClick,
style = ElementRichTextEditorStyle.textStyle(),
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
@ -157,7 +159,9 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
TimelineItemVideoView(
content = content,
hideMediaContent = false,
onShowClick = {},
onShowContentClick = {},
onContentClick = {},
onLinkClick = {},
onContentLayoutChange = {},
)
}
@ -168,7 +172,9 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
TimelineItemVideoView(
content = aTimelineItemVideoContent(),
hideMediaContent = true,
onShowClick = {},
onShowContentClick = {},
onContentClick = {},
onLinkClick = {},
onContentLayoutChange = {},
)
}

View file

@ -86,7 +86,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemImageContent(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -105,7 +105,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemStickerContent(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -142,7 +142,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemVideoContent(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
thumbnailSource = messageType.info?.thumbnailSource,
videoSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -161,7 +161,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemAudioContent(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -176,7 +176,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
eventId = eventId,
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -187,7 +187,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemAudioContent(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -202,7 +202,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemFileContent(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = messageType.formattedCaption,
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
thumbnailSource = messageType.info?.thumbnailSource,
fileSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension),

View file

@ -9,8 +9,10 @@ package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId
@ -93,6 +95,17 @@ sealed interface TimelineItem {
val isRemote = eventId != null
/** Whether a click on any part of the event bubble should trigger the 'onContentClick' callback.
*
* This is `true` for all events except for visual media events with a caption or formatted caption.
*/
val isWholeContentClickable = when (content) {
is TimelineItemStickerContent -> content.formattedCaption == null && content.caption == null
is TimelineItemImageContent -> content.formattedCaption == null && content.caption == null
is TimelineItemVideoContent -> content.formattedCaption == null && content.caption == null
else -> true
}
val eventOrTransactionId: EventOrTransactionId
get() = EventOrTransactionId.from(eventId = eventId, transactionId = transactionId)

View file

@ -8,14 +8,13 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
import kotlin.time.Duration
data class TimelineItemAudioContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
override val formattedCaption: CharSequence?,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,

View file

@ -8,7 +8,6 @@
package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
@Immutable
sealed interface TimelineItemEventContent {
@ -19,7 +18,7 @@ sealed interface TimelineItemEventContent {
sealed interface TimelineItemEventContentWithAttachment : TimelineItemEventContent {
val filename: String
val caption: String?
val formattedCaption: FormattedBody?
val formattedCaption: CharSequence?
val bestDescription: String
get() = caption ?: filename

View file

@ -8,13 +8,12 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
data class TimelineItemFileContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
override val formattedCaption: CharSequence?,
val fileSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,

View file

@ -9,7 +9,6 @@ package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData
@ -17,7 +16,7 @@ import io.element.android.libraries.matrix.ui.media.MediaRequestData
data class TimelineItemImageContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
override val formattedCaption: CharSequence?,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,

View file

@ -8,12 +8,11 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
data class TimelineItemStickerContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
override val formattedCaption: CharSequence?,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,

View file

@ -8,13 +8,12 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import kotlin.time.Duration
data class TimelineItemVideoContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
override val formattedCaption: CharSequence?,
val duration: Duration,
val videoSource: MediaSource,
val thumbnailSource: MediaSource?,

View file

@ -9,7 +9,6 @@ package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
@ -17,7 +16,7 @@ data class TimelineItemVoiceContent(
val eventId: EventId?,
override val filename: String,
override val caption: String?,
override val formattedCaption: FormattedBody?,
override val formattedCaption: CharSequence?,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,

View file

@ -529,7 +529,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
state = state,
onBackClick = onBackClick,
onRoomDetailsClick = onRoomDetailsClick,
onEventClick = onEventClick,
onEventContentClick = onEventClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onPreviewAttachments = onPreviewAttachments,

View file

@ -158,7 +158,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
timelineProtectionState = timelineProtectionState,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onMessageClick = onMessageClick,
onContentClick = onMessageClick,
onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick,

View file

@ -286,7 +286,7 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemVideoContent(
filename = "body.mp4",
caption = "body.mp4 caption",
formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"),
formattedCaption = SpannedString("formatted"),
duration = 1.minutes,
videoSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
@ -527,7 +527,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemImageContent(
filename = "body.jpg",
formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"),
formattedCaption = SpannedString("formatted"),
caption = "body.jpg caption",
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),

View file

@ -26,7 +26,7 @@ import org.junit.Rule
import org.junit.Test
import java.util.Optional
class RoomAliasResolverPresenterTest {
class RoomAliasHelperPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()

View file

@ -27,7 +27,7 @@ import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomAliasResolverViewTest {
class RoomAliasHelperViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test

View file

@ -145,7 +145,7 @@ private fun RoomDirectoryRoomList(
Text(
text = stringResource(id = CommonStrings.common_no_results),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPlaceholder,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(16.dp)
)
}
@ -185,8 +185,8 @@ private fun SearchTextField(
colors: TextFieldColors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
unfocusedPlaceholderColor = ElementTheme.colors.textPlaceholder,
focusedPlaceholderColor = ElementTheme.colors.textPlaceholder,
unfocusedPlaceholderColor = ElementTheme.colors.textSecondary,
focusedPlaceholderColor = ElementTheme.colors.textSecondary,
focusedTextColor = ElementTheme.colors.textPrimary,
unfocusedTextColor = ElementTheme.colors.textPrimary,
focusedIndicatorColor = ElementTheme.colors.borderInteractiveSecondary,

View file

@ -41,7 +41,7 @@ fun NumberedListMolecule(
private fun ItemNumber(
index: Int,
) {
val color = ElementTheme.colors.textPlaceholder
val color = ElementTheme.colors.textSecondary
Box(
modifier = Modifier
.border(1.dp, color, CircleShape)

View file

@ -200,7 +200,7 @@ private fun DecorationBox(
if (placeholder != null && isTextEmpty) {
Text(
text = placeholder,
color = ElementTheme.colors.textPlaceholder,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyLgRegular,
)
}

View file

@ -168,3 +168,13 @@ fun MatrixClient.getRoomInfoFlow(roomIdOrAlias: RoomIdOrAlias): Flow<Optional<Ma
.map { roomSummary -> roomSummary.map { it.info } }
.distinctUntilChanged()
}
/**
* Returns a room alias from a room alias name.
* @param name the room alias name ie. the local part of the room alias.
*/
fun MatrixClient.roomAliasFromName(name: String): Result<RoomAlias> {
return runCatching {
RoomAlias("#$name:${userIdServerName()}")
}
}

View file

@ -20,5 +20,5 @@ data class CreateRoomParameters(
val invite: List<UserId>? = null,
val avatar: String? = null,
val joinRuleOverride: JoinRuleOverride = JoinRuleOverride.None,
val canonicalAlias: Optional<String> = Optional.empty(),
val roomAliasName: Optional<String> = Optional.empty(),
)

View file

@ -0,0 +1,15 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.room.alias
import io.element.android.libraries.matrix.api.core.RoomAlias
interface RoomAliasHelper {
fun roomAliasNameFromRoomDisplayName(name: String): String
fun isRoomAliasValid(roomAlias: RoomAlias): Boolean
}

View file

@ -334,7 +334,7 @@ class RustMatrixClient(
JoinRuleOverride.Knock -> RustJoinRule.Knock
JoinRuleOverride.None -> null
},
canonicalAlias = createRoomParams.canonicalAlias.getOrNull(),
canonicalAlias = createRoomParams.roomAliasName.getOrNull(),
)
val roomId = RoomId(client.createRoom(rustParams))
// Wait to receive the room back from the sync but do not returns failure if it fails.

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.room.alias
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultRoomAliasHelper @Inject constructor() : RoomAliasHelper {
override fun roomAliasNameFromRoomDisplayName(name: String): String {
return org.matrix.rustcomponents.sdk.roomAliasNameFromRoomDisplayName(name)
}
override fun isRoomAliasValid(roomAlias: RoomAlias): Boolean {
return org.matrix.rustcomponents.sdk.isRoomAliasFormatValid(roomAlias.value)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.test.room.alias
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
class FakeRoomAliasHelper(
private val roomAliasNameFromRoomDisplayNameLambda: (String) -> String = { name ->
name.trimStart().trimEnd().replace(" ", "_")
},
private val isRoomAliasValidLambda: (RoomAlias) -> Boolean = { true }
) : RoomAliasHelper {
override fun roomAliasNameFromRoomDisplayName(name: String): String {
return roomAliasNameFromRoomDisplayNameLambda(name)
}
override fun isRoomAliasValid(roomAlias: RoomAlias): Boolean {
return isRoomAliasValidLambda(roomAlias)
}
}

View file

@ -32,10 +32,8 @@ val localAarProjects = listOf(
val excludedKoverSubProjects = listOf(
":app",
":samples",
":anvilannotations",
":anvilcodegen",
":samples:minimal",
":tests:testutils",
// Exclude `:libraries:matrix:impl` module, it contains only wrappers to access the Rust Matrix
// SDK api, so it is not really relevant to unit test it: there is no logic to test.

View file

@ -6,7 +6,7 @@
*/
/**
* This will generate the plugin "io.element.android-compose-application" to use by app and samples modules
* This will generate the plugin "io.element.android-compose-application" to use by app
*/
import extension.androidConfig
import extension.commonDependencies

View file

@ -1 +0,0 @@
/build

View file

@ -1,67 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
plugins {
id("io.element.android-compose-application")
alias(libs.plugins.kotlin.android)
}
android {
namespace = "io.element.android.samples.minimal"
defaultConfig {
applicationId = "io.element.android.samples.minimal"
targetSdk = Versions.TARGET_SDK
versionCode = Versions.VERSION_CODE
versionName = Versions.VERSION_NAME
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
buildConfig = true
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.preference)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.permissions.noop)
implementation(projects.libraries.sessionStorage.implMemory)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.network)
implementation(projects.libraries.dateformatter.impl)
implementation(projects.libraries.eventformatter.impl)
implementation(projects.libraries.fullscreenintent.impl)
implementation(projects.libraries.preferences.impl)
implementation(projects.libraries.preferences.test)
implementation(projects.libraries.indicator.impl)
implementation(projects.features.invite.impl)
implementation(projects.features.roomlist.impl)
implementation(projects.features.leaveroom.impl)
implementation(projects.features.login.impl)
implementation(projects.features.logout.impl)
implementation(projects.features.networkmonitor.impl)
implementation(projects.services.toolbox.impl)
implementation(projects.libraries.featureflag.impl)
implementation(projects.services.analytics.noop)
implementation(libs.coroutines.core)
implementation(projects.libraries.push.test)
}

View file

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2023 New Vector Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only
~ Please see LICENSE in the repository root for full details.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ElementX">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.ElementX">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -1,23 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.samples.minimal
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlagService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class AlwaysEnabledFeatureFlagService : FeatureFlagService {
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
return flowOf(true)
}
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean {
return true
}
}

View file

@ -1,43 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.samples.minimal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordPresenter
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordView
import io.element.android.features.login.impl.util.defaultAccountProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
class LoginScreen(private val authenticationService: MatrixAuthenticationService) {
@Composable
fun Content(modifier: Modifier = Modifier) {
val presenter = remember {
LoginPasswordPresenter(
authenticationService = authenticationService,
AccountProviderDataSource(),
DefaultLoginUserStory(),
)
}
LaunchedEffect(Unit) {
authenticationService.setHomeserver(defaultAccountProvider.url)
}
val state = presenter.present()
LoginPasswordView(
state = state,
modifier = modifier,
onBackClick = {},
)
}
}

View file

@ -1,95 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.samples.minimal
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.view.WindowCompat
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.impl.RustClientBuilderProvider
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
import io.element.android.libraries.matrix.impl.auth.OidcConfigurationProvider
import io.element.android.libraries.matrix.impl.auth.RustMatrixAuthenticationService
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.matrix.impl.room.RustTimelineEventTypeFilterFactory
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore
import io.element.android.services.analytics.noop.NoopAnalyticsService
import io.element.android.services.toolbox.impl.systemclock.DefaultSystemClock
import kotlinx.coroutines.runBlocking
import java.io.File
class MainActivity : ComponentActivity() {
private val matrixAuthenticationService: MatrixAuthenticationService by lazy {
val baseDirectory = File(applicationContext.filesDir, "sessions")
val userAgentProvider = SimpleUserAgentProvider("MinimalSample")
val sessionStore = InMemorySessionStore()
val userCertificatesProvider = NoOpUserCertificatesProvider()
val proxyProvider = NoOpProxyProvider()
RustMatrixAuthenticationService(
sessionPathsFactory = SessionPathsFactory(baseDirectory, applicationContext.cacheDir),
coroutineDispatchers = Singleton.coroutineDispatchers,
sessionStore = sessionStore,
rustMatrixClientFactory = RustMatrixClientFactory(
baseDirectory = baseDirectory,
cacheDirectory = applicationContext.cacheDir,
appCoroutineScope = Singleton.appScope,
coroutineDispatchers = Singleton.coroutineDispatchers,
sessionStore = sessionStore,
userAgentProvider = userAgentProvider,
userCertificatesProvider = userCertificatesProvider,
proxyProvider = proxyProvider,
clock = DefaultSystemClock(),
analyticsService = NoopAnalyticsService(),
featureFlagService = AlwaysEnabledFeatureFlagService(),
timelineEventTypeFilterFactory = RustTimelineEventTypeFilterFactory(),
clientBuilderProvider = RustClientBuilderProvider(),
),
passphraseGenerator = NullPassphraseGenerator(),
oidcConfigurationProvider = OidcConfigurationProvider(baseDirectory),
appPreferencesStore = InMemoryAppPreferencesStore(),
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
ElementTheme {
val loggedInState by matrixAuthenticationService.loggedInStateFlow().collectAsState(initial = LoggedInState.NotLoggedIn)
Content(isLoggedIn = loggedInState is LoggedInState.LoggedIn, modifier = Modifier.fillMaxSize())
}
}
}
@Composable
fun Content(
isLoggedIn: Boolean,
modifier: Modifier = Modifier
) {
if (!isLoggedIn) {
LoginScreen(authenticationService = matrixAuthenticationService).Content(modifier)
} else {
val matrixClient = runBlocking {
val sessionId = matrixAuthenticationService.getLatestSessionId()!!
matrixAuthenticationService.restoreSession(sessionId).getOrNull()
}
RoomListScreen(LocalContext.current, matrixClient!!).Content(modifier)
}
}
}

View file

@ -1,14 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.samples.minimal
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
class NoOpProxyProvider : ProxyProvider {
override fun provides(): String? = null
}

View file

@ -1,14 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.samples.minimal
import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider
class NoOpUserCertificatesProvider : UserCertificatesProvider {
override fun provides(): List<ByteArray> = emptyList()
}

View file

@ -1,14 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.samples.minimal
import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator
class NullPassphraseGenerator : PassphraseGenerator {
override fun generatePassphrase(): String? = null
}

View file

@ -1,18 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.samples.minimal
import android.net.Uri
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
class OnlyFallbackPermalinkParser : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return PermalinkData.FallbackLink(Uri.parse(uriString))
}
}

View file

@ -1,174 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.samples.minimal
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import io.element.android.features.invite.impl.response.AcceptDeclineInvitePresenter
import io.element.android.features.invite.impl.response.AcceptDeclineInviteView
import io.element.android.features.leaveroom.impl.LeaveRoomPresenter
import io.element.android.features.logout.impl.direct.DirectLogoutPresenter
import io.element.android.features.networkmonitor.impl.DefaultNetworkMonitor
import io.element.android.features.roomlist.impl.RoomListPresenter
import io.element.android.features.roomlist.impl.RoomListView
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter
import io.element.android.features.roomlist.impl.filters.selection.DefaultFilterSelectionStrategy
import io.element.android.features.roomlist.impl.search.RoomListSearchDataSource
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
import io.element.android.libraries.androidutils.system.DefaultDateTimeObserver
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.impl.DateFormatters
import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.eventformatter.impl.DefaultRoomLastMessageFormatter
import io.element.android.libraries.eventformatter.impl.ProfileChangeContentFormatter
import io.element.android.libraries.eventformatter.impl.RoomMembershipContentFormatter
import io.element.android.libraries.eventformatter.impl.StateContentFormatter
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.indicator.impl.DefaultIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.impl.room.join.DefaultJoinRoom
import io.element.android.libraries.preferences.impl.store.DefaultSessionPreferencesStore
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.services.analytics.noop.NoopAnalyticsService
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import timber.log.Timber
import java.util.Locale
class RoomListScreen(
context: Context,
private val matrixClient: MatrixClient,
private val coroutineDispatchers: CoroutineDispatchers = Singleton.coroutineDispatchers,
) {
private val clock = Clock.System
private val locale = Locale.getDefault()
private val dateTimeProvider = LocalDateTimeProvider(clock) { TimeZone.currentSystemDefault() }
private val dateFormatters = DateFormatters(locale, clock) { TimeZone.currentSystemDefault() }
private val sessionVerificationService = matrixClient.sessionVerificationService()
private val encryptionService = matrixClient.encryptionService()
private val stringProvider = AndroidStringProvider(context.resources)
private val featureFlagService = AlwaysEnabledFeatureFlagService()
private val roomListRoomSummaryFactory = RoomListRoomSummaryFactory(
lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter(
localDateTimeProvider = dateTimeProvider,
dateFormatters = dateFormatters
),
roomLastMessageFormatter = DefaultRoomLastMessageFormatter(
sp = stringProvider,
roomMembershipContentFormatter = RoomMembershipContentFormatter(
matrixClient = matrixClient,
sp = stringProvider
),
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
stateContentFormatter = StateContentFormatter(stringProvider),
permalinkParser = OnlyFallbackPermalinkParser(),
),
)
private val presenter = RoomListPresenter(
client = matrixClient,
networkMonitor = DefaultNetworkMonitor(context, Singleton.appScope),
snackbarDispatcher = SnackbarDispatcher(),
leaveRoomPresenter = LeaveRoomPresenter(matrixClient, RoomMembershipObserver(), coroutineDispatchers),
roomListDataSource = RoomListDataSource(
roomListService = matrixClient.roomListService,
roomListRoomSummaryFactory = roomListRoomSummaryFactory,
coroutineDispatchers = coroutineDispatchers,
notificationSettingsService = matrixClient.notificationSettingsService(),
appScope = Singleton.appScope,
dateTimeObserver = DefaultDateTimeObserver(context),
),
indicatorService = DefaultIndicatorService(
sessionVerificationService = sessionVerificationService,
encryptionService = encryptionService,
),
featureFlagService = featureFlagService,
searchPresenter = RoomListSearchPresenter(
RoomListSearchDataSource(
roomListService = matrixClient.roomListService,
roomSummaryFactory = roomListRoomSummaryFactory,
coroutineDispatchers = coroutineDispatchers,
),
featureFlagService = featureFlagService,
),
sessionPreferencesStore = DefaultSessionPreferencesStore(
context = context,
sessionId = matrixClient.sessionId,
sessionCoroutineScope = Singleton.appScope
),
filtersPresenter = RoomListFiltersPresenter(
roomListService = matrixClient.roomListService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
),
acceptDeclineInvitePresenter = AcceptDeclineInvitePresenter(
client = matrixClient,
joinRoom = DefaultJoinRoom(matrixClient, NoopAnalyticsService()),
notificationCleaner = FakeNotificationCleaner(),
),
analyticsService = NoopAnalyticsService(),
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
notificationCleaner = FakeNotificationCleaner(),
logoutPresenter = DirectLogoutPresenter(matrixClient, encryptionService),
)
@Composable
fun Content(modifier: Modifier = Modifier) {
fun onRoomClick(roomId: RoomId) {
Singleton.appScope.launch {
withContext(coroutineDispatchers.io) {
matrixClient.getRoom(roomId)!!.use { room ->
room.liveTimeline.paginate(Timeline.PaginationDirection.BACKWARDS)
}
}
}
}
val state = presenter.present()
RoomListView(
state = state,
onRoomClick = ::onRoomClick,
onSettingsClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onCreateRoomClick = {},
onRoomSettingsClick = {},
onMenuActionClick = {},
onRoomDirectorySearchClick = {},
modifier = modifier,
acceptDeclineInviteView = {
AcceptDeclineInviteView(state = state.acceptDeclineInviteState, onAcceptInvite = {}, onDeclineInvite = {})
},
onMigrateToNativeSlidingSyncClick = {},
)
DisposableEffect(Unit) {
Timber.w("Start sync!")
runBlocking {
matrixClient.syncService().startSync()
}
onDispose {
Timber.w("Stop sync!")
runBlocking {
matrixClient.syncService().stopSync()
}
}
}
}
}

View file

@ -1,55 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.samples.minimal
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
import io.element.android.libraries.matrix.api.tracing.TracingFilterConfigurations
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
import io.element.android.libraries.matrix.impl.tracing.RustTracingService
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.plus
object Singleton {
val buildMeta = BuildMeta(
isDebuggable = true,
buildType = BuildType.DEBUG,
applicationName = "EAX-Minimal",
productionApplicationName = "EAX-Minimal",
desktopApplicationName = "EAX-Minimal-Desktop",
applicationId = "io.element.android.samples.minimal",
isEnterpriseBuild = false,
lowPrivacyLoggingEnabled = false,
versionName = "0.1.0",
versionCode = 1,
gitRevision = "",
gitBranchName = "",
flavorDescription = "NA",
flavorShortDescription = "NA",
)
init {
val tracingConfiguration = TracingConfiguration(
filterConfiguration = TracingFilterConfigurations.debug,
writesToLogcat = true,
writesToFilesConfiguration = WriteToFilesConfiguration.Disabled
)
RustTracingService(buildMeta).setupTracing(tracingConfiguration)
}
val appScope = MainScope() + CoroutineName("Minimal Scope")
val coroutineDispatchers = CoroutineDispatchers(
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2023 New Vector Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only
~ Please see LICENSE in the repository root for full details.
-->
<resources>
<style name="Theme.ElementX" parent="android:Theme.Material.NoActionBar" />
</resources>

View file

@ -1,9 +0,0 @@
<!--
~ Copyright 2023 New Vector Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only
~ Please see LICENSE in the repository root for full details.
-->
<resources>
<string name="app_name">EAX-Sample</string>
</resources>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2023 New Vector Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only
~ Please see LICENSE in the repository root for full details.
-->
<resources>
<style name="Theme.ElementX" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View file

@ -70,8 +70,6 @@ include(":tests:testutils")
include(":anvilannotations")
include(":anvilcodegen")
include(":samples:minimal")
fun includeProjects(directory: File, path: String, maxDepth: Int = 1) {
directory.listFiles().orEmpty().also { it.sort() }.forEach { file ->
if (file.isDirectory) {

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c94e5e7c3b044017e6cb98985671875c7ab6103533e2a5e15aed17a01a52576b
size 30470
oid sha256:3b380df8a4e69e9e11337db374b682e01f96738948e69eb33886817894a049b0
size 30716

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e6270f3b7935c47da76ce4e81aa7e8eac13a9340e7ccb198f8cd6956d746dfa8
size 59452
oid sha256:ffbdcd28c32808fba8ffb7071b6440578716d3ae89149eb319c22d3f3b78b1ff
size 59499

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6a98ba9f5edc21b8781e84ccaf6f93c3608a4aa64464494304962d8cb62a102
size 31477
oid sha256:9dc23b7e9533aa94245efa2089956259b00882809157cf32decaaa22827aeb59
size 31678

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c3b02b0bc0d557bac4d5aa945daf04d5d58dab44d9e4284d030804e6b5ed6a49
size 60703
oid sha256:9dc54d0a6c3cb3069482b1c27ed6d80fd63b0bcad1a550bf5c4edb09f69db3a7
size 60749

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:91cdcd831bc82480a130093a2f561bde8f0c3f0f2b9e7251628763202fc7bc39
size 35249
oid sha256:ad1a15591ad4c27119d50e14869314371a87e3afe7bd6757bf245f27c99ef00c
size 35246

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:971e260530cfd5b25cce035272820cb067c7e4a4a34fa6992526e5176913ea2d
size 36617
oid sha256:4a63c918393297e53f1a54b8de150ae807662ac905d5112d43812665f5a8f0ef
size 36647

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:56fde0cdb5a5196c277c43a1b9909401f40a4e7ef8bfe2e4d80592f0768fe5d5
size 27306
oid sha256:59bf5b8ed10344e299e5a8b977f3bc21915c500c7b79cfd44bdcc2a8a134fbdc
size 27291

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2db3dad746069968a83693752e985c25b72ca31be11b5c65d7da234f96d377d3
size 34055
oid sha256:0a3339897e11dbdb7d1fa99cf208d467dc19db03599cde5e6ba21d7b19c522de
size 34193

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:61739bd834ee18df778b4737f38f656d4342397135d518791ff4518d8fdea96d
size 35173
oid sha256:116c5f660aca1866667814903df752ae522c4dcdee2478c7d6815bc5f427684e
size 35317

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae8a729e91657959dbac49ef1c0f41fe37c296923eef6c22bdc9692d389ca346
size 25207
oid sha256:30ac5683fe4cc16bd6e31745b9f479a1b1d5e2a1dcf3f18ec7bd5f7793b8fda9
size 25273

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c66e45979657915aa7010b4796bf80865e8cb96118888c78d86e9edf0fa9bb88
size 66346
oid sha256:b738607cad294fea5617f81e4d410896704c395ffcf4118c25228e1d4ab693ba
size 66436

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:341782379e27258de391ba5d9713172eda666a593ca3040c9cfff96aaff72825
size 64908
oid sha256:d35646ae638353674a985215cc521db18e8ae6df7de3d241767ad5c994985814
size 65036

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:788722dec315d910767e187eb6c868fb85918d0220c7b923fed82fae736c7c38
size 49947
oid sha256:b1b50c558703a1fb83dfd8b4dcc8484fc3aaf019e4ab466f0e515db401f0797d
size 49999

Some files were not shown because too many files have changed in this diff Show more