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 461565fc9c
319 changed files with 2828 additions and 1838 deletions

View file

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

View file

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

View file

@ -93,6 +93,8 @@ allprojects {
// Fix compilation warning for annotations // Fix compilation warning for annotations
// See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details // See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details
freeCompilerArgs.add("-Xannotation-default-target=first-only") 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") @SerialName("send_event")
SendEvent, SendEvent,
@SerialName("content_loaded")
ContentLoaded,
} }
} }

View file

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

View file

@ -215,7 +215,7 @@ import kotlin.time.Duration.Companion.seconds
} }
@Test @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 navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
@ -238,7 +238,7 @@ import kotlin.time.Duration.Companion.seconds
messageInterceptor.givenInterceptedMessage( messageInterceptor.givenInterceptedMessage(
""" """
{ {
"action":"io.element.join", "action":"content_loaded",
"api":"fromWidget", "api":"fromWidget",
"widgetId":"1", "widgetId":"1",
"requestId":"1" "requestId":"1"

View file

@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_administrators">"Gweinyddwyr yn unig"</string>
<string name="screen_room_change_permissions_ban_people">"Gwahardd pobl"</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_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_invite_people">"Gwahodd pobl a derbyn ceisiadau i ymuno"</string>
<string name="screen_room_change_permissions_member_moderation">"Cymedroli aelodau"</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_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_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_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_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_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_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">"%1$s (Yn aros)"</string>
<string name="screen_room_change_role_invited_member_name_android">"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_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_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_administrators">"Gweinyddwyr"</string>
<string name="screen_room_change_role_section_moderators">"Cymedrolwyr"</string> <string name="screen_room_change_role_section_moderators">"Cymedrolwyr"</string>
<string name="screen_room_change_role_section_users">"Aelodau"</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_pending_header_title">"Dan ystyriaeth"</string>
<string name="screen_room_member_list_role_administrator">"Gweinyddwr"</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_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_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_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">"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_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_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_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_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_messages_and_content">"Negeseuon a chynnwys"</string>
<string name="screen_room_roles_and_permissions_moderators">"Cymedrolwyr"</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_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">"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> <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_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_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_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_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_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_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_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">"%1$s (Kutsuttu)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(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> <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_moderators">"Valvojat"</string>
<string name="screen_room_change_role_section_users">"Jäsenet"</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_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> <string name="screen_room_member_list_banned_empty">"Tässä huoneessa ei ole porttikieltoja"</string>
<plurals name="screen_room_member_list_header_title"> <plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d henkilö"</item> <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 { interface FtueService {
/** The current state of the FTUE. */ /** The current state of the FTUE. */
val state: StateFlow<FtueState> val state: StateFlow<FtueState>
/** Reset the FTUE state. */
suspend fun reset()
} }
/** The state of the FTUE. */ /** 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin 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.sessionverification.FtueSessionVerificationFlowNode
import io.element.android.features.ftue.impl.state.DefaultFtueService 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.FtueStep
import io.element.android.features.ftue.impl.state.InternalFtueState
import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -49,9 +46,8 @@ import kotlinx.parcelize.Parcelize
class FtueFlowNode( class FtueFlowNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val ftueState: DefaultFtueService, private val defaultFtueService: DefaultFtueService,
private val analyticsEntryPoint: AnalyticsEntryPoint, private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService,
private val lockScreenEntryPoint: LockScreenEntryPoint, private val lockScreenEntryPoint: LockScreenEntryPoint,
) : BaseFlowNode<FtueFlowNode.NavTarget>( ) : BaseFlowNode<FtueFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -80,19 +76,11 @@ class FtueFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
defaultFtueService.ftueStepStateFlow
lifecycle.subscribe(onCreate = { .filterIsInstance(InternalFtueState.Incomplete::class)
moveToNextStepIfNeeded() .onEach {
}) showStep(it.nextStep)
}
analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope)
ftueState.isVerificationStatusKnown
.filter { it }
.onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
} }
@ -104,7 +92,7 @@ class FtueFlowNode(
is NavTarget.SessionVerification -> { is NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback { val callback = object : FtueSessionVerificationFlowNode.Callback {
override fun onDone() { override fun onDone() {
moveToNextStepIfNeeded() defaultFtueService.onUserCompletedSessionVerification()
} }
} }
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback)) createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
@ -112,7 +100,7 @@ class FtueFlowNode(
NavTarget.NotificationsOptIn -> { NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback { val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() { override fun onNotificationsOptInFinished() {
moveToNextStepIfNeeded() defaultFtueService.updateFtueStep()
} }
} }
createNode<NotificationsOptInNode>(buildContext, listOf(callback)) createNode<NotificationsOptInNode>(buildContext, listOf(callback))
@ -123,7 +111,7 @@ class FtueFlowNode(
NavTarget.LockScreenSetup -> { NavTarget.LockScreenSetup -> {
val callback = object : LockScreenEntryPoint.Callback { val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupDone() { override fun onSetupDone() {
moveToNextStepIfNeeded() defaultFtueService.updateFtueStep()
} }
} }
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup) lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
@ -133,8 +121,8 @@ class FtueFlowNode(
} }
} }
private fun moveToNextStepIfNeeded() = lifecycleScope.launch { private fun showStep(ftueStep: FtueStep) {
when (ftueState.getNextStep()) { when (ftueStep) {
FtueStep.WaitingForInitialState -> { FtueStep.WaitingForInitialState -> {
backstack.newRoot(NavTarget.Placeholder) backstack.newRoot(NavTarget.Placeholder)
} }
@ -150,7 +138,6 @@ class FtueFlowNode(
FtueStep.LockscreenSetup -> { FtueStep.LockscreenSetup -> {
backstack.newRoot(NavTarget.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.Manifest
import android.os.Build import android.os.Build
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService 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.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService 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 io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
@Inject @Inject
class DefaultFtueService( class DefaultFtueService(
private val sdkVersionProvider: BuildVersionSdkIntProvider, private val sdkVersionProvider: BuildVersionSdkIntProvider,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val permissionStateProvider: PermissionStateProvider, private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService, private val lockScreenService: LockScreenService,
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
) : FtueService { ) : FtueService {
override val state = MutableStateFlow<FtueState>(FtueState.Unknown) private val userNeedsToConfirmSessionVerificationSuccess = MutableStateFlow(false)
/** val ftueStepStateFlow = MutableStateFlow<InternalFtueState>(InternalFtueState.Unknown)
* 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()
override suspend fun reset() { override val state = ftueStepStateFlow
analyticsService.reset() .mapState {
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { when (it) {
permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS) 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 { private suspend fun getNextStep(completedStep: FtueStep? = null): FtueStep? =
sessionVerificationService.sessionVerifiedStatus when (completedStep) {
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
}
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
null -> if (!isSessionVerificationStateReady()) { null -> if (!isSessionVerificationStateReady()) {
FtueStep.WaitingForInitialState FtueStep.WaitingForInitialState
} else { } else {
getNextStep(FtueStep.WaitingForInitialState) getNextStep(FtueStep.WaitingForInitialState)
} }
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) { FtueStep.WaitingForInitialState -> if (isSessionNotVerified() || userNeedsToConfirmSessionVerificationSuccess.value) {
FtueStep.SessionVerification FtueStep.SessionVerification
} else { } else {
getNextStep(FtueStep.SessionVerification) getNextStep(FtueStep.SessionVerification)
@ -108,9 +117,6 @@ class DefaultFtueService(
} }
private suspend fun isSessionNotVerified(): Boolean { private suspend fun isSessionNotVerified(): Boolean {
// Wait until the session verification status is known
isVerificationStatusKnown.filter { it }.first()
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification() return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
} }
@ -137,14 +143,8 @@ class DefaultFtueService(
return lockScreenService.isSetupRequired().first() return lockScreenService.isSetupRequired().first()
} }
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) fun onUserCompletedSessionVerification() {
internal suspend fun updateState() { userNeedsToConfirmSessionVerificationSuccess.value = false
val nextStep = getNextStep()
state.value = when {
// Final state, there aren't any more next steps
nextStep == null -> FtueState.Complete
else -> FtueState.Incomplete
}
} }
} }

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.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.api.LockScreenEntryPoint 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.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -36,8 +35,7 @@ class DefaultFtueEntryPointTest {
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
analyticsEntryPoint = { _, _ -> lambdaError() }, analyticsEntryPoint = { _, _ -> lambdaError() },
ftueState = createDefaultFtueService(), defaultFtueService = createDefaultFtueService(),
analyticsService = FakeAnalyticsService(),
lockScreenEntryPoint = object : LockScreenEntryPoint { lockScreenEntryPoint = object : LockScreenEntryPoint {
override fun nodeBuilder( override fun nodeBuilder(
parentNode: com.bumble.appyx.core.node.Node, 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.api.state.FtueState
import io.element.android.features.ftue.impl.state.DefaultFtueService 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.FtueStep
import io.element.android.features.ftue.impl.state.InternalFtueState
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService 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.noop.NoopAnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider 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.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -69,9 +68,11 @@ class DefaultFtueServiceTest {
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
service.updateState() service.updateFtueStep()
service.state.test {
assertThat(service.state.value).isEqualTo(FtueState.Complete) assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
}
} }
@Test @Test
@ -90,9 +91,11 @@ class DefaultFtueServiceTest {
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
service.updateState() service.updateFtueStep()
service.state.test {
assertThat(service.state.value).isEqualTo(FtueState.Complete) assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
}
} }
@Test @Test
@ -109,35 +112,30 @@ class DefaultFtueServiceTest {
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
val steps = mutableListOf<FtueStep?>()
// Session verification service.ftueStepStateFlow.test {
steps.add(service.getNextStep(steps.lastOrNull())) assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified) // Session verification
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.SessionVerification))
// Notifications opt in sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
steps.add(service.getNextStep(steps.lastOrNull())) // User completes verification
permissionStateProvider.setPermissionGranted() service.onUserCompletedSessionVerification()
// Notifications opt in
// Entering PIN code assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.NotificationsOptIn))
steps.add(service.getNextStep(steps.lastOrNull())) permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) // Simulate event from NotificationsOptInNode.Callback.onNotificationsOptInFinished
service.updateFtueStep()
// Analytics opt in // Entering PIN code
steps.add(service.getNextStep(steps.lastOrNull())) assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.LockscreenSetup))
analyticsService.setDidAskUserConsent() lockScreenService.setIsPinSetup(true)
// Simulate event from LockScreenEntryPoint.Callback.onSetupDone()
// Final step (null) service.updateFtueStep()
steps.add(service.getNextStep(steps.lastOrNull())) // Analytics opt in
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
assertThat(steps).containsExactly( analyticsService.setDidAskUserConsent()
FtueStep.SessionVerification, // Final step
FtueStep.NotificationsOptIn, assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
FtueStep.LockscreenSetup, }
FtueStep.AnalyticsOptIn,
// Final state
null,
)
} }
@Test @Test
@ -158,10 +156,13 @@ class DefaultFtueServiceTest {
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) service.ftueStepStateFlow.test {
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
analyticsService.setDidAskUserConsent() // Analytics opt in
assertThat(service.getNextStep(null)).isNull() assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
analyticsService.setDidAskUserConsent()
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
}
} }
@Test @Test
@ -180,51 +181,13 @@ class DefaultFtueServiceTest {
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) service.ftueStepStateFlow.test {
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
analyticsService.setDidAskUserConsent() // Analytics opt in
assertThat(service.getNextStep(null)).isNull() assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
} analyticsService.setDidAskUserConsent()
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
@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"))
} }
} }

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.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class FakeFtueService( class FakeFtueService : FtueService {
private val resetLambda: () -> Unit = { lambdaError() },
) : FtueService {
override val state: MutableStateFlow<FtueState> = MutableStateFlow(FtueState.Unknown) override val state: MutableStateFlow<FtueState> = MutableStateFlow(FtueState.Unknown)
override suspend fun reset() {
resetLambda()
}
suspend fun emitState(newState: FtueState) { suspend fun emitState(newState: FtueState) {
state.emit(newState) state.emit(newState)
} }

View file

@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Inject @Inject
@ -102,13 +103,44 @@ class RoomListDataSource(
} }
private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) { 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 -> val roomListRoomSummaries = diffCache.indices().mapNotNull { index ->
if (useCache) { 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 { } 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) 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()) _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_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="full_screen_intent_banner_title">"Gwella profiad eich galwadau"</string>
<string name="screen_home_tab_chats">"Sgyrsiau"</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_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_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> <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">"Gwahoddiadau"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Does gennych chi ddim gwahoddiadau yn aros."</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">"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_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_mixed_empty_state_title">"Does gennych chi ddim sgyrsiau ar gyfer y dewis hwn"</string>
<string name="screen_roomlist_filter_people">"Pobl"</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">"Kutsut"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Sinulla ei ole yhtään odottavaa kutsua."</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">"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_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_mixed_empty_state_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</string>
<string name="screen_roomlist_filter_people">"Ihmiset"</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_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="full_screen_intent_banner_title">"Forbedre samtaleopplevelsen din"</string>
<string name="screen_home_tab_chats">"Chatter"</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_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_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> <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">"邀請"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"您沒有任何擱置中的邀請。"</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">"低優先度"</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_subtitle">"您可以取消選取篩選條件以檢視其他聊天"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"您並無此選擇的聊天"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"您並無此選擇的聊天"</string>
<string name="screen_roomlist_filter_people">"夥伴"</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_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_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_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_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_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> <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_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_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_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> <string name="leave_room_alert_subtitle">"Ydych chi\'n siŵr eich bod am adael yr ystafell?"</string>
</resources> </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_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">"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_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_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_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> <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_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_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_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_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: <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> %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_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_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_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_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_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> <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.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState 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.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.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
@ -328,7 +329,10 @@ class MessagesPresenter(
val displayThreads = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) val displayThreads = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
if (displayThreads) { 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 // 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) navigator.onOpenThread(threadId, null)
} else { } else {
handleActionReply(targetEvent, composerState, timelineProtectionState) 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.ExpandableBottomSheetLayoutState
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.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.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
@ -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.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toAnnotatedString 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.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.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.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.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser 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.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -202,7 +208,13 @@ fun MessagesView(
Column { Column {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
if (state.timelineState.timelineMode is Timeline.Mode.Thread) { 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 { } else {
MessagesViewTopBar( MessagesViewTopBar(
roomName = state.roomName, roomName = state.roomName,
@ -573,14 +585,48 @@ private fun MessagesViewTopBar(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ThreadTopBar( private fun ThreadTopBar(
roomName: String?,
roomAvatarData: AvatarData,
heroes: ImmutableList<AvatarData>,
isTombstoned: Boolean,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
TopAppBar( TopAppBar(
modifier = modifier,
navigationIcon = { navigationIcon = {
BackButton(onClick = onBackClick) BackButton(onClick = onBackClick)
}, },
title = { 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 = {}, 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.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory 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.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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
@ -174,7 +175,7 @@ class DefaultActionListPresenter(
add(TimelineItemAction.ReplyInThread) add(TimelineItemAction.ReplyInThread)
add(TimelineItemAction.Reply) add(TimelineItemAction.Reply)
} else { } 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 // If threads are not enabled, we can reply in a thread if the item is already in the thread
add(TimelineItemAction.ReplyInThread) add(TimelineItemAction.ReplyInThread)
} else { } 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.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions 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.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.anAggregatedReaction
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent 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.core.UserId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom 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.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.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -146,7 +146,7 @@ internal fun aTimelineItemEvent(
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState? = null, sendState: LocalEventSendState? = null,
inReplyTo: InReplyToDetails? = null, inReplyTo: InReplyToDetails? = null,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null), threadInfo: TimelineItemThreadInfo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(), timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(), 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 BUBBLE_RADIUS = 12.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2 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 private val MIN_BUBBLE_WIDTH = 80.dp
@Composable @Composable
@ -66,34 +64,6 @@ fun MessageEventBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit = {}, 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()) { val clickableModifier = if (isTalkbackActive()) {
Modifier Modifier
} else { } else {
@ -108,11 +78,8 @@ fun MessageEventBubble(
} }
// Ignore state.isHighlighted for now, we need a design decision on it. // Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = when { val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine)
state.isMine -> ElementTheme.colors.messageFromMeBackground val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
else -> ElementTheme.colors.messageFromOtherBackground
}
val bubbleShape = bubbleShape()
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
@ -147,7 +114,7 @@ fun MessageEventBubble(
.testTag(TestTags.messageBubble) .testTag(TestTags.messageBubble)
.widthIn( .widthIn(
min = MIN_BUBBLE_WIDTH, min = MIN_BUBBLE_WIDTH,
max = (constraints.maxWidth * BUBBLE_WIDTH_RATIO) max = (constraints.maxWidth * MessageEventBubbleDefaults.BUBBLE_WIDTH_RATIO)
.toInt() .toInt()
.toDp() .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 @PreviewsDayNight
@Composable @Composable
internal fun MessageEventBubblePreview(@PreviewParameter(BubbleStateProvider::class) state: BubbleState) = ElementPreview { 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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -34,6 +37,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.pluralStringResource 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.isTraversalGroup
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp 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.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent 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.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
import io.element.android.libraries.designsystem.text.toPx 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.Icon
import io.element.android.libraries.designsystem.theme.components.Text 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.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.UserId
import io.element.android.libraries.matrix.api.core.toThreadId 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.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.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.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.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.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@ -256,22 +265,22 @@ fun TimelineItemEventRow(
) )
} }
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread) { if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) {
event.threadInfo.threadSummary?.let { threadSummary -> ThreadSummaryView(
val threadPart = stringResource(CommonStrings.common_thread) modifier = if (event.isMine) {
val numberOfReplies = threadSummary.numberOfReplies.toInt().let { replies -> Modifier.align(Alignment.End).padding(end = 16.dp)
pluralStringResource(CommonPlurals.common_replies, replies, replies) } 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 // 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]. * Impact ViewConfiguration.touchSlop by [sensitivityFactor].
* Inspired from https://issuetracker.google.com/u/1/issues/269627294. * Inspired from https://issuetracker.google.com/u/1/issues/269627294.
@ -694,7 +778,7 @@ private fun MessageEventBubbleContent(
else -> ContentPadding.Textual else -> ContentPadding.Textual
} }
CommonLayout( CommonLayout(
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo.threadRootId != null, showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse,
timestampPosition = timestampPosition, timestampPosition = timestampPosition,
paddingBehaviour = paddingBehaviour, paddingBehaviour = paddingBehaviour,
inReplyToDetails = event.inReplyTo, inReplyToDetails = event.inReplyTo,
@ -746,9 +830,27 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
" hopefully can be manually adjusted to test different behaviors." " hopefully can be manually adjusted to test different behaviors."
), ),
groupPosition = TimelineItemGroupPosition.First, groupPosition = TimelineItemGroupPosition.First,
threadInfo = EventThreadInfo( threadInfo = TimelineItemThreadInfo.ThreadRoot(
threadRootId = ThreadId("\$thread-root-id"), latestEventText = "This is the latest message in the thread",
threadSummary = ThreadSummary(AsyncData.Uninitialized, numberOfReplies = 20L) 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, 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.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition 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.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.ThreadId 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.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
@ -58,10 +58,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
), ),
inReplyTo = inReplyToDetails, inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,
threadInfo = EventThreadInfo( threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = ThreadId("\$thread-root-id")),
threadRootId = ThreadId("\$thread-root-id"),
threadSummary = null,
),
groupPosition = TimelineItemGroupPosition.Last, 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.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent 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.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.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.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent 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.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent 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.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.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent 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.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent 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.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
@Inject @Inject
class TimelineItemContentFactory( class TimelineItemContentFactory(
@ -40,26 +45,53 @@ class TimelineItemContentFactory(
private val stateFactory: TimelineItemContentStateFactory, private val stateFactory: TimelineItemContentStateFactory,
private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory, private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory,
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory, private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder,
) { ) {
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { 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 FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> { is MessageContent -> {
val senderDisambiguatedDisplayName = eventTimelineItem.senderProfile.getDisambiguatedDisplayName(eventTimelineItem.sender) val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
messageFactory.create( messageFactory.create(
content = itemContent, content = itemContent,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, 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 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 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 StickerContent -> stickerFactory.create(itemContent)
is PollContent -> pollFactory.create(eventTimelineItem, itemContent) is PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent) is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent() is CallNotifyContent -> TimelineItemRtcNotificationContent()
is UnknownContent -> TimelineItemUnknownContent 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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory 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 io.element.android.libraries.matrix.api.timeline.item.event.PollContent
@Inject @Inject
@ -19,14 +19,16 @@ class TimelineItemContentPollFactory(
private val pollContentStateFactory: PollContentStateFactory, private val pollContentStateFactory: PollContentStateFactory,
) { ) {
suspend fun create( suspend fun create(
event: EventTimelineItem, eventId: EventId?,
isEditable: Boolean,
isOwn: Boolean,
content: PollContent, content: PollContent,
): TimelineItemEventContent { ): TimelineItemEventContent {
val pollContentState = pollContentStateFactory.create(event, content) val pollContentState = pollContentStateFactory.create(eventId, isEditable, isOwn, content)
return TimelineItemPollContent( return TimelineItemPollContent(
isMine = pollContentState.isMine, isMine = pollContentState.isMine,
isEditable = pollContentState.isPollEditable, isEditable = pollContentState.isPollEditable,
eventId = event.eventId, eventId = eventId,
question = pollContentState.question, question = pollContentState.question,
answerItems = pollContentState.answerItems, answerItems = pollContentState.answerItems,
pollKind = pollContentState.pollKind, 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.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter 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 @Inject
class TimelineItemContentProfileChangeFactory( class TimelineItemContentProfileChangeFactory(
private val timelineEventFormatter: TimelineEventFormatter, private val timelineEventFormatter: TimelineEventFormatter,
) { ) {
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { fun create(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem) val text = timelineEventFormatter.format(content, isOutgoing, sender, senderDisambiguatedDisplayName)
return TimelineItemProfileChangeContent(text.orEmpty().toString()) 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.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter 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 @Inject
class TimelineItemContentRoomMembershipFactory( class TimelineItemContentRoomMembershipFactory(
private val timelineEventFormatter: TimelineEventFormatter, private val timelineEventFormatter: TimelineEventFormatter,
) { ) {
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { fun create(eventContent: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem) val text = timelineEventFormatter.format(eventContent, isOutgoing, sender, senderDisambiguatedDisplayName)
return TimelineItemRoomMembershipContent(text.orEmpty().toString()) 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.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter 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 @Inject
class TimelineItemContentStateFactory( class TimelineItemContentStateFactory(
private val timelineEventFormatter: TimelineEventFormatter, private val timelineEventFormatter: TimelineEventFormatter,
) { ) {
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { fun create(eventContent: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent {
val text = timelineEventFormatter.format(eventTimelineItem) val text = timelineEventFormatter.format(eventContent, isOutgoing, sender, senderDisambiguatedDisplayName)
return TimelineItemStateEventContent(text.orEmpty().toString()) 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.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions 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.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.core.bool.orTrue
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
@ -43,6 +46,7 @@ class TimelineItemEventFactory(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val summaryFormatter: MessageSummaryFormatter,
) { ) {
@AssistedFactory @AssistedFactory
interface Creator { interface Creator {
@ -69,6 +73,29 @@ class TimelineItemEventFactory(
url = senderProfile.getAvatarUrl(), url = senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineSender 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( return TimelineItem.Event(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId, eventId = currentTimelineItem.eventId,
@ -87,7 +114,7 @@ class TimelineItemEventFactory(
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
localSendState = currentTimelineItem.event.localSendState, localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser), inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
threadInfo = currentTimelineItem.event.threadInfo() ?: EventThreadInfo(threadRootId = null, threadSummary = null), threadInfo = mappedThreadInfo,
origin = currentTimelineItem.event.origin, origin = currentTimelineItem.event.origin,
timelineItemDebugInfoProvider = currentTimelineItem.event.timelineItemDebugInfoProvider, timelineItemDebugInfoProvider = currentTimelineItem.event.timelineItemDebugInfoProvider,
messageShieldProvider = currentTimelineItem.event.messageShieldProvider, 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.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId 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.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.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId 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.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.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
@ -82,7 +83,7 @@ sealed interface TimelineItem {
val readReceiptState: TimelineItemReadReceipts, val readReceiptState: TimelineItemReadReceipts,
val localSendState: LocalEventSendState?, val localSendState: LocalEventSendState?,
val inReplyTo: InReplyToDetails?, val inReplyTo: InReplyToDetails?,
val threadInfo: EventThreadInfo, val threadInfo: TimelineItemThreadInfo?,
val origin: TimelineItemEventOrigin?, val origin: TimelineItemEventOrigin?,
val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider, val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider,
val messageShieldProvider: MessageShieldProvider, val messageShieldProvider: MessageShieldProvider,
@ -130,3 +131,8 @@ sealed interface TimelineItem {
val aggregatedReadReceipts: ImmutableList<ReadReceiptData>, val aggregatedReadReceipts: ImmutableList<ReadReceiptData>,
) : TimelineItem ) : 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 android.content.Context
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject 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.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent 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.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent 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( class DefaultMessageSummaryFormatter(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : MessageSummaryFormatter { ) : MessageSummaryFormatter {
override fun format(event: TimelineItem.Event): String { override fun format(content: TimelineItemEventContent): String {
return when (event.content) { return when (content) {
is TimelineItemTextBasedContent -> event.content.plainText is TimelineItemTextBasedContent -> content.plainText
is TimelineItemProfileChangeContent -> event.content.body is TimelineItemProfileChangeContent -> content.body
is TimelineItemStateContent -> event.content.body is TimelineItemStateContent -> content.body
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location) is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt) is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed) 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 TimelineItemVoiceContent -> context.getString(CommonStrings.common_voice_message)
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event) is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image) is TimelineItemImageContent -> context.getString(CommonStrings.common_image)

View file

@ -8,7 +8,11 @@
package io.element.android.features.messages.impl.utils.messagesummary 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
interface MessageSummaryFormatter { 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_objects">"Předměty"</string>
<string name="emoji_picker_category_people">"Smajlíci a lidé"</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_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="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_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> <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_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_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_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_optimize_image_quality_title">"Optimalizace kvality obrazu"</string>
<string name="screen_media_upload_preview_processing">"Probíhá zpracování…"</string> <string name="screen_media_upload_preview_processing">"Probíhá zpracování…"</string>
<string name="screen_report_content_block_user">"Zablokovat uživatele"</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_objects">"Gwrthrychau"</string>
<string name="emoji_picker_category_people">"Wynebau Hapus a Phobl"</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_places">"Teithio a Llefydd"</string>
<string name="emoji_picker_category_recent">"Emojis diweddar"</string>
<string name="emoji_picker_category_symbols">"Symbolau"</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_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_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_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">"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_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> <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_less_reactions">"Dangos llai"</string>
<string name="screen_room_timeline_message_copied">"Neges wedi\'i chopïo"</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> <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_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_less">"Dangos llai"</string>
<string name="screen_room_timeline_reactions_show_more">"Dangos rhagor"</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_objects">"Esineet"</string>
<string name="emoji_picker_category_people">"Hymiöt ja ihmiset"</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_places">"Matkustaminen ja paikat"</string>
<string name="emoji_picker_category_recent">"Viimeaikaiset emojit"</string>
<string name="emoji_picker_category_symbols">"Symbolit"</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_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> <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_places">"旅行與景點"</string>
<string name="emoji_picker_category_symbols">"標誌"</string> <string name="emoji_picker_category_symbols">"標誌"</string>
<string name="screen_media_upload_preview_caption_warning">"使用舊應用程式的使用者可能看不到標題。"</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_could_not_be_uploaded">"無法上傳檔案。"</string>
<string name="screen_media_upload_preview_error_failed_processing">"無法處理要上傳的媒體,請再試一次。"</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_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_message">"允許的最大檔案大小為 %1$s。"</string>
<string name="screen_media_upload_preview_error_too_large_title">"檔案太大,無法上傳"</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">"封鎖使用者"</string>
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string> <string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</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_objects">"Objects"</string>
<string name="emoji_picker_category_people">"Smileys &amp; People"</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_places">"Travel &amp; Places"</string>
<string name="emoji_picker_category_recent">"Recent emojis"</string>
<string name="emoji_picker_category_symbols">"Symbols"</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_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> <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_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_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_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_optimize_image_quality_title">"Optimise image quality"</string>
<string name="screen_media_upload_preview_processing">"Processing…"</string> <string name="screen_media_upload_preview_processing">"Processing…"</string>
<string name="screen_report_content_block_user">"Block user"</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.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents 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.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.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent 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.RoomMembershipState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom 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.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.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@ -1181,7 +1181,7 @@ class MessagesPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction( initialState.eventSink(MessagesEvents.HandleAction(
action = TimelineItemAction.ReplyInThread, action = TimelineItemAction.ReplyInThread,
event = aMessageEvent(threadInfo = EventThreadInfo(A_THREAD_ID, null)) event = aMessageEvent(threadInfo = TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
)) ))
awaitItem() awaitItem()
openThreadLambda.assertions().isCalledOnce().with(value(A_THREAD_ID), value(null)) openThreadLambda.assertions().isCalledOnce().with(value(A_THREAD_ID), value(null))
@ -1204,7 +1204,7 @@ class MessagesPresenterTest {
event = aMessageEvent( event = aMessageEvent(
// The event id will be used as the thread id instead // The event id will be used as the thread id instead
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
threadInfo = EventThreadInfo(null, null), threadInfo = null,
) )
)) ))
awaitItem() 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.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.fixtures.aMessageEvent 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.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.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent 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.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.BaseRoom 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.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.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_CAPTION import io.element.android.libraries.matrix.test.A_CAPTION
@ -198,7 +198,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
isEditable = 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) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
@ -432,7 +432,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, 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) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
@ -1264,7 +1264,7 @@ class ActionListPresenterTest {
content = aTimelineItemVoiceContent( content = aTimelineItemVoiceContent(
caption = null, caption = null,
), ),
threadInfo = EventThreadInfo(A_THREAD_ID, null) threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
@ -1368,7 +1368,7 @@ class ActionListPresenterTest {
content = aTimelineItemVoiceContent( content = aTimelineItemVoiceContent(
caption = null, caption = null,
), ),
threadInfo = EventThreadInfo(A_THREAD_ID, null), threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID),
) )
assertThat(messageEvent.isRemote).isTrue() 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.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.EventId
import io.element.android.libraries.matrix.api.core.TransactionId 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.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.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShieldProvider import io.element.android.libraries.matrix.api.timeline.item.event.MessageShieldProvider
import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider
@ -41,7 +41,7 @@ internal fun aMessageEvent(
canBeRepliedTo: Boolean = true, canBeRepliedTo: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false), content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false),
inReplyTo: InReplyToDetails? = null, inReplyTo: InReplyToDetails? = null,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null), threadInfo: TimelineItemThreadInfo? = null,
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID), sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() }, debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() },
messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null }, messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null },

View file

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

View file

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

View file

@ -750,7 +750,7 @@ class TimelineItemContentMessageFactoryTest {
body: String = "Body", body: String = "Body",
inReplyTo: InReplyTo? = null, inReplyTo: InReplyTo? = null,
isEdited: Boolean = false, isEdited: Boolean = false,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null), threadInfo: EventThreadInfo? = null,
type: MessageType, type: MessageType,
): MessageContent { ): MessageContent {
return 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.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData 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.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.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
@ -42,7 +41,7 @@ class TimelineItemGrouperTest {
isEditable = false, isEditable = false,
canBeRepliedTo = false, canBeRepliedTo = false,
inReplyTo = null, inReplyTo = null,
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null), threadInfo = null,
origin = null, origin = null,
timelineItemDebugInfoProvider = { aTimelineItemDebugInfo() }, timelineItemDebugInfoProvider = { aTimelineItemDebugInfo() },
messageShieldProvider = { null }, messageShieldProvider = { null },

View file

@ -7,9 +7,18 @@
package io.element.android.features.poll.api.pollcontent 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.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
interface PollContentStateFactory { 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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_will_remove_selection">"Bydd yn dileu\'r dewis blaenorol"</string>
<string name="a11y_polls_winning_answer">"Dyma\'r ateb buddugol"</string> <string name="a11y_polls_winning_answer">"Dyma\'r ateb buddugol"</string>
</resources> </resources>

View file

@ -45,7 +45,12 @@ class PollHistoryItemsFactory(
return when (timelineItem) { return when (timelineItem) {
is MatrixTimelineItem.Event -> { is MatrixTimelineItem.Event -> {
val pollContent = timelineItem.event.content as? PollContent ?: return null 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( PollHistoryItem(
formattedDate = dateFormatter.format( formattedDate = dateFormatter.format(
timestamp = timelineItem.event.timestamp, 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.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient 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.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 io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -25,8 +25,10 @@ class DefaultPollContentStateFactory(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
) : PollContentStateFactory { ) : PollContentStateFactory {
override suspend fun create( override suspend fun create(
event: EventTimelineItem, eventId: EventId?,
content: PollContent isEditable: Boolean,
isOwn: Boolean,
content: PollContent,
): PollContentState { ): PollContentState {
val totalVoteCount = content.votes.flatMap { it.value }.size val totalVoteCount = content.votes.flatMap { it.value }.size
val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys
@ -59,13 +61,13 @@ class DefaultPollContentStateFactory(
} }
return PollContentState( return PollContentState(
eventId = event.eventId, eventId = eventId,
question = content.question, question = content.question,
answerItems = answerItems.toImmutableList(), answerItems = answerItems.toImmutableList(),
pollKind = content.kind, pollKind = content.kind,
isPollEditable = event.isEditable, isPollEditable = isEditable,
isPollEnded = isPollEnded, 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.PollAnswerItem
import io.element.android.features.poll.api.pollcontent.PollContentState import io.element.android.features.poll.api.pollcontent.PollContentState
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory 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 io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
class FakePollContentStateFactory : PollContentStateFactory { 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( return PollContentState(
eventId = event.eventId, eventId = eventId,
question = content.question, question = content.question,
answerItems = emptyList<PollAnswerItem>().toImmutableList(), answerItems = emptyList<PollAnswerItem>().toImmutableList(),
pollKind = content.kind, pollKind = content.kind,
isPollEditable = event.isEditable, isPollEditable = isEditable,
isPollEnded = content.endTime != null, isPollEnded = content.endTime != null,
isMine = event.isOwn isMine = isOwn,
) )
} }
} }

View file

@ -72,7 +72,6 @@ dependencies {
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.features.lockscreen.api) implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api)
implementation(projects.features.licenses.api) implementation(projects.features.licenses.api)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)
implementation(projects.features.deactivation.api) implementation(projects.features.deactivation.api)
@ -101,7 +100,6 @@ dependencies {
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.ftue.test)
testImplementation(projects.features.invite.test) testImplementation(projects.features.invite.test)
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.logout.test) testImplementation(projects.features.logout.test)

View file

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

View file

@ -12,7 +12,6 @@ import coil3.SingletonImageLoader
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Provider 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.invite.api.SeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -36,7 +35,6 @@ class DefaultClearCacheUseCase(
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val defaultCacheService: DefaultCacheService, private val defaultCacheService: DefaultCacheService,
private val okHttpClient: Provider<OkHttpClient>, private val okHttpClient: Provider<OkHttpClient>,
private val ftueService: FtueService,
private val pushService: PushService, private val pushService: PushService,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val activeRoomsHolder: ActiveRoomsHolder, private val activeRoomsHolder: ActiveRoomsHolder,
@ -56,7 +54,6 @@ class DefaultClearCacheUseCase(
// Clear app cache // Clear app cache
context.cacheDir.deleteRecursively() context.cacheDir.deleteRecursively()
// Clear some settings // Clear some settings
ftueService.reset()
seenInvitesStore.clear() seenInvitesStore.clear()
// Ensure any error will be displayed again // Ensure any error will be displayed again
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false) 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_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_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_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_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_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> <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_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_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_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_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_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> <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_description">"上傳照片與影片更快且減少資料使用量"</string>
<string name="screen_advanced_settings_media_compression_title">"最佳化媒體品質"</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_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_push_provider_android">"推播通知提供者"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"手動輸入 Markdown停用格式化文字編輯器。"</string> <string name="screen_advanced_settings_rich_text_editor_description">"手動輸入 Markdown停用格式化文字編輯器。"</string>
<string name="screen_advanced_settings_send_read_receipts">"已讀回條"</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 androidx.test.platform.app.InstrumentationRegistry
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.invite.test.InMemorySeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@ -41,10 +40,6 @@ class DefaultClearCacheUseCaseTest {
clearCacheLambda = clearCacheLambda, clearCacheLambda = clearCacheLambda,
) )
val defaultCacheService = DefaultCacheService() val defaultCacheService = DefaultCacheService()
val resetFtueLambda = lambdaRecorder<Unit> { }
val ftueService = FakeFtueService(
resetLambda = resetFtueLambda,
)
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> } val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
val resetBatteryOptimizationStateResult = lambdaRecorder<Unit> { } val resetBatteryOptimizationStateResult = lambdaRecorder<Unit> { }
val pushService = FakePushService( val pushService = FakePushService(
@ -59,7 +54,6 @@ class DefaultClearCacheUseCaseTest {
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
defaultCacheService = defaultCacheService, defaultCacheService = defaultCacheService,
okHttpClient = { OkHttpClient.Builder().build() }, okHttpClient = { OkHttpClient.Builder().build() },
ftueService = ftueService,
pushService = pushService, pushService = pushService,
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
activeRoomsHolder = activeRoomsHolder, activeRoomsHolder = activeRoomsHolder,
@ -67,7 +61,6 @@ class DefaultClearCacheUseCaseTest {
defaultCacheService.clearedCacheEventFlow.test { defaultCacheService.clearedCacheEventFlow.test {
sut.invoke() sut.invoke()
clearCacheLambda.assertions().isCalledOnce() clearCacheLambda.assertions().isCalledOnce()
resetFtueLambda.assertions().isCalledOnce()
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce() setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
.with(value(matrixClient.sessionId), value(false)) .with(value(matrixClient.sessionId), value(false))
resetBatteryOptimizationStateResult.assertions().isCalledOnce() 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_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_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_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_invite_people_title">"Pozvat přátele"</string>
<string name="screen_room_details_leave_conversation_title">"Opustit konverzaci"</string> <string name="screen_room_details_leave_conversation_title">"Opustit konverzaci"</string>
<string name="screen_room_details_leave_room_title">"Opustit místnost"</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_polls_history_title">"Pleidleisiau"</string>
<string name="screen_room_change_permissions_administrators">"Gweinyddwyr yn unig"</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_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_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_invite_people">"Gwahodd pobl a derbyn ceisiadau i ymuno"</string>
<string name="screen_room_change_permissions_member_moderation">"Cymedroli aelodau"</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_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_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_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_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_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_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">"%1$s (Yn aros)"</string>
<string name="screen_room_change_role_invited_member_name_android">"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_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_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_administrators">"Gweinyddwyr"</string>
<string name="screen_room_change_role_section_moderators">"Cymedrolwyr"</string> <string name="screen_room_change_role_section_moderators">"Cymedrolwyr"</string>
<string name="screen_room_change_role_section_users">"Aelodau"</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_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_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_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_invite_people_title">"Gwahodd pobl"</string>
<string name="screen_room_details_leave_conversation_title">"Gadael y sgwrs"</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> <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_pending_header_title">"Dan ystyriaeth"</string>
<string name="screen_room_member_list_role_administrator">"Gweinyddwr"</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_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_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_member_list_unbanning_user">"Dad-wahardd %1$s"</string>
<string name="screen_room_notification_settings_allow_custom">"Caniatáu gosodiad personol"</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_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_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">"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_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_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_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_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_messages_and_content">"Negeseuon a chynnwys"</string>
<string name="screen_room_roles_and_permissions_moderators">"Cymedrolwyr"</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_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">"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> <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_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_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_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_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_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_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_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">"%1$s (Kutsuttu)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(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> <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_moderators">"Valvojat"</string>
<string name="screen_room_change_role_section_users">"Jäsenet"</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_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_add_topic_title">"Lisää aihe"</string>
<string name="screen_room_details_badge_encrypted">"Salattu"</string> <string name="screen_room_details_badge_encrypted">"Salattu"</string>
<string name="screen_room_details_badge_not_encrypted">"Ei 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_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_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_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_invite_people_title">"Kutsu ihmisiä"</string>
<string name="screen_room_details_leave_conversation_title">"Poistu keskustelusta"</string> <string name="screen_room_details_leave_conversation_title">"Poistu keskustelusta"</string>
<string name="screen_room_details_leave_room_title">"Poistu huoneesta"</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_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_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_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_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_conversation_title">"Quitter la discussion"</string>
<string name="screen_room_details_leave_room_title">"Quitter le salon"</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_loading_notification_settings">"載入通知設定時發生錯誤。"</string>
<string name="screen_room_details_error_muting">"無法關閉聊天室通知,請再試一次。"</string> <string name="screen_room_details_error_muting">"無法關閉聊天室通知,請再試一次。"</string>
<string name="screen_room_details_error_unmuting">"無法開啟聊天室通知,請再試一次。"</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_invite_people_title">"邀請夥伴"</string>
<string name="screen_room_details_leave_conversation_title">"離開對話"</string> <string name="screen_room_details_leave_conversation_title">"離開對話"</string>
<string name="screen_room_details_leave_room_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">"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_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_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_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_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> <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" haze = "1.6.10"
# Dependency analysis # Dependency analysis
dependencyAnalysis = "2.19.0" dependencyAnalysis = "3.0.4"
# DI # DI
metro = "0.6.4" 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" } 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" } 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 # 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" } 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" } 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" } 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 # 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 # 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. # 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 # Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } 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_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2" 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_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.2.0" 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" } metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" }
# Element Call # 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 # Auto services
google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } 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), TimelineRoom(32.dp),
TimelineSender(32.dp), TimelineSender(32.dp),
TimelineReadReceipt(16.dp), TimelineReadReceipt(16.dp),
TimelineThreadLatestEventSender(24.dp),
ComposerAlert(32.dp), ComposerAlert(32.dp),

View file

@ -7,8 +7,19 @@
package io.element.android.libraries.eventformatter.api 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.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
interface TimelineEventFormatter { 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.di.SessionScope
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode 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.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.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -43,12 +45,16 @@ class DefaultTimelineEventFormatter(
override fun format(event: EventTimelineItem): CharSequence? { override fun format(event: EventTimelineItem): CharSequence? {
val isOutgoing = event.isOwn val isOutgoing = event.isOwn
val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender) 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 -> { is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing) roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing)
} }
is ProfileChangeContent -> { is ProfileChangeContent -> {
profileChangeContentFormatter.format(content, event.sender, senderDisambiguatedDisplayName, isOutgoing) profileChangeContentFormatter.format(content, sender, senderDisambiguatedDisplayName, isOutgoing)
} }
is StateContent -> { is StateContent -> {
stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.Timeline) stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.Timeline)
@ -66,7 +72,7 @@ class DefaultTimelineEventFormatter(
is FailedToParseStateContent, is FailedToParseStateContent,
is UnknownContent -> { is UnknownContent -> {
if (buildMeta.isDebuggable) { 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) 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.core.UserId
import io.element.android.libraries.matrix.api.media.ImageInfo 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.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.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@ -175,7 +174,7 @@ class DefaultBaseRoomLastMessageFormatterTest {
) { ) {
val body = "Shared body" val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent { fun createMessageContent(type: MessageType): MessageContent {
return MessageContent(body, null, false, EventThreadInfo(null, null), type) return MessageContent(body, null, false, null, type)
} }
val sharedContentMessagesTypes = arrayOf( 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.core.UserId
import io.element.android.libraries.matrix.api.media.ImageInfo 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.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.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@ -130,7 +129,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
fun `Message contents`() { fun `Message contents`() {
val body = "Shared body" val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent { fun createMessageContent(type: MessageType): MessageContent {
return MessageContent(body, null, false, EventThreadInfo(null, null), type) return MessageContent(body, null, false, null, type)
} }
val sharedContentMessagesTypes = arrayOf( 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.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
data class EventThreadInfo( sealed interface EventThreadInfo {
val threadRootId: ThreadId?, data class ThreadRoot(val summary: ThreadSummary) : EventThreadInfo
val threadSummary: ThreadSummary?, data class ThreadResponse(val threadRootId: ThreadId) : EventThreadInfo
) }
data class ThreadSummary( data class ThreadSummary(
val latestEvent: AsyncData<EmbeddedEventInfo>, val latestEvent: AsyncData<EmbeddedEventInfo>,

View file

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

View file

@ -33,6 +33,12 @@ class RoomSummaryListProcessor(
updates.forEach { update -> updates.forEach { update ->
applyUpdate(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( 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 { init {
@ -221,7 +221,6 @@ class RustTimeline(
items = items, items = items,
hasMoreToLoadBackward = backwardPaginationStatus.hasMoreToLoad, hasMoreToLoadBackward = backwardPaginationStatus.hasMoreToLoad,
hasMoreToLoadForward = forwardPaginationStatus.hasMoreToLoad, hasMoreToLoadForward = forwardPaginationStatus.hasMoreToLoad,
timelineMode = mode,
) )
} }
.let { items -> .let { items ->

View file

@ -38,7 +38,7 @@ private const val MSG_TYPE_GALLERY_UNSTABLE = "dm.filament.gallery"
class EventMessageMapper { class EventMessageMapper {
private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) } 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 type = it.content.msgType.use(this::mapMessageType)
val inReplyToEvent: InReplyTo? = inReplyTo?.use(inReplyToMapper::map) val inReplyToEvent: InReplyTo? = inReplyTo?.use(inReplyToMapper::map)
MessageContent( MessageContent(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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