Merge branch 'develop' into feature/fga/advanced_settings_moderation_and_safety
This commit is contained in:
commit
497022291b
278 changed files with 2632 additions and 1649 deletions
|
|
@ -27,8 +27,7 @@ class AnalyticsPreferencesPresenter @Inject constructor(
|
|||
@Composable
|
||||
override fun present(): AnalyticsPreferencesState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val isEnabled = analyticsService.getUserConsent()
|
||||
.collectAsState(initial = false)
|
||||
val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false)
|
||||
|
||||
fun handleEvents(event: AnalyticsOptInEvents) {
|
||||
when (event) {
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ class AnalyticsOptInPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(analyticsService.didAskUserConsent().first()).isFalse()
|
||||
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
|
||||
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
|
||||
assertThat(analyticsService.didAskUserConsent().first()).isTrue()
|
||||
assertThat(analyticsService.getUserConsent().first()).isTrue()
|
||||
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
|
||||
assertThat(analyticsService.userConsentFlow.first()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,10 +53,10 @@ class AnalyticsOptInPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(analyticsService.didAskUserConsent().first()).isFalse()
|
||||
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
|
||||
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
|
||||
assertThat(analyticsService.didAskUserConsent().first()).isTrue()
|
||||
assertThat(analyticsService.getUserConsent().first()).isFalse()
|
||||
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
|
||||
assertThat(analyticsService.userConsentFlow.first()).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class CreateRoomDataStore @Inject constructor(
|
|||
}
|
||||
|
||||
val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
|
||||
selectedUserListDataStore.selectedUsers(),
|
||||
selectedUserListDataStore.selectedUsers,
|
||||
createRoomConfigFlow,
|
||||
) { selectedUsers, config ->
|
||||
config.copy(invites = selectedUsers.toImmutableList())
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ class ConfigureRoomPresenter @Inject constructor(
|
|||
val cameraPermissionState = cameraPermissionPresenter.present()
|
||||
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
|
||||
val homeserverName = remember { matrixClient.userIdServerName() }
|
||||
val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false)
|
||||
val isKnockFeatureEnabled by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
|
||||
}.collectAsState(initial = false)
|
||||
val roomAddressValidity = remember {
|
||||
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,9 @@ class CreateRoomRootPresenter @Inject constructor(
|
|||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
|
||||
val isRoomDirectorySearchEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch).collectAsState(initial = false)
|
||||
val isRoomDirectorySearchEnabled by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch)
|
||||
}.collectAsState(initial = false)
|
||||
|
||||
fun handleEvents(event: CreateRoomRootEvents) {
|
||||
when (event) {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
|||
recentDirectRooms = matrixClient.getRecentDirectRooms()
|
||||
}
|
||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
|
||||
val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
|
||||
mutableStateOf(SearchBarResultState.Initial())
|
||||
|
|
|
|||
|
|
@ -8,22 +8,22 @@
|
|||
package io.element.android.features.createroom.impl.userlist
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
class UserListDataStore @Inject constructor() {
|
||||
private val selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
|
||||
private val _selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
|
||||
|
||||
fun selectUser(user: MatrixUser) {
|
||||
if (!selectedUsers.value.contains(user)) {
|
||||
selectedUsers.tryEmit(selectedUsers.value.plus(user))
|
||||
if (!_selectedUsers.value.contains(user)) {
|
||||
_selectedUsers.tryEmit(_selectedUsers.value.plus(user))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeUserFromSelection(user: MatrixUser) {
|
||||
selectedUsers.tryEmit(selectedUsers.value.minus(user))
|
||||
_selectedUsers.tryEmit(_selectedUsers.value.minus(user))
|
||||
}
|
||||
|
||||
fun selectedUsers(): Flow<List<MatrixUser>> = selectedUsers
|
||||
val selectedUsers = _selectedUsers.asStateFlow()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
moveToNextStepIfNeeded()
|
||||
})
|
||||
|
||||
analyticsService.didAskUserConsent()
|
||||
analyticsService.didAskUserConsentFlow
|
||||
.distinctUntilChanged()
|
||||
.onEach { moveToNextStepIfNeeded() }
|
||||
.launchIn(lifecycleScope)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class DefaultFtueService @Inject constructor(
|
|||
.onEach { updateState() }
|
||||
.launchIn(sessionCoroutineScope)
|
||||
|
||||
analyticsService.didAskUserConsent()
|
||||
analyticsService.didAskUserConsentFlow
|
||||
.distinctUntilChanged()
|
||||
.onEach { updateState() }
|
||||
.launchIn(sessionCoroutineScope)
|
||||
|
|
@ -118,7 +118,7 @@ class DefaultFtueService @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun needsAnalyticsOptIn(): Boolean {
|
||||
return analyticsService.didAskUserConsent().first().not()
|
||||
return analyticsService.didAskUserConsentFlow.first().not()
|
||||
}
|
||||
|
||||
private suspend fun shouldAskNotificationPermissions(): Boolean {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface SeenInvitesStore {
|
||||
/**
|
||||
* Returns a flow of seen room IDs of invitation.
|
||||
*/
|
||||
fun seenRoomIds(): Flow<Set<RoomId>>
|
||||
|
||||
/**
|
||||
* Mark the invitation as seen.
|
||||
* Call this when the invitation details are shown to the user.
|
||||
* @param roomId the room ID of the invitation to mark as seen.
|
||||
*/
|
||||
suspend fun markAsSeen(roomId: RoomId)
|
||||
|
||||
/**
|
||||
* Mark the invitation as unseen.
|
||||
* Call this when the invitation has been accepted or declined.
|
||||
* @param roomId the room ID of the invitation to mark as unseen.
|
||||
*/
|
||||
suspend fun markAsUnSeen(roomId: RoomId)
|
||||
|
||||
/**
|
||||
* Delete the store.
|
||||
*/
|
||||
suspend fun clear()
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ setupAnvil()
|
|||
dependencies {
|
||||
api(projects.features.invite.api)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
|
|
@ -35,6 +36,7 @@ dependencies {
|
|||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.libraries.androidutils.file.safeDelete
|
||||
import io.element.android.libraries.androidutils.hash.hash
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
|
||||
|
||||
@SingleIn(SessionScope::class)
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultSeenInvitesStore @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
currentSessionIdHolder: CurrentSessionIdHolder,
|
||||
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
|
||||
sessionObserver: SessionObserver,
|
||||
) : SeenInvitesStore {
|
||||
private val sessionId: SessionId = currentSessionIdHolder.current
|
||||
|
||||
init {
|
||||
sessionObserver.addListener(object : SessionListener {
|
||||
override suspend fun onSessionCreated(userId: String) = Unit
|
||||
override suspend fun onSessionDeleted(userId: String) {
|
||||
if (sessionId.value == userId) {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private val dataStoreFile = sessionId.value.hash().take(16).let { hashedUserId ->
|
||||
context.preferencesDataStoreFile("session_${hashedUserId}_seen-invites")
|
||||
}
|
||||
|
||||
private val store = PreferenceDataStoreFactory.create(
|
||||
scope = sessionCoroutineScope,
|
||||
migrations = emptyList(),
|
||||
) {
|
||||
dataStoreFile
|
||||
}
|
||||
|
||||
override fun seenRoomIds(): Flow<Set<RoomId>> =
|
||||
store.data.map { prefs ->
|
||||
prefs[seenInvitesKey]
|
||||
.orEmpty()
|
||||
.map { RoomId(it) }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override suspend fun markAsSeen(roomId: RoomId) {
|
||||
store.edit { prefs ->
|
||||
prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() + roomId.value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markAsUnSeen(roomId: RoomId) {
|
||||
store.edit { prefs ->
|
||||
prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() - roomId.value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
dataStoreFile.safeDelete()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
||||
|
|
@ -34,6 +35,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
|||
private val client: MatrixClient,
|
||||
private val joinRoom: JoinRoom,
|
||||
private val notificationCleaner: NotificationCleaner,
|
||||
private val seenInvitesStore: SeenInvitesStore,
|
||||
) : Presenter<AcceptDeclineInviteState> {
|
||||
@Composable
|
||||
override fun present(): AcceptDeclineInviteState {
|
||||
|
|
@ -107,6 +109,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
|||
)
|
||||
.onSuccess {
|
||||
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
|
||||
seenInvitesStore.markAsUnSeen(roomId)
|
||||
}
|
||||
.map { roomId }
|
||||
}
|
||||
|
|
@ -125,6 +128,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
|||
client.ignoreUser(inviteData.senderId).getOrThrow()
|
||||
}
|
||||
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
|
||||
seenInvitesStore.markAsUnSeen(inviteData.roomId)
|
||||
inviteData.roomId
|
||||
}.runCatchingUpdatingState(declinedAction)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ package io.element.android.features.invite.impl.response
|
|||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
||||
import io.element.android.features.invite.api.response.InviteData
|
||||
import io.element.android.features.invite.test.InMemorySeenInvitesStore
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
|
@ -20,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
|
|
@ -33,6 +37,7 @@ import io.element.android.tests.testutils.lambda.assert
|
|||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
|
@ -54,7 +59,10 @@ class AcceptDeclineInvitePresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - declining invite cancel flow`() = runTest {
|
||||
val presenter = createAcceptDeclineInvitePresenter()
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
awaitItem().also { state ->
|
||||
|
|
@ -72,6 +80,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
}
|
||||
}
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -84,7 +93,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
|
||||
}
|
||||
)
|
||||
val presenter = createAcceptDeclineInvitePresenter(client = client)
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
client = client,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
awaitItem().also { state ->
|
||||
|
|
@ -111,6 +124,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
assert(declineInviteFailure).isCalledOnce()
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -129,9 +143,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
|
||||
}
|
||||
)
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
client = client,
|
||||
notificationCleaner = fakeNotificationCleaner,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
|
|
@ -156,6 +172,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
clearMembershipNotificationForRoomLambda.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -174,9 +191,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
},
|
||||
ignoreUserResult = ignoreUserSuccess
|
||||
)
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
client = client,
|
||||
notificationCleaner = fakeNotificationCleaner,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
|
|
@ -202,6 +221,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
clearMembershipNotificationForRoomLambda.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -214,7 +234,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
|
||||
}
|
||||
)
|
||||
val presenter = createAcceptDeclineInvitePresenter(client = client)
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
client = client,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
awaitItem().also { state ->
|
||||
|
|
@ -230,6 +254,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
}
|
||||
assertThat(awaitItem().declineAction.isLoading()).isTrue()
|
||||
}
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -237,7 +262,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
||||
Result.failure<Unit>(RuntimeException("Failed to join room $roomIdOrAlias"))
|
||||
}
|
||||
val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomFailure)
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
joinRoomLambda = joinRoomFailure,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
awaitItem().also { state ->
|
||||
|
|
@ -266,6 +295,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
value(emptyList<String>()),
|
||||
value(JoinedRoom.Trigger.Invite)
|
||||
)
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -279,9 +309,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
joinRoomLambda = joinRoomSuccess,
|
||||
notificationCleaner = fakeNotificationCleaner,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
|
|
@ -308,6 +340,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
clearMembershipNotificationForRoomLambda.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
private fun anInviteData(
|
||||
|
|
@ -330,11 +363,13 @@ class AcceptDeclineInvitePresenterTest {
|
|||
Result.success(Unit)
|
||||
},
|
||||
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
||||
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
||||
): AcceptDeclineInvitePresenter {
|
||||
return AcceptDeclineInvitePresenter(
|
||||
client = client,
|
||||
joinRoom = FakeJoinRoom(joinRoomLambda),
|
||||
notificationCleaner = notificationCleaner,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
features/invite/test/build.gradle.kts
Normal file
29
features/invite/test/build.gradle.kts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.invite.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
api(projects.features.invite.api)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.test
|
||||
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class InMemorySeenInvitesStore(
|
||||
initialRoomIds: Set<RoomId> = emptySet(),
|
||||
) : SeenInvitesStore {
|
||||
private val roomIds = MutableStateFlow(initialRoomIds)
|
||||
|
||||
override fun seenRoomIds(): Flow<Set<RoomId>> = roomIds
|
||||
|
||||
override suspend fun markAsSeen(roomId: RoomId) {
|
||||
roomIds.value += roomId
|
||||
}
|
||||
|
||||
override suspend fun markAsUnSeen(roomId: RoomId) {
|
||||
roomIds.value -= roomId
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
roomIds.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ dependencies {
|
|||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.joinroom.impl
|
|||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.InviteData
|
||||
|
|
@ -69,6 +71,7 @@ class JoinRoomPresenter @AssistedInject constructor(
|
|||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val seenInvitesStore: SeenInvitesStore,
|
||||
) : Presenter<JoinRoomState> {
|
||||
interface Factory {
|
||||
fun create(
|
||||
|
|
@ -84,7 +87,9 @@ class JoinRoomPresenter @AssistedInject constructor(
|
|||
override fun present(): JoinRoomState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var retryCount by remember { mutableIntStateOf(0) }
|
||||
val roomInfo by matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias()).collectAsState(initial = Optional.empty())
|
||||
val roomInfo by remember {
|
||||
matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias())
|
||||
}.collectAsState(initial = Optional.empty())
|
||||
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
|
|
@ -150,6 +155,10 @@ class JoinRoomPresenter @AssistedInject constructor(
|
|||
}
|
||||
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
|
||||
|
||||
LaunchedEffect(contentState) {
|
||||
contentState.markRoomInviteAsSeen()
|
||||
}
|
||||
|
||||
fun handleEvents(event: JoinRoomEvents) {
|
||||
when (event) {
|
||||
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
|
||||
|
|
@ -238,6 +247,12 @@ class JoinRoomPresenter @AssistedInject constructor(
|
|||
forgetRoom.invoke(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ContentState.markRoomInviteAsSeen() {
|
||||
if ((this as? ContentState.Loaded)?.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited != null) {
|
||||
seenInvitesStore.markAsSeen(roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.squareup.anvil.annotations.ContributesTo
|
|||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.joinroom.impl.JoinRoomPresenter
|
||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
|
|
@ -37,6 +38,7 @@ object JoinRoomModule {
|
|||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
buildMeta: BuildMeta,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
seenInvitesStore: SeenInvitesStore,
|
||||
): JoinRoomPresenter.Factory {
|
||||
return object : JoinRoomPresenter.Factory {
|
||||
override fun create(
|
||||
|
|
@ -60,6 +62,7 @@ object JoinRoomModule {
|
|||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||
buildMeta = buildMeta,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ package io.element.android.features.joinroom.impl
|
|||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||
import io.element.android.features.invite.test.InMemorySeenInvitesStore
|
||||
import io.element.android.features.joinroom.impl.di.CancelKnockRoom
|
||||
import io.element.android.features.joinroom.impl.di.ForgetRoom
|
||||
import io.element.android.features.joinroom.impl.di.KnockRoom
|
||||
|
|
@ -54,6 +56,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
|
|||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
|
|
@ -113,14 +116,19 @@ class JoinRoomPresenterTest {
|
|||
flowOf(Optional.of(roomSummary))
|
||||
}
|
||||
}
|
||||
val seenInvitesStore = InMemorySeenInvitesStore()
|
||||
val presenter = createJoinRoomPresenter(
|
||||
matrixClient = matrixClient
|
||||
matrixClient = matrixClient,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
|
||||
}
|
||||
// Check that the roomId is stored in the seen invites store
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomSummary.roomId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -762,7 +770,8 @@ class JoinRoomPresenterTest {
|
|||
forgetRoom: ForgetRoom = FakeForgetRoom(),
|
||||
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
|
||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore()
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
||||
): JoinRoomPresenter {
|
||||
return JoinRoomPresenter(
|
||||
roomId = roomId,
|
||||
|
|
@ -778,6 +787,7 @@ class JoinRoomPresenterTest {
|
|||
buildMeta = buildMeta,
|
||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.net.toUri
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.libraries.androidutils.system.openAppSettingsPage
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
|
|
@ -25,7 +27,7 @@ class AndroidLocationActions @Inject constructor(
|
|||
) : LocationActions {
|
||||
override fun share(location: Location, label: String?) {
|
||||
runCatching {
|
||||
val uri = Uri.parse(buildUrl(location, label))
|
||||
val uri = buildUrl(location, label).toUri()
|
||||
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
|
||||
val chooserIntent = Intent.createChooser(showMapsIntent, null)
|
||||
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
|
@ -42,17 +44,14 @@ class AndroidLocationActions @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
|
||||
@VisibleForTesting
|
||||
internal fun buildUrl(
|
||||
location: Location,
|
||||
label: String?,
|
||||
urlEncoder: (String) -> String = Uri::encode
|
||||
): String {
|
||||
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
|
||||
val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon)
|
||||
return if (label == null) {
|
||||
base
|
||||
} else {
|
||||
"%s (%s)".format(base, urlEncoder(label))
|
||||
}
|
||||
// This is needed so the coordinates are formatted with a dot as decimal separator
|
||||
val locale = Locale.ENGLISH
|
||||
return "geo:0,0?q=%.6f,%.6f (%s)".format(locale, location.lat, location.lon, urlEncoder(label.orEmpty()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
|
|||
import io.element.android.features.location.api.Location
|
||||
import org.junit.Test
|
||||
import java.net.URLEncoder
|
||||
import java.util.Locale
|
||||
|
||||
internal class AndroidLocationActionsTest {
|
||||
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
|
||||
|
|
@ -25,7 +26,7 @@ internal class AndroidLocationActionsTest {
|
|||
)
|
||||
|
||||
val actual = buildUrl(location, null, ::urlEncoder)
|
||||
val expected = "geo:0,0?q=1.234568,123.456789"
|
||||
val expected = "geo:0,0?q=1.234568,123.456789 ()"
|
||||
|
||||
assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
|
@ -57,4 +58,20 @@ internal class AndroidLocationActionsTest {
|
|||
|
||||
assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildUrl - URL encodes coordinates in locale with comma decimal separator`() {
|
||||
val location = Location(
|
||||
lat = 1.000001,
|
||||
lon = 2.000001,
|
||||
accuracy = 0f
|
||||
)
|
||||
// Set a locale with comma as decimal separator
|
||||
Locale.setDefault(Locale.Category.FORMAT, Locale("pt", "BR"))
|
||||
|
||||
val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
|
||||
val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
|
||||
|
||||
assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "io.element.android.features.lockscreen.impl"
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
setupAnvil()
|
||||
|
|
@ -30,6 +34,8 @@ dependencies {
|
|||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.cryptography.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.libraries.uiUtils)
|
||||
implementation(projects.features.logout.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
|
|
@ -42,6 +48,9 @@ dependencies {
|
|||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testImplementation(libs.androidx.test.ext.junit)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(projects.libraries.cryptography.test)
|
||||
|
|
@ -50,4 +59,5 @@ dependencies {
|
|||
testImplementation(projects.libraries.sessionStorage.test)
|
||||
testImplementation(projects.services.appnavstate.test)
|
||||
testImplementation(projects.features.logout.test)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,9 @@ class DefaultBiometricAuthenticatorManager @Inject constructor(
|
|||
|
||||
@Composable
|
||||
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
|
||||
val isBiometricAllowed by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
|
||||
val isBiometricAllowed by remember {
|
||||
lockScreenStore.isBiometricUnlockAllowed()
|
||||
}.collectAsState(initial = false)
|
||||
val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
|
||||
val isAvailable by remember(lifecycleState) {
|
||||
derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator }
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ class LockScreenSettingsPresenter @Inject constructor(
|
|||
value = !lockScreenConfig.isPinMandatory && hasPinCode
|
||||
}
|
||||
}
|
||||
val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
|
||||
val isBiometricEnabled by remember {
|
||||
lockScreenStore.isBiometricUnlockAllowed()
|
||||
}.collectAsState(initial = false)
|
||||
var showRemovePinConfirmation by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceIn
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -37,6 +43,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
|||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toSp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.time.digit
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
@ -60,7 +68,22 @@ fun PinKeypad(
|
|||
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
|
||||
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
|
||||
Column(
|
||||
modifier = modifier,
|
||||
modifier = modifier.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyUp) {
|
||||
val digitChar = event.digit
|
||||
if (digitChar != null) {
|
||||
onClick(PinKeypadModel.Number(digitChar))
|
||||
true
|
||||
} else if (event.key == Key.Backspace) {
|
||||
onClick(PinKeypadModel.Back)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
) {
|
||||
|
|
@ -183,7 +206,7 @@ private fun PinKeypadBackButton(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Backspace,
|
||||
contentDescription = null,
|
||||
contentDescription = stringResource(CommonStrings.a11y_delete),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.unlock.keypad
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isRoot
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.requestFocus
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PinKeypadTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on a number emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<PinKeypadModel>()
|
||||
rule.setPinKeyPad(onClick = eventsRecorder)
|
||||
rule.onNode(hasText("1")).performClick()
|
||||
eventsRecorder.assertSingle(PinKeypadModel.Number('1'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on the delete previous character button emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<PinKeypadModel>()
|
||||
rule.setPinKeyPad(onClick = eventsRecorder)
|
||||
rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.a11y_delete))).performClick()
|
||||
eventsRecorder.assertSingle(PinKeypadModel.Back)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun `typing using the hardware keyboard emits the expected events`() {
|
||||
val eventsRecorder = EventsRecorder<PinKeypadModel>()
|
||||
rule.setPinKeyPad(onClick = eventsRecorder)
|
||||
rule.onNodeWithText("1").requestFocus()
|
||||
rule.onAllNodes(isRoot())[0].performKeyInput {
|
||||
val keys = listOf(
|
||||
Key.A,
|
||||
Key.NumPad1,
|
||||
Key.NumPad2,
|
||||
Key.NumPad3,
|
||||
Key.NumPad4,
|
||||
Key.NumPad5,
|
||||
Key.NumPad6,
|
||||
Key.NumPad7,
|
||||
Key.NumPad8,
|
||||
Key.NumPad9,
|
||||
Key.NumPad0,
|
||||
Key(KeyEvent.KEYCODE_1),
|
||||
Key(KeyEvent.KEYCODE_2),
|
||||
Key(KeyEvent.KEYCODE_3),
|
||||
Key(KeyEvent.KEYCODE_4),
|
||||
Key(KeyEvent.KEYCODE_5),
|
||||
Key(KeyEvent.KEYCODE_6),
|
||||
Key(KeyEvent.KEYCODE_7),
|
||||
Key(KeyEvent.KEYCODE_8),
|
||||
Key(KeyEvent.KEYCODE_9),
|
||||
Key(KeyEvent.KEYCODE_0),
|
||||
Key.Backspace,
|
||||
)
|
||||
for (key in keys) {
|
||||
pressKey(key)
|
||||
}
|
||||
}
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
// Note that the first key is not a number, but a letter so it's ignored as input
|
||||
// Then we have the numpad keys
|
||||
PinKeypadModel.Number('1'),
|
||||
PinKeypadModel.Number('2'),
|
||||
PinKeypadModel.Number('3'),
|
||||
PinKeypadModel.Number('4'),
|
||||
PinKeypadModel.Number('5'),
|
||||
PinKeypadModel.Number('6'),
|
||||
PinKeypadModel.Number('7'),
|
||||
PinKeypadModel.Number('8'),
|
||||
PinKeypadModel.Number('9'),
|
||||
PinKeypadModel.Number('0'),
|
||||
// And the normal keys from the number row in the keyboard
|
||||
PinKeypadModel.Number('1'),
|
||||
PinKeypadModel.Number('2'),
|
||||
PinKeypadModel.Number('3'),
|
||||
PinKeypadModel.Number('4'),
|
||||
PinKeypadModel.Number('5'),
|
||||
PinKeypadModel.Number('6'),
|
||||
PinKeypadModel.Number('7'),
|
||||
PinKeypadModel.Number('8'),
|
||||
PinKeypadModel.Number('9'),
|
||||
PinKeypadModel.Number('0'),
|
||||
PinKeypadModel.Back,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinKeyPad(
|
||||
onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setContent {
|
||||
PinKeypad(
|
||||
onClick = onClick,
|
||||
maxWidth = 1000.dp,
|
||||
maxHeight = 1000.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,9 +33,7 @@ class AccountProviderDataSource @Inject constructor(
|
|||
defaultAccountProvider
|
||||
)
|
||||
|
||||
fun flow(): StateFlow<AccountProvider> {
|
||||
return accountProvider.asStateFlow()
|
||||
}
|
||||
val flow: StateFlow<AccountProvider> = accountProvider.asStateFlow()
|
||||
|
||||
fun reset() {
|
||||
accountProvider.tryEmit(defaultAccountProvider)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun present(): ConfirmAccountProviderState {
|
||||
val accountProvider by accountProviderDataSource.flow().collectAsState()
|
||||
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val loginFlowAction: MutableState<AsyncData<LoginFlow>> = remember {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class DefaultMessageParser @Inject constructor(
|
|||
val parser = Json { ignoreUnknownKeys = true }
|
||||
val response = parser.decodeFromString(MobileRegistrationResponse.serializer(), message)
|
||||
val userId = response.userId ?: error("No user ID in response")
|
||||
val homeServer = response.homeServer ?: accountProviderDataSource.flow().value.url
|
||||
val homeServer = response.homeServer ?: accountProviderDataSource.flow.value.url
|
||||
val accessToken = response.accessToken ?: error("No access token in response")
|
||||
val deviceId = response.deviceId ?: error("No device ID in response")
|
||||
return ExternalSession(
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class LoginPasswordPresenter @Inject constructor(
|
|||
val formState = rememberSaveable {
|
||||
mutableStateOf(LoginFormState.Default)
|
||||
}
|
||||
val accountProvider by accountProviderDataSource.flow().collectAsState()
|
||||
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
||||
|
||||
fun handleEvents(event: LoginPasswordEvents) {
|
||||
when (event) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class AccountProviderDataSourceTest {
|
|||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val sut = AccountProviderDataSource(FakeEnterpriseService())
|
||||
sut.flow().test {
|
||||
sut.flow.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(
|
||||
AccountProvider(
|
||||
|
|
@ -43,7 +43,7 @@ class AccountProviderDataSourceTest {
|
|||
val sut = AccountProviderDataSource(FakeEnterpriseService(
|
||||
defaultHomeserverResult = { AuthenticationConfig.MATRIX_ORG_URL }
|
||||
))
|
||||
sut.flow().test {
|
||||
sut.flow.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(
|
||||
AccountProvider(
|
||||
|
|
@ -61,7 +61,7 @@ class AccountProviderDataSourceTest {
|
|||
@Test
|
||||
fun `present - user change and reset`() = runTest {
|
||||
val sut = AccountProviderDataSource(FakeEnterpriseService())
|
||||
sut.flow().test {
|
||||
sut.flow.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.url).isEqualTo(FakeEnterpriseService.A_FAKE_HOMESERVER)
|
||||
sut.userSelection(AccountProvider(url = "https://example.com"))
|
||||
|
|
|
|||
|
|
@ -15,6 +15,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.setValue
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
|
|
@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
|
|||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -44,6 +46,16 @@ class LogoutPresenter @Inject constructor(
|
|||
}
|
||||
.collectAsState(initial = BackupUploadState.Unknown)
|
||||
|
||||
var waitingForALongTime by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(backupUploadState) {
|
||||
if (backupUploadState is BackupUploadState.Waiting) {
|
||||
delay(2_000)
|
||||
waitingForALongTime = true
|
||||
} else {
|
||||
waitingForALongTime = false
|
||||
}
|
||||
}
|
||||
|
||||
val isLastDevice by encryptionService.isLastDevice.collectAsState()
|
||||
val backupState by encryptionService.backupStateStateFlow.collectAsState()
|
||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||
|
|
@ -79,6 +91,7 @@ class LogoutPresenter @Inject constructor(
|
|||
doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(),
|
||||
recoveryState = recoveryState,
|
||||
backupUploadState = backupUploadState,
|
||||
waitingForALongTime = waitingForALongTime,
|
||||
logoutAction = logoutAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ data class LogoutState(
|
|||
val doesBackupExistOnServer: Boolean,
|
||||
val recoveryState: RecoveryState,
|
||||
val backupUploadState: BackupUploadState,
|
||||
val waitingForALongTime: Boolean,
|
||||
val logoutAction: AsyncAction<Unit>,
|
||||
val eventSink: (LogoutEvents) -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ open class LogoutStateProvider : PreviewParameterProvider<LogoutState> {
|
|||
aLogoutState(isLastDevice = true, recoveryState = RecoveryState.DISABLED),
|
||||
// Last session no backup
|
||||
aLogoutState(isLastDevice = true, backupState = BackupState.UNKNOWN, doesBackupExistOnServer = false),
|
||||
aLogoutState(
|
||||
isLastDevice = false,
|
||||
backupUploadState = BackupUploadState.Waiting,
|
||||
),
|
||||
aLogoutState(
|
||||
isLastDevice = false,
|
||||
backupUploadState = BackupUploadState.Waiting,
|
||||
waitingForALongTime = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +47,7 @@ fun aLogoutState(
|
|||
doesBackupExistOnServer: Boolean = true,
|
||||
recoveryState: RecoveryState = RecoveryState.ENABLED,
|
||||
backupUploadState: BackupUploadState = BackupUploadState.Unknown,
|
||||
waitingForALongTime: Boolean = false,
|
||||
logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (LogoutEvents) -> Unit = {},
|
||||
) = LogoutState(
|
||||
|
|
@ -46,6 +56,7 @@ fun aLogoutState(
|
|||
doesBackupExistOnServer = doesBackupExistOnServer,
|
||||
recoveryState = recoveryState,
|
||||
backupUploadState = backupUploadState,
|
||||
waitingForALongTime = waitingForALongTime,
|
||||
logoutAction = logoutAction,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -143,24 +143,41 @@ private fun ColumnScope.Buttons(
|
|||
@Composable
|
||||
private fun Content(
|
||||
state: LogoutState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.backupUploadState is BackupUploadState.Uploading) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 60.dp, start = 20.dp, end = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
|
||||
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 60.dp, start = 20.dp, end = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
when (state.backupUploadState) {
|
||||
is BackupUploadState.Uploading -> {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
|
||||
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
BackupUploadState.Waiting -> {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
||||
)
|
||||
if (state.waitingForALongTime) {
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
text = stringResource(CommonStrings.common_please_check_internet_connection),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class LogoutPresenterTest {
|
|||
assertThat(initialState.doesBackupExistOnServer).isTrue()
|
||||
assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN)
|
||||
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
|
||||
assertThat(initialState.waitingForALongTime).isFalse()
|
||||
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +67,34 @@ class LogoutPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state - waiting a long time`() = runTest {
|
||||
val encryptionService = FakeEncryptionService()
|
||||
encryptionService.givenWaitForBackupUploadSteadyStateFlow(
|
||||
flow {
|
||||
emit(BackupUploadState.Waiting)
|
||||
delay(3_000)
|
||||
}
|
||||
)
|
||||
val presenter = createLogoutPresenter(
|
||||
encryptionService = encryptionService
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.waitingForALongTime).isFalse()
|
||||
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
|
||||
val waitingState = awaitItem()
|
||||
assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
|
||||
assertThat(initialState.waitingForALongTime).isFalse()
|
||||
skipItems(1)
|
||||
val waitingALongTimeState = awaitItem()
|
||||
assertThat(waitingALongTimeState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
|
||||
assertThat(waitingALongTimeState.waitingForALongTime).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state - backing up`() = runTest {
|
||||
val encryptionService = FakeEncryptionService()
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
|||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
|
|
@ -183,7 +182,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
showReinvitePrompt = !hasDismissedInviteDialog && composerHasFocus && roomInfo.isDm && roomInfo.activeMembersCount == 1L
|
||||
}
|
||||
}
|
||||
val isOnline by syncService.isOnline().collectAsState()
|
||||
val isOnline by syncService.isOnline.collectAsState()
|
||||
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
|||
mutableStateOf(ActionListState.Target.None)
|
||||
}
|
||||
|
||||
val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false)
|
||||
val isDeveloperModeEnabled by remember {
|
||||
appPreferencesStore.isDeveloperModeEnabledFlow()
|
||||
}.collectAsState(initial = false)
|
||||
val isPinnedEventsEnabled = isPinnedMessagesFeatureEnabled()
|
||||
val pinnedEventIds by remember {
|
||||
room.roomInfoFlow.map { it.pinnedEventIds }
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
|
|||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.mediaupload.api.allFiles
|
||||
|
|
@ -78,8 +79,12 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
|
||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)
|
||||
val showCaptionCompatibilityWarning by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning).collectAsState(initial = false)
|
||||
val allowCaption by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation)
|
||||
}.collectAsState(initial = false)
|
||||
val showCaptionCompatibilityWarning by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning)
|
||||
}.collectAsState(initial = false)
|
||||
|
||||
var useSendQueue by remember { mutableStateOf(false) }
|
||||
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
|
@ -123,6 +128,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
caption = caption,
|
||||
sendActionState = sendActionState,
|
||||
dismissAfterSend = !useSendQueue,
|
||||
replyParameters = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -233,6 +239,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
caption: String?,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
dismissAfterSend: Boolean,
|
||||
replyParameters: ReplyParameters?,
|
||||
) = runCatching {
|
||||
val context = coroutineContext
|
||||
val progressCallback = object : ProgressCallback {
|
||||
|
|
@ -247,7 +254,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
mediaUploadInfo = mediaUploadInfo,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
progressCallback = progressCallback
|
||||
progressCallback = progressCallback,
|
||||
replyParameters = replyParameters,
|
||||
).getOrThrow()
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
|||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
||||
import io.element.android.libraries.matrix.api.room.isDm
|
||||
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
|
|
@ -177,7 +178,9 @@ class MessageComposerPresenter @AssistedInject constructor(
|
|||
}
|
||||
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
||||
|
||||
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
|
||||
val sendTypingNotifications by remember {
|
||||
sessionPreferencesStore.isSendTypingNotificationsEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
|
||||
LaunchedEffect(cameraPermissionState.permissionGranted) {
|
||||
if (cameraPermissionState.permissionGranted) {
|
||||
|
|
@ -397,16 +400,16 @@ class MessageComposerPresenter @AssistedInject constructor(
|
|||
.stateIn(this, SharingStarted.Lazily, emptyList())
|
||||
|
||||
combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions ->
|
||||
val result = suggestionsProcessor.process(
|
||||
suggestion = suggestion,
|
||||
roomMembersState = roomMembersState,
|
||||
roomAliasSuggestions = roomAliasSuggestions,
|
||||
currentUserId = currentUserId,
|
||||
canSendRoomMention = ::canSendRoomMention,
|
||||
)
|
||||
suggestions.clear()
|
||||
suggestions.addAll(result)
|
||||
}
|
||||
val result = suggestionsProcessor.process(
|
||||
suggestion = suggestion,
|
||||
roomMembersState = roomMembersState,
|
||||
roomAliasSuggestions = roomAliasSuggestions,
|
||||
currentUserId = currentUserId,
|
||||
canSendRoomMention = ::canSendRoomMention,
|
||||
)
|
||||
suggestions.clear()
|
||||
suggestions.addAll(result)
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -450,7 +453,19 @@ class MessageComposerPresenter @AssistedInject constructor(
|
|||
}
|
||||
is MessageComposerMode.Reply -> {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
|
||||
with(capturedMode) {
|
||||
replyMessage(
|
||||
body = message.markdown,
|
||||
htmlBody = message.html,
|
||||
intentionalMentions = message.intentionalMentions,
|
||||
replyParameters = ReplyParameters(
|
||||
inReplyToEventId = eventId,
|
||||
enforceThreadReply = inThread,
|
||||
// This should be false until we add a way to make a reply in a thread an explicit reply to the provided eventId
|
||||
replyWithinThread = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,9 +109,15 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
|
||||
|
||||
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
|
||||
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
|
||||
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
|
||||
val isLive by timelineController.isLive().collectAsState(initial = true)
|
||||
val isSendPublicReadReceiptsEnabled by remember {
|
||||
sessionPreferencesStore.isSendPublicReadReceiptsEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
val renderReadReceipts by remember {
|
||||
sessionPreferencesStore.isRenderReadReceiptsEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
val isLive by remember {
|
||||
timelineController.isLive()
|
||||
}.collectAsState(initial = true)
|
||||
|
||||
fun handleEvents(event: TimelineEvents) {
|
||||
when (event) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannedString
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
|
|
@ -71,7 +71,7 @@ fun TimelineItemTextView(
|
|||
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
|
||||
val mentionSpanUpdater = LocalMentionSpanUpdater.current
|
||||
val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody)
|
||||
return SpannableString(bodyWithResolvedMentions)
|
||||
return SpannedString.valueOf(bodyWithResolvedMentions)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ class TypingNotificationPresenter @Inject constructor(
|
|||
) : Presenter<TypingNotificationState> {
|
||||
@Composable
|
||||
override fun present(): TypingNotificationState {
|
||||
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
|
||||
val renderTypingNotifications by remember {
|
||||
sessionPreferencesStore.isRenderTypingNotificationsEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) {
|
||||
if (renderTypingNotifications) {
|
||||
observeRoomTypingMembers()
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
|
|||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
|
||||
import io.element.android.libraries.matrix.test.A_CAPTION
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||
|
|
@ -105,7 +106,8 @@ class AttachmentsPreviewPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - send media success scenario`() = runTest {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
val sendFileResult =
|
||||
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
|
|
@ -142,7 +144,8 @@ class AttachmentsPreviewPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - send media after pre-processing success scenario`() = runTest {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
val sendFileResult =
|
||||
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
|
|
@ -177,7 +180,8 @@ class AttachmentsPreviewPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - send media before pre-processing success scenario`() = runTest {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
val sendFileResult =
|
||||
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
|
|
@ -287,7 +291,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
@Test
|
||||
fun `present - send image with caption success scenario`() = runTest {
|
||||
val sendImageResult =
|
||||
lambdaRecorder<File, File?, ImageInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||
|
|
@ -320,6 +324,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
value(A_CAPTION),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
)
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
}
|
||||
|
|
@ -328,7 +333,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
@Test
|
||||
fun `present - send video with caption success scenario`() = runTest {
|
||||
val sendVideoResult =
|
||||
lambdaRecorder<File, File?, VideoInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
lambdaRecorder { _: File, _: File?, _: VideoInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||
|
|
@ -361,6 +366,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
value(A_CAPTION),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
)
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
}
|
||||
|
|
@ -369,7 +375,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
@Test
|
||||
fun `present - send audio with caption success scenario`() = runTest {
|
||||
val sendAudioResult =
|
||||
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||
|
|
@ -399,6 +405,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
value(A_CAPTION),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
)
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
}
|
||||
|
|
@ -407,7 +414,8 @@ class AttachmentsPreviewPresenterTest {
|
|||
@Test
|
||||
fun `present - send media failure scenario without media queue`() = runTest {
|
||||
val failure = MediaPreProcessor.Failure(null)
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
val sendFileResult =
|
||||
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.failure(failure)
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
|
|
@ -435,7 +443,8 @@ class AttachmentsPreviewPresenterTest {
|
|||
@Test
|
||||
fun `present - send media failure scenario with media queue`() = runTest {
|
||||
val failure = MediaPreProcessor.Failure(null)
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
val sendFileResult =
|
||||
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.failure(failure)
|
||||
}
|
||||
val onDoneListenerResult = lambdaRecorder<Unit> {}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
|||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
||||
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
|
|
@ -611,7 +612,7 @@ class MessageComposerPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - reply message`() = runTest {
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
|
|
@ -1110,7 +1111,7 @@ class MessageComposerPresenterTest {
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - send messages with intentional mentions`() = runTest {
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.messagecomposer.aReplyMode
|
|||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
|
|
@ -60,7 +61,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
)
|
||||
private val analyticsService = FakeAnalyticsService()
|
||||
private val sendVoiceMessageResult =
|
||||
lambdaRecorder<File, AudioInfo, List<Float>, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _ ->
|
||||
lambdaRecorder<File, AudioInfo, List<Float>, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
private val matrixRoom = FakeMatrixRoom(
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ class MigrationPresenter @Inject constructor(
|
|||
|
||||
@Composable
|
||||
override fun present(): MigrationState {
|
||||
val migrationStoreVersion by migrationStore.applicationMigrationVersion().collectAsState(initial = null)
|
||||
val migrationStoreVersion by remember {
|
||||
migrationStore.applicationMigrationVersion()
|
||||
}.collectAsState(initial = null)
|
||||
var migrationAction: AsyncData<Unit> by remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||
|
||||
// Uncomment this block to run the migration everytime
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class PollHistoryPresenter @Inject constructor(
|
|||
@Composable
|
||||
override fun present(): PollHistoryState {
|
||||
val timeline = room.liveTimeline
|
||||
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
|
||||
val paginationState by timeline.backwardPaginationStatus.collectAsState()
|
||||
val pollHistoryItemsFlow = remember {
|
||||
timeline.timelineItems.map { items ->
|
||||
pollHistoryItemFactory.create(items)
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ dependencies {
|
|||
implementation(projects.features.licenses.api)
|
||||
implementation(projects.features.logout.api)
|
||||
implementation(projects.features.deactivation.api)
|
||||
implementation(projects.features.invite.api)
|
||||
implementation(projects.features.roomlist.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.services.analytics.compose)
|
||||
|
|
@ -103,6 +104,7 @@ dependencies {
|
|||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.libraries.pushstore.test)
|
||||
testImplementation(projects.features.ftue.test)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.features.rageshake.test)
|
||||
testImplementation(projects.features.rageshake.impl)
|
||||
testImplementation(projects.features.logout.test)
|
||||
|
|
|
|||
|
|
@ -30,19 +30,18 @@ class AdvancedSettingsPresenter @Inject constructor(
|
|||
@Composable
|
||||
override fun present(): AdvancedSettingsState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val isDeveloperModeEnabled by appPreferencesStore
|
||||
.isDeveloperModeEnabledFlow()
|
||||
.collectAsState(initial = false)
|
||||
val isSharePresenceEnabled by sessionPreferencesStore
|
||||
.isSharePresenceEnabled()
|
||||
.collectAsState(initial = true)
|
||||
val doesCompressMedia by sessionPreferencesStore
|
||||
.doesCompressMedia()
|
||||
.collectAsState(initial = true)
|
||||
val isDeveloperModeEnabled by remember {
|
||||
appPreferencesStore.isDeveloperModeEnabledFlow()
|
||||
}.collectAsState(initial = false)
|
||||
val isSharePresenceEnabled by remember {
|
||||
sessionPreferencesStore.isSharePresenceEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
val doesCompressMedia by remember {
|
||||
sessionPreferencesStore.doesCompressMedia()
|
||||
}.collectAsState(initial = true)
|
||||
val theme by remember {
|
||||
appPreferencesStore.getThemeFlow().mapToTheme()
|
||||
}
|
||||
.collectAsState(initial = Theme.System)
|
||||
}.collectAsState(initial = Theme.System)
|
||||
var showChangeThemeDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val hideInviteAvatars by remember {
|
||||
|
|
|
|||
|
|
@ -44,17 +44,17 @@ class BlockedUsersPresenter @Inject constructor(
|
|||
mutableStateOf(AsyncAction.Uninitialized)
|
||||
}
|
||||
|
||||
val renderBlockedUsersDetail = featureFlagService
|
||||
.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
|
||||
.collectAsState(initial = false)
|
||||
val renderBlockedUsersDetail by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
|
||||
}.collectAsState(initial = false)
|
||||
val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState()
|
||||
val ignoredMatrixUser by produceState(
|
||||
initialValue = ignoredUserIds.map { MatrixUser(userId = it) },
|
||||
key1 = renderBlockedUsersDetail.value,
|
||||
key1 = renderBlockedUsersDetail,
|
||||
key2 = ignoredUserIds
|
||||
) {
|
||||
value = ignoredUserIds.map {
|
||||
if (renderBlockedUsersDetail.value) {
|
||||
if (renderBlockedUsersDetail) {
|
||||
matrixClient.getProfile(it).getOrNull()
|
||||
} else {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -71,9 +71,10 @@ class DeveloperSettingsPresenter @Inject constructor(
|
|||
val clearCacheAction = remember {
|
||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val customElementCallBaseUrl by appPreferencesStore
|
||||
.getCustomElementCallBaseUrlFlow()
|
||||
.collectAsState(initial = null)
|
||||
val customElementCallBaseUrl by remember {
|
||||
appPreferencesStore
|
||||
.getCustomElementCallBaseUrlFlow()
|
||||
}.collectAsState(initial = null)
|
||||
|
||||
val tracingLogLevelFlow = remember {
|
||||
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }
|
||||
|
|
|
|||
|
|
@ -58,9 +58,9 @@ class NotificationSettingsPresenter @Inject constructor(
|
|||
val changeNotificationSettingAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val appNotificationsEnabled = userPushStore
|
||||
.getNotificationEnabledForDevice()
|
||||
.collectAsState(initial = false)
|
||||
val appNotificationsEnabled by remember {
|
||||
userPushStore.getNotificationEnabledForDevice()
|
||||
}.collectAsState(initial = false)
|
||||
|
||||
val matrixSettings: MutableState<NotificationSettingsState.MatrixSettings> = remember {
|
||||
mutableStateOf(NotificationSettingsState.MatrixSettings.Uninitialized)
|
||||
|
|
@ -158,7 +158,7 @@ class NotificationSettingsPresenter @Inject constructor(
|
|||
matrixSettings = matrixSettings.value,
|
||||
appSettings = NotificationSettingsState.AppSettings(
|
||||
systemNotificationsEnabled = systemNotificationsEnabled.value,
|
||||
appNotificationsEnabled = appNotificationsEnabled.value
|
||||
appNotificationsEnabled = appNotificationsEnabled,
|
||||
),
|
||||
changeNotificationSettingAction = changeNotificationSettingAction.value,
|
||||
currentPushDistributor = currentDistributor,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import android.content.Context
|
|||
import coil3.SingletonImageLoader
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.ftue.api.state.FtueService
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.preferences.impl.DefaultCacheService
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
|
|
@ -35,6 +36,7 @@ class DefaultClearCacheUseCase @Inject constructor(
|
|||
private val okHttpClient: Provider<OkHttpClient>,
|
||||
private val ftueService: FtueService,
|
||||
private val pushService: PushService,
|
||||
private val seenInvitesStore: SeenInvitesStore,
|
||||
) : ClearCacheUseCase {
|
||||
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
|
||||
// Clear Matrix cache
|
||||
|
|
@ -50,6 +52,7 @@ class DefaultClearCacheUseCase @Inject constructor(
|
|||
context.cacheDir.deleteRecursively()
|
||||
// Clear some settings
|
||||
ftueService.reset()
|
||||
seenInvitesStore.clear()
|
||||
// Ensure any error will be displayed again
|
||||
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
|
||||
// Ensure the app is restarted
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@
|
|||
<string name="screen_advanced_settings_element_call_base_url">"Vlastná Element Call základná URL adresa"</string>
|
||||
<string name="screen_advanced_settings_element_call_base_url_description">"Nastaviť vlastnú základnú URL adresu pre Element Call."</string>
|
||||
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Neplatná adresa URL, uistite sa, že ste uviedli protokol (http/https) a správnu adresu."</string>
|
||||
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Skrytie profilové obrázky v žiadostiach o pozvánku do miestnosti"</string>
|
||||
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Skryť ukážky médií na časovej osi"</string>
|
||||
<string name="screen_advanced_settings_media_compression_description">"Nahrávajte fotografie a videá rýchlejšie a znížte spotrebu dát"</string>
|
||||
<string name="screen_advanced_settings_media_compression_title">"Optimalizovať kvalitu médií"</string>
|
||||
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderovanie a bezpečnosť"</string>
|
||||
<string name="screen_advanced_settings_push_provider_android">"Poskytovateľ oznámení Push"</string>
|
||||
<string name="screen_advanced_settings_rich_text_editor_description">"Vypnite rozšírený textový editor na ručné písanie Markdown."</string>
|
||||
<string name="screen_advanced_settings_send_read_receipts">"Potvrdenia o prečítaní"</string>
|
||||
|
|
|
|||
|
|
@ -11,13 +11,16 @@ import androidx.test.platform.app.InstrumentationRegistry
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.ftue.test.FakeFtueService
|
||||
import io.element.android.features.invite.test.InMemorySeenInvitesStore
|
||||
import io.element.android.features.preferences.impl.DefaultCacheService
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.push.test.FakePushService
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Test
|
||||
|
|
@ -41,6 +44,8 @@ class DefaultClearCacheUseCaseTest {
|
|||
val pushService = FakePushService(
|
||||
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
|
||||
)
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID))
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty()
|
||||
val sut = DefaultClearCacheUseCase(
|
||||
context = InstrumentationRegistry.getInstrumentation().context,
|
||||
matrixClient = matrixClient,
|
||||
|
|
@ -49,6 +54,7 @@ class DefaultClearCacheUseCaseTest {
|
|||
okHttpClient = { OkHttpClient.Builder().build() },
|
||||
ftueService = ftueService,
|
||||
pushService = pushService,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
defaultCacheService.clearedCacheEventFlow.test {
|
||||
sut.invoke()
|
||||
|
|
@ -57,6 +63,7 @@ class DefaultClearCacheUseCaseTest {
|
|||
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
|
||||
.with(value(matrixClient.sessionId), value(false))
|
||||
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,9 +64,9 @@ class BugReportPresenter @Inject constructor(
|
|||
screenshotHolder.getFileUri()
|
||||
)
|
||||
}
|
||||
val crashInfo: String by crashDataStore
|
||||
.crashInfo()
|
||||
.collectAsState(initial = "")
|
||||
val crashInfo: String by remember {
|
||||
crashDataStore.crashInfo()
|
||||
}.collectAsState(initial = "")
|
||||
|
||||
val sendingProgress = remember {
|
||||
mutableFloatStateOf(0f)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.rageshake.impl.preferences
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -39,13 +40,13 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
|
|||
mutableStateOf(rageshake.isAvailable())
|
||||
}
|
||||
val isFeatureAvailable = remember { rageshakeFeatureAvailability.isAvailable() }
|
||||
val isEnabled = rageshakeDataStore
|
||||
.isEnabled()
|
||||
.collectAsState(initial = false)
|
||||
val isEnabled by remember {
|
||||
rageshakeDataStore.isEnabled()
|
||||
}.collectAsState(initial = false)
|
||||
|
||||
val sensitivity = rageshakeDataStore
|
||||
.sensitivity()
|
||||
.collectAsState(initial = 0f)
|
||||
val sensitivity by remember {
|
||||
rageshakeDataStore.sensitivity()
|
||||
}.collectAsState(initial = 0f)
|
||||
|
||||
fun handleEvents(event: RageshakePreferencesEvents) {
|
||||
when (event) {
|
||||
|
|
@ -56,9 +57,9 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
|
|||
|
||||
return RageshakePreferencesState(
|
||||
isFeatureEnabled = isFeatureAvailable,
|
||||
isEnabled = isEnabled.value,
|
||||
isEnabled = isEnabled,
|
||||
isSupported = isSupported.value,
|
||||
sensitivity = sensitivity.value,
|
||||
sensitivity = sensitivity,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ sealed interface RoomDetailsEvent {
|
|||
data object LeaveRoom : RoomDetailsEvent
|
||||
data object MuteNotification : RoomDetailsEvent
|
||||
data object UnmuteNotification : RoomDetailsEvent
|
||||
data class CopyToClipboard(val text: String) : RoomDetailsEvent
|
||||
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,12 @@ import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEn
|
|||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.securityAndPrivacyPermissionsAsState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
|
|
@ -45,6 +49,7 @@ import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
|||
import io.element.android.libraries.matrix.ui.room.isDmAsState
|
||||
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
|
||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
|
@ -65,6 +70,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
private val dispatchers: CoroutineDispatchers,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
) : Presenter<RoomDetailsState> {
|
||||
@Composable
|
||||
override fun present(): RoomDetailsState {
|
||||
|
|
@ -122,7 +128,9 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
}
|
||||
|
||||
val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
|
||||
val isKnockRequestsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(false)
|
||||
val isKnockRequestsEnabled by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
|
||||
}.collectAsState(false)
|
||||
val knockRequestsCount by produceState<Int?>(null) {
|
||||
room.knockRequestsFlow.collect { value = it.size }
|
||||
}
|
||||
|
|
@ -132,6 +140,9 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
|
||||
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
|
||||
|
||||
val snackbarDispatcher = LocalSnackbarDispatcher.current
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
fun handleEvents(event: RoomDetailsEvent) {
|
||||
when (event) {
|
||||
RoomDetailsEvent.LeaveRoom ->
|
||||
|
|
@ -147,6 +158,10 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite)
|
||||
is RoomDetailsEvent.CopyToClipboard -> {
|
||||
clipboardHelper.copyPlainText(event.text)
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +203,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
canShowPinnedMessages = canShowPinnedMessages,
|
||||
canShowMediaGallery = canShowMediaGallery,
|
||||
pinnedMessagesCount = pinnedMessagesCount,
|
||||
snackbarMessage = snackbarMessage,
|
||||
canShowKnockRequests = canShowKnockRequests,
|
||||
knockRequestsCount = knockRequestsCount,
|
||||
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.runtime.Immutable
|
|||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
|
|
@ -42,6 +43,7 @@ data class RoomDetailsState(
|
|||
val canShowPinnedMessages: Boolean,
|
||||
val canShowMediaGallery: Boolean,
|
||||
val pinnedMessagesCount: Int?,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val canShowKnockRequests: Boolean,
|
||||
val knockRequestsCount: Int?,
|
||||
val canShowSecurityAndPrivacy: Boolean,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import io.element.android.features.userprofile.api.UserProfileState
|
|||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -111,6 +112,7 @@ fun aRoomDetailsState(
|
|||
canShowPinnedMessages: Boolean = true,
|
||||
canShowMediaGallery: Boolean = true,
|
||||
pinnedMessagesCount: Int? = null,
|
||||
snackbarMessage: SnackbarMessage? = null,
|
||||
canShowKnockRequests: Boolean = false,
|
||||
knockRequestsCount: Int? = null,
|
||||
canShowSecurityAndPrivacy: Boolean = true,
|
||||
|
|
@ -139,11 +141,12 @@ fun aRoomDetailsState(
|
|||
canShowPinnedMessages = canShowPinnedMessages,
|
||||
canShowMediaGallery = canShowMediaGallery,
|
||||
pinnedMessagesCount = pinnedMessagesCount,
|
||||
snackbarMessage = snackbarMessage,
|
||||
canShowKnockRequests = canShowKnockRequests,
|
||||
knockRequestsCount = knockRequestsCount,
|
||||
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
|
||||
hasMemberVerificationViolations = hasMemberVerificationViolations,
|
||||
eventSink = eventSink
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aRoomNotificationSettings(
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import io.element.android.libraries.designsystem.components.button.MainActionBut
|
|||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||
import io.element.android.libraries.designsystem.modifiers.niceClickable
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
||||
|
|
@ -69,6 +70,8 @@ import io.element.android.libraries.designsystem.theme.components.ListItemStyle
|
|||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -106,6 +109,7 @@ fun RoomDetailsView(
|
|||
onProfileClick: (UserId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
|
|
@ -115,6 +119,7 @@ fun RoomDetailsView(
|
|||
onActionClick = onActionClick
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -135,6 +140,9 @@ fun RoomDetailsView(
|
|||
openAvatarPreview = { avatarUrl ->
|
||||
openAvatarPreview(state.roomName, avatarUrl)
|
||||
},
|
||||
onSubtitleClick = { subtitle ->
|
||||
state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
|
||||
}
|
||||
)
|
||||
}
|
||||
is RoomDetailsType.Dm -> {
|
||||
|
|
@ -145,6 +153,9 @@ fun RoomDetailsView(
|
|||
openAvatarPreview = { name, avatarUrl ->
|
||||
openAvatarPreview(name, avatarUrl)
|
||||
},
|
||||
onSubtitleClick = { subtitle ->
|
||||
state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -368,6 +379,7 @@ private fun RoomHeaderSection(
|
|||
roomAlias: RoomAlias?,
|
||||
heroes: ImmutableList<MatrixUser>,
|
||||
openAvatarPreview: (url: String) -> Unit,
|
||||
onSubtitleClick: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -384,7 +396,11 @@ private fun RoomHeaderSection(
|
|||
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
|
||||
.testTag(TestTags.roomDetailAvatar)
|
||||
)
|
||||
TitleAndSubtitle(title = roomName, subtitle = roomAlias?.value)
|
||||
TitleAndSubtitle(
|
||||
title = roomName,
|
||||
subtitle = roomAlias?.value,
|
||||
onSubtitleClick = onSubtitleClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -394,6 +410,7 @@ private fun DmHeaderSection(
|
|||
otherMember: RoomMember,
|
||||
roomName: String,
|
||||
openAvatarPreview: (name: String, url: String) -> Unit,
|
||||
onSubtitleClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -411,6 +428,7 @@ private fun DmHeaderSection(
|
|||
TitleAndSubtitle(
|
||||
title = roomName,
|
||||
subtitle = otherMember.userId.value,
|
||||
onSubtitleClick = onSubtitleClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -419,6 +437,7 @@ private fun DmHeaderSection(
|
|||
private fun TitleAndSubtitle(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
onSubtitleClick: (String) -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
|
@ -430,6 +449,7 @@ private fun TitleAndSubtitle(
|
|||
if (subtitle != null) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
modifier = Modifier.niceClickable { onSubtitleClick(subtitle) },
|
||||
text = subtitle,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
|
|
@ -612,13 +632,13 @@ private fun PinnedMessagesItem(
|
|||
headlineContent = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())),
|
||||
trailingContent =
|
||||
if (pinnedMessagesCount == null) {
|
||||
ListItemContent.Custom {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
} else {
|
||||
ListItemContent.Text(pinnedMessagesCount.toString())
|
||||
},
|
||||
if (pinnedMessagesCount == null) {
|
||||
ListItemContent.Custom {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
} else {
|
||||
ListItemContent.Text(pinnedMessagesCount.toString())
|
||||
},
|
||||
onClick = {
|
||||
analyticsService.captureInteraction(Interaction.Name.PinnedMessageRoomInfoButton)
|
||||
onPinnedMessagesClick()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import dagger.Module
|
|||
import dagger.Provides
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
|
|
@ -25,6 +26,7 @@ object RoomMemberModule {
|
|||
room: MatrixRoom,
|
||||
userProfilePresenterFactory: UserProfilePresenterFactory,
|
||||
encryptionService: EncryptionService,
|
||||
clipboardHelper: ClipboardHelper,
|
||||
): RoomMemberDetailsPresenter.Factory {
|
||||
return object : RoomMemberDetailsPresenter.Factory {
|
||||
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
|
||||
|
|
@ -33,6 +35,7 @@ object RoomMemberModule {
|
|||
room = room,
|
||||
userProfilePresenterFactory = userProfilePresenterFactory,
|
||||
encryptionService = encryptionService,
|
||||
clipboardHelper = clipboardHelper,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ import io.element.android.features.userprofile.api.UserProfileEvents
|
|||
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
|
|
@ -27,6 +31,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
|
||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
|
|
@ -42,6 +47,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
|||
@Assisted private val roomMemberId: UserId,
|
||||
private val room: MatrixRoom,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
userProfilePresenterFactory: UserProfilePresenterFactory,
|
||||
) : Presenter<UserProfileState> {
|
||||
interface Factory {
|
||||
|
|
@ -55,6 +61,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
|||
override fun present(): UserProfileState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val snackbarDispatcher = LocalSnackbarDispatcher.current
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
val roomMember by room.getRoomMemberAsState(roomMemberId)
|
||||
LaunchedEffect(Unit) {
|
||||
// Update room member info when opening this screen
|
||||
|
|
@ -111,7 +119,11 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
|||
UserProfileEvents.WithdrawVerification -> coroutineScope.launch {
|
||||
encryptionService.withdrawVerification(roomMemberId)
|
||||
}
|
||||
else -> Unit
|
||||
is UserProfileEvents.CopyToClipboard -> {
|
||||
clipboardHelper.copyPlainText(event.text)
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
}
|
||||
else -> userProfileState.eventSink(event)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,13 +131,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
|||
userName = roomUserName ?: userProfileState.userName,
|
||||
avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl,
|
||||
verificationState = verificationState,
|
||||
eventSink = { event ->
|
||||
if (event is UserProfileEvents.WithdrawVerification) {
|
||||
eventSink(UserProfileEvents.WithdrawVerification)
|
||||
} else {
|
||||
userProfileState.eventSink(event)
|
||||
}
|
||||
}
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = ::eventSink
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@
|
|||
<item quantity="one">"%1$d osaleja"</item>
|
||||
<item quantity="other">"%1$d osalejat"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_action">"Eemalda"</string>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_description">"Uue kutse saamisel on tal võimalik selle jututoaga uuesti liituda."</string>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_title">"Kas sa oled kindel, et soovid selle osaleja eemaldada?"</string>
|
||||
<string name="screen_room_member_list_manage_member_ban">"Eemalda ja sea suhtluskeeld"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove">"Eemalda kasutaja jututoast"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Eemalda ja sea suhtluskeeld"</string>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@
|
|||
<item quantity="one">"%1$d személy"</item>
|
||||
<item quantity="other">"%1$d személy"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_action">"Eltávolítás"</string>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_description">"Ehhez a szobához is csatlakozhat, ha meghívják."</string>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_title">"Biztos, hogy eltávolítja ezt a tagot?"</string>
|
||||
<string name="screen_room_member_list_manage_member_ban">"Eltávolítás és a tag kitiltása"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove">"Eltávolítás a szobából"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Eltávolítás és a tag kitiltása"</string>
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@
|
|||
<item quantity="few">"%1$d osoby"</item>
|
||||
<item quantity="other">"%1$d osôb"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_action">"Odstrániť"</string>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_description">"V prípade pozvania sa budú môcť znova pripojiť k tejto miestnosti."</string>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_title">"Ste si istý, že chcete odstrániť tohto člena?"</string>
|
||||
<string name="screen_room_member_list_manage_member_ban">"Odstrániť a zakázať člena"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove">"Odstrániť z miestnosti"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Odstrániť a zakázať člena"</string>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@
|
|||
<item quantity="one">"%1$d person"</item>
|
||||
<item quantity="other">"%1$d personer"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_action">"Ta bort"</string>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_description">"Denne kommer kunna gå med i rummet igen om denne bjuds in"</string>
|
||||
<string name="screen_room_member_list_kick_member_confirmation_title">"Är du säker på att du vill ta bort den här medlemmen?"</string>
|
||||
<string name="screen_room_member_list_manage_member_ban">"Ta bort och banna medlem"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove">"Ta bort från rummet"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ta bort och banna medlem"</string>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import io.element.android.features.roomcall.api.aStandByCallState
|
|||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
|
|
@ -81,6 +83,7 @@ class RoomDetailsPresenterTest {
|
|||
),
|
||||
isPinnedMessagesFeatureEnabled: Boolean = true,
|
||||
encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
||||
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
|
||||
): RoomDetailsPresenter {
|
||||
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
|
||||
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
|
||||
|
|
@ -92,6 +95,7 @@ class RoomDetailsPresenterTest {
|
|||
Presenter { aUserProfileState() }
|
||||
},
|
||||
encryptionService = encryptionService,
|
||||
clipboardHelper = clipboardHelper,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -106,6 +110,7 @@ class RoomDetailsPresenterTest {
|
|||
dispatchers = dispatchers,
|
||||
isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled },
|
||||
analyticsService = analyticsService,
|
||||
clipboardHelper = clipboardHelper,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import io.element.android.features.userprofile.api.UserProfileEvents
|
|||
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
|
|
@ -350,12 +352,14 @@ class RoomMemberDetailsPresenterTest {
|
|||
}
|
||||
},
|
||||
encryptionService: FakeEncryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(null) }),
|
||||
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
|
||||
): RoomMemberDetailsPresenter {
|
||||
return RoomMemberDetailsPresenter(
|
||||
roomMemberId = UserId("@alice:server.org"),
|
||||
room = room,
|
||||
userProfilePresenterFactory = userProfilePresenterFactory,
|
||||
encryptionService = encryptionService,
|
||||
clipboardHelper = clipboardHelper,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.features.logout.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.dateformatter.test)
|
||||
|
|
@ -72,7 +75,5 @@ dependencies {
|
|||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
testImplementation(projects.features.logout.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
|||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
|
||||
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
|
||||
open class RoomListContentStateProvider : PreviewParameterProvider<RoomListContentState> {
|
||||
override val values: Sequence<RoomListContentState>
|
||||
|
|
@ -29,10 +31,12 @@ internal fun aRoomsContentState(
|
|||
securityBannerState: SecurityBannerState = SecurityBannerState.None,
|
||||
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
|
||||
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
|
||||
seenRoomInvites: Set<RoomId> = emptySet(),
|
||||
) = RoomListContentState.Rooms(
|
||||
securityBannerState = securityBannerState,
|
||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
|
||||
summaries = summaries,
|
||||
seenRoomInvites = seenRoomInvites.toPersistentSet(),
|
||||
)
|
||||
|
||||
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.InviteData
|
||||
|
|
@ -50,7 +51,6 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
|||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomList
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
|
|
@ -58,6 +58,7 @@ import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
|||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -93,6 +94,7 @@ class RoomListPresenter @Inject constructor(
|
|||
private val logoutPresenter: Presenter<DirectLogoutState>,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||
private val seenInvitesStore: SeenInvitesStore,
|
||||
) : Presenter<RoomListState> {
|
||||
private val encryptionService: EncryptionService = client.encryptionService()
|
||||
|
||||
|
|
@ -101,7 +103,7 @@ class RoomListPresenter @Inject constructor(
|
|||
val coroutineScope = rememberCoroutineScope()
|
||||
val leaveRoomState = leaveRoomPresenter.present()
|
||||
val matrixUser = client.userProfile.collectAsState()
|
||||
val isOnline by syncService.isOnline().collectAsState()
|
||||
val isOnline by syncService.isOnline.collectAsState()
|
||||
val filtersState = filtersPresenter.present()
|
||||
val searchState = searchPresenter.present()
|
||||
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
|
||||
|
|
@ -232,6 +234,7 @@ class RoomListPresenter @Inject constructor(
|
|||
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
|
||||
}
|
||||
}
|
||||
val seenRoomInvites by remember { seenInvitesStore.seenRoomIds() }.collectAsState(emptySet())
|
||||
val securityBannerState by rememberSecurityBannerState(securityBannerDismissed)
|
||||
return when {
|
||||
showEmpty -> RoomListContentState.Empty(securityBannerState = securityBannerState)
|
||||
|
|
@ -240,7 +243,8 @@ class RoomListPresenter @Inject constructor(
|
|||
RoomListContentState.Rooms(
|
||||
securityBannerState = securityBannerState,
|
||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
||||
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList()
|
||||
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
|
||||
seenRoomInvites = seenRoomInvites.toPersistentSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermiss
|
|||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
@Immutable
|
||||
data class RoomListState(
|
||||
|
|
@ -66,9 +67,11 @@ sealed interface RoomListContentState {
|
|||
data class Empty(
|
||||
val securityBannerState: SecurityBannerState,
|
||||
) : RoomListContentState
|
||||
|
||||
data class Rooms(
|
||||
val securityBannerState: SecurityBannerState,
|
||||
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
|
||||
val summaries: ImmutableList<RoomListRoomSummary>,
|
||||
val seenRoomInvites: ImmutableSet<RoomId>,
|
||||
) : RoomListContentState
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
|||
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
|
|
@ -245,6 +246,8 @@ private fun RoomsViewList(
|
|||
RoomSummaryRow(
|
||||
room = room,
|
||||
hideInviteAvatars = hideInvitesAvatars,
|
||||
isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
|
||||
state.seenRoomInvites.contains(room.roomId),
|
||||
onClick = onRoomClick,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ internal val minHeight = 84.dp
|
|||
internal fun RoomSummaryRow(
|
||||
room: RoomListRoomSummary,
|
||||
hideInviteAvatars: Boolean,
|
||||
isInviteSeen: Boolean,
|
||||
onClick: (RoomListRoomSummary) -> Unit,
|
||||
eventSink: (RoomListEvents) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -87,7 +88,7 @@ internal fun RoomSummaryRow(
|
|||
Timber.d("Long click on invite room")
|
||||
},
|
||||
) {
|
||||
InviteNameAndIndicatorRow(name = room.name)
|
||||
InviteNameAndIndicatorRow(name = room.name, isInviteSeen = isInviteSeen)
|
||||
InviteSubtitle(isDm = room.isDm, inviteSender = room.inviteSender)
|
||||
if (!room.isDm && room.inviteSender != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
|
@ -305,6 +306,7 @@ private fun LastMessageAndIndicatorRow(
|
|||
@Composable
|
||||
private fun InviteNameAndIndicatorRow(
|
||||
name: String?,
|
||||
isInviteSeen: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -321,9 +323,11 @@ private fun InviteNameAndIndicatorRow(
|
|||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
UnreadIndicatorAtom(
|
||||
color = ElementTheme.colors.unreadIndicator
|
||||
)
|
||||
if (!isInviteSeen) {
|
||||
UnreadIndicatorAtom(
|
||||
color = ElementTheme.colors.unreadIndicator
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -390,6 +394,8 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider
|
|||
RoomSummaryRow(
|
||||
room = data,
|
||||
hideInviteAvatars = false,
|
||||
// Set isInviteSeen to true for the preview when the room has name "Bob"
|
||||
isInviteSeen = data.name == "Bob",
|
||||
onClick = {},
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,12 +39,10 @@ data class RoomListRoomSummary(
|
|||
) {
|
||||
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
|
||||
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
|
||||
isMarkedUnread ||
|
||||
displayType == RoomSummaryDisplayType.INVITE
|
||||
isMarkedUnread
|
||||
|
||||
val hasNewContent = numberOfUnreadMessages > 0 ||
|
||||
numberOfUnreadMentions > 0 ||
|
||||
numberOfUnreadNotifications > 0 ||
|
||||
isMarkedUnread ||
|
||||
displayType == RoomSummaryDisplayType.INVITE
|
||||
isMarkedUnread
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,8 @@ private fun RoomListSearchContent(
|
|||
RoomSummaryRow(
|
||||
room = room,
|
||||
hideInviteAvatars = hideInvitesAvatars,
|
||||
// TODO
|
||||
isInviteSeen = false,
|
||||
onClick = ::onRoomClick,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||
import io.element.android.features.invite.test.InMemorySeenInvitesStore
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.leaveroom.api.aLeaveRoomState
|
||||
|
|
@ -169,10 +171,11 @@ class RoomListPresenterTest {
|
|||
val matrixClient = FakeMatrixClient(
|
||||
roomListService = roomListService
|
||||
)
|
||||
val presenter = createRoomListPresenter(client = matrixClient)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val presenter = createRoomListPresenter(
|
||||
client = matrixClient,
|
||||
seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last()
|
||||
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
|
||||
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
|
||||
|
|
@ -194,6 +197,7 @@ class RoomListPresenterTest {
|
|||
timestamp = "0 TimeOrDate true",
|
||||
)
|
||||
)
|
||||
assertThat(withRoomsState.contentAsRooms().seenRoomInvites).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
|
@ -680,6 +684,7 @@ class RoomListPresenterTest {
|
|||
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
|
||||
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore()
|
||||
) = RoomListPresenter(
|
||||
client = client,
|
||||
syncService = syncService,
|
||||
|
|
@ -711,6 +716,7 @@ class RoomListPresenterTest {
|
|||
logoutPresenter = { aDirectLogoutState() },
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,12 +65,12 @@ class RoomListRoomSummaryTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `when display type is invite then isHighlighted and hasNewContent are true`() {
|
||||
fun `when display type is invite then isHighlighted and hasNewContent are false`() {
|
||||
val sut = createRoomListRoomSummary(
|
||||
displayType = RoomSummaryDisplayType.INVITE,
|
||||
)
|
||||
assertThat(sut.isHighlighted).isTrue()
|
||||
assertThat(sut.hasNewContent).isTrue()
|
||||
assertThat(sut.isHighlighted).isFalse()
|
||||
assertThat(sut.hasNewContent).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
|
|||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
|
||||
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.FakeMatrixClient
|
||||
|
|
@ -116,7 +117,8 @@ class SharePresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - send media ok`() = runTest {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
val sendFileResult =
|
||||
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ class SignedOutPresenter @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun present(): SignedOutState {
|
||||
val sessions by sessionStore.sessionsFlow().collectAsState(initial = emptyList())
|
||||
val sessions by remember {
|
||||
sessionStore.sessionsFlow()
|
||||
}.collectAsState(initial = emptyList())
|
||||
val signedOutSession by remember {
|
||||
derivedStateOf { sessions.firstOrNull { it.userId == sessionId } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@ android {
|
|||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ sealed interface UserProfileEvents {
|
|||
data object ClearBlockUserError : UserProfileEvents
|
||||
data object ClearConfirmationDialog : UserProfileEvents
|
||||
data object WithdrawVerification : UserProfileEvents
|
||||
data class CopyToClipboard(val text: String) : UserProfileEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.userprofile.api
|
|||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ data class UserProfileState(
|
|||
val isCurrentUser: Boolean,
|
||||
val dmRoomId: RoomId?,
|
||||
val canCall: Boolean,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val eventSink: (UserProfileEvents) -> Unit
|
||||
) {
|
||||
enum class ConfirmationDialog {
|
||||
|
|
|
|||
|
|
@ -120,8 +120,9 @@ class UserProfilePresenter @AssistedInject constructor(
|
|||
UserProfileEvents.ClearStartDMState -> {
|
||||
startDmActionState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
// Do nothing for withdrawing verification as it's handled by the RoomMemberDetailsPresenter if needed
|
||||
UserProfileEvents.WithdrawVerification -> Unit
|
||||
// Do nothing for other event as they are handled by the RoomMemberDetailsPresenter if needed
|
||||
UserProfileEvents.WithdrawVerification,
|
||||
is UserProfileEvents.CopyToClipboard -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,6 +137,7 @@ class UserProfilePresenter @AssistedInject constructor(
|
|||
isCurrentUser = isCurrentUser,
|
||||
dmRoomId = dmRoomId,
|
||||
canCall = canCall,
|
||||
snackbarMessage = null,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRow
|
|||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.modifiers.niceClickable
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
|
|
@ -48,6 +49,7 @@ fun UserProfileHeaderSection(
|
|||
userName: String?,
|
||||
verificationState: UserProfileVerificationState,
|
||||
openAvatarPreview: (url: String) -> Unit,
|
||||
onUserIdClick: () -> Unit,
|
||||
withdrawVerificationClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
@ -75,6 +77,7 @@ fun UserProfileHeaderSection(
|
|||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.niceClickable { onUserIdClick() },
|
||||
text = userId.value,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
|
|
@ -125,6 +128,7 @@ internal fun UserProfileHeaderSectionPreview() = ElementPreview {
|
|||
userName = "Alice",
|
||||
verificationState = UserProfileVerificationState.VERIFIED,
|
||||
openAvatarPreview = {},
|
||||
onUserIdClick = {},
|
||||
withdrawVerificationClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -138,6 +142,7 @@ internal fun UserProfileHeaderSectionWithVerificationViolationPreview() = Elemen
|
|||
userName = "Alice",
|
||||
verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION,
|
||||
openAvatarPreview = {},
|
||||
onUserIdClick = {},
|
||||
withdrawVerificationClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import io.element.android.features.userprofile.api.UserProfileState
|
|||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
|
|
@ -45,6 +46,7 @@ fun aUserProfileState(
|
|||
isCurrentUser: Boolean = false,
|
||||
dmRoomId: RoomId? = null,
|
||||
canCall: Boolean = false,
|
||||
snackbarMessage: SnackbarMessage? = null,
|
||||
eventSink: (UserProfileEvents) -> Unit = {},
|
||||
) = UserProfileState(
|
||||
userId = userId,
|
||||
|
|
@ -57,5 +59,6 @@ fun aUserProfileState(
|
|||
isCurrentUser = isCurrentUser,
|
||||
dmRoomId = dmRoomId,
|
||||
canCall = canCall,
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import io.element.android.libraries.designsystem.theme.components.ListItem
|
|||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
|
||||
|
|
@ -55,17 +57,19 @@ fun UserProfileView(
|
|||
onVerifyClick: (UserId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) })
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
UserProfileHeaderSection(
|
||||
avatarUrl = state.avatarUrl,
|
||||
|
|
@ -75,6 +79,9 @@ fun UserProfileView(
|
|||
openAvatarPreview = { avatarUrl ->
|
||||
openAvatarPreview(state.userName ?: state.userId.value, avatarUrl)
|
||||
},
|
||||
onUserIdClick = {
|
||||
state.eventSink(UserProfileEvents.CopyToClipboard(state.userId.value))
|
||||
},
|
||||
withdrawVerificationClick = { state.eventSink(UserProfileEvents.WithdrawVerification) },
|
||||
)
|
||||
UserProfileMainActionsSection(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue