Merge branch 'develop' into feature/fga/join_space

This commit is contained in:
Benoit Marty 2025-09-24 11:20:17 +02:00 committed by GitHub
commit f3f19ec476
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
319 changed files with 2828 additions and 1838 deletions

View file

@ -9,6 +9,7 @@
<locale android:name="el"/>
<locale android:name="en"/>
<locale android:name="en_US"/>
<locale android:name="eo"/>
<locale android:name="es"/>
<locale android:name="et"/>
<locale android:name="eu"/>

View file

@ -54,7 +54,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
@ -111,7 +110,7 @@ class RoomFlowNode(
data class JoinedRoom(val roomId: RoomId) : NavTarget
@Parcelize
data class Space(val spaceId: RoomId) : NavTarget
data class JoinedSpace(val spaceId: RoomId) : NavTarget
}
override fun onBuilt() {
@ -149,30 +148,28 @@ class RoomFlowNode(
.withPreviousValue()
combine(currentMembershipFlow, isSpaceFlow) { (previousMembership, membership), isSpace ->
Timber.d("Room membership: $membership")
when (membership) {
CurrentUserMembership.JOINED -> {
if (isSpace) {
backstack.newRoot(NavTarget.Space(spaceId = roomId))
} else {
backstack.newRoot(NavTarget.JoinedRoom(roomId))
}
if (membership == CurrentUserMembership.JOINED) {
if (isSpace) {
backstack.newRoot(NavTarget.JoinedSpace(spaceId = roomId))
} else {
backstack.newRoot(NavTarget.JoinedRoom(roomId))
}
else -> {
if (membership == CurrentUserMembership.LEFT && previousMembership == CurrentUserMembership.JOINED) {
// The user left the room in this device, remove the room from the backstack
if (!membershipUpdateFlow.first().isUserInRoom) {
navigateUp()
}
} else {
// Was invited or the room is not known, display the join room screen
backstack.newRoot(
NavTarget.JoinRoom(
roomId = roomId,
serverNames = serverNames,
trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite,
)
} else {
val leavingFromCurrentDevice =
membership == CurrentUserMembership.LEFT &&
previousMembership == CurrentUserMembership.JOINED &&
membershipUpdateFlow.replayCache.lastOrNull()?.isUserInRoom == false
if (leavingFromCurrentDevice) {
navigateUp()
} else {
backstack.newRoot(
NavTarget.JoinRoom(
roomId = roomId,
serverNames = serverNames,
trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite,
)
}
)
}
}
}.launchIn(lifecycleScope)
@ -214,7 +211,7 @@ class RoomFlowNode(
)
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
}
is NavTarget.Space -> {
is NavTarget.JoinedSpace -> {
val spaceCallback = plugins<SpaceEntryPoint.Callback>().single()
spaceEntryPoint.nodeBuilder(this, buildContext)
.inputs(SpaceEntryPoint.Inputs(roomId = navTarget.spaceId))

View file

@ -93,6 +93,8 @@ allprojects {
// Fix compilation warning for annotations
// See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details
freeCompilerArgs.add("-Xannotation-default-target=first-only")
// Opt-in to context receivers
freeCompilerArgs.add("-Xcontext-parameters")
}
}
}

View file

@ -41,5 +41,8 @@ data class WidgetMessage(
@SerialName("send_event")
SendEvent,
@SerialName("content_loaded")
ContentLoaded,
}
}

View file

@ -79,7 +79,7 @@ class CallScreenPresenter(
val urlState = remember { mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized) }
val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) }
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
var isJoinedCall by rememberSaveable { mutableStateOf(false) }
var isWidgetLoaded by rememberSaveable { mutableStateOf(false) }
var ignoreWebViewError by rememberSaveable { mutableStateOf(false) }
var webViewError by remember { mutableStateOf<String?>(null) }
val languageTag = languageTagProvider.provideLanguageTag()
@ -139,8 +139,8 @@ class CallScreenPresenter(
if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) {
if (parsedMessage.action == WidgetMessage.Action.Close) {
close(callWidgetDriver.value, navigator)
} else if (parsedMessage.action == WidgetMessage.Action.Join) {
isJoinedCall = true
} else if (parsedMessage.action == WidgetMessage.Action.ContentLoaded) {
isWidgetLoaded = true
}
}
}
@ -151,8 +151,8 @@ class CallScreenPresenter(
// Wait for the call to be joined, if it takes too long, we display an error
delay(10.seconds)
if (!isJoinedCall) {
Timber.w("The call took too long to be joined. Displaying an error before exiting.")
if (!isWidgetLoaded) {
Timber.w("The call took too long to load. Displaying an error before exiting.")
// This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
webViewError = ""
@ -165,10 +165,10 @@ class CallScreenPresenter(
is CallScreenEvents.Hangup -> {
val widgetId = callWidgetDriver.value?.id
val interceptor = messageInterceptor.value
if (widgetId != null && interceptor != null && isJoinedCall) {
if (widgetId != null && interceptor != null && isWidgetLoaded) {
// If the call was joined, we need to hang up first. Then the UI will be dismissed automatically.
sendHangupMessage(widgetId, interceptor)
isJoinedCall = false
isWidgetLoaded = false
coroutineScope.launch {
// Wait for a couple of seconds to receive the hangup message
@ -198,7 +198,7 @@ class CallScreenPresenter(
urlState = urlState.value,
webViewError = webViewError,
userAgent = userAgent,
isCallActive = isJoinedCall,
isCallActive = isWidgetLoaded,
isInWidgetMode = isInWidgetMode,
eventSink = { handleEvents(it) },
)

View file

@ -215,7 +215,7 @@ import kotlin.time.Duration.Companion.seconds
}
@Test
fun `present - a received 'joined' action makes the call to be active`() = runTest {
fun `present - a received 'content loaded' action makes the call to be active`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
@ -238,7 +238,7 @@ import kotlin.time.Duration.Companion.seconds
messageInterceptor.givenInterceptedMessage(
"""
{
"action":"io.element.join",
"action":"content_loaded",
"api":"fromWidget",
"widgetId":"1",
"requestId":"1"

View file

@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_change_permissions_administrators">"Gweinyddwyr yn unig"</string>
<string name="screen_room_change_permissions_ban_people">"Gwahardd pobl"</string>
<string name="screen_room_change_permissions_delete_messages">"Dileu negeseuon"</string>
<string name="screen_room_change_permissions_delete_messages">"Tynnu negeseuon"</string>
<string name="screen_room_change_permissions_everyone">"Pawb"</string>
<string name="screen_room_change_permissions_invite_people">"Gwahodd pobl a derbyn ceisiadau i ymuno"</string>
<string name="screen_room_change_permissions_member_moderation">"Cymedroli aelodau"</string>
@ -17,13 +17,17 @@
<string name="screen_room_change_role_administrators_title">"Golygu Gweinyddwyr"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych chi\'n hyrwyddo\'r defnyddiwr i gael yr un lefel pŵer â chi."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Ychwanegu Gweinyddwr?"</string>
<string name="screen_room_change_role_confirm_change_owners_description">"Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych yn trosglwyddo\'r berchnogaeth i\'r defnyddwyr a ddewiswyd. Unwaith y byddwch yn gadael bydd hyn yn barhaol."</string>
<string name="screen_room_change_role_confirm_change_owners_title">"Trosglwyddo perchnogaeth?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Gostwng"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Fyddwch chi ddim yn gallu dadwneud y newid hwn gan eich bod yn israddio eich hun, os mai chi yw\'r defnyddiwr breintiedig olaf yn yr ystafell bydd yn amhosibl adennill breintiau."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Israddio eich hun?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (Yn aros)"</string>
<string name="screen_room_change_role_invited_member_name_android">"Yn aros"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Mae gan weinyddwyr freintiau cymedrolwr yn awtomatig"</string>
<string name="screen_room_change_role_moderators_owner_section_footer">"Mae gan berchnogion freintiau gweinyddwr yn awtomatig."</string>
<string name="screen_room_change_role_moderators_title">"Golygu Cymedrolwyr"</string>
<string name="screen_room_change_role_owners_title">"Dewiswch Berchnogion"</string>
<string name="screen_room_change_role_section_administrators">"Gweinyddwyr"</string>
<string name="screen_room_change_role_section_moderators">"Cymedrolwyr"</string>
<string name="screen_room_change_role_section_users">"Aelodau"</string>
@ -48,15 +52,18 @@
<string name="screen_room_member_list_pending_header_title">"Dan ystyriaeth"</string>
<string name="screen_room_member_list_role_administrator">"Gweinyddwr"</string>
<string name="screen_room_member_list_role_moderator">"Cymedrolwr"</string>
<string name="screen_room_member_list_role_owner">"Perchennog"</string>
<string name="screen_room_member_list_room_members_header_title">"Aelodau\'r ystafell"</string>
<string name="screen_room_member_list_unbanning_user">"Dad-wahardd %1$s"</string>
<string name="screen_room_roles_and_permissions_admins">"Gweinyddwyr"</string>
<string name="screen_room_roles_and_permissions_admins_and_owners">"Gweinyddwyr a pherchnogion"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Newid fy rôl"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Israddio aelod"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Israddio cymedrolwr"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Cymedroli aelodau"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Negeseuon a chynnwys"</string>
<string name="screen_room_roles_and_permissions_moderators">"Cymedrolwyr"</string>
<string name="screen_room_roles_and_permissions_owners">"Perchnogion"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Caniatâd"</string>
<string name="screen_room_roles_and_permissions_reset">"Ailosod caniatâd"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Ar ôl i chi ailosod caniatâd, byddwch yn colli\'r gosodiadau cyfredol."</string>

View file

@ -16,12 +16,12 @@
<string name="screen_room_change_permissions_send_messages">"Viestien lähettäminen"</string>
<string name="screen_room_change_role_administrators_title">"Muokkaa ylläpitäjiä"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Et voi peruuttaa tätä toimenpidettä. Ylennät käyttäjän samalle oikeustasolle kuin sinä."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Lisää ylläpitäjä?"</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Lisätäänkö ylläpitäjä?"</string>
<string name="screen_room_change_role_confirm_change_owners_description">"Et voi kumota tätä toimintoa. Olet siirtämässä omistajuuden valituille käyttäjille. Kun poistut, muutos on pysyvä."</string>
<string name="screen_room_change_role_confirm_change_owners_title">"Siirretäänkö omistajuus?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Alenna"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Et voi perua tätä muutosta, koska olet alentamassa itseäsi. Jos olet viimeinen oikeutettu henkilö tässä huoneessa, oikeuksia ei voi enää saada takaisin."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Alenna itsesi?"</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Haluatko alentaa itsesi?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (Kutsuttu)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(Kutsuttu)"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Ylläpitäjillä on automaattisesti valvojan oikeudet"</string>
@ -32,7 +32,7 @@
<string name="screen_room_change_role_section_moderators">"Valvojat"</string>
<string name="screen_room_change_role_section_users">"Jäsenet"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Sinulla on tallentamattomia muutoksia"</string>
<string name="screen_room_change_role_unsaved_changes_title">"Tallenna muutokset?"</string>
<string name="screen_room_change_role_unsaved_changes_title">"Tallennetaanko muutokset?"</string>
<string name="screen_room_member_list_banned_empty">"Tässä huoneessa ei ole porttikieltoja"</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d henkilö"</item>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_list_item_3">"Delete your account information from our server."</string>
</resources>

View file

@ -15,9 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
interface FtueService {
/** The current state of the FTUE. */
val state: StateFlow<FtueState>
/** Reset the FTUE state. */
suspend fun reset()
}
/** The state of the FTUE. */

View file

@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -30,18 +29,16 @@ import io.element.android.features.ftue.impl.notifications.NotificationsOptInNod
import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
import io.element.android.features.ftue.impl.state.DefaultFtueService
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.state.InternalFtueState
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@ -49,9 +46,8 @@ import kotlinx.parcelize.Parcelize
class FtueFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val ftueState: DefaultFtueService,
private val defaultFtueService: DefaultFtueService,
private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService,
private val lockScreenEntryPoint: LockScreenEntryPoint,
) : BaseFlowNode<FtueFlowNode.NavTarget>(
backstack = BackStack(
@ -80,19 +76,11 @@ class FtueFlowNode(
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(onCreate = {
moveToNextStepIfNeeded()
})
analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope)
ftueState.isVerificationStatusKnown
.filter { it }
.onEach { moveToNextStepIfNeeded() }
defaultFtueService.ftueStepStateFlow
.filterIsInstance(InternalFtueState.Incomplete::class)
.onEach {
showStep(it.nextStep)
}
.launchIn(lifecycleScope)
}
@ -104,7 +92,7 @@ class FtueFlowNode(
is NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback {
override fun onDone() {
moveToNextStepIfNeeded()
defaultFtueService.onUserCompletedSessionVerification()
}
}
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
@ -112,7 +100,7 @@ class FtueFlowNode(
NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() {
moveToNextStepIfNeeded()
defaultFtueService.updateFtueStep()
}
}
createNode<NotificationsOptInNode>(buildContext, listOf(callback))
@ -123,7 +111,7 @@ class FtueFlowNode(
NavTarget.LockScreenSetup -> {
val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupDone() {
moveToNextStepIfNeeded()
defaultFtueService.updateFtueStep()
}
}
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
@ -133,8 +121,8 @@ class FtueFlowNode(
}
}
private fun moveToNextStepIfNeeded() = lifecycleScope.launch {
when (ftueState.getNextStep()) {
private fun showStep(ftueStep: FtueStep) {
when (ftueStep) {
FtueStep.WaitingForInitialState -> {
backstack.newRoot(NavTarget.Placeholder)
}
@ -150,7 +138,6 @@ class FtueFlowNode(
FtueStep.LockscreenSetup -> {
backstack.newRoot(NavTarget.LockScreenSetup)
}
null -> Unit
}
}

View file

@ -9,13 +9,13 @@ package io.element.android.features.ftue.impl.state
import android.Manifest
import android.os.Build
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@ -26,61 +26,70 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
@Inject
class DefaultFtueService(
private val sdkVersionProvider: BuildVersionSdkIntProvider,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService,
private val sessionVerificationService: SessionVerificationService,
private val sessionPreferencesStore: SessionPreferencesStore,
) : FtueService {
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
private val userNeedsToConfirmSessionVerificationSuccess = MutableStateFlow(false)
/**
* This flow emits true when the FTUE flow is ready to be displayed.
* In this case, the FTUE flow is ready when the session verification status is known.
*/
val isVerificationStatusKnown = sessionVerificationService.sessionVerifiedStatus
.map { it != SessionVerifiedStatus.Unknown }
.distinctUntilChanged()
val ftueStepStateFlow = MutableStateFlow<InternalFtueState>(InternalFtueState.Unknown)
override suspend fun reset() {
analyticsService.reset()
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS)
override val state = ftueStepStateFlow
.mapState {
when (it) {
is InternalFtueState.Unknown -> FtueState.Unknown
is InternalFtueState.Incomplete -> FtueState.Incomplete
is InternalFtueState.Complete -> FtueState.Complete
}
}
init {
combine(
sessionVerificationService.sessionVerifiedStatus.onEach { sessionVerifiedStatus ->
if (sessionVerifiedStatus == SessionVerifiedStatus.NotVerified) {
// Ensure we wait for the user to confirm the session verified screen before going further
userNeedsToConfirmSessionVerificationSuccess.value = true
}
},
userNeedsToConfirmSessionVerificationSuccess,
analyticsService.didAskUserConsentFlow.distinctUntilChanged(),
) {
updateFtueStep()
}
.launchIn(sessionCoroutineScope)
}
fun updateFtueStep() = sessionCoroutineScope.launch {
val step = getNextStep(null)
ftueStepStateFlow.value = when (step) {
null -> InternalFtueState.Complete
else -> InternalFtueState.Incomplete(step)
}
}
init {
sessionVerificationService.sessionVerifiedStatus
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
}
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
private suspend fun getNextStep(completedStep: FtueStep? = null): FtueStep? =
when (completedStep) {
null -> if (!isSessionVerificationStateReady()) {
FtueStep.WaitingForInitialState
} else {
getNextStep(FtueStep.WaitingForInitialState)
}
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) {
FtueStep.WaitingForInitialState -> if (isSessionNotVerified() || userNeedsToConfirmSessionVerificationSuccess.value) {
FtueStep.SessionVerification
} else {
getNextStep(FtueStep.SessionVerification)
@ -108,9 +117,6 @@ class DefaultFtueService(
}
private suspend fun isSessionNotVerified(): Boolean {
// Wait until the session verification status is known
isVerificationStatusKnown.filter { it }.first()
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
}
@ -137,14 +143,8 @@ class DefaultFtueService(
return lockScreenService.isSetupRequired().first()
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal suspend fun updateState() {
val nextStep = getNextStep()
state.value = when {
// Final state, there aren't any more next steps
nextStep == null -> FtueState.Complete
else -> FtueState.Incomplete
}
fun onUserCompletedSessionVerification() {
userNeedsToConfirmSessionVerificationSuccess.value = false
}
}

View file

@ -0,0 +1,18 @@
/*
* 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.ftue.impl.state
sealed interface InternalFtueState {
data object Unknown : InternalFtueState
data class Incomplete(
val nextStep: FtueStep,
) : InternalFtueState
data object Complete : InternalFtueState
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new backup password"</string>
<string name="screen_identity_confirmation_subtitle">"Confirm this device to set up secure messaging."</string>
<string name="screen_identity_confirmation_title">"Confirm it\'s you"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Use backup password"</string>
<string name="screen_identity_confirmed_title">"Device confirmed"</string>
<string name="screen_session_verification_enter_recovery_key">"Enter backup password"</string>
</resources>

View file

@ -14,7 +14,6 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
@ -36,8 +35,7 @@ class DefaultFtueEntryPointTest {
buildContext = buildContext,
plugins = plugins,
analyticsEntryPoint = { _, _ -> lambdaError() },
ftueState = createDefaultFtueService(),
analyticsService = FakeAnalyticsService(),
defaultFtueService = createDefaultFtueService(),
lockScreenEntryPoint = object : LockScreenEntryPoint {
override fun nodeBuilder(
parentNode: com.bumble.appyx.core.node.Node,

View file

@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.state.DefaultFtueService
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.state.InternalFtueState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@ -26,8 +27,6 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.noop.NoopAnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -69,9 +68,11 @@ class DefaultFtueServiceTest {
analyticsService.setDidAskUserConsent()
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
service.updateState()
assertThat(service.state.value).isEqualTo(FtueState.Complete)
service.updateFtueStep()
service.state.test {
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
}
}
@Test
@ -90,9 +91,11 @@ class DefaultFtueServiceTest {
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
service.updateState()
assertThat(service.state.value).isEqualTo(FtueState.Complete)
service.updateFtueStep()
service.state.test {
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
}
}
@Test
@ -109,35 +112,30 @@ class DefaultFtueServiceTest {
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
val steps = mutableListOf<FtueStep?>()
// Session verification
steps.add(service.getNextStep(steps.lastOrNull()))
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
// Notifications opt in
steps.add(service.getNextStep(steps.lastOrNull()))
permissionStateProvider.setPermissionGranted()
// Entering PIN code
steps.add(service.getNextStep(steps.lastOrNull()))
lockScreenService.setIsPinSetup(true)
// Analytics opt in
steps.add(service.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent()
// Final step (null)
steps.add(service.getNextStep(steps.lastOrNull()))
assertThat(steps).containsExactly(
FtueStep.SessionVerification,
FtueStep.NotificationsOptIn,
FtueStep.LockscreenSetup,
FtueStep.AnalyticsOptIn,
// Final state
null,
)
service.ftueStepStateFlow.test {
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
// Session verification
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.SessionVerification))
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
// User completes verification
service.onUserCompletedSessionVerification()
// Notifications opt in
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.NotificationsOptIn))
permissionStateProvider.setPermissionGranted()
// Simulate event from NotificationsOptInNode.Callback.onNotificationsOptInFinished
service.updateFtueStep()
// Entering PIN code
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.LockscreenSetup))
lockScreenService.setIsPinSetup(true)
// Simulate event from LockScreenEntryPoint.Callback.onSetupDone()
service.updateFtueStep()
// Analytics opt in
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
analyticsService.setDidAskUserConsent()
// Final step
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
}
}
@Test
@ -158,10 +156,13 @@ class DefaultFtueServiceTest {
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent()
assertThat(service.getNextStep(null)).isNull()
service.ftueStepStateFlow.test {
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
// Analytics opt in
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
analyticsService.setDidAskUserConsent()
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
}
}
@Test
@ -180,51 +181,13 @@ class DefaultFtueServiceTest {
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
lockScreenService.setIsPinSetup(true)
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent()
assertThat(service.getNextStep(null)).isNull()
}
@Test
fun `reset do the expected actions S`() = runTest {
val resetAnalyticsLambda = lambdaRecorder<Unit> { }
val analyticsService = FakeAnalyticsService(
resetLambda = resetAnalyticsLambda
)
val resetPermissionLambda = lambdaRecorder<String, Unit> { }
val permissionStateProvider = FakePermissionStateProvider(
resetPermissionLambda = resetPermissionLambda
)
val service = createDefaultFtueService(
sdkIntVersion = Build.VERSION_CODES.S,
permissionStateProvider = permissionStateProvider,
analyticsService = analyticsService,
)
service.reset()
resetAnalyticsLambda.assertions().isCalledOnce()
resetPermissionLambda.assertions().isNeverCalled()
}
@Test
fun `reset do the expected actions TIRAMISU`() = runTest {
val resetLambda = lambdaRecorder<Unit> { }
val analyticsService = FakeAnalyticsService(
resetLambda = resetLambda
)
val resetPermissionLambda = lambdaRecorder<String, Unit> { }
val permissionStateProvider = FakePermissionStateProvider(
resetPermissionLambda = resetPermissionLambda
)
val service = createDefaultFtueService(
sdkIntVersion = Build.VERSION_CODES.TIRAMISU,
permissionStateProvider = permissionStateProvider,
analyticsService = analyticsService,
)
service.reset()
resetLambda.assertions().isCalledOnce()
resetPermissionLambda.assertions().isCalledOnce()
.with(value("android.permission.POST_NOTIFICATIONS"))
service.ftueStepStateFlow.test {
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
// Analytics opt in
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
analyticsService.setDidAskUserConsent()
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
}
}
}

View file

@ -9,18 +9,11 @@ package io.element.android.features.ftue.test
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow
class FakeFtueService(
private val resetLambda: () -> Unit = { lambdaError() },
) : FtueService {
class FakeFtueService : FtueService {
override val state: MutableStateFlow<FtueState> = MutableStateFlow(FtueState.Unknown)
override suspend fun reset() {
resetLambda()
}
suspend fun emitState(newState: FtueState) {
state.emit(newState)
}

View file

@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@Inject
@ -102,13 +103,44 @@ class RoomListDataSource(
}
private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) {
// Used to detect duplicates in the room list summaries - see comment below
data class CacheResult(val index: Int, val fromCache: Boolean)
val cachingResults = mutableMapOf<RoomId, MutableList<CacheResult>>()
val roomListRoomSummaries = diffCache.indices().mapNotNull { index ->
if (useCache) {
diffCache.get(index) ?: buildAndCacheItem(roomSummaries, index)
diffCache.get(index)?.let { cachedItem ->
// Add the cached item to the caching results
val pairs = cachingResults.getOrDefault(cachedItem.roomId, mutableListOf())
pairs.add(CacheResult(index, fromCache = true))
cachingResults[cachedItem.roomId] = pairs
cachedItem
} ?: run {
roomSummaries.getOrNull(index)?.roomId?.let {
// Add the non-cached item to the caching results
val pairs = cachingResults.getOrDefault(it, mutableListOf())
pairs.add(CacheResult(index, fromCache = false))
cachingResults[it] = pairs
}
buildAndCacheItem(roomSummaries, index)
}
} else {
roomSummaries.getOrNull(index)?.roomId?.let {
// Add the non-cached item to the caching results
val pairs = cachingResults.getOrDefault(it, mutableListOf())
pairs.add(CacheResult(index, fromCache = false))
cachingResults[it] = pairs
}
buildAndCacheItem(roomSummaries, index)
}
}
// TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed
val duplicates = cachingResults.filter { (_, operations) -> operations.size > 1 }
if (duplicates.isNotEmpty()) {
Timber.e("Found duplicates in room summaries after an UI update: $duplicates. This could be a race condition/caching issue of some kind")
}
_allRooms.emit(roomListRoomSummaries.toImmutableList())
}

View file

@ -13,6 +13,7 @@
<string name="full_screen_intent_banner_message">"Er mwyn sicrhau fyddwch chi ddim yn colli galwad bwysig, newidiwch eich gosodiadau i ganiatáu hysbysiadau sgrin lawn pan fydd eich ffôn wedi\'i gloi."</string>
<string name="full_screen_intent_banner_title">"Gwella profiad eich galwadau"</string>
<string name="screen_home_tab_chats">"Sgyrsiau"</string>
<string name="screen_home_tab_spaces">"Gofodau"</string>
<string name="screen_invites_decline_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Gwrthod y gwahoddiad"</string>
<string name="screen_invites_decline_direct_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y sgwrs breifat hon gyda %1$s?"</string>
@ -32,6 +33,7 @@ Am y tro, gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"</strin
<string name="screen_roomlist_filter_invites">"Gwahoddiadau"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Does gennych chi ddim gwahoddiadau yn aros."</string>
<string name="screen_roomlist_filter_low_priority">"Blaenoriaeth Isel"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Does gennych chi ddim sgyrsiau blaenoriaeth isel eto"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Does gennych chi ddim sgyrsiau ar gyfer y dewis hwn"</string>
<string name="screen_roomlist_filter_people">"Pobl"</string>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Restore your account security and message history with a backup password if you have lost all your existing devices."</string>
<string name="banner_set_up_recovery_submit">"Set up backup"</string>
<string name="banner_set_up_recovery_title">"Set up backup to protect your account"</string>
<string name="confirm_recovery_key_banner_message">"Confirm your backup password to maintain access to your message backup and message history."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Enter your backup password"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Forgot your backup password?"</string>
<string name="confirm_recovery_key_banner_title">"Your message backup is out of sync"</string>
<string name="session_verification_banner_message">"Looks like you\'re using a new device. Confirm it with another connected device to access your encrypted messages."</string>
</resources>

View file

@ -33,6 +33,7 @@ Toistaiseksi voit poistaa suodattimien valinnan, jotta näet muut keskustelut."<
<string name="screen_roomlist_filter_invites">"Kutsut"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Sinulla ei ole yhtään odottavaa kutsua."</string>
<string name="screen_roomlist_filter_low_priority">"Matala prioriteetti"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Sinulla ei ole vielä yhtään matalan prioriteetin keskustelua"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi."</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</string>
<string name="screen_roomlist_filter_people">"Ihmiset"</string>

View file

@ -13,6 +13,7 @@
<string name="full_screen_intent_banner_message">"For å sikre at du aldri går glipp av en viktig samtale, må du endre innstillingene dine for å tillate fullskjermvarsler når telefonen er låst."</string>
<string name="full_screen_intent_banner_title">"Forbedre samtaleopplevelsen din"</string>
<string name="screen_home_tab_chats">"Chatter"</string>
<string name="screen_home_tab_spaces">"Områder"</string>
<string name="screen_invites_decline_chat_message">"Er du sikker på at du vil takke nei til invitasjonen til å bli med i %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Avvis invitasjon"</string>
<string name="screen_invites_decline_direct_chat_message">"Er du sikker på at du vil avslå denne private chatten med %1$s?"</string>

View file

@ -33,6 +33,7 @@
<string name="screen_roomlist_filter_invites">"邀請"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"您沒有任何擱置中的邀請。"</string>
<string name="screen_roomlist_filter_low_priority">"低優先度"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"您尚無任何低優先程度聊天"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"您可以取消選取篩選條件以檢視其他聊天"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"您並無此選擇的聊天"</string>
<string name="screen_roomlist_filter_people">"夥伴"</string>

View file

@ -18,6 +18,7 @@
<string name="screen_join_room_join_action">"Ymuno â\'r ystafell"</string>
<string name="screen_join_room_join_restricted_message">"Efallai y bydd angen i chi gael eich gwahodd neu fod yn aelod o ofod er mwyn ymuno."</string>
<string name="screen_join_room_knock_action">"Anfon cais i ymuno"</string>
<string name="screen_join_room_knock_message_characters_count">"Nodau a ganiateir %1$d o %2$d"</string>
<string name="screen_join_room_knock_message_description">"Neges (dewisol)"</string>
<string name="screen_join_room_knock_sent_description">"Byddwch yn derbyn gwahoddiad i ymuno â\'r ystafell os caiff eich cais ei dderbyn."</string>
<string name="screen_join_room_knock_sent_title">"Anfonwyd y cais i ymuno"</string>

View file

@ -3,5 +3,8 @@
<string name="leave_conversation_alert_subtitle">"Ydych chi\'n siŵr eich bod am adael y sgwrs hon? Dyw\'r sgwrs hon ddim yn gyhoeddus a fyddwch chi ddim yn gallu ailymuno heb wahoddiad."</string>
<string name="leave_room_alert_empty_subtitle">"Ydych chi\'n siŵr eich bod am adael yr ystafell hon? Chi yw\'r unig berson yma. Os byddwch yn gadael, fydd neb yn gallu ymuno yn y dyfodol, gan gynnwys chi."</string>
<string name="leave_room_alert_private_subtitle">"Ydych chi\'n siŵr eich bod am adael yr ystafell hon? Dyw\'r ystafell hon ddim yn gyhoeddus a fyddwch chi ddim yn gallu ailymuno heb wahoddiad."</string>
<string name="leave_room_alert_select_new_owner_action">"Dewiswch Berchnogion"</string>
<string name="leave_room_alert_select_new_owner_subtitle">"Chi yw unig berchennog yr ystafell hon. Mae angen i chi drosglwyddo perchnogaeth i rywun arall cyn i chi adael yr room."</string>
<string name="leave_room_alert_select_new_owner_title">"Trosglwyddo perchnogaeth"</string>
<string name="leave_room_alert_subtitle">"Ydych chi\'n siŵr eich bod am adael yr ystafell?"</string>
</resources>

View file

@ -9,7 +9,7 @@
<string name="screen_app_lock_settings_enable_biometric_unlock">"Salli biometrinen tunnistus"</string>
<string name="screen_app_lock_settings_remove_pin">"Poista PIN-koodi"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Haluatko varmasti poistaa PIN-koodin?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Poista PIN-koodi?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Poistetaanko PIN-koodi?"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Salli %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"Käytän mieluummin PIN-koodia"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Säästä aikaa ja ota käyttöön %1$s"</string>

View file

@ -13,6 +13,9 @@
<string name="screen_change_account_provider_other">"Arall"</string>
<string name="screen_change_account_provider_subtitle">"Defnyddiwch ddarparwr cyfrif gwahanol, fel eich gweinydd preifat eich hun neu gyfrif gwaith."</string>
<string name="screen_change_account_provider_title">"Newid darparwr cyfrif"</string>
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"Mae angen yr ap Element Pro ar %1$s. Llwythwch ef o\'r siop."</string>
<string name="screen_change_server_error_element_pro_required_title">"Mae angen Element Pro"</string>
<string name="screen_change_server_error_invalid_homeserver">"Doedd dim modd i ni gyrraedd y gweinydd cartref hwn. Gwiriwch eich bod wedi rhoi URL y gweinydd cartref yn gywir. Os yw\'r URL yn gywir, cysylltwch â gweinyddwr eich gweinydd cartref am ragor o help."</string>
<string name="screen_change_server_error_invalid_well_known">"Dyw cydweddu llithrig ddim ar gael oherwydd problem yn y ffeil .well-known:
%1$s"</string>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_qr_code_login_connection_note_secure_state_description">"A secure connection could not be made to the new device. Your existing connected devices are still safe and you don\'t need to worry about them."</string>
</resources>

View file

@ -13,6 +13,7 @@
<string name="screen_change_account_provider_other">"Annet"</string>
<string name="screen_change_account_provider_subtitle">"Bruk en annen kontotilbyder, for eksempel din egen private server eller en arbeidskonto."</string>
<string name="screen_change_account_provider_title">"Bytt kontotilbyder"</string>
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"Element Pro-appen er nødvendig på %1$s. Last den ned fra butikken."</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro kreves"</string>
<string name="screen_change_server_error_invalid_homeserver">"Vi kunne ikke nå denne hjemmeserveren. Kontroller at du har skrevet inn hjemmeserverens URL riktig. Hvis URL-en er riktig, kontakt administratoren for hjemmeserveren din for å få mer hjelp."</string>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_key_backup_offline_subtitle">"Your messages were still being backed up when you went offline. Reconnect so that your messages can be backed up before signing out."</string>
<string name="screen_signout_key_backup_offline_title">"Your messages are still being backed up"</string>
<string name="screen_signout_key_backup_ongoing_title">"Your messages are still being backed up"</string>
<string name="screen_signout_recovery_disabled_title">"Backup not set up"</string>
<string name="screen_signout_save_recovery_key_title">"Have you saved your backup password?"</string>
</resources>

View file

@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
@ -328,7 +329,10 @@ class MessagesPresenter(
val displayThreads = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
if (displayThreads) {
// Get either the thread id this event is in, or the event id if it's not in a thread so we can start one
val threadId = targetEvent.threadInfo.threadRootId ?: targetEvent.eventId!!.toThreadId()
val threadId = when (targetEvent.threadInfo) {
is TimelineItemThreadInfo.ThreadResponse -> targetEvent.threadInfo.threadRootId
is TimelineItemThreadInfo.ThreadRoot, null -> targetEvent.eventId?.toThreadId()
} ?: return@launch
navigator.onOpenThread(threadId, null)
} else {
handleActionReply(targetEvent, composerState, timelineProtectionState)

View file

@ -87,6 +87,7 @@ import io.element.android.libraries.designsystem.components.ExpandableBottomShee
import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayoutState
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.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
@ -95,6 +96,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
@ -111,10 +113,14 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
@ -202,7 +208,13 @@ fun MessagesView(
Column {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
if (state.timelineState.timelineMode is Timeline.Mode.Thread) {
ThreadTopBar(onBackClick = onBackClick)
ThreadTopBar(
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
heroes = state.heroes,
isTombstoned = state.isTombstoned,
onBackClick = onBackClick,
)
} else {
MessagesViewTopBar(
roomName = state.roomName,
@ -573,14 +585,48 @@ private fun MessagesViewTopBar(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ThreadTopBar(
roomName: String?,
roomAvatarData: AvatarData,
heroes: ImmutableList<AvatarData>,
isTombstoned: Boolean,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
navigationIcon = {
BackButton(onClick = onBackClick)
},
title = {
Text(stringResource(CommonStrings.common_thread))
Row(verticalAlignment = Alignment.CenterVertically) {
Avatar(
avatarData = roomAvatarData,
avatarType = AvatarType.Room(
heroes = heroes,
isTombstoned = isTombstoned,
),
)
Column(
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 8.dp)
.semantics {
heading()
},
) {
Text(
text = stringResource(CommonStrings.common_thread),
style = ElementTheme.typography.fontBodyLgMedium,
)
Text(
text = roomName ?: stringResource(CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodySmRegular,
fontStyle = FontStyle.Italic.takeIf { roomName == null },
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
)
}
@ -673,3 +719,58 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
knockRequestsBannerView = {},
)
}
@PreviewsDayNight
@Composable
internal fun ThreadTopBarPreview() {
ElementPreview {
val name = "Room name"
val initialsAvatarData = AvatarData(
id = "id",
name = name,
url = null,
size = AvatarSize.TimelineRoom,
)
Column {
ThreadTopBar(
roomName = name,
roomAvatarData = initialsAvatarData,
heroes = persistentListOf(),
isTombstoned = false,
onBackClick = {},
)
HorizontalDivider()
ThreadTopBar(
roomName = name,
roomAvatarData = initialsAvatarData,
heroes = aMatrixUserList().map { it.getAvatarData(AvatarSize.TimelineRoom) }.toImmutableList(),
isTombstoned = false,
onBackClick = {},
)
HorizontalDivider()
ThreadTopBar(
roomName = null,
roomAvatarData = initialsAvatarData,
heroes = persistentListOf(),
isTombstoned = false,
onBackClick = {},
)
HorizontalDivider()
ThreadTopBar(
roomName = name,
roomAvatarData = initialsAvatarData.copy(url = "https://some-avatar.jpg"),
heroes = persistentListOf(),
isTombstoned = false,
onBackClick = {},
)
HorizontalDivider()
ThreadTopBar(
roomName = name,
roomAvatarData = initialsAvatarData,
heroes = persistentListOf(),
isTombstoned = true,
onBackClick = {},
)
}
}
}

View file

@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
@ -174,7 +175,7 @@ class DefaultActionListPresenter(
add(TimelineItemAction.ReplyInThread)
add(TimelineItemAction.Reply)
} else {
if (!isThreadsEnabled && timelineItem.threadInfo.threadRootId != null) {
if (!isThreadsEnabled && timelineItem.threadInfo is TimelineItemThreadInfo.ThreadResponse) {
// If threads are not enabled, we can reply in a thread if the item is already in the thread
add(TimelineItemAction.ReplyInThread)
} else {

View file

@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
@ -32,7 +33,6 @@ import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -146,7 +146,7 @@ internal fun aTimelineItemEvent(
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState? = null,
inReplyTo: InReplyToDetails? = null,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo: TimelineItemThreadInfo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),

View file

@ -53,8 +53,6 @@ import io.element.android.libraries.ui.utils.time.isTalkbackActive
private val BUBBLE_RADIUS = 12.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 78% now.
private const val BUBBLE_WIDTH_RATIO = 0.78f
private val MIN_BUBBLE_WIDTH = 80.dp
@Composable
@ -66,34 +64,6 @@ fun MessageEventBubble(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit = {},
) {
fun bubbleShape(): Shape {
val topLeftCorner = if (state.cutTopStart) 0.dp else BUBBLE_RADIUS
return when (state.groupPosition) {
TimelineItemGroupPosition.First -> if (state.isMine) {
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS)
} else {
RoundedCornerShape(topLeftCorner, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
}
TimelineItemGroupPosition.Middle -> if (state.isMine) {
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS)
} else {
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
}
TimelineItemGroupPosition.Last -> if (state.isMine) {
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS)
} else {
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS)
}
TimelineItemGroupPosition.None ->
RoundedCornerShape(
topLeftCorner,
BUBBLE_RADIUS,
BUBBLE_RADIUS,
BUBBLE_RADIUS
)
}
}
val clickableModifier = if (isTalkbackActive()) {
Modifier
} else {
@ -108,11 +78,8 @@ fun MessageEventBubble(
}
// Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = when {
state.isMine -> ElementTheme.colors.messageFromMeBackground
else -> ElementTheme.colors.messageFromOtherBackground
}
val bubbleShape = bubbleShape()
val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine)
val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
@ -147,7 +114,7 @@ fun MessageEventBubble(
.testTag(TestTags.messageBubble)
.widthIn(
min = MIN_BUBBLE_WIDTH,
max = (constraints.maxWidth * BUBBLE_WIDTH_RATIO)
max = (constraints.maxWidth * MessageEventBubbleDefaults.BUBBLE_WIDTH_RATIO)
.toInt()
.toDp()
)
@ -157,6 +124,48 @@ fun MessageEventBubble(
}
}
object MessageEventBubbleDefaults {
fun shape(cutTopStart: Boolean, groupPosition: TimelineItemGroupPosition, isMine: Boolean): Shape {
val topLeftCorner = if (cutTopStart) 0.dp else BUBBLE_RADIUS
return when (groupPosition) {
TimelineItemGroupPosition.First -> if (isMine) {
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS)
} else {
RoundedCornerShape(topLeftCorner, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
}
TimelineItemGroupPosition.Middle -> if (isMine) {
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS)
} else {
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
}
TimelineItemGroupPosition.Last -> if (isMine) {
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS)
} else {
RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS)
}
TimelineItemGroupPosition.None ->
RoundedCornerShape(
topLeftCorner,
BUBBLE_RADIUS,
BUBBLE_RADIUS,
BUBBLE_RADIUS
)
}
}
@Composable
fun backgroundBubbleColor(isMine: Boolean): Color {
return if (isMine) {
ElementTheme.colors.messageFromMeBackground
} else {
ElementTheme.colors.messageFromOtherBackground
}
}
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 78% now.
const val BUBBLE_WIDTH_RATIO = 0.78f
}
@PreviewsDayNight
@Composable
internal fun MessageEventBubblePreview(@PreviewParameter(BubbleStateProvider::class) state: BubbleState) = ElementPreview {

View file

@ -15,6 +15,7 @@ import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -23,6 +24,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@ -34,6 +37,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.pluralStringResource
@ -43,6 +47,7 @@ import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@ -61,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@ -78,25 +84,28 @@ import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn
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.components.avatar.AvatarType
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.swipe.SwipeableActionsState
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toThreadId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@ -256,22 +265,22 @@ fun TimelineItemEventRow(
)
}
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread) {
event.threadInfo.threadSummary?.let { threadSummary ->
val threadPart = stringResource(CommonStrings.common_thread)
val numberOfReplies = threadSummary.numberOfReplies.toInt().let { replies ->
pluralStringResource(CommonPlurals.common_replies, replies, replies)
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) {
ThreadSummaryView(
modifier = if (event.isMine) {
Modifier.align(Alignment.End).padding(end = 16.dp)
} else {
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
}.padding(top = 2.dp),
threadSummary = event.threadInfo.summary,
latestEventText = event.threadInfo.latestEventText,
isOutgoing = event.isMine,
onClick = {
event.eventId?.let {
eventSink(TimelineEvents.OpenThread(it.toThreadId(), null))
}
}
Button(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 2.dp)
.align(if (event.isMine) Alignment.End else Alignment.Start),
text = "$threadPart - $numberOfReplies",
size = ButtonSize.Small,
onClick = {
eventSink(TimelineEvents.OpenThread(event.eventId!!.toThreadId(), null))
},
)
}
)
}
// Read receipts / Send state
@ -288,6 +297,81 @@ fun TimelineItemEventRow(
}
}
@Composable
private fun ThreadSummaryView(
threadSummary: ThreadSummary,
latestEventText: String?,
isOutgoing: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier = modifier) {
Row(
modifier = Modifier
.then(if (!isOutgoing) Modifier.padding(start = 16.dp) else Modifier)
.graphicsLayer {
shape = RoundedCornerShape(8.dp)
clip = true
}
.background(MessageEventBubbleDefaults.backgroundBubbleColor(isOutgoing))
.niceClickable(onClick)
.padding(horizontal = 12.dp, vertical = 10.dp)
.widthIn(max = (maxWidth - 24.dp) * MessageEventBubbleDefaults.BUBBLE_WIDTH_RATIO),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.ThreadsSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = pluralStringResource(CommonPlurals.common_replies, threadSummary.numberOfReplies.toInt(), threadSummary.numberOfReplies),
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textSecondary,
)
Spacer(modifier = Modifier.width(8.dp))
threadSummary.latestEvent.dataOrNull()?.let { latestEvent ->
val avatarData = AvatarData(
id = latestEvent.senderId.value,
name = latestEvent.senderProfile.getDisplayName(),
url = latestEvent.senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineThreadLatestEventSender,
)
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId),
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(4.dp))
latestEventText?.let {
Text(
text = it,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
}
}
}
}
/**
* Impact ViewConfiguration.touchSlop by [sensitivityFactor].
* Inspired from https://issuetracker.google.com/u/1/issues/269627294.
@ -694,7 +778,7 @@ private fun MessageEventBubbleContent(
else -> ContentPadding.Textual
}
CommonLayout(
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo.threadRootId != null,
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse,
timestampPosition = timestampPosition,
paddingBehaviour = paddingBehaviour,
inReplyToDetails = event.inReplyTo,
@ -746,9 +830,27 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
" hopefully can be manually adjusted to test different behaviors."
),
groupPosition = TimelineItemGroupPosition.First,
threadInfo = EventThreadInfo(
threadRootId = ThreadId("\$thread-root-id"),
threadSummary = ThreadSummary(AsyncData.Uninitialized, numberOfReplies = 20L)
threadInfo = TimelineItemThreadInfo.ThreadRoot(
latestEventText = "This is the latest message in the thread",
summary = ThreadSummary(AsyncData.Success(
EmbeddedEventInfo(
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
content = MessageContent(
body = "This is the latest message in the thread",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("This is the latest message in the thread", null)
),
senderId = UserId("@user:id"),
senderProfile = ProfileTimelineDetails.Ready(
displayName = "Alice",
avatarUrl = null,
displayNameAmbiguous = false,
),
timestamp = 0L,
)
), numberOfReplies = 20L)
)
),
displayThreadSummaries = true,
@ -756,3 +858,40 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
}
}
}
@PreviewsDayNight
@Composable
internal fun ThreadSummaryViewPreview() {
ElementPreview {
val body = "This is the latest message in the thread"
val threadSummary = ThreadSummary(
AsyncData.Success(
EmbeddedEventInfo(
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
content = MessageContent(
body = body,
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType(body, null)
),
senderId = UserId("@user:id"),
senderProfile = ProfileTimelineDetails.Ready(
displayName = "Alice",
avatarUrl = null,
displayNameAmbiguous = true,
),
timestamp = 0L,
)
),
numberOfReplies = 12,
)
ThreadSummaryView(
threadSummary = threadSummary,
latestEventText = "Some event with a very long text that should get clipped",
isOutgoing = true,
onClick = {},
)
}
}

View file

@ -13,12 +13,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
@ -58,10 +58,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
),
inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous,
threadInfo = EventThreadInfo(
threadRootId = ThreadId("\$thread-root-id"),
threadSummary = null,
),
threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = ThreadId("\$thread-root-id")),
groupPosition = TimelineItemGroupPosition.Last,
),
)

View file

@ -12,7 +12,10 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -20,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInv
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
@ -27,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
@Inject
class TimelineItemContentFactory(
@ -40,26 +45,53 @@ class TimelineItemContentFactory(
private val stateFactory: TimelineItemContentStateFactory,
private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory,
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder,
) {
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
return when (val itemContent = eventTimelineItem.content) {
return create(
itemContent = eventTimelineItem.content,
eventId = eventTimelineItem.eventId,
isEditable = eventTimelineItem.isEditable,
sender = eventTimelineItem.sender,
senderProfile = eventTimelineItem.senderProfile,
)
}
suspend fun create(
itemContent: EventContent,
eventId: EventId?,
isEditable: Boolean,
sender: UserId,
senderProfile: ProfileTimelineDetails,
): TimelineItemEventContent {
val isOutgoing = currentSessionIdHolder.current == sender
return when (itemContent) {
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> {
val senderDisambiguatedDisplayName = eventTimelineItem.senderProfile.getDisambiguatedDisplayName(eventTimelineItem.sender)
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
messageFactory.create(
content = itemContent,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
eventId = eventTimelineItem.eventId,
eventId = eventId,
)
}
is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
is ProfileChangeContent -> {
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
profileChangeFactory.create(itemContent, isOutgoing, sender, senderDisambiguatedDisplayName)
}
is RedactedContent -> redactedMessageFactory.create(itemContent)
is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem)
is RoomMembershipContent -> {
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
roomMembershipFactory.create(itemContent, isOutgoing, sender, senderDisambiguatedDisplayName)
}
is LegacyCallInviteContent -> TimelineItemLegacyCallInviteContent
is StateContent -> stateFactory.create(eventTimelineItem)
is StateContent -> {
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
stateFactory.create(itemContent, isOutgoing, sender, senderDisambiguatedDisplayName)
}
is StickerContent -> stickerFactory.create(itemContent)
is PollContent -> pollFactory.create(eventTimelineItem, itemContent)
is PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent()
is UnknownContent -> TimelineItemUnknownContent

View file

@ -11,7 +11,7 @@ import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
@Inject
@ -19,14 +19,16 @@ class TimelineItemContentPollFactory(
private val pollContentStateFactory: PollContentStateFactory,
) {
suspend fun create(
event: EventTimelineItem,
eventId: EventId?,
isEditable: Boolean,
isOwn: Boolean,
content: PollContent,
): TimelineItemEventContent {
val pollContentState = pollContentStateFactory.create(event, content)
val pollContentState = pollContentStateFactory.create(eventId, isEditable, isOwn, content)
return TimelineItemPollContent(
isMine = pollContentState.isMine,
isEditable = pollContentState.isPollEditable,
eventId = event.eventId,
eventId = eventId,
question = pollContentState.question,
answerItems = pollContentState.answerItems,
pollKind = pollContentState.pollKind,

View file

@ -12,14 +12,15 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@Inject
class TimelineItemContentProfileChangeFactory(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem)
fun create(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent {
val text = timelineEventFormatter.format(content, isOutgoing, sender, senderDisambiguatedDisplayName)
return TimelineItemProfileChangeContent(text.orEmpty().toString())
}
}

View file

@ -12,14 +12,15 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@Inject
class TimelineItemContentRoomMembershipFactory(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem)
fun create(eventContent: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventContent, isOutgoing, sender, senderDisambiguatedDisplayName)
return TimelineItemRoomMembershipContent(text.orEmpty().toString())
}
}

View file

@ -12,14 +12,15 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@Inject
class TimelineItemContentStateFactory(
private val timelineEventFormatter: TimelineEventFormatter,
) {
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem)
fun create(eventContent: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventContent, isOutgoing, sender, senderDisambiguatedDisplayName)
return TimelineItemStateEventContent(text.orEmpty().toString())
}
}

View file

@ -19,6 +19,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.libraries.architecture.map
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
@ -43,6 +46,7 @@ class TimelineItemEventFactory(
private val matrixClient: MatrixClient,
private val dateFormatter: DateFormatter,
private val permalinkParser: PermalinkParser,
private val summaryFormatter: MessageSummaryFormatter,
) {
@AssistedFactory
interface Creator {
@ -69,6 +73,29 @@ class TimelineItemEventFactory(
url = senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineSender
)
val mappedThreadInfo = when (val threadInfo = currentTimelineItem.event.threadInfo()) {
is EventThreadInfo.ThreadResponse -> {
TimelineItemThreadInfo.ThreadResponse(threadInfo.threadRootId)
}
is EventThreadInfo.ThreadRoot -> {
TimelineItemThreadInfo.ThreadRoot(
summary = threadInfo.summary,
latestEventText = threadInfo.summary.latestEvent.dataOrNull()
?.let {
contentFactory.create(
itemContent = it.content,
eventId = it.eventOrTransactionId.eventId,
isEditable = false,
sender = it.senderId,
senderProfile = it.senderProfile,
)
}
?.let(summaryFormatter::format)
)
}
null -> null
}
return TimelineItem.Event(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
@ -87,7 +114,7 @@ class TimelineItemEventFactory(
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
threadInfo = currentTimelineItem.event.threadInfo() ?: EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo = mappedThreadInfo,
origin = currentTimelineItem.event.origin,
timelineItemDebugInfoProvider = currentTimelineItem.event.timelineItemDebugInfoProvider,
messageShieldProvider = currentTimelineItem.event.messageShieldProvider,

View file

@ -17,10 +17,11 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SendHandle
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
@ -82,7 +83,7 @@ sealed interface TimelineItem {
val readReceiptState: TimelineItemReadReceipts,
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyToDetails?,
val threadInfo: EventThreadInfo,
val threadInfo: TimelineItemThreadInfo?,
val origin: TimelineItemEventOrigin?,
val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider,
val messageShieldProvider: MessageShieldProvider,
@ -130,3 +131,8 @@ sealed interface TimelineItem {
val aggregatedReadReceipts: ImmutableList<ReadReceiptData>,
) : TimelineItem
}
sealed interface TimelineItemThreadInfo {
data class ThreadRoot(val summary: ThreadSummary, val latestEventText: String?) : TimelineItemThreadInfo
data class ThreadResponse(val threadRootId: ThreadId) : TimelineItemThreadInfo
}

View file

@ -10,9 +10,9 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
@ -37,15 +37,15 @@ import io.element.android.libraries.ui.strings.CommonStrings
class DefaultMessageSummaryFormatter(
@ApplicationContext private val context: Context,
) : MessageSummaryFormatter {
override fun format(event: TimelineItem.Event): String {
return when (event.content) {
is TimelineItemTextBasedContent -> event.content.plainText
is TimelineItemProfileChangeContent -> event.content.body
is TimelineItemStateContent -> event.content.body
override fun format(content: TimelineItemEventContent): String {
return when (content) {
is TimelineItemTextBasedContent -> content.plainText
is TimelineItemProfileChangeContent -> content.body
is TimelineItemStateContent -> content.body
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemPollContent -> event.content.question
is TimelineItemPollContent -> content.question
is TimelineItemVoiceContent -> context.getString(CommonStrings.common_voice_message)
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)

View file

@ -8,7 +8,11 @@
package io.element.android.features.messages.impl.utils.messagesummary
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
interface MessageSummaryFormatter {
fun format(event: TimelineItem.Event): String
fun format(event: TimelineItem.Event): String {
return format(event.content)
}
fun format(content: TimelineItemEventContent): String
}

View file

@ -7,6 +7,7 @@
<string name="emoji_picker_category_objects">"Předměty"</string>
<string name="emoji_picker_category_people">"Smajlíci a lidé"</string>
<string name="emoji_picker_category_places">"Cestování a místa"</string>
<string name="emoji_picker_category_recent">"Nedávné emotikony"</string>
<string name="emoji_picker_category_symbols">"Symboly"</string>
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Klepnutím změníte kvalitu nahrávání videa"</string>
@ -15,6 +16,7 @@
<string name="screen_media_upload_preview_error_failed_sending">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Maximální povolená velikost souboru je %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Soubor je pro nahrání příliš velký."</string>
<string name="screen_media_upload_preview_item_count">"Položka %1$d z %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimalizace kvality obrazu"</string>
<string name="screen_media_upload_preview_processing">"Probíhá zpracování…"</string>
<string name="screen_report_content_block_user">"Zablokovat uživatele"</string>

View file

@ -7,10 +7,18 @@
<string name="emoji_picker_category_objects">"Gwrthrychau"</string>
<string name="emoji_picker_category_people">"Wynebau Hapus a Phobl"</string>
<string name="emoji_picker_category_places">"Teithio a Llefydd"</string>
<string name="emoji_picker_category_recent">"Emojis diweddar"</string>
<string name="emoji_picker_category_symbols">"Symbolau"</string>
<string name="screen_media_upload_preview_caption_warning">"Efallai na fydd capsiynau yn weladwy i bobl sy\'n defnyddio apiau hŷn."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tapiwch i newid ansawdd llwytho\'r fideo"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Nid oedd modd llwytho\'r ffeil."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Wedi methu â phrosesu cyfryngau i\'w llwytho, ceisiwch eto."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Wedi methu llwytho cyfryngau, ceisiwch eto."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Y maint ffeil mwyaf a ganiateir yw %1$s ."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Mae\'r ffeil yn rhy fawr i\'w llwytho"</string>
<string name="screen_media_upload_preview_item_count">"Eitem %1$d o %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimeiddio ansawdd delwedd"</string>
<string name="screen_media_upload_preview_processing">"Prosesu…"</string>
<string name="screen_report_content_block_user">"Rhwystro defnyddiwr"</string>
<string name="screen_report_content_block_user_hint">"Gwiriwch a ydych am guddio\'r holl negeseuon presennol ac yn y dyfodol gan y defnyddiwr hwn"</string>
<string name="screen_report_content_explanation">"Bydd y neges hon yn cael ei hadrodd i weinyddwr eich gweinyddwr cartref. Fyddan nhw ddim yn gallu darllen unrhyw negeseuon wedi\'u hamgryptio."</string>
@ -38,6 +46,14 @@
<string name="screen_room_timeline_less_reactions">"Dangos llai"</string>
<string name="screen_room_timeline_message_copied">"Neges wedi\'i chopïo"</string>
<string name="screen_room_timeline_no_permission_to_post">"Does gennych chi ddim caniatâd i bostio i\'r ystafell hon"</string>
<plurals name="screen_room_timeline_reaction_a11y">
<item quantity="zero">"Ymatebodd %1$d aelodau gyda %2$s"</item>
<item quantity="one">"Ymatebodd %1$d aelodau gyda %2$s"</item>
<item quantity="two">"Ymatebodd %1$d aelod gyda %2$s"</item>
<item quantity="few">"Ymatebodd %1$d aelod gyda %2$s"</item>
<item quantity="many">"Ymatebodd %1$d aelod gyda %2$s"</item>
<item quantity="other">"Ymatebodd %1$d aelod gyda %2$s"</item>
</plurals>
<string name="screen_room_timeline_reaction_you_a11y">"Rydych chi wedi ymateb gyda %1$s"</string>
<string name="screen_room_timeline_reactions_show_less">"Dangos llai"</string>
<string name="screen_room_timeline_reactions_show_more">"Dangos rhagor"</string>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_encrypted_history_banner_unverified">"Message history is unavailable in this room. Confirm this device to see your message history."</string>
</resources>

View file

@ -7,6 +7,7 @@
<string name="emoji_picker_category_objects">"Esineet"</string>
<string name="emoji_picker_category_people">"Hymiöt ja ihmiset"</string>
<string name="emoji_picker_category_places">"Matkustaminen ja paikat"</string>
<string name="emoji_picker_category_recent">"Viimeaikaiset emojit"</string>
<string name="emoji_picker_category_symbols">"Symbolit"</string>
<string name="screen_media_upload_preview_caption_warning">"Kuvatekstit eivät välttämättä näy ihmisille, jotka käyttävät vanhempia sovelluksia."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Napauta muuttaaksesi videon lähetyslaatua"</string>

View file

@ -9,11 +9,14 @@
<string name="emoji_picker_category_places">"旅行與景點"</string>
<string name="emoji_picker_category_symbols">"標誌"</string>
<string name="screen_media_upload_preview_caption_warning">"使用舊應用程式的使用者可能看不到標題。"</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"輕點即可變更影片上傳品質"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"無法上傳檔案。"</string>
<string name="screen_media_upload_preview_error_failed_processing">"無法處理要上傳的媒體,請再試一次。"</string>
<string name="screen_media_upload_preview_error_failed_sending">"無法上傳媒體檔案,請稍後再試。"</string>
<string name="screen_media_upload_preview_error_too_large_message">"允許的最大檔案大小為 %1$s。"</string>
<string name="screen_media_upload_preview_error_too_large_title">"檔案太大,無法上傳"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"最佳化影像品質"</string>
<string name="screen_media_upload_preview_processing">"正在處理……"</string>
<string name="screen_report_content_block_user">"封鎖使用者"</string>
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</string>

View file

@ -7,6 +7,7 @@
<string name="emoji_picker_category_objects">"Objects"</string>
<string name="emoji_picker_category_people">"Smileys &amp; People"</string>
<string name="emoji_picker_category_places">"Travel &amp; Places"</string>
<string name="emoji_picker_category_recent">"Recent emojis"</string>
<string name="emoji_picker_category_symbols">"Symbols"</string>
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tap to change the video upload quality"</string>
@ -15,6 +16,7 @@
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
<string name="screen_media_upload_preview_error_too_large_message">"The maximum file size allowed is %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"The file is too large to upload"</string>
<string name="screen_media_upload_preview_item_count">"Item %1$d of %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimise image quality"</string>
<string name="screen_media_upload_preview_processing">"Processing…"</string>
<string name="screen_report_content_block_user">"Block user"</string>

View file

@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
@ -61,7 +62,6 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@ -1181,7 +1181,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(
action = TimelineItemAction.ReplyInThread,
event = aMessageEvent(threadInfo = EventThreadInfo(A_THREAD_ID, null))
event = aMessageEvent(threadInfo = TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
))
awaitItem()
openThreadLambda.assertions().isCalledOnce().with(value(A_THREAD_ID), value(null))
@ -1204,7 +1204,7 @@ class MessagesPresenterTest {
event = aMessageEvent(
// The event id will be used as the thread id instead
eventId = AN_EVENT_ID,
threadInfo = EventThreadInfo(null, null),
threadInfo = null,
)
))
awaitItem()

View file

@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
@ -31,7 +32,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_CAPTION
@ -198,7 +198,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
isMine = false,
isEditable = false,
threadInfo = EventThreadInfo(threadRootId = A_THREAD_ID, threadSummary = null),
threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID),
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
@ -432,7 +432,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
threadInfo = EventThreadInfo(threadRootId = A_THREAD_ID, threadSummary = null),
threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID),
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
@ -1264,7 +1264,7 @@ class ActionListPresenterTest {
content = aTimelineItemVoiceContent(
caption = null,
),
threadInfo = EventThreadInfo(A_THREAD_ID, null)
threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@ -1368,7 +1368,7 @@ class ActionListPresenterTest {
content = aTimelineItemVoiceContent(
caption = null,
),
threadInfo = EventThreadInfo(A_THREAD_ID, null),
threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID),
)
assertThat(messageEvent.isRemote).isTrue()

View file

@ -12,6 +12,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -19,7 +20,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShieldProvider
import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider
@ -41,7 +41,7 @@ internal fun aMessageEvent(
canBeRepliedTo: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false),
inReplyTo: InReplyToDetails? = null,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo: TimelineItemThreadInfo? = null,
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() },
messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null },

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.fixtures
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
@ -30,7 +31,9 @@ import io.element.android.features.poll.test.pollcontent.FakePollContentStateFac
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
@ -75,11 +78,13 @@ internal fun TestScope.aTimelineItemsFactory(
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
currentSessionIdHolder = CurrentSessionIdHolder(matrixClient),
),
matrixClient = matrixClient,
dateFormatter = FakeDateFormatter(),
permalinkParser = FakePermalinkParser(),
config = config
config = config,
summaryFormatter = FakeMessageSummaryFormatter(),
)
}
},
@ -95,7 +100,7 @@ internal fun TestScope.aTimelineItemsFactory(
internal fun aTimelineEventFormatter(): TimelineEventFormatter {
return object : TimelineEventFormatter {
override fun format(event: EventTimelineItem): CharSequence {
override fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence? {
return ""
}
}

View file

@ -7,13 +7,13 @@
package io.element.android.features.messages.impl.messagesummary
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
class FakeMessageSummaryFormatter : MessageSummaryFormatter {
private var result = "A message"
override fun format(event: TimelineItem.Event): String = result
override fun format(content: TimelineItemEventContent): String = result
fun givenMessageResult(value: String) {
result = value

View file

@ -750,7 +750,7 @@ class TimelineItemContentMessageFactoryTest {
body: String = "Body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo: EventThreadInfo? = null,
type: MessageType,
): MessageContent {
return MessageContent(

View file

@ -18,7 +18,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
@ -42,7 +41,7 @@ class TimelineItemGrouperTest {
isEditable = false,
canBeRepliedTo = false,
inReplyTo = null,
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo = null,
origin = null,
timelineItemDebugInfoProvider = { aTimelineItemDebugInfo() },
messageShieldProvider = { null },

View file

@ -7,9 +7,18 @@
package io.element.android.features.poll.api.pollcontent
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
interface PollContentStateFactory {
suspend fun create(event: EventTimelineItem, content: PollContent): PollContentState
suspend fun create(eventTimelineItem: EventTimelineItem, content: PollContent): PollContentState {
return create(
eventId = eventTimelineItem.eventId,
isEditable = eventTimelineItem.isEditable,
isOwn = eventTimelineItem.isOwn,
content = content,
)
}
suspend fun create(eventId: EventId?, isEditable: Boolean, isOwn: Boolean, content: PollContent): PollContentState
}

View file

@ -1,5 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="a11y_polls_percent_of_total">
<item quantity="zero">"%1$d y cant o\'r holl bleidleisiau"</item>
<item quantity="one">"%1$d y cant o\'r holl bleidleisiau"</item>
<item quantity="two">"%1$d y cant o\'r holl bleidleisiau"</item>
<item quantity="few">"%1$d y cant o\'r holl bleidleisiau"</item>
<item quantity="many">"%1$d y cant o\'r holl bleidleisiau"</item>
<item quantity="other">"%1$d y cant o\'r holl bleidleisiau"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"Bydd yn dileu\'r dewis blaenorol"</string>
<string name="a11y_polls_winning_answer">"Dyma\'r ateb buddugol"</string>
</resources>

View file

@ -45,7 +45,12 @@ class PollHistoryItemsFactory(
return when (timelineItem) {
is MatrixTimelineItem.Event -> {
val pollContent = timelineItem.event.content as? PollContent ?: return null
val pollContentState = pollContentStateFactory.create(timelineItem.event, pollContent)
val pollContentState = pollContentStateFactory.create(
eventId = timelineItem.eventId,
isEditable = timelineItem.event.isEditable,
isOwn = timelineItem.event.isOwn,
content = pollContent,
)
PollHistoryItem(
formattedDate = dateFormatter.format(
timestamp = timelineItem.event.timestamp,

View file

@ -14,8 +14,8 @@ import io.element.android.features.poll.api.pollcontent.PollContentState
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.isDisclosed
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.toImmutableList
@ -25,8 +25,10 @@ class DefaultPollContentStateFactory(
private val matrixClient: MatrixClient,
) : PollContentStateFactory {
override suspend fun create(
event: EventTimelineItem,
content: PollContent
eventId: EventId?,
isEditable: Boolean,
isOwn: Boolean,
content: PollContent,
): PollContentState {
val totalVoteCount = content.votes.flatMap { it.value }.size
val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys
@ -59,13 +61,13 @@ class DefaultPollContentStateFactory(
}
return PollContentState(
eventId = event.eventId,
eventId = eventId,
question = content.question,
answerItems = answerItems.toImmutableList(),
pollKind = content.kind,
isPollEditable = event.isEditable,
isPollEditable = isEditable,
isPollEnded = isPollEnded,
isMine = event.isOwn,
isMine = isOwn,
)
}
}

View file

@ -10,20 +10,20 @@ package io.element.android.features.poll.test.pollcontent
import io.element.android.features.poll.api.pollcontent.PollAnswerItem
import io.element.android.features.poll.api.pollcontent.PollContentState
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.toImmutableList
class FakePollContentStateFactory : PollContentStateFactory {
override suspend fun create(event: EventTimelineItem, content: PollContent): PollContentState {
override suspend fun create(eventId: EventId?, isEditable: Boolean, isOwn: Boolean, content: PollContent): PollContentState {
return PollContentState(
eventId = event.eventId,
eventId = eventId,
question = content.question,
answerItems = emptyList<PollAnswerItem>().toImmutableList(),
pollKind = content.kind,
isPollEditable = event.isEditable,
isPollEditable = isEditable,
isPollEnded = content.endTime != null,
isMine = event.isOwn
isMine = isOwn,
)
}
}

View file

@ -72,7 +72,6 @@ dependencies {
implementation(projects.features.rageshake.api)
implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api)
implementation(projects.features.licenses.api)
implementation(projects.features.logout.api)
implementation(projects.features.deactivation.api)
@ -101,7 +100,6 @@ dependencies {
testImplementation(projects.libraries.preferences.test)
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.logout.test)

View file

@ -208,6 +208,10 @@ class PreferencesFlowNode(
navigateUp()
}
}
override fun openIgnoredUsers() {
backstack.push(NavTarget.BlockedUsers)
}
})
.build()
}

View file

@ -12,7 +12,6 @@ import coil3.SingletonImageLoader
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Provider
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
@ -36,7 +35,6 @@ class DefaultClearCacheUseCase(
private val coroutineDispatchers: CoroutineDispatchers,
private val defaultCacheService: DefaultCacheService,
private val okHttpClient: Provider<OkHttpClient>,
private val ftueService: FtueService,
private val pushService: PushService,
private val seenInvitesStore: SeenInvitesStore,
private val activeRoomsHolder: ActiveRoomsHolder,
@ -56,7 +54,6 @@ class DefaultClearCacheUseCase(
// Clear app cache
context.cacheDir.deleteRecursively()
// Clear some settings
ftueService.reset()
seenInvitesStore.clear()
// Ensure any error will be displayed again
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)

View file

@ -13,6 +13,13 @@
<string name="screen_advanced_settings_media_compression_description">"Llwythwch i fyny lluniau a fideos yn gynt a lleihau\'r defnydd o ddata"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimeiddio ansawdd y cyfryngau"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Cymedroli a Diogelwch"</string>
<string name="screen_advanced_settings_optimise_image_upload_quality_description">"Optimeiddio delweddau\'n awtomatig ar gyfer llwytho cyflymach a meintiau ffeiliau llai."</string>
<string name="screen_advanced_settings_optimise_image_upload_quality_title">"Optimeiddio ansawdd llwytho delweddau"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_description">"%1$s Tapiwch yma i newid."</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_high">"Uchel (1080p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_low">"Isel (480c)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_standard">"Safonol (720p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_title">"Ansawdd lwytho fideo"</string>
<string name="screen_advanced_settings_push_provider_android">"Darparwr hysbysiad gwthio"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Analluogi\'r golygydd testun cyfoethog i deipio Markdown â llaw."</string>
<string name="screen_advanced_settings_send_read_receipts">"Derbynebau darllen"</string>

View file

@ -16,6 +16,9 @@
<string name="screen_advanced_settings_optimise_image_upload_quality_description">"Optimoi kuvat automaattisesti nopeampia lähetysnopeuksia ja pienempiä tiedostokokoja varten."</string>
<string name="screen_advanced_settings_optimise_image_upload_quality_title">"Optimoi kuvien lähetyslaatu"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_description">"%1$s. Napauta tästä vaihtaaksesi."</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_high">"Korkea (1080p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_low">"Matala (480p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_standard">"Normaali (720p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_title">"Videon lähetyslaatu"</string>
<string name="screen_advanced_settings_push_provider_android">"Push-ilmoitusten tarjoaja"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Ota rikastettu tekstieditori pois käytöstä, jotta voit kirjoittaa Markdownia manuaalisesti."</string>

View file

@ -13,6 +13,13 @@
<string name="screen_advanced_settings_media_compression_description">"上傳照片與影片更快且減少資料使用量"</string>
<string name="screen_advanced_settings_media_compression_title">"最佳化媒體品質"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"管理與安全"</string>
<string name="screen_advanced_settings_optimise_image_upload_quality_description">"自動最佳化影像以提供更快的上傳速度與較小的檔案大小。"</string>
<string name="screen_advanced_settings_optimise_image_upload_quality_title">"最佳化影像上傳品質"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_description">"%1$s。輕點此處以變更。"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_high">"高 (1080p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_low">"低 (480p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_standard">"標準 (720p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_title">"視訊上傳品質"</string>
<string name="screen_advanced_settings_push_provider_android">"推播通知提供者"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"手動輸入 Markdown停用格式化文字編輯器。"</string>
<string name="screen_advanced_settings_send_read_receipts">"已讀回條"</string>

View file

@ -10,7 +10,6 @@ package io.element.android.features.preferences.impl.tasks
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
@ -41,10 +40,6 @@ class DefaultClearCacheUseCaseTest {
clearCacheLambda = clearCacheLambda,
)
val defaultCacheService = DefaultCacheService()
val resetFtueLambda = lambdaRecorder<Unit> { }
val ftueService = FakeFtueService(
resetLambda = resetFtueLambda,
)
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
val resetBatteryOptimizationStateResult = lambdaRecorder<Unit> { }
val pushService = FakePushService(
@ -59,7 +54,6 @@ class DefaultClearCacheUseCaseTest {
coroutineDispatchers = testCoroutineDispatchers(),
defaultCacheService = defaultCacheService,
okHttpClient = { OkHttpClient.Builder().build() },
ftueService = ftueService,
pushService = pushService,
seenInvitesStore = seenInvitesStore,
activeRoomsHolder = activeRoomsHolder,
@ -67,7 +61,6 @@ class DefaultClearCacheUseCaseTest {
defaultCacheService.clearedCacheEventFlow.test {
sut.invoke()
clearCacheLambda.assertions().isCalledOnce()
resetFtueLambda.assertions().isCalledOnce()
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
.with(value(matrixClient.sessionId), value(false))
resetBatteryOptimizationStateResult.assertions().isCalledOnce()

View file

@ -50,6 +50,8 @@
<string name="screen_room_details_error_loading_notification_settings">"Při načítání nastavení oznámení došlo k chybě."</string>
<string name="screen_room_details_error_muting">"Ztišení této místnosti se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_details_error_unmuting">"Nepodařilo se zrušit ztišení této místnosti, zkuste to prosím znovu."</string>
<string name="screen_room_details_invite_people_dont_close">"Nezavírejte aplikaci, dokud neskončíte."</string>
<string name="screen_room_details_invite_people_preparing">"Příprava pozvánek…"</string>
<string name="screen_room_details_invite_people_title">"Pozvat přátele"</string>
<string name="screen_room_details_leave_conversation_title">"Opustit konverzaci"</string>
<string name="screen_room_details_leave_room_title">"Opustit místnost"</string>

View file

@ -7,7 +7,7 @@
<string name="screen_polls_history_title">"Pleidleisiau"</string>
<string name="screen_room_change_permissions_administrators">"Gweinyddwyr yn unig"</string>
<string name="screen_room_change_permissions_ban_people">"Gwahardd pobl"</string>
<string name="screen_room_change_permissions_delete_messages">"Dileu negeseuon"</string>
<string name="screen_room_change_permissions_delete_messages">"Tynnu negeseuon"</string>
<string name="screen_room_change_permissions_everyone">"Pawb"</string>
<string name="screen_room_change_permissions_invite_people">"Gwahodd pobl a derbyn ceisiadau i ymuno"</string>
<string name="screen_room_change_permissions_member_moderation">"Cymedroli aelodau"</string>
@ -22,13 +22,17 @@
<string name="screen_room_change_role_administrators_title">"Golygu Gweinyddwyr"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych chi\'n hyrwyddo\'r defnyddiwr i gael yr un lefel pŵer â chi."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Ychwanegu Gweinyddwr?"</string>
<string name="screen_room_change_role_confirm_change_owners_description">"Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych yn trosglwyddo\'r berchnogaeth i\'r defnyddwyr a ddewiswyd. Unwaith y byddwch yn gadael bydd hyn yn barhaol."</string>
<string name="screen_room_change_role_confirm_change_owners_title">"Trosglwyddo perchnogaeth?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Gostwng"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Fyddwch chi ddim yn gallu dadwneud y newid hwn gan eich bod yn israddio eich hun, os mai chi yw\'r defnyddiwr breintiedig olaf yn yr ystafell bydd yn amhosibl adennill breintiau."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Israddio eich hun?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (Yn aros)"</string>
<string name="screen_room_change_role_invited_member_name_android">"Yn aros"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Mae gan weinyddwyr freintiau cymedrolwr yn awtomatig"</string>
<string name="screen_room_change_role_moderators_owner_section_footer">"Mae gan berchnogion freintiau gweinyddwr yn awtomatig."</string>
<string name="screen_room_change_role_moderators_title">"Golygu Cymedrolwyr"</string>
<string name="screen_room_change_role_owners_title">"Dewiswch Berchnogion"</string>
<string name="screen_room_change_role_section_administrators">"Gweinyddwyr"</string>
<string name="screen_room_change_role_section_moderators">"Cymedrolwyr"</string>
<string name="screen_room_change_role_section_users">"Aelodau"</string>
@ -46,6 +50,8 @@
<string name="screen_room_details_error_loading_notification_settings">"Digwyddodd gwall wrth lwytho gosodiadau hysbysu."</string>
<string name="screen_room_details_error_muting">"Wedi methu tewi\'r ystafell hon, ceisiwch eto."</string>
<string name="screen_room_details_error_unmuting">"Wedi methu dad-dewi\'r ystafell hon, ceisiwch eto."</string>
<string name="screen_room_details_invite_people_dont_close">"Peidiwch â chau\'r ap nes ei fod wedi gorffen."</string>
<string name="screen_room_details_invite_people_preparing">"Wrthi\'n paratoi gwahoddiadau…"</string>
<string name="screen_room_details_invite_people_title">"Gwahodd pobl"</string>
<string name="screen_room_details_leave_conversation_title">"Gadael y sgwrs"</string>
<string name="screen_room_details_leave_room_title">"Gadael yr ystafell"</string>
@ -83,6 +89,7 @@
<string name="screen_room_member_list_pending_header_title">"Dan ystyriaeth"</string>
<string name="screen_room_member_list_role_administrator">"Gweinyddwr"</string>
<string name="screen_room_member_list_role_moderator">"Cymedrolwr"</string>
<string name="screen_room_member_list_role_owner">"Perchennog"</string>
<string name="screen_room_member_list_room_members_header_title">"Aelodau\'r ystafell"</string>
<string name="screen_room_member_list_unbanning_user">"Dad-wahardd %1$s"</string>
<string name="screen_room_notification_settings_allow_custom">"Caniatáu gosodiad personol"</string>
@ -100,12 +107,14 @@
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Crybwylliadau ac Allweddeiriau\'n unig"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Yn yr ystafell hon, rhowch wybod i mi am"</string>
<string name="screen_room_roles_and_permissions_admins">"Gweinyddwyr"</string>
<string name="screen_room_roles_and_permissions_admins_and_owners">"Gweinyddwyr a pherchnogion"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Newid fy rôl"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Israddio aelod"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Israddio cymedrolwr"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Cymedroli aelodau"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Negeseuon a chynnwys"</string>
<string name="screen_room_roles_and_permissions_moderators">"Cymedrolwyr"</string>
<string name="screen_room_roles_and_permissions_owners">"Perchnogion"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Caniatâd"</string>
<string name="screen_room_roles_and_permissions_reset">"Ailosod caniatâd"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Ar ôl i chi ailosod caniatâd, byddwch yn colli\'r gosodiadau cyfredol."</string>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients can unlock them."</string>
</resources>

View file

@ -21,12 +21,12 @@
<string name="screen_room_change_permissions_send_messages">"Viestien lähettäminen"</string>
<string name="screen_room_change_role_administrators_title">"Muokkaa ylläpitäjiä"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Et voi peruuttaa tätä toimenpidettä. Ylennät käyttäjän samalle oikeustasolle kuin sinä."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Lisää ylläpitäjä?"</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Lisätäänkö ylläpitäjä?"</string>
<string name="screen_room_change_role_confirm_change_owners_description">"Et voi kumota tätä toimintoa. Olet siirtämässä omistajuuden valituille käyttäjille. Kun poistut, muutos on pysyvä."</string>
<string name="screen_room_change_role_confirm_change_owners_title">"Siirretäänkö omistajuus?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Alenna"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Et voi perua tätä muutosta, koska olet alentamassa itseäsi. Jos olet viimeinen oikeutettu henkilö tässä huoneessa, oikeuksia ei voi enää saada takaisin."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Alenna itsesi?"</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Haluatko alentaa itsesi?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (Kutsuttu)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(Kutsuttu)"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Ylläpitäjillä on automaattisesti valvojan oikeudet"</string>
@ -37,7 +37,7 @@
<string name="screen_room_change_role_section_moderators">"Valvojat"</string>
<string name="screen_room_change_role_section_users">"Jäsenet"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Sinulla on tallentamattomia muutoksia"</string>
<string name="screen_room_change_role_unsaved_changes_title">"Tallenna muutokset?"</string>
<string name="screen_room_change_role_unsaved_changes_title">"Tallennetaanko muutokset?"</string>
<string name="screen_room_details_add_topic_title">"Lisää aihe"</string>
<string name="screen_room_details_badge_encrypted">"Salattu"</string>
<string name="screen_room_details_badge_not_encrypted">"Ei salattu"</string>
@ -50,6 +50,8 @@
<string name="screen_room_details_error_loading_notification_settings">"Ilmoitusasetuksia ladattaessa tapahtui virhe."</string>
<string name="screen_room_details_error_muting">"Tämän huoneen mykistäminen epäonnistui, yritä uudelleen."</string>
<string name="screen_room_details_error_unmuting">"Tämän huoneen mykistyksen poistaminen epäonnistui, yritä uudelleen."</string>
<string name="screen_room_details_invite_people_dont_close">"Älä sulje sovellusta ennen kuin se on valmis."</string>
<string name="screen_room_details_invite_people_preparing">"Valmistellaan kutsuja…"</string>
<string name="screen_room_details_invite_people_title">"Kutsu ihmisiä"</string>
<string name="screen_room_details_leave_conversation_title">"Poistu keskustelusta"</string>
<string name="screen_room_details_leave_room_title">"Poistu huoneesta"</string>

View file

@ -50,6 +50,7 @@
<string name="screen_room_details_error_loading_notification_settings">"Une erreur sest produite lors du chargement des paramètres de notification."</string>
<string name="screen_room_details_error_muting">"Échec de la mise en sourdine de ce salon, veuillez réessayer."</string>
<string name="screen_room_details_error_unmuting">"Échec de la désactivation de la mise en sourdine de ce salon, veuillez réessayer."</string>
<string name="screen_room_details_invite_people_preparing">"Préparation des invitations…"</string>
<string name="screen_room_details_invite_people_title">"Inviter des amis"</string>
<string name="screen_room_details_leave_conversation_title">"Quitter la discussion"</string>
<string name="screen_room_details_leave_room_title">"Quitter le salon"</string>

View file

@ -50,6 +50,8 @@
<string name="screen_room_details_error_loading_notification_settings">"載入通知設定時發生錯誤。"</string>
<string name="screen_room_details_error_muting">"無法關閉聊天室通知,請再試一次。"</string>
<string name="screen_room_details_error_unmuting">"無法開啟聊天室通知,請再試一次。"</string>
<string name="screen_room_details_invite_people_dont_close">"完成前請勿關閉應用程式。"</string>
<string name="screen_room_details_invite_people_preparing">"正在準備邀請……"</string>
<string name="screen_room_details_invite_people_title">"邀請夥伴"</string>
<string name="screen_room_details_leave_conversation_title">"離開對話"</string>
<string name="screen_room_details_leave_room_title">"離開聊天室"</string>

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_chat_backup_key_backup_action_disable">"Delete message backup"</string>
<string name="screen_chat_backup_key_backup_description">"Store your account security and messages securely on the server. This will allow you to view your message history on any new devices. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Message backup"</string>
<string name="screen_chat_backup_key_storage_disabled_error">"Turn on message backup to set it up."</string>
<string name="screen_chat_backup_key_storage_toggle_description">"Upload messages from this device"</string>
<string name="screen_chat_backup_key_storage_toggle_title">"Allow message backup"</string>
<string name="screen_chat_backup_recovery_action_change">"Change backup password"</string>
<string name="screen_chat_backup_recovery_action_change_description">"Restore your account security and message history with a backup password if you\'ve lost all your existing devices."</string>
<string name="screen_chat_backup_recovery_action_confirm">"Enter backup password"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Your message backup is currently out of sync."</string>
<string name="screen_chat_backup_recovery_action_setup">"Set up backup"</string>
<string name="screen_create_new_recovery_key_list_item_3">"When asked to confirm your device, select %1$s"</string>
<string name="screen_create_new_recovery_key_list_item_4">"Follow the instructions to create a new backup password"</string>
<string name="screen_create_new_recovery_key_list_item_5">"Save your new backup password in a password manager or encrypted note"</string>
<string name="screen_encryption_reset_bullet_3">"You will need to confirm all your existing devices and verify contacts again"</string>
<string name="screen_encryption_reset_footer">"Only start fresh if you don\'t have access to another signed-in device and you\'ve lost your backup password."</string>
<string name="screen_encryption_reset_title">"Can\'t confirm? You\'ll need to start fresh."</string>
<string name="screen_key_backup_disable_description">"Deleting message backup will remove your account security and messages from the server and turn off the following security features:"</string>
<string name="screen_key_backup_disable_title">"Are you sure you want to turn off message backup and delete it?"</string>
<string name="screen_recovery_key_change_description">"Get a new backup password if you\'ve lost your existing one. After changing your backup password, your old one will no longer work."</string>
<string name="screen_recovery_key_change_generate_key">"Generate a new backup password"</string>
<string name="screen_recovery_key_change_success">"Backup password changed"</string>
<string name="screen_recovery_key_change_title">"Change backup password?"</string>
<string name="screen_recovery_key_confirm_error_content">"Please try again to confirm access to your message backup."</string>
<string name="screen_recovery_key_confirm_error_title">"Incorrect backup password"</string>
<string name="screen_recovery_key_confirm_key_description">"You might have seen the terms \"recovery key\", \"security key\" or \"security phrase\" instead of \"backup password\". Don\'t worry, this is all the same."</string>
<string name="screen_recovery_key_confirm_success">"Backup password confirmed"</string>
<string name="screen_recovery_key_confirm_title">"Enter your backup password"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Copied backup password"</string>
<string name="screen_recovery_key_save_action">"Save backup password"</string>
<string name="screen_recovery_key_save_description">"Write down this backup password somewhere safe, like a password manager, encrypted note, or a physical safe."</string>
<string name="screen_recovery_key_save_key_description">"Tap to copy backup password"</string>
<string name="screen_recovery_key_save_title">"Save your backup password somewhere safe"</string>
<string name="screen_recovery_key_setup_confirmation_description">"You will not be able to access your new backup password after this step."</string>
<string name="screen_recovery_key_setup_confirmation_title">"Have you saved your backup password?"</string>
<string name="screen_recovery_key_setup_description">"Your message backup is protected by a backup password. If you need a new backup password after setup, you can recreate it by selecting Change backup password."</string>
<string name="screen_recovery_key_setup_generate_key">"Generate your backup password"</string>
<string name="screen_recovery_key_setup_success">"Backup setup successful"</string>
<string name="screen_recovery_key_setup_title">"Set up backup"</string>
<string name="screen_reset_encryption_confirmation_alert_title">"Are you sure you want to start fresh?"</string>
<string name="screen_reset_encryption_password_subtitle">"Confirm that you want to start fresh."</string>
</resources>

View file

@ -37,7 +37,7 @@
<string name="screen_recovery_key_change_generate_key">"Luo uusi palautusavain"</string>
<string name="screen_recovery_key_change_generate_key_description">"Älä jaa tätä kenenkään kanssa!"</string>
<string name="screen_recovery_key_change_success">"Palautusavain vaihdettu"</string>
<string name="screen_recovery_key_change_title">"Vaihda palautusavain?"</string>
<string name="screen_recovery_key_change_title">"Vaihdetaanko palautusavain?"</string>
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Luo uusi palautusavain"</string>
<string name="screen_recovery_key_confirm_description">"Varmista, ettei kukaan näe tätä ruutua!"</string>
<string name="screen_recovery_key_confirm_error_content">"Yritä uudelleen vahvistaaksesi pääsyn avainten säilytykseen."</string>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new backup password"</string>
<string name="screen_identity_confirmation_subtitle">"Confirm this device to set up secure messaging."</string>
<string name="screen_identity_confirmation_title">"Confirm it\'s you"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Use backup password"</string>
<string name="screen_identity_confirmed_title">"Device confirmed"</string>
<string name="screen_session_verification_complete_user_subtitle">"Now you can trust this user when sending or receiving messages."</string>
<string name="screen_session_verification_enter_recovery_key">"Enter backup password"</string>
<string name="screen_session_verification_request_success_title">"Device confirmed"</string>
<string name="screen_session_verification_use_another_device_title">"Open the app on another confirmed device"</string>
<string name="screen_session_verification_user_responder_subtitle">"For extra security, another user wants to verify you. You\'ll be shown a set of emojis to compare."</string>
</resources>

View file

@ -48,7 +48,7 @@ telephoto = "0.17.0"
haze = "1.6.10"
# Dependency analysis
dependencyAnalysis = "2.19.0"
dependencyAnalysis = "3.0.4"
# DI
metro = "0.6.4"
@ -74,7 +74,7 @@ kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlin
kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
# https://firebase.google.com/docs/android/setup#available-libraries
google_firebase_bom = "com.google.firebase:firebase-bom:34.2.0"
google_firebase_bom = "com.google.firebase:firebase-bom:34.3.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
@ -166,7 +166,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
# All new features should not be implemented in the pull request that upgrades the version, developers should
# only fix API breaks and may add some TODOs.
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.9.18"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.9.23"
# Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
@ -196,7 +196,7 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:11.13.1"
maplibre = "org.maplibre.gl:android-sdk:11.13.5"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.2.0"
@ -219,7 +219,7 @@ inject = "javax.inject:javax.inject:1"
metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" }
# Element Call
element_call_embedded = "io.element.android:element-call-embedded:0.15.0"
element_call_embedded = "io.element.android:element-call-embedded:0.16.0-rc.4"
# Auto services
google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }

View file

@ -34,6 +34,7 @@ enum class AvatarSize(val dp: Dp) {
TimelineRoom(32.dp),
TimelineSender(32.dp),
TimelineReadReceipt(16.dp),
TimelineThreadLatestEventSender(24.dp),
ComposerAlert(32.dp),

View file

@ -7,8 +7,19 @@
package io.element.android.libraries.eventformatter.api
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
interface TimelineEventFormatter {
fun format(event: EventTimelineItem): CharSequence?
fun format(event: EventTimelineItem): CharSequence? {
return format(
content = event.content,
isOutgoing = event.isOwn,
sender = event.sender,
senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
)
}
fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence?
}

View file

@ -13,7 +13,9 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -43,12 +45,16 @@ class DefaultTimelineEventFormatter(
override fun format(event: EventTimelineItem): CharSequence? {
val isOutgoing = event.isOwn
val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
return when (val content = event.content) {
return format(event.content, isOutgoing, event.sender, senderDisambiguatedDisplayName)
}
override fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence? {
return when (content) {
is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
profileChangeContentFormatter.format(content, event.sender, senderDisambiguatedDisplayName, isOutgoing)
profileChangeContentFormatter.format(content, sender, senderDisambiguatedDisplayName, isOutgoing)
}
is StateContent -> {
stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.Timeline)
@ -66,7 +72,7 @@ class DefaultTimelineEventFormatter(
is FailedToParseStateContent,
is UnknownContent -> {
if (buildMeta.isDebuggable) {
error("You should not use this formatter for this event: $event")
error("You should not use this formatter for this event content: $content")
}
sp.getString(CommonStrings.common_unsupported_event)
}

View file

@ -14,7 +14,6 @@ import com.google.common.truth.Truth.assertWithMessage
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@ -175,7 +174,7 @@ class DefaultBaseRoomLastMessageFormatterTest {
) {
val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent {
return MessageContent(body, null, false, EventThreadInfo(null, null), type)
return MessageContent(body, null, false, null, type)
}
val sharedContentMessagesTypes = arrayOf(

View file

@ -14,7 +14,6 @@ import com.google.common.truth.Truth.assertWithMessage
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@ -130,7 +129,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
fun `Message contents`() {
val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent {
return MessageContent(body, null, false, EventThreadInfo(null, null), type)
return MessageContent(body, null, false, null, type)
}
val sharedContentMessagesTypes = arrayOf(

View file

@ -14,10 +14,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
data class EventThreadInfo(
val threadRootId: ThreadId?,
val threadSummary: ThreadSummary?,
)
sealed interface EventThreadInfo {
data class ThreadRoot(val summary: ThreadSummary) : EventThreadInfo
data class ThreadResponse(val threadRootId: ThreadId) : EventThreadInfo
}
data class ThreadSummary(
val latestEvent: AsyncData<EmbeddedEventInfo>,

View file

@ -24,7 +24,7 @@ data class MessageContent(
val body: String,
val inReplyTo: InReplyTo?,
val isEdited: Boolean,
val threadInfo: EventThreadInfo,
val threadInfo: EventThreadInfo?,
val type: MessageType
) : EventContent

View file

@ -33,6 +33,12 @@ class RoomSummaryListProcessor(
updates.forEach { update ->
applyUpdate(update)
}
// TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed
val duplicates = groupingBy { it.roomId }.eachCount().filter { it.value > 1 }
if (duplicates.isNotEmpty()) {
Timber.e("Found duplicates in room summaries after a list update from the SDK: $duplicates. Updates: $updates")
}
}
}

View file

@ -123,7 +123,7 @@ class RustTimeline(
)
override val forwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode !is Timeline.Mode.FocusedOnEvent)
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode is Timeline.Mode.FocusedOnEvent)
)
init {
@ -221,7 +221,6 @@ class RustTimeline(
items = items,
hasMoreToLoadBackward = backwardPaginationStatus.hasMoreToLoad,
hasMoreToLoadForward = forwardPaginationStatus.hasMoreToLoad,
timelineMode = mode,
)
}
.let { items ->

View file

@ -38,7 +38,7 @@ private const val MSG_TYPE_GALLERY_UNSTABLE = "dm.filament.gallery"
class EventMessageMapper {
private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) }
fun map(message: MsgLikeKind.Message, inReplyTo: InReplyToDetails?, threadInfo: EventThreadInfo): MessageContent = message.use {
fun map(message: MsgLikeKind.Message, inReplyTo: InReplyToDetails?, threadInfo: EventThreadInfo?): MessageContent = message.use {
val type = it.content.msgType.use(this::mapMessageType)
val inReplyToEvent: InReplyTo? = inReplyTo?.use(inReplyToMapper::map)
MessageContent(

View file

@ -79,7 +79,7 @@ class TimelineEventContentMapper(
content = map(latestEvent.content),
senderId = UserId(latestEvent.sender),
senderProfile = latestEvent.senderProfile.map(),
timestamp = latestEvent.timestamp.toLong()
timestamp = latestEvent.timestamp.toLong(),
)
)
}
@ -89,10 +89,12 @@ class TimelineEventContentMapper(
numberOfReplies = numberOfReplies,
)
}
val threadInfo = EventThreadInfo(
threadRootId = it.content.threadRoot?.let(::ThreadId),
threadSummary = threadSummary,
)
val threadRootId = it.content.threadRoot?.let(::ThreadId)
val threadInfo = when {
threadSummary != null -> EventThreadInfo.ThreadRoot(threadSummary)
threadRootId != null -> EventThreadInfo.ThreadResponse(threadRootId)
else -> null
}
eventMessageMapper.map(kind, inReplyTo, threadInfo)
}
is MsgLikeKind.Redacted -> {

View file

@ -18,9 +18,8 @@ class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) {
items: List<MatrixTimelineItem>,
hasMoreToLoadBackward: Boolean,
hasMoreToLoadForward: Boolean,
timelineMode: Timeline.Mode,
): List<MatrixTimelineItem> {
val shouldAddForwardLoadingIndicator = timelineMode is Timeline.Mode.Live && hasMoreToLoadForward && items.isNotEmpty()
val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty()
val currentTimestamp = systemClock.epochMillis()
return buildList {
if (hasMoreToLoadBackward) {

View file

@ -17,6 +17,7 @@ import org.matrix.rustcomponents.sdk.SpaceRoom
fun aRustSpaceRoom(
roomId: RoomId = A_ROOM_ID,
isDirect: Boolean = false,
canonicalAlias: String? = null,
name: String? = null,
topic: String? = null,
@ -31,6 +32,7 @@ fun aRustSpaceRoom(
heroes: List<RoomHero> = emptyList(),
) = SpaceRoom(
roomId = roomId.value,
isDirect = isDirect,
canonicalAlias = canonicalAlias,
name = name,
topic = topic,

View file

@ -24,7 +24,6 @@ class LoadingIndicatorsPostProcessorTest {
items = listOf(messageEvent, messageEvent2),
hasMoreToLoadBackward = true,
hasMoreToLoadForward = false,
timelineMode = Timeline.Mode.Live,
)
assertThat(result).containsExactly(
MatrixTimelineItem.Virtual(
@ -47,7 +46,6 @@ class LoadingIndicatorsPostProcessorTest {
items = listOf(messageEvent, messageEvent2),
hasMoreToLoadBackward = false,
hasMoreToLoadForward = true,
timelineMode = Timeline.Mode.Live,
)
assertThat(result).containsExactly(
messageEvent,
@ -70,7 +68,6 @@ class LoadingIndicatorsPostProcessorTest {
items = listOf(messageEvent, messageEvent2),
hasMoreToLoadBackward = true,
hasMoreToLoadForward = true,
timelineMode = Timeline.Mode.Live,
)
assertThat(result).containsExactly(
MatrixTimelineItem.Virtual(
@ -100,7 +97,6 @@ class LoadingIndicatorsPostProcessorTest {
items = listOf(),
hasMoreToLoadBackward = true,
hasMoreToLoadForward = true,
timelineMode = Timeline.Mode.Live,
)
assertThat(result).containsExactly(
MatrixTimelineItem.Virtual(

View file

@ -104,7 +104,7 @@ fun aMessageContent(
body: String = "body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo: EventThreadInfo? = null,
messageType: MessageType = TextMessageType(
body = body,
formatted = null

View file

@ -134,10 +134,7 @@ class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() {
private fun aMessageContent(
body: String,
type: MessageType,
threadInfo: EventThreadInfo = EventThreadInfo(
threadRootId = null,
threadSummary = null,
),
threadInfo: EventThreadInfo? = null,
) = MessageContent(
body = body,
inReplyTo = null,

View file

@ -8,7 +8,6 @@
package io.element.android.libraries.matrix.ui.messages.reply
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
@ -70,7 +69,7 @@ class InReplyToDetailTest {
body = "**Hello!**",
inReplyTo = null,
isEdited = false,
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo = null,
type = TextMessageType(
body = "**Hello!**",
formatted = FormattedBody(
@ -95,7 +94,7 @@ class InReplyToDetailTest {
body = "**Hello!**",
inReplyTo = null,
isEdited = false,
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo = null,
type = TextMessageType(
body = "**Hello!**",
formatted = null,

View file

@ -16,6 +16,4 @@ interface PermissionStateProvider {
suspend fun setPermissionAsked(permission: String, value: Boolean)
fun isPermissionAsked(permission: String): Flow<Boolean>
suspend fun resetPermission(permission: String)
}

View file

@ -43,5 +43,6 @@ dependencies {
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.troubleshoot.test)
testImplementation(projects.services.toolbox.test)
}

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