Merge pull request #112 from vector-im/feature/fga/update_rust_sdk

Feature/fga/update rust sdk
This commit is contained in:
ganfra
2023-03-01 12:37:58 +01:00
committed by GitHub
124 changed files with 2480 additions and 937 deletions

View File

@@ -26,7 +26,7 @@ maestro test \
-e USERNAME=user \
-e PASSWORD=123 \
-e ROOM_NAME="my room" \
.maestro/allTest.yaml
.maestro/allTests.yaml
```
### Output

View File

@@ -45,6 +45,7 @@ Come chat with the community in the dedicated Matrix [room](https://matrix.to/#/
## Build instructions
Just clone the project and open it in Android Studio.
Makes sure to select the `app` configuration when building (as we also have sample apps in the project).
## Support

View File

@@ -38,9 +38,9 @@ android {
defaultConfig {
applicationId = "io.element.android.x"
targetSdk = 33 // TODO Use Versions.targetSdk
versionCode = 1
versionName = "1.0"
targetSdk = Versions.targetSdk
versionCode = Versions.versionCode
versionName = Versions.versionName
vectorDrawables {
useSupportLibrary = true
@@ -109,24 +109,9 @@ android {
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composecompiler.get()
}
packagingOptions {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
// Waiting for https://github.com/google/ksp/issues/37
applicationVariants.all {

View File

@@ -34,8 +34,7 @@
android:theme="@style/Theme.ElementX.Splash"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true"
android:windowSoftInputMode="adjustResize"
tools:ignore="LockedOrientationActivity">
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -23,6 +23,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -66,8 +67,11 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
loggedInState.value = LoggedInState.LoggingIn
try {
//TODO rework the setHomeserver flow
tryOrNull {
authenticationService.setHomeserver(homeserver)
}
try {
val sessionId = authenticationService.login(formState.login.trim(), formState.password.trim())
loggedInState.value = LoggedInState.LoggedIn(sessionId)
} catch (failure: Throwable) {

View File

@@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.textcomposer)
implementation(projects.libraries.dateformatter)
implementation(libs.coil.compose)
implementation(libs.datetime)
implementation(libs.accompanist.flowlayout)

View File

@@ -20,5 +20,5 @@ import io.element.android.features.messages.actionlist.model.TimelineItemAction
import io.element.android.features.messages.timeline.model.TimelineItem
sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val messageEvent: TimelineItem.MessageEvent) : MessagesEvents
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
}

View File

@@ -32,7 +32,7 @@ import io.element.android.features.messages.textcomposer.MessageComposerState
import io.element.android.features.messages.timeline.TimelineEvents
import io.element.android.features.messages.timeline.TimelinePresenter
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.content.TimelineItemTextBasedContent
import io.element.android.features.messages.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -79,7 +79,7 @@ class MessagesPresenter @Inject constructor(
}
fun handleEvents(event: MessagesEvents) {
when (event) {
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.messageEvent, composerState)
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.event, composerState)
}
}
return MessagesState(
@@ -95,7 +95,7 @@ class MessagesPresenter @Inject constructor(
fun CoroutineScope.handleTimelineAction(
action: TimelineItemAction,
targetEvent: TimelineItem.MessageEvent,
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
) = launch {
when (action) {
@@ -111,13 +111,15 @@ class MessagesPresenter @Inject constructor(
Timber.v("NotImplementedYet")
}
private suspend fun handleActionRedact(event: TimelineItem.MessageEvent) {
room.redactEvent(event.id)
private suspend fun handleActionRedact(event: TimelineItem.Event) {
if (event.eventId == null) return
room.redactEvent(event.eventId)
}
private fun handleActionEdit(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) {
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
if (targetEvent.eventId == null) return
val composerMode = MessageComposerMode.Edit(
targetEvent.id,
targetEvent.eventId,
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty()
)
composerState.eventSink(
@@ -125,8 +127,9 @@ class MessagesPresenter @Inject constructor(
)
}
private fun handleActionReply(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) {
val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, "")
private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
if (targetEvent.eventId == null) return
val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.eventId, "")
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)

View File

@@ -45,7 +45,6 @@ fun aMessagesState() = MessagesState(
),
timelineState = aTimelineState().copy(
timelineItems = aTimelineItemList(aTimelineItemContent()),
hasMoreToLoad = false,
),
actionListState = anActionListState(),
eventSink = {}

View File

@@ -88,21 +88,21 @@ fun MessagesView(
LogCompositions(tag = "MessagesScreen", msg = "Content")
fun onMessageClicked(messageEvent: TimelineItem.MessageEvent) {
Timber.v("OnMessageClicked= ${messageEvent.id}")
fun onMessageClicked(event: TimelineItem.Event) {
Timber.v("OnMessageClicked= ${event.id}")
}
fun onMessageLongClicked(messageEvent: TimelineItem.MessageEvent) {
Timber.v("OnMessageLongClicked= ${messageEvent.id}")
fun onMessageLongClicked(event: TimelineItem.Event) {
Timber.v("OnMessageLongClicked= ${event.id}")
focusManager.clearFocus(force = true)
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(messageEvent))
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
coroutineScope.launch {
itemActionsBottomSheetState.show()
}
}
fun onActionSelected(action: TimelineItemAction, messageEvent: TimelineItem.MessageEvent) {
state.eventSink(MessagesEvents.HandleAction(action, messageEvent))
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
state.eventSink(MessagesEvents.HandleAction(action, event))
}
Scaffold(
@@ -142,8 +142,8 @@ fun MessagesView(
fun MessagesViewContent(
state: MessagesState,
modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {},
onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {},
onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
) {
Column(
modifier = modifier

View File

@@ -20,5 +20,5 @@ import io.element.android.features.messages.timeline.model.TimelineItem
sealed interface ActionListEvents {
object Clear : ActionListEvents
data class ComputeForMessage(val messageEvent: TimelineItem.MessageEvent) : ActionListEvents
data class ComputeForMessage(val event: TimelineItem.Event) : ActionListEvents
}

View File

@@ -23,7 +23,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.messages.actionlist.model.TimelineItemAction
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent
import io.element.android.features.messages.timeline.model.event.TimelineItemRedactedContent
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -43,7 +43,7 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
fun handleEvents(event: ActionListEvents) {
when (event) {
ActionListEvents.Clear -> target.value = ActionListState.Target.None
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(event.messageEvent, target)
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(event.event, target)
}
}
@@ -53,7 +53,7 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
)
}
fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.MessageEvent, target: MutableState<ActionListState.Target>) = launch {
fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState<ActionListState.Target>) = launch {
target.value = ActionListState.Target.Loading(timelineItem)
val actions =
if (timelineItem.content is TimelineItemRedactedContent) {

View File

@@ -28,9 +28,9 @@ data class ActionListState(
) {
sealed interface Target {
object None : Target
data class Loading(val messageEvent: TimelineItem.MessageEvent) : Target
data class Loading(val event: TimelineItem.Event) : Target
data class Success(
val messageEvent: TimelineItem.MessageEvent,
val event: TimelineItem.Event,
val actions: ImmutableList<TimelineItemAction>,
) : Target
}

View File

@@ -18,17 +18,17 @@ package io.element.android.features.messages.actionlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.actionlist.model.TimelineItemAction
import io.element.android.features.messages.timeline.aMessageEvent
import io.element.android.features.messages.timeline.aTimelineItemEvent
import kotlinx.collections.immutable.persistentListOf
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
override val values: Sequence<ActionListState>
get() = sequenceOf(
anActionListState(),
anActionListState().copy(target = ActionListState.Target.Loading(aMessageEvent())),
anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())),
anActionListState().copy(
target = ActionListState.Target.Success(
messageEvent = aMessageEvent(),
event = aTimelineItemEvent(),
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,

View File

@@ -53,7 +53,7 @@ import kotlinx.coroutines.launch
fun ActionListView(
state: ActionListState,
modalBottomSheetState: ModalBottomSheetState,
onActionSelected: (action: TimelineItemAction, TimelineItem.MessageEvent) -> Unit,
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
@@ -67,7 +67,7 @@ fun ActionListView(
fun onItemActionClicked(
itemAction: TimelineItemAction,
targetItem: TimelineItem.MessageEvent
targetItem: TimelineItem.Event
) {
onActionSelected(itemAction, targetItem)
coroutineScope.launch {
@@ -94,7 +94,7 @@ fun ActionListView(
private fun SheetContent(
state: ActionListState,
modifier: Modifier = Modifier,
onActionClicked: (TimelineItemAction, TimelineItem.MessageEvent) -> Unit = { _, _ -> },
onActionClicked: (TimelineItemAction, TimelineItem.Event) -> Unit = { _, _ -> },
) {
when (val target = state.target) {
is ActionListState.Target.Loading,
@@ -112,7 +112,7 @@ private fun SheetContent(
) { action ->
ListItem(
modifier = Modifier.clickable {
onActionClicked(action, target.messageEvent)
onActionClicked(action, target.event)
},
text = {
Text(

View File

@@ -1,253 +0,0 @@
/*
* 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.timeline
import androidx.recyclerview.widget.DiffUtil
import io.element.android.features.messages.timeline.diff.CacheInvalidator
import io.element.android.features.messages.timeline.diff.MatrixTimelineItemsDiffCallback
import io.element.android.features.messages.timeline.model.AggregatedReaction
import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.TimelineItemReactions
import io.element.android.features.messages.timeline.model.content.TimelineItemContent
import io.element.android.features.messages.timeline.model.content.TimelineItemEmoteContent
import io.element.android.features.messages.timeline.model.content.TimelineItemEncryptedContent
import io.element.android.features.messages.timeline.model.content.TimelineItemImageContent
import io.element.android.features.messages.timeline.model.content.TimelineItemNoticeContent
import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent
import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent
import io.element.android.features.messages.timeline.model.content.TimelineItemUnknownContent
import io.element.android.features.messages.timeline.util.invalidateLast
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.core.EventId
import io.element.android.libraries.matrix.media.MediaResolver
import io.element.android.libraries.matrix.room.MatrixRoom
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.ui.MatrixItemHelper
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.matrix.rustcomponents.sdk.FormattedBody
import org.matrix.rustcomponents.sdk.MessageFormat
import org.matrix.rustcomponents.sdk.MessageType
import timber.log.Timber
import javax.inject.Inject
import kotlin.system.measureTimeMillis
class TimelineItemsFactory @Inject constructor(
private val matrixItemHelper: MatrixItemHelper,
private val room: MatrixRoom,
private val dispatcher: CoroutineDispatcher,
) {
private val timelineItems = MutableStateFlow<List<TimelineItem>>(emptyList())
private val timelineItemsCache = arrayListOf<TimelineItem?>()
// Items from rust sdk, used for diffing
private var matrixTimelineItems: List<MatrixTimelineItem> = emptyList()
private val lock = Mutex()
private val cacheInvalidator = CacheInvalidator(timelineItemsCache)
fun flow(): StateFlow<List<TimelineItem>> = timelineItems.asStateFlow()
suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,
) = withContext(dispatcher) {
lock.withLock {
calculateAndApplyDiff(timelineItems)
buildAndEmitTimelineItemStates(timelineItems)
}
}
suspend fun pushItem(
timelineItem: MatrixTimelineItem,
) = withContext(dispatcher) {
lock.withLock {
// Makes sure to invalidate last as we need to recompute some data (like groupPosition)
timelineItemsCache.invalidateLast()
timelineItemsCache.add(null)
matrixTimelineItems = matrixTimelineItems + timelineItem
buildAndEmitTimelineItemStates(matrixTimelineItems)
}
}
private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) {
val newTimelineItemStates = ArrayList<TimelineItem>()
for (index in timelineItemsCache.indices.reversed()) {
val cacheItem = timelineItemsCache[index]
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState)
}
} else {
newTimelineItemStates.add(cacheItem)
}
}
this.timelineItems.emit(newTimelineItemStates)
}
private fun calculateAndApplyDiff(newTimelineItems: List<MatrixTimelineItem>) {
val timeToDiff = measureTimeMillis {
val diffCallback =
MatrixTimelineItemsDiffCallback(
oldList = matrixTimelineItems,
newList = newTimelineItems
)
val diffResult = DiffUtil.calculateDiff(diffCallback, false)
matrixTimelineItems = newTimelineItems
diffResult.dispatchUpdatesTo(cacheInvalidator)
}
Timber.v("Time to apply diff on new list of ${newTimelineItems.size} items: $timeToDiff ms")
}
private suspend fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>,
index: Int
): TimelineItem? {
val timelineItemState =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> {
buildMessageEvent(
currentTimelineItem,
index,
timelineItems,
)
}
is MatrixTimelineItem.Virtual -> TimelineItem.Virtual(
"virtual_item_$index"
)
MatrixTimelineItem.Other -> null
}
timelineItemsCache[index] = timelineItemState
return timelineItemState
}
private suspend fun buildMessageEvent(
currentTimelineItem: MatrixTimelineItem.Event,
index: Int,
timelineItems: List<MatrixTimelineItem>,
): TimelineItem.MessageEvent {
val currentSender = currentTimelineItem.event.sender()
val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index)
val senderDisplayName = room.userDisplayName(currentSender).getOrNull()
val senderAvatarUrl = room.userAvatarUrl(currentSender).getOrNull()
val senderAvatarData = AvatarData(
id = currentSender,
name = senderDisplayName,
url = senderAvatarUrl,
size = AvatarSize.SMALL
)
return TimelineItem.MessageEvent(
id = EventId(currentTimelineItem.uniqueId),
senderId = currentSender,
senderDisplayName = senderDisplayName,
senderAvatar = senderAvatarData,
content = currentTimelineItem.computeContent(),
isMine = currentTimelineItem.event.isOwn(),
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState()
)
}
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
val aggregatedReactions = event.reactions().map {
AggregatedReaction(key = it.key, count = it.count.toString(), isHighlighted = false)
}
return TimelineItemReactions(aggregatedReactions.toImmutableList())
}
private fun MatrixTimelineItem.Event.computeContent(): TimelineItemContent {
val content = event.content()
content.asUnableToDecrypt()?.let { encryptedMessage ->
return TimelineItemEncryptedContent(encryptedMessage)
}
if (content.isRedactedMessage()) {
return TimelineItemRedactedContent
}
val contentAsMessage = content.asMessage()
return when (val messageType = contentAsMessage?.msgtype()) {
is MessageType.Emote -> TimelineItemEmoteContent(
body = messageType.content.body,
htmlDocument = messageType.content.formatted?.toHtmlDocument()
)
is MessageType.Image -> {
val height = messageType.content.info?.height?.toFloat()
val width = messageType.content.info?.width?.toFloat()
val aspectRatio = if (height != null && width != null) {
width / height
} else {
0.7f
}
TimelineItemImageContent(
body = messageType.content.body,
imageMeta = MediaResolver.Meta(
source = messageType.content.source,
kind = MediaResolver.Kind.Content
),
blurhash = messageType.content.info?.blurhash,
aspectRatio = aspectRatio
)
}
is MessageType.Notice -> TimelineItemNoticeContent(
body = messageType.content.body,
htmlDocument = messageType.content.formatted?.toHtmlDocument()
)
is MessageType.Text -> TimelineItemTextContent(
body = messageType.content.body,
htmlDocument = messageType.content.formatted?.toHtmlDocument()
)
else -> TimelineItemUnknownContent
}
}
private fun FormattedBody.toHtmlDocument(): Document? {
return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody ->
Jsoup.parse(formattedBody)
}
}
private fun computeGroupPosition(
currentTimelineItem: MatrixTimelineItem.Event,
timelineItems: List<MatrixTimelineItem>,
index: Int
): TimelineItemGroupPosition {
val prevTimelineItem =
timelineItems.getOrNull(index - 1) as? MatrixTimelineItem.Event
val nextTimelineItem =
timelineItems.getOrNull(index + 1) as? MatrixTimelineItem.Event
val currentSender = currentTimelineItem.event.sender()
val previousSender = prevTimelineItem?.event?.sender()
val nextSender = nextTimelineItem?.event?.sender()
return when {
previousSender != currentSender && nextSender == currentSender -> TimelineItemGroupPosition.First
previousSender == currentSender && nextSender == currentSender -> TimelineItemGroupPosition.Middle
previousSender == currentSender && nextSender != currentSender -> TimelineItemGroupPosition.Last
else -> TimelineItemGroupPosition.None
}
}
}

View File

@@ -24,61 +24,46 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.messages.timeline.factories.TimelineItemsFactory
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.MatrixClient
import io.element.android.libraries.matrix.core.EventId
import io.element.android.libraries.matrix.room.MatrixRoom
import io.element.android.libraries.matrix.timeline.MatrixTimeline
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.ui.MatrixItemHelper
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val PAGINATION_COUNT = 50
private const val backPaginationEventLimit = 20
private const val backPaginationPageSize = 50
class TimelinePresenter @Inject constructor(
coroutineDispatchers: CoroutineDispatchers,
client: MatrixClient,
private val timelineItemsFactory: TimelineItemsFactory,
room: MatrixRoom,
) : Presenter<TimelineState> {
private val timeline = room.timeline()
private val matrixItemHelper = MatrixItemHelper(client)
private val timelineItemsFactory =
TimelineItemsFactory(matrixItemHelper, room, coroutineDispatchers.computation)
private class TimelineCallback(
private val coroutineScope: CoroutineScope,
private val timelineItemsFactory: TimelineItemsFactory,
) : MatrixTimeline.Callback {
override fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) {
coroutineScope.launch {
timelineItemsFactory.pushItem(timelineItem)
}
}
}
@Composable
override fun present(): TimelineState {
val localCoroutineScope = rememberCoroutineScope()
val hasMoreToLoad = rememberSaveable {
mutableStateOf(timeline.hasMoreToLoad)
}
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null)
}
val timelineItems = timelineItemsFactory
.flow()
.collectAsState(emptyList())
.collectAsState()
val paginationState = timeline
.paginationState()
.collectAsState()
fun handleEvents(event: TimelineEvents) {
when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(hasMoreToLoad)
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value)
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
}
}
@@ -87,28 +72,34 @@ class TimelinePresenter @Inject constructor(
timeline
.timelineItems()
.onEach(timelineItemsFactory::replaceWith)
.onEach { timelineItems ->
if (timelineItems.isEmpty()) {
loadMore(paginationState.value)
}
}
.launchIn(this)
}
DisposableEffect(Unit) {
timeline.callback = TimelineCallback(localCoroutineScope, timelineItemsFactory)
timeline.initialize()
onDispose {
timeline.callback = null
timeline.dispose()
}
}
return TimelineState(
highlightedEventId = highlightedEventId.value,
paginationState = paginationState.value,
timelineItems = timelineItems.value.toImmutableList(),
hasMoreToLoad = hasMoreToLoad.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.loadMore(hasMoreToLoad: MutableState<Boolean>) = launch {
timeline.paginateBackwards(PAGINATION_COUNT)
hasMoreToLoad.value = timeline.hasMoreToLoad
private fun CoroutineScope.loadMore(paginationState: MatrixTimeline.PaginationState) = launch {
if (paginationState.canBackPaginate && !paginationState.isBackPaginating) {
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
} else {
Timber.v("Can't back paginate as paginationState = $paginationState")
}
}
}

View File

@@ -19,12 +19,13 @@ package io.element.android.features.messages.timeline
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.libraries.matrix.core.EventId
import io.element.android.libraries.matrix.timeline.MatrixTimeline
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>,
val hasMoreToLoad: Boolean,
val highlightedEventId: EventId?,
val paginationState: MatrixTimeline.PaginationState,
val eventSink: (TimelineEvents) -> Unit
)

View File

@@ -17,53 +17,54 @@
package io.element.android.features.messages.timeline
import io.element.android.features.messages.timeline.model.AggregatedReaction
import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.timeline.model.TimelineItemReactions
import io.element.android.features.messages.timeline.model.content.TimelineItemContent
import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.core.EventId
import io.element.android.libraries.matrix.timeline.MatrixTimeline
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
fun aTimelineState() = TimelineState(
timelineItems = persistentListOf(),
hasMoreToLoad = false,
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
highlightedEventId = null,
eventSink = {}
)
internal fun aTimelineItemList(content: TimelineItemContent): ImmutableList<TimelineItem> {
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
return persistentListOf(
// 3 items (First Middle Last) with isMine = false
aMessageEvent(
aTimelineItemEvent(
isMine = false,
content = content,
groupPosition = TimelineItemGroupPosition.Last
),
aMessageEvent(
aTimelineItemEvent(
isMine = false,
content = content,
groupPosition = TimelineItemGroupPosition.Middle
),
aMessageEvent(
aTimelineItemEvent(
isMine = false,
content = content,
groupPosition = TimelineItemGroupPosition.First
),
// 3 items (First Middle Last) with isMine = true
aMessageEvent(
aTimelineItemEvent(
isMine = true,
content = content,
groupPosition = TimelineItemGroupPosition.Last
),
aMessageEvent(
aTimelineItemEvent(
isMine = true,
content = content,
groupPosition = TimelineItemGroupPosition.Middle
),
aMessageEvent(
aTimelineItemEvent(
isMine = true,
content = content,
groupPosition = TimelineItemGroupPosition.First
@@ -71,13 +72,15 @@ internal fun aTimelineItemList(content: TimelineItemContent): ImmutableList<Time
)
}
internal fun aMessageEvent(
internal fun aTimelineItemEvent(
isMine: Boolean = false,
content: TimelineItemContent = aTimelineItemContent(),
content: TimelineItemEventContent = aTimelineItemContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First
): TimelineItem.MessageEvent {
return TimelineItem.MessageEvent(
id = EventId(Math.random().toString()),
): TimelineItem.Event {
val randomId = Math.random().toString()
return TimelineItem.Event(
id = randomId,
eventId = EventId(randomId),
senderId = "@senderId",
senderAvatar = AvatarData("@senderId", "sender"),
content = content,
@@ -92,7 +95,7 @@ internal fun aMessageEvent(
)
}
internal fun aTimelineItemContent(): TimelineItemContent {
internal fun aTimelineItemContent(): TimelineItemEventContent {
return TimelineItemTextContent(
body = "Text",
htmlDocument = null

View File

@@ -34,7 +34,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
@@ -54,27 +54,21 @@ 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 io.element.android.features.messages.timeline.components.BubbleState
import io.element.android.features.messages.timeline.components.MessageEventBubble
import io.element.android.features.messages.timeline.components.TimelineItemEncryptedView
import io.element.android.features.messages.timeline.components.TimelineItemImageView
import io.element.android.features.messages.timeline.components.TimelineItemReactionsView
import io.element.android.features.messages.timeline.components.TimelineItemRedactedView
import io.element.android.features.messages.timeline.components.TimelineItemTextView
import io.element.android.features.messages.timeline.components.TimelineItemUnknownView
import io.element.android.features.messages.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.timeline.components.virtual.TimelineItemDaySeparatorView
import io.element.android.features.messages.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.content.TimelineItemContent
import io.element.android.features.messages.timeline.model.content.TimelineItemContentProvider
import io.element.android.features.messages.timeline.model.content.TimelineItemEncryptedContent
import io.element.android.features.messages.timeline.model.content.TimelineItemImageContent
import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent
import io.element.android.features.messages.timeline.model.content.TimelineItemTextBasedContent
import io.element.android.features.messages.timeline.model.content.TimelineItemUnknownContent
import io.element.android.features.messages.timeline.model.bubble.BubbleState
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.timeline.model.virtual.TimelineItemLoadingModel
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@@ -86,9 +80,14 @@ import kotlinx.coroutines.launch
fun TimelineView(
state: TimelineState,
modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {},
onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {},
onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
) {
fun onReachedLoadMore() {
state.eventSink(TimelineEvents.LoadMore)
}
val lazyListState = rememberLazyListState()
Box(modifier = modifier) {
LazyColumn(
@@ -98,29 +97,23 @@ fun TimelineView(
verticalArrangement = Arrangement.Bottom,
reverseLayout = true
) {
items(
itemsIndexed(
items = state.timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
key = { timelineItem -> timelineItem.key() },
) { timelineItem ->
contentType = { _, timelineItem -> timelineItem.contentType() },
key = { _, timelineItem -> timelineItem.key() },
) { index, timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
isHighlighted = timelineItem.key() == state.highlightedEventId?.value,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked
)
}
if (state.hasMoreToLoad) {
item {
TimelineLoadingMoreIndicator()
if (index == state.timelineItems.lastIndex) {
onReachedLoadMore()
}
}
}
fun onReachedLoadMore() {
state.eventSink(TimelineEvents.LoadMore)
}
TimelineScrollHelper(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
@@ -131,14 +124,15 @@ fun TimelineView(
private fun TimelineItem.key(): String {
return when (this) {
is TimelineItem.MessageEvent -> id.value
is TimelineItem.Event -> id
is TimelineItem.Virtual -> id
}
}
private fun TimelineItem.contentType(): Int {
// Todo optimize for each subtype
return when (this) {
is TimelineItem.MessageEvent -> 0
is TimelineItem.Event -> 0
is TimelineItem.Virtual -> 1
}
}
@@ -147,30 +141,55 @@ private fun TimelineItem.contentType(): Int {
fun TimelineItemRow(
timelineItem: TimelineItem,
isHighlighted: Boolean,
onClick: (TimelineItem.MessageEvent) -> Unit,
onLongClick: (TimelineItem.MessageEvent) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
) {
when (timelineItem) {
is TimelineItem.Virtual -> return
is TimelineItem.MessageEvent -> MessageEventRow(
messageEvent = timelineItem,
isHighlighted = isHighlighted,
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }
is TimelineItem.Virtual -> TimelineItemVirtualRow(
virtual = timelineItem
)
is TimelineItem.Event -> {
fun onClick() {
onClick(timelineItem)
}
fun onLongClick() {
onLongClick(timelineItem)
}
TimelineItemEventRow(
event = timelineItem,
isHighlighted = isHighlighted,
onClick = ::onClick,
onLongClick = ::onLongClick
)
}
}
}
@Composable
fun MessageEventRow(
messageEvent: TimelineItem.MessageEvent,
fun TimelineItemVirtualRow(
virtual: TimelineItem.Virtual,
modifier: Modifier = Modifier
) {
when (virtual.model) {
is TimelineItemLoadingModel -> TimelineLoadingMoreIndicator(modifier)
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
else -> return
}
}
@Composable
fun TimelineItemEventRow(
event: TimelineItem.Event,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
val (parentAlignment, contentAlignment) = if (event.isMine) {
Pair(Alignment.CenterEnd, Alignment.End)
} else {
Pair(Alignment.CenterStart, Alignment.Start)
@@ -182,21 +201,21 @@ fun MessageEventRow(
contentAlignment = parentAlignment
) {
Row {
if (!messageEvent.isMine) {
if (!event.isMine) {
Spacer(modifier = Modifier.width(16.dp))
}
Column(horizontalAlignment = contentAlignment) {
if (messageEvent.showSenderInformation) {
if (event.showSenderInformation) {
MessageSenderInformation(
messageEvent.safeSenderName,
messageEvent.senderAvatar,
event.safeSenderName,
event.senderAvatar,
Modifier.zIndex(1f)
)
}
MessageEventBubble(
state = BubbleState(
groupPosition = messageEvent.groupPosition,
isMine = messageEvent.isMine,
groupPosition = event.groupPosition,
isMine = event.isMine,
isHighlighted = isHighlighted,
),
interactionSource = interactionSource,
@@ -207,45 +226,21 @@ fun MessageEventRow(
.widthIn(max = 320.dp)
) {
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
when (messageEvent.content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = messageEvent.content,
modifier = contentModifier
)
is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = messageEvent.content,
modifier = contentModifier
)
is TimelineItemTextBasedContent -> TimelineItemTextView(
content = messageEvent.content,
interactionSource = interactionSource,
modifier = contentModifier,
onTextClicked = onClick,
onTextLongClicked = onLongClick
)
is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = messageEvent.content,
modifier = contentModifier
)
is TimelineItemImageContent -> TimelineItemImageView(
content = messageEvent.content,
modifier = contentModifier
)
}
TimelineItemEventContentView(event.content, interactionSource, onClick, onLongClick, contentModifier)
}
TimelineItemReactionsView(
reactionsState = messageEvent.reactionsState,
reactionsState = event.reactionsState,
modifier = Modifier
.zIndex(1f)
.offset(x = if (messageEvent.isMine) 0.dp else 20.dp, y = -(16.dp))
.offset(x = if (event.isMine) 0.dp else 20.dp, y = -(16.dp))
)
}
if (messageEvent.isMine) {
if (event.isMine) {
Spacer(modifier = Modifier.width(16.dp))
}
}
}
if (messageEvent.groupPosition.isNew()) {
if (event.groupPosition.isNew()) {
Spacer(modifier = modifier.height(8.dp))
} else {
Spacer(modifier = modifier.height(2.dp))
@@ -332,41 +327,24 @@ internal fun BoxScope.TimelineScrollHelper(
}
}
@Composable
internal fun TimelineLoadingMoreIndicator() {
Box(
Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
@Preview
@Composable
fun LoginRootScreenLightPreview(
@PreviewParameter(TimelineItemContentProvider::class) content: TimelineItemContent
fun TimelineViewLightPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewLight { ContentToPreview(content) }
@Preview
@Composable
fun LoginRootScreenDarkPreview(
@PreviewParameter(TimelineItemContentProvider::class) content: TimelineItemContent
fun TimelineViewDarkPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewDark { ContentToPreview(content) }
@Composable
private fun ContentToPreview(content: TimelineItemContent) {
private fun ContentToPreview(content: TimelineItemEventContent) {
val timelineItems = aTimelineItemList(content)
TimelineView(
state = aTimelineState().copy(
timelineItems = timelineItems,
hasMoreToLoad = true,
)
)
}

View File

@@ -36,6 +36,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.timeline.model.bubble.BubbleState
import io.element.android.features.messages.timeline.model.bubble.BubbleStateProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme

View File

@@ -0,0 +1,62 @@
/*
* 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.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.timeline.model.event.TimelineItemUnknownContent
@Composable
fun TimelineItemEventContentView(
content: TimelineItemEventContent,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content,
modifier = modifier
)
is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = content,
modifier = modifier
)
is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content,
interactionSource = interactionSource,
modifier = modifier,
onTextClicked = onClick,
onTextLongClicked = onLongClick
)
is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content,
modifier = modifier
)
is TimelineItemImageContent -> TimelineItemImageView(
content = content,
modifier = modifier
)
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -14,14 +14,14 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.components
package io.element.android.features.messages.timeline.components.event
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.features.messages.timeline.model.content.TimelineItemEncryptedContent
import io.element.android.features.messages.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import org.matrix.rustcomponents.sdk.EncryptedMessage

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.components
package io.element.android.features.messages.timeline.components.event
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
@@ -32,8 +32,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.element.android.features.messages.timeline.model.content.TimelineItemImageContent
import io.element.android.features.messages.timeline.model.content.TimelineItemImageContentProvider
import io.element.android.features.messages.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.timeline.model.event.TimelineItemImageContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.components
package io.element.android.features.messages.timeline.components.event
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -14,14 +14,14 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.components
package io.element.android.features.messages.timeline.components.event
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent
import io.element.android.features.messages.timeline.model.event.TimelineItemRedactedContent
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.components
package io.element.android.features.messages.timeline.components.event
import android.text.SpannableString
import android.text.style.URLSpan
@@ -31,8 +31,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.core.text.util.LinkifyCompat
import io.element.android.features.messages.timeline.components.html.HtmlDocument
import io.element.android.features.messages.timeline.model.content.TimelineItemTextBasedContent
import io.element.android.features.messages.timeline.model.content.TimelineItemTextBasedContentProvider
import io.element.android.features.messages.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -14,14 +14,14 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.components
package io.element.android.features.messages.timeline.components.event
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.features.messages.timeline.model.content.TimelineItemUnknownContent
import io.element.android.features.messages.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight

View File

@@ -0,0 +1,70 @@
/*
* 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.timeline.components.virtual
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.timeline.model.virtual.TimelineItemDaySeparatorModelProvider
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
@Composable
internal fun TimelineItemDaySeparatorView(
model: TimelineItemDaySeparatorModel,
modifier: Modifier = Modifier
) {
Box(
modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = model.formattedDate,
color = MaterialTheme.colorScheme.secondary,
)
}
}
@Preview
@Composable
internal fun TimelineItemDaySeparatorViewLightPreview(@PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel) =
ElementPreviewLight { ContentToPreview(model) }
@Preview
@Composable
internal fun TimelineItemDaySeparatorViewDarkPreview(@PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel) =
ElementPreviewDark { ContentToPreview(model) }
@Composable
private fun ContentToPreview(model: TimelineItemDaySeparatorModel) {
TimelineItemDaySeparatorView(
model = model,
)
}

View File

@@ -0,0 +1,60 @@
/*
* 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.timeline.components.virtual
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.components.CircularProgressIndicator
@Composable
internal fun TimelineLoadingMoreIndicator(modifier: Modifier = Modifier) {
Box(
modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
strokeWidth = 2.dp,
)
}
}
@Preview
@Composable
internal fun TimelineLoadingMoreIndicatorLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun TimelineLoadingMoreIndicatorDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
TimelineLoadingMoreIndicator()
}

View File

@@ -0,0 +1,105 @@
/*
* 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.timeline.factories
import androidx.recyclerview.widget.DiffUtil
import io.element.android.features.messages.timeline.diff.CacheInvalidator
import io.element.android.features.messages.timeline.diff.MatrixTimelineItemsDiffCallback
import io.element.android.features.messages.timeline.factories.event.TimelineItemEventFactory
import io.element.android.features.messages.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
import kotlin.system.measureTimeMillis
class TimelineItemsFactory @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val eventItemFactory: TimelineItemEventFactory,
private val virtualItemFactory: TimelineItemVirtualFactory,
) {
private val timelineItems = MutableStateFlow<List<TimelineItem>>(emptyList())
private val timelineItemsCache = arrayListOf<TimelineItem?>()
// Items from rust sdk, used for diffing
private var matrixTimelineItems: List<MatrixTimelineItem> = emptyList()
private val lock = Mutex()
private val cacheInvalidator = CacheInvalidator(timelineItemsCache)
fun flow(): StateFlow<List<TimelineItem>> = timelineItems.asStateFlow()
suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,
) = withContext(dispatchers.computation) {
lock.withLock {
calculateAndApplyDiff(timelineItems)
buildAndEmitTimelineItemStates(timelineItems)
}
}
private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) {
val newTimelineItemStates = ArrayList<TimelineItem>()
for (index in timelineItemsCache.indices.reversed()) {
val cacheItem = timelineItemsCache[index]
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState)
}
} else {
newTimelineItemStates.add(cacheItem)
}
}
this.timelineItems.emit(newTimelineItemStates)
}
private fun calculateAndApplyDiff(newTimelineItems: List<MatrixTimelineItem>) {
val timeToDiff = measureTimeMillis {
val diffCallback =
MatrixTimelineItemsDiffCallback(
oldList = matrixTimelineItems,
newList = newTimelineItems
)
val diffResult = DiffUtil.calculateDiff(diffCallback, false)
matrixTimelineItems = newTimelineItems
diffResult.dispatchUpdatesTo(cacheInvalidator)
}
Timber.v("Time to apply diff on new list of ${newTimelineItems.size} items: $timeToDiff ms")
}
private suspend fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>,
index: Int
): TimelineItem? {
val timelineItemState =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem, index, timelineItems)
MatrixTimelineItem.Other -> null
}
timelineItemsCache[index] = timelineItemState
return timelineItemState
}
}

View File

@@ -0,0 +1,50 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import javax.inject.Inject
typealias RustTimelineItemContent = org.matrix.rustcomponents.sdk.TimelineItemContent
class TimelineItemContentFactory @Inject constructor(
private val messageFactory: TimelineItemContentMessageFactory,
private val redactedMessageFactory: TimelineItemContentRedactedFactory,
private val stickerFactory: TimelineItemContentStickerFactory,
private val utdFactory: TimelineItemContentUTDFactory,
private val roomMembershipFactory: TimelineItemContentRoomMembershipFactory,
private val profileChangeFactory: TimelineItemContentProfileChangeFactory,
private val stateFactory: TimelineItemContentStateFactory,
private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory,
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory
) {
fun create(itemContent: RustTimelineItemContent): TimelineItemEventContent {
return when (val kind = itemContent.kind()) {
is TimelineItemContentKind.Message -> messageFactory.create(itemContent.asMessage())
is TimelineItemContentKind.RedactedMessage -> redactedMessageFactory.create(kind)
is TimelineItemContentKind.Sticker -> stickerFactory.create(kind)
is TimelineItemContentKind.UnableToDecrypt -> utdFactory.create(kind)
is TimelineItemContentKind.RoomMembership -> roomMembershipFactory.create(kind)
is TimelineItemContentKind.ProfileChange -> profileChangeFactory.create(kind)
is TimelineItemContentKind.State -> stateFactory.create(kind)
is TimelineItemContentKind.FailedToParseMessageLike -> failedToParseMessageFactory.create(kind)
is TimelineItemContentKind.FailedToParseState -> failedToParseStateFactory.create(kind)
}
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemUnknownContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import javax.inject.Inject
class TimelineItemContentFailedToParseMessageFactory @Inject constructor() {
fun create(kind: TimelineItemContentKind.FailedToParseMessageLike): TimelineItemEventContent {
return TimelineItemUnknownContent
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemUnknownContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import javax.inject.Inject
class TimelineItemContentFailedToParseStateFactory @Inject constructor() {
fun create(kind: TimelineItemContentKind.FailedToParseState): TimelineItemEventContent {
return TimelineItemUnknownContent
}
}

View File

@@ -0,0 +1,68 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.timeline.util.toHtmlDocument
import io.element.android.libraries.matrix.media.MediaResolver
import org.matrix.rustcomponents.sdk.Message
import org.matrix.rustcomponents.sdk.MessageType
import javax.inject.Inject
class TimelineItemContentMessageFactory @Inject constructor() {
fun create(contentAsMessage: Message?): TimelineItemEventContent {
return when (val messageType = contentAsMessage?.msgtype()) {
is MessageType.Emote -> TimelineItemEmoteContent(
body = messageType.content.body,
htmlDocument = messageType.content.formatted?.toHtmlDocument()
)
is MessageType.Image -> {
val height = messageType.content.info?.height?.toFloat()
val width = messageType.content.info?.width?.toFloat()
val aspectRatio = if (height != null && width != null) {
width / height
} else {
0.7f
}
TimelineItemImageContent(
body = messageType.content.body,
imageMeta = MediaResolver.Meta(
source = messageType.content.source,
kind = MediaResolver.Kind.Content
),
blurhash = messageType.content.info?.blurhash,
aspectRatio = aspectRatio
)
}
is MessageType.Notice -> TimelineItemNoticeContent(
body = messageType.content.body,
htmlDocument = messageType.content.formatted?.toHtmlDocument()
)
is MessageType.Text -> TimelineItemTextContent(
body = messageType.content.body,
htmlDocument = messageType.content.formatted?.toHtmlDocument()
)
else -> TimelineItemUnknownContent
}
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemUnknownContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import javax.inject.Inject
class TimelineItemContentProfileChangeFactory @Inject constructor() {
fun create(kind: TimelineItemContentKind.ProfileChange): TimelineItemEventContent {
return TimelineItemUnknownContent
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemRedactedContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import javax.inject.Inject
class TimelineItemContentRedactedFactory @Inject constructor() {
fun create(kind: TimelineItemContentKind.RedactedMessage): TimelineItemEventContent {
return TimelineItemRedactedContent
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemUnknownContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import javax.inject.Inject
class TimelineItemContentRoomMembershipFactory @Inject constructor() {
fun create(kind: TimelineItemContentKind.RoomMembership): TimelineItemEventContent {
return TimelineItemUnknownContent
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemUnknownContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import javax.inject.Inject
class TimelineItemContentStateFactory @Inject constructor() {
fun create(kind: TimelineItemContentKind.State): TimelineItemEventContent {
return TimelineItemUnknownContent
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemUnknownContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import javax.inject.Inject
class TimelineItemContentStickerFactory @Inject constructor() {
fun create(kind: TimelineItemContentKind.Sticker): TimelineItemEventContent {
return TimelineItemUnknownContent
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemEncryptedContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import javax.inject.Inject
class TimelineItemContentUTDFactory @Inject constructor() {
fun create(kind: TimelineItemContentKind.UnableToDecrypt): TimelineItemEventContent {
return TimelineItemEncryptedContent(kind.msg)
}
}

View File

@@ -0,0 +1,104 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.timeline.model.AggregatedReaction
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.timeline.model.TimelineItemReactions
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.ProfileTimelineDetails
import javax.inject.Inject
class TimelineItemEventFactory @Inject constructor(
private val contentFactory: TimelineItemContentFactory,
) {
fun create(
currentTimelineItem: MatrixTimelineItem.Event,
index: Int,
timelineItems: List<MatrixTimelineItem>,
): TimelineItem.Event {
val currentSender = currentTimelineItem.event.sender()
val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index)
val senderDisplayName: String?
val senderAvatarUrl: String?
when (val senderProfile = currentTimelineItem.event.senderProfile()) {
ProfileTimelineDetails.Unavailable,
ProfileTimelineDetails.Pending,
is ProfileTimelineDetails.Error -> {
senderDisplayName = null
senderAvatarUrl = null
}
is ProfileTimelineDetails.Ready -> {
senderDisplayName = senderProfile.displayName
senderAvatarUrl = senderProfile.avatarUrl
}
}
val senderAvatarData = AvatarData(
id = currentSender,
name = senderDisplayName ?: currentSender,
url = senderAvatarUrl,
size = AvatarSize.SMALL
)
return TimelineItem.Event(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
senderId = currentSender,
senderDisplayName = senderDisplayName,
senderAvatar = senderAvatarData,
content = contentFactory.create(currentTimelineItem.event.content()),
isMine = currentTimelineItem.event.isOwn(),
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState()
)
}
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
val aggregatedReactions = event.reactions().orEmpty().map {
AggregatedReaction(key = it.key, count = it.count.toString(), isHighlighted = false)
}
return TimelineItemReactions(aggregatedReactions.toImmutableList())
}
private fun computeGroupPosition(
currentTimelineItem: MatrixTimelineItem.Event,
timelineItems: List<MatrixTimelineItem>,
index: Int
): TimelineItemGroupPosition {
val prevTimelineItem =
timelineItems.getOrNull(index - 1) as? MatrixTimelineItem.Event
val nextTimelineItem =
timelineItems.getOrNull(index + 1) as? MatrixTimelineItem.Event
val currentSender = currentTimelineItem.event.sender()
val previousSender = prevTimelineItem?.event?.sender()
val nextSender = nextTimelineItem?.event?.sender()
return when {
previousSender != currentSender && nextSender == currentSender -> TimelineItemGroupPosition.First
previousSender == currentSender && nextSender == currentSender -> TimelineItemGroupPosition.Middle
previousSender == currentSender && nextSender != currentSender -> TimelineItemGroupPosition.Last
else -> TimelineItemGroupPosition.None
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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.timeline.factories.virtual
import io.element.android.features.messages.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.dateformatter.DaySeparatorFormatter
import org.matrix.rustcomponents.sdk.VirtualTimelineItem
import javax.inject.Inject
class TimelineItemDaySeparatorFactory @Inject constructor(private val daySeparatorFormatter: DaySeparatorFormatter) {
fun create(virtualItem: VirtualTimelineItem.DayDivider): TimelineItemVirtualModel {
val formattedDate = daySeparatorFormatter.format(virtualItem.ts.toLong())
return TimelineItemDaySeparatorModel(
formattedDate = formattedDate
)
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.timeline.factories.virtual
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.virtual.TimelineItemLoadingModel
import io.element.android.features.messages.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.timeline.model.virtual.TimelineItemUnknownVirtualModel
import io.element.android.features.messages.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
import org.matrix.rustcomponents.sdk.VirtualTimelineItem
import javax.inject.Inject
class TimelineItemVirtualFactory @Inject constructor(
private val daySeparatorFactory: TimelineItemDaySeparatorFactory,
) {
fun create(
currentTimelineItem: MatrixTimelineItem.Virtual,
index: Int,
timelineItems: List<MatrixTimelineItem>,
): TimelineItem.Virtual {
return TimelineItem.Virtual(
id = "virtual_item_$index",
model = currentTimelineItem.computeModel(index)
)
}
private fun MatrixTimelineItem.Virtual.computeModel(index: Int): TimelineItemVirtualModel {
return when (val inner = virtual) {
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingModel
is VirtualTimelineItem.TimelineStart -> TimelineItemReadMarkerModel
else -> TimelineItemUnknownVirtualModel
}
}
}

View File

@@ -17,22 +17,33 @@
package io.element.android.features.messages.timeline.model
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.timeline.model.content.TimelineItemContent
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.core.EventId
@Immutable
sealed interface TimelineItem {
fun identifier(): String = when(this){
is Event -> id
is Virtual -> id
}
@Immutable
data class Virtual(
val id: String
val id: String,
val model: TimelineItemVirtualModel
) : TimelineItem
data class MessageEvent(
val id: EventId,
@Immutable
data class Event(
val id: String,
val eventId: EventId? = null,
val senderId: String,
val senderDisplayName: String?,
val senderAvatar: AvatarData,
val content: TimelineItemContent,
val content: TimelineItemEventContent,
val sentTime: String = "",
val isMine: Boolean = false,
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,

View File

@@ -14,12 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.components
package io.element.android.features.messages.timeline.model.bubble
import androidx.compose.runtime.Stable
import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition
@Stable
data class BubbleState(
val groupPosition: TimelineItemGroupPosition,
val isMine: Boolean,

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.components
package io.element.android.features.messages.timeline.model.bubble
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model.content
package io.element.android.features.messages.timeline.model.event
import org.jsoup.nodes.Document

View File

@@ -14,10 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model.content
package io.element.android.features.messages.timeline.model.event
import org.matrix.rustcomponents.sdk.EncryptedMessage
data class TimelineItemEncryptedContent(
val encryptedMessage: EncryptedMessage
) : TimelineItemContent
) : TimelineItemEventContent

View File

@@ -14,6 +14,9 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model.content
package io.element.android.features.messages.timeline.model.event
sealed interface TimelineItemContent
import androidx.compose.runtime.Immutable
@Immutable
sealed interface TimelineItemEventContent

View File

@@ -14,13 +14,13 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model.content
package io.element.android.features.messages.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import org.jsoup.Jsoup
import org.matrix.rustcomponents.sdk.EncryptedMessage
class TimelineItemContentProvider : PreviewParameterProvider<TimelineItemContent> {
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
override val values = sequenceOf(
aTimelineItemEmoteContent(),
aTimelineItemEncryptedContent(),

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model.content
package io.element.android.features.messages.timeline.model.event
import io.element.android.libraries.matrix.media.MediaResolver
@@ -23,4 +23,4 @@ data class TimelineItemImageContent(
val imageMeta: MediaResolver.Meta,
val blurhash: String?,
val aspectRatio: Float
) : TimelineItemContent
) : TimelineItemEventContent

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model.content
package io.element.android.features.messages.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.media.MediaResolver

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model.content
package io.element.android.features.messages.timeline.model.event
import org.jsoup.nodes.Document

View File

@@ -14,6 +14,6 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model.content
package io.element.android.features.messages.timeline.model.event
object TimelineItemRedactedContent : TimelineItemContent
object TimelineItemRedactedContent : TimelineItemEventContent

View File

@@ -14,11 +14,11 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model.content
package io.element.android.features.messages.timeline.model.event
import org.jsoup.nodes.Document
sealed interface TimelineItemTextBasedContent : TimelineItemContent {
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
val body: String
val htmlDocument: Document?
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model.content
package io.element.android.features.messages.timeline.model.event
import org.jsoup.nodes.Document

View File

@@ -14,6 +14,6 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model.content
package io.element.android.features.messages.timeline.model.event
object TimelineItemUnknownContent : TimelineItemContent
object TimelineItemUnknownContent : TimelineItemEventContent

View File

@@ -0,0 +1,21 @@
/*
* 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.timeline.model.virtual
data class TimelineItemDaySeparatorModel(
val formattedDate: String
) : TimelineItemVirtualModel

View File

@@ -0,0 +1,30 @@
/*
* 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.timeline.model.virtual
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class TimelineItemDaySeparatorModelProvider : PreviewParameterProvider<TimelineItemDaySeparatorModel> {
override val values = sequenceOf(
aTimelineItemDaySeparatorModel("Today"),
aTimelineItemDaySeparatorModel("March 6, 2023")
)
}
fun aTimelineItemDaySeparatorModel(formattedDate: String) = TimelineItemDaySeparatorModel(
formattedDate = formattedDate
)

View File

@@ -0,0 +1,19 @@
/*
* 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.timeline.model.virtual
object TimelineItemLoadingModel : TimelineItemVirtualModel

View File

@@ -0,0 +1,19 @@
/*
* 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.timeline.model.virtual
object TimelineItemReadMarkerModel : TimelineItemVirtualModel

View File

@@ -0,0 +1,19 @@
/*
* 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.timeline.model.virtual
object TimelineItemTimelineStartModel : TimelineItemVirtualModel

View File

@@ -0,0 +1,19 @@
/*
* 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.timeline.model.virtual
object TimelineItemUnknownVirtualModel : TimelineItemVirtualModel

View File

@@ -0,0 +1,22 @@
/*
* 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.timeline.model.virtual
import androidx.compose.runtime.Immutable
@Immutable
sealed interface TimelineItemVirtualModel

View File

@@ -0,0 +1,28 @@
/*
* 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.timeline.util
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.matrix.rustcomponents.sdk.FormattedBody
import org.matrix.rustcomponents.sdk.MessageFormat
fun FormattedBody.toHtmlDocument(): Document? {
return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody ->
Jsoup.parse(formattedBody)
}
}

View File

@@ -24,12 +24,14 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.actionlist.ActionListPresenter
import io.element.android.features.messages.actionlist.model.TimelineItemAction
import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.timeline.TimelinePresenter
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.TimelineItemReactions
import io.element.android.features.messages.timeline.model.content.TimelineItemContent
import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.room.MatrixRoom
@@ -38,7 +40,6 @@ import io.element.android.libraries.matrixtest.A_MESSAGE
import io.element.android.libraries.matrixtest.A_ROOM_ID
import io.element.android.libraries.matrixtest.A_USER_ID
import io.element.android.libraries.matrixtest.A_USER_NAME
import io.element.android.libraries.matrixtest.FakeMatrixClient
import io.element.android.libraries.matrixtest.room.FakeMatrixRoom
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.persistentListOf
@@ -134,14 +135,13 @@ class MessagesPresenterTest {
private fun TestScope.createMessagePresenter(
matrixRoom: MatrixRoom = FakeMatrixRoom()
): MessagesPresenter {
val matrixClient = FakeMatrixClient()
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
room = matrixRoom
)
val timelinePresenter = TimelinePresenter(
coroutineDispatchers = testCoroutineDispatchers(),
client = matrixClient,
timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom,
)
val actionListPresenter = ActionListPresenter()
@@ -154,25 +154,3 @@ class MessagesPresenterTest {
}
}
// TODO Move to common module to reuse
fun testCoroutineDispatchers() = CoroutineDispatchers(
io = UnconfinedTestDispatcher(),
computation = UnconfinedTestDispatcher(),
main = UnconfinedTestDispatcher(),
diffUpdateDispatcher = UnconfinedTestDispatcher(),
)
// TODO Move to common module to reuse and remove this duplication
private fun aMessageEvent(
isMine: Boolean = true,
content: TimelineItemContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null),
) = TimelineItem.MessageEvent(
id = AN_EVENT_ID,
senderId = A_USER_ID.value,
senderDisplayName = A_USER_NAME,
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME),
content = content,
sentTime = "",
isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf())
)

View File

@@ -25,9 +25,9 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.actionlist.model.TimelineItemAction
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.TimelineItemReactions
import io.element.android.features.messages.timeline.model.content.TimelineItemContent
import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent
import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrixtest.AN_EVENT_ID
import io.element.android.libraries.matrixtest.A_MESSAGE
@@ -37,6 +37,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.TimelineItemContent
class ActionListPresenterTest {
@Test
@@ -163,9 +164,10 @@ class ActionListPresenterTest {
private fun aMessageEvent(
isMine: Boolean,
content: TimelineItemContent,
) = TimelineItem.MessageEvent(
id = AN_EVENT_ID,
content: TimelineItemEventContent,
) = TimelineItem.Event(
id = AN_EVENT_ID.value,
eventId = AN_EVENT_ID,
senderId = A_USER_ID.value,
senderDisplayName = A_USER_NAME,
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME),

View File

@@ -0,0 +1,32 @@
/*
* 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.fakes
import io.element.android.libraries.dateformatter.DaySeparatorFormatter
class FakeDaySeparatorFormatter : DaySeparatorFormatter {
private var format = ""
fun givenFormat(format: String) {
this.format = format
}
override fun format(timestamp: Long): String {
return format
}
}

View File

@@ -0,0 +1,43 @@
/*
* 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.fixtures
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.TimelineItemReactions
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrixtest.AN_EVENT_ID
import io.element.android.libraries.matrixtest.A_MESSAGE
import io.element.android.libraries.matrixtest.A_USER_ID
import io.element.android.libraries.matrixtest.A_USER_NAME
import kotlinx.collections.immutable.persistentListOf
internal fun aMessageEvent(
isMine: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null),
) = TimelineItem.Event(
id = AN_EVENT_ID.value,
eventId = AN_EVENT_ID,
senderId = A_USER_ID.value,
senderDisplayName = A_USER_NAME,
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME),
content = content,
sentTime = "",
isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf())
)

View File

@@ -14,16 +14,13 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.timeline
package io.element.android.features.messages.fixtures
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
// TODO Move to common module to reuse
fun testCoroutineDispatchers() = CoroutineDispatchers(
internal fun testCoroutineDispatchers() = CoroutineDispatchers(
io = UnconfinedTestDispatcher(),
computation = UnconfinedTestDispatcher(),
main = UnconfinedTestDispatcher(),

View File

@@ -0,0 +1,55 @@
/*
* 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.fixtures
import io.element.android.features.messages.fakes.FakeDaySeparatorFormatter
import io.element.android.features.messages.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.timeline.factories.event.TimelineItemContentFactory
import io.element.android.features.messages.timeline.factories.event.TimelineItemContentFailedToParseMessageFactory
import io.element.android.features.messages.timeline.factories.event.TimelineItemContentFailedToParseStateFactory
import io.element.android.features.messages.timeline.factories.event.TimelineItemContentMessageFactory
import io.element.android.features.messages.timeline.factories.event.TimelineItemContentProfileChangeFactory
import io.element.android.features.messages.timeline.factories.event.TimelineItemContentRedactedFactory
import io.element.android.features.messages.timeline.factories.event.TimelineItemContentRoomMembershipFactory
import io.element.android.features.messages.timeline.factories.event.TimelineItemContentStateFactory
import io.element.android.features.messages.timeline.factories.event.TimelineItemContentStickerFactory
import io.element.android.features.messages.timeline.factories.event.TimelineItemContentUTDFactory
import io.element.android.features.messages.timeline.factories.event.TimelineItemEventFactory
import io.element.android.features.messages.timeline.factories.virtual.TimelineItemDaySeparatorFactory
import io.element.android.features.messages.timeline.factories.virtual.TimelineItemVirtualFactory
internal fun aTimelineItemsFactory() = TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory(
TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
utdFactory = TimelineItemContentUTDFactory(),
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(),
profileChangeFactory = TimelineItemContentProfileChangeFactory(),
stateFactory = TimelineItemContentStateFactory(),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory()
)
),
virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory(
FakeDaySeparatorFormatter()
),
)
)

View File

@@ -22,10 +22,8 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.libraries.matrixtest.AN_EVENT_ID
import io.element.android.libraries.matrixtest.FakeMatrixClient
import io.element.android.libraries.matrixtest.room.FakeMatrixRoom
import io.element.android.libraries.matrixtest.timeline.FakeMatrixTimeline
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -36,52 +34,69 @@ class TimelinePresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = TimelinePresenter(
testCoroutineDispatchers(),
FakeMatrixClient(),
FakeMatrixRoom()
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.timelineItems).isEmpty()
val loadedNoTimelineState = awaitItem()
assertThat(loadedNoTimelineState.timelineItems).isEmpty()
}
}
@Test
fun `present - load more`() = runTest {
val matrixTimeline = FakeMatrixTimeline()
val matrixRoom = FakeMatrixRoom(matrixTimeline = matrixTimeline)
fun `present - makes sure timeline is initialized and disposed`() = runTest {
val fakeTimeline = FakeMatrixTimeline()
val presenter = TimelinePresenter(
testCoroutineDispatchers(),
FakeMatrixClient(),
matrixRoom
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(matrixTimeline = fakeTimeline),
)
assertThat(fakeTimeline.isInitialized).isFalse()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(2)
assertThat(fakeTimeline.isInitialized).isTrue()
}
assertThat(fakeTimeline.isInitialized).isFalse()
}
@Test
fun `present - load more`() = runTest {
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.hasMoreToLoad).isTrue()
matrixTimeline.givenHasMoreToLoad(false)
assertThat(initialState.paginationState.canBackPaginate).isTrue()
assertThat(initialState.paginationState.isBackPaginating).isFalse()
initialState.eventSink.invoke(TimelineEvents.LoadMore)
val loadedState = awaitItem()
assertThat(loadedState.hasMoreToLoad).isFalse()
val inPaginationState = awaitItem()
assertThat(inPaginationState.paginationState.isBackPaginating).isTrue()
assertThat(inPaginationState.paginationState.canBackPaginate).isTrue()
val postPaginationState = awaitItem()
assertThat(postPaginationState.paginationState.canBackPaginate).isTrue()
assertThat(postPaginationState.paginationState.isBackPaginating).isFalse()
}
}
@Test
fun `present - set highlighted event`() = runTest {
val matrixTimeline = FakeMatrixTimeline()
val matrixRoom = FakeMatrixRoom(matrixTimeline = matrixTimeline)
val presenter = TimelinePresenter(
testCoroutineDispatchers(),
FakeMatrixClient(),
matrixRoom
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
assertThat(initialState.highlightedEventId).isNull()
initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(AN_EVENT_ID))
val withHighlightedState = awaitItem()
@@ -91,26 +106,4 @@ class TimelinePresenterTest {
assertThat(withoutHighlightedState.highlightedEventId).isNull()
}
}
@Test
fun `present - test callback`() = runTest {
val matrixTimeline = FakeMatrixTimeline()
val matrixRoom = FakeMatrixRoom(matrixTimeline = matrixTimeline)
val presenter = TimelinePresenter(
testCoroutineDispatchers(),
FakeMatrixClient(),
matrixRoom
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.timelineItems).isEmpty()
// Simulate callback from the SDK
matrixTimeline.callback?.onPushedTimelineItem(MatrixTimelineItem.Virtual)
val nonEmptyState = awaitItem()
assertThat(nonEmptyState.timelineItems).isNotEmpty()
assertThat(nonEmptyState.timelineItems[0]).isEqualTo(TimelineItem.Virtual("virtual_item_0"))
}
}
}

View File

@@ -118,6 +118,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.2"
# Di
inject = "javax.inject:javax.inject:1"

View File

@@ -0,0 +1,21 @@
/*
* 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.libraries.dateformatter
interface DaySeparatorFormatter {
fun format(timestamp: Long): String
}

View File

@@ -0,0 +1,90 @@
/*
* 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.libraries.dateformatter.impl
import android.text.format.DateFormat
import android.text.format.DateUtils
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import java.time.Period
import java.time.format.DateTimeFormatter
import java.util.Locale
import javax.inject.Inject
import kotlin.math.absoluteValue
// TODO rework this date formatting
class DateFormatters @Inject constructor(
private val locale: Locale,
private val clock: Clock,
private val timeZone: TimeZone,
) {
private val onlyTimeFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") ?: "HH:mm"
DateTimeFormatter.ofPattern(pattern)
}
private val dateWithMonthFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") ?: "d MMM"
DateTimeFormatter.ofPattern(pattern)
}
private val dateWithYearFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") ?: "dd.MM.yyyy"
DateTimeFormatter.ofPattern(pattern)
}
internal fun formatTime(localDateTime: LocalDateTime): String {
return onlyTimeFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithMonth(localDateTime: LocalDateTime): String {
return dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithYear(localDateTime: LocalDateTime): String {
return dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDate(
dateToFormat: LocalDateTime,
currentDate: LocalDateTime,
useRelative: Boolean
): String {
val period = Period.between(dateToFormat.date.toJavaLocalDate(), currentDate.date.toJavaLocalDate())
return if (period.years.absoluteValue >= 1) {
formatDateWithYear(dateToFormat)
} else if (useRelative && period.days.absoluteValue < 2 && period.months.absoluteValue < 1) {
getRelativeDay(dateToFormat.toInstant(timeZone).toEpochMilliseconds())
} else {
formatDateWithMonth(dateToFormat)
}
}
private fun getRelativeDay(ts: Long): String {
return DateUtils.getRelativeTimeSpanString(
ts,
clock.now().toEpochMilliseconds(),
DateUtils.DAY_IN_MILLIS,
DateUtils.FORMAT_SHOW_WEEKDAY
)?.toString() ?: ""
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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.libraries.dateformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.dateformatter.DaySeparatorFormatter
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultDaySeparatorFormatter @Inject constructor(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) : DaySeparatorFormatter {
override fun format(timestamp: Long): String {
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
return dateFormatters.formatDateWithYear(dateToFormat)
}
}

View File

@@ -16,91 +16,33 @@
package io.element.android.libraries.dateformatter.impl
import android.text.format.DateFormat
import android.text.format.DateUtils
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.dateformatter.LastMessageFormatter
import io.element.android.libraries.di.AppScope
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toLocalDateTime
import java.time.Period
import java.time.format.DateTimeFormatter
import java.util.Locale
import javax.inject.Inject
import kotlin.math.absoluteValue
@ContributesBinding(AppScope::class)
class DefaultLastMessageFormatter @Inject constructor(
private val clock: Clock,
private val locale: Locale,
private val timezone: TimeZone,
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) : LastMessageFormatter {
private val onlyTimeFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") ?: "HH:mm"
DateTimeFormatter.ofPattern(pattern)
}
private val dateWithMonthFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") ?: "d MMM"
DateTimeFormatter.ofPattern(pattern)
}
private val dateWithYearFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") ?: "dd.MM.yyyy"
DateTimeFormatter.ofPattern(pattern)
}
override fun format(timestamp: Long?): String {
if (timestamp == null) return ""
val now: Instant = clock.now()
val tsInstant = Instant.fromEpochMilliseconds(timestamp)
val nowDateTime = now.toLocalDateTime(timezone)
val tsDateTime = tsInstant.toLocalDateTime(timezone)
val isSameDay = nowDateTime.date == tsDateTime.date
val currentDate = localDateTimeProvider.providesNow()
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
val isSameDay = currentDate.date == dateToFormat.date
return when {
isSameDay -> {
onlyTimeFormatter.format(tsDateTime.toJavaLocalDateTime())
dateFormatters.formatTime(dateToFormat)
}
else -> {
formatDate(tsDateTime, nowDateTime)
dateFormatters.formatDate(
dateToFormat = dateToFormat,
currentDate = currentDate,
useRelative = true
)
}
}
}
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).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() ?: ""
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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.libraries.dateformatter.impl
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import javax.inject.Inject
class LocalDateTimeProvider @Inject constructor(
private val clock: Clock,
private val timezone: TimeZone,
) {
fun providesNow(): LocalDateTime {
val now: Instant = clock.now()
return now.toLocalDateTime(timezone)
}
fun providesFromTimestamp(timestamp: Long): LocalDateTime {
val tsInstant = Instant.fromEpochMilliseconds(timestamp)
return tsInstant.toLocalDateTime(timezone)
}
}

View File

@@ -101,6 +101,8 @@ class DefaultLastMessageFormatterTest {
*/
private fun createFormatter(@Suppress("SameParameterValue") currentDate: String): LastMessageFormatter {
val clock = FakeClock().also { it.givenInstant(Instant.parse(currentDate)) }
return DefaultLastMessageFormatter(clock, Locale.US, TimeZone.UTC)
val localDateTimeProvider = LocalDateTimeProvider(clock, TimeZone.UTC)
val dateFormatters = DateFormatters(Locale.US, clock, TimeZone.UTC)
return DefaultLastMessageFormatter(localDateTimeProvider, dateFormatters)
}
}

View File

@@ -31,7 +31,8 @@ anvil {
}
dependencies {
api(projects.libraries.rustsdk)
// api(projects.libraries.rustsdk)
api(libs.matrix.sdk)
implementation(projects.libraries.di)
implementation(libs.dagger)
implementation(projects.libraries.core)

View File

@@ -36,13 +36,14 @@ import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.RequiredState
import org.matrix.rustcomponents.sdk.SlidingSyncMode
import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters
import org.matrix.rustcomponents.sdk.SlidingSyncViewBuilder
import org.matrix.rustcomponents.sdk.StoppableSpawn
import org.matrix.rustcomponents.sdk.TaskHandle
import timber.log.Timber
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
internal class RustMatrixClient internal constructor(
class RustMatrixClient constructor(
private val client: Client,
private val sessionStore: SessionStore,
private val coroutineScope: CoroutineScope,
@@ -66,25 +67,42 @@ internal class RustMatrixClient internal constructor(
}
}
private val slidingSyncView = SlidingSyncViewBuilder()
.timelineLimit(limit = 10u)
private val slidingSyncFilters by lazy {
SlidingSyncRequestListFilters(
isDm = null,
spaces = emptyList(),
isEncrypted = null,
isInvite = false,
isTombstoned = false,
roomTypes = emptyList(),
notRoomTypes = listOf("m.space"),
roomNameLike = null,
tags = emptyList(),
notTags = emptyList()
)
}
private val visibleRoomsView = SlidingSyncViewBuilder()
.timelineLimit(limit = 1u)
.requiredState(
requiredState = listOf(
RequiredState(key = "m.room.avatar", value = ""),
RequiredState(key = "m.room.encryption", value = ""),
)
)
.name(name = "HomeScreenView")
.filters(slidingSyncFilters)
.name(name = "CurrentlyVisibleRooms")
.sendUpdatesForItems(true)
.syncMode(mode = SlidingSyncMode.SELECTIVE)
.addRange(0u, 30u)
.addRange(0u, 20u)
.build()
private val slidingSync = client
.slidingSync()
.homeserver("https://slidingsync.lab.element.dev")
.homeserver("https://slidingsync.lab.matrix.org")
.withCommonExtensions()
// .coldCache("ElementX")
.addView(slidingSyncView)
.coldCache("ElementX")
.addView(visibleRoomsView)
.build()
private val slidingSyncObserverProxy = SlidingSyncObserverProxy(coroutineScope)
@@ -92,57 +110,58 @@ internal class RustMatrixClient internal constructor(
RustRoomSummaryDataSource(
slidingSyncObserverProxy.updateSummaryFlow,
slidingSync,
slidingSyncView,
visibleRoomsView,
dispatchers,
::onRestartSync
)
private var slidingSyncObserverToken: StoppableSpawn? = null
private var slidingSyncObserverToken: TaskHandle? = null
private val mediaResolver = RustMediaResolver(this)
private val isSyncing = AtomicBoolean(false)
init {
client.setDelegate(clientDelegate)
roomSummaryDataSource.init()
slidingSync.setObserver(slidingSyncObserverProxy)
}
private fun onRestartSync() {
slidingSyncObserverToken = slidingSync.sync()
stopSync()
startSync()
}
override fun getRoom(roomId: RoomId): MatrixRoom? {
val slidingSyncRoom = slidingSync.getRoom(roomId.value) ?: return null
val room = slidingSyncRoom.fullRoom() ?: return null
val fullRoom = slidingSyncRoom.fullRoom() ?: return null
return RustMatrixRoom(
slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow,
slidingSyncRoom = slidingSyncRoom,
room = room,
innerRoom = fullRoom,
coroutineScope = coroutineScope,
coroutineDispatchers = dispatchers
)
}
override fun startSync() {
if (isSyncing.compareAndSet(false, true)) {
roomSummaryDataSource.startSync()
slidingSync.setObserver(slidingSyncObserverProxy)
slidingSyncObserverToken = slidingSync.sync()
}
}
override fun stopSync() {
if (isSyncing.compareAndSet(true, false)) {
roomSummaryDataSource.stopSync()
slidingSync.setObserver(null)
slidingSyncObserverToken?.cancel()
}
}
override fun roomSummaryDataSource(): RoomSummaryDataSource = roomSummaryDataSource
override fun mediaResolver(): MediaResolver = mediaResolver
override fun startSync() {
if (client.isSoftLogout()) return
if (isSyncing.compareAndSet(false, true)) {
slidingSyncObserverToken = slidingSync.sync()
}
}
override fun stopSync() {
if (isSyncing.compareAndSet(true, false)) {
slidingSyncObserverToken?.cancel()
}
}
override fun close() {
stopSync()
slidingSync.setObserver(null)
roomSummaryDataSource.close()
client.setDelegate(null)
}

View File

@@ -31,6 +31,6 @@ object MatrixModule {
@Provides
@SingleIn(AppScope::class)
fun providesRustAuthenticationService(baseDirectory: File): AuthenticationService {
return AuthenticationService(baseDirectory.absolutePath)
return AuthenticationService(baseDirectory.absolutePath, null, null)
}
}

View File

@@ -33,6 +33,8 @@ interface MatrixRoom {
fun timeline(): MatrixTimeline
suspend fun fetchMembers(): Result<Unit>
suspend fun userDisplayName(userId: String): Result<String?>
suspend fun userAvatarUrl(userId: String): Result<String?>
@@ -44,4 +46,5 @@ interface MatrixRoom {
suspend fun replyMessage(eventId: EventId, message: String): Result<Unit>
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
}

View File

@@ -1,40 +0,0 @@
/*
* Copyright (c) 2022 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.libraries.matrix.room
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineListener
fun Room.timelineDiff(scope: CoroutineScope): Flow<TimelineDiff> = callbackFlow {
val listener = object : TimelineListener {
override fun onUpdate(update: TimelineDiff) {
scope.launch {
send(update)
}
}
}
addTimelineListener(listener)
awaitClose {
removeTimeline()
}
}

View File

@@ -22,7 +22,6 @@ import io.element.android.libraries.matrix.sync.state
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -38,7 +37,6 @@ import org.matrix.rustcomponents.sdk.SlidingSyncViewRoomsListDiff
import org.matrix.rustcomponents.sdk.UpdateSummary
import timber.log.Timber
import java.io.Closeable
import java.util.Collections
import java.util.UUID
interface RoomSummaryDataSource {
@@ -60,7 +58,7 @@ internal class RustRoomSummaryDataSource(
private val roomSummaries = MutableStateFlow<List<RoomSummary>>(emptyList())
private val state = MutableStateFlow(SlidingSyncState.COLD)
fun startSync() {
fun init() {
coroutineScope.launch {
updateRoomSummaries {
addAll(
@@ -89,10 +87,6 @@ internal class RustRoomSummaryDataSource(
}.launchIn(coroutineScope)
}
fun stopSync() {
coroutineScope.coroutineContext.cancelChildren()
}
override fun close() {
coroutineScope.cancel()
}
@@ -133,29 +127,45 @@ internal class RustRoomSummaryDataSource(
}
Timber.v("ApplyDiff: $diff for list with size: $size")
when (diff) {
is SlidingSyncViewRoomsListDiff.Push -> {
is SlidingSyncViewRoomsListDiff.Append -> {
val roomSummaries = diff.values.map {
buildSummaryForRoomListEntry(it)
}
addAll(roomSummaries)
}
is SlidingSyncViewRoomsListDiff.PushBack -> {
val roomSummary = buildSummaryForRoomListEntry(diff.value)
add(roomSummary)
}
is SlidingSyncViewRoomsListDiff.UpdateAt -> {
is SlidingSyncViewRoomsListDiff.PushFront -> {
val roomSummary = buildSummaryForRoomListEntry(diff.value)
add(0, roomSummary)
}
is SlidingSyncViewRoomsListDiff.Set -> {
fillUntil(diff.index.toInt())
val roomSummary = buildSummaryForRoomListEntry(diff.value)
set(diff.index.toInt(), roomSummary)
}
is SlidingSyncViewRoomsListDiff.InsertAt -> {
is SlidingSyncViewRoomsListDiff.Insert -> {
val roomSummary = buildSummaryForRoomListEntry(diff.value)
add(diff.index.toInt(), roomSummary)
}
is SlidingSyncViewRoomsListDiff.Move -> {
Collections.swap(this, diff.oldIndex.toInt(), diff.newIndex.toInt())
}
is SlidingSyncViewRoomsListDiff.RemoveAt -> {
is SlidingSyncViewRoomsListDiff.Remove -> {
removeAt(diff.index.toInt())
}
is SlidingSyncViewRoomsListDiff.Replace -> {
is SlidingSyncViewRoomsListDiff.Reset -> {
clear()
addAll(diff.values.map { buildSummaryForRoomListEntry(it) })
}
SlidingSyncViewRoomsListDiff.PopBack -> {
removeFirstOrNull()
}
SlidingSyncViewRoomsListDiff.PopFront -> {
removeLastOrNull()
}
SlidingSyncViewRoomsListDiff.Clear -> {
clear()
}
}
}
@@ -184,13 +194,4 @@ internal class RustRoomSummaryDataSource(
block(mutableRoomSummaries)
roomSummaries.value = mutableRoomSummaries
}
fun SlidingSyncViewRoomsListDiff.isInvalidation(): Boolean {
return when (this) {
is SlidingSyncViewRoomsListDiff.InsertAt -> this.value is RoomListEntry.Invalidated
is SlidingSyncViewRoomsListDiff.UpdateAt -> this.value is RoomListEntry.Invalidated
is SlidingSyncViewRoomsListDiff.Push -> this.value is RoomListEntry.Invalidated
else -> false
}
}
}

View File

@@ -32,11 +32,12 @@ import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.UpdateSummary
import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import timber.log.Timber
class RustMatrixRoom(
private val slidingSyncUpdateFlow: Flow<UpdateSummary>,
private val slidingSyncRoom: SlidingSyncRoom,
private val room: Room,
private val innerRoom: Room,
private val coroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixRoom {
@@ -44,7 +45,7 @@ class RustMatrixRoom(
override fun syncUpdateFlow(): Flow<Long> {
return slidingSyncUpdateFlow
.filter {
it.rooms.contains(room.id())
it.rooms.contains(innerRoom.id())
}
.map {
System.currentTimeMillis()
@@ -55,14 +56,14 @@ class RustMatrixRoom(
override fun timeline(): MatrixTimeline {
return RustMatrixTimeline(
matrixRoom = this,
room = room,
innerRoom = innerRoom,
slidingSyncRoom = slidingSyncRoom,
coroutineScope = coroutineScope,
coroutineDispatchers = coroutineDispatchers
)
}
override val roomId = RoomId(room.id())
override val roomId = RoomId(innerRoom.id())
override val name: String?
get() {
@@ -71,35 +72,41 @@ class RustMatrixRoom(
override val bestName: String
get() {
return name?.takeIf { it.isNotEmpty() } ?: room.id()
return name?.takeIf { it.isNotEmpty() } ?: innerRoom.id()
}
override val displayName: String
get() {
return room.displayName()
return innerRoom.displayName()
}
override val topic: String?
get() {
return room.topic()
return innerRoom.topic()
}
override val avatarUrl: String?
get() {
return room.avatarUrl()
return innerRoom.avatarUrl()
}
override suspend fun fetchMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.fetchMembers()
}
}
override suspend fun userDisplayName(userId: String): Result<String?> =
withContext(coroutineDispatchers.io) {
runCatching {
room.memberDisplayName(userId)
innerRoom.memberDisplayName(userId)
}
}
override suspend fun userAvatarUrl(userId: String): Result<String?> =
withContext(coroutineDispatchers.io) {
runCatching {
room.memberAvatarUrl(userId)
innerRoom.memberAvatarUrl(userId)
}
}
@@ -107,7 +114,7 @@ class RustMatrixRoom(
val transactionId = genTransactionId()
val content = messageEventContentFromMarkdown(message)
runCatching {
room.send(content, transactionId)
innerRoom.send(content, transactionId)
}
}
@@ -115,7 +122,7 @@ class RustMatrixRoom(
val transactionId = genTransactionId()
// val content = messageEventContentFromMarkdown(message)
runCatching {
room.edit(/* TODO use content */ message, originalEventId.value, transactionId)
innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId)
}
}
@@ -123,14 +130,14 @@ class RustMatrixRoom(
val transactionId = genTransactionId()
// val content = messageEventContentFromMarkdown(message)
runCatching {
room.sendReply(/* TODO use content */ message, eventId.value, transactionId)
innerRoom.sendReply(/* TODO use content */ message, eventId.value, transactionId)
}
}
override suspend fun redactEvent(eventId: EventId, reason: String?) = withContext(coroutineDispatchers.io) {
val transactionId = genTransactionId()
runCatching {
room.redact(eventId.value, reason, transactionId)
innerRoom.redact(eventId.value, reason, transactionId)
}
}
}

View File

@@ -27,7 +27,7 @@ class RoomMessageFactory {
eventId = EventId(eventTimelineItem.eventId() ?: ""),
body = eventTimelineItem.content().asMessage()?.body() ?: "",
sender = UserId(eventTimelineItem.sender()),
originServerTs = eventTimelineItem.originServerTs()?.toLong() ?: 0L
originServerTs = eventTimelineItem.timestamp().toLong()
)
}
}

View File

@@ -54,7 +54,8 @@ class PreferencesSessionStore @Inject constructor(
val homeserverUrl: String,
val isSoftLogout: Boolean,
val refreshToken: String?,
val userId: String
val userId: String,
val slidingSyncProxy: String?
)
private val store = context.dataStore
@@ -73,7 +74,8 @@ class PreferencesSessionStore @Inject constructor(
homeserverUrl = session.homeserverUrl,
isSoftLogout = session.isSoftLogout,
refreshToken = session.refreshToken,
userId = session.userId
userId = session.userId,
slidingSyncProxy = session.slidingSyncProxy
)
val encodedSession = Json.encodeToString(sessionData)
prefs[sessionKey] = encodedSession
@@ -90,7 +92,8 @@ class PreferencesSessionStore @Inject constructor(
homeserverUrl = sessionData.homeserverUrl,
isSoftLogout = sessionData.isSoftLogout,
refreshToken = sessionData.refreshToken,
userId = sessionData.userId
userId = sessionData.userId,
slidingSyncProxy = sessionData.slidingSyncProxy
)
}
}

View File

@@ -18,20 +18,19 @@ package io.element.android.libraries.matrix.timeline
import io.element.android.libraries.matrix.core.EventId
import kotlinx.coroutines.flow.Flow
import org.matrix.rustcomponents.sdk.TimelineListener
import kotlinx.coroutines.flow.StateFlow
interface MatrixTimeline {
var callback: Callback?
val hasMoreToLoad: Boolean
interface Callback {
fun onUpdatedTimelineItem(timelineItem: MatrixTimelineItem) = Unit
fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) = Unit
}
data class PaginationState(
val isBackPaginating: Boolean,
val canBackPaginate: Boolean
)
fun paginationState(): StateFlow<PaginationState>
fun timelineItems(): Flow<List<MatrixTimelineItem>>
suspend fun paginateBackwards(count: Int): Result<Unit>
fun addListener(timelineListener: TimelineListener)
suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit>
fun initialize()
fun dispose()

View File

@@ -0,0 +1,118 @@
/*
* 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.libraries.matrix.timeline
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.TimelineChange
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineListener
import org.matrix.rustcomponents.sdk.VirtualTimelineItem
internal class MatrixTimelineDiffProcessor(
private val paginationState: MutableStateFlow<MatrixTimeline.PaginationState>,
private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>>,
private val coroutineScope: CoroutineScope,
private val diffDispatcher: CoroutineDispatcher,
) : TimelineListener {
override fun onUpdate(update: TimelineDiff) {
coroutineScope.launch {
updateTimelineItems {
applyDiff(update)
}
when (val firstItem = timelineItems.value.firstOrNull()) {
is MatrixTimelineItem.Virtual -> updateBackPaginationState(firstItem.virtual)
else -> updateBackPaginationState(null)
}
}
}
private fun updateBackPaginationState(virtualItem: VirtualTimelineItem?) {
val currentPaginationState = paginationState.value
val newPaginationState = when (virtualItem) {
VirtualTimelineItem.LoadingIndicator -> currentPaginationState.copy(
isBackPaginating = true,
canBackPaginate = true
)
VirtualTimelineItem.TimelineStart -> currentPaginationState.copy(
isBackPaginating = false,
canBackPaginate = false
)
else -> currentPaginationState.copy(
isBackPaginating = false,
canBackPaginate = true
)
}
paginationState.value = newPaginationState
}
private suspend fun updateTimelineItems(block: MutableList<MatrixTimelineItem>.() -> Unit) =
withContext(diffDispatcher) {
val mutableTimelineItems = timelineItems.value.toMutableList()
block(mutableTimelineItems)
timelineItems.value = mutableTimelineItems
}
private fun MutableList<MatrixTimelineItem>.applyDiff(diff: TimelineDiff) {
when (diff.change()) {
TimelineChange.APPEND -> {
val items = diff.append()?.map { it.asMatrixTimelineItem() } ?: return
addAll(items)
}
TimelineChange.PUSH_BACK -> {
val item = diff.pushBack()?.asMatrixTimelineItem() ?: return
add(item)
}
TimelineChange.PUSH_FRONT -> {
val item = diff.pushFront()?.asMatrixTimelineItem() ?: return
add(0, item)
}
TimelineChange.SET -> {
val updateAtData = diff.set() ?: return
val item = updateAtData.item.asMatrixTimelineItem()
set(updateAtData.index.toInt(), item)
}
TimelineChange.INSERT -> {
val insertAtData = diff.insert() ?: return
val item = insertAtData.item.asMatrixTimelineItem()
add(insertAtData.index.toInt(), item)
}
TimelineChange.REMOVE -> {
val removeAtData = diff.remove() ?: return
removeAt(removeAtData.toInt())
}
TimelineChange.RESET -> {
clear()
val items = diff.reset()?.map { it.asMatrixTimelineItem() } ?: return
addAll(items)
}
TimelineChange.POP_FRONT -> {
removeFirstOrNull()
}
TimelineChange.POP_BACK -> {
removeLastOrNull()
}
TimelineChange.CLEAR -> {
clear()
}
}
}
}

View File

@@ -16,20 +16,18 @@
package io.element.android.libraries.matrix.timeline
import io.element.android.libraries.matrix.core.EventId
import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.TimelineItem
import org.matrix.rustcomponents.sdk.TimelineKey
import org.matrix.rustcomponents.sdk.VirtualTimelineItem
sealed interface MatrixTimelineItem {
data class Event(val event: EventTimelineItem) : MatrixTimelineItem {
val uniqueId: String
get() = when (val eventKey = event.key()) {
is TimelineKey.TransactionId -> eventKey.txnId
is TimelineKey.EventId -> eventKey.eventId
}
val uniqueId: String = event.uniqueIdentifier()
val eventId: EventId? = event.eventId()?.let { EventId(it) }
}
object Virtual : MatrixTimelineItem
data class Virtual(val virtual: VirtualTimelineItem) : MatrixTimelineItem
object Other : MatrixTimelineItem
}
@@ -40,7 +38,7 @@ fun TimelineItem.asMatrixTimelineItem(): MatrixTimelineItem {
}
val asVirtual = asVirtual()
if (asVirtual != null) {
return MatrixTimelineItem.Virtual
return MatrixTimelineItem.Virtual(asVirtual)
}
return MatrixTimelineItem.Other
}

View File

@@ -18,121 +18,83 @@ package io.element.android.libraries.matrix.timeline
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.core.EventId
import io.element.android.libraries.matrix.room.RustMatrixRoom
import io.element.android.libraries.matrix.room.MatrixRoom
import io.element.android.libraries.matrix.util.TaskHandleBag
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.PaginationOutcome
import org.matrix.rustcomponents.sdk.PaginationOptions
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.TimelineChange
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineItem
import org.matrix.rustcomponents.sdk.TimelineListener
import timber.log.Timber
import java.util.Collections
class RustMatrixTimeline(
private val matrixRoom: RustMatrixRoom,
private val room: Room,
private val matrixRoom: MatrixRoom,
private val innerRoom: Room,
private val slidingSyncRoom: SlidingSyncRoom,
private val coroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
) : TimelineListener, MatrixTimeline {
) : MatrixTimeline {
override var callback: MatrixTimeline.Callback? = null
private val paginationOutcome = MutableStateFlow(PaginationOutcome(true))
private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
MutableStateFlow(emptyList())
private val paginationState = MutableStateFlow(
MatrixTimeline.PaginationState(canBackPaginate = true, isBackPaginating = false)
)
private val innerTimelineListener = MatrixTimelineDiffProcessor(
paginationState = paginationState,
timelineItems = timelineItems,
coroutineScope = coroutineScope,
diffDispatcher = coroutineDispatchers.diffUpdateDispatcher
)
private val listenerTokens = TaskHandleBag()
override fun paginationState(): StateFlow<MatrixTimeline.PaginationState> {
return paginationState
}
@OptIn(FlowPreview::class)
override fun timelineItems(): Flow<List<MatrixTimelineItem>> {
return timelineItems.sample(50)
}
override val hasMoreToLoad: Boolean
get() {
return paginationOutcome.value.moreMessages
}
private fun MutableList<MatrixTimelineItem>.applyDiff(diff: TimelineDiff) {
when (diff.change()) {
TimelineChange.PUSH -> {
Timber.v("Apply push on list with size: $size")
val item = diff.push()?.asMatrixTimelineItem() ?: return
callback?.onPushedTimelineItem(item)
add(item)
}
TimelineChange.UPDATE_AT -> {
val updateAtData = diff.updateAt() ?: return
Timber.v("Apply $updateAtData on list with size: $size")
val item = updateAtData.item.asMatrixTimelineItem()
callback?.onUpdatedTimelineItem(item)
set(updateAtData.index.toInt(), item)
}
TimelineChange.INSERT_AT -> {
val insertAtData = diff.insertAt() ?: return
Timber.v("Apply $insertAtData on list with size: $size")
val item = insertAtData.item.asMatrixTimelineItem()
add(insertAtData.index.toInt(), item)
}
TimelineChange.MOVE -> {
val moveData = diff.move() ?: return
Timber.v("Apply $moveData on list with size: $size")
Collections.swap(this, moveData.oldIndex.toInt(), moveData.newIndex.toInt())
}
TimelineChange.REMOVE_AT -> {
val removeAtData = diff.removeAt() ?: return
Timber.v("Apply $removeAtData on list with size: $size")
removeAt(removeAtData.toInt())
}
TimelineChange.REPLACE -> {
Timber.v("Apply REPLACE on list with size: $size")
clear()
val items = diff.replace()?.map { it.asMatrixTimelineItem() } ?: return
addAll(items)
}
TimelineChange.POP -> {
Timber.v("Apply POP on list with size: $size")
removeLast()
}
TimelineChange.CLEAR -> {
Timber.v("Apply CLEAR on list with size: $size")
clear()
}
}
}
override suspend fun paginateBackwards(count: Int): Result<Unit> = withContext(coroutineDispatchers.io) {
if (!paginationOutcome.value.moreMessages) {
return@withContext Result.failure(IllegalStateException("no more message"))
}
runCatching {
paginationOutcome.value = room.paginateBackwards(count.toUShort())
}
}
private suspend fun updateTimelineItems(block: MutableList<MatrixTimelineItem>.() -> Unit) =
withContext(coroutineDispatchers.diffUpdateDispatcher) {
val mutableTimelineItems = timelineItems.value.toMutableList()
block(mutableTimelineItems)
timelineItems.value = mutableTimelineItems
}
override fun addListener(timelineListener: TimelineListener) {
slidingSyncRoom.addTimelineListener(timelineListener)
}
override fun initialize() {
addListener(this)
Timber.v("Init timeline for room ${matrixRoom.roomId}")
coroutineScope.launch {
matrixRoom.fetchMembers()
.onFailure {
Timber.e(it, "Fail to fetch members for room ${matrixRoom.roomId}")
}.onSuccess {
Timber.v("Success fetching members for room ${matrixRoom.roomId}")
}
}
coroutineScope.launch {
val result = addListener(innerTimelineListener)
result
.onSuccess { timelineItems ->
val matrixTimelineItems = timelineItems.map { it.asMatrixTimelineItem() }
withContext(coroutineDispatchers.diffUpdateDispatcher) {
this@RustMatrixTimeline.timelineItems.value = matrixTimelineItems
}
}
.onFailure {
Timber.e("Failed adding timeline listener on room with identifier: ${matrixRoom.roomId})")
}
}
}
override fun dispose() {
slidingSyncRoom.removeTimeline()
Timber.v("Dispose timeline for room ${matrixRoom.roomId}")
listenerTokens.dispose()
}
/**
@@ -150,11 +112,26 @@ class RustMatrixTimeline(
return matrixRoom.replyMessage(inReplyToEventId, message)
}
override fun onUpdate(update: TimelineDiff) {
coroutineScope.launch {
updateTimelineItems {
applyDiff(update)
}
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
Timber.v("Start back paginating for room ${matrixRoom.roomId} ")
val paginationOptions = PaginationOptions.UntilNumItems(
eventLimit = requestSize.toUShort(),
items = untilNumberOfItems.toUShort()
)
innerRoom.paginateBackwards(paginationOptions)
}.onFailure {
Timber.e(it, "Fail to paginate for room ${matrixRoom.roomId}")
}.onSuccess {
Timber.v("Success back paginating for room ${matrixRoom.roomId}")
}
}
private suspend fun addListener(timelineListener: TimelineListener): Result<List<TimelineItem>> = withContext(coroutineDispatchers.io) {
runCatching {
val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, null)
listenerTokens += result.taskHandle
result.items
}
}
}

View File

@@ -16,49 +16,73 @@
package io.element.android.libraries.matrix.tracing
import timber.log.Timber
data class TracingConfiguration(
val common: LogLevel = LogLevel.Warn,
val targets: Map<Target, LogLevel> = emptyMap()
val overrides: Map<Target, LogLevel> = emptyMap()
) {
val filter = "$common,${
targets.map { "${it.key.filter}=${it.value.filter}" }.joinToString(separator = ",")
}"
// Order should matters
private val targets: MutableMap<Target, LogLevel> = mutableMapOf(
Target.Common to LogLevel.Warn,
Target.Hyper to LogLevel.Warn,
Target.Sled to LogLevel.Warn,
Target.MatrixSdk.Root to LogLevel.Warn,
Target.MatrixSdk.Sled to LogLevel.Warn,
Target.MatrixSdk.Crypto to LogLevel.Debug,
Target.MatrixSdk.HttpClient to LogLevel.Debug,
Target.MatrixSdk.SlidingSync to LogLevel.Trace,
Target.MatrixSdk.BaseSlidingSync to LogLevel.Trace,
)
sealed class Target(open val filter: String) {
object Hyper : Target("hyper")
object Sled : Target("sled")
sealed class MatrixSdk(override val filter: String) : Target(filter) {
object Root : MatrixSdk("matrix_sdk")
object Sled : MatrixSdk("matrix_sdk_sled")
object FFI : MatrixSdk("matrix_sdk_ffi")
object HttpClient : MatrixSdk("matrix_sdk::http_client")
object UniffiAPI : MatrixSdk("matrix_sdk_ffi::uniffi_api")
object SlidingSync : MatrixSdk("matrix_sdk::sliding_sync")
object BaseSlidingSync : MatrixSdk("matrix_sdk_base::sliding_sync")
val filter: String
get() {
overrides.forEach { (target, logLevel) ->
targets[target] = logLevel
}
return targets.map {
if (it.key.filter.isEmpty()) {
it.value.filter
} else {
"${it.key.filter}=${it.value.filter}"
}
}.joinToString(separator = ",")
}
}
}
sealed class LogLevel(val filter: String) {
object Warn : LogLevel("warn")
object Trace : LogLevel("trace")
object Info : LogLevel("info")
object Debug : LogLevel("debug")
object Error : LogLevel("error")
sealed class Target(open val filter: String) {
object Common : Target("")
object Hyper : Target("hyper")
object Sled : Target("sled")
sealed class MatrixSdk(override val filter: String) : Target(filter) {
object Root : MatrixSdk("matrix_sdk")
object Sled : MatrixSdk("matrix_sdk_sled")
object Crypto: MatrixSdk("matrix_sdk_crypto")
object FFI : MatrixSdk("matrix_sdk_ffi")
object HttpClient : MatrixSdk("matrix_sdk::http_client")
object UniffiAPI : MatrixSdk("matrix_sdk_ffi::uniffi_api")
object SlidingSync : MatrixSdk("matrix_sdk::sliding_sync")
object BaseSlidingSync : MatrixSdk("matrix_sdk_base::sliding_sync")
}
}
sealed class LogLevel(val filter: String) {
object Warn : LogLevel("warn")
object Trace : LogLevel("trace")
object Info : LogLevel("info")
object Debug : LogLevel("debug")
object Error : LogLevel("error")
}
fun setupTracing(tracingConfiguration: TracingConfiguration) {
org.matrix.rustcomponents.sdk.setupTracing(tracingConfiguration.filter)
val filter = tracingConfiguration.filter
Timber.v("Tracing config filter = $filter")
org.matrix.rustcomponents.sdk.setupTracing(filter)
}
object TracingConfigurations {
val release = TracingConfiguration(common = TracingConfiguration.LogLevel.Info)
val debug = TracingConfiguration()
val full = TracingConfiguration(
common = TracingConfiguration.LogLevel.Info,
targets = mapOf(
TracingConfiguration.Target.Sled to TracingConfiguration.LogLevel.Warn
)
)
val release = TracingConfiguration(overrides = mapOf(Target.Common to LogLevel.Info))
val debug = TracingConfiguration(overrides = mapOf(Target.Common to LogLevel.Info))
fun custom(overrides: Map<Target, LogLevel>) = TracingConfiguration(overrides)
}

View File

@@ -19,12 +19,13 @@ package io.element.android.libraries.matrix.util
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import org.matrix.rustcomponents.sdk.StoppableSpawn
import org.matrix.rustcomponents.sdk.TaskHandle
internal fun <T> mxCallbackFlow(block: suspend ProducerScope<T>.() -> StoppableSpawn) =
internal fun <T> mxCallbackFlow(block: suspend ProducerScope<T>.() -> TaskHandle) =
callbackFlow {
val token: StoppableSpawn = block(this)
val token: TaskHandle = block(this)
awaitClose {
token.cancel()
token.destroy()
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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.libraries.matrix.util
import org.matrix.rustcomponents.sdk.TaskHandle
import java.util.concurrent.CopyOnWriteArraySet
class TaskHandleBag(private val tokens: MutableSet<TaskHandle> = CopyOnWriteArraySet()) : Set<TaskHandle> by tokens {
operator fun plusAssign(taskHandle: TaskHandle?) {
if (taskHandle == null) return
tokens += taskHandle
}
fun dispose() {
tokens.forEach { it.cancel() }
tokens.clear()
}
}

View File

@@ -44,6 +44,10 @@ class FakeMatrixRoom(
return matrixTimeline
}
override suspend fun fetchMembers(): Result<Unit> {
return Result.success(Unit)
}
override suspend fun userDisplayName(userId: String): Result<String?> {
return Result.success("")
}

View File

@@ -21,35 +21,55 @@ import io.element.android.libraries.matrix.timeline.MatrixTimeline
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import org.matrix.rustcomponents.sdk.TimelineListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeMatrixTimeline : MatrixTimeline {
override var callback: MatrixTimeline.Callback? = null
class FakeMatrixTimeline(
initialTimelineItems: List<MatrixTimelineItem> = emptyList(),
initialPaginationState: MatrixTimeline.PaginationState = MatrixTimeline.PaginationState(canBackPaginate = true, isBackPaginating = false)
) : MatrixTimeline {
private var hasMoreToLoadValue: Boolean = true
private val paginationState: MutableStateFlow<MatrixTimeline.PaginationState> = MutableStateFlow(initialPaginationState)
private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>> = MutableStateFlow(initialTimelineItems)
var isInitialized = false
fun givenHasMoreToLoad(hasMoreToLoad: Boolean) {
this.hasMoreToLoadValue = hasMoreToLoad
fun updatePaginationState(update: (MatrixTimeline.PaginationState.() -> MatrixTimeline.PaginationState)) {
paginationState.value = update(paginationState.value)
}
override val hasMoreToLoad: Boolean
get() = hasMoreToLoadValue
fun updateTimelineItems(update: (items: List<MatrixTimelineItem>) -> List<MatrixTimelineItem>) {
timelineItems.value = update(timelineItems.value)
}
override fun paginationState(): StateFlow<MatrixTimeline.PaginationState> {
return paginationState
}
override fun timelineItems(): Flow<List<MatrixTimelineItem>> {
return emptyFlow()
return timelineItems
}
override suspend fun paginateBackwards(count: Int): Result<Unit> {
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> {
updatePaginationState {
copy(isBackPaginating = true)
}
delay(100)
updatePaginationState {
copy(isBackPaginating = false)
}
updateTimelineItems { timelineItems ->
timelineItems
}
return Result.success(Unit)
}
override fun addListener(timelineListener: TimelineListener) = Unit
override fun initialize() {
isInitialized = true
}
override fun initialize() = Unit
override fun dispose() = Unit
override fun dispose() {
isInitialized = false
}
override suspend fun sendMessage(message: String): Result<Unit> {
return Result.success(Unit)

View File

@@ -28,4 +28,5 @@ dependencies {
implementation(libs.android.gradle.plugin)
implementation(libs.kotlin.gradle.plugin)
implementation(libs.firebase.gradle.plugin)
implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
}

View File

@@ -18,8 +18,10 @@ package extension
import Versions
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import java.io.File
import org.gradle.accessors.dm.LibrariesForLibs
fun CommonExtension<*, *, *, *>.androidConfig(project: Project) {
defaultConfig {
@@ -30,6 +32,8 @@ fun CommonExtension<*, *, *, *>.androidConfig(project: Project) {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
testOptions {
@@ -44,13 +48,14 @@ fun CommonExtension<*, *, *, *>.androidConfig(project: Project) {
}
}
fun CommonExtension<*, *, *, *>.composeConfig() {
fun CommonExtension<*, *, *, *>.composeConfig(libs: LibrariesForLibs) {
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.2" // libs.versions.composecompiler.get()
kotlinCompilerExtensionVersion = libs.versions.composecompiler.get()
}
packagingOptions {

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