Merge branch 'develop' into feature/fga/room_list_api

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

View file

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

View file

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

View file

@ -42,7 +42,7 @@ jobs:
name: elementx-debug
path: |
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:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
app-file: app/build/outputs/apk/debug/app-universal-debug.apk

View file

@ -3,6 +3,7 @@
<!--- TOC -->
* [Contributing code to Matrix](#contributing-code-to-matrix)
* [Developer onboarding](#developer-onboarding)
* [Android Studio settings](#android-studio-settings)
* [Compilation](#compilation)
* [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
## Developer onboarding
For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md).
## 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`).

View file

@ -26,6 +26,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
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 io.element.android.libraries.architecture.bindings
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.x.di.AppBindings
import io.element.android.x.intent.SafeUriHandler
import timber.log.Timber
private val loggerTag = LoggerTag("MainActivity")
@ -63,6 +65,7 @@ class MainActivity : NodeComponentActivity() {
ElementTheme {
CompositionLocalProvider(
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
LocalUriHandler provides SafeUriHandler(this),
) {
Box(
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>
</style>
<style name="Theme.ElementX" parent="Theme.ElementAndroid" />
<style name="Theme.ElementX" parent="Theme.Material3.Dark" />
</resources>

View file

@ -21,5 +21,5 @@
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="postSplashScreenTheme">@style/Theme.ElementX</item>
</style>
<style name="Theme.ElementX" parent="Theme.ElementAndroid" />
<style name="Theme.ElementX" parent="Theme.Material3.Light" />
</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.verification.SessionVerificationService
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.Job
import kotlinx.coroutines.flow.drop
@ -48,13 +48,13 @@ class LoggedInEventProcessor @Inject constructor(
fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = coroutineScope.launch {
displayLeftRoomMessage.onEach {
displayMessage(R.string.common_current_user_left_room)
displayMessage(CommonStrings.common_current_user_left_room)
}.launchIn(this)
displayVerificationSuccessfulMessage
.drop(1)
.onEach {
displayMessage(R.string.common_verification_complete)
displayMessage(CommonStrings.common_verification_complete)
}.launchIn(this)
}
}

View file

@ -257,6 +257,10 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onRoomSettingsClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId, initialElement = RoomFlowNode.NavTarget.RoomDetails))
}
override fun onReportBugClicked() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}
}
roomListEntryPoint
.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.PreviewParameter
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.PreferenceSwitch
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
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
fun AnalyticsPreferencesView(
@ -43,11 +43,11 @@ fun AnalyticsPreferencesView(
state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled))
}
PreferenceCategory(title = stringResource(id = StringR.string.screen_analytics_settings_share_data)) {
val firstPart = stringResource(id = StringR.string.screen_analytics_settings_help_us_improve, state.applicationName)
PreferenceCategory(title = stringResource(id = CommonStrings.screen_analytics_settings_share_data)) {
val firstPart = stringResource(id = CommonStrings.screen_analytics_settings_help_us_improve, state.applicationName)
val secondPart = buildAnnotatedStringWithColoredPart(
StringR.string.screen_analytics_settings_read_terms,
StringR.string.screen_analytics_settings_read_terms_content_link
CommonStrings.screen_analytics_settings_read_terms,
CommonStrings.screen_analytics_settings_read_terms_content_link
)
val title = "$firstPart\n\n$secondPart"

View file

@ -36,7 +36,6 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
api(projects.features.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.ElementPreviewLight
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.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
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
fun AnalyticsOptInView(
@ -162,7 +162,7 @@ private fun AnalyticsOptInContentRow(
modifier = modifier
.fillMaxWidth()
.background(
color = LocalColors.current.quinary,
color = ElementTheme.legacyColors.quinary,
shape = bgShape,
)
.padding(vertical = 12.dp, horizontal = 20.dp),
@ -174,8 +174,7 @@ private fun AnalyticsOptInContentRow(
.padding(2.dp),
imageVector = Icons.Rounded.Check,
contentDescription = null,
// TODO Compound, this color is not yet in the theme
tint = Color(0xFF007A61)
tint = ElementTheme.colors.iconSuccessPrimary,
)
Text(
modifier = Modifier.padding(start = 16.dp),
@ -199,13 +198,13 @@ private fun AnalyticsOptInFooter(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) },
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = StringR.string.action_ok))
Text(text = stringResource(id = CommonStrings.action_ok))
}
TextButton(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) },
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.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.deeplink)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
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.Text
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)
@Composable
@ -107,7 +107,7 @@ fun AddPeopleViewTopBar(
modifier = Modifier.padding(horizontal = 8.dp),
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 = stringResource(id = textActionResId),
fontSize = 16.sp,

View file

@ -20,9 +20,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.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.CheckableUnresolvedUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@ -40,7 +40,7 @@ fun SearchMultipleUsersResultItem(
CheckableUnresolvedUserRow(
checked = isUserSelected,
modifier = modifier,
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)),
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
id = searchResult.matrixUser.userId.value,
onCheckedChange = onCheckedChange,
)
@ -49,7 +49,7 @@ fun SearchMultipleUsersResultItem(
checked = isUserSelected,
modifier = modifier,
matrixUser = searchResult.matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
avatarSize = AvatarSize.UserListItem,
onCheckedChange = onCheckedChange,
)
}
@ -63,8 +63,11 @@ internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview { Con
private fun ContentToPreview() {
Column {
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = false)
Divider()
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = true)
Divider()
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = false)
Divider()
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.ui.Modifier
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.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.UnresolvedUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@ -39,26 +39,27 @@ fun SearchSingleUserResultItem(
if (searchResult.isUnresolved) {
UnresolvedUserRow(
modifier = modifier.clickable(onClick = onClick),
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)),
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
id = searchResult.matrixUser.userId.value,
)
} else {
MatrixUserRow(
modifier = modifier.clickable(onClick = onClick),
matrixUser = searchResult.matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
avatarSize = AvatarSize.UserListItem,
)
}
}
@Preview
@Composable
internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview{ ContentToPreview() }
internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false))
Divider()
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.matrix.api.user.MatrixUser
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 kotlinx.collections.immutable.ImmutableList
@ -54,7 +54,7 @@ fun SearchUserBar(
isMultiSelectionEnabled: Boolean,
modifier: Modifier = Modifier,
showBackButton: Boolean = true,
placeHolderTitle: String = stringResource(R.string.common_search_for_someone),
placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone),
onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> 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.libraries.architecture.Async
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.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@ -131,7 +131,7 @@ class ConfigureRoomPresenter @Inject constructor(
dataStore.clearCachedData()
analyticsService.capture(CreatedRoom(isDM = false))
}
}.execute(createRoomAction)
}.runCatchingUpdatingState(createRoomAction)
}
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.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class)
@Composable
@ -84,7 +84,7 @@ fun ConfigureRoomView(
if (state.createRoomAction is Async.Success) {
LaunchedEffect(state.createRoomAction) {
onRoomCreated(state.createRoomAction.state)
onRoomCreated(state.createRoomAction.data)
}
}
@ -158,7 +158,7 @@ fun ConfigureRoomView(
when (state.createRoomAction) {
is Async.Loading -> {
ProgressDialog(text = stringResource(StringR.string.common_creating_room))
ProgressDialog(text = stringResource(CommonStrings.common_creating_room))
}
is Async.Failure -> {
@ -198,7 +198,7 @@ fun ConfigureRoomToolbar(
onClick = onNextPressed,
) {
Text(
text = stringResource(StringR.string.action_create),
text = stringResource(CommonStrings.action_create),
fontSize = 16.sp,
)
}
@ -227,7 +227,7 @@ fun RoomNameWithAvatar(
LabelledTextField(
label = stringResource(R.string.screen_create_room_room_name_label),
value = roomName,
placeholder = stringResource(StringR.string.common_room_name_placeholder),
placeholder = stringResource(CommonStrings.common_room_name_placeholder),
singleLine = true,
onValueChange = onRoomNameChanged,
)
@ -244,7 +244,7 @@ fun RoomTopic(
modifier = modifier,
label = stringResource(R.string.screen_create_room_topic_label),
value = topic,
placeholder = stringResource(StringR.string.common_topic_placeholder),
placeholder = stringResource(CommonStrings.common_topic_placeholder),
onValueChange = onTopicChanged,
maxLines = 3,
)

View file

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

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.libraries.architecture.Async
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.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@ -95,6 +95,6 @@ class CreateRoomRootPresenter @Inject constructor(
suspend {
matrixClient.createDM(user.userId).getOrThrow()
.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.Text
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.ui.strings.R as StringR
@OptIn(ExperimentalLayoutApi::class)
@Composable
@ -69,7 +69,7 @@ fun CreateRoomRootView(
) {
if (state.startDmAction is Async.Success) {
LaunchedEffect(state.startDmAction) {
onOpenDM(state.startDmAction.state)
onOpenDM(state.startDmAction.data)
}
}
@ -107,7 +107,7 @@ fun CreateRoomRootView(
when (state.startDmAction) {
is Async.Loading -> {
ProgressDialog(text = stringResource(id = StringR.string.common_starting_chat))
ProgressDialog(text = stringResource(id = CommonStrings.common_starting_chat))
}
is Async.Failure -> {
@ -137,7 +137,7 @@ fun CreateRoomRootViewTopBar(
modifier = modifier,
title = {
Text(
text = stringResource(id = StringR.string.action_start_chat),
text = stringResource(id = CommonStrings.action_start_chat),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
@ -146,7 +146,7 @@ fun CreateRoomRootViewTopBar(
IconButton(onClick = onClosePressed) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = StringR.string.action_close),
contentDescription = stringResource(id = CommonStrings.action_close),
tint = MaterialTheme.colorScheme.primary,
)
}
@ -169,7 +169,7 @@ fun CreateRoomActionButtonsList(
)
CreateRoomActionButton(
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,
)
}

View file

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

View file

@ -25,16 +25,20 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.impl.model.InviteListInviteSummary
import io.element.android.features.invitelist.impl.model.InviteSender
import io.element.android.libraries.architecture.Async
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.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
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.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
@ -44,6 +48,7 @@ import javax.inject.Inject
class InviteListPresenter @Inject constructor(
private val client: MatrixClient,
private val store: SeenInvitesStore,
private val analyticsService: AnalyticsService,
) : Presenter<InviteListState> {
@Composable
@ -133,9 +138,10 @@ class InviteListPresenter @Inject constructor(
suspend {
client.getRoom(roomId)?.use {
it.acceptInvitation().getOrThrow()
analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
}
roomId
}.execute(acceptedAction)
}.runCatchingUpdatingState(acceptedAction)
}
private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<Async<Unit>>) = launch {
@ -143,7 +149,7 @@ class InviteListPresenter @Inject constructor(
client.getRoom(roomId)?.use {
it.rejectInvitation().getOrThrow()
} ?: Unit
}.execute(declinedAction)
}.runCatchingUpdatingState(declinedAction)
}
private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run {
@ -153,12 +159,14 @@ class InviteListPresenter @Inject constructor(
id = i.userId.value,
name = i.displayName,
url = i.avatarUrl,
size = AvatarSize.RoomInviteItem,
)
else
AvatarData(
id = roomId.value,
name = name,
url = avatarURLString
url = avatarURLString,
size = AvatarSize.RoomInviteItem,
)
val alias = if (isDirect)
@ -181,6 +189,7 @@ class InviteListPresenter @Inject constructor(
id = userId.value,
name = displayName,
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.TopAppBar
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
fun InviteListView(
@ -58,7 +58,7 @@ fun InviteListView(
) {
if (state.acceptedAction is Async.Success) {
LaunchedEffect(state.acceptedAction) {
onInviteAccepted(state.acceptedAction.state)
onInviteAccepted(state.acceptedAction.data)
}
}
@ -82,8 +82,8 @@ fun InviteListView(
ConfirmationDialog(
content = stringResource(contentResource, state.declineConfirmationDialog.name),
title = stringResource(titleResource),
submitText = stringResource(StringR.string.action_decline),
cancelText = stringResource(StringR.string.action_cancel),
submitText = stringResource(CommonStrings.action_decline),
cancelText = stringResource(CommonStrings.action_cancel),
emphasizeSubmitButton = true,
onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) },
onDismiss = { state.eventSink(InviteListEvents.CancelDeclineInvite) }
@ -92,18 +92,18 @@ fun InviteListView(
if (state.acceptedAction is Async.Failure) {
ErrorDialog(
content = stringResource(StringR.string.error_unknown),
title = stringResource(StringR.string.common_error),
submitText = stringResource(StringR.string.action_ok),
content = stringResource(CommonStrings.error_unknown),
title = stringResource(CommonStrings.common_error),
submitText = stringResource(CommonStrings.action_ok),
onDismiss = { state.eventSink(InviteListEvents.DismissAcceptError) }
)
}
if (state.declinedAction is Async.Failure) {
ErrorDialog(
content = stringResource(StringR.string.error_unknown),
title = stringResource(StringR.string.common_error),
submitText = stringResource(StringR.string.action_ok),
content = stringResource(CommonStrings.error_unknown),
title = stringResource(CommonStrings.common_error),
submitText = stringResource(CommonStrings.action_ok),
onDismiss = { state.eventSink(InviteListEvents.DismissDeclineError) }
)
}
@ -124,7 +124,7 @@ fun InviteListContent(
BackButton(onClick = onBackClicked)
},
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
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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.InviteSender
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.AvatarSize
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.noFontPadding
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
@ -74,8 +71,8 @@ internal fun InviteSummaryRow(
) {
Box(
modifier = modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.fillMaxWidth()
.heightIn(min = minHeight)
) {
DefaultInviteSummaryRow(
invite = invite,
@ -93,20 +90,20 @@ internal fun DefaultInviteSummaryRow(
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.height(IntrinsicSize.Min),
.fillMaxWidth()
.padding(16.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.Top
) {
Avatar(
invite.roomAvatarData.copy(size = AvatarSize.Custom(52.dp)),
invite.roomAvatarData,
)
Column(
modifier = Modifier
.padding(start = 16.dp, end = 4.dp)
.alignByBaseline()
.weight(1f)
.padding(start = 16.dp, end = 4.dp)
.alignByBaseline()
.weight(1f)
) {
val bonusPadding = if (invite.isNew) 12.dp else 0.dp
@ -143,31 +140,29 @@ internal fun DefaultInviteSummaryRow(
// CTAs
Row(Modifier.padding(top = 12.dp)) {
OutlinedButton(
content = { Text(stringResource(StringR.string.action_decline), style = ElementTextStyles.Button) },
content = { Text(stringResource(CommonStrings.action_decline), style = ElementTextStyles.Button) },
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),
)
Spacer(modifier = Modifier.width(12.dp))
Button(
content = { Text(stringResource(StringR.string.action_accept), style = ElementTextStyles.Button) },
content = { Text(stringResource(CommonStrings.action_accept), style = ElementTextStyles.Button) },
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),
)
}
}
val unreadIndicatorColor = if (invite.isNew) MaterialTheme.roomListUnreadIndicator() else Color.Transparent
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(unreadIndicatorColor)
)
UnreadIndicatorAtom(color = unreadIndicatorColor)
}
}
@ -178,7 +173,7 @@ private fun SenderRow(sender: InviteSender) {
modifier = Modifier.padding(top = 6.dp),
) {
Avatar(
avatarData = sender.avatarData.copy(size = AvatarSize.Custom(16.dp)),
avatarData = sender.avatarData,
)
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 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.UserId
@ -26,14 +27,14 @@ data class InviteListInviteSummary(
val roomId: RoomId,
val roomName: String = "",
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 isDirect: Boolean = false,
val isNew: Boolean = false,
)
data class InviteSender(
data class InviteSender constructor(
val userId: UserId,
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.turbine.test
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.libraries.architecture.Async
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.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -50,6 +52,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -74,6 +77,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -88,6 +92,7 @@ class InviteListPresenterTests {
id = A_USER_ID.value,
name = A_USER_NAME,
url = AN_AVATAR_URL,
size = AvatarSize.RoomInviteItem,
)
)
Truth.assertThat(withInviteState.inviteList[0].sender).isNull()
@ -102,6 +107,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -115,6 +121,7 @@ class InviteListPresenterTests {
id = A_USER_ID.value,
name = A_USER_NAME,
url = AN_AVATAR_URL,
size = AvatarSize.InviteSender,
)
)
}
@ -128,6 +135,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -152,6 +160,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -176,6 +185,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -199,7 +209,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
@ -225,7 +235,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
@ -256,7 +266,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
@ -288,7 +298,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
@ -311,7 +321,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
@ -336,7 +346,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
@ -365,6 +375,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
),
store,
FakeAnalyticsService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -401,6 +412,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource,
),
store,
FakeAnalyticsService(),
)
moleculeFlow(RecompositionClock.Immediate) {
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.ElementPreviewLight
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.R
import io.element.android.libraries.ui.strings.R as StringR
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LeaveRoomView(
@ -50,19 +49,19 @@ private fun LeaveRoomConfirmationDialog(
when (state.confirmation) {
is LeaveRoomState.Confirmation.Hidden -> {}
is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog(
text = StringR.string.leave_room_alert_private_subtitle,
text = CommonStrings.leave_room_alert_private_subtitle,
roomId = state.confirmation.roomId,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog(
text = StringR.string.leave_room_alert_empty_subtitle,
text = CommonStrings.leave_room_alert_empty_subtitle,
roomId = state.confirmation.roomId,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog(
text = StringR.string.leave_room_alert_subtitle,
text = CommonStrings.leave_room_alert_subtitle,
roomId = state.confirmation.roomId,
eventSink = state.eventSink,
)
@ -77,7 +76,7 @@ private fun LeaveRoomConfirmationDialog(
) {
ConfirmationDialog(
content = stringResource(text),
submitText = stringResource(R.string.action_leave),
submitText = stringResource(CommonStrings.action_leave),
onSubmitClicked = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) },
onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) },
)
@ -90,7 +89,7 @@ private fun LeaveRoomProgressDialog(
when (state.progress) {
is LeaveRoomState.Progress.Hidden -> {}
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) {
is LeaveRoomState.Error.Hidden -> {}
is LeaveRoomState.Error.Shown -> ErrorDialog(
content = stringResource(StringR.string.error_unknown),
content = stringResource(CommonStrings.error_unknown),
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.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.FloatingActionButton
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.toImmutableList
import timber.log.Timber
@ -67,7 +67,7 @@ import kotlin.coroutines.suspendCoroutine
fun MapView(
modifier: Modifier = Modifier,
mapState: MapState = rememberMapState(),
darkMode: Boolean = !ElementTheme.colors.isLight,
darkMode: Boolean = !ElementTheme.isLightTheme,
onLocationClick: () -> Unit,
) {
// 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.rememberAsyncImagePainter
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.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
/**
@ -52,7 +52,7 @@ fun StaticMapView(
zoom: Double,
contentDescription: String?,
modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.colors.isLight,
darkMode: Boolean = !ElementTheme.isLightTheme,
) {
// Using BoxWithConstraints to:
// 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.PreviewParameterProvider
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.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.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.features.location.api.R
import io.element.android.libraries.ui.strings.R as StringsR
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun StaticMapPlaceholder(
showProgress: Boolean,
contentDescription: String?,
modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.colors.isLight,
darkMode: Boolean = !ElementTheme.isLightTheme,
onLoadMapClick: () -> Unit,
) {
Box(
@ -76,7 +76,7 @@ internal fun StaticMapPlaceholder(
imageVector = Icons.Default.Refresh,
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.network)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(libs.androidx.browser)

View file

@ -18,6 +18,7 @@ package io.element.android.features.login.impl
import android.app.Activity
import android.os.Parcelable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.createNode
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.matrix.api.auth.OidcDetails
import io.element.android.libraries.theme.ElementTheme
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@ -155,7 +156,7 @@ class LoginFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
activity = LocalContext.current as? Activity
darkTheme = !ElementTheme.colors.isLight
darkTheme = !ElementTheme.isLightTheme
DisposableEffect(Unit) {
onDispose {
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.libraries.architecture.Async
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.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
@ -71,6 +71,6 @@ class ChangeServerPresenter @Inject constructor(
// Valid, remember user choice
accountProviderDataSource.userSelection(data)
}.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 io.element.android.features.login.impl.R
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
internal fun SlidingSyncNotSupportedDialog(
@ -32,11 +32,11 @@ internal fun SlidingSyncNotSupportedDialog(
ConfirmationDialog(
modifier = modifier,
onDismiss = onDismiss,
submitText = stringResource(StringR.string.action_learn_more),
submitText = stringResource(CommonStrings.action_learn_more),
onSubmitClicked = onLearnMoreClicked,
onCancelClicked = onDismiss,
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),
)
}

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.AuthenticationException
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
fun loginError(
throwable: Throwable
): Int {
val authException = throwable as? AuthenticationException ?: return StringR.string.error_unknown
val authException = throwable as? AuthenticationException ?: return CommonStrings.error_unknown
return when (authException.errorCode) {
AuthErrorCode.FORBIDDEN -> R.string.screen_login_error_invalid_credentials
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.libraries.architecture.Async
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.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
@ -95,6 +95,6 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
throw IllegalStateException("Unsupported login flow")
}
}.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.Success -> {
when (val loginFlowState = state.loginFlow.state) {
when (val loginFlowState = state.loginFlow.data) {
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
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.testtags.TestTags
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)
@Composable
@ -213,7 +213,7 @@ internal fun LoginForm(
IconButton(onClick = {
loginFieldState = ""
}) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(StringR.string.action_clear))
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(CommonStrings.action_clear))
}
}
} else null,
@ -248,7 +248,7 @@ internal fun LoginForm(
val image =
if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
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 }) {
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.testtags.TestTags
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
@ -143,7 +143,7 @@ fun SearchAccountProviderView(
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(StringR.string.action_clear)
contentDescription = stringResource(CommonStrings.action_clear)
)
}
}
@ -171,7 +171,7 @@ fun SearchAccountProviderView(
}
}
is Async.Success -> {
items(state.userInputResult.state) { homeserverData ->
items(state.userInputResult.data) { homeserverData ->
val item = homeserverData.toAccountProvider()
AccountProviderView(
item = item,

View file

@ -19,8 +19,8 @@ package io.element.android.features.login.impl.error
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.R
import io.element.android.libraries.matrix.api.auth.AuthenticationException
import io.element.android.libraries.ui.strings.CommonStrings
import org.junit.Test
import io.element.android.libraries.ui.strings.R as StringR
class ErrorFormatterTests {
@ -28,19 +28,19 @@ class ErrorFormatterTests {
@Test
fun `loginError - invalid unknown error returns unknown error message`() {
val error = Throwable("Some unknown error")
assertThat(loginError(error)).isEqualTo(StringR.string.error_unknown)
assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown)
}
@Test
fun `loginError - invalid auth error returns unknown error message`() {
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
fun `loginError - unknown error returns unknown error message`() {
val error = AuthenticationException.Generic("M_UNKNOWN")
assertThat(loginError(error)).isEqualTo(StringR.string.error_unknown)
assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown)
}
@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.preview.ElementPreviewDark
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
fun LogoutPreferenceView(
@ -81,7 +81,7 @@ fun LogoutPreferenceView(
fun LogoutPreferenceContent(
onClick: () -> Unit = {},
) {
PreferenceCategory(title = stringResource(id = StringR.string.settings_title_general)) {
PreferenceCategory(title = stringResource(id = CommonStrings.settings_title_general)) {
PreferenceText(
title = stringResource(id = R.string.screen_signout_preference_item),
icon = Icons.Default.Logout,

View file

@ -35,7 +35,6 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
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.LogoutPreferenceState
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.matrix.api.MatrixClient
import kotlinx.coroutines.CoroutineScope
@ -59,6 +59,6 @@ class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixCli
private fun CoroutineScope.logout(logoutAction: MutableState<Async<Unit>>) = launch {
suspend {
matrixClient.logout()
}.execute(logoutAction)
}.runCatchingUpdatingState(logoutAction)
}
}

View file

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

View file

@ -22,6 +22,6 @@ import io.element.android.libraries.matrix.api.core.EventId
sealed interface 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
}

View file

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

View file

@ -118,7 +118,7 @@ class MessagesPresenter @AssistedInject constructor(
id = room.roomId.value,
name = room.name,
url = room.avatarUrl,
size = AvatarSize.SMALL
size = AvatarSize.TimelineRoom
)
roomName.value = room.name
}
@ -130,8 +130,8 @@ class MessagesPresenter @AssistedInject constructor(
is MessagesEvents.HandleAction -> {
localCoroutineScope.handleTimelineAction(event.action, event.event, composerState)
}
is MessagesEvents.SendReaction -> {
localCoroutineScope.sendReaction(event.emoji, event.eventId)
is MessagesEvents.ToggleReaction -> {
localCoroutineScope.toggleReaction(event.emoji, event.eventId)
}
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,
eventId: EventId,
) = launch(dispatchers.io) {
room.sendReaction(emoji, eventId)
room.toggleReaction(emoji, eventId)
.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.libraries.core.data.StableCharSequence
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.textcomposer.MessageComposerMode
@ -42,7 +43,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
fun aMessagesState() = MessagesState(
roomId = RoomId("!id:domain"),
roomName = "Room name",
roomAvatar = AvatarData("!id:domain", "Room name"),
roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
userHasPermissionToSendMessage = true,
composerState = aMessageComposerState().copy(
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.matrix.api.core.UserId
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 timber.log.Timber
import io.element.android.libraries.ui.strings.R as StringsR
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
@ -120,7 +120,7 @@ fun MessagesView(
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
if (event.eventId == null) return
state.eventSink(MessagesEvents.SendReaction(emoji, event.eventId))
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId))
}
Scaffold(
@ -150,7 +150,8 @@ fun MessagesView(
if (event.sendState is EventSendState.SendingFailed) {
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
}
}
},
onReactionClicked = ::onEmojiReactionClicked
)
},
snackbarHost = {
@ -174,7 +175,7 @@ fun MessagesView(
state = state.customReactionState,
onEmojiSelected = { emoji ->
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))
}
}
@ -195,7 +196,7 @@ private fun AttachmentStateView(
is AttachmentsState.Previewing -> LaunchedEffect(state) {
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,
onMessageClicked: (TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit,
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
@ -223,6 +225,7 @@ fun MessagesViewContent(
onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
onTimestampClicked = onTimestampClicked,
onReactionClicked = onReactionClicked,
)
}
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 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.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
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
@ -29,15 +33,27 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(),
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
TimelineItemAction.Edit,
TimelineItemAction.Redact,
)
actions = aTimelineItemActionList(),
)
)
),
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,
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.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
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.TextUnit
import androidx.compose.ui.unit.dp
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.event.TimelineItemEncryptedContent
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.TimelineItemProfileChangeContent
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.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.preview.ElementPreviewDark
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.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
@ -169,9 +165,11 @@ private fun SheetContent(
) {
item {
Column {
MessageSummary(event = target.event, modifier = Modifier
MessageSummary(
event = target.event, modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp))
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(14.dp))
Divider()
}
@ -214,10 +212,10 @@ private fun SheetContent(
@Composable
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
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 imageModifier = Modifier
.size(36.dp)
.size(AvatarSize.MessageActionSender.dp)
.clip(RoundedCornerShape(9.dp))
@Composable
@ -232,7 +230,6 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
when (event.content) {
is TimelineItemTextBasedContent,
is TimelineItemStateContent,
is TimelineItemProfileChangeContent,
is TimelineItemEncryptedContent,
is TimelineItemRedactedContent,
is TimelineItemUnknownContent -> content = { ContentForBody(textContent) }
@ -282,7 +279,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
Row(modifier = modifier) {
icon()
Spacer(modifier = Modifier.width(8.dp))
Column {
Column(modifier = Modifier.weight(1f)) {
Row {
if (event.senderDisplayName != null) {
Text(
@ -291,16 +288,16 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
color = MaterialTheme.colorScheme.primary
)
}
Text(
event.sentTime,
style = ElementTextStyles.Regular.caption2,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f)
)
}
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(
emoji,
fontSize = 28.dpToSp(),
fontSize = 28.dp.toSp(),
modifier = modifier.clickable(
enabled = true,
onClick = { onClicked(emoji) },
@ -359,11 +356,6 @@ private fun EmojiButton(
)
}
@Composable
private fun Int.dpToSp(): TextUnit = with(LocalDensity.current) {
return dp.toSp()
}
@Preview
@Composable
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.libraries.architecture.NodeInputs
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
@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.libraries.architecture.Async
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -83,8 +83,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mediaAttachment: Attachment.Media,
sendActionState: MutableState<Async<Unit>>,
) {
suspend {
sendActionState.runUpdatingState {
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.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.R
import io.element.android.libraries.ui.strings.R as StringsR
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun AttachmentsPreviewView(
@ -92,7 +91,7 @@ private fun AttachmentSendStateView(
) {
when (sendActionState) {
is Async.Loading -> {
ProgressDialog(text = stringResource(id = R.string.common_loading))
ProgressDialog(text = stringResource(id = CommonStrings.common_loading))
}
is Async.Failure -> {
@ -151,10 +150,10 @@ private fun AttachmentsPreviewBottomActions(
modifier = modifier,
) {
TextButton(onClick = onCancelClicked) {
Text(stringResource(id = StringsR.string.action_cancel))
Text(stringResource(id = CommonStrings.action_cancel))
}
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
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(
throwable: Throwable
): Int {
return if (throwable is MediaPreProcessor.Failure) {
R.string.screen_media_upload_preview_error_failed_processing
CommonStrings.screen_media_upload_preview_error_failed_processing
} 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 io.element.android.libraries.architecture.Async
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.matrix.api.MatrixClient
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.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
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.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
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.room.RoomSummaryDetails
import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@ -111,7 +112,7 @@ fun ForwardMessagesView(
modifier = modifier,
topBar = {
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 = {
BackButton(onClick = { onBackButton(state) })
},
@ -120,7 +121,7 @@ fun ForwardMessagesView(
enabled = state.selectedRooms.isNotEmpty(),
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)
) {
SearchBar<ImmutableList<RoomSummaryDetails>>(
placeHolderTitle = stringResource(StringR.string.action_search),
placeHolderTitle = stringResource(CommonStrings.action_search),
query = state.query,
onQueryChange = { state.eventSink(ForwardMessagesEvents.UpdateQuery(it)) },
active = state.isSearchActive,
@ -227,18 +228,22 @@ internal fun RoomSummaryView(
modifier = modifier
.clickable { onSelection(summary) }
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(IntrinsicSize.Min),
.padding(start = 16.dp, end = 4.dp)
.heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically
) {
val roomAlias = summary.canonicalAlias ?: summary.roomId.value
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(
modifier = Modifier
.padding(start = 12.dp, end = 4.dp, top = 8.dp, bottom = 8.dp)
.alignByBaseline()
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
.weight(1f)
) {
// 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.isMimeTypeVideo
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.Text
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
@ -242,7 +241,7 @@ fun MediaFileView(
fontSize = 16.sp,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
color = ElementTheme.colors.gray1400
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
@ -250,7 +249,7 @@ fun MediaFileView(
fontSize = 14.sp,
maxLines = 1,
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.asImageBitmap
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 io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import me.saket.telephoto.zoomable.zoomable
@ -51,7 +51,7 @@ fun PdfViewer(
modifier = modifier.zoomable(pdfViewerState.zoomableState),
contentAlignment = Alignment.Center
) {
val maxWidthInPx = maxWidth.dpToPx()
val maxWidthInPx = maxWidth.roundToPx()
DisposableEffect(pdfViewerState) {
pdfViewerState.openForWidth(maxWidthInPx)
onDispose {
@ -107,15 +107,9 @@ private fun PdfPageView(
Box(
modifier = modifier
.fillMaxWidth()
.height(state.height.pxToDp())
.height(state.height.toDp())
.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.libraries.architecture.NodeInputs
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.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.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR
import io.element.android.libraries.ui.strings.R as StringR
class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerNode.Inputs,
@ -117,9 +117,9 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) {
localMediaActions.saveOnDisk(localMedia.state)
localMediaActions.saveOnDisk(localMedia.data)
.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)
}
.onFailure {
@ -131,7 +131,7 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) {
localMediaActions.share(localMedia.state)
localMediaActions.share(localMedia.data)
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
@ -141,7 +141,7 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) {
localMediaActions.open(localMedia.state)
localMediaActions.open(localMedia.data)
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
@ -153,7 +153,7 @@ class MediaViewerPresenter @AssistedInject constructor(
return if (throwable is ActivityNotFoundException) {
UtilsR.string.error_no_compatible_app_found
} 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.rememberLocalMediaViewState
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.designsystem.components.button.BackButton
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.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.delay
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun MediaViewerView(
@ -131,7 +130,7 @@ fun MediaViewerView(
) {
if (state.downloadedMedia is Async.Failure) {
ErrorView(
errorMessage = stringResource(id = StringR.string.error_unknown),
errorMessage = stringResource(id = CommonStrings.error_unknown),
onRetry = ::onRetry,
onDismiss = ::onDismissError
)
@ -188,7 +187,7 @@ private fun MediaViewerTopBar(
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(
enabled = actionsEnabled,
@ -196,7 +195,7 @@ private fun MediaViewerTopBar(
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(
enabled = actionsEnabled,
@ -204,7 +203,7 @@ private fun MediaViewerTopBar(
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 io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.executeResult
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
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.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
class ReportMessagePresenter @AssistedInject constructor(
private val room: MatrixRoom,
@ -87,12 +86,12 @@ class ReportMessagePresenter @AssistedInject constructor(
blockUser: Boolean,
result: MutableState<Async<Unit>>,
) = launch {
suspend {
result.runUpdatingState {
val userIdToBlock = userId.takeIf { blockUser }
room.reportContent(eventId, reason, userIdToBlock)
.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.Scaffold
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)
@Composable
@ -74,7 +74,7 @@ fun ReportMessageView(
}
is Async.Failure -> {
ErrorDialog(
content = stringResource(StringR.string.error_unknown),
content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(ReportMessageEvents.ClearError) }
)
}
@ -86,7 +86,7 @@ fun ReportMessageView(
CenterAlignedTopAppBar(
title = {
Text(
stringResource(StringR.string.action_report_content),
stringResource(CommonStrings.action_report_content),
style = ElementTextStyles.Regular.callout,
fontWeight = FontWeight.Medium,
)
@ -112,14 +112,14 @@ fun ReportMessageView(
OutlinedTextField(
value = state.reason,
onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) },
placeholder = { Text(stringResource(StringR.string.report_content_hint)) },
placeholder = { Text(stringResource(CommonStrings.report_content_hint)) },
enabled = !isSending,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 90.dp)
)
Text(
text = stringResource(StringR.string.report_content_explanation),
text = stringResource(CommonStrings.report_content_explanation),
style = ElementTextStyles.Regular.caption1,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Start,
@ -133,11 +133,11 @@ fun ReportMessageView(
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(StringR.string.screen_report_content_block_user),
text = stringResource(CommonStrings.screen_report_content_block_user),
style = ElementTextStyles.Regular.callout,
)
Text(
text = stringResource(StringR.string.screen_report_content_block_user_hint),
text = stringResource(CommonStrings.screen_report_content_block_user_hint),
style = ElementTextStyles.Regular.bodyMD,
color = MaterialTheme.colorScheme.secondary,
)
@ -152,7 +152,7 @@ fun ReportMessageView(
Spacer(modifier = Modifier.height(24.dp))
ButtonWithProgress(
text = stringResource(StringR.string.action_send),
text = stringResource(CommonStrings.action_send),
enabled = state.reason.isNotBlank() && !isSending,
showProgress = isSending,
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.aTimelineItemStateEventContent
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.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlin.random.Random
fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState(
@ -83,15 +88,23 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
content = content,
groupPosition = TimelineItemGroupPosition.First
),
// A state event on top of it
aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
),
// A grouped event on top of it
aGroupedEvents(),
// A day separator
aTimelineItemDaySeparator(),
// 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(
eventId: EventId = EventId("\$" + Random.nextInt().toString()),
transactionId: String? = null,
@ -101,22 +114,19 @@ internal fun aTimelineItemEvent(
sendState: EventSendState = EventSendState.Sent(eventId),
inReplyTo: InReplyTo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
): TimelineItem.Event {
return TimelineItem.Event(
id = eventId.value,
eventId = eventId,
transactionId = transactionId,
senderId = UserId("@senderId:domain"),
senderAvatar = AvatarData("@senderId:domain", "sender"),
senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
content = content,
reactionsState = TimelineItemReactions(
persistentListOf(
AggregatedReaction("👍", "1")
)
),
reactionsState = timelineItemReactions,
sentTime = "12:34",
isMine = isMine,
senderDisplayName = "sender",
senderDisplayName = "Sender",
groupPosition = groupPosition,
sendState = sendState,
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(
model: String = "Rust(Model())",
originalJson: String? = null,
@ -131,3 +154,17 @@ internal fun aTimelineItemDebugInfo(
) = TimelineItemDebugInfo(
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,
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
) {
fun onReachedLoadMore() {
@ -89,7 +90,7 @@ fun TimelineView(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
reverseLayout = true,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
) {
itemsIndexed(
items = state.timelineItems,
@ -103,6 +104,7 @@ fun TimelineView(
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
inReplyToClick = ::inReplyToClicked,
onReactionClick = onReactionClicked,
onTimestampClicked = onTimestampClicked,
)
if (index == state.timelineItems.lastIndex) {
@ -127,6 +129,7 @@ fun TimelineItemRow(
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier
) {
@ -162,6 +165,7 @@ fun TimelineItemRow(
onLongClick = ::onLongClick,
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onTimestampClicked = onTimestampClicked,
modifier = modifier,
)
@ -196,6 +200,7 @@ fun TimelineItemRow(
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
)
}
}
@ -286,5 +291,6 @@ private fun ContentToPreview(content: TimelineItemEventContent) {
onTimestampClicked = {},
onUserDataClicked = {},
onMessageLongClicked = {},
onReactionClicked = { _, _ -> },
)
}

View file

@ -20,6 +20,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
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.designsystem.preview.ElementPreviewDark
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.Text
private val BUBBLE_RADIUS = 12.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)
@Composable
fun MessageEventBubble(
@ -94,30 +98,39 @@ fun MessageEventBubble(
}
val backgroundBubbleColor = if (state.isHighlighted) {
ElementTheme.colors.messageHighlightedBackground
ElementTheme.legacyColors.messageHighlightedBackground
} else {
if (state.isMine) {
ElementTheme.colors.messageFromMeBackground
ElementTheme.legacyColors.messageFromMeBackground
} else {
ElementTheme.colors.messageFromOtherBackground
ElementTheme.legacyColors.messageFromOtherBackground
}
}
val bubbleShape = bubbleShape()
Surface(
Box(
modifier = modifier
.widthIn(min = 80.dp)
.offsetForItem()
.clip(bubbleShape)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(),
interactionSource = interactionSource
),
color = backgroundBubbleColor,
shape = bubbleShape,
content = content
)
.fillMaxWidth(BUBBLE_WIDTH_RATIO)
.padding(horizontal = 16.dp)
.offsetForItem(),
// Need to set the contentAlignment again (it's already set in TimelineItemEventRow), for the case
// when content width is low.
contentAlignment = if (state.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Surface(
modifier = Modifier
.widthIn(min = 80.dp)
.clip(bubbleShape)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(),
interactionSource = interactionSource
),
color = backgroundBubbleColor,
shape = bubbleShape,
content = content
)
}
}
@Preview

View file

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

View file

@ -17,6 +17,9 @@
package io.element.android.features.messages.impl.timeline.components
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.Spacer
import androidx.compose.foundation.layout.padding
@ -27,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
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.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@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(
modifier = modifier,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = modifier.clickable(onClick = onClick::invoke),
// TODO Should use compound.bgSubtlePrimary
color = ElementTheme.legacyColors.gray300,
border = BorderStroke(2.dp, MaterialTheme.colorScheme.background),
shape = RoundedCornerShape(corner = CornerSize(12.dp)),
shape = RoundedCornerShape(corner = CornerSize(14.dp)),
) {
Row(
modifier = Modifier.padding(vertical = 5.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// TODO `reaction.isHighlighted` is not used.
Text(text = reaction.key, fontSize = 12.sp)
Spacer(modifier = Modifier.width(4.dp))
Text(text = reaction.count, color = MaterialTheme.colorScheme.secondary, fontSize = 12.sp)
Box(modifier = Modifier.padding(2.dp)) {
val reactionModifier = if (reaction.isHighlighted) {
Modifier
// TODO Check the color, should use compound.borderInteractivePrimary
.border(BorderStroke(1.dp, Color(0xFF808994)), RoundedCornerShape(corner = CornerSize(12.dp)))
} else {
Modifier
}
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
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.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.libraries.designsystem.theme.components.Text
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
fun TimelineEventTimestampView(
@ -55,7 +54,7 @@ fun TimelineEventTimestampView(
val formattedTime = event.sentTime
val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed
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(
modifier = Modifier
.clickable(
@ -70,7 +69,7 @@ fun TimelineEventTimestampView(
) {
if (isMessageEdited) {
Text(
stringResource(R.string.common_edited_suffix),
stringResource(CommonStrings.common_edited_suffix),
style = ElementTextStyles.Regular.caption2,
color = tint ?: MaterialTheme.colorScheme.secondary,
)

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@ -40,22 +39,32 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.style.TextAlign
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.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.toExtraPadding
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.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.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.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.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.matrix.api.core.EventId
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.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.theme.ElementTheme
import org.jsoup.Jsoup
@Composable
fun TimelineItemEventRow(
@ -76,6 +87,7 @@ fun TimelineItemEventRow(
onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
@ -84,71 +96,81 @@ fun TimelineItemEventRow(
onUserDataClick(event.senderId)
}
fun onReactionClicked(emoji: String) =
onReactionClick(emoji, event)
fun inReplyToClicked() {
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
inReplyToClick(inReplyToEventId)
}
val (parentAlignment, contentAlignment) = if (event.isMine) {
Pair(Alignment.CenterEnd, Alignment.End)
} else {
Pair(Alignment.CenterStart, Alignment.Start)
}
// To avoid using negative offset, we display in this Box a column with:
// - Spacer to give room to the Sender information if they must be displayed;
// - The message bubble;
// - 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(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = parentAlignment
contentAlignment = if (event.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Row {
Column(horizontalAlignment = contentAlignment) {
if (event.showSenderInformation) {
MessageSenderInformation(
event.safeSenderName,
event.senderAvatar,
Modifier
.zIndex(1f)
.offset(y = 12.dp)
.clickable(onClick = ::onUserDataClicked)
)
}
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
isMine = event.isMine,
isHighlighted = isHighlighted,
)
MessageEventBubble(
state = bubbleState,
Column {
if (event.showSenderInformation) {
Spacer(modifier = Modifier.height(event.senderAvatar.size.dp - 8.dp))
}
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
isMine = event.isMine,
isHighlighted = isHighlighted,
)
MessageEventBubble(
state = bubbleState,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
) {
MessageEventBubbleContent(
event = event,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
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))
onMessageClick = onClick,
onMessageLongClick = onLongClick,
inReplyToClick = ::inReplyToClicked,
onTimestampClicked = {
onTimestampClicked(event)
}
)
}
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()) {
Spacer(modifier = modifier.height(8.dp))
Spacer(modifier = modifier.height(16.dp))
} else {
Spacer(modifier = modifier.height(2.dp))
}
@ -157,20 +179,38 @@ fun TimelineItemEventRow(
@Composable
private fun MessageSenderInformation(
sender: String,
senderAvatar: AvatarData?,
senderAvatar: AvatarData,
modifier: Modifier = Modifier
) {
Row(modifier = modifier) {
if (senderAvatar != null) {
val avatarStrokeSize = 3.dp
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)
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,
onClick = onMessageClick,
onLongClick = onMessageLongClick,
extraPadding = event.toExtraPadding(),
modifier = modifier,
)
}
@ -215,20 +256,20 @@ private fun MessageEventBubbleContent(
onClick = onTimestampClicked,
modifier = timestampModifier
.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)
.padding(horizontal = 4.dp, vertical = 2.dp) // Inner padding
)
}
} else {
Column(modifier) {
ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp))
Box(modifier) {
ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp))
TimelineEventTimestampView(
event = event,
onClick = onTimestampClicked,
modifier = timestampModifier
.align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 2.dp)
.align(Alignment.BottomEnd)
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
@ -328,7 +369,7 @@ private fun ReplyToContent(
text = text.orEmpty(),
style = ElementTextStyles.Regular.caption1,
textAlign = TextAlign.Start,
color = LocalColors.current.placeholder,
color = ElementTheme.legacyColors.placeholder,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -358,3 +399,94 @@ private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) =
)
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(
reactionsState: TimelineItemReactions,
modifier: Modifier = Modifier,
onReactionClicked: (emoji: String) -> Unit
) {
if (reactionsState.reactions.isEmpty()) return
FlowRow(
modifier = modifier,
mainAxisSpacing = 2.dp,
crossAxisSpacing = 8.dp,
) {
reactionsState.reactions.forEach { reaction ->
MessagesReactionButton(reaction = reaction)
MessagesReactionButton(
reaction = reaction,
onClick = { onReactionClicked(reaction.key) }
)
}
}
}
@ -56,6 +59,7 @@ internal fun TimelineItemReactionsViewDarkPreview() =
@Composable
private fun ContentToPreview() {
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.noExtraPadding
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.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@Composable
fun TimelineItemStateEventRow(
@ -60,8 +67,33 @@ fun TimelineItemStateEventRow(
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
extraPadding = noExtraPadding,
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
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
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.TimelineItemEventContent
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(
content: TimelineItemEventContent,
interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
@ -42,14 +41,17 @@ fun TimelineItemEventContentView(
when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content,
extraPadding = extraPadding,
modifier = modifier
)
is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = content,
extraPadding = extraPadding,
modifier = modifier
)
is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content,
extraPadding = extraPadding,
interactionSource = interactionSource,
modifier = modifier,
onTextClicked = onClick,
@ -57,6 +59,7 @@ fun TimelineItemEventContentView(
)
is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content,
extraPadding = extraPadding,
modifier = modifier
)
is TimelineItemImageContent -> TimelineItemImageView(
@ -69,6 +72,7 @@ fun TimelineItemEventContentView(
)
is TimelineItemFileContent -> TimelineItemFileView(
content = content,
extraPadding = extraPadding,
modifier = modifier
)
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.ElementPreviewLight
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.ui.strings.R as StringR
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineItemEncryptedView(
content: TimelineItemEncryptedContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier
) {
TimelineItemInformativeView(
text = stringResource(id = StringR.string.common_decryption_error),
iconDescription = stringResource(id = StringR.string.dialog_title_warning),
text = stringResource(id = CommonStrings.common_decryption_error),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
icon = Icons.Default.Warning,
extraPadding = extraPadding,
modifier = modifier
)
}
@ -57,6 +58,7 @@ private fun ContentToPreview() {
TimelineItemEncryptedView(
content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.Unknown
)
),
extraPadding = noExtraPadding
)
}

View file

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

View file

@ -41,6 +41,7 @@ fun TimelineItemInformativeView(
text: String,
iconDescription: String,
icon: ImageVector,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier
) {
Row(
@ -58,7 +59,7 @@ fun TimelineItemInformativeView(
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.secondary,
fontSize = 14.sp,
text = text
text = text + extraPadding.str
)
}
}
@ -76,6 +77,7 @@ private fun ContentToPreview() {
TimelineItemInformativeView(
text = "Info",
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.libraries.designsystem.preview.ElementPreviewDark
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
fun TimelineItemRedactedView(
content: TimelineItemRedactedContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier
) {
TimelineItemInformativeView(
text = stringResource(id = StringR.string.common_message_removed),
iconDescription = stringResource(id = StringR.string.common_message_removed),
text = stringResource(id = CommonStrings.common_message_removed),
iconDescription = stringResource(id = CommonStrings.common_message_removed),
icon = Icons.Default.Delete,
extraPadding = extraPadding,
modifier = modifier
)
}
@ -53,5 +54,8 @@ internal fun TimelineItemRedactedViewDarkPreview() =
@Composable
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 androidx.compose.foundation.interaction.MutableInteractionSource
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.remember
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
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.model.event.TimelineItemTextBasedContent
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.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.LinkColor
import io.element.android.libraries.designsystem.text.toAnnotatedString
@Composable
fun TimelineItemTextView(
content: TimelineItemTextBasedContent,
interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit = {},
onTextLongClicked: () -> Unit = {},
) {
val htmlDocument = content.htmlDocument
if (htmlDocument != null) {
HtmlDocument(
document = htmlDocument,
modifier = modifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
// For now we ignore the extra padding for html content, so add some spacing
// below the content (as previous behavior)
Column(modifier = modifier) {
HtmlDocument(
document = htmlDocument,
modifier = Modifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
Spacer(Modifier.height(16.dp))
}
} else {
Box(modifier) {
val linkStyle = SpanStyle(
color = LinkColor,
)
val styledText = remember(content.body) { content.body.linkify(linkStyle) }
val styledText = remember(content.body) { content.body.linkify(linkStyle) + extraPadding.str.toAnnotatedString() }
ClickableLinkText(
text = styledText,
linkAnnotationTag = "URL",
@ -109,6 +120,10 @@ internal fun TimelineItemTextViewDarkPreview(@PreviewParameter(TimelineItemTextB
@Composable
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.libraries.designsystem.preview.ElementPreviewDark
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
fun TimelineItemUnknownView(
content: TimelineItemUnknownContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier
) {
TimelineItemInformativeView(
text = stringResource(id = StringR.string.common_unsupported_event),
iconDescription = stringResource(id = StringR.string.dialog_title_warning),
text = stringResource(id = CommonStrings.common_unsupported_event),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
icon = Icons.Default.Info,
extraPadding = extraPadding,
modifier = modifier
)
}
@ -53,5 +54,8 @@ internal fun TimelineItemUnknownViewDarkPreview() =
@Composable
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 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.theme.ElementTheme
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.Text
@ -54,7 +54,7 @@ fun GroupHeaderView(
modifier: Modifier = Modifier
) {
val backgroundColor = if (isHighlighted) {
ElementTheme.colors.messageHighlightedBackground
ElementTheme.legacyColors.messageHighlightedBackground
} else {
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.sp
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.preview.ElementPreviewDark
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.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.theme.LinkColor
import kotlinx.collections.immutable.persistentMapOf
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
@ -535,7 +535,7 @@ private fun AnnotatedString.Builder.appendLink(link: Element) {
val permalinkData = PermalinkParser.parse(uriString)
when (permalinkData) {
is PermalinkData.FallbackLink -> {
pushStringAnnotation(tag = "URL", annotation = link.ownText())
pushStringAnnotation(tag = "URL", annotation = permalinkData.uri.toString())
withStyle(
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.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState
@ -37,7 +38,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
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.features.messages.impl.R
import kotlinx.coroutines.launch
@ -129,7 +129,7 @@ private fun ColumnScope.RetrySendMenuContents(
headlineContent = {
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 {
coroutineScope.launch {
sheetState.hide()

View file

@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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.PreviewParameter
import androidx.compose.ui.unit.dp
@ -39,30 +40,31 @@ internal fun TimelineItemDaySeparatorView(
modifier: Modifier = Modifier
) {
Box(
modifier
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(8.dp),
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = model.formattedDate,
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.primary,
)
}
}
@Preview
@Composable
internal fun TimelineItemDaySeparatorViewLightPreview(@PreviewParameter(
TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel
internal fun TimelineItemDaySeparatorViewLightPreview(
@PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel
) =
ElementPreviewLight { ContentToPreview(model) }
@Preview
@Composable
internal fun TimelineItemDaySeparatorViewDarkPreview(@PreviewParameter(
TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel
internal fun TimelineItemDaySeparatorViewDarkPreview(
@PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel
) =
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.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.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
@ -34,6 +35,7 @@ import javax.inject.Inject
class TimelineItemEventFactory @Inject constructor(
private val contentFactory: TimelineItemContentFactory,
private val matrixClient: MatrixClient,
) {
fun create(
@ -67,7 +69,7 @@ class TimelineItemEventFactory @Inject constructor(
id = currentSender.value,
name = senderDisplayName ?: currentSender.value,
url = senderAvatarUrl,
size = AvatarSize.SMALL
size = AvatarSize.TimelineSender
)
return TimelineItem.Event(
id = currentTimelineItem.uniqueId,
@ -89,8 +91,13 @@ class TimelineItemEventFactory @Inject constructor(
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
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())
}

View file

@ -16,8 +16,13 @@
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(
val key: String,
val count: String,
val count: Int,
val isHighlighted: Boolean = false
)

View file

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

View file

@ -39,11 +39,11 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
override val values = sequenceOf(
aTimelineItemEmoteContent(),
aTimelineItemEmoteContent().copy(htmlDocument = Jsoup.parse("Emote")),
aTimelineItemEmoteContent().copy(htmlDocument = Jsoup.parse("Emote Document")),
aTimelineItemNoticeContent(),
aTimelineItemNoticeContent().copy(htmlDocument = Jsoup.parse("Notice")),
aTimelineItemNoticeContent().copy(htmlDocument = Jsoup.parse("Notice Document")),
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,
thumbnailSource = 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.libraries.di.ApplicationContext
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
@ContributesBinding(RoomScope::class)
@ -42,12 +42,12 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemTextBasedContent -> event.content.body
is TimelineItemStateContent -> event.content.body
is TimelineItemProfileChangeContent -> event.content.body
is TimelineItemEncryptedContent -> context.getString(R.string.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(R.string.common_message_removed)
is TimelineItemUnknownContent -> context.getString(R.string.common_unsupported_event)
is TimelineItemImageContent -> context.getString(R.string.common_image)
is TimelineItemVideoContent -> context.getString(R.string.common_video)
is TimelineItemFileContent -> context.getString(R.string.common_file)
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
}
}
}

View file

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

View file

@ -16,11 +16,12 @@
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.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData
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.timeline.item.TimelineItemDebugInfo
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_NAME
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
import kotlinx.collections.immutable.persistentListOf
internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID,
@ -43,11 +43,11 @@ internal fun aMessageEvent(
eventId = eventId,
senderId = A_USER_ID,
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,
sentTime = "",
isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf()),
reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID),
inReplyTo = inReplyTo,
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.eventformatter.api.TimelineEventFormatter
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 kotlinx.coroutines.test.TestScope
@ -44,7 +45,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
return TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory(
TimelineItemContentFactory(
contentFactory = TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
@ -54,7 +55,8 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory()
)
),
matrixClient = FakeMatrixClient(),
),
virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory(

View file

@ -18,10 +18,9 @@ package io.element.android.features.messages.timeline.groups
import com.google.common.truth.Truth.assertThat
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.model.AggregatedReaction
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.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
@ -42,7 +41,7 @@ class TimelineItemGrouperTest {
senderAvatar = anAvatarData(),
senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = TimelineItemReactions(emptyList<AggregatedReaction>().toImmutableList()),
reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID),
inReplyTo = null,
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.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.ui.strings.R as StringR
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ConnectivityIndicatorView(
@ -83,7 +82,7 @@ private fun Indicator(modifier: Modifier = Modifier) {
Row(
modifier
.fillMaxWidth()
.background(LocalColors.current.gray400)
.background(MaterialTheme.colorScheme.secondaryContainer)
.statusBarsPadding()
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.Center,
@ -98,7 +97,7 @@ private fun Indicator(modifier: Modifier = Modifier) {
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(StringR.string.common_offline),
text = stringResource(CommonStrings.common_offline),
style = ElementTextStyles.Regular.bodyMD.copy(fontWeight = FontWeight.Medium),
color = tint,
)

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