diff --git a/app/build.gradle b/app/build.gradle index f5f4211354..6cbb9d4bd5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -69,6 +69,7 @@ dependencies { implementation project(":features:login") implementation project(":features:roomlist") + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.2.0" implementation 'io.github.raamcosta.compose-destinations:core:1.7.23-beta' ksp 'io.github.raamcosta.compose-destinations:ksp:1.7.23-beta' diff --git a/features/roomlist/build.gradle.kts b/features/roomlist/build.gradle.kts index 15e539f139..6bb83ff0d5 100644 --- a/features/roomlist/build.gradle.kts +++ b/features/roomlist/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(project(":libraries:designsystem")) implementation(libs.mavericks.compose) implementation(libs.timber) + implementation(libs.datetime) testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/LastMessageFormatter.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/LastMessageFormatter.kt new file mode 100644 index 0000000000..3db02b4ffa --- /dev/null +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/LastMessageFormatter.kt @@ -0,0 +1,82 @@ +package io.element.android.x.features.roomlist + +import android.text.format.DateFormat +import android.text.format.DateUtils +import kotlinx.datetime.* +import kotlinx.datetime.TimeZone +import java.time.Period +import java.time.format.DateTimeFormatter +import java.util.* +import kotlin.math.absoluteValue + +class LastMessageFormatter( + private val clock: Clock = Clock.System, + private val locale: Locale = Locale.getDefault() +) { + + private val onlyTimeFormatter: DateTimeFormatter by lazy { + val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") + DateTimeFormatter.ofPattern(pattern) + } + + private val dateWithMonthFormatter: DateTimeFormatter by lazy { + val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") + DateTimeFormatter.ofPattern(pattern) + } + + private val dateWithYearFormatter: DateTimeFormatter by lazy { + val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") + DateTimeFormatter.ofPattern(pattern) + } + + + fun format(timestamp: Long?): String { + if (timestamp == null) return "" + val now: Instant = clock.now() + val tsInstant = Instant.fromEpochSeconds(timestamp) + val nowDateTime = now.toLocalDateTime(TimeZone.currentSystemDefault()) + val tsDateTime = tsInstant.toLocalDateTime(TimeZone.currentSystemDefault()) + val isSameDay = nowDateTime.date == tsDateTime.date + return when { + isSameDay -> { + onlyTimeFormatter.format(tsDateTime.toJavaLocalDateTime()) + } + else -> { + formatDate(tsDateTime, nowDateTime) + } + } + } + + private fun formatDate( + date: LocalDateTime, + currentDate: LocalDateTime, + ): String { + val period = Period.between(date.date.toJavaLocalDate(), currentDate.date.toJavaLocalDate()) + return if (period.years.absoluteValue >= 1) { + formatDateWithYear(date) + } else if (period.days.absoluteValue < 2 && period.months.absoluteValue < 1) { + getRelativeDay(date.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds()) + } else { + formatDateWithMonth(date) + } + } + + private fun formatDateWithMonth(localDateTime: LocalDateTime): String { + return dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime()) + } + + private fun formatDateWithYear(localDateTime: LocalDateTime): String { + return dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime()) + } + + private fun getRelativeDay(ts: Long): String { + return DateUtils.getRelativeTimeSpanString( + ts, + clock.now().toEpochMilliseconds(), + DateUtils.DAY_IN_MILLIS, + DateUtils.FORMAT_SHOW_WEEKDAY + ).toString() + } + + +} \ No newline at end of file diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListActions.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListActions.kt index 8dbb84733d..1a41ef6089 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListActions.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListActions.kt @@ -1,6 +1,5 @@ package io.element.android.x.features.roomlist sealed interface RoomListActions { - object LoadMore : RoomListActions object Logout : RoomListActions } diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt index 9fad891834..c899b67e0b 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt @@ -1,11 +1,13 @@ package io.element.android.x.features.roomlist -import android.widget.Space +import Avatar +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExitToApp import androidx.compose.material.ripple.rememberRipple @@ -15,9 +17,11 @@ import androidx.compose.runtime.getValue 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.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.airbnb.mvrx.Success @@ -25,10 +29,10 @@ import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel import io.element.android.x.core.data.LogCompositions import io.element.android.x.designsystem.LightGrey -import io.element.android.x.designsystem.components.Avatar import io.element.android.x.features.roomlist.model.MatrixUser +import io.element.android.x.features.roomlist.model.RoomListRoomSummary +import io.element.android.x.features.roomlist.model.RoomListViewState import io.element.android.x.matrix.core.RoomId -import io.element.android.x.matrix.room.RoomSummary @Composable fun RoomListScreen( @@ -54,7 +58,7 @@ fun RoomListScreen( @Composable fun RoomListContent( - roomSummaries: List, + roomSummaries: List, matrixUser: MatrixUser, onRoomClicked: (RoomId) -> Unit, onLogoutClicked: () -> Unit, @@ -69,12 +73,11 @@ fun RoomListContent( onLogoutClicked = onLogoutClicked ) LazyColumn { - items(roomSummaries, key = { it.identifier() }) { room -> + items(roomSummaries, key = { it.id }) { room -> RoomItem(room = room) { onRoomClicked(it) } } - } } } @@ -90,7 +93,7 @@ fun RoomListTopBar(matrixUser: MatrixUser, onLogoutClicked: () -> Unit) { modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - Avatar(data = matrixUser.avatarData) + Avatar(data = matrixUser.avatarData, size = 32.dp) Spacer(modifier = Modifier.width(8.dp)) Text("${matrixUser.username}") } @@ -108,18 +111,17 @@ fun RoomListTopBar(matrixUser: MatrixUser, onLogoutClicked: () -> Unit) { @Composable private fun RoomItem( modifier: Modifier = Modifier, - room: RoomSummary, + room: RoomListRoomSummary, onClick: (RoomId) -> Unit ) { - if (room !is RoomSummary.Filled) { + if (room.isPlaceholder) { return } - val details = room.details Column( modifier = modifier .fillMaxWidth() .clickable( - onClick = { onClick(room.details.roomId) }, + onClick = { onClick(room.roomId) }, indication = rememberRipple(), interactionSource = remember { MutableInteractionSource() } ), @@ -128,12 +130,9 @@ private fun RoomItem( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Box(modifier = Modifier - .align(Alignment.CenterVertically) - ) { - Avatar(data = null) - } + Avatar(data = room.avatarData) Column( modifier = Modifier .padding(12.dp) @@ -143,31 +142,56 @@ private fun RoomItem( fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.Black, - text = details.name, + text = room.name, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( - text = details.lastMessage?.toString().orEmpty(), + text = room.lastMessage?.toString().orEmpty(), color = LightGrey, maxLines = 1, overflow = TextOverflow.Ellipsis ) } Column( - Modifier - .padding(horizontal = 8.dp) - .align(Alignment.CenterVertically) + Modifier.padding(horizontal = 8.dp) ) { Text( fontSize = 12.sp, - text = "14:18", - color = LightGrey + text = room.timestamp ?: "", + color = LightGrey, + ) + Spacer(modifier.size(4.dp)) + val unreadIndicatorColor = if(room.hasUnread) Color.Black else Color.Transparent + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(unreadIndicatorColor) + .align(Alignment.End), ) - Spacer(Modifier.size(20.dp)) } } } +} - +@Preview +@Composable +private fun PreviewableRoomListContent() { + val roomSummaries = listOf( + RoomListRoomSummary( + name = "Room", + hasUnread = true, + timestamp = "14:18", + lastMessage = "A message", + avatarData = null, + id = "roomId" + ) + ) + RoomListContent( + roomSummaries = roomSummaries, + matrixUser = MatrixUser("User#1"), + onRoomClicked = {}, + onLogoutClicked = {} + ) } \ No newline at end of file diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewModel.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewModel.kt index 9e3a446859..72ace28464 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewModel.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewModel.kt @@ -4,9 +4,16 @@ import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Loading import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.Success +import io.element.android.x.core.data.parallelMap import io.element.android.x.features.roomlist.model.MatrixUser +import io.element.android.x.features.roomlist.model.RoomListRoomSummary +import io.element.android.x.features.roomlist.model.RoomListViewState import io.element.android.x.matrix.MatrixClient import io.element.android.x.matrix.MatrixInstance +import io.element.android.x.matrix.room.RoomSummary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.matrix.rustcomponents.sdk.mediaSourceFromUrl @@ -14,6 +21,7 @@ class RoomListViewModel(initialState: RoomListViewState) : MavericksViewModel(initialState) { private val matrix = MatrixInstance.getInstance() + private val lastMessageFormatter = LastMessageFormatter() init { handleInit() @@ -21,12 +29,11 @@ class RoomListViewModel(initialState: RoomListViewState) : fun handle(action: RoomListActions) { when (action) { - RoomListActions.LoadMore -> TODO() RoomListActions.Logout -> handleLogout() } } - fun logout(){ + fun logout() { handleLogout() } @@ -36,26 +43,63 @@ class RoomListViewModel(initialState: RoomListViewState) : client.startSync() val userAvatarUrl = client.loadUserAvatarURLString().getOrNull() val userDisplayName = client.loadUserDisplayName().getOrNull() - val avatarData = userAvatarUrl?.let { - mediaSourceFromUrl(it) - }?.let { - client.loadMediaContentForSource(it) - } + val avatarData = loadAvatarData(client, userAvatarUrl) setState { copy( user = MatrixUser( username = userDisplayName, avatarUrl = userAvatarUrl, - avatarData = avatarData?.getOrNull() + avatarData = avatarData, ) ) } - client.roomSummaryDataSource().roomSummaries().execute { - copy(rooms = it) + client.roomSummaryDataSource().roomSummaries() + .map { roomSummaries -> + mapRoomSummaries(client, roomSummaries) + } + .flowOn(Dispatchers.Default) + .execute { + copy(rooms = it) + } + } + } + + private suspend fun mapRoomSummaries( + client: MatrixClient, + roomSummaries: List + ): List { + return roomSummaries.parallelMap { roomSummary -> + when (roomSummary) { + is RoomSummary.Empty -> RoomListRoomSummary( + id = roomSummary.identifier, + isPlaceholder = true + ) + is RoomSummary.Filled -> { + val avatarData = loadAvatarData(client, roomSummary.details.avatarURLString) + RoomListRoomSummary( + id = roomSummary.identifier(), + name = roomSummary.details.name, + hasUnread = roomSummary.details.unreadNotificationCount > 0, + timestamp = lastMessageFormatter.format(roomSummary.details.lastMessageTimestamp), + lastMessage = roomSummary.details.lastMessage, + avatarData = avatarData, + ) + } } } } + private suspend fun loadAvatarData(client: MatrixClient, url: String?, size: Long = 48): ByteArray? { + val mediaContent = url?.let { + val mediaSource = mediaSourceFromUrl(it) + client.loadMediaThumbnailForSource(mediaSource, size, size) + } + return mediaContent?.fold( + { it }, + { null } + ) + } + private fun handleLogout() { viewModelScope.launch { setState { copy(logoutAction = Loading()) } diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/MatrixUser.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/MatrixUser.kt index c61477b6aa..dd0f8ae083 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/MatrixUser.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/MatrixUser.kt @@ -6,5 +6,5 @@ import androidx.compose.runtime.Stable data class MatrixUser( val username: String? = null, val avatarUrl: String? = null, - val avatarData: List? = null, + val avatarData: ByteArray? = null, ) diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListRoomSummary.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListRoomSummary.kt new file mode 100644 index 0000000000..ed333e2db4 --- /dev/null +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListRoomSummary.kt @@ -0,0 +1,14 @@ +package io.element.android.x.features.roomlist.model + +import io.element.android.x.matrix.core.RoomId + +data class RoomListRoomSummary( + val id: String, + val roomId: RoomId = RoomId(id), + val name: String = "", + val hasUnread: Boolean = false, + val timestamp: String? = null, + val lastMessage: CharSequence? = null, + val avatarData: ByteArray? = null, + val isPlaceholder: Boolean = false, +) \ No newline at end of file diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewState.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListViewState.kt similarity index 77% rename from features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewState.kt rename to features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListViewState.kt index 23b65d08f6..d7439a16b2 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewState.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListViewState.kt @@ -1,4 +1,4 @@ -package io.element.android.x.features.roomlist +package io.element.android.x.features.roomlist.model import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState @@ -8,7 +8,7 @@ import io.element.android.x.matrix.room.RoomSummary data class RoomListViewState( val user: MatrixUser = MatrixUser(), - val rooms: Async> = Uninitialized, + val rooms: Async> = Uninitialized, val canLoadMore: Boolean = false, val logoutAction: Async = Uninitialized, ) : MavericksState diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 88fa3a94f2..ea2510df11 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ test_orchestrator = "1.4.1" mavericks = "3.0.1" timber = "5.0.1" coil = "2.2.1" +datetime = "0.4.0" [libraries] # Project @@ -70,6 +71,8 @@ test_orchestrator = { module = "androidx.test:orchestrator", version.ref = "test mavericks_compose = { module = "com.airbnb.android:mavericks-compose", version.ref = "mavericks" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } -coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } + [bundles] diff --git a/libraries/core/src/main/java/io/element/android/x/core/data/pmap.kt b/libraries/core/src/main/java/io/element/android/x/core/data/pmap.kt new file mode 100644 index 0000000000..8ec3a7fffe --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/data/pmap.kt @@ -0,0 +1,9 @@ +package io.element.android.x.core.data + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +suspend fun Iterable.parallelMap(f: suspend (A) -> B): List = coroutineScope { + map { async { f(it) } }.awaitAll() +} \ No newline at end of file diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/Avatar.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/Avatar.kt index c7cdebdf83..2fac353f48 100644 --- a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/Avatar.kt +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/Avatar.kt @@ -1,37 +1,31 @@ -package io.element.android.x.designsystem.components - import android.util.Log -import androidx.compose.foundation.Image -import androidx.compose.foundation.border import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil.compose.rememberAsyncImagePainter +import coil.compose.AsyncImage /** * TODO fallback Avatar */ @Composable fun Avatar( - data: List?, + data: ByteArray?, size: Dp = 48.dp, ) { - Image( - painter = rememberAsyncImagePainter( - model = data?.toUByteArray()?.toByteArray(), - onError = { - Log.e("TAG", "Error $it\n${it.result}", it.result.throwable) - }), + AsyncImage( + model = data, + onError = { + Log.e("TAG", "Error $it\n${it.result}", it.result.throwable) + }, contentDescription = null, modifier = Modifier .size(size) .clip(CircleShape) - .border(1.5.dp, MaterialTheme.colorScheme.primary, CircleShape) ) } diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt index a9969f61fb..67d56f059c 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt @@ -96,10 +96,21 @@ class MatrixClient internal constructor( } } - suspend fun loadMediaContentForSource(source: MediaSource): Result> = + suspend fun loadMediaContentForSource(source: MediaSource): Result = withContext(dispatchers.io) { runCatching { - client.getMediaContent(source) + client.getMediaContent(source).toUByteArray().toByteArray() + } + } + + suspend fun loadMediaThumbnailForSource( + source: MediaSource, + width: Long, + height: Long + ): Result = + withContext(dispatchers.io) { + runCatching { + client.getMediaThumbnail(source, width.toULong(), height.toULong()).toUByteArray().toByteArray() } } diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummary.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummary.kt index 1a831d4ef6..ff4a01eaf0 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummary.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummary.kt @@ -23,5 +23,5 @@ data class RoomSummaryDetails( val avatarURLString: String?, val lastMessage: CharSequence?, val lastMessageTimestamp: Long?, - val unreadNotificationCount: UInt, + val unreadNotificationCount: Int, ) diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummaryDataSource.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummaryDataSource.kt index 6c5f93183f..a4cf89a4a7 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummaryDataSource.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RoomSummaryDataSource.kt @@ -126,7 +126,7 @@ internal class RustRoomSummaryDataSource( name = room.name() ?: identifier, isDirect = room.isDm() ?: false, avatarURLString = room.fullRoom()?.avatarUrl(), - unreadNotificationCount = room.unreadNotifications().notificationCount(), + unreadNotificationCount = room.unreadNotifications().notificationCount().toInt(), lastMessage = latestRoomMessage?.body, lastMessageTimestamp = latestRoomMessage?.originServerTs ) diff --git a/plugins/src/main/java/extension/CommonExtension.kt b/plugins/src/main/java/extension/CommonExtension.kt index da97ef62c5..05cc4880f3 100644 --- a/plugins/src/main/java/extension/CommonExtension.kt +++ b/plugins/src/main/java/extension/CommonExtension.kt @@ -7,15 +7,16 @@ import composeVersion import org.gradle.api.artifacts.VersionCatalog fun CommonExtension<*, *, *, *>.androidConfig() { - - - defaultConfig { compileSdk = Versions.compileSdk minSdk = Versions.minSdk testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + compileOptions { + isCoreLibraryDesugaringEnabled = true + } + testOptions { unitTests.isReturnDefaultValues = true }