mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge remote-tracking branch 'origin/develop' into feature/account-provider-selection
This commit is contained in:
+2
-2
@@ -299,10 +299,10 @@ private fun Content(
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the string does not contain any new line characters, which can happen when pasting values.
|
||||
* Ensure that the string does not contain any line separator, which can happen when pasting values.
|
||||
*/
|
||||
private fun String.sanitize(): String {
|
||||
return replace("\n", "")
|
||||
return filterNot { it == '\n' || it == '\r' }
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
||||
+11
-2
@@ -60,7 +60,13 @@ class LeaveRoomPresenter(
|
||||
roomId: RoomId,
|
||||
leaveAction: MutableState<AsyncAction<Unit>>,
|
||||
) = launch(dispatchers.io) {
|
||||
client.getRoom(roomId)?.use { room ->
|
||||
val room = client.getRoom(roomId)
|
||||
if (room == null) {
|
||||
Timber.e("Unable to find room $roomId to leave it")
|
||||
leaveAction.value = AsyncAction.Failure(RoomNotFoundException(roomId))
|
||||
return@launch
|
||||
}
|
||||
room.use {
|
||||
val roomInfo = room.roomInfoFlow.first()
|
||||
leaveAction.value = when {
|
||||
roomInfo.isDm -> Confirmation.Dm(roomId)
|
||||
@@ -78,7 +84,8 @@ class LeaveRoomPresenter(
|
||||
leaveAction: MutableState<AsyncAction<Unit>>,
|
||||
) = launch(dispatchers.io) {
|
||||
leaveAction.runCatchingUpdatingState {
|
||||
client.getRoom(roomId)!!.use { room ->
|
||||
val room = client.getRoom(roomId) ?: throw RoomNotFoundException(roomId)
|
||||
room.use {
|
||||
room
|
||||
.leave()
|
||||
.onSuccess { notificationConversationService.onLeftRoom(client.sessionId, roomId) }
|
||||
@@ -100,3 +107,5 @@ class LeaveRoomPresenter(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class RoomNotFoundException(roomId: RoomId) : IllegalStateException("Room $roomId not found")
|
||||
|
||||
+26
@@ -175,6 +175,32 @@ class LeaveBaseRoomPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show error if the room to confirm leaving is not found`() = runTest {
|
||||
val presenter = createLeaveRoomPresenter(client = FakeMatrixClient())
|
||||
presenter.stateFlow().test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
|
||||
val errorState = awaitItem()
|
||||
assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show error if the room to leave is not found`() = runTest {
|
||||
val presenter = createLeaveRoomPresenter(client = FakeMatrixClient())
|
||||
presenter.stateFlow().test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
|
||||
val progressState = awaitItem()
|
||||
assertThat(progressState.leaveAction).isEqualTo(AsyncAction.Loading)
|
||||
val errorState = awaitItem()
|
||||
assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - reset state after error`() = runTest {
|
||||
val presenter = createLeaveRoomPresenter(
|
||||
|
||||
+29
-3
@@ -39,6 +39,10 @@ class LinkNewDesktopHandler(
|
||||
LinkDesktopStep.Uninitialized
|
||||
)
|
||||
|
||||
/**
|
||||
* A [StateFlow] that emits the current step of the link desktop process.
|
||||
* It starts with [LinkDesktopStep.Uninitialized] and will emit other steps as they occur.
|
||||
*/
|
||||
val stepFlow: StateFlow<LinkDesktopStep>
|
||||
get() = linkDesktopStepFlow.asStateFlow()
|
||||
|
||||
@@ -53,15 +57,31 @@ class LinkNewDesktopHandler(
|
||||
timerJob = null
|
||||
}
|
||||
|
||||
fun createNewHandler() {
|
||||
resetJobs()
|
||||
handler = matrixClient.createLinkDesktopHandler().getOrNull()
|
||||
/**
|
||||
* Starts (or restarts) a timer that will emit an error in [stepFlow] after 2 minutes.
|
||||
*/
|
||||
fun startTimer() {
|
||||
timerJob?.cancel()
|
||||
timerJob = sessionScope.launch {
|
||||
delay(2.minutes)
|
||||
linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long.")))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new [LinkDesktopHandler] and cancels any existing job.
|
||||
* This should be called when the user wants to retry scanning a QR code.
|
||||
*/
|
||||
fun createNewHandler() {
|
||||
currentJob?.cancel()
|
||||
currentJob = null
|
||||
handler = matrixClient.createLinkDesktopHandler().getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the state of the handler and cancels any existing job.
|
||||
* This should be called when the user wants to start over.
|
||||
*/
|
||||
fun reset() {
|
||||
resetJobs()
|
||||
sessionScope.launch {
|
||||
@@ -69,6 +89,12 @@ class LinkNewDesktopHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the scanned QR code data.
|
||||
* This should be called when the user scans a QR code.
|
||||
*
|
||||
* @param data The scanned QR code data as a byte array.
|
||||
*/
|
||||
fun onScannedCode(data: ByteArray) {
|
||||
resetJobs()
|
||||
val currentHandler = handler
|
||||
|
||||
+8
@@ -263,6 +263,8 @@ class LinkNewDeviceFlowNode(
|
||||
}
|
||||
LinkDeviceType.Desktop -> {
|
||||
linkNewDesktopHandler.reset()
|
||||
// Ensure unlock state does not last longer than the timeout for scanning the QR code, so start the timer after unlock
|
||||
linkNewDesktopHandler.startTimer()
|
||||
backstack.push(NavTarget.DesktopNotice)
|
||||
}
|
||||
}
|
||||
@@ -278,10 +280,14 @@ class LinkNewDeviceFlowNode(
|
||||
NavTarget.DesktopNotice -> {
|
||||
val callback = object : DesktopNoticeNode.Callback {
|
||||
override fun navigateBack() {
|
||||
// Stop the timer when the user leaves the screen (user will have to authenticate again)
|
||||
linkNewDesktopHandler.reset()
|
||||
backstack.pop()
|
||||
}
|
||||
|
||||
override fun navigateToQrCodeScanner() {
|
||||
// Start again the timer when the user enters the next screen (they clicked on "Ready to scan")
|
||||
linkNewDesktopHandler.startTimer()
|
||||
backstack.push(NavTarget.DesktopScanQrCode)
|
||||
}
|
||||
}
|
||||
@@ -290,6 +296,8 @@ class LinkNewDeviceFlowNode(
|
||||
NavTarget.DesktopScanQrCode -> {
|
||||
val callback = object : ScanQrCodeNode.Callback {
|
||||
override fun cancel() {
|
||||
// start again the timer when the user goes back to the DesktopNoticeNode
|
||||
linkNewDesktopHandler.startTimer()
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -40,9 +40,19 @@ class LinkNewMobileHandler(
|
||||
LinkMobileStep.Uninitialized
|
||||
)
|
||||
|
||||
/**
|
||||
* A [StateFlow] that emits the current step of the link mobile process.
|
||||
* It starts with [LinkMobileStep.Uninitialized] and will emit other steps as they occur.
|
||||
*/
|
||||
val stepFlow: StateFlow<LinkMobileStep>
|
||||
get() = linkMobileStepFlow.asStateFlow()
|
||||
|
||||
/**
|
||||
* Creates a new [LinkMobileHandler] and starts the linking process.
|
||||
* If [forRotating] is true, it indicates that this is a rotation of the QR code.
|
||||
*
|
||||
* @param forRotating Whether this is a rotation of the QR code. Defaults to false.
|
||||
*/
|
||||
fun createAndStartNewHandler(forRotating: Boolean = false) {
|
||||
Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
|
||||
currentJob?.cancel()
|
||||
@@ -62,6 +72,10 @@ class LinkNewMobileHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the handler and cancels any ongoing job.
|
||||
* This will also emit [LinkMobileStep.Uninitialized] to the [stepFlow].
|
||||
*/
|
||||
fun reset() {
|
||||
currentJob?.cancel()
|
||||
currentJob = null
|
||||
@@ -70,10 +84,18 @@ class LinkNewMobileHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates the QR code by creating a new handler and starting the linking process.
|
||||
* This is typically called when a timeout occurs for the current QR code.
|
||||
*/
|
||||
fun rotateQrCode() {
|
||||
createAndStartNewHandler(forRotating = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the case where there are too many QR code rotations.
|
||||
* This will reset the handler and emit an error to the [stepFlow].
|
||||
*/
|
||||
fun onTooManyRotation() {
|
||||
reset()
|
||||
sessionScope.launch {
|
||||
|
||||
+5
-2
@@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||
import io.element.android.libraries.designsystem.modifiers.bringIntoViewOnImeVisible
|
||||
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -191,6 +192,7 @@ private fun LoginForm(
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewOnImeVisible()
|
||||
.onTabOrEnterKeyFocusNext(focusManager)
|
||||
.testTag(TestTags.loginEmailUsername)
|
||||
.semantics {
|
||||
@@ -243,6 +245,7 @@ private fun LoginForm(
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewOnImeVisible()
|
||||
.onTabOrEnterKeyFocusNext(focusManager)
|
||||
.testTag(TestTags.loginPassword)
|
||||
.semantics {
|
||||
@@ -274,10 +277,10 @@ private fun LoginForm(
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the string does not contain any new line characters, which can happen when pasting values.
|
||||
* Ensure that the string does not contain any line separator, which can happen when pasting values.
|
||||
*/
|
||||
private fun String.sanitize(): String {
|
||||
return replace("\n", "")
|
||||
return filterNot { it == '\n' || it == '\r' }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+30
@@ -81,6 +81,21 @@ class LoginPasswordViewTest : RobolectricTest() {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing login removes carriage returns the expected event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
|
||||
setLoginPasswordView(
|
||||
aLoginPasswordState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
val userNameHint = activity!!.getString(CommonStrings.common_username)
|
||||
onNodeWithText(userNameHint).performTextInput("a\r\nb")
|
||||
eventsRecorder.assertSingle(
|
||||
LoginPasswordEvents.SetLogin("ab")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing login invokes the expected event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
|
||||
@@ -112,6 +127,21 @@ class LoginPasswordViewTest : RobolectricTest() {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing password removes carriage returns the expected event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
|
||||
setLoginPasswordView(
|
||||
aLoginPasswordState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
val passwordHint = activity!!.getString(CommonStrings.common_password)
|
||||
onNodeWithText(passwordHint).performTextInput("a\r\nb")
|
||||
eventsRecorder.assertSingle(
|
||||
LoginPasswordEvents.SetPassword("ab")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reveal password makes the password visible`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false)
|
||||
|
||||
+11
@@ -86,6 +86,7 @@ import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -103,7 +104,9 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -459,6 +462,14 @@ class MessageComposerPresenter(
|
||||
|
||||
val mentionTriggerFlow = merge(mentionStartTrigger, mentionCompletionTrigger)
|
||||
|
||||
// Refresh the room members, which are only loaded on demand, when a mention starts
|
||||
launch {
|
||||
suggestionSearchTrigger
|
||||
.distinctUntilChangedBy { it?.type }
|
||||
.filter { it?.type == SuggestionType.Mention }
|
||||
.collect { room.updateMembers() }
|
||||
}
|
||||
|
||||
val roomAliasSuggestionsFlow = roomAliasSuggestionsDataSource
|
||||
.getAllRoomAliasSuggestions()
|
||||
.stateIn(this, SharingStarted.Lazily, emptyList())
|
||||
|
||||
+54
-2
@@ -1056,7 +1056,8 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = FakeRoomPermissions(
|
||||
canTriggerRoomNotification = true,
|
||||
)
|
||||
),
|
||||
updateMembersResult = {},
|
||||
),
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
).apply {
|
||||
@@ -1103,6 +1104,55 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - mention suggestions refresh the room members`() = runTest {
|
||||
val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
|
||||
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
|
||||
val updateMembersResult = lambdaRecorder<Unit> { }
|
||||
val room = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = FakeRoomPermissions(
|
||||
canTriggerRoomNotification = true,
|
||||
),
|
||||
updateMembersResult = updateMembersResult,
|
||||
),
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
).apply {
|
||||
givenRoomMembersState(
|
||||
RoomMembersState.Ready(
|
||||
persistentListOf(currentUser, bob),
|
||||
)
|
||||
)
|
||||
givenRoomInfo(aRoomInfo(isDirect = false))
|
||||
}
|
||||
val presenter = createPresenter(
|
||||
room = room,
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
getSuggestionsResult = { _, _ -> emptyList() },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.AtRoom, ResolvedSuggestion.Member(bob))
|
||||
updateMembersResult.assertions().isCalledOnce()
|
||||
|
||||
// Typing after the `@` must not refresh the members again
|
||||
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 1, SuggestionType.Mention, "b")))
|
||||
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 2, SuggestionType.Mention, "bo")))
|
||||
advanceUntilIdle()
|
||||
updateMembersResult.assertions().isCalledOnce()
|
||||
|
||||
// Starting a new mention refreshes them again
|
||||
initialState.eventSink(MessageComposerEvent.SuggestionReceived(null))
|
||||
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||
advanceUntilIdle()
|
||||
updateMembersResult.assertions().isCalledExactly(2)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - room mention suggestions no permission`() = runTest {
|
||||
val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
|
||||
@@ -1113,7 +1163,8 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = FakeRoomPermissions(
|
||||
canTriggerRoomNotification = false,
|
||||
)
|
||||
),
|
||||
updateMembersResult = {},
|
||||
),
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
).apply {
|
||||
@@ -1144,6 +1195,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
val room = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true),
|
||||
updateMembersResult = {},
|
||||
),
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
).apply {
|
||||
|
||||
Reference in New Issue
Block a user