Merge branch 'develop' into feature/fga/room_list_api

This commit is contained in:
ganfra 2023-06-28 15:14:06 +02:00
commit e3744636b6
935 changed files with 6059 additions and 3293 deletions

View file

@ -23,6 +23,12 @@
"org.jetbrains.kotlinx.kover" "org.jetbrains.kotlinx.kover"
], ],
"enabled" : false "enabled" : false
},
{
"matchPackagePatterns" : [
"^org.maplibre"
],
"versioning" : "semver"
} }
] ]
} }

View file

@ -47,6 +47,8 @@ jobs:
app/build/outputs/apk/debug/*.apk app/build/outputs/apk/debug/*.apk
- uses: rnkdsh/action-upload-diawi@v1.5.0 - uses: rnkdsh/action-upload-diawi@v1.5.0
id: diawi id: diawi
# Do not fail the whole build if Diawi upload fails
continue-on-error: true
env: env:
token: ${{ secrets.DIAWI_TOKEN }} token: ${{ secrets.DIAWI_TOKEN }}
if: ${{ github.event_name == 'pull_request' && env.token != '' }} if: ${{ github.event_name == 'pull_request' && env.token != '' }}

View file

@ -42,7 +42,7 @@ jobs:
name: elementx-debug name: elementx-debug
path: | path: |
app/build/outputs/apk/debug/*.apk app/build/outputs/apk/debug/*.apk
- uses: mobile-dev-inc/action-maestro-cloud@v1.3.3 - uses: mobile-dev-inc/action-maestro-cloud@v1.4.1
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
app-file: app/build/outputs/apk/debug/app-universal-debug.apk app-file: app/build/outputs/apk/debug/app-universal-debug.apk

View file

@ -3,6 +3,7 @@
<!--- TOC --> <!--- TOC -->
* [Contributing code to Matrix](#contributing-code-to-matrix) * [Contributing code to Matrix](#contributing-code-to-matrix)
* [Developer onboarding](#developer-onboarding)
* [Android Studio settings](#android-studio-settings) * [Android Studio settings](#android-studio-settings)
* [Compilation](#compilation) * [Compilation](#compilation)
* [Strings](#strings) * [Strings](#strings)
@ -32,6 +33,10 @@ Element X Android support can be found in this room: [![Element Android Matrix r
The rest of the document contains specific rules for Matrix Android projects The rest of the document contains specific rules for Matrix Android projects
## Developer onboarding
For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md).
## Android Studio settings ## Android Studio settings
Please set the "hard wrap" setting of Android Studio to 160 chars, this is the setting we use internally to format the source code (Menu `Settings/Editor/Code Style` then `Hard wrap at`). Please set the "hard wrap" setting of Android Studio to 160 chars, this is the setting we use internally to format the source code (Menu `Settings/Editor/Code Style` then `Hard wrap at`).

View file

@ -26,6 +26,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import com.bumble.appyx.core.integration.NodeHost import com.bumble.appyx.core.integration.NodeHost
@ -33,9 +34,10 @@ import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher
import io.element.android.x.di.AppBindings import io.element.android.x.di.AppBindings
import io.element.android.x.intent.SafeUriHandler
import timber.log.Timber import timber.log.Timber
private val loggerTag = LoggerTag("MainActivity") private val loggerTag = LoggerTag("MainActivity")
@ -63,6 +65,7 @@ class MainActivity : NodeComponentActivity() {
ElementTheme { ElementTheme {
CompositionLocalProvider( CompositionLocalProvider(
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(), LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
LocalUriHandler provides SafeUriHandler(this),
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.intent
import android.app.Activity
import androidx.compose.ui.platform.UriHandler
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
class SafeUriHandler(private val activity: Activity) : UriHandler {
override fun openUri(uri: String) {
activity.openUrlInExternalApp(uri)
}
}

View file

@ -22,5 +22,5 @@
<item name="postSplashScreenTheme">@style/Theme.ElementX</item> <item name="postSplashScreenTheme">@style/Theme.ElementX</item>
</style> </style>
<style name="Theme.ElementX" parent="Theme.ElementAndroid" /> <style name="Theme.ElementX" parent="Theme.Material3.Dark" />
</resources> </resources>

View file

@ -21,5 +21,5 @@
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item> <item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="postSplashScreenTheme">@style/Theme.ElementX</item> <item name="postSplashScreenTheme">@style/Theme.ElementX</item>
</style> </style>
<style name="Theme.ElementX" parent="Theme.ElementAndroid" /> <style name="Theme.ElementX" parent="Theme.Material3.Light" />
</resources> </resources>

View file

@ -21,7 +21,7 @@ import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.ui.strings.R import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
@ -48,13 +48,13 @@ class LoggedInEventProcessor @Inject constructor(
fun observeEvents(coroutineScope: CoroutineScope) { fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = coroutineScope.launch { observingJob = coroutineScope.launch {
displayLeftRoomMessage.onEach { displayLeftRoomMessage.onEach {
displayMessage(R.string.common_current_user_left_room) displayMessage(CommonStrings.common_current_user_left_room)
}.launchIn(this) }.launchIn(this)
displayVerificationSuccessfulMessage displayVerificationSuccessfulMessage
.drop(1) .drop(1)
.onEach { .onEach {
displayMessage(R.string.common_verification_complete) displayMessage(CommonStrings.common_verification_complete)
}.launchIn(this) }.launchIn(this)
} }
} }

View file

@ -257,6 +257,10 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onRoomSettingsClicked(roomId: RoomId) { override fun onRoomSettingsClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId, initialElement = RoomFlowNode.NavTarget.RoomDetails)) backstack.push(NavTarget.Room(roomId, initialElement = RoomFlowNode.NavTarget.RoomDetails))
} }
override fun onReportBugClicked() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}
} }
roomListEntryPoint roomListEntryPoint
.nodeBuilder(this, buildContext) .nodeBuilder(this, buildContext)

1
changelog.d/517.bugfix Normal file
View file

@ -0,0 +1 @@
Crash when clicking on a link with associated text.

View file

@ -27,12 +27,12 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.LinkColor
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.theme.LinkColor
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun AnalyticsPreferencesView( fun AnalyticsPreferencesView(
@ -43,11 +43,11 @@ fun AnalyticsPreferencesView(
state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled)) state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled))
} }
PreferenceCategory(title = stringResource(id = StringR.string.screen_analytics_settings_share_data)) { PreferenceCategory(title = stringResource(id = CommonStrings.screen_analytics_settings_share_data)) {
val firstPart = stringResource(id = StringR.string.screen_analytics_settings_help_us_improve, state.applicationName) val firstPart = stringResource(id = CommonStrings.screen_analytics_settings_help_us_improve, state.applicationName)
val secondPart = buildAnnotatedStringWithColoredPart( val secondPart = buildAnnotatedStringWithColoredPart(
StringR.string.screen_analytics_settings_read_terms, CommonStrings.screen_analytics_settings_read_terms,
StringR.string.screen_analytics_settings_read_terms_content_link CommonStrings.screen_analytics_settings_read_terms_content_link
) )
val title = "$firstPart\n\n$secondPart" val title = "$firstPart\n\n$secondPart"

View file

@ -36,7 +36,6 @@ dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
api(projects.features.analytics.api) api(projects.features.analytics.api)
api(projects.services.analytics.api) api(projects.services.analytics.api)

View file

@ -55,13 +55,13 @@ import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
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.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun AnalyticsOptInView( fun AnalyticsOptInView(
@ -162,7 +162,7 @@ private fun AnalyticsOptInContentRow(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background( .background(
color = LocalColors.current.quinary, color = ElementTheme.legacyColors.quinary,
shape = bgShape, shape = bgShape,
) )
.padding(vertical = 12.dp, horizontal = 20.dp), .padding(vertical = 12.dp, horizontal = 20.dp),
@ -174,8 +174,7 @@ private fun AnalyticsOptInContentRow(
.padding(2.dp), .padding(2.dp),
imageVector = Icons.Rounded.Check, imageVector = Icons.Rounded.Check,
contentDescription = null, contentDescription = null,
// TODO Compound, this color is not yet in the theme tint = ElementTheme.colors.iconSuccessPrimary,
tint = Color(0xFF007A61)
) )
Text( Text(
modifier = Modifier.padding(start = 16.dp), modifier = Modifier.padding(start = 16.dp),
@ -199,13 +198,13 @@ private fun AnalyticsOptInFooter(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) }, onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(text = stringResource(id = StringR.string.action_ok)) Text(text = stringResource(id = CommonStrings.action_ok))
} }
TextButton( TextButton(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) }, onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(text = stringResource(id = StringR.string.action_not_now)) Text(text = stringResource(id = CommonStrings.action_not_now))
} }
} }
} }

View file

@ -43,9 +43,9 @@ dependencies {
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.deeplink)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.usersearch.impl) implementation(projects.libraries.usersearch.impl)

View file

@ -42,7 +42,7 @@ import io.element.android.libraries.designsystem.theme.components.CenterAlignedT
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
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -107,7 +107,7 @@ fun AddPeopleViewTopBar(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp),
onClick = onNextPressed, onClick = onNextPressed,
) { ) {
val textActionResId = if (hasSelectedUsers) StringR.string.action_next else StringR.string.action_skip val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip
Text( Text(
text = stringResource(id = textActionResId), text = stringResource(id = textActionResId),
fontSize = 16.sp, fontSize = 16.sp,

View file

@ -20,9 +20,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
@ -40,7 +40,7 @@ fun SearchMultipleUsersResultItem(
CheckableUnresolvedUserRow( CheckableUnresolvedUserRow(
checked = isUserSelected, checked = isUserSelected,
modifier = modifier, modifier = modifier,
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
id = searchResult.matrixUser.userId.value, id = searchResult.matrixUser.userId.value,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
) )
@ -49,7 +49,7 @@ fun SearchMultipleUsersResultItem(
checked = isUserSelected, checked = isUserSelected,
modifier = modifier, modifier = modifier,
matrixUser = searchResult.matrixUser, matrixUser = searchResult.matrixUser,
avatarSize = AvatarSize.Custom(36.dp), avatarSize = AvatarSize.UserListItem,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
) )
} }
@ -63,8 +63,11 @@ internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview { Con
private fun ContentToPreview() { private fun ContentToPreview() {
Column { Column {
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = false) SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = false)
Divider()
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = true) SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = true)
Divider()
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = false) SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = false)
Divider()
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = true) SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = true)
} }
} }

View file

@ -21,9 +21,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.UnresolvedUserRow import io.element.android.libraries.matrix.ui.components.UnresolvedUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
@ -39,26 +39,27 @@ fun SearchSingleUserResultItem(
if (searchResult.isUnresolved) { if (searchResult.isUnresolved) {
UnresolvedUserRow( UnresolvedUserRow(
modifier = modifier.clickable(onClick = onClick), modifier = modifier.clickable(onClick = onClick),
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)), avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
id = searchResult.matrixUser.userId.value, id = searchResult.matrixUser.userId.value,
) )
} else { } else {
MatrixUserRow( MatrixUserRow(
modifier = modifier.clickable(onClick = onClick), modifier = modifier.clickable(onClick = onClick),
matrixUser = searchResult.matrixUser, matrixUser = searchResult.matrixUser,
avatarSize = AvatarSize.Custom(36.dp), avatarSize = AvatarSize.UserListItem,
) )
} }
} }
@Preview @Preview
@Composable @Composable
internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview{ ContentToPreview() } internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview { ContentToPreview() }
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
Column { Column {
SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false)) SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false))
Divider()
SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true)) SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true))
} }
} }

View file

@ -40,7 +40,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
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.SelectedUsersList import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.ui.strings.R import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -54,7 +54,7 @@ fun SearchUserBar(
isMultiSelectionEnabled: Boolean, isMultiSelectionEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showBackButton: Boolean = true, showBackButton: Boolean = true,
placeHolderTitle: String = stringResource(R.string.common_search_for_someone), placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone),
onActiveChanged: (Boolean) -> Unit = {}, onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {}, onTextChanged: (String) -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {}, onUserSelected: (MatrixUser) -> Unit = {},

View file

@ -30,7 +30,7 @@ import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -131,7 +131,7 @@ class ConfigureRoomPresenter @Inject constructor(
dataStore.clearCachedData() dataStore.clearCachedData()
analyticsService.capture(CreatedRoom(isDM = false)) analyticsService.capture(CreatedRoom(isDM = false))
} }
}.execute(createRoomAction) }.runCatchingUpdatingState(createRoomAction)
} }
private suspend fun uploadAvatar(avatarUri: Uri): String { private suspend fun uploadAvatar(avatarUri: Uri): String {

View file

@ -65,8 +65,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.SelectedUsersList import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class)
@Composable @Composable
@ -84,7 +84,7 @@ fun ConfigureRoomView(
if (state.createRoomAction is Async.Success) { if (state.createRoomAction is Async.Success) {
LaunchedEffect(state.createRoomAction) { LaunchedEffect(state.createRoomAction) {
onRoomCreated(state.createRoomAction.state) onRoomCreated(state.createRoomAction.data)
} }
} }
@ -158,7 +158,7 @@ fun ConfigureRoomView(
when (state.createRoomAction) { when (state.createRoomAction) {
is Async.Loading -> { is Async.Loading -> {
ProgressDialog(text = stringResource(StringR.string.common_creating_room)) ProgressDialog(text = stringResource(CommonStrings.common_creating_room))
} }
is Async.Failure -> { is Async.Failure -> {
@ -198,7 +198,7 @@ fun ConfigureRoomToolbar(
onClick = onNextPressed, onClick = onNextPressed,
) { ) {
Text( Text(
text = stringResource(StringR.string.action_create), text = stringResource(CommonStrings.action_create),
fontSize = 16.sp, fontSize = 16.sp,
) )
} }
@ -227,7 +227,7 @@ fun RoomNameWithAvatar(
LabelledTextField( LabelledTextField(
label = stringResource(R.string.screen_create_room_room_name_label), label = stringResource(R.string.screen_create_room_room_name_label),
value = roomName, value = roomName,
placeholder = stringResource(StringR.string.common_room_name_placeholder), placeholder = stringResource(CommonStrings.common_room_name_placeholder),
singleLine = true, singleLine = true,
onValueChange = onRoomNameChanged, onValueChange = onRoomNameChanged,
) )
@ -244,7 +244,7 @@ fun RoomTopic(
modifier = modifier, modifier = modifier,
label = stringResource(R.string.screen_create_room_topic_label), label = stringResource(R.string.screen_create_room_topic_label),
value = topic, value = topic,
placeholder = stringResource(StringR.string.common_topic_placeholder), placeholder = stringResource(CommonStrings.common_topic_placeholder),
onValueChange = onTopicChanged, onValueChange = onTopicChanged,
maxLines = 3, maxLines = 3,
) )

View file

@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import android.content.Context import android.app.Activity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -29,24 +29,18 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
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.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.ui.strings.R
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import timber.log.Timber
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class CreateRoomRootNode @AssistedInject constructor( class CreateRoomRootNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: CreateRoomRootPresenter, private val presenter: CreateRoomRootPresenter,
private val matrixClient: MatrixClient,
private val buildMeta: BuildMeta,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val inviteFriendsUseCase: InviteFriendsUseCase,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
@ -73,31 +67,18 @@ class CreateRoomRootNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
val context = LocalContext.current val activity = LocalContext.current as Activity
CreateRoomRootView( CreateRoomRootView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onClosePressed = this::navigateUp, onClosePressed = this::navigateUp,
onNewRoomClicked = callback::onCreateNewRoom, onNewRoomClicked = callback::onCreateNewRoom,
onOpenDM = callback::onStartChatSuccess, onOpenDM = callback::onStartChatSuccess,
onInviteFriendsClicked = { invitePeople(context) }, onInviteFriendsClicked = { invitePeople(activity) }
) )
} }
private fun invitePeople(context: Context) { private fun invitePeople(activity: Activity) {
val permalinkResult = PermalinkBuilder.permalinkForUser(matrixClient.sessionId) inviteFriendsUseCase.execute(activity)
permalinkResult.onSuccess { permalink ->
val appName = buildMeta.applicationName
startSharePlainTextIntent(
context = context,
activityResultLauncher = null,
chooserTitle = context.getString(R.string.action_invite_friends),
text = context.getString(R.string.invite_friends_text, appName, permalink),
extraTitle = context.getString(R.string.invite_friends_rich_title, appName),
noActivityFoundMessage = context.getString(io.element.android.libraries.androidutils.R.string.error_no_compatible_app_found)
)
}.onFailure {
Timber.e(it)
}
} }
} }

View file

@ -28,7 +28,7 @@ import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -95,6 +95,6 @@ class CreateRoomRootPresenter @Inject constructor(
suspend { suspend {
matrixClient.createDM(user.userId).getOrThrow() matrixClient.createDM(user.userId).getOrThrow()
.also { analyticsService.capture(CreatedRoom(isDM = true)) } .also { analyticsService.capture(CreatedRoom(isDM = true)) }
}.execute(startDmAction) }.runCatchingUpdatingState(startDmAction)
} }
} }

View file

@ -54,8 +54,8 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
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
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.designsystem.R as DrawableR
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -69,7 +69,7 @@ fun CreateRoomRootView(
) { ) {
if (state.startDmAction is Async.Success) { if (state.startDmAction is Async.Success) {
LaunchedEffect(state.startDmAction) { LaunchedEffect(state.startDmAction) {
onOpenDM(state.startDmAction.state) onOpenDM(state.startDmAction.data)
} }
} }
@ -107,7 +107,7 @@ fun CreateRoomRootView(
when (state.startDmAction) { when (state.startDmAction) {
is Async.Loading -> { is Async.Loading -> {
ProgressDialog(text = stringResource(id = StringR.string.common_starting_chat)) ProgressDialog(text = stringResource(id = CommonStrings.common_starting_chat))
} }
is Async.Failure -> { is Async.Failure -> {
@ -137,7 +137,7 @@ fun CreateRoomRootViewTopBar(
modifier = modifier, modifier = modifier,
title = { title = {
Text( Text(
text = stringResource(id = StringR.string.action_start_chat), text = stringResource(id = CommonStrings.action_start_chat),
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
@ -146,7 +146,7 @@ fun CreateRoomRootViewTopBar(
IconButton(onClick = onClosePressed) { IconButton(onClick = onClosePressed) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = stringResource(id = StringR.string.action_close), contentDescription = stringResource(id = CommonStrings.action_close),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
} }
@ -169,7 +169,7 @@ fun CreateRoomActionButtonsList(
) )
CreateRoomActionButton( CreateRoomActionButton(
iconRes = DrawableR.drawable.ic_share, iconRes = DrawableR.drawable.ic_share,
text = stringResource(id = StringR.string.action_invite_friends_to_app, state.applicationName), text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
onClick = onInvitePeopleClicked, onClick = onInvitePeopleClicked,
) )
} }

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
@ -50,6 +51,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.invitelist.test) testImplementation(projects.features.invitelist.test)
testImplementation(projects.features.analytics.test)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -25,16 +25,20 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invitelist.api.SeenInvitesStore import io.element.android.features.invitelist.api.SeenInvitesStore
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
import io.element.android.features.invitelist.impl.model.InviteSender import io.element.android.features.invitelist.impl.model.InviteSender
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
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.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -44,6 +48,7 @@ import javax.inject.Inject
class InviteListPresenter @Inject constructor( class InviteListPresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val store: SeenInvitesStore, private val store: SeenInvitesStore,
private val analyticsService: AnalyticsService,
) : Presenter<InviteListState> { ) : Presenter<InviteListState> {
@Composable @Composable
@ -133,9 +138,10 @@ class InviteListPresenter @Inject constructor(
suspend { suspend {
client.getRoom(roomId)?.use { client.getRoom(roomId)?.use {
it.acceptInvitation().getOrThrow() it.acceptInvitation().getOrThrow()
analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
} }
roomId roomId
}.execute(acceptedAction) }.runCatchingUpdatingState(acceptedAction)
} }
private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<Async<Unit>>) = launch { private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<Async<Unit>>) = launch {
@ -143,7 +149,7 @@ class InviteListPresenter @Inject constructor(
client.getRoom(roomId)?.use { client.getRoom(roomId)?.use {
it.rejectInvitation().getOrThrow() it.rejectInvitation().getOrThrow()
} ?: Unit } ?: Unit
}.execute(declinedAction) }.runCatchingUpdatingState(declinedAction)
} }
private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run { private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run {
@ -153,12 +159,14 @@ class InviteListPresenter @Inject constructor(
id = i.userId.value, id = i.userId.value,
name = i.displayName, name = i.displayName,
url = i.avatarUrl, url = i.avatarUrl,
size = AvatarSize.RoomInviteItem,
) )
else else
AvatarData( AvatarData(
id = roomId.value, id = roomId.value,
name = name, name = name,
url = avatarURLString url = avatarURLString,
size = AvatarSize.RoomInviteItem,
) )
val alias = if (isDirect) val alias = if (isDirect)
@ -181,6 +189,7 @@ class InviteListPresenter @Inject constructor(
id = userId.value, id = userId.value,
name = displayName, name = displayName,
url = avatarUrl, url = avatarUrl,
size = AvatarSize.InviteSender,
), ),
) )
}, },

View file

@ -47,7 +47,7 @@ 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
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun InviteListView( fun InviteListView(
@ -58,7 +58,7 @@ fun InviteListView(
) { ) {
if (state.acceptedAction is Async.Success) { if (state.acceptedAction is Async.Success) {
LaunchedEffect(state.acceptedAction) { LaunchedEffect(state.acceptedAction) {
onInviteAccepted(state.acceptedAction.state) onInviteAccepted(state.acceptedAction.data)
} }
} }
@ -82,8 +82,8 @@ fun InviteListView(
ConfirmationDialog( ConfirmationDialog(
content = stringResource(contentResource, state.declineConfirmationDialog.name), content = stringResource(contentResource, state.declineConfirmationDialog.name),
title = stringResource(titleResource), title = stringResource(titleResource),
submitText = stringResource(StringR.string.action_decline), submitText = stringResource(CommonStrings.action_decline),
cancelText = stringResource(StringR.string.action_cancel), cancelText = stringResource(CommonStrings.action_cancel),
emphasizeSubmitButton = true, emphasizeSubmitButton = true,
onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) }, onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) },
onDismiss = { state.eventSink(InviteListEvents.CancelDeclineInvite) } onDismiss = { state.eventSink(InviteListEvents.CancelDeclineInvite) }
@ -92,18 +92,18 @@ fun InviteListView(
if (state.acceptedAction is Async.Failure) { if (state.acceptedAction is Async.Failure) {
ErrorDialog( ErrorDialog(
content = stringResource(StringR.string.error_unknown), content = stringResource(CommonStrings.error_unknown),
title = stringResource(StringR.string.common_error), title = stringResource(CommonStrings.common_error),
submitText = stringResource(StringR.string.action_ok), submitText = stringResource(CommonStrings.action_ok),
onDismiss = { state.eventSink(InviteListEvents.DismissAcceptError) } onDismiss = { state.eventSink(InviteListEvents.DismissAcceptError) }
) )
} }
if (state.declinedAction is Async.Failure) { if (state.declinedAction is Async.Failure) {
ErrorDialog( ErrorDialog(
content = stringResource(StringR.string.error_unknown), content = stringResource(CommonStrings.error_unknown),
title = stringResource(StringR.string.common_error), title = stringResource(CommonStrings.common_error),
submitText = stringResource(StringR.string.action_ok), submitText = stringResource(CommonStrings.action_ok),
onDismiss = { state.eventSink(InviteListEvents.DismissDeclineError) } onDismiss = { state.eventSink(InviteListEvents.DismissDeclineError) }
) )
} }
@ -124,7 +124,7 @@ fun InviteListContent(
BackButton(onClick = onBackClicked) BackButton(onClick = onBackClicked)
}, },
title = { title = {
Text(text = stringResource(StringR.string.action_invites_list)) Text(text = stringResource(CommonStrings.action_invites_list))
} }
) )
}, },

View file

@ -16,7 +16,6 @@
package io.element.android.features.invitelist.impl.components package io.element.android.features.invitelist.impl.components
import androidx.compose.foundation.background
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.Column import androidx.compose.foundation.layout.Column
@ -28,14 +27,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -52,6 +48,7 @@ import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
import io.element.android.features.invitelist.impl.model.InviteListInviteSummaryProvider import io.element.android.features.invitelist.impl.model.InviteListInviteSummaryProvider
import io.element.android.features.invitelist.impl.model.InviteSender import io.element.android.features.invitelist.impl.model.InviteSender
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
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.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -61,7 +58,7 @@ import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.noFontPadding import io.element.android.libraries.designsystem.theme.noFontPadding
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.CommonStrings
private val minHeight = 72.dp private val minHeight = 72.dp
@ -74,8 +71,8 @@ internal fun InviteSummaryRow(
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = minHeight) .heightIn(min = minHeight)
) { ) {
DefaultInviteSummaryRow( DefaultInviteSummaryRow(
invite = invite, invite = invite,
@ -93,20 +90,20 @@ internal fun DefaultInviteSummaryRow(
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp)
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
Avatar( Avatar(
invite.roomAvatarData.copy(size = AvatarSize.Custom(52.dp)), invite.roomAvatarData,
) )
Column( Column(
modifier = Modifier modifier = Modifier
.padding(start = 16.dp, end = 4.dp) .padding(start = 16.dp, end = 4.dp)
.alignByBaseline() .alignByBaseline()
.weight(1f) .weight(1f)
) { ) {
val bonusPadding = if (invite.isNew) 12.dp else 0.dp val bonusPadding = if (invite.isNew) 12.dp else 0.dp
@ -143,31 +140,29 @@ internal fun DefaultInviteSummaryRow(
// CTAs // CTAs
Row(Modifier.padding(top = 12.dp)) { Row(Modifier.padding(top = 12.dp)) {
OutlinedButton( OutlinedButton(
content = { Text(stringResource(StringR.string.action_decline), style = ElementTextStyles.Button) }, content = { Text(stringResource(CommonStrings.action_decline), style = ElementTextStyles.Button) },
onClick = onDeclineClicked, onClick = onDeclineClicked,
modifier = Modifier.weight(1f).heightIn(max = 36.dp), modifier = Modifier
.weight(1f)
.heightIn(max = 36.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Button( Button(
content = { Text(stringResource(StringR.string.action_accept), style = ElementTextStyles.Button) }, content = { Text(stringResource(CommonStrings.action_accept), style = ElementTextStyles.Button) },
onClick = onAcceptClicked, onClick = onAcceptClicked,
modifier = Modifier.weight(1f).heightIn(max = 36.dp), modifier = Modifier
.weight(1f)
.heightIn(max = 36.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
) )
} }
} }
val unreadIndicatorColor = if (invite.isNew) MaterialTheme.roomListUnreadIndicator() else Color.Transparent val unreadIndicatorColor = if (invite.isNew) MaterialTheme.roomListUnreadIndicator() else Color.Transparent
UnreadIndicatorAtom(color = unreadIndicatorColor)
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(unreadIndicatorColor)
)
} }
} }
@ -178,7 +173,7 @@ private fun SenderRow(sender: InviteSender) {
modifier = Modifier.padding(top = 6.dp), modifier = Modifier.padding(top = 6.dp),
) { ) {
Avatar( Avatar(
avatarData = sender.avatarData.copy(size = AvatarSize.Custom(16.dp)), avatarData = sender.avatarData,
) )
Text( Text(
text = stringResource(R.string.screen_invites_invited_you, sender.displayName, sender.userId.value).let { text -> text = stringResource(R.string.screen_invites_invited_you, sender.displayName, sender.userId.value).let { text ->

View file

@ -18,6 +18,7 @@ package io.element.android.features.invitelist.impl.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
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.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -26,14 +27,14 @@ data class InviteListInviteSummary(
val roomId: RoomId, val roomId: RoomId,
val roomName: String = "", val roomName: String = "",
val roomAlias: String? = null, val roomAlias: String? = null,
val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName), val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName, size = AvatarSize.RoomInviteItem),
val sender: InviteSender? = null, val sender: InviteSender? = null,
val isDirect: Boolean = false, val isDirect: Boolean = false,
val isNew: Boolean = false, val isNew: Boolean = false,
) )
data class InviteSender( data class InviteSender constructor(
val userId: UserId, val userId: UserId,
val displayName: String, val displayName: String,
val avatarData: AvatarData = AvatarData(userId.value, displayName), val avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
) )

View file

@ -20,9 +20,11 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.invitelist.test.FakeSeenInvitesStore import io.element.android.features.invitelist.test.FakeSeenInvitesStore
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
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.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -50,6 +52,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -74,6 +77,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -88,6 +92,7 @@ class InviteListPresenterTests {
id = A_USER_ID.value, id = A_USER_ID.value,
name = A_USER_NAME, name = A_USER_NAME,
url = AN_AVATAR_URL, url = AN_AVATAR_URL,
size = AvatarSize.RoomInviteItem,
) )
) )
Truth.assertThat(withInviteState.inviteList[0].sender).isNull() Truth.assertThat(withInviteState.inviteList[0].sender).isNull()
@ -102,6 +107,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -115,6 +121,7 @@ class InviteListPresenterTests {
id = A_USER_ID.value, id = A_USER_ID.value,
name = A_USER_NAME, name = A_USER_NAME,
url = AN_AVATAR_URL, url = AN_AVATAR_URL,
size = AvatarSize.InviteSender,
) )
) )
} }
@ -128,6 +135,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -152,6 +160,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -176,6 +185,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -199,7 +209,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore()) val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -225,7 +235,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore()) val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex)) room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -256,7 +266,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore()) val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex)) room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -288,7 +298,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore()) val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -311,7 +321,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore()) val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex)) room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -336,7 +346,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore()) val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex)) room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -365,6 +375,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
), ),
store, store,
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -401,6 +412,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
), ),
store, store,
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()

View file

@ -31,8 +31,7 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.R import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun LeaveRoomView( fun LeaveRoomView(
@ -50,19 +49,19 @@ private fun LeaveRoomConfirmationDialog(
when (state.confirmation) { when (state.confirmation) {
is LeaveRoomState.Confirmation.Hidden -> {} is LeaveRoomState.Confirmation.Hidden -> {}
is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog( is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog(
text = StringR.string.leave_room_alert_private_subtitle, text = CommonStrings.leave_room_alert_private_subtitle,
roomId = state.confirmation.roomId, roomId = state.confirmation.roomId,
eventSink = state.eventSink, eventSink = state.eventSink,
) )
is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog( is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog(
text = StringR.string.leave_room_alert_empty_subtitle, text = CommonStrings.leave_room_alert_empty_subtitle,
roomId = state.confirmation.roomId, roomId = state.confirmation.roomId,
eventSink = state.eventSink, eventSink = state.eventSink,
) )
is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog( is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog(
text = StringR.string.leave_room_alert_subtitle, text = CommonStrings.leave_room_alert_subtitle,
roomId = state.confirmation.roomId, roomId = state.confirmation.roomId,
eventSink = state.eventSink, eventSink = state.eventSink,
) )
@ -77,7 +76,7 @@ private fun LeaveRoomConfirmationDialog(
) { ) {
ConfirmationDialog( ConfirmationDialog(
content = stringResource(text), content = stringResource(text),
submitText = stringResource(R.string.action_leave), submitText = stringResource(CommonStrings.action_leave),
onSubmitClicked = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) }, onSubmitClicked = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) },
onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) }, onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) },
) )
@ -90,7 +89,7 @@ private fun LeaveRoomProgressDialog(
when (state.progress) { when (state.progress) {
is LeaveRoomState.Progress.Hidden -> {} is LeaveRoomState.Progress.Hidden -> {}
is LeaveRoomState.Progress.Shown -> ProgressDialog( is LeaveRoomState.Progress.Shown -> ProgressDialog(
text = stringResource(StringR.string.common_leaving_room), text = stringResource(CommonStrings.common_leaving_room),
) )
} }
} }
@ -102,7 +101,7 @@ private fun LeaveRoomErrorDialog(
when (state.error) { when (state.error) {
is LeaveRoomState.Error.Hidden -> {} is LeaveRoomState.Error.Hidden -> {}
is LeaveRoomState.Error.Shown -> ErrorDialog( is LeaveRoomState.Error.Shown -> ErrorDialog(
content = stringResource(StringR.string.error_unknown), content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(LeaveRoomEvent.HideError) } onDismiss = { state.eventSink(LeaveRoomEvent.HideError) }
) )
} }

View file

@ -50,9 +50,9 @@ import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import io.element.android.features.location.api.internal.buildTileServerUrl import io.element.android.features.location.api.internal.buildTileServerUrl
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber import timber.log.Timber
@ -67,7 +67,7 @@ import kotlin.coroutines.suspendCoroutine
fun MapView( fun MapView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
mapState: MapState = rememberMapState(), mapState: MapState = rememberMapState(),
darkMode: Boolean = !ElementTheme.colors.isLight, darkMode: Boolean = !ElementTheme.isLightTheme,
onLocationClick: () -> Unit, onLocationClick: () -> Unit,
) { ) {
// When in preview, early return a Box with the received modifier preserving layout // When in preview, early return a Box with the received modifier preserving layout

View file

@ -34,12 +34,12 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl import io.element.android.features.location.api.internal.buildStaticMapsApiUrl
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.theme.ElementTheme
import timber.log.Timber import timber.log.Timber
/** /**
@ -52,7 +52,7 @@ fun StaticMapView(
zoom: Double, zoom: Double,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.colors.isLight, darkMode: Boolean = !ElementTheme.isLightTheme,
) { ) {
// Using BoxWithConstraints to: // Using BoxWithConstraints to:
// 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints. // 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints.

View file

@ -33,21 +33,21 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.location.api.R
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
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.features.location.api.R import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.R as StringsR import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun StaticMapPlaceholder( internal fun StaticMapPlaceholder(
showProgress: Boolean, showProgress: Boolean,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.colors.isLight, darkMode: Boolean = !ElementTheme.isLightTheme,
onLoadMapClick: () -> Unit, onLoadMapClick: () -> Unit,
) { ) {
Box( Box(
@ -76,7 +76,7 @@ internal fun StaticMapPlaceholder(
imageVector = Icons.Default.Refresh, imageVector = Icons.Default.Refresh,
contentDescription = null contentDescription = null
) )
Text(text = stringResource(id = StringsR.string.action_static_map_load)) Text(text = stringResource(id = CommonStrings.action_static_map_load))
} }
} }
} }

View file

@ -46,7 +46,6 @@ dependencies {
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.network) implementation(projects.libraries.network)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)

View file

@ -18,6 +18,7 @@ package io.element.android.features.login.impl
import android.app.Activity import android.app.Activity
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -45,9 +46,9 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.theme.ElementTheme
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@ -155,7 +156,7 @@ class LoginFlowNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
activity = LocalContext.current as? Activity activity = LocalContext.current as? Activity
darkTheme = !ElementTheme.colors.isLight darkTheme = !ElementTheme.isLightTheme
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
activity = null activity = null

View file

@ -26,7 +26,7 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -71,6 +71,6 @@ class ChangeServerPresenter @Inject constructor(
// Valid, remember user choice // Valid, remember user choice
accountProviderDataSource.userSelection(data) accountProviderDataSource.userSelection(data)
}.getOrThrow() }.getOrThrow()
}.execute(changeServerAction, errorMapping = ChangeServerError::from) }.runCatchingUpdatingState(changeServerAction, errorTransform = ChangeServerError::from)
} }
} }

View file

@ -21,7 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun SlidingSyncNotSupportedDialog( internal fun SlidingSyncNotSupportedDialog(
@ -32,11 +32,11 @@ internal fun SlidingSyncNotSupportedDialog(
ConfirmationDialog( ConfirmationDialog(
modifier = modifier, modifier = modifier,
onDismiss = onDismiss, onDismiss = onDismiss,
submitText = stringResource(StringR.string.action_learn_more), submitText = stringResource(CommonStrings.action_learn_more),
onSubmitClicked = onLearnMoreClicked, onSubmitClicked = onLearnMoreClicked,
onCancelClicked = onDismiss, onCancelClicked = onDismiss,
emphasizeSubmitButton = true, emphasizeSubmitButton = true,
title = stringResource(StringR.string.dialog_title_error), title = stringResource(CommonStrings.dialog_title_error),
content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message), content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message),
) )
} }

View file

@ -21,16 +21,16 @@ import io.element.android.features.login.impl.R
import io.element.android.libraries.matrix.api.auth.AuthErrorCode import io.element.android.libraries.matrix.api.auth.AuthErrorCode
import io.element.android.libraries.matrix.api.auth.AuthenticationException import io.element.android.libraries.matrix.api.auth.AuthenticationException
import io.element.android.libraries.matrix.api.auth.errorCode import io.element.android.libraries.matrix.api.auth.errorCode
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.CommonStrings
@StringRes @StringRes
fun loginError( fun loginError(
throwable: Throwable throwable: Throwable
): Int { ): Int {
val authException = throwable as? AuthenticationException ?: return StringR.string.error_unknown val authException = throwable as? AuthenticationException ?: return CommonStrings.error_unknown
return when (authException.errorCode) { return when (authException.errorCode) {
AuthErrorCode.FORBIDDEN -> R.string.screen_login_error_invalid_credentials AuthErrorCode.FORBIDDEN -> R.string.screen_login_error_invalid_credentials
AuthErrorCode.USER_DEACTIVATED -> R.string.screen_login_error_deactivated_account AuthErrorCode.USER_DEACTIVATED -> R.string.screen_login_error_deactivated_account
AuthErrorCode.UNKNOWN -> StringR.string.error_unknown AuthErrorCode.UNKNOWN -> CommonStrings.error_unknown
} }
} }

View file

@ -30,7 +30,7 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -95,6 +95,6 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
throw IllegalStateException("Unsupported login flow") throw IllegalStateException("Unsupported login flow")
} }
}.getOrThrow() }.getOrThrow()
}.execute(loginFlowAction, errorMapping = ChangeServerError::from) }.runCatchingUpdatingState(loginFlowAction, errorTransform = ChangeServerError::from)
} }
} }

View file

@ -131,7 +131,7 @@ fun ConfirmAccountProviderView(
} }
is Async.Loading -> Unit // The Continue button shows the loading state is Async.Loading -> Unit // The Continue button shows the loading state
is Async.Success -> { is Async.Success -> {
when (val loginFlowState = state.loginFlow.state) { when (val loginFlowState = state.loginFlow.data) {
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails) is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
LoginFlow.PasswordLogin -> onLoginPasswordNeeded() LoginFlow.PasswordLogin -> onLoginPasswordNeeded()
} }

View file

@ -76,7 +76,7 @@ import io.element.android.libraries.designsystem.theme.components.autofill
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
@ -213,7 +213,7 @@ internal fun LoginForm(
IconButton(onClick = { IconButton(onClick = {
loginFieldState = "" loginFieldState = ""
}) { }) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(StringR.string.action_clear)) Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(CommonStrings.action_clear))
} }
} }
} else null, } else null,
@ -248,7 +248,7 @@ internal fun LoginForm(
val image = val image =
if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
val description = val description =
if (passwordVisible) stringResource(StringR.string.a11y_hide_password) else stringResource(StringR.string.a11y_show_password) if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
IconButton(onClick = { passwordVisible = !passwordVisible }) { IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, description) Icon(imageVector = image, description)

View file

@ -72,7 +72,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.CommonStrings
/** /**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435 * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435
@ -143,7 +143,7 @@ fun SearchAccountProviderView(
}) { }) {
Icon( Icon(
imageVector = Icons.Filled.Close, imageVector = Icons.Filled.Close,
contentDescription = stringResource(StringR.string.action_clear) contentDescription = stringResource(CommonStrings.action_clear)
) )
} }
} }
@ -171,7 +171,7 @@ fun SearchAccountProviderView(
} }
} }
is Async.Success -> { is Async.Success -> {
items(state.userInputResult.state) { homeserverData -> items(state.userInputResult.data) { homeserverData ->
val item = homeserverData.toAccountProvider() val item = homeserverData.toAccountProvider()
AccountProviderView( AccountProviderView(
item = item, item = item,

View file

@ -19,8 +19,8 @@ package io.element.android.features.login.impl.error
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.libraries.matrix.api.auth.AuthenticationException import io.element.android.libraries.matrix.api.auth.AuthenticationException
import io.element.android.libraries.ui.strings.CommonStrings
import org.junit.Test import org.junit.Test
import io.element.android.libraries.ui.strings.R as StringR
class ErrorFormatterTests { class ErrorFormatterTests {
@ -28,19 +28,19 @@ class ErrorFormatterTests {
@Test @Test
fun `loginError - invalid unknown error returns unknown error message`() { fun `loginError - invalid unknown error returns unknown error message`() {
val error = Throwable("Some unknown error") val error = Throwable("Some unknown error")
assertThat(loginError(error)).isEqualTo(StringR.string.error_unknown) assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown)
} }
@Test @Test
fun `loginError - invalid auth error returns unknown error message`() { fun `loginError - invalid auth error returns unknown error message`() {
val error = AuthenticationException.SlidingSyncNotAvailable("Some message. Also contains M_FORBIDDEN, but won't be parsed") val error = AuthenticationException.SlidingSyncNotAvailable("Some message. Also contains M_FORBIDDEN, but won't be parsed")
assertThat(loginError(error)).isEqualTo(StringR.string.error_unknown) assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown)
} }
@Test @Test
fun `loginError - unknown error returns unknown error message`() { fun `loginError - unknown error returns unknown error message`() {
val error = AuthenticationException.Generic("M_UNKNOWN") val error = AuthenticationException.Generic("M_UNKNOWN")
assertThat(loginError(error)).isEqualTo(StringR.string.error_unknown) assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown)
} }
@Test @Test

View file

@ -31,7 +31,7 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen
import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun LogoutPreferenceView( fun LogoutPreferenceView(
@ -81,7 +81,7 @@ fun LogoutPreferenceView(
fun LogoutPreferenceContent( fun LogoutPreferenceContent(
onClick: () -> Unit = {}, onClick: () -> Unit = {},
) { ) {
PreferenceCategory(title = stringResource(id = StringR.string.settings_title_general)) { PreferenceCategory(title = stringResource(id = CommonStrings.settings_title_general)) {
PreferenceText( PreferenceText(
title = stringResource(id = R.string.screen_signout_preference_item), title = stringResource(id = R.string.screen_signout_preference_item),
icon = Icons.Default.Logout, icon = Icons.Default.Logout,

View file

@ -35,7 +35,6 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)

View file

@ -26,7 +26,7 @@ import io.element.android.features.logout.api.LogoutPreferenceEvents
import io.element.android.features.logout.api.LogoutPreferencePresenter import io.element.android.features.logout.api.LogoutPreferencePresenter
import io.element.android.features.logout.api.LogoutPreferenceState import io.element.android.features.logout.api.LogoutPreferenceState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -59,6 +59,6 @@ class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixCli
private fun CoroutineScope.logout(logoutAction: MutableState<Async<Unit>>) = launch { private fun CoroutineScope.logout(logoutAction: MutableState<Async<Unit>>) = launch {
suspend { suspend {
matrixClient.logout() matrixClient.logout()
}.execute(logoutAction) }.runCatchingUpdatingState(logoutAction)
} }
} }

View file

@ -47,6 +47,7 @@ dependencies {
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.datetime) implementation(libs.datetime)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)

View file

@ -22,6 +22,6 @@ import io.element.android.libraries.matrix.api.core.EventId
sealed interface MessagesEvents { sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
data class SendReaction(val emoji: String, val eventId: EventId) : MessagesEvents data class ToggleReaction(val emoji: String, val eventId: EventId) : MessagesEvents
object Dismiss : MessagesEvents object Dismiss : MessagesEvents
} }

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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,13 +31,18 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
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.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.extensions.toAnalyticsViewRoom
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class MessagesNode @AssistedInject constructor( class MessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
private val presenterFactory: MessagesPresenter.Factory, private val presenterFactory: MessagesPresenter.Factory,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
@ -53,6 +59,14 @@ class MessagesNode @AssistedInject constructor(
fun onReportMessage(eventId: EventId, senderId: UserId) fun onReportMessage(eventId: EventId, senderId: UserId)
} }
init {
lifecycle.subscribe(
onCreate = {
analyticsService.capture(room.toAnalyticsViewRoom())
}
)
}
private fun onRoomDetailsClicked() { private fun onRoomDetailsClicked() {
callback?.onRoomDetailsClicked() callback?.onRoomDetailsClicked()
} }

View file

@ -118,7 +118,7 @@ class MessagesPresenter @AssistedInject constructor(
id = room.roomId.value, id = room.roomId.value,
name = room.name, name = room.name,
url = room.avatarUrl, url = room.avatarUrl,
size = AvatarSize.SMALL size = AvatarSize.TimelineRoom
) )
roomName.value = room.name roomName.value = room.name
} }
@ -130,8 +130,8 @@ class MessagesPresenter @AssistedInject constructor(
is MessagesEvents.HandleAction -> { is MessagesEvents.HandleAction -> {
localCoroutineScope.handleTimelineAction(event.action, event.event, composerState) localCoroutineScope.handleTimelineAction(event.action, event.event, composerState)
} }
is MessagesEvents.SendReaction -> { is MessagesEvents.ToggleReaction -> {
localCoroutineScope.sendReaction(event.emoji, event.eventId) localCoroutineScope.toggleReaction(event.emoji, event.eventId)
} }
is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear) is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear)
} }
@ -168,11 +168,11 @@ class MessagesPresenter @AssistedInject constructor(
} }
} }
private fun CoroutineScope.sendReaction( private fun CoroutineScope.toggleReaction(
emoji: String, emoji: String,
eventId: EventId, eventId: EventId,
) = launch(dispatchers.io) { ) = launch(dispatchers.io) {
room.sendReaction(emoji, eventId) room.toggleReaction(emoji, eventId)
.onFailure { Timber.e(it) } .onFailure { Timber.e(it) }
} }

View file

@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.components.retrysendme
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.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
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.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
@ -42,7 +43,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
fun aMessagesState() = MessagesState( fun aMessagesState() = MessagesState(
roomId = RoomId("!id:domain"), roomId = RoomId("!id:domain"),
roomName = "Room name", roomName = "Room name",
roomAvatar = AvatarData("!id:domain", "Room name"), roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
userHasPermissionToSendMessage = true, userHasPermissionToSendMessage = true,
composerState = aMessageComposerState().copy( composerState = aMessageComposerState().copy(
text = StableCharSequence("Hello"), text = StableCharSequence("Hello"),

View file

@ -77,9 +77,9 @@ import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
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.event.EventSendState import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber import timber.log.Timber
import io.element.android.libraries.ui.strings.R as StringsR
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
@ -120,7 +120,7 @@ fun MessagesView(
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) { fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
if (event.eventId == null) return if (event.eventId == null) return
state.eventSink(MessagesEvents.SendReaction(emoji, event.eventId)) state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId))
} }
Scaffold( Scaffold(
@ -150,7 +150,8 @@ fun MessagesView(
if (event.sendState is EventSendState.SendingFailed) { if (event.sendState is EventSendState.SendingFailed) {
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
} }
} },
onReactionClicked = ::onEmojiReactionClicked
) )
}, },
snackbarHost = { snackbarHost = {
@ -174,7 +175,7 @@ fun MessagesView(
state = state.customReactionState, state = state.customReactionState,
onEmojiSelected = { emoji -> onEmojiSelected = { emoji ->
state.customReactionState.selectedEventId?.let { eventId -> state.customReactionState.selectedEventId?.let { eventId ->
state.eventSink(MessagesEvents.SendReaction(emoji.unicode, eventId)) state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
} }
} }
@ -195,7 +196,7 @@ private fun AttachmentStateView(
is AttachmentsState.Previewing -> LaunchedEffect(state) { is AttachmentsState.Previewing -> LaunchedEffect(state) {
onPreviewAttachments(state.attachments) onPreviewAttachments(state.attachments)
} }
is AttachmentsState.Sending -> ProgressDialog(text = stringResource(id = StringsR.string.common_loading)) is AttachmentsState.Sending -> ProgressDialog(text = stringResource(id = CommonStrings.common_loading))
} }
} }
@ -204,6 +205,7 @@ fun MessagesViewContent(
state: MessagesState, state: MessagesState,
onMessageClicked: (TimelineItem.Event) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -223,6 +225,7 @@ fun MessagesViewContent(
onMessageLongClicked = onMessageLongClicked, onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked, onUserDataClicked = onUserDataClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClicked = onReactionClicked,
) )
} }
if (state.userHasPermissionToSendMessage) { if (state.userHasPermissionToSendMessage) {

View file

@ -19,6 +19,10 @@ package io.element.android.features.messages.impl.actionlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
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.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> { open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
@ -29,15 +33,27 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState().copy( anActionListState().copy(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent(), event = aTimelineItemEvent(),
actions = persistentListOf( actions = aTimelineItemActionList(),
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
TimelineItemAction.Edit,
TimelineItemAction.Redact,
)
) )
) ),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemImageContent()),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemVideoContent()),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemFileContent()),
actions = aTimelineItemActionList(),
)
),
) )
} }
@ -45,3 +61,15 @@ fun anActionListState() = ActionListState(
target = ActionListState.Target.None, target = ActionListState.Target.None,
eventSink = {} eventSink = {}
) )
fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> {
return persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
TimelineItemAction.Edit,
TimelineItemAction.Redact,
TimelineItemAction.ReportContent,
TimelineItemAction.Developer,
)
}

View file

@ -42,27 +42,22 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
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.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
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.TimelineItemProfileChangeContent
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.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -74,6 +69,7 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Divider import io.element.android.libraries.designsystem.theme.components.Divider
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.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
@ -169,9 +165,11 @@ private fun SheetContent(
) { ) {
item { item {
Column { Column {
MessageSummary(event = target.event, modifier = Modifier MessageSummary(
event = target.event, modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp)) .padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(14.dp)) Spacer(modifier = Modifier.height(14.dp))
Divider() Divider()
} }
@ -214,10 +212,10 @@ private fun SheetContent(
@Composable @Composable
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) { private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
val content: @Composable () -> Unit val content: @Composable () -> Unit
var icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.SMALL)) } var icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.MessageActionSender)) }
val contentStyle = ElementTextStyles.Regular.bodyMD.copy(color = MaterialTheme.colorScheme.secondary) val contentStyle = ElementTextStyles.Regular.bodyMD.copy(color = MaterialTheme.colorScheme.secondary)
val imageModifier = Modifier val imageModifier = Modifier
.size(36.dp) .size(AvatarSize.MessageActionSender.dp)
.clip(RoundedCornerShape(9.dp)) .clip(RoundedCornerShape(9.dp))
@Composable @Composable
@ -232,7 +230,6 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
when (event.content) { when (event.content) {
is TimelineItemTextBasedContent, is TimelineItemTextBasedContent,
is TimelineItemStateContent, is TimelineItemStateContent,
is TimelineItemProfileChangeContent,
is TimelineItemEncryptedContent, is TimelineItemEncryptedContent,
is TimelineItemRedactedContent, is TimelineItemRedactedContent,
is TimelineItemUnknownContent -> content = { ContentForBody(textContent) } is TimelineItemUnknownContent -> content = { ContentForBody(textContent) }
@ -282,7 +279,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
Row(modifier = modifier) { Row(modifier = modifier) {
icon() icon()
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Column { Column(modifier = Modifier.weight(1f)) {
Row { Row {
if (event.senderDisplayName != null) { if (event.senderDisplayName != null) {
Text( Text(
@ -291,16 +288,16 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
Text(
event.sentTime,
style = ElementTextStyles.Regular.caption2,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f)
)
} }
content() content()
} }
Spacer(modifier = Modifier.width(16.dp))
Text(
event.sentTime,
style = ElementTextStyles.Regular.caption2,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.End,
)
} }
} }
@ -349,7 +346,7 @@ private fun EmojiButton(
) { ) {
Text( Text(
emoji, emoji,
fontSize = 28.dpToSp(), fontSize = 28.dp.toSp(),
modifier = modifier.clickable( modifier = modifier.clickable(
enabled = true, enabled = true,
onClick = { onClicked(emoji) }, onClick = { onClicked(emoji) },
@ -359,11 +356,6 @@ private fun EmojiButton(
) )
} }
@Composable
private fun Int.dpToSp(): TextUnit = with(LocalDensity.current) {
return dp.toSp()
}
@Preview @Preview
@Composable @Composable
fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) = fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =

View file

@ -27,7 +27,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme import io.element.android.libraries.theme.ForcedDarkElementTheme
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)

View file

@ -27,7 +27,7 @@ import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.executeResult import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -83,8 +83,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mediaAttachment: Attachment.Media, mediaAttachment: Attachment.Media,
sendActionState: MutableState<Async<Unit>>, sendActionState: MutableState<Async<Unit>>,
) { ) {
suspend { sendActionState.runUpdatingState {
mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.info.mimeType, mediaAttachment.compressIfPossible) mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.info.mimeType, mediaAttachment.compressIfPossible)
}.executeResult(sendActionState) }
} }
} }

View file

@ -41,8 +41,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
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
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.R import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.strings.R as StringsR
@Composable @Composable
fun AttachmentsPreviewView( fun AttachmentsPreviewView(
@ -92,7 +91,7 @@ private fun AttachmentSendStateView(
) { ) {
when (sendActionState) { when (sendActionState) {
is Async.Loading -> { is Async.Loading -> {
ProgressDialog(text = stringResource(id = R.string.common_loading)) ProgressDialog(text = stringResource(id = CommonStrings.common_loading))
} }
is Async.Failure -> { is Async.Failure -> {
@ -151,10 +150,10 @@ private fun AttachmentsPreviewBottomActions(
modifier = modifier, modifier = modifier,
) { ) {
TextButton(onClick = onCancelClicked) { TextButton(onClick = onCancelClicked) {
Text(stringResource(id = StringsR.string.action_cancel)) Text(stringResource(id = CommonStrings.action_cancel))
} }
TextButton(onClick = onSendClicked) { TextButton(onClick = onSendClicked) {
Text(stringResource(id = StringsR.string.action_send)) Text(stringResource(id = CommonStrings.action_send))
} }
} }
} }

View file

@ -17,14 +17,14 @@
package io.element.android.features.messages.impl.attachments.preview.error package io.element.android.features.messages.impl.attachments.preview.error
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.ui.strings.R import io.element.android.libraries.ui.strings.CommonStrings
fun sendAttachmentError( fun sendAttachmentError(
throwable: Throwable throwable: Throwable
): Int { ): Int {
return if (throwable is MediaPreProcessor.Failure) { return if (throwable is MediaPreProcessor.Failure) {
R.string.screen_media_upload_preview_error_failed_processing CommonStrings.screen_media_upload_preview_error_failed_processing
} else { } else {
R.string.screen_media_upload_preview_error_failed_sending CommonStrings.screen_media_upload_preview_error_failed_sending
} }
} }

View file

@ -30,7 +30,6 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
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.core.EventId

View file

@ -21,13 +21,13 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
@ -48,6 +48,7 @@ import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
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.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
@ -66,8 +67,8 @@ import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
import io.element.android.libraries.matrix.ui.components.SelectedRoom import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
@ -111,7 +112,7 @@ fun ForwardMessagesView(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { Text(stringResource(StringR.string.common_forward_message), style = ElementTextStyles.Bold.callout) }, title = { Text(stringResource(CommonStrings.common_forward_message), style = ElementTextStyles.Bold.callout) },
navigationIcon = { navigationIcon = {
BackButton(onClick = { onBackButton(state) }) BackButton(onClick = { onBackButton(state) })
}, },
@ -120,7 +121,7 @@ fun ForwardMessagesView(
enabled = state.selectedRooms.isNotEmpty(), enabled = state.selectedRooms.isNotEmpty(),
onClick = { state.eventSink(ForwardMessagesEvents.ForwardEvent) } onClick = { state.eventSink(ForwardMessagesEvents.ForwardEvent) }
) { ) {
Text(text = stringResource(StringR.string.action_send)) Text(text = stringResource(CommonStrings.action_send))
} }
} }
) )
@ -132,7 +133,7 @@ fun ForwardMessagesView(
.consumeWindowInsets(paddingValues) .consumeWindowInsets(paddingValues)
) { ) {
SearchBar<ImmutableList<RoomSummaryDetails>>( SearchBar<ImmutableList<RoomSummaryDetails>>(
placeHolderTitle = stringResource(StringR.string.action_search), placeHolderTitle = stringResource(CommonStrings.action_search),
query = state.query, query = state.query,
onQueryChange = { state.eventSink(ForwardMessagesEvents.UpdateQuery(it)) }, onQueryChange = { state.eventSink(ForwardMessagesEvents.UpdateQuery(it)) },
active = state.isSearchActive, active = state.isSearchActive,
@ -227,18 +228,22 @@ internal fun RoomSummaryView(
modifier = modifier modifier = modifier
.clickable { onSelection(summary) } .clickable { onSelection(summary) }
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(start = 16.dp, end = 4.dp)
.height(IntrinsicSize.Min), .heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val roomAlias = summary.canonicalAlias ?: summary.roomId.value val roomAlias = summary.canonicalAlias ?: summary.roomId.value
Avatar( Avatar(
avatarData = AvatarData(id = roomAlias, name = summary.name, url = summary.avatarURLString), avatarData = AvatarData(
id = roomAlias,
name = summary.name,
url = summary.avatarURLString,
size = AvatarSize.ForwardRoomListItem,
),
) )
Column( Column(
modifier = Modifier modifier = Modifier
.padding(start = 12.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
.alignByBaseline()
.weight(1f) .weight(1f)
) { ) {
// Name // Name

View file

@ -63,7 +63,6 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.R import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.theme.ElementTheme
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.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
@ -242,7 +241,7 @@ fun MediaFileView(
fontSize = 16.sp, fontSize = 16.sp,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = ElementTheme.colors.gray1400 color = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
@ -250,7 +249,7 @@ fun MediaFileView(
fontSize = 14.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.gray1400 color = MaterialTheme.colorScheme.primary
) )
} }
} }

View file

@ -35,9 +35,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import me.saket.telephoto.zoomable.zoomable import me.saket.telephoto.zoomable.zoomable
@ -51,7 +51,7 @@ fun PdfViewer(
modifier = modifier.zoomable(pdfViewerState.zoomableState), modifier = modifier.zoomable(pdfViewerState.zoomableState),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val maxWidthInPx = maxWidth.dpToPx() val maxWidthInPx = maxWidth.roundToPx()
DisposableEffect(pdfViewerState) { DisposableEffect(pdfViewerState) {
pdfViewerState.openForWidth(maxWidthInPx) pdfViewerState.openForWidth(maxWidthInPx)
onDispose { onDispose {
@ -107,15 +107,9 @@ private fun PdfPageView(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(state.height.pxToDp()) .height(state.height.toDp())
.background(color = Color.White) .background(color = Color.White)
) )
} }
} }
} }
@Composable
private fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() }
@Composable
private fun Dp.dpToPx() = with(LocalDensity.current) { this@dpToPx.roundToPx() }

View file

@ -27,7 +27,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme import io.element.android.libraries.theme.ForcedDarkElementTheme
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource

View file

@ -38,10 +38,10 @@ import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR import io.element.android.libraries.androidutils.R as UtilsR
import io.element.android.libraries.ui.strings.R as StringR
class MediaViewerPresenter @AssistedInject constructor( class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerNode.Inputs, @Assisted private val inputs: MediaViewerNode.Inputs,
@ -117,9 +117,9 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch { private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) { if (localMedia is Async.Success) {
localMediaActions.saveOnDisk(localMedia.state) localMediaActions.saveOnDisk(localMedia.data)
.onSuccess { .onSuccess {
val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android) val snackbarMessage = SnackbarMessage(CommonStrings.common_file_saved_on_disk_android)
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)
} }
.onFailure { .onFailure {
@ -131,7 +131,7 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch { private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) { if (localMedia is Async.Success) {
localMediaActions.share(localMedia.state) localMediaActions.share(localMedia.data)
.onFailure { .onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it)) val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)
@ -141,7 +141,7 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch { private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) { if (localMedia is Async.Success) {
localMediaActions.open(localMedia.state) localMediaActions.open(localMedia.data)
.onFailure { .onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it)) val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)
@ -153,7 +153,7 @@ class MediaViewerPresenter @AssistedInject constructor(
return if (throwable is ActivityNotFoundException) { return if (throwable is ActivityNotFoundException) {
UtilsR.string.error_no_compatible_app_found UtilsR.string.error_no_compatible_app_found
} else { } else {
StringR.string.error_unknown CommonStrings.error_unknown
} }
} }
} }

View file

@ -57,7 +57,6 @@ import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
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.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
@ -69,8 +68,8 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun MediaViewerView( fun MediaViewerView(
@ -131,7 +130,7 @@ fun MediaViewerView(
) { ) {
if (state.downloadedMedia is Async.Failure) { if (state.downloadedMedia is Async.Failure) {
ErrorView( ErrorView(
errorMessage = stringResource(id = StringR.string.error_unknown), errorMessage = stringResource(id = CommonStrings.error_unknown),
onRetry = ::onRetry, onRetry = ::onRetry,
onDismiss = ::onDismissError onDismiss = ::onDismissError
) )
@ -188,7 +187,7 @@ private fun MediaViewerTopBar(
eventSink(MediaViewerEvents.OpenWith) eventSink(MediaViewerEvents.OpenWith)
}, },
) { ) {
Icon(imageVector = Icons.Default.OpenInNew, contentDescription = stringResource(id = StringR.string.action_open_with)) Icon(imageVector = Icons.Default.OpenInNew, contentDescription = stringResource(id = CommonStrings.action_open_with))
} }
IconButton( IconButton(
enabled = actionsEnabled, enabled = actionsEnabled,
@ -196,7 +195,7 @@ private fun MediaViewerTopBar(
eventSink(MediaViewerEvents.SaveOnDisk) eventSink(MediaViewerEvents.SaveOnDisk)
}, },
) { ) {
Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = StringR.string.action_save)) Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = CommonStrings.action_save))
} }
IconButton( IconButton(
enabled = actionsEnabled, enabled = actionsEnabled,
@ -204,7 +203,7 @@ private fun MediaViewerTopBar(
eventSink(MediaViewerEvents.Share) eventSink(MediaViewerEvents.Share)
}, },
) { ) {
Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = StringR.string.action_share)) Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = CommonStrings.action_share))
} }
} }
) )

View file

@ -29,16 +29,15 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.executeResult import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.designsystem.utils.SnackbarMessage
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.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
class ReportMessagePresenter @AssistedInject constructor( class ReportMessagePresenter @AssistedInject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
@ -87,12 +86,12 @@ class ReportMessagePresenter @AssistedInject constructor(
blockUser: Boolean, blockUser: Boolean,
result: MutableState<Async<Unit>>, result: MutableState<Async<Unit>>,
) = launch { ) = launch {
suspend { result.runUpdatingState {
val userIdToBlock = userId.takeIf { blockUser } val userIdToBlock = userId.takeIf { blockUser }
room.reportContent(eventId, reason, userIdToBlock) room.reportContent(eventId, reason, userIdToBlock)
.onSuccess { .onSuccess {
snackbarDispatcher.post(SnackbarMessage(StringR.string.common_report_submitted)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_report_submitted))
} }
}.executeResult(result) }
} }
} }

View file

@ -54,7 +54,7 @@ import io.element.android.libraries.designsystem.theme.components.CenterAlignedT
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
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
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
@ -74,7 +74,7 @@ fun ReportMessageView(
} }
is Async.Failure -> { is Async.Failure -> {
ErrorDialog( ErrorDialog(
content = stringResource(StringR.string.error_unknown), content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(ReportMessageEvents.ClearError) } onDismiss = { state.eventSink(ReportMessageEvents.ClearError) }
) )
} }
@ -86,7 +86,7 @@ fun ReportMessageView(
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { title = {
Text( Text(
stringResource(StringR.string.action_report_content), stringResource(CommonStrings.action_report_content),
style = ElementTextStyles.Regular.callout, style = ElementTextStyles.Regular.callout,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
@ -112,14 +112,14 @@ fun ReportMessageView(
OutlinedTextField( OutlinedTextField(
value = state.reason, value = state.reason,
onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) }, onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) },
placeholder = { Text(stringResource(StringR.string.report_content_hint)) }, placeholder = { Text(stringResource(CommonStrings.report_content_hint)) },
enabled = !isSending, enabled = !isSending,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 90.dp) .heightIn(min = 90.dp)
) )
Text( Text(
text = stringResource(StringR.string.report_content_explanation), text = stringResource(CommonStrings.report_content_explanation),
style = ElementTextStyles.Regular.caption1, style = ElementTextStyles.Regular.caption1,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
@ -133,11 +133,11 @@ fun ReportMessageView(
) { ) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
text = stringResource(StringR.string.screen_report_content_block_user), text = stringResource(CommonStrings.screen_report_content_block_user),
style = ElementTextStyles.Regular.callout, style = ElementTextStyles.Regular.callout,
) )
Text( Text(
text = stringResource(StringR.string.screen_report_content_block_user_hint), text = stringResource(CommonStrings.screen_report_content_block_user_hint),
style = ElementTextStyles.Regular.bodyMD, style = ElementTextStyles.Regular.bodyMD,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
) )
@ -152,7 +152,7 @@ fun ReportMessageView(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
ButtonWithProgress( ButtonWithProgress(
text = stringResource(StringR.string.action_send), text = stringResource(CommonStrings.action_send),
enabled = state.reason.isNotBlank() && !isSending, enabled = state.reason.isNotBlank() && !isSending,
showProgress = isSending, showProgress = isSending,
onClick = { onClick = {

View file

@ -23,7 +23,10 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
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
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.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
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.matrix.api.core.EventId 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.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
@ -32,6 +35,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventSendStat
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlin.random.Random import kotlin.random.Random
fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState( fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState(
@ -83,15 +88,23 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
content = content, content = content,
groupPosition = TimelineItemGroupPosition.First groupPosition = TimelineItemGroupPosition.First
), ),
// A state event on top of it // A grouped event on top of it
aTimelineItemEvent( aGroupedEvents(),
isMine = true, // A day separator
content = aTimelineItemStateEventContent(), aTimelineItemDaySeparator(),
groupPosition = TimelineItemGroupPosition.None // Loading
), aTimelineItemLoading(),
) )
} }
fun aTimelineItemLoading(): TimelineItem.Virtual {
return TimelineItem.Virtual("virtual_loading", TimelineItemLoadingModel)
}
fun aTimelineItemDaySeparator(): TimelineItem.Virtual {
return TimelineItem.Virtual("virtual_day", aTimelineItemDaySeparatorModel("Today"))
}
internal fun aTimelineItemEvent( internal fun aTimelineItemEvent(
eventId: EventId = EventId("\$" + Random.nextInt().toString()), eventId: EventId = EventId("\$" + Random.nextInt().toString()),
transactionId: String? = null, transactionId: String? = null,
@ -101,22 +114,19 @@ internal fun aTimelineItemEvent(
sendState: EventSendState = EventSendState.Sent(eventId), sendState: EventSendState = EventSendState.Sent(eventId),
inReplyTo: InReplyTo? = null, inReplyTo: InReplyTo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
): TimelineItem.Event { ): TimelineItem.Event {
return TimelineItem.Event( return TimelineItem.Event(
id = eventId.value, id = eventId.value,
eventId = eventId, eventId = eventId,
transactionId = transactionId, transactionId = transactionId,
senderId = UserId("@senderId:domain"), senderId = UserId("@senderId:domain"),
senderAvatar = AvatarData("@senderId:domain", "sender"), senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
content = content, content = content,
reactionsState = TimelineItemReactions( reactionsState = timelineItemReactions,
persistentListOf(
AggregatedReaction("👍", "1")
)
),
sentTime = "12:34", sentTime = "12:34",
isMine = isMine, isMine = isMine,
senderDisplayName = "sender", senderDisplayName = "Sender",
groupPosition = groupPosition, groupPosition = groupPosition,
sendState = sendState, sendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
@ -124,6 +134,19 @@ internal fun aTimelineItemEvent(
) )
} }
fun aTimelineItemReactions(
count: Int = 1,
isHighlighted: Boolean = false,
): TimelineItemReactions {
return TimelineItemReactions(
reactions = buildList {
repeat(count) {
add(AggregatedReaction(key = "👍", count = 1 + it, isHighlighted = isHighlighted))
}
}.toPersistentList()
)
}
internal fun aTimelineItemDebugInfo( internal fun aTimelineItemDebugInfo(
model: String = "Rust(Model())", model: String = "Rust(Model())",
originalJson: String? = null, originalJson: String? = null,
@ -131,3 +154,17 @@ internal fun aTimelineItemDebugInfo(
) = TimelineItemDebugInfo( ) = TimelineItemDebugInfo(
model, originalJson, latestEditedJson model, originalJson, latestEditedJson
) )
fun aGroupedEvents(): TimelineItem.GroupedEvents {
val event = aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
)
return TimelineItem.GroupedEvents(
events = listOf(
event,
event,
).toImmutableList()
)
}

View file

@ -72,6 +72,7 @@ fun TimelineView(
onMessageClicked: (TimelineItem.Event) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onReachedLoadMore() { fun onReachedLoadMore() {
@ -89,7 +90,7 @@ fun TimelineView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = lazyListState, state = lazyListState,
reverseLayout = true, reverseLayout = true,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
) { ) {
itemsIndexed( itemsIndexed(
items = state.timelineItems, items = state.timelineItems,
@ -103,6 +104,7 @@ fun TimelineView(
onLongClick = onMessageLongClicked, onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked, onUserDataClick = onUserDataClicked,
inReplyToClick = ::inReplyToClicked, inReplyToClick = ::inReplyToClicked,
onReactionClick = onReactionClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
) )
if (index == state.timelineItems.lastIndex) { if (index == state.timelineItems.lastIndex) {
@ -127,6 +129,7 @@ fun TimelineItemRow(
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -162,6 +165,7 @@ fun TimelineItemRow(
onLongClick = ::onLongClick, onLongClick = ::onLongClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
modifier = modifier, modifier = modifier,
) )
@ -196,6 +200,7 @@ fun TimelineItemRow(
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
) )
} }
} }
@ -286,5 +291,6 @@ private fun ContentToPreview(content: TimelineItemEventContent) {
onTimestampClicked = {}, onTimestampClicked = {},
onUserDataClicked = {}, onUserDataClicked = {},
onMessageLongClicked = {}, onMessageLongClicked = {},
onReactionClicked = { _, _ -> },
) )
} }

View file

@ -20,6 +20,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -41,13 +42,16 @@ import io.element.android.features.messages.impl.timeline.model.bubble.BubbleSta
import io.element.android.libraries.core.extensions.to01 import io.element.android.libraries.core.extensions.to01
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
private val BUBBLE_RADIUS = 12.dp private val BUBBLE_RADIUS = 12.dp
private val BUBBLE_INCOMING_OFFSET = 16.dp private val BUBBLE_INCOMING_OFFSET = 16.dp
// Design says: The maximum width of a bubble is still 3/4 of the screen width
private const val BUBBLE_WIDTH_RATIO = 0.75f
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun MessageEventBubble( fun MessageEventBubble(
@ -94,30 +98,39 @@ fun MessageEventBubble(
} }
val backgroundBubbleColor = if (state.isHighlighted) { val backgroundBubbleColor = if (state.isHighlighted) {
ElementTheme.colors.messageHighlightedBackground ElementTheme.legacyColors.messageHighlightedBackground
} else { } else {
if (state.isMine) { if (state.isMine) {
ElementTheme.colors.messageFromMeBackground ElementTheme.legacyColors.messageFromMeBackground
} else { } else {
ElementTheme.colors.messageFromOtherBackground ElementTheme.legacyColors.messageFromOtherBackground
} }
} }
val bubbleShape = bubbleShape() val bubbleShape = bubbleShape()
Surface( Box(
modifier = modifier modifier = modifier
.widthIn(min = 80.dp) .fillMaxWidth(BUBBLE_WIDTH_RATIO)
.offsetForItem() .padding(horizontal = 16.dp)
.clip(bubbleShape) .offsetForItem(),
.combinedClickable( // Need to set the contentAlignment again (it's already set in TimelineItemEventRow), for the case
onClick = onClick, // when content width is low.
onLongClick = onLongClick, contentAlignment = if (state.isMine) Alignment.CenterEnd else Alignment.CenterStart
indication = rememberRipple(), ) {
interactionSource = interactionSource Surface(
), modifier = Modifier
color = backgroundBubbleColor, .widthIn(min = 80.dp)
shape = bubbleShape, .clip(bubbleShape)
content = content .combinedClickable(
) onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(),
interactionSource = interactionSource
),
color = backgroundBubbleColor,
shape = bubbleShape,
content = content
)
}
} }
@Preview @Preview

View file

@ -33,7 +33,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
private val CORNER_RADIUS = 8.dp private val CORNER_RADIUS = 8.dp
@ -49,7 +49,7 @@ fun MessageStateEventContainer(
content: @Composable () -> Unit = {}, content: @Composable () -> Unit = {},
) { ) {
val backgroundColor = if (isHighlighted) { val backgroundColor = if (isHighlighted) {
ElementTheme.colors.messageHighlightedBackground ElementTheme.legacyColors.messageHighlightedBackground
} else { } else {
Color.Companion.Transparent Color.Companion.Transparent
} }

View file

@ -17,6 +17,9 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -27,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -37,23 +41,40 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@Composable @Composable
fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier) { fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier, onClick: () -> Unit) {
// First Surface is to render a border with the same background color as the background
Surface( Surface(
modifier = modifier, modifier = modifier.clickable(onClick = onClick::invoke),
color = MaterialTheme.colorScheme.surfaceVariant, // TODO Should use compound.bgSubtlePrimary
color = ElementTheme.legacyColors.gray300,
border = BorderStroke(2.dp, MaterialTheme.colorScheme.background), border = BorderStroke(2.dp, MaterialTheme.colorScheme.background),
shape = RoundedCornerShape(corner = CornerSize(12.dp)), shape = RoundedCornerShape(corner = CornerSize(14.dp)),
) { ) {
Row( Box(modifier = Modifier.padding(2.dp)) {
modifier = Modifier.padding(vertical = 5.dp, horizontal = 8.dp), val reactionModifier = if (reaction.isHighlighted) {
verticalAlignment = Alignment.CenterVertically Modifier
) { // TODO Check the color, should use compound.borderInteractivePrimary
// TODO `reaction.isHighlighted` is not used. .border(BorderStroke(1.dp, Color(0xFF808994)), RoundedCornerShape(corner = CornerSize(12.dp)))
Text(text = reaction.key, fontSize = 12.sp) } else {
Spacer(modifier = Modifier.width(4.dp)) Modifier
Text(text = reaction.count, color = MaterialTheme.colorScheme.secondary, fontSize = 12.sp) }
Row(
modifier = reactionModifier.padding(vertical = 4.dp, horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = reaction.key, fontSize = 15.sp)
if (reaction.count > 1) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = reaction.count.toString(),
color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
fontSize = 14.sp
)
}
}
} }
} }
} }
@ -70,5 +91,5 @@ internal fun MessagesReactionButtonDarkPreview(@PreviewParameter(AggregatedReact
@Composable @Composable
private fun ContentToPreview(reaction: AggregatedReaction) { private fun ContentToPreview(reaction: AggregatedReaction) {
MessagesReactionButton(reaction) MessagesReactionButton(reaction, onClick = { })
} }

View file

@ -40,11 +40,10 @@ import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
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.timeline.item.event.EventSendState import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.ui.strings.R import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineEventTimestampView( fun TimelineEventTimestampView(
@ -55,7 +54,7 @@ fun TimelineEventTimestampView(
val formattedTime = event.sentTime val formattedTime = event.sentTime
val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse() val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null
Row( Row(
modifier = Modifier modifier = Modifier
.clickable( .clickable(
@ -70,7 +69,7 @@ fun TimelineEventTimestampView(
) { ) {
if (isMessageEdited) { if (isMessageEdited) {
Text( Text(
stringResource(R.string.common_edited_suffix), stringResource(CommonStrings.common_edited_suffix),
style = ElementTextStyles.Regular.caption2, style = ElementTextStyles.Regular.caption2,
color = tint ?: MaterialTheme.colorScheme.secondary, color = tint ?: MaterialTheme.colorScheme.secondary,
) )

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@ -27,11 +28,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
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.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -40,22 +39,32 @@ import androidx.compose.runtime.remember
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.layout.LastBaseline import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.unit.sp
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.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
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.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.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
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.theme.LocalColors import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
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.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -66,6 +75,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.theme.ElementTheme
import org.jsoup.Jsoup
@Composable @Composable
fun TimelineItemEventRow( fun TimelineItemEventRow(
@ -76,6 +87,7 @@ fun TimelineItemEventRow(
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@ -84,71 +96,81 @@ fun TimelineItemEventRow(
onUserDataClick(event.senderId) onUserDataClick(event.senderId)
} }
fun onReactionClicked(emoji: String) =
onReactionClick(emoji, event)
fun inReplyToClicked() { fun inReplyToClicked() {
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
inReplyToClick(inReplyToEventId) inReplyToClick(inReplyToEventId)
} }
val (parentAlignment, contentAlignment) = if (event.isMine) { // To avoid using negative offset, we display in this Box a column with:
Pair(Alignment.CenterEnd, Alignment.End) // - Spacer to give room to the Sender information if they must be displayed;
} else { // - The message bubble;
Pair(Alignment.CenterStart, Alignment.Start) // - Spacer for the reactions if there are some.
} // Then the Sender information and the reactions are displayed on top of it.
// This fixes some clickable issue and some unexpected margin on top and bottom of each message row
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight(), .wrapContentHeight(),
contentAlignment = parentAlignment contentAlignment = if (event.isMine) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
Row { Column {
Column(horizontalAlignment = contentAlignment) { if (event.showSenderInformation) {
if (event.showSenderInformation) { Spacer(modifier = Modifier.height(event.senderAvatar.size.dp - 8.dp))
MessageSenderInformation( }
event.safeSenderName, val bubbleState = BubbleState(
event.senderAvatar, groupPosition = event.groupPosition,
Modifier isMine = event.isMine,
.zIndex(1f) isHighlighted = isHighlighted,
.offset(y = 12.dp) )
.clickable(onClick = ::onUserDataClicked) MessageEventBubble(
) state = bubbleState,
} interactionSource = interactionSource,
val bubbleState = BubbleState( onClick = onClick,
groupPosition = event.groupPosition, onLongClick = onLongClick,
isMine = event.isMine, ) {
isHighlighted = isHighlighted, MessageEventBubbleContent(
) event = event,
MessageEventBubble(
state = bubbleState,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onMessageClick = onClick,
onLongClick = onLongClick, onMessageLongClick = onLongClick,
modifier = Modifier inReplyToClick = ::inReplyToClicked,
.zIndex(-1f) onTimestampClicked = {
.widthIn(max = 320.dp) onTimestampClicked(event)
) { }
MessageEventBubbleContent(
event = event,
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick,
inReplyToClick = ::inReplyToClicked,
onTimestampClicked = {
onTimestampClicked(event)
}
)
}
TimelineItemReactionsView(
reactionsState = event.reactionsState,
modifier = Modifier
.zIndex(1f)
.offset(x = if (event.isMine) 0.dp else 20.dp, y = -(4.dp))
) )
} }
if (event.reactionsState.reactions.isNotEmpty()) {
Spacer(modifier = Modifier.height(28.dp))
}
}
// Align to the top of the box
if (event.showSenderInformation) {
MessageSenderInformation(
event.safeSenderName,
event.senderAvatar,
Modifier
.padding(horizontal = 16.dp)
.align(Alignment.TopStart)
.clickable(onClick = ::onUserDataClicked)
)
}
// Align to the bottom of the box
if (event.reactionsState.reactions.isNotEmpty()) {
TimelineItemReactionsView(
reactionsState = event.reactionsState,
onReactionClicked = ::onReactionClicked,
modifier = Modifier
.align(if (event.isMine) Alignment.BottomEnd else Alignment.BottomStart)
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
)
} }
} }
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage.
if (event.groupPosition.isNew()) { if (event.groupPosition.isNew()) {
Spacer(modifier = modifier.height(8.dp)) Spacer(modifier = modifier.height(16.dp))
} else { } else {
Spacer(modifier = modifier.height(2.dp)) Spacer(modifier = modifier.height(2.dp))
} }
@ -157,20 +179,38 @@ fun TimelineItemEventRow(
@Composable @Composable
private fun MessageSenderInformation( private fun MessageSenderInformation(
sender: String, sender: String,
senderAvatar: AvatarData?, senderAvatar: AvatarData,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row(modifier = modifier) { val avatarStrokeSize = 3.dp
if (senderAvatar != null) { val avatarStrokeColor = MaterialTheme.colorScheme.background
val avatarSize = senderAvatar.size.dp
Box(
modifier = modifier
) {
// Background of Avatar, to erase the corner of the message content
Canvas(
modifier = Modifier
.size(size = avatarSize + avatarStrokeSize)
.clipToBounds()
) {
drawCircle(
color = avatarStrokeColor,
center = Offset(x = (avatarSize / 2).toPx(), y = (avatarSize / 2).toPx()),
radius = (avatarSize / 2 + avatarStrokeSize).toPx()
)
}
// Content
Row {
Avatar(senderAvatar) Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text(
text = sender,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium,
)
} }
Text(
text = sender,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.alignBy(LastBaseline)
)
} }
} }
@ -196,6 +236,7 @@ private fun MessageEventBubbleContent(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onMessageClick, onClick = onMessageClick,
onLongClick = onMessageLongClick, onLongClick = onMessageLongClick,
extraPadding = event.toExtraPadding(),
modifier = modifier, modifier = modifier,
) )
} }
@ -215,20 +256,20 @@ private fun MessageEventBubbleContent(
onClick = onTimestampClicked, onClick = onTimestampClicked,
modifier = timestampModifier modifier = timestampModifier
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding .padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
.background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp)) .background(ElementTheme.legacyColors.gray300, RoundedCornerShape(10.0.dp))
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(horizontal = 4.dp, vertical = 2.dp) // Inner padding .padding(horizontal = 4.dp, vertical = 2.dp) // Inner padding
) )
} }
} else { } else {
Column(modifier) { Box(modifier) {
ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp)) ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp))
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, event = event,
onClick = onTimestampClicked, onClick = onTimestampClicked,
modifier = timestampModifier modifier = timestampModifier
.align(Alignment.End) .align(Alignment.BottomEnd)
.padding(horizontal = 8.dp, vertical = 2.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) )
} }
} }
@ -328,7 +369,7 @@ private fun ReplyToContent(
text = text.orEmpty(), text = text.orEmpty(),
style = ElementTextStyles.Regular.caption1, style = ElementTextStyles.Regular.caption1,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
color = LocalColors.current.placeholder, color = ElementTheme.legacyColors.placeholder,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@ -358,3 +399,94 @@ private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) =
) )
else -> null else -> null
} }
@Preview
@Composable
internal fun TimelineItemEventRowLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun TimelineItemEventRowDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
sequenceOf(false, true).forEach {
TimelineItemEventRow(
event = aTimelineItemEvent(
isMine = it,
content = aTimelineItemTextContent().copy(
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
)
),
isHighlighted = false,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onTimestampClicked = {},
)
TimelineItemEventRow(
event = aTimelineItemEvent(
isMine = it,
content = aTimelineItemImageContent().copy(
aspectRatio = 5f
)
),
isHighlighted = false,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onTimestampClicked = {},
)
}
}
}
@Preview
@Composable
internal fun TimelineItemEventRowTimestampLightPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) =
ElementPreviewLight { ContentTimestampToPreview(event) }
@Preview
@Composable
internal fun TimelineItemEventRowTimestampDarkPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) =
ElementPreviewDark { ContentTimestampToPreview(event) }
@Composable
private fun ContentTimestampToPreview(event: TimelineItem.Event) {
Column {
val oldContent = event.content as TimelineItemTextContent
listOf(
"Text",
"Text longer, displayed on 1 line",
"Text which should be rendered on several lines",
).forEach { str ->
listOf(false, true).forEach { useDocument ->
TimelineItemEventRow(
event = event.copy(
content = oldContent.copy(
body = str,
htmlDocument = if (useDocument) Jsoup.parse(str) else null,
),
reactionsState = aTimelineItemReactions(count = 0),
senderDisplayName = if (useDocument) "Document case" else "Text case",
),
isHighlighted = false,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onTimestampClicked = {},
)
}
}
}
}

View file

@ -30,15 +30,18 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
fun TimelineItemReactionsView( fun TimelineItemReactionsView(
reactionsState: TimelineItemReactions, reactionsState: TimelineItemReactions,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onReactionClicked: (emoji: String) -> Unit
) { ) {
if (reactionsState.reactions.isEmpty()) return
FlowRow( FlowRow(
modifier = modifier, modifier = modifier,
mainAxisSpacing = 2.dp, mainAxisSpacing = 2.dp,
crossAxisSpacing = 8.dp, crossAxisSpacing = 8.dp,
) { ) {
reactionsState.reactions.forEach { reaction -> reactionsState.reactions.forEach { reaction ->
MessagesReactionButton(reaction = reaction) MessagesReactionButton(
reaction = reaction,
onClick = { onReactionClicked(reaction.key) }
)
} }
} }
} }
@ -56,6 +59,7 @@ internal fun TimelineItemReactionsViewDarkPreview() =
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
TimelineItemReactionsView( TimelineItemReactionsView(
reactionsState = aTimelineItemReactions() reactionsState = aTimelineItemReactions(),
onReactionClicked = { }
) )
} }

View file

@ -25,11 +25,18 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
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.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@Composable @Composable
fun TimelineItemStateEventRow( fun TimelineItemStateEventRow(
@ -60,8 +67,33 @@ fun TimelineItemStateEventRow(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
extraPadding = noExtraPadding,
modifier = Modifier.defaultTimelineContentPadding() modifier = Modifier.defaultTimelineContentPadding()
) )
} }
} }
} }
@Preview
@Composable
internal fun TimelineItemStateEventRowLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun TimelineItemStateEventRowDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
TimelineItemStateEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
),
isHighlighted = false,
onClick = {},
onLongClick = {},
)
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.ui.strings.CommonStrings
// Allow to not overlap the timestamp with the text, in the message bubble.
// Compute the size of the worst case.
data class ExtraPadding(val str: String)
val noExtraPadding = ExtraPadding("")
/**
* See [io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView] for the related View.
* And https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1819%253A99506 for the design.
*/
@Composable
fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
val formattedTime = sentTime
val hasMessageSendingFailed = sendState is EventSendState.SendingFailed
val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
var strLen = 2
if (isMessageEdited) {
strLen += stringResource(id = CommonStrings.common_edited_suffix).length + 2
}
strLen += formattedTime.length
if (hasMessageSendingFailed) {
strLen += 5
}
// A space and a few unbreakable spaces
return ExtraPadding(" " + "\u00A0".repeat(strLen))
}

View file

@ -17,10 +17,8 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -35,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
fun TimelineItemEventContentView( fun TimelineItemEventContentView(
content: TimelineItemEventContent, content: TimelineItemEventContent,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -42,14 +41,17 @@ fun TimelineItemEventContentView(
when (content) { when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView( is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content, content = content,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
is TimelineItemRedactedContent -> TimelineItemRedactedView( is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = content, content = content,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
is TimelineItemTextBasedContent -> TimelineItemTextView( is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content, content = content,
extraPadding = extraPadding,
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = modifier, modifier = modifier,
onTextClicked = onClick, onTextClicked = onClick,
@ -57,6 +59,7 @@ fun TimelineItemEventContentView(
) )
is TimelineItemUnknownContent -> TimelineItemUnknownView( is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content, content = content,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
is TimelineItemImageContent -> TimelineItemImageView( is TimelineItemImageContent -> TimelineItemImageView(
@ -69,6 +72,7 @@ fun TimelineItemEventContentView(
) )
is TimelineItemFileContent -> TimelineItemFileView( is TimelineItemFileContent -> TimelineItemFileView(
content = content, content = content,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
is TimelineItemStateContent -> TimelineItemStateView( is TimelineItemStateContent -> TimelineItemStateView(

View file

@ -26,18 +26,19 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
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.ui.strings.CommonStrings
import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun TimelineItemEncryptedView( fun TimelineItemEncryptedView(
content: TimelineItemEncryptedContent, content: TimelineItemEncryptedContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
TimelineItemInformativeView( TimelineItemInformativeView(
text = stringResource(id = StringR.string.common_decryption_error), text = stringResource(id = CommonStrings.common_decryption_error),
iconDescription = stringResource(id = StringR.string.dialog_title_warning), iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
icon = Icons.Default.Warning, icon = Icons.Default.Warning,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
} }
@ -57,6 +58,7 @@ private fun ContentToPreview() {
TimelineItemEncryptedView( TimelineItemEncryptedView(
content = TimelineItemEncryptedContent( content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.Unknown data = UnableToDecryptContent.Data.Unknown
) ),
extraPadding = noExtraPadding
) )
} }

View file

@ -47,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable @Composable
fun TimelineItemFileView( fun TimelineItemFileView(
content: TimelineItemFileContent, content: TimelineItemFileContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@ -76,7 +77,7 @@ fun TimelineItemFileView(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = content.fileExtensionAndSize, text = content.fileExtensionAndSize + extraPadding.str,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
fontSize = 12.sp, fontSize = 12.sp,
maxLines = 1, maxLines = 1,
@ -98,5 +99,8 @@ internal fun TimelineItemFileViewDarkPreview(@PreviewParameter(TimelineItemFileC
@Composable @Composable
private fun ContentToPreview(content: TimelineItemFileContent) { private fun ContentToPreview(content: TimelineItemFileContent) {
TimelineItemFileView(content) TimelineItemFileView(
content,
extraPadding = noExtraPadding,
)
} }

View file

@ -41,6 +41,7 @@ fun TimelineItemInformativeView(
text: String, text: String,
iconDescription: String, iconDescription: String,
icon: ImageVector, icon: ImageVector,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
@ -58,7 +59,7 @@ fun TimelineItemInformativeView(
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
fontSize = 14.sp, fontSize = 14.sp,
text = text text = text + extraPadding.str
) )
} }
} }
@ -76,6 +77,7 @@ private fun ContentToPreview() {
TimelineItemInformativeView( TimelineItemInformativeView(
text = "Info", text = "Info",
iconDescription = "", iconDescription = "",
icon = Icons.Default.Delete icon = Icons.Default.Delete,
extraPadding = noExtraPadding,
) )
} }

View file

@ -25,18 +25,19 @@ import androidx.compose.ui.tooling.preview.Preview
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.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun TimelineItemRedactedView( fun TimelineItemRedactedView(
content: TimelineItemRedactedContent, content: TimelineItemRedactedContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
TimelineItemInformativeView( TimelineItemInformativeView(
text = stringResource(id = StringR.string.common_message_removed), text = stringResource(id = CommonStrings.common_message_removed),
iconDescription = stringResource(id = StringR.string.common_message_removed), iconDescription = stringResource(id = CommonStrings.common_message_removed),
icon = Icons.Default.Delete, icon = Icons.Default.Delete,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
} }
@ -53,5 +54,8 @@ internal fun TimelineItemRedactedViewDarkPreview() =
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
TimelineItemRedactedView(TimelineItemRedactedContent) TimelineItemRedactedView(
TimelineItemRedactedContent,
extraPadding = noExtraPadding
)
} }

View file

@ -22,6 +22,9 @@ import android.text.util.Linkify.PHONE_NUMBERS
import android.text.util.Linkify.WEB_URLS import android.text.util.Linkify.WEB_URLS
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -29,38 +32,46 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.core.text.util.LinkifyCompat import androidx.core.text.util.LinkifyCompat
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
import io.element.android.libraries.designsystem.LinkColor
import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.LinkColor
import io.element.android.libraries.designsystem.text.toAnnotatedString
@Composable @Composable
fun TimelineItemTextView( fun TimelineItemTextView(
content: TimelineItemTextBasedContent, content: TimelineItemTextBasedContent,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onTextClicked: () -> Unit = {}, onTextClicked: () -> Unit = {},
onTextLongClicked: () -> Unit = {}, onTextLongClicked: () -> Unit = {},
) { ) {
val htmlDocument = content.htmlDocument val htmlDocument = content.htmlDocument
if (htmlDocument != null) { if (htmlDocument != null) {
HtmlDocument( // For now we ignore the extra padding for html content, so add some spacing
document = htmlDocument, // below the content (as previous behavior)
modifier = modifier, Column(modifier = modifier) {
onTextClicked = onTextClicked, HtmlDocument(
onTextLongClicked = onTextLongClicked, document = htmlDocument,
interactionSource = interactionSource modifier = Modifier,
) onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
Spacer(Modifier.height(16.dp))
}
} else { } else {
Box(modifier) { Box(modifier) {
val linkStyle = SpanStyle( val linkStyle = SpanStyle(
color = LinkColor, color = LinkColor,
) )
val styledText = remember(content.body) { content.body.linkify(linkStyle) } val styledText = remember(content.body) { content.body.linkify(linkStyle) + extraPadding.str.toAnnotatedString() }
ClickableLinkText( ClickableLinkText(
text = styledText, text = styledText,
linkAnnotationTag = "URL", linkAnnotationTag = "URL",
@ -109,6 +120,10 @@ internal fun TimelineItemTextViewDarkPreview(@PreviewParameter(TimelineItemTextB
@Composable @Composable
fun ContentToPreview(content: TimelineItemTextBasedContent) { fun ContentToPreview(content: TimelineItemTextBasedContent) {
TimelineItemTextView(content, MutableInteractionSource()) TimelineItemTextView(
content = content,
interactionSource = MutableInteractionSource(),
extraPadding = ExtraPadding(" (padding)"),
)
} }

View file

@ -25,18 +25,19 @@ import androidx.compose.ui.tooling.preview.Preview
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.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun TimelineItemUnknownView( fun TimelineItemUnknownView(
content: TimelineItemUnknownContent, content: TimelineItemUnknownContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
TimelineItemInformativeView( TimelineItemInformativeView(
text = stringResource(id = StringR.string.common_unsupported_event), text = stringResource(id = CommonStrings.common_unsupported_event),
iconDescription = stringResource(id = StringR.string.dialog_title_warning), iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
icon = Icons.Default.Info, icon = Icons.Default.Info,
extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )
} }
@ -53,5 +54,8 @@ internal fun TimelineItemUnknownViewDarkPreview() =
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
TimelineItemUnknownView(TimelineItemUnknownContent) TimelineItemUnknownView(
content = TimelineItemUnknownContent,
extraPadding = noExtraPadding
)
} }

View file

@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
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.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -54,7 +54,7 @@ fun GroupHeaderView(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val backgroundColor = if (isHighlighted) { val backgroundColor = if (isHighlighted) {
ElementTheme.colors.messageHighlightedBackground ElementTheme.legacyColors.messageHighlightedBackground
} else { } else {
Color.Companion.Transparent Color.Companion.Transparent
} }

View file

@ -47,7 +47,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
import io.element.android.libraries.designsystem.LinkColor
import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -55,6 +54,7 @@ import io.element.android.libraries.designsystem.theme.components.Surface
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.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.theme.LinkColor
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
@ -535,7 +535,7 @@ private fun AnnotatedString.Builder.appendLink(link: Element) {
val permalinkData = PermalinkParser.parse(uriString) val permalinkData = PermalinkParser.parse(uriString)
when (permalinkData) { when (permalinkData) {
is PermalinkData.FallbackLink -> { is PermalinkData.FallbackLink -> {
pushStringAnnotation(tag = "URL", annotation = link.ownText()) pushStringAnnotation(tag = "URL", annotation = permalinkData.uri.toString())
withStyle( withStyle(
style = SpanStyle(color = LinkColor) style = SpanStyle(color = LinkColor)
) { ) {

View file

@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
@ -37,7 +38,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -129,7 +129,7 @@ private fun ColumnScope.RetrySendMenuContents(
headlineContent = { headlineContent = {
Text(stringResource(R.string.screen_room_retry_send_menu_remove_action)) Text(stringResource(R.string.screen_room_retry_send_menu_remove_action))
}, },
colors = ListItemDefaults.colors(headlineColor = LocalColors.current.textActionCritical), colors = ListItemDefaults.colors(headlineColor = MaterialTheme.colorScheme.error),
modifier = Modifier.clickable { modifier = Modifier.clickable {
coroutineScope.launch { coroutineScope.launch {
sheetState.hide() sheetState.hide()

View file

@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -39,30 +40,31 @@ internal fun TimelineItemDaySeparatorView(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( Box(
modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .wrapContentHeight()
.padding(8.dp), .padding(16.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = model.formattedDate, text = model.formattedDate,
color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.primary,
) )
} }
} }
@Preview @Preview
@Composable @Composable
internal fun TimelineItemDaySeparatorViewLightPreview(@PreviewParameter( internal fun TimelineItemDaySeparatorViewLightPreview(
TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel @PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel
) = ) =
ElementPreviewLight { ContentToPreview(model) } ElementPreviewLight { ContentToPreview(model) }
@Preview @Preview
@Composable @Composable
internal fun TimelineItemDaySeparatorViewDarkPreview(@PreviewParameter( internal fun TimelineItemDaySeparatorViewDarkPreview(
TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel @PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel
) = ) =
ElementPreviewDark { ContentToPreview(model) } ElementPreviewDark { ContentToPreview(model) }

View file

@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.core.bool.orTrue
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.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
@ -34,6 +35,7 @@ import javax.inject.Inject
class TimelineItemEventFactory @Inject constructor( class TimelineItemEventFactory @Inject constructor(
private val contentFactory: TimelineItemContentFactory, private val contentFactory: TimelineItemContentFactory,
private val matrixClient: MatrixClient,
) { ) {
fun create( fun create(
@ -67,7 +69,7 @@ class TimelineItemEventFactory @Inject constructor(
id = currentSender.value, id = currentSender.value,
name = senderDisplayName ?: currentSender.value, name = senderDisplayName ?: currentSender.value,
url = senderAvatarUrl, url = senderAvatarUrl,
size = AvatarSize.SMALL size = AvatarSize.TimelineSender
) )
return TimelineItem.Event( return TimelineItem.Event(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
@ -89,8 +91,13 @@ class TimelineItemEventFactory @Inject constructor(
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions { private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
val aggregatedReactions = event.reactions.map { val aggregatedReactions = event.reactions.map {
AggregatedReaction(key = it.key, count = it.count.toString(), isHighlighted = false) AggregatedReaction(
key = it.key,
count = it.count.toInt(),
isHighlighted = it.senderIds.contains(matrixClient.sessionId),
)
} }
aggregatedReactions.sortedByDescending { it.count }
return TimelineItemReactions(aggregatedReactions.toImmutableList()) return TimelineItemReactions(aggregatedReactions.toImmutableList())
} }

View file

@ -16,8 +16,13 @@
package io.element.android.features.messages.impl.timeline.model package io.element.android.features.messages.impl.timeline.model
/**
* @property key the reaction key (e.g. "👍")
* @property count the number of users who reacted with this key
* @property isHighlighted true if the reaction has (also) been sent by the current user.
*/
data class AggregatedReaction( data class AggregatedReaction(
val key: String, val key: String,
val count: String, val count: Int,
val isHighlighted: Boolean = false val isHighlighted: Boolean = false
) )

View file

@ -20,16 +20,20 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> { open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> {
override val values: Sequence<AggregatedReaction> override val values: Sequence<AggregatedReaction>
get() = sequenceOf( get() = sequenceOf(false, true).flatMap {
anAggregatedReaction(), sequenceOf(
anAggregatedReaction().copy(count = "88"), anAggregatedReaction(isHighlighted = it),
anAggregatedReaction().copy(isHighlighted = true), anAggregatedReaction(isHighlighted = it, count = 88),
anAggregatedReaction().copy(count = "88", isHighlighted = true), )
) }
} }
fun anAggregatedReaction() = AggregatedReaction( fun anAggregatedReaction(
key = "👍", key: String = "👍",
count = "1", // TODO Why is it a String? count: Int = 1,
isHighlighted = false, isHighlighted: Boolean = false,
) = AggregatedReaction(
key = key,
count = count,
isHighlighted = isHighlighted,
) )

View file

@ -39,11 +39,11 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> { class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
override val values = sequenceOf( override val values = sequenceOf(
aTimelineItemEmoteContent(), aTimelineItemEmoteContent(),
aTimelineItemEmoteContent().copy(htmlDocument = Jsoup.parse("Emote")), aTimelineItemEmoteContent().copy(htmlDocument = Jsoup.parse("Emote Document")),
aTimelineItemNoticeContent(), aTimelineItemNoticeContent(),
aTimelineItemNoticeContent().copy(htmlDocument = Jsoup.parse("Notice")), aTimelineItemNoticeContent().copy(htmlDocument = Jsoup.parse("Notice Document")),
aTimelineItemTextContent(), aTimelineItemTextContent(),
aTimelineItemTextContent().copy(htmlDocument = Jsoup.parse("Text")), aTimelineItemTextContent().copy(htmlDocument = Jsoup.parse("Text Document")),
) )
} }

View file

@ -29,7 +29,7 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
) )
} }
fun aTimelineItemFileContent(fileName: String) = TimelineItemFileContent( fun aTimelineItemFileContent(fileName: String = "A file.pdf") = TimelineItemFileContent(
body = fileName, body = fileName,
thumbnailSource = MediaSource(url = ""), thumbnailSource = MediaSource(url = ""),
fileSource = MediaSource(url = ""), fileSource = MediaSource(url = ""),

View file

@ -30,7 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.strings.R import io.element.android.libraries.ui.strings.CommonStrings
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(RoomScope::class) @ContributesBinding(RoomScope::class)
@ -42,12 +42,12 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemTextBasedContent -> event.content.body is TimelineItemTextBasedContent -> event.content.body
is TimelineItemStateContent -> event.content.body is TimelineItemStateContent -> event.content.body
is TimelineItemProfileChangeContent -> event.content.body is TimelineItemProfileChangeContent -> event.content.body
is TimelineItemEncryptedContent -> context.getString(R.string.common_unable_to_decrypt) is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(R.string.common_message_removed) is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemUnknownContent -> context.getString(R.string.common_unsupported_event) is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(R.string.common_image) is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(R.string.common_video) is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
is TimelineItemFileContent -> context.getString(R.string.common_file) is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
} }
} }
} }

View file

@ -80,7 +80,7 @@ class MessagesPresenterTest {
} }
@Test @Test
fun `present - handle sending a reaction`() = runTest { fun `present - handle toggling a reaction`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = createMessagePresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) val presenter = createMessagePresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
@ -89,17 +89,35 @@ class MessagesPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID)) initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
assertThat(room.sendReactionCount).isEqualTo(1) assertThat(room.myReactions.count()).isEqualTo(1)
// No crashes when sending a reaction failed // No crashes when sending a reaction failed
room.givenSendReactionResult(Result.failure(IllegalStateException("Failed to send reaction"))) room.givenToggleReactionResult(Result.failure(IllegalStateException("Failed to send reaction")))
initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID)) initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
assertThat(room.sendReactionCount).isEqualTo(2) assertThat(room.myReactions.count()).isEqualTo(1)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
} }
} }
@Test
fun `present - handle toggling a reaction twice`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val room = FakeMatrixRoom()
val presenter = createMessagePresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
assertThat(room.myReactions.count()).isEqualTo(1)
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
assertThat(room.myReactions.count()).isEqualTo(0)
}
}
@Test @Test
fun `present - handle action forward`() = runTest { fun `present - handle action forward`() = runTest {
val navigator = FakeMessagesNavigator() val navigator = FakeMessagesNavigator()

View file

@ -16,11 +16,12 @@
package io.element.android.features.messages.fixtures package io.element.android.features.messages.fixtures
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
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.TimelineItemReactions
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
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.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
@ -30,7 +31,6 @@ import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
import kotlinx.collections.immutable.persistentListOf
internal fun aMessageEvent( internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID, eventId: EventId? = AN_EVENT_ID,
@ -43,11 +43,11 @@ internal fun aMessageEvent(
eventId = eventId, eventId = eventId,
senderId = A_USER_ID, senderId = A_USER_ID,
senderDisplayName = A_USER_NAME, senderDisplayName = A_USER_NAME,
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME), senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender),
content = content, content = content,
sentTime = "", sentTime = "",
isMine = isMine, isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf()), reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID), sendState = EventSendState.Sent(AN_EVENT_ID),
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
debugInfo = debugInfo, debugInfo = debugInfo,

View file

@ -36,6 +36,7 @@ import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
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.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -44,7 +45,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
return TimelineItemsFactory( return TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory( eventItemFactory = TimelineItemEventFactory(
TimelineItemContentFactory( contentFactory = TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()), messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()),
redactedMessageFactory = TimelineItemContentRedactedFactory(), redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(), stickerFactory = TimelineItemContentStickerFactory(),
@ -54,7 +55,8 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter), stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory() failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory()
) ),
matrixClient = FakeMatrixClient(),
), ),
virtualItemFactory = TimelineItemVirtualFactory( virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory( daySeparatorFactory = TimelineItemDaySeparatorFactory(

View file

@ -18,10 +18,9 @@ package io.element.android.features.messages.timeline.groups
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aMessageEvent import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
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.TimelineItemReactions
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.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
@ -42,7 +41,7 @@ class TimelineItemGrouperTest {
senderAvatar = anAvatarData(), senderAvatar = anAvatarData(),
senderDisplayName = "", senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"), content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = TimelineItemReactions(emptyList<AggregatedReaction>().toImmutableList()), reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID), sendState = EventSendState.Sent(AN_EVENT_ID),
inReplyTo = null, inReplyTo = null,
debugInfo = aTimelineItemDebugInfo(), debugInfo = aTimelineItemDebugInfo(),

View file

@ -48,8 +48,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.LocalColors import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun ConnectivityIndicatorView( fun ConnectivityIndicatorView(
@ -83,7 +82,7 @@ private fun Indicator(modifier: Modifier = Modifier) {
Row( Row(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.background(LocalColors.current.gray400) .background(MaterialTheme.colorScheme.secondaryContainer)
.statusBarsPadding() .statusBarsPadding()
.padding(vertical = 6.dp), .padding(vertical = 6.dp),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
@ -98,7 +97,7 @@ private fun Indicator(modifier: Modifier = Modifier) {
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = stringResource(StringR.string.common_offline), text = stringResource(CommonStrings.common_offline),
style = ElementTextStyles.Regular.bodyMD.copy(fontWeight = FontWeight.Medium), style = ElementTextStyles.Regular.bodyMD.copy(fontWeight = FontWeight.Medium),
color = tint, color = tint,
) )

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