Merge pull request #112 from vector-im/feature/fga/update_rust_sdk
Feature/fga/update rust sdk
This commit is contained in:
commit
4e1d76cfc1
124 changed files with 2480 additions and 937 deletions
|
|
@ -26,7 +26,7 @@ maestro test \
|
||||||
-e USERNAME=user \
|
-e USERNAME=user \
|
||||||
-e PASSWORD=123 \
|
-e PASSWORD=123 \
|
||||||
-e ROOM_NAME="my room" \
|
-e ROOM_NAME="my room" \
|
||||||
.maestro/allTest.yaml
|
.maestro/allTests.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Output
|
### Output
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ Come chat with the community in the dedicated Matrix [room](https://matrix.to/#/
|
||||||
## Build instructions
|
## Build instructions
|
||||||
|
|
||||||
Just clone the project and open it in Android Studio.
|
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
|
## Support
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,9 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "io.element.android.x"
|
applicationId = "io.element.android.x"
|
||||||
targetSdk = 33 // TODO Use Versions.targetSdk
|
targetSdk = Versions.targetSdk
|
||||||
versionCode = 1
|
versionCode = Versions.versionCode
|
||||||
versionName = "1.0"
|
versionName = Versions.versionName
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|
@ -109,24 +109,9 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "1.8"
|
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
|
// Waiting for https://github.com/google/ksp/issues/37
|
||||||
applicationVariants.all {
|
applicationVariants.all {
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,7 @@
|
||||||
android:theme="@style/Theme.ElementX.Splash"
|
android:theme="@style/Theme.ElementX.Splash"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize">
|
||||||
tools:ignore="LockedOrientationActivity">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
import io.element.android.libraries.matrix.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.auth.MatrixAuthenticationService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
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 {
|
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
|
||||||
loggedInState.value = LoggedInState.LoggingIn
|
loggedInState.value = LoggedInState.LoggingIn
|
||||||
try {
|
//TODO rework the setHomeserver flow
|
||||||
|
tryOrNull {
|
||||||
authenticationService.setHomeserver(homeserver)
|
authenticationService.setHomeserver(homeserver)
|
||||||
|
}
|
||||||
|
try {
|
||||||
val sessionId = authenticationService.login(formState.login.trim(), formState.password.trim())
|
val sessionId = authenticationService.login(formState.login.trim(), formState.password.trim())
|
||||||
loggedInState.value = LoggedInState.LoggedIn(sessionId)
|
loggedInState.value = LoggedInState.LoggedIn(sessionId)
|
||||||
} catch (failure: Throwable) {
|
} catch (failure: Throwable) {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ dependencies {
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.textcomposer)
|
implementation(projects.libraries.textcomposer)
|
||||||
|
implementation(projects.libraries.dateformatter)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.datetime)
|
implementation(libs.datetime)
|
||||||
implementation(libs.accompanist.flowlayout)
|
implementation(libs.accompanist.flowlayout)
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,5 @@ 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.TimelineItem
|
||||||
|
|
||||||
sealed interface MessagesEvents {
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.TimelineEvents
|
||||||
import io.element.android.features.messages.timeline.TimelinePresenter
|
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.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.architecture.Presenter
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
|
@ -79,7 +79,7 @@ class MessagesPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
fun handleEvents(event: MessagesEvents) {
|
fun handleEvents(event: MessagesEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.messageEvent, composerState)
|
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.event, composerState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return MessagesState(
|
return MessagesState(
|
||||||
|
|
@ -95,7 +95,7 @@ class MessagesPresenter @Inject constructor(
|
||||||
|
|
||||||
fun CoroutineScope.handleTimelineAction(
|
fun CoroutineScope.handleTimelineAction(
|
||||||
action: TimelineItemAction,
|
action: TimelineItemAction,
|
||||||
targetEvent: TimelineItem.MessageEvent,
|
targetEvent: TimelineItem.Event,
|
||||||
composerState: MessageComposerState,
|
composerState: MessageComposerState,
|
||||||
) = launch {
|
) = launch {
|
||||||
when (action) {
|
when (action) {
|
||||||
|
|
@ -111,13 +111,15 @@ class MessagesPresenter @Inject constructor(
|
||||||
Timber.v("NotImplementedYet")
|
Timber.v("NotImplementedYet")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleActionRedact(event: TimelineItem.MessageEvent) {
|
private suspend fun handleActionRedact(event: TimelineItem.Event) {
|
||||||
room.redactEvent(event.id)
|
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(
|
val composerMode = MessageComposerMode.Edit(
|
||||||
targetEvent.id,
|
targetEvent.eventId,
|
||||||
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty()
|
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty()
|
||||||
)
|
)
|
||||||
composerState.eventSink(
|
composerState.eventSink(
|
||||||
|
|
@ -125,8 +127,9 @@ class MessagesPresenter @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleActionReply(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) {
|
private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
|
||||||
val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, "")
|
if (targetEvent.eventId == null) return
|
||||||
|
val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.eventId, "")
|
||||||
composerState.eventSink(
|
composerState.eventSink(
|
||||||
MessageComposerEvents.SetMode(composerMode)
|
MessageComposerEvents.SetMode(composerMode)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ fun aMessagesState() = MessagesState(
|
||||||
),
|
),
|
||||||
timelineState = aTimelineState().copy(
|
timelineState = aTimelineState().copy(
|
||||||
timelineItems = aTimelineItemList(aTimelineItemContent()),
|
timelineItems = aTimelineItemList(aTimelineItemContent()),
|
||||||
hasMoreToLoad = false,
|
|
||||||
),
|
),
|
||||||
actionListState = anActionListState(),
|
actionListState = anActionListState(),
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
|
|
|
||||||
|
|
@ -88,21 +88,21 @@ fun MessagesView(
|
||||||
|
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Content")
|
LogCompositions(tag = "MessagesScreen", msg = "Content")
|
||||||
|
|
||||||
fun onMessageClicked(messageEvent: TimelineItem.MessageEvent) {
|
fun onMessageClicked(event: TimelineItem.Event) {
|
||||||
Timber.v("OnMessageClicked= ${messageEvent.id}")
|
Timber.v("OnMessageClicked= ${event.id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageLongClicked(messageEvent: TimelineItem.MessageEvent) {
|
fun onMessageLongClicked(event: TimelineItem.Event) {
|
||||||
Timber.v("OnMessageLongClicked= ${messageEvent.id}")
|
Timber.v("OnMessageLongClicked= ${event.id}")
|
||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(messageEvent))
|
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
itemActionsBottomSheetState.show()
|
itemActionsBottomSheetState.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onActionSelected(action: TimelineItemAction, messageEvent: TimelineItem.MessageEvent) {
|
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
||||||
state.eventSink(MessagesEvents.HandleAction(action, messageEvent))
|
state.eventSink(MessagesEvents.HandleAction(action, event))
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -142,8 +142,8 @@ fun MessagesView(
|
||||||
fun MessagesViewContent(
|
fun MessagesViewContent(
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {},
|
onMessageClicked: (TimelineItem.Event) -> Unit = {},
|
||||||
onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {},
|
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,5 @@ import io.element.android.features.messages.timeline.model.TimelineItem
|
||||||
|
|
||||||
sealed interface ActionListEvents {
|
sealed interface ActionListEvents {
|
||||||
object Clear : ActionListEvents
|
object Clear : ActionListEvents
|
||||||
data class ComputeForMessage(val messageEvent: TimelineItem.MessageEvent) : ActionListEvents
|
data class ComputeForMessage(val event: TimelineItem.Event) : ActionListEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import io.element.android.features.messages.actionlist.model.TimelineItemAction
|
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.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 io.element.android.libraries.architecture.Presenter
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -43,7 +43,7 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
|
||||||
fun handleEvents(event: ActionListEvents) {
|
fun handleEvents(event: ActionListEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
ActionListEvents.Clear -> target.value = ActionListState.Target.None
|
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)
|
target.value = ActionListState.Target.Loading(timelineItem)
|
||||||
val actions =
|
val actions =
|
||||||
if (timelineItem.content is TimelineItemRedactedContent) {
|
if (timelineItem.content is TimelineItemRedactedContent) {
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ data class ActionListState(
|
||||||
) {
|
) {
|
||||||
sealed interface Target {
|
sealed interface Target {
|
||||||
object None : Target
|
object None : Target
|
||||||
data class Loading(val messageEvent: TimelineItem.MessageEvent) : Target
|
data class Loading(val event: TimelineItem.Event) : Target
|
||||||
data class Success(
|
data class Success(
|
||||||
val messageEvent: TimelineItem.MessageEvent,
|
val event: TimelineItem.Event,
|
||||||
val actions: ImmutableList<TimelineItemAction>,
|
val actions: ImmutableList<TimelineItemAction>,
|
||||||
) : Target
|
) : Target
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,17 @@ package io.element.android.features.messages.actionlist
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.messages.actionlist.model.TimelineItemAction
|
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
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
||||||
override val values: Sequence<ActionListState>
|
override val values: Sequence<ActionListState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
anActionListState(),
|
anActionListState(),
|
||||||
anActionListState().copy(target = ActionListState.Target.Loading(aMessageEvent())),
|
anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())),
|
||||||
anActionListState().copy(
|
anActionListState().copy(
|
||||||
target = ActionListState.Target.Success(
|
target = ActionListState.Target.Success(
|
||||||
messageEvent = aMessageEvent(),
|
event = aTimelineItemEvent(),
|
||||||
actions = persistentListOf(
|
actions = persistentListOf(
|
||||||
TimelineItemAction.Reply,
|
TimelineItemAction.Reply,
|
||||||
TimelineItemAction.Forward,
|
TimelineItemAction.Forward,
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ import kotlinx.coroutines.launch
|
||||||
fun ActionListView(
|
fun ActionListView(
|
||||||
state: ActionListState,
|
state: ActionListState,
|
||||||
modalBottomSheetState: ModalBottomSheetState,
|
modalBottomSheetState: ModalBottomSheetState,
|
||||||
onActionSelected: (action: TimelineItemAction, TimelineItem.MessageEvent) -> Unit,
|
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
@ -67,7 +67,7 @@ fun ActionListView(
|
||||||
|
|
||||||
fun onItemActionClicked(
|
fun onItemActionClicked(
|
||||||
itemAction: TimelineItemAction,
|
itemAction: TimelineItemAction,
|
||||||
targetItem: TimelineItem.MessageEvent
|
targetItem: TimelineItem.Event
|
||||||
) {
|
) {
|
||||||
onActionSelected(itemAction, targetItem)
|
onActionSelected(itemAction, targetItem)
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
@ -94,7 +94,7 @@ fun ActionListView(
|
||||||
private fun SheetContent(
|
private fun SheetContent(
|
||||||
state: ActionListState,
|
state: ActionListState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onActionClicked: (TimelineItemAction, TimelineItem.MessageEvent) -> Unit = { _, _ -> },
|
onActionClicked: (TimelineItemAction, TimelineItem.Event) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
when (val target = state.target) {
|
when (val target = state.target) {
|
||||||
is ActionListState.Target.Loading,
|
is ActionListState.Target.Loading,
|
||||||
|
|
@ -112,7 +112,7 @@ private fun SheetContent(
|
||||||
) { action ->
|
) { action ->
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
onActionClicked(action, target.messageEvent)
|
onActionClicked(action, target.event)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -24,61 +24,46 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
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.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.core.EventId
|
||||||
import io.element.android.libraries.matrix.room.MatrixRoom
|
import io.element.android.libraries.matrix.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.timeline.MatrixTimeline
|
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.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val PAGINATION_COUNT = 50
|
private const val backPaginationEventLimit = 20
|
||||||
|
private const val backPaginationPageSize = 50
|
||||||
|
|
||||||
class TimelinePresenter @Inject constructor(
|
class TimelinePresenter @Inject constructor(
|
||||||
coroutineDispatchers: CoroutineDispatchers,
|
private val timelineItemsFactory: TimelineItemsFactory,
|
||||||
client: MatrixClient,
|
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
) : Presenter<TimelineState> {
|
) : Presenter<TimelineState> {
|
||||||
|
|
||||||
private val timeline = room.timeline()
|
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
|
@Composable
|
||||||
override fun present(): TimelineState {
|
override fun present(): TimelineState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val hasMoreToLoad = rememberSaveable {
|
|
||||||
mutableStateOf(timeline.hasMoreToLoad)
|
|
||||||
}
|
|
||||||
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
|
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
|
||||||
mutableStateOf(null)
|
mutableStateOf(null)
|
||||||
}
|
}
|
||||||
val timelineItems = timelineItemsFactory
|
val timelineItems = timelineItemsFactory
|
||||||
.flow()
|
.flow()
|
||||||
.collectAsState(emptyList())
|
.collectAsState()
|
||||||
|
|
||||||
|
val paginationState = timeline
|
||||||
|
.paginationState()
|
||||||
|
.collectAsState()
|
||||||
|
|
||||||
fun handleEvents(event: TimelineEvents) {
|
fun handleEvents(event: TimelineEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(hasMoreToLoad)
|
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value)
|
||||||
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,28 +72,34 @@ class TimelinePresenter @Inject constructor(
|
||||||
timeline
|
timeline
|
||||||
.timelineItems()
|
.timelineItems()
|
||||||
.onEach(timelineItemsFactory::replaceWith)
|
.onEach(timelineItemsFactory::replaceWith)
|
||||||
|
.onEach { timelineItems ->
|
||||||
|
if (timelineItems.isEmpty()) {
|
||||||
|
loadMore(paginationState.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
timeline.callback = TimelineCallback(localCoroutineScope, timelineItemsFactory)
|
|
||||||
timeline.initialize()
|
timeline.initialize()
|
||||||
onDispose {
|
onDispose {
|
||||||
timeline.callback = null
|
|
||||||
timeline.dispose()
|
timeline.dispose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return TimelineState(
|
return TimelineState(
|
||||||
highlightedEventId = highlightedEventId.value,
|
highlightedEventId = highlightedEventId.value,
|
||||||
|
paginationState = paginationState.value,
|
||||||
timelineItems = timelineItems.value.toImmutableList(),
|
timelineItems = timelineItems.value.toImmutableList(),
|
||||||
hasMoreToLoad = hasMoreToLoad.value,
|
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.loadMore(hasMoreToLoad: MutableState<Boolean>) = launch {
|
private fun CoroutineScope.loadMore(paginationState: MatrixTimeline.PaginationState) = launch {
|
||||||
timeline.paginateBackwards(PAGINATION_COUNT)
|
if (paginationState.canBackPaginate && !paginationState.isBackPaginating) {
|
||||||
hasMoreToLoad.value = timeline.hasMoreToLoad
|
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
|
||||||
|
} else {
|
||||||
|
Timber.v("Can't back paginate as paginationState = $paginationState")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,13 @@ package io.element.android.features.messages.timeline
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.timeline.model.TimelineItem
|
import io.element.android.features.messages.timeline.model.TimelineItem
|
||||||
import io.element.android.libraries.matrix.core.EventId
|
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.ImmutableList
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class TimelineState(
|
data class TimelineState(
|
||||||
val timelineItems: ImmutableList<TimelineItem>,
|
val timelineItems: ImmutableList<TimelineItem>,
|
||||||
val hasMoreToLoad: Boolean,
|
|
||||||
val highlightedEventId: EventId?,
|
val highlightedEventId: EventId?,
|
||||||
|
val paginationState: MatrixTimeline.PaginationState,
|
||||||
val eventSink: (TimelineEvents) -> Unit
|
val eventSink: (TimelineEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,53 +17,54 @@
|
||||||
package io.element.android.features.messages.timeline
|
package io.element.android.features.messages.timeline
|
||||||
|
|
||||||
import io.element.android.features.messages.timeline.model.AggregatedReaction
|
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.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.TimelineItemReactions
|
||||||
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.content.TimelineItemTextContent
|
import io.element.android.features.messages.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.matrix.core.EventId
|
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.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
fun aTimelineState() = TimelineState(
|
fun aTimelineState() = TimelineState(
|
||||||
timelineItems = persistentListOf(),
|
timelineItems = persistentListOf(),
|
||||||
hasMoreToLoad = false,
|
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
|
||||||
highlightedEventId = null,
|
highlightedEventId = null,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun aTimelineItemList(content: TimelineItemContent): ImmutableList<TimelineItem> {
|
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
||||||
return persistentListOf(
|
return persistentListOf(
|
||||||
// 3 items (First Middle Last) with isMine = false
|
// 3 items (First Middle Last) with isMine = false
|
||||||
aMessageEvent(
|
aTimelineItemEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
content = content,
|
content = content,
|
||||||
groupPosition = TimelineItemGroupPosition.Last
|
groupPosition = TimelineItemGroupPosition.Last
|
||||||
),
|
),
|
||||||
aMessageEvent(
|
aTimelineItemEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
content = content,
|
content = content,
|
||||||
groupPosition = TimelineItemGroupPosition.Middle
|
groupPosition = TimelineItemGroupPosition.Middle
|
||||||
),
|
),
|
||||||
aMessageEvent(
|
aTimelineItemEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
content = content,
|
content = content,
|
||||||
groupPosition = TimelineItemGroupPosition.First
|
groupPosition = TimelineItemGroupPosition.First
|
||||||
),
|
),
|
||||||
// 3 items (First Middle Last) with isMine = true
|
// 3 items (First Middle Last) with isMine = true
|
||||||
aMessageEvent(
|
aTimelineItemEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
content = content,
|
content = content,
|
||||||
groupPosition = TimelineItemGroupPosition.Last
|
groupPosition = TimelineItemGroupPosition.Last
|
||||||
),
|
),
|
||||||
aMessageEvent(
|
aTimelineItemEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
content = content,
|
content = content,
|
||||||
groupPosition = TimelineItemGroupPosition.Middle
|
groupPosition = TimelineItemGroupPosition.Middle
|
||||||
),
|
),
|
||||||
aMessageEvent(
|
aTimelineItemEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
content = content,
|
content = content,
|
||||||
groupPosition = TimelineItemGroupPosition.First
|
groupPosition = TimelineItemGroupPosition.First
|
||||||
|
|
@ -71,13 +72,15 @@ internal fun aTimelineItemList(content: TimelineItemContent): ImmutableList<Time
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun aMessageEvent(
|
internal fun aTimelineItemEvent(
|
||||||
isMine: Boolean = false,
|
isMine: Boolean = false,
|
||||||
content: TimelineItemContent = aTimelineItemContent(),
|
content: TimelineItemEventContent = aTimelineItemContent(),
|
||||||
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First
|
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First
|
||||||
): TimelineItem.MessageEvent {
|
): TimelineItem.Event {
|
||||||
return TimelineItem.MessageEvent(
|
val randomId = Math.random().toString()
|
||||||
id = EventId(Math.random().toString()),
|
return TimelineItem.Event(
|
||||||
|
id = randomId,
|
||||||
|
eventId = EventId(randomId),
|
||||||
senderId = "@senderId",
|
senderId = "@senderId",
|
||||||
senderAvatar = AvatarData("@senderId", "sender"),
|
senderAvatar = AvatarData("@senderId", "sender"),
|
||||||
content = content,
|
content = content,
|
||||||
|
|
@ -92,7 +95,7 @@ internal fun aMessageEvent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun aTimelineItemContent(): TimelineItemContent {
|
internal fun aTimelineItemContent(): TimelineItemEventContent {
|
||||||
return TimelineItemTextContent(
|
return TimelineItemTextContent(
|
||||||
body = "Text",
|
body = "Text",
|
||||||
htmlDocument = null
|
htmlDocument = null
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
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.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import io.element.android.features.messages.timeline.components.BubbleState
|
|
||||||
import io.element.android.features.messages.timeline.components.MessageEventBubble
|
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.TimelineItemReactionsView
|
||||||
import io.element.android.features.messages.timeline.components.TimelineItemRedactedView
|
import io.element.android.features.messages.timeline.components.event.TimelineItemEventContentView
|
||||||
import io.element.android.features.messages.timeline.components.TimelineItemTextView
|
import io.element.android.features.messages.timeline.components.virtual.TimelineItemDaySeparatorView
|
||||||
import io.element.android.features.messages.timeline.components.TimelineItemUnknownView
|
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.TimelineItem
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemContent
|
import io.element.android.features.messages.timeline.model.bubble.BubbleState
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemContentProvider
|
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemEncryptedContent
|
import io.element.android.features.messages.timeline.model.event.TimelineItemEventContentProvider
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemImageContent
|
import io.element.android.features.messages.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent
|
import io.element.android.features.messages.timeline.model.virtual.TimelineItemLoadingModel
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemTextBasedContent
|
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemUnknownContent
|
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
@ -86,9 +80,14 @@ import kotlinx.coroutines.launch
|
||||||
fun TimelineView(
|
fun TimelineView(
|
||||||
state: TimelineState,
|
state: TimelineState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {},
|
onMessageClicked: (TimelineItem.Event) -> Unit = {},
|
||||||
onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {},
|
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
fun onReachedLoadMore() {
|
||||||
|
state.eventSink(TimelineEvents.LoadMore)
|
||||||
|
}
|
||||||
|
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
|
@ -98,29 +97,23 @@ fun TimelineView(
|
||||||
verticalArrangement = Arrangement.Bottom,
|
verticalArrangement = Arrangement.Bottom,
|
||||||
reverseLayout = true
|
reverseLayout = true
|
||||||
) {
|
) {
|
||||||
items(
|
itemsIndexed(
|
||||||
items = state.timelineItems,
|
items = state.timelineItems,
|
||||||
contentType = { timelineItem -> timelineItem.contentType() },
|
contentType = { _, timelineItem -> timelineItem.contentType() },
|
||||||
key = { timelineItem -> timelineItem.key() },
|
key = { _, timelineItem -> timelineItem.key() },
|
||||||
) { timelineItem ->
|
) { index, timelineItem ->
|
||||||
TimelineItemRow(
|
TimelineItemRow(
|
||||||
timelineItem = timelineItem,
|
timelineItem = timelineItem,
|
||||||
isHighlighted = timelineItem.key() == state.highlightedEventId?.value,
|
isHighlighted = timelineItem.key() == state.highlightedEventId?.value,
|
||||||
onClick = onMessageClicked,
|
onClick = onMessageClicked,
|
||||||
onLongClick = onMessageLongClicked
|
onLongClick = onMessageLongClicked
|
||||||
)
|
)
|
||||||
}
|
if (index == state.timelineItems.lastIndex) {
|
||||||
if (state.hasMoreToLoad) {
|
onReachedLoadMore()
|
||||||
item {
|
|
||||||
TimelineLoadingMoreIndicator()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onReachedLoadMore() {
|
|
||||||
state.eventSink(TimelineEvents.LoadMore)
|
|
||||||
}
|
|
||||||
|
|
||||||
TimelineScrollHelper(
|
TimelineScrollHelper(
|
||||||
lazyListState = lazyListState,
|
lazyListState = lazyListState,
|
||||||
timelineItems = state.timelineItems,
|
timelineItems = state.timelineItems,
|
||||||
|
|
@ -131,14 +124,15 @@ fun TimelineView(
|
||||||
|
|
||||||
private fun TimelineItem.key(): String {
|
private fun TimelineItem.key(): String {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is TimelineItem.MessageEvent -> id.value
|
is TimelineItem.Event -> id
|
||||||
is TimelineItem.Virtual -> id
|
is TimelineItem.Virtual -> id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TimelineItem.contentType(): Int {
|
private fun TimelineItem.contentType(): Int {
|
||||||
|
// Todo optimize for each subtype
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is TimelineItem.MessageEvent -> 0
|
is TimelineItem.Event -> 0
|
||||||
is TimelineItem.Virtual -> 1
|
is TimelineItem.Virtual -> 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -147,30 +141,55 @@ private fun TimelineItem.contentType(): Int {
|
||||||
fun TimelineItemRow(
|
fun TimelineItemRow(
|
||||||
timelineItem: TimelineItem,
|
timelineItem: TimelineItem,
|
||||||
isHighlighted: Boolean,
|
isHighlighted: Boolean,
|
||||||
onClick: (TimelineItem.MessageEvent) -> Unit,
|
onClick: (TimelineItem.Event) -> Unit,
|
||||||
onLongClick: (TimelineItem.MessageEvent) -> Unit,
|
onLongClick: (TimelineItem.Event) -> Unit,
|
||||||
) {
|
) {
|
||||||
when (timelineItem) {
|
when (timelineItem) {
|
||||||
is TimelineItem.Virtual -> return
|
is TimelineItem.Virtual -> TimelineItemVirtualRow(
|
||||||
is TimelineItem.MessageEvent -> MessageEventRow(
|
virtual = timelineItem
|
||||||
messageEvent = timelineItem,
|
|
||||||
isHighlighted = isHighlighted,
|
|
||||||
onClick = { onClick(timelineItem) },
|
|
||||||
onLongClick = { onLongClick(timelineItem) }
|
|
||||||
)
|
)
|
||||||
|
is TimelineItem.Event -> {
|
||||||
|
|
||||||
|
fun onClick() {
|
||||||
|
onClick(timelineItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLongClick() {
|
||||||
|
onLongClick(timelineItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
TimelineItemEventRow(
|
||||||
|
event = timelineItem,
|
||||||
|
isHighlighted = isHighlighted,
|
||||||
|
onClick = ::onClick,
|
||||||
|
onLongClick = ::onLongClick
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageEventRow(
|
fun TimelineItemVirtualRow(
|
||||||
messageEvent: TimelineItem.MessageEvent,
|
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,
|
isHighlighted: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
|
val (parentAlignment, contentAlignment) = if (event.isMine) {
|
||||||
Pair(Alignment.CenterEnd, Alignment.End)
|
Pair(Alignment.CenterEnd, Alignment.End)
|
||||||
} else {
|
} else {
|
||||||
Pair(Alignment.CenterStart, Alignment.Start)
|
Pair(Alignment.CenterStart, Alignment.Start)
|
||||||
|
|
@ -182,21 +201,21 @@ fun MessageEventRow(
|
||||||
contentAlignment = parentAlignment
|
contentAlignment = parentAlignment
|
||||||
) {
|
) {
|
||||||
Row {
|
Row {
|
||||||
if (!messageEvent.isMine) {
|
if (!event.isMine) {
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
}
|
}
|
||||||
Column(horizontalAlignment = contentAlignment) {
|
Column(horizontalAlignment = contentAlignment) {
|
||||||
if (messageEvent.showSenderInformation) {
|
if (event.showSenderInformation) {
|
||||||
MessageSenderInformation(
|
MessageSenderInformation(
|
||||||
messageEvent.safeSenderName,
|
event.safeSenderName,
|
||||||
messageEvent.senderAvatar,
|
event.senderAvatar,
|
||||||
Modifier.zIndex(1f)
|
Modifier.zIndex(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageEventBubble(
|
MessageEventBubble(
|
||||||
state = BubbleState(
|
state = BubbleState(
|
||||||
groupPosition = messageEvent.groupPosition,
|
groupPosition = event.groupPosition,
|
||||||
isMine = messageEvent.isMine,
|
isMine = event.isMine,
|
||||||
isHighlighted = isHighlighted,
|
isHighlighted = isHighlighted,
|
||||||
),
|
),
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
|
|
@ -207,45 +226,21 @@ fun MessageEventRow(
|
||||||
.widthIn(max = 320.dp)
|
.widthIn(max = 320.dp)
|
||||||
) {
|
) {
|
||||||
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
when (messageEvent.content) {
|
TimelineItemEventContentView(event.content, interactionSource, onClick, onLongClick, contentModifier)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
TimelineItemReactionsView(
|
TimelineItemReactionsView(
|
||||||
reactionsState = messageEvent.reactionsState,
|
reactionsState = event.reactionsState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.zIndex(1f)
|
.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))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (messageEvent.groupPosition.isNew()) {
|
if (event.groupPosition.isNew()) {
|
||||||
Spacer(modifier = modifier.height(8.dp))
|
Spacer(modifier = modifier.height(8.dp))
|
||||||
} else {
|
} else {
|
||||||
Spacer(modifier = modifier.height(2.dp))
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginRootScreenLightPreview(
|
fun TimelineViewLightPreview(
|
||||||
@PreviewParameter(TimelineItemContentProvider::class) content: TimelineItemContent
|
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
|
||||||
) = ElementPreviewLight { ContentToPreview(content) }
|
) = ElementPreviewLight { ContentToPreview(content) }
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginRootScreenDarkPreview(
|
fun TimelineViewDarkPreview(
|
||||||
@PreviewParameter(TimelineItemContentProvider::class) content: TimelineItemContent
|
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
|
||||||
) = ElementPreviewDark { ContentToPreview(content) }
|
) = ElementPreviewDark { ContentToPreview(content) }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(content: TimelineItemContent) {
|
private fun ContentToPreview(content: TimelineItemEventContent) {
|
||||||
val timelineItems = aTimelineItemList(content)
|
val timelineItems = aTimelineItemList(content)
|
||||||
TimelineView(
|
TimelineView(
|
||||||
state = aTimelineState().copy(
|
state = aTimelineState().copy(
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
hasMoreToLoad = true,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition
|
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.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.ElementTheme
|
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,14 +14,14 @@
|
||||||
* limitations under the License.
|
* 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.Icons
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import 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.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import org.matrix.rustcomponents.sdk.EncryptedMessage
|
import org.matrix.rustcomponents.sdk.EncryptedMessage
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.Box
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
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 androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemImageContent
|
import io.element.android.features.messages.timeline.model.event.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemImageContentProvider
|
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.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground
|
import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,14 +14,14 @@
|
||||||
* limitations under the License.
|
* 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.Icons
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import 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.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.SpannableString
|
||||||
import android.text.style.URLSpan
|
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.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.core.text.util.LinkifyCompat
|
import androidx.core.text.util.LinkifyCompat
|
||||||
import io.element.android.features.messages.timeline.components.html.HtmlDocument
|
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.event.TimelineItemTextBasedContent
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemTextBasedContentProvider
|
import io.element.android.features.messages.timeline.model.event.TimelineItemTextBasedContentProvider
|
||||||
import io.element.android.libraries.designsystem.LinkColor
|
import io.element.android.libraries.designsystem.LinkColor
|
||||||
import io.element.android.libraries.designsystem.components.ClickableLinkText
|
import io.element.android.libraries.designsystem.components.ClickableLinkText
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,14 +14,14 @@
|
||||||
* limitations under the License.
|
* 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.Icons
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import 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.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,22 +17,33 @@
|
||||||
package io.element.android.features.messages.timeline.model
|
package io.element.android.features.messages.timeline.model
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
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.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.matrix.core.EventId
|
import io.element.android.libraries.matrix.core.EventId
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface TimelineItem {
|
sealed interface TimelineItem {
|
||||||
|
|
||||||
|
fun identifier(): String = when(this){
|
||||||
|
is Event -> id
|
||||||
|
is Virtual -> id
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
data class Virtual(
|
data class Virtual(
|
||||||
val id: String
|
val id: String,
|
||||||
|
val model: TimelineItemVirtualModel
|
||||||
) : TimelineItem
|
) : TimelineItem
|
||||||
|
|
||||||
data class MessageEvent(
|
@Immutable
|
||||||
val id: EventId,
|
data class Event(
|
||||||
|
val id: String,
|
||||||
|
val eventId: EventId? = null,
|
||||||
val senderId: String,
|
val senderId: String,
|
||||||
val senderDisplayName: String?,
|
val senderDisplayName: String?,
|
||||||
val senderAvatar: AvatarData,
|
val senderAvatar: AvatarData,
|
||||||
val content: TimelineItemContent,
|
val content: TimelineItemEventContent,
|
||||||
val sentTime: String = "",
|
val sentTime: String = "",
|
||||||
val isMine: Boolean = false,
|
val isMine: Boolean = false,
|
||||||
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,10 @@
|
||||||
* limitations under the License.
|
* 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
|
import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class BubbleState(
|
data class BubbleState(
|
||||||
val groupPosition: TimelineItemGroupPosition,
|
val groupPosition: TimelineItemGroupPosition,
|
||||||
val isMine: Boolean,
|
val isMine: Boolean,
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition
|
import io.element.android.features.messages.timeline.model.TimelineItemGroupPosition
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
|
|
@ -14,10 +14,10 @@
|
||||||
* limitations under the License.
|
* 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
|
import org.matrix.rustcomponents.sdk.EncryptedMessage
|
||||||
|
|
||||||
data class TimelineItemEncryptedContent(
|
data class TimelineItemEncryptedContent(
|
||||||
val encryptedMessage: EncryptedMessage
|
val encryptedMessage: EncryptedMessage
|
||||||
) : TimelineItemContent
|
) : TimelineItemEventContent
|
||||||
|
|
@ -14,6 +14,9 @@
|
||||||
* limitations under the License.
|
* 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
|
||||||
|
|
@ -14,13 +14,13 @@
|
||||||
* limitations under the License.
|
* 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.matrix.rustcomponents.sdk.EncryptedMessage
|
import org.matrix.rustcomponents.sdk.EncryptedMessage
|
||||||
|
|
||||||
class TimelineItemContentProvider : PreviewParameterProvider<TimelineItemContent> {
|
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
|
||||||
override val values = sequenceOf(
|
override val values = sequenceOf(
|
||||||
aTimelineItemEmoteContent(),
|
aTimelineItemEmoteContent(),
|
||||||
aTimelineItemEncryptedContent(),
|
aTimelineItemEncryptedContent(),
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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
|
import io.element.android.libraries.matrix.media.MediaResolver
|
||||||
|
|
||||||
|
|
@ -23,4 +23,4 @@ data class TimelineItemImageContent(
|
||||||
val imageMeta: MediaResolver.Meta,
|
val imageMeta: MediaResolver.Meta,
|
||||||
val blurhash: String?,
|
val blurhash: String?,
|
||||||
val aspectRatio: Float
|
val aspectRatio: Float
|
||||||
) : TimelineItemContent
|
) : TimelineItemEventContent
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.matrix.media.MediaResolver
|
import io.element.android.libraries.matrix.media.MediaResolver
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
|
|
@ -14,6 +14,6 @@
|
||||||
* limitations under the License.
|
* 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
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
* limitations under the License.
|
* 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
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
sealed interface TimelineItemTextBasedContent : TimelineItemContent {
|
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
|
||||||
val body: String
|
val body: String
|
||||||
val htmlDocument: Document?
|
val htmlDocument: Document?
|
||||||
}
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
|
|
@ -14,6 +14,6 @@
|
||||||
* limitations under the License.
|
* 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,12 +24,14 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.actionlist.ActionListPresenter
|
import io.element.android.features.messages.actionlist.ActionListPresenter
|
||||||
import io.element.android.features.messages.actionlist.model.TimelineItemAction
|
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.textcomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.timeline.TimelinePresenter
|
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.TimelineItem
|
||||||
import io.element.android.features.messages.timeline.model.TimelineItemReactions
|
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.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent
|
import io.element.android.features.messages.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.matrix.room.MatrixRoom
|
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_ROOM_ID
|
||||||
import io.element.android.libraries.matrixtest.A_USER_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.A_USER_NAME
|
||||||
import io.element.android.libraries.matrixtest.FakeMatrixClient
|
|
||||||
import io.element.android.libraries.matrixtest.room.FakeMatrixRoom
|
import io.element.android.libraries.matrixtest.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -134,14 +135,13 @@ class MessagesPresenterTest {
|
||||||
private fun TestScope.createMessagePresenter(
|
private fun TestScope.createMessagePresenter(
|
||||||
matrixRoom: MatrixRoom = FakeMatrixRoom()
|
matrixRoom: MatrixRoom = FakeMatrixRoom()
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
val matrixClient = FakeMatrixClient()
|
|
||||||
val messageComposerPresenter = MessageComposerPresenter(
|
val messageComposerPresenter = MessageComposerPresenter(
|
||||||
appCoroutineScope = this,
|
appCoroutineScope = this,
|
||||||
room = matrixRoom
|
room = matrixRoom
|
||||||
)
|
)
|
||||||
|
|
||||||
val timelinePresenter = TimelinePresenter(
|
val timelinePresenter = TimelinePresenter(
|
||||||
coroutineDispatchers = testCoroutineDispatchers(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
client = matrixClient,
|
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
)
|
)
|
||||||
val actionListPresenter = ActionListPresenter()
|
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())
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.timeline.model.TimelineItem
|
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.TimelineItemReactions
|
||||||
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.content.TimelineItemRedactedContent
|
import io.element.android.features.messages.timeline.model.event.TimelineItemRedactedContent
|
||||||
import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent
|
import io.element.android.features.messages.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.matrixtest.AN_EVENT_ID
|
import io.element.android.libraries.matrixtest.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrixtest.A_MESSAGE
|
import io.element.android.libraries.matrixtest.A_MESSAGE
|
||||||
|
|
@ -37,6 +37,7 @@ import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.matrix.rustcomponents.sdk.TimelineItemContent
|
||||||
|
|
||||||
class ActionListPresenterTest {
|
class ActionListPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -163,9 +164,10 @@ class ActionListPresenterTest {
|
||||||
|
|
||||||
private fun aMessageEvent(
|
private fun aMessageEvent(
|
||||||
isMine: Boolean,
|
isMine: Boolean,
|
||||||
content: TimelineItemContent,
|
content: TimelineItemEventContent,
|
||||||
) = TimelineItem.MessageEvent(
|
) = TimelineItem.Event(
|
||||||
id = AN_EVENT_ID,
|
id = AN_EVENT_ID.value,
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
senderId = A_USER_ID.value,
|
senderId = A_USER_ID.value,
|
||||||
senderDisplayName = A_USER_NAME,
|
senderDisplayName = A_USER_NAME,
|
||||||
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME),
|
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
)
|
||||||
|
|
@ -14,16 +14,13 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
package io.element.android.features.messages.fixtures
|
||||||
|
|
||||||
package io.element.android.features.messages.timeline
|
|
||||||
|
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
|
||||||
// TODO Move to common module to reuse
|
// TODO Move to common module to reuse
|
||||||
fun testCoroutineDispatchers() = CoroutineDispatchers(
|
internal fun testCoroutineDispatchers() = CoroutineDispatchers(
|
||||||
io = UnconfinedTestDispatcher(),
|
io = UnconfinedTestDispatcher(),
|
||||||
computation = UnconfinedTestDispatcher(),
|
computation = UnconfinedTestDispatcher(),
|
||||||
main = UnconfinedTestDispatcher(),
|
main = UnconfinedTestDispatcher(),
|
||||||
|
|
@ -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()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -22,10 +22,8 @@ import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.timeline.model.TimelineItem
|
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
|
||||||
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
|
|
||||||
import io.element.android.libraries.matrixtest.AN_EVENT_ID
|
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.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrixtest.timeline.FakeMatrixTimeline
|
import io.element.android.libraries.matrixtest.timeline.FakeMatrixTimeline
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -36,52 +34,69 @@ class TimelinePresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val presenter = TimelinePresenter(
|
val presenter = TimelinePresenter(
|
||||||
testCoroutineDispatchers(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
FakeMatrixClient(),
|
room = FakeMatrixRoom(),
|
||||||
FakeMatrixRoom()
|
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.timelineItems).isEmpty()
|
assertThat(initialState.timelineItems).isEmpty()
|
||||||
|
val loadedNoTimelineState = awaitItem()
|
||||||
|
assertThat(loadedNoTimelineState.timelineItems).isEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - load more`() = runTest {
|
fun `present - makes sure timeline is initialized and disposed`() = runTest {
|
||||||
val matrixTimeline = FakeMatrixTimeline()
|
val fakeTimeline = FakeMatrixTimeline()
|
||||||
val matrixRoom = FakeMatrixRoom(matrixTimeline = matrixTimeline)
|
|
||||||
val presenter = TimelinePresenter(
|
val presenter = TimelinePresenter(
|
||||||
testCoroutineDispatchers(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
FakeMatrixClient(),
|
room = FakeMatrixRoom(matrixTimeline = fakeTimeline),
|
||||||
matrixRoom
|
)
|
||||||
|
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) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.hasMoreToLoad).isTrue()
|
assertThat(initialState.paginationState.canBackPaginate).isTrue()
|
||||||
matrixTimeline.givenHasMoreToLoad(false)
|
assertThat(initialState.paginationState.isBackPaginating).isFalse()
|
||||||
initialState.eventSink.invoke(TimelineEvents.LoadMore)
|
initialState.eventSink.invoke(TimelineEvents.LoadMore)
|
||||||
val loadedState = awaitItem()
|
val inPaginationState = awaitItem()
|
||||||
assertThat(loadedState.hasMoreToLoad).isFalse()
|
assertThat(inPaginationState.paginationState.isBackPaginating).isTrue()
|
||||||
|
assertThat(inPaginationState.paginationState.canBackPaginate).isTrue()
|
||||||
|
val postPaginationState = awaitItem()
|
||||||
|
assertThat(postPaginationState.paginationState.canBackPaginate).isTrue()
|
||||||
|
assertThat(postPaginationState.paginationState.isBackPaginating).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - set highlighted event`() = runTest {
|
fun `present - set highlighted event`() = runTest {
|
||||||
val matrixTimeline = FakeMatrixTimeline()
|
|
||||||
val matrixRoom = FakeMatrixRoom(matrixTimeline = matrixTimeline)
|
|
||||||
val presenter = TimelinePresenter(
|
val presenter = TimelinePresenter(
|
||||||
testCoroutineDispatchers(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
FakeMatrixClient(),
|
room = FakeMatrixRoom(),
|
||||||
matrixRoom
|
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
assertThat(initialState.highlightedEventId).isNull()
|
assertThat(initialState.highlightedEventId).isNull()
|
||||||
initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(AN_EVENT_ID))
|
initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(AN_EVENT_ID))
|
||||||
val withHighlightedState = awaitItem()
|
val withHighlightedState = awaitItem()
|
||||||
|
|
@ -91,26 +106,4 @@ class TimelinePresenterTest {
|
||||||
assertThat(withoutHighlightedState.highlightedEventId).isNull()
|
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
||||||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||||
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
|
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
|
||||||
timber = "com.jakewharton.timber:timber:5.0.1"
|
timber = "com.jakewharton.timber:timber:5.0.1"
|
||||||
|
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.2"
|
||||||
|
|
||||||
# Di
|
# Di
|
||||||
inject = "javax.inject:javax.inject:1"
|
inject = "javax.inject:javax.inject:1"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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() ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,91 +16,33 @@
|
||||||
|
|
||||||
package io.element.android.libraries.dateformatter.impl
|
package io.element.android.libraries.dateformatter.impl
|
||||||
|
|
||||||
import android.text.format.DateFormat
|
|
||||||
import android.text.format.DateUtils
|
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.libraries.dateformatter.LastMessageFormatter
|
import io.element.android.libraries.dateformatter.LastMessageFormatter
|
||||||
import io.element.android.libraries.di.AppScope
|
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 javax.inject.Inject
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultLastMessageFormatter @Inject constructor(
|
class DefaultLastMessageFormatter @Inject constructor(
|
||||||
private val clock: Clock,
|
private val localDateTimeProvider: LocalDateTimeProvider,
|
||||||
private val locale: Locale,
|
private val dateFormatters: DateFormatters,
|
||||||
private val timezone: TimeZone,
|
|
||||||
) : LastMessageFormatter {
|
) : 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 {
|
override fun format(timestamp: Long?): String {
|
||||||
if (timestamp == null) return ""
|
if (timestamp == null) return ""
|
||||||
val now: Instant = clock.now()
|
val currentDate = localDateTimeProvider.providesNow()
|
||||||
val tsInstant = Instant.fromEpochMilliseconds(timestamp)
|
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
|
||||||
val nowDateTime = now.toLocalDateTime(timezone)
|
val isSameDay = currentDate.date == dateToFormat.date
|
||||||
val tsDateTime = tsInstant.toLocalDateTime(timezone)
|
|
||||||
val isSameDay = nowDateTime.date == tsDateTime.date
|
|
||||||
return when {
|
return when {
|
||||||
isSameDay -> {
|
isSameDay -> {
|
||||||
onlyTimeFormatter.format(tsDateTime.toJavaLocalDateTime())
|
dateFormatters.formatTime(dateToFormat)
|
||||||
}
|
}
|
||||||
else -> {
|
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() ?: ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -101,6 +101,8 @@ class DefaultLastMessageFormatterTest {
|
||||||
*/
|
*/
|
||||||
private fun createFormatter(@Suppress("SameParameterValue") currentDate: String): LastMessageFormatter {
|
private fun createFormatter(@Suppress("SameParameterValue") currentDate: String): LastMessageFormatter {
|
||||||
val clock = FakeClock().also { it.givenInstant(Instant.parse(currentDate)) }
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ anvil {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.libraries.rustsdk)
|
// api(projects.libraries.rustsdk)
|
||||||
|
api(libs.matrix.sdk)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
implementation(libs.dagger)
|
implementation(libs.dagger)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
|
|
|
||||||
|
|
@ -36,13 +36,14 @@ import org.matrix.rustcomponents.sdk.ClientDelegate
|
||||||
import org.matrix.rustcomponents.sdk.MediaSource
|
import org.matrix.rustcomponents.sdk.MediaSource
|
||||||
import org.matrix.rustcomponents.sdk.RequiredState
|
import org.matrix.rustcomponents.sdk.RequiredState
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncMode
|
import org.matrix.rustcomponents.sdk.SlidingSyncMode
|
||||||
|
import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncViewBuilder
|
import org.matrix.rustcomponents.sdk.SlidingSyncViewBuilder
|
||||||
import org.matrix.rustcomponents.sdk.StoppableSpawn
|
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
internal class RustMatrixClient internal constructor(
|
class RustMatrixClient constructor(
|
||||||
private val client: Client,
|
private val client: Client,
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
|
|
@ -66,25 +67,42 @@ internal class RustMatrixClient internal constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val slidingSyncView = SlidingSyncViewBuilder()
|
private val slidingSyncFilters by lazy {
|
||||||
.timelineLimit(limit = 10u)
|
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(
|
||||||
requiredState = listOf(
|
requiredState = listOf(
|
||||||
RequiredState(key = "m.room.avatar", value = ""),
|
RequiredState(key = "m.room.avatar", value = ""),
|
||||||
RequiredState(key = "m.room.encryption", value = ""),
|
RequiredState(key = "m.room.encryption", value = ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.name(name = "HomeScreenView")
|
.filters(slidingSyncFilters)
|
||||||
|
.name(name = "CurrentlyVisibleRooms")
|
||||||
|
.sendUpdatesForItems(true)
|
||||||
.syncMode(mode = SlidingSyncMode.SELECTIVE)
|
.syncMode(mode = SlidingSyncMode.SELECTIVE)
|
||||||
.addRange(0u, 30u)
|
.addRange(0u, 20u)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val slidingSync = client
|
private val slidingSync = client
|
||||||
.slidingSync()
|
.slidingSync()
|
||||||
.homeserver("https://slidingsync.lab.element.dev")
|
.homeserver("https://slidingsync.lab.matrix.org")
|
||||||
.withCommonExtensions()
|
.withCommonExtensions()
|
||||||
// .coldCache("ElementX")
|
.coldCache("ElementX")
|
||||||
.addView(slidingSyncView)
|
.addView(visibleRoomsView)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val slidingSyncObserverProxy = SlidingSyncObserverProxy(coroutineScope)
|
private val slidingSyncObserverProxy = SlidingSyncObserverProxy(coroutineScope)
|
||||||
|
|
@ -92,57 +110,58 @@ internal class RustMatrixClient internal constructor(
|
||||||
RustRoomSummaryDataSource(
|
RustRoomSummaryDataSource(
|
||||||
slidingSyncObserverProxy.updateSummaryFlow,
|
slidingSyncObserverProxy.updateSummaryFlow,
|
||||||
slidingSync,
|
slidingSync,
|
||||||
slidingSyncView,
|
visibleRoomsView,
|
||||||
dispatchers,
|
dispatchers,
|
||||||
::onRestartSync
|
::onRestartSync
|
||||||
)
|
)
|
||||||
private var slidingSyncObserverToken: StoppableSpawn? = null
|
private var slidingSyncObserverToken: TaskHandle? = null
|
||||||
|
|
||||||
private val mediaResolver = RustMediaResolver(this)
|
private val mediaResolver = RustMediaResolver(this)
|
||||||
private val isSyncing = AtomicBoolean(false)
|
private val isSyncing = AtomicBoolean(false)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
client.setDelegate(clientDelegate)
|
client.setDelegate(clientDelegate)
|
||||||
|
roomSummaryDataSource.init()
|
||||||
|
slidingSync.setObserver(slidingSyncObserverProxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onRestartSync() {
|
private fun onRestartSync() {
|
||||||
slidingSyncObserverToken = slidingSync.sync()
|
stopSync()
|
||||||
|
startSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getRoom(roomId: RoomId): MatrixRoom? {
|
override fun getRoom(roomId: RoomId): MatrixRoom? {
|
||||||
val slidingSyncRoom = slidingSync.getRoom(roomId.value) ?: return null
|
val slidingSyncRoom = slidingSync.getRoom(roomId.value) ?: return null
|
||||||
val room = slidingSyncRoom.fullRoom() ?: return null
|
val fullRoom = slidingSyncRoom.fullRoom() ?: return null
|
||||||
return RustMatrixRoom(
|
return RustMatrixRoom(
|
||||||
slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow,
|
slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow,
|
||||||
slidingSyncRoom = slidingSyncRoom,
|
slidingSyncRoom = slidingSyncRoom,
|
||||||
room = room,
|
innerRoom = fullRoom,
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
coroutineDispatchers = dispatchers
|
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 roomSummaryDataSource(): RoomSummaryDataSource = roomSummaryDataSource
|
||||||
|
|
||||||
override fun mediaResolver(): MediaResolver = mediaResolver
|
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() {
|
override fun close() {
|
||||||
stopSync()
|
stopSync()
|
||||||
|
slidingSync.setObserver(null)
|
||||||
roomSummaryDataSource.close()
|
roomSummaryDataSource.close()
|
||||||
client.setDelegate(null)
|
client.setDelegate(null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,6 @@ object MatrixModule {
|
||||||
@Provides
|
@Provides
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
fun providesRustAuthenticationService(baseDirectory: File): AuthenticationService {
|
fun providesRustAuthenticationService(baseDirectory: File): AuthenticationService {
|
||||||
return AuthenticationService(baseDirectory.absolutePath)
|
return AuthenticationService(baseDirectory.absolutePath, null, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ interface MatrixRoom {
|
||||||
|
|
||||||
fun timeline(): MatrixTimeline
|
fun timeline(): MatrixTimeline
|
||||||
|
|
||||||
|
suspend fun fetchMembers(): Result<Unit>
|
||||||
|
|
||||||
suspend fun userDisplayName(userId: String): Result<String?>
|
suspend fun userDisplayName(userId: String): Result<String?>
|
||||||
|
|
||||||
suspend fun userAvatarUrl(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 replyMessage(eventId: EventId, message: String): Result<Unit>
|
||||||
|
|
||||||
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
|
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,7 +22,6 @@ import io.element.android.libraries.matrix.sync.state
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.cancelChildren
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -38,7 +37,6 @@ import org.matrix.rustcomponents.sdk.SlidingSyncViewRoomsListDiff
|
||||||
import org.matrix.rustcomponents.sdk.UpdateSummary
|
import org.matrix.rustcomponents.sdk.UpdateSummary
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.util.Collections
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
interface RoomSummaryDataSource {
|
interface RoomSummaryDataSource {
|
||||||
|
|
@ -60,7 +58,7 @@ internal class RustRoomSummaryDataSource(
|
||||||
private val roomSummaries = MutableStateFlow<List<RoomSummary>>(emptyList())
|
private val roomSummaries = MutableStateFlow<List<RoomSummary>>(emptyList())
|
||||||
private val state = MutableStateFlow(SlidingSyncState.COLD)
|
private val state = MutableStateFlow(SlidingSyncState.COLD)
|
||||||
|
|
||||||
fun startSync() {
|
fun init() {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
updateRoomSummaries {
|
updateRoomSummaries {
|
||||||
addAll(
|
addAll(
|
||||||
|
|
@ -89,10 +87,6 @@ internal class RustRoomSummaryDataSource(
|
||||||
}.launchIn(coroutineScope)
|
}.launchIn(coroutineScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopSync() {
|
|
||||||
coroutineScope.coroutineContext.cancelChildren()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
coroutineScope.cancel()
|
coroutineScope.cancel()
|
||||||
}
|
}
|
||||||
|
|
@ -133,29 +127,45 @@ internal class RustRoomSummaryDataSource(
|
||||||
}
|
}
|
||||||
Timber.v("ApplyDiff: $diff for list with size: $size")
|
Timber.v("ApplyDiff: $diff for list with size: $size")
|
||||||
when (diff) {
|
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)
|
val roomSummary = buildSummaryForRoomListEntry(diff.value)
|
||||||
add(roomSummary)
|
add(roomSummary)
|
||||||
}
|
}
|
||||||
is SlidingSyncViewRoomsListDiff.UpdateAt -> {
|
is SlidingSyncViewRoomsListDiff.PushFront -> {
|
||||||
|
val roomSummary = buildSummaryForRoomListEntry(diff.value)
|
||||||
|
add(0, roomSummary)
|
||||||
|
}
|
||||||
|
is SlidingSyncViewRoomsListDiff.Set -> {
|
||||||
fillUntil(diff.index.toInt())
|
fillUntil(diff.index.toInt())
|
||||||
val roomSummary = buildSummaryForRoomListEntry(diff.value)
|
val roomSummary = buildSummaryForRoomListEntry(diff.value)
|
||||||
set(diff.index.toInt(), roomSummary)
|
set(diff.index.toInt(), roomSummary)
|
||||||
}
|
}
|
||||||
is SlidingSyncViewRoomsListDiff.InsertAt -> {
|
is SlidingSyncViewRoomsListDiff.Insert -> {
|
||||||
val roomSummary = buildSummaryForRoomListEntry(diff.value)
|
val roomSummary = buildSummaryForRoomListEntry(diff.value)
|
||||||
add(diff.index.toInt(), roomSummary)
|
add(diff.index.toInt(), roomSummary)
|
||||||
}
|
}
|
||||||
is SlidingSyncViewRoomsListDiff.Move -> {
|
is SlidingSyncViewRoomsListDiff.Remove -> {
|
||||||
Collections.swap(this, diff.oldIndex.toInt(), diff.newIndex.toInt())
|
|
||||||
}
|
|
||||||
is SlidingSyncViewRoomsListDiff.RemoveAt -> {
|
|
||||||
removeAt(diff.index.toInt())
|
removeAt(diff.index.toInt())
|
||||||
}
|
}
|
||||||
is SlidingSyncViewRoomsListDiff.Replace -> {
|
is SlidingSyncViewRoomsListDiff.Reset -> {
|
||||||
clear()
|
clear()
|
||||||
addAll(diff.values.map { buildSummaryForRoomListEntry(it) })
|
addAll(diff.values.map { buildSummaryForRoomListEntry(it) })
|
||||||
}
|
}
|
||||||
|
SlidingSyncViewRoomsListDiff.PopBack -> {
|
||||||
|
removeFirstOrNull()
|
||||||
|
}
|
||||||
|
SlidingSyncViewRoomsListDiff.PopFront -> {
|
||||||
|
removeLastOrNull()
|
||||||
|
}
|
||||||
|
SlidingSyncViewRoomsListDiff.Clear -> {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,13 +194,4 @@ internal class RustRoomSummaryDataSource(
|
||||||
block(mutableRoomSummaries)
|
block(mutableRoomSummaries)
|
||||||
roomSummaries.value = 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,12 @@ import org.matrix.rustcomponents.sdk.SlidingSyncRoom
|
||||||
import org.matrix.rustcomponents.sdk.UpdateSummary
|
import org.matrix.rustcomponents.sdk.UpdateSummary
|
||||||
import org.matrix.rustcomponents.sdk.genTransactionId
|
import org.matrix.rustcomponents.sdk.genTransactionId
|
||||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
class RustMatrixRoom(
|
class RustMatrixRoom(
|
||||||
private val slidingSyncUpdateFlow: Flow<UpdateSummary>,
|
private val slidingSyncUpdateFlow: Flow<UpdateSummary>,
|
||||||
private val slidingSyncRoom: SlidingSyncRoom,
|
private val slidingSyncRoom: SlidingSyncRoom,
|
||||||
private val room: Room,
|
private val innerRoom: Room,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
) : MatrixRoom {
|
) : MatrixRoom {
|
||||||
|
|
@ -44,7 +45,7 @@ class RustMatrixRoom(
|
||||||
override fun syncUpdateFlow(): Flow<Long> {
|
override fun syncUpdateFlow(): Flow<Long> {
|
||||||
return slidingSyncUpdateFlow
|
return slidingSyncUpdateFlow
|
||||||
.filter {
|
.filter {
|
||||||
it.rooms.contains(room.id())
|
it.rooms.contains(innerRoom.id())
|
||||||
}
|
}
|
||||||
.map {
|
.map {
|
||||||
System.currentTimeMillis()
|
System.currentTimeMillis()
|
||||||
|
|
@ -55,14 +56,14 @@ class RustMatrixRoom(
|
||||||
override fun timeline(): MatrixTimeline {
|
override fun timeline(): MatrixTimeline {
|
||||||
return RustMatrixTimeline(
|
return RustMatrixTimeline(
|
||||||
matrixRoom = this,
|
matrixRoom = this,
|
||||||
room = room,
|
innerRoom = innerRoom,
|
||||||
slidingSyncRoom = slidingSyncRoom,
|
slidingSyncRoom = slidingSyncRoom,
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
coroutineDispatchers = coroutineDispatchers
|
coroutineDispatchers = coroutineDispatchers
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override val roomId = RoomId(room.id())
|
override val roomId = RoomId(innerRoom.id())
|
||||||
|
|
||||||
override val name: String?
|
override val name: String?
|
||||||
get() {
|
get() {
|
||||||
|
|
@ -71,35 +72,41 @@ class RustMatrixRoom(
|
||||||
|
|
||||||
override val bestName: String
|
override val bestName: String
|
||||||
get() {
|
get() {
|
||||||
return name?.takeIf { it.isNotEmpty() } ?: room.id()
|
return name?.takeIf { it.isNotEmpty() } ?: innerRoom.id()
|
||||||
}
|
}
|
||||||
|
|
||||||
override val displayName: String
|
override val displayName: String
|
||||||
get() {
|
get() {
|
||||||
return room.displayName()
|
return innerRoom.displayName()
|
||||||
}
|
}
|
||||||
|
|
||||||
override val topic: String?
|
override val topic: String?
|
||||||
get() {
|
get() {
|
||||||
return room.topic()
|
return innerRoom.topic()
|
||||||
}
|
}
|
||||||
|
|
||||||
override val avatarUrl: String?
|
override val avatarUrl: String?
|
||||||
get() {
|
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?> =
|
override suspend fun userDisplayName(userId: String): Result<String?> =
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
runCatching {
|
runCatching {
|
||||||
room.memberDisplayName(userId)
|
innerRoom.memberDisplayName(userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun userAvatarUrl(userId: String): Result<String?> =
|
override suspend fun userAvatarUrl(userId: String): Result<String?> =
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
runCatching {
|
runCatching {
|
||||||
room.memberAvatarUrl(userId)
|
innerRoom.memberAvatarUrl(userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +114,7 @@ class RustMatrixRoom(
|
||||||
val transactionId = genTransactionId()
|
val transactionId = genTransactionId()
|
||||||
val content = messageEventContentFromMarkdown(message)
|
val content = messageEventContentFromMarkdown(message)
|
||||||
runCatching {
|
runCatching {
|
||||||
room.send(content, transactionId)
|
innerRoom.send(content, transactionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,7 +122,7 @@ class RustMatrixRoom(
|
||||||
val transactionId = genTransactionId()
|
val transactionId = genTransactionId()
|
||||||
// val content = messageEventContentFromMarkdown(message)
|
// val content = messageEventContentFromMarkdown(message)
|
||||||
runCatching {
|
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 transactionId = genTransactionId()
|
||||||
// val content = messageEventContentFromMarkdown(message)
|
// val content = messageEventContentFromMarkdown(message)
|
||||||
runCatching {
|
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) {
|
override suspend fun redactEvent(eventId: EventId, reason: String?) = withContext(coroutineDispatchers.io) {
|
||||||
val transactionId = genTransactionId()
|
val transactionId = genTransactionId()
|
||||||
runCatching {
|
runCatching {
|
||||||
room.redact(eventId.value, reason, transactionId)
|
innerRoom.redact(eventId.value, reason, transactionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ class RoomMessageFactory {
|
||||||
eventId = EventId(eventTimelineItem.eventId() ?: ""),
|
eventId = EventId(eventTimelineItem.eventId() ?: ""),
|
||||||
body = eventTimelineItem.content().asMessage()?.body() ?: "",
|
body = eventTimelineItem.content().asMessage()?.body() ?: "",
|
||||||
sender = UserId(eventTimelineItem.sender()),
|
sender = UserId(eventTimelineItem.sender()),
|
||||||
originServerTs = eventTimelineItem.originServerTs()?.toLong() ?: 0L
|
originServerTs = eventTimelineItem.timestamp().toLong()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ class PreferencesSessionStore @Inject constructor(
|
||||||
val homeserverUrl: String,
|
val homeserverUrl: String,
|
||||||
val isSoftLogout: Boolean,
|
val isSoftLogout: Boolean,
|
||||||
val refreshToken: String?,
|
val refreshToken: String?,
|
||||||
val userId: String
|
val userId: String,
|
||||||
|
val slidingSyncProxy: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
private val store = context.dataStore
|
private val store = context.dataStore
|
||||||
|
|
@ -73,7 +74,8 @@ class PreferencesSessionStore @Inject constructor(
|
||||||
homeserverUrl = session.homeserverUrl,
|
homeserverUrl = session.homeserverUrl,
|
||||||
isSoftLogout = session.isSoftLogout,
|
isSoftLogout = session.isSoftLogout,
|
||||||
refreshToken = session.refreshToken,
|
refreshToken = session.refreshToken,
|
||||||
userId = session.userId
|
userId = session.userId,
|
||||||
|
slidingSyncProxy = session.slidingSyncProxy
|
||||||
)
|
)
|
||||||
val encodedSession = Json.encodeToString(sessionData)
|
val encodedSession = Json.encodeToString(sessionData)
|
||||||
prefs[sessionKey] = encodedSession
|
prefs[sessionKey] = encodedSession
|
||||||
|
|
@ -90,7 +92,8 @@ class PreferencesSessionStore @Inject constructor(
|
||||||
homeserverUrl = sessionData.homeserverUrl,
|
homeserverUrl = sessionData.homeserverUrl,
|
||||||
isSoftLogout = sessionData.isSoftLogout,
|
isSoftLogout = sessionData.isSoftLogout,
|
||||||
refreshToken = sessionData.refreshToken,
|
refreshToken = sessionData.refreshToken,
|
||||||
userId = sessionData.userId
|
userId = sessionData.userId,
|
||||||
|
slidingSyncProxy = sessionData.slidingSyncProxy
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,19 @@ package io.element.android.libraries.matrix.timeline
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.core.EventId
|
import io.element.android.libraries.matrix.core.EventId
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface MatrixTimeline {
|
interface MatrixTimeline {
|
||||||
var callback: Callback?
|
|
||||||
val hasMoreToLoad: Boolean
|
|
||||||
|
|
||||||
interface Callback {
|
data class PaginationState(
|
||||||
fun onUpdatedTimelineItem(timelineItem: MatrixTimelineItem) = Unit
|
val isBackPaginating: Boolean,
|
||||||
fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) = Unit
|
val canBackPaginate: Boolean
|
||||||
}
|
)
|
||||||
|
|
||||||
|
fun paginationState(): StateFlow<PaginationState>
|
||||||
|
|
||||||
fun timelineItems(): Flow<List<MatrixTimelineItem>>
|
fun timelineItems(): Flow<List<MatrixTimelineItem>>
|
||||||
suspend fun paginateBackwards(count: Int): Result<Unit>
|
suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit>
|
||||||
fun addListener(timelineListener: TimelineListener)
|
|
||||||
fun initialize()
|
fun initialize()
|
||||||
fun dispose()
|
fun dispose()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,20 +16,18 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.timeline
|
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.EventTimelineItem
|
||||||
import org.matrix.rustcomponents.sdk.TimelineItem
|
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||||
import org.matrix.rustcomponents.sdk.TimelineKey
|
import org.matrix.rustcomponents.sdk.VirtualTimelineItem
|
||||||
|
|
||||||
sealed interface MatrixTimelineItem {
|
sealed interface MatrixTimelineItem {
|
||||||
data class Event(val event: EventTimelineItem) : MatrixTimelineItem {
|
data class Event(val event: EventTimelineItem) : MatrixTimelineItem {
|
||||||
val uniqueId: String
|
val uniqueId: String = event.uniqueIdentifier()
|
||||||
get() = when (val eventKey = event.key()) {
|
val eventId: EventId? = event.eventId()?.let { EventId(it) }
|
||||||
is TimelineKey.TransactionId -> eventKey.txnId
|
|
||||||
is TimelineKey.EventId -> eventKey.eventId
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
object Virtual : MatrixTimelineItem
|
data class Virtual(val virtual: VirtualTimelineItem) : MatrixTimelineItem
|
||||||
object Other : MatrixTimelineItem
|
object Other : MatrixTimelineItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,7 +38,7 @@ fun TimelineItem.asMatrixTimelineItem(): MatrixTimelineItem {
|
||||||
}
|
}
|
||||||
val asVirtual = asVirtual()
|
val asVirtual = asVirtual()
|
||||||
if (asVirtual != null) {
|
if (asVirtual != null) {
|
||||||
return MatrixTimelineItem.Virtual
|
return MatrixTimelineItem.Virtual(asVirtual)
|
||||||
}
|
}
|
||||||
return MatrixTimelineItem.Other
|
return MatrixTimelineItem.Other
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,121 +18,83 @@ package io.element.android.libraries.matrix.timeline
|
||||||
|
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.matrix.core.EventId
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.sample
|
import kotlinx.coroutines.flow.sample
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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.Room
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
|
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
|
||||||
import org.matrix.rustcomponents.sdk.TimelineChange
|
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
|
||||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Collections
|
|
||||||
|
|
||||||
class RustMatrixTimeline(
|
class RustMatrixTimeline(
|
||||||
private val matrixRoom: RustMatrixRoom,
|
private val matrixRoom: MatrixRoom,
|
||||||
private val room: Room,
|
private val innerRoom: Room,
|
||||||
private val slidingSyncRoom: SlidingSyncRoom,
|
private val slidingSyncRoom: SlidingSyncRoom,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
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>> =
|
private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
|
||||||
MutableStateFlow(emptyList())
|
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)
|
@OptIn(FlowPreview::class)
|
||||||
override fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
override fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
||||||
return timelineItems.sample(50)
|
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() {
|
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() {
|
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)
|
return matrixRoom.replyMessage(inReplyToEventId, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUpdate(update: TimelineDiff) {
|
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
coroutineScope.launch {
|
runCatching {
|
||||||
updateTimelineItems {
|
Timber.v("Start back paginating for room ${matrixRoom.roomId} ")
|
||||||
applyDiff(update)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,49 +16,73 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.tracing
|
package io.element.android.libraries.matrix.tracing
|
||||||
|
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
data class TracingConfiguration(
|
data class TracingConfiguration(
|
||||||
val common: LogLevel = LogLevel.Warn,
|
val overrides: Map<Target, LogLevel> = emptyMap()
|
||||||
val targets: Map<Target, LogLevel> = emptyMap()
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val filter = "$common,${
|
// Order should matters
|
||||||
targets.map { "${it.key.filter}=${it.value.filter}" }.joinToString(separator = ",")
|
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) {
|
val filter: String
|
||||||
object Hyper : Target("hyper")
|
get() {
|
||||||
object Sled : Target("sled")
|
overrides.forEach { (target, logLevel) ->
|
||||||
sealed class MatrixSdk(override val filter: String) : Target(filter) {
|
targets[target] = logLevel
|
||||||
object Root : MatrixSdk("matrix_sdk")
|
}
|
||||||
object Sled : MatrixSdk("matrix_sdk_sled")
|
return targets.map {
|
||||||
object FFI : MatrixSdk("matrix_sdk_ffi")
|
if (it.key.filter.isEmpty()) {
|
||||||
object HttpClient : MatrixSdk("matrix_sdk::http_client")
|
it.value.filter
|
||||||
object UniffiAPI : MatrixSdk("matrix_sdk_ffi::uniffi_api")
|
} else {
|
||||||
object SlidingSync : MatrixSdk("matrix_sdk::sliding_sync")
|
"${it.key.filter}=${it.value.filter}"
|
||||||
object BaseSlidingSync : MatrixSdk("matrix_sdk_base::sliding_sync")
|
}
|
||||||
|
}.joinToString(separator = ",")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class LogLevel(val filter: String) {
|
sealed class Target(open val filter: String) {
|
||||||
object Warn : LogLevel("warn")
|
object Common : Target("")
|
||||||
object Trace : LogLevel("trace")
|
object Hyper : Target("hyper")
|
||||||
object Info : LogLevel("info")
|
object Sled : Target("sled")
|
||||||
object Debug : LogLevel("debug")
|
sealed class MatrixSdk(override val filter: String) : Target(filter) {
|
||||||
object Error : LogLevel("error")
|
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) {
|
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 {
|
object TracingConfigurations {
|
||||||
val release = TracingConfiguration(common = TracingConfiguration.LogLevel.Info)
|
val release = TracingConfiguration(overrides = mapOf(Target.Common to LogLevel.Info))
|
||||||
val debug = TracingConfiguration()
|
val debug = TracingConfiguration(overrides = mapOf(Target.Common to LogLevel.Info))
|
||||||
val full = TracingConfiguration(
|
|
||||||
common = TracingConfiguration.LogLevel.Info,
|
fun custom(overrides: Map<Target, LogLevel>) = TracingConfiguration(overrides)
|
||||||
targets = mapOf(
|
|
||||||
TracingConfiguration.Target.Sled to TracingConfiguration.LogLevel.Warn
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,13 @@ package io.element.android.libraries.matrix.util
|
||||||
import kotlinx.coroutines.channels.ProducerScope
|
import kotlinx.coroutines.channels.ProducerScope
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
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 {
|
callbackFlow {
|
||||||
val token: StoppableSpawn = block(this)
|
val token: TaskHandle = block(this)
|
||||||
awaitClose {
|
awaitClose {
|
||||||
token.cancel()
|
token.cancel()
|
||||||
|
token.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,10 @@ class FakeMatrixRoom(
|
||||||
return matrixTimeline
|
return matrixTimeline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchMembers(): Result<Unit> {
|
||||||
|
return Result.success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun userDisplayName(userId: String): Result<String?> {
|
override suspend fun userDisplayName(userId: String): Result<String?> {
|
||||||
return Result.success("")
|
return Result.success("")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,35 +21,55 @@ import io.element.android.libraries.matrix.timeline.MatrixTimeline
|
||||||
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
class FakeMatrixTimeline : MatrixTimeline {
|
class FakeMatrixTimeline(
|
||||||
override var callback: MatrixTimeline.Callback? = null
|
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) {
|
fun updatePaginationState(update: (MatrixTimeline.PaginationState.() -> MatrixTimeline.PaginationState)) {
|
||||||
this.hasMoreToLoadValue = hasMoreToLoad
|
paginationState.value = update(paginationState.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
override val hasMoreToLoad: Boolean
|
fun updateTimelineItems(update: (items: List<MatrixTimelineItem>) -> List<MatrixTimelineItem>) {
|
||||||
get() = hasMoreToLoadValue
|
timelineItems.value = update(timelineItems.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun paginationState(): StateFlow<MatrixTimeline.PaginationState> {
|
||||||
|
return paginationState
|
||||||
|
}
|
||||||
|
|
||||||
override fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
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)
|
delay(100)
|
||||||
|
updatePaginationState {
|
||||||
|
copy(isBackPaginating = false)
|
||||||
|
}
|
||||||
|
updateTimelineItems { timelineItems ->
|
||||||
|
timelineItems
|
||||||
|
}
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addListener(timelineListener: TimelineListener) = Unit
|
override fun initialize() {
|
||||||
|
isInitialized = true
|
||||||
|
}
|
||||||
|
|
||||||
override fun initialize() = Unit
|
override fun dispose() {
|
||||||
|
isInitialized = false
|
||||||
override fun dispose() = Unit
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(message: String): Result<Unit> {
|
override suspend fun sendMessage(message: String): Result<Unit> {
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,5 @@ dependencies {
|
||||||
implementation(libs.android.gradle.plugin)
|
implementation(libs.android.gradle.plugin)
|
||||||
implementation(libs.kotlin.gradle.plugin)
|
implementation(libs.kotlin.gradle.plugin)
|
||||||
implementation(libs.firebase.gradle.plugin)
|
implementation(libs.firebase.gradle.plugin)
|
||||||
|
implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ package extension
|
||||||
|
|
||||||
import Versions
|
import Versions
|
||||||
import com.android.build.api.dsl.CommonExtension
|
import com.android.build.api.dsl.CommonExtension
|
||||||
|
import org.gradle.api.JavaVersion
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import org.gradle.accessors.dm.LibrariesForLibs
|
||||||
|
|
||||||
fun CommonExtension<*, *, *, *>.androidConfig(project: Project) {
|
fun CommonExtension<*, *, *, *>.androidConfig(project: Project) {
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
|
@ -30,6 +32,8 @@ fun CommonExtension<*, *, *, *>.androidConfig(project: Project) {
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
|
|
@ -44,13 +48,14 @@ fun CommonExtension<*, *, *, *>.androidConfig(project: Project) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun CommonExtension<*, *, *, *>.composeConfig() {
|
fun CommonExtension<*, *, *, *>.composeConfig(libs: LibrariesForLibs) {
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion = "1.4.2" // libs.versions.composecompiler.get()
|
kotlinCompilerExtensionVersion = libs.versions.composecompiler.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue