Merge branch 'develop' into feature/fga/timeline_thread_decoration

This commit is contained in:
ganfra 2023-09-13 11:42:46 +02:00
commit f27f49160d
124 changed files with 566 additions and 304 deletions

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

@ -0,0 +1 @@
Reply action: harmonize conditions in bottom sheet and swipe to reply.

1
changelog.d/1289.feature Normal file
View file

@ -0,0 +1 @@
[Rich text editor] Add feature flag for rich text editor. Markdown support can now be enabled by disabling the rich text editor.

View file

@ -26,10 +26,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.messages.api.MessageComposerContext import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
@ -119,9 +119,8 @@ class SendLocationPresenter @Inject constructor(
Composer( Composer(
inThread = messageComposerContext.composerMode.inThread, inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing, isEditing = messageComposerContext.composerMode.isEditing,
isLocation = true,
isReply = messageComposerContext.composerMode.isReply, isReply = messageComposerContext.composerMode.isReply,
locationType = Composer.LocationType.PinDrop, messageType = Composer.MessageType.LocationPin,
) )
) )
} }
@ -138,9 +137,8 @@ class SendLocationPresenter @Inject constructor(
Composer( Composer(
inThread = messageComposerContext.composerMode.inThread, inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing, isEditing = messageComposerContext.composerMode.isEditing,
isLocation = true,
isReply = messageComposerContext.composerMode.isReply, isReply = messageComposerContext.composerMode.isReply,
locationType = Composer.LocationType.MyLocation, messageType = Composer.MessageType.LocationUser,
) )
) )
} }

View file

@ -308,9 +308,8 @@ class SendLocationPresenterTest {
Composer( Composer(
inThread = false, inThread = false,
isEditing = false, isEditing = false,
isLocation = true,
isReply = false, isReply = false,
locationType = Composer.LocationType.MyLocation, messageType = Composer.MessageType.LocationUser,
) )
) )
} }
@ -365,9 +364,8 @@ class SendLocationPresenterTest {
Composer( Composer(
inThread = false, inThread = false,
isEditing = false, isEditing = false,
isLocation = true,
isReply = false, isReply = false,
locationType = Composer.LocationType.PinDrop, messageType = Composer.MessageType.LocationPin,
) )
) )
} }
@ -412,9 +410,8 @@ class SendLocationPresenterTest {
Composer( Composer(
inThread = false, inThread = false,
isEditing = true, isEditing = true,
isLocation = true,
isReply = false, isReply = false,
locationType = Composer.LocationType.PinDrop, messageType = Composer.MessageType.LocationPin,
) )
) )
} }

View file

@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -65,6 +66,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@ -74,6 +77,7 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.room.canRedactAsState import io.element.android.libraries.matrix.ui.room.canRedactAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -92,6 +96,8 @@ class MessagesPresenter @AssistedInject constructor(
private val messageSummaryFormatter: MessageSummaryFormatter, private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val clipboardHelper: ClipboardHelper, private val clipboardHelper: ClipboardHelper,
private val analyticsService: AnalyticsService,
private val featureFlagService: FeatureFlagService,
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
) : Presenter<MessagesState> { ) : Presenter<MessagesState> {
@ -140,6 +146,11 @@ class MessagesPresenter @AssistedInject constructor(
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId)) timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
} }
var enableTextFormatting by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
enableTextFormatting = featureFlagService.isFeatureEnabled(FeatureFlags.RichTextEditor)
}
fun handleEvents(event: MessagesEvents) { fun handleEvents(event: MessagesEvents) {
when (event) { when (event) {
is MessagesEvents.HandleAction -> { is MessagesEvents.HandleAction -> {
@ -175,6 +186,7 @@ class MessagesPresenter @AssistedInject constructor(
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
showReinvitePrompt = showReinvitePrompt, showReinvitePrompt = showReinvitePrompt,
inviteProgress = inviteProgress.value, inviteProgress = inviteProgress.value,
enableTextFormatting = enableTextFormatting,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }
@ -247,11 +259,15 @@ class MessagesPresenter @AssistedInject constructor(
} }
} }
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { private suspend fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
val composerMode = MessageComposerMode.Edit( val composerMode = MessageComposerMode.Edit(
targetEvent.eventId, targetEvent.eventId,
(targetEvent.content as? TimelineItemTextBasedContent)?.let { (targetEvent.content as? TimelineItemTextBasedContent)?.let {
it.htmlBody ?: it.body if (featureFlagService.isFeatureEnabled(FeatureFlags.RichTextEditor)) {
it.htmlBody ?: it.body
} else {
it.body
}
}.orEmpty(), }.orEmpty(),
targetEvent.transactionId, targetEvent.transactionId,
) )
@ -321,8 +337,10 @@ class MessagesPresenter @AssistedInject constructor(
} }
private suspend fun handleEndPollAction(event: TimelineItem.Event) { private suspend fun handleEndPollAction(event: TimelineItem.Event) {
event.eventId?.let { room.endPoll(it, "The poll with event id: $it has ended.") } event.eventId?.let {
// TODO Polls: Send poll end analytic room.endPoll(it, "The poll with event id: $it has ended.")
analyticsService.capture(PollEnd())
}
} }
private suspend fun handleCopyContents(event: TimelineItem.Event) { private suspend fun handleCopyContents(event: TimelineItem.Event) {

View file

@ -45,5 +45,6 @@ data class MessagesState(
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val inviteProgress: Async<Unit>, val inviteProgress: Async<Unit>,
val showReinvitePrompt: Boolean, val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean,
val eventSink: (MessagesEvents) -> Unit val eventSink: (MessagesEvents) -> Unit
) )

View file

@ -82,5 +82,6 @@ fun aMessagesState() = MessagesState(
snackbarMessage = null, snackbarMessage = null,
inviteProgress = Async.Uninitialized, inviteProgress = Async.Uninitialized,
showReinvitePrompt = false, showReinvitePrompt = false,
enableTextFormatting = true,
eventSink = {} eventSink = {}
) )

View file

@ -123,7 +123,13 @@ fun MessagesView(
fun onMessageLongClicked(event: TimelineItem.Event) { fun onMessageLongClicked(event: TimelineItem.Event) {
Timber.v("OnMessageLongClicked= ${event.id}") Timber.v("OnMessageLongClicked= ${event.id}")
localView.hideKeyboard() localView.hideKeyboard()
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event, state.userHasPermissionToRedact)) state.actionListState.eventSink(
ActionListEvents.ComputeForMessage(
event = event,
canRedact = state.userHasPermissionToRedact,
canSendMessage = state.userHasPermissionToSendMessage,
)
)
} }
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
@ -203,8 +209,8 @@ fun MessagesView(
CustomReactionBottomSheet( CustomReactionBottomSheet(
state = state.customReactionState, state = state.customReactionState,
onEmojiSelected = { eventId, emoji -> onEmojiSelected = { eventId, emoji ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId)) state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
state.customReactionState.eventSink(CustomReactionEvents.DismissCustomReactionSheet) state.customReactionState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
} }
) )
@ -298,6 +304,7 @@ private fun MessagesViewContent(
state = state.composerState, state = state.composerState,
onSendLocationClicked = onSendLocationClicked, onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked, onCreatePollClicked = onCreatePollClicked,
enableTextFormatting = state.enableTextFormatting,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight(Alignment.Bottom) .wrapContentHeight(Alignment.Bottom)

View file

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

View file

@ -62,6 +62,7 @@ class ActionListPresenter @Inject constructor(
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage( is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(
timelineItem = event.event, timelineItem = event.event,
userCanRedact = event.canRedact, userCanRedact = event.canRedact,
userCanSendMessage = event.canSendMessage,
target = target, target = target,
) )
} }
@ -77,6 +78,7 @@ class ActionListPresenter @Inject constructor(
private fun CoroutineScope.computeForMessage( private fun CoroutineScope.computeForMessage(
timelineItem: TimelineItem.Event, timelineItem: TimelineItem.Event,
userCanRedact: Boolean, userCanRedact: Boolean,
userCanSendMessage: Boolean,
target: MutableState<ActionListState.Target> target: MutableState<ActionListState.Target>
) = launch { ) = launch {
target.value = ActionListState.Target.Loading(timelineItem) target.value = ActionListState.Target.Loading(timelineItem)
@ -101,7 +103,8 @@ class ActionListPresenter @Inject constructor(
buildList { buildList {
val isMineOrCanRedact = timelineItem.isMine || userCanRedact val isMineOrCanRedact = timelineItem.isMine || userCanRedact
// TODO Poll: Reply to poll // TODO Poll: Reply to poll. Ensure to update `fun TimelineItemEventContent.canBeReplied()`
// when touching this
// if (timelineItem.isRemote) { // if (timelineItem.isRemote) {
// // Can only reply or forward messages already uploaded to the server // // Can only reply or forward messages already uploaded to the server
// add(TimelineItemAction.Reply) // add(TimelineItemAction.Reply)
@ -126,7 +129,9 @@ class ActionListPresenter @Inject constructor(
else -> buildList<TimelineItemAction> { else -> buildList<TimelineItemAction> {
if (timelineItem.isRemote) { if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server // Can only reply or forward messages already uploaded to the server
add(TimelineItemAction.Reply) if (userCanSendMessage) {
add(TimelineItemAction.Reply)
}
add(TimelineItemAction.Forward) add(TimelineItemAction.Forward)
} }
if (timelineItem.isMine && timelineItem.isTextMessage) { if (timelineItem.isMine && timelineItem.isTextMessage) {

View file

@ -35,5 +35,5 @@ sealed class TimelineItemAction(
data object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit) data object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit)
data object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode) data object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true) data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, VectorIcons.EndPoll) data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, VectorIcons.PollEnd)
} }

View file

@ -55,6 +55,7 @@ internal fun AttachmentsBottomSheet(
state: MessageComposerState, state: MessageComposerState,
onSendLocationClicked: () -> Unit, onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit, onCreatePollClicked: () -> Unit,
enableTextFormatting: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val localView = LocalView.current val localView = LocalView.current
@ -87,6 +88,7 @@ internal fun AttachmentsBottomSheet(
) { ) {
AttachmentSourcePickerMenu( AttachmentSourcePickerMenu(
state = state, state = state,
enableTextFormatting = enableTextFormatting,
onSendLocationClicked = onSendLocationClicked, onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked, onCreatePollClicked = onCreatePollClicked,
) )
@ -100,6 +102,7 @@ internal fun AttachmentSourcePickerMenu(
state: MessageComposerState, state: MessageComposerState,
onSendLocationClicked: () -> Unit, onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit, onCreatePollClicked: () -> Unit,
enableTextFormatting: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -146,11 +149,13 @@ internal fun AttachmentSourcePickerMenu(
text = { Text(stringResource(R.string.screen_room_attachment_source_poll)) }, text = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
) )
} }
ListItem( if (enableTextFormatting) {
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = true)) }, ListItem(
icon = { Icon(Icons.Default.FormatColorText, null) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = true)) },
text = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) }, icon = { Icon(Icons.Default.FormatColorText, null) },
) text = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
)
}
} }
} }
@ -163,5 +168,6 @@ internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
), ),
onSendLocationClicked = {}, onSendLocationClicked = {},
onCreatePollClicked = {}, onCreatePollClicked = {},
enableTextFormatting = true,
) )
} }

View file

@ -148,14 +148,6 @@ class MessageComposerPresenter @Inject constructor(
) )
is MessageComposerEvents.SetMode -> { is MessageComposerEvents.SetMode -> {
messageComposerContext.composerMode = event.composerMode messageComposerContext.composerMode = event.composerMode
analyticsService.capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isReply = messageComposerContext.composerMode.isReply,
isLocation = false,
)
)
} }
MessageComposerEvents.AddAttachment -> localCoroutineScope.launch { MessageComposerEvents.AddAttachment -> localCoroutineScope.launch {
showAttachmentSourcePicker = true showAttachmentSourcePicker = true
@ -238,6 +230,14 @@ class MessageComposerPresenter @Inject constructor(
message.html, message.html,
) )
} }
analyticsService.capture(
Composer(
inThread = capturedMode.inThread,
isEditing = capturedMode.isEditing,
isReply = capturedMode.isReply,
messageType = Composer.MessageType.Text, // Set proper type when we'll be sending other types of messages.
)
)
} }
private fun CoroutineScope.sendAttachment( private fun CoroutineScope.sendAttachment(

View file

@ -31,6 +31,7 @@ fun MessageComposerView(
state: MessageComposerState, state: MessageComposerState,
onSendLocationClicked: () -> Unit, onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit, onCreatePollClicked: () -> Unit,
enableTextFormatting: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onFullscreenToggle() { fun onFullscreenToggle() {
@ -62,6 +63,7 @@ fun MessageComposerView(
state = state, state = state,
onSendLocationClicked = onSendLocationClicked, onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked, onCreatePollClicked = onCreatePollClicked,
enableTextFormatting = enableTextFormatting,
) )
TextComposer( TextComposer(
@ -74,6 +76,7 @@ fun MessageComposerView(
onResetComposerMode = ::onCloseSpecialMode, onResetComposerMode = ::onCloseSpecialMode,
onAddAttachment = ::onAddAttachment, onAddAttachment = ::onAddAttachment,
onDismissTextFormatting = ::onDismissTextFormatting, onDismissTextFormatting = ::onDismissTextFormatting,
enableTextFormatting = enableTextFormatting,
onError = ::onError, onError = ::onError,
) )
} }
@ -95,5 +98,6 @@ private fun ContentToPreview(state: MessageComposerState) {
state = state, state = state,
onSendLocationClicked = {}, onSendLocationClicked = {},
onCreatePollClicked = {}, onCreatePollClicked = {},
enableTextFormatting = true,
) )
} }

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -35,6 +36,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -51,6 +53,7 @@ class TimelinePresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope, private val appScope: CoroutineScope,
private val analyticsService: AnalyticsService,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val timeline = room.timeline private val timeline = room.timeline
@ -93,7 +96,7 @@ class TimelinePresenter @Inject constructor(
pollStartId = event.pollStartId, pollStartId = event.pollStartId,
answers = listOf(event.answerId), answers = listOf(event.answerId),
) )
// TODO Polls: Send poll vote analytic analyticsService.capture(PollVote())
} }
} }
} }
@ -116,7 +119,7 @@ class TimelinePresenter @Inject constructor(
return TimelineState( return TimelineState(
highlightedEventId = highlightedEventId.value, highlightedEventId = highlightedEventId.value,
canReply = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
paginationState = paginationState, paginationState = paginationState,
timelineItems = timelineItems, timelineItems = timelineItems,
hasNewItems = hasNewItems.value, hasNewItems = hasNewItems.value,

View file

@ -26,7 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
data class TimelineState( data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>, val timelineItems: ImmutableList<TimelineItem>,
val highlightedEventId: EventId?, val highlightedEventId: EventId?,
val canReply: Boolean, val userHasPermissionToSendMessage: Boolean,
val paginationState: MatrixTimeline.PaginationState, val paginationState: MatrixTimeline.PaginationState,
val hasNewItems: Boolean, val hasNewItems: Boolean,
val eventSink: (TimelineEvents) -> Unit val eventSink: (TimelineEvents) -> Unit

View file

@ -44,7 +44,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
timelineItems = timelineItems, timelineItems = timelineItems,
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true), paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
highlightedEventId = null, highlightedEventId = null,
canReply = true, userHasPermissionToSendMessage = true,
hasNewItems = false, hasNewItems = false,
eventSink = {}, eventSink = {},
) )

View file

@ -63,6 +63,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
@ -119,7 +120,7 @@ fun TimelineView(
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
highlightedItem = state.highlightedEventId?.value, highlightedItem = state.highlightedEventId?.value,
canReply = state.canReply, userHasPermissionToSendMessage = state.userHasPermissionToSendMessage,
onClick = onMessageClicked, onClick = onMessageClicked,
onLongClick = onMessageLongClicked, onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked, onUserDataClick = onUserDataClicked,
@ -156,7 +157,7 @@ fun TimelineView(
fun TimelineItemRow( fun TimelineItemRow(
timelineItem: TimelineItem, timelineItem: TimelineItem,
highlightedItem: String?, highlightedItem: String?,
canReply: Boolean, userHasPermissionToSendMessage: Boolean,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
@ -189,7 +190,7 @@ fun TimelineItemRow(
TimelineItemEventRow( TimelineItemEventRow(
event = timelineItem, event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(), isHighlighted = highlightedItem == timelineItem.identifier(),
canReply = canReply, canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
onClick = { onClick(timelineItem) }, onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
@ -228,7 +229,7 @@ fun TimelineItemRow(
TimelineItemRow( TimelineItemRow(
timelineItem = subGroupEvent, timelineItem = subGroupEvent,
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
canReply = false, userHasPermissionToSendMessage = false,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,

View file

@ -18,10 +18,14 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -69,3 +73,10 @@ fun ExtraPadding.getStr(fontSize: TextUnit): String {
// A space and some unbreakable spaces // A space and some unbreakable spaces
return " " + "\u00A0".repeat(nbOfSpaces) return " " + "\u00A0".repeat(nbOfSpaces)
} }
@Composable
fun ExtraPadding.getDpSize(): Dp {
if (nbChars == 0) return 0.dp
val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp
return nbChars * timestampFontSize.toDp() / 3
}

View file

@ -18,9 +18,6 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@ -28,7 +25,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -51,18 +47,14 @@ fun TimelineItemTextView(
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textPrimary) { CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textPrimary) {
val htmlDocument = content.htmlDocument val htmlDocument = content.htmlDocument
if (htmlDocument != null) { if (htmlDocument != null) {
// For now we ignore the extra padding for html content, so add some spacing HtmlDocument(
// below the content (as previous behavior) document = htmlDocument,
Column(modifier = modifier) { extraPadding = extraPadding,
HtmlDocument( modifier = modifier,
document = htmlDocument, onTextClicked = onTextClicked,
modifier = Modifier, onTextLongClicked = onTextLongClicked,
onTextClicked = onTextClicked, interactionSource = interactionSource
onTextLongClicked = onTextLongClicked, )
interactionSource = interactionSource
)
Spacer(Modifier.height(16.dp))
}
} else { } else {
Box(modifier) { Box(modifier) {
val textWithPadding = remember(content.body) { val textWithPadding = remember(content.body) {

View file

@ -25,8 +25,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
@ -53,13 +55,18 @@ 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.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.components.event.ExtraPadding
import io.element.android.features.messages.impl.timeline.components.event.getDpSize
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.theme.LinkColor import io.element.android.libraries.theme.LinkColor
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
@ -72,18 +79,28 @@ private const val CHIP_ID = "chip"
@Composable @Composable
fun HtmlDocument( fun HtmlDocument(
document: Document, document: Document,
extraPadding: ExtraPadding,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onTextClicked: () -> Unit = {}, onTextClicked: () -> Unit = {},
onTextLongClicked: () -> Unit = {}, onTextLongClicked: () -> Unit = {},
) { ) {
HtmlBody( FlowRow(
body = document.body(),
interactionSource = interactionSource,
modifier = modifier, modifier = modifier,
onTextClicked = onTextClicked, ) {
onTextLongClicked = onTextLongClicked, HtmlBody(
) body = document.body(),
interactionSource = interactionSource,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
)
Spacer(
modifier = Modifier.size(
width = extraPadding.getDpSize(),
height = ElementTheme.typography.fontBodyXsRegular.fontSize.toDp() * 1.25f
)
)
}
} }
@Composable @Composable
@ -603,5 +620,9 @@ internal fun HtmlDocumentDarkPreview(@PreviewParameter(DocumentProvider::class)
@Composable @Composable
private fun ContentToPreview(document: Document) { private fun ContentToPreview(document: Document) {
HtmlDocument(document, remember { MutableInteractionSource() }) HtmlDocument(
document = document,
extraPadding = noExtraPadding,
interactionSource = remember { MutableInteractionSource() }
)
} }

View file

@ -34,6 +34,18 @@ fun TimelineItemEventContent.canBeCopied(): Boolean =
else -> false else -> false
} }
/**
* Determine if the event content can be replied to.
* Note: it should match the logic in [io.element.android.features.messages.impl.actionlist.ActionListPresenter].
*/
fun TimelineItemEventContent.canBeRepliedTo(): Boolean =
when (this) {
is TimelineItemRedactedContent,
is TimelineItemStateContent,
is TimelineItemPollContent -> false
else -> true
}
/** /**
* Return true if user can react (i.e. send a reaction) on the event content. * Return true if user can react (i.e. send a reaction) on the event content.
*/ */

View file

@ -21,6 +21,7 @@ import app.cash.molecule.RecompositionMode
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 im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.messages.fixtures.aMessageEvent import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.InviteDialogAction import io.element.android.features.messages.impl.InviteDialogAction
@ -575,7 +576,11 @@ class MessagesPresenterTest {
@Test @Test
fun `present - handle poll end`() = runTest { fun `present - handle poll end`() = runTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = createMessagePresenter(matrixRoom = room) val analyticsService = FakeAnalyticsService()
val presenter = createMessagePresenter(
matrixRoom = room,
analyticsService = analyticsService,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -586,7 +591,8 @@ class MessagesPresenterTest {
assertThat(room.endPollInvocations.size).isEqualTo(1) assertThat(room.endPollInvocations.size).isEqualTo(1)
assertThat(room.endPollInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID) assertThat(room.endPollInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
assertThat(room.endPollInvocations.first().text).isEqualTo("The poll with event id: \$anEventId has ended.") assertThat(room.endPollInvocations.first().text).isEqualTo("The poll with event id: \$anEventId has ended.")
// TODO Polls: Test poll end analytic assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents.last()).isEqualTo(PollEnd())
} }
} }
@ -595,6 +601,7 @@ class MessagesPresenterTest {
matrixRoom: MatrixRoom = FakeMatrixRoom(), matrixRoom: MatrixRoom = FakeMatrixRoom(),
navigator: FakeMessagesNavigator = FakeMessagesNavigator(), navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): MessagesPresenter { ): MessagesPresenter {
val messageComposerPresenter = MessageComposerPresenter( val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this, appCoroutineScope = this,
@ -604,7 +611,7 @@ class MessagesPresenterTest {
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl), localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom), mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
analyticsService = FakeAnalyticsService(), analyticsService = analyticsService,
messageComposerContext = MessageComposerContextImpl(), messageComposerContext = MessageComposerContextImpl(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(), richTextEditorStateFactory = TestRichTextEditorStateFactory(),
@ -613,13 +620,15 @@ class MessagesPresenterTest {
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom, room = matrixRoom,
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
appScope = this appScope = this,
analyticsService = analyticsService,
) )
val buildMeta = aBuildMeta() val buildMeta = aBuildMeta()
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta) val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom) val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
val featureFlagsService = FakeFeatureFlagService(mapOf(FeatureFlags.RichTextEditor.key to true))
return MessagesPresenter( return MessagesPresenter(
room = matrixRoom, room = matrixRoom,
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,
@ -633,6 +642,8 @@ class MessagesPresenterTest {
messageSummaryFormatter = FakeMessageSummaryFormatter(), messageSummaryFormatter = FakeMessageSummaryFormatter(),
navigator = navigator, navigator = navigator,
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
analyticsService = analyticsService,
featureFlagService = featureFlagsService,
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
) )
} }

View file

@ -64,7 +64,7 @@ class ActionListPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent(isMine = true, content = TimelineItemRedactedContent) val messageEvent = aMessageEvent(isMine = true, content = TimelineItemRedactedContent)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -89,7 +89,7 @@ class ActionListPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent(isMine = false, content = TimelineItemRedactedContent) val messageEvent = aMessageEvent(isMine = false, content = TimelineItemRedactedContent)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -117,7 +117,7 @@ class ActionListPresenterTest {
isMine = false, isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -138,6 +138,37 @@ class ActionListPresenterTest {
} }
} }
@Test
fun `present - compute for others message cannot sent message`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Forward,
TimelineItemAction.Copy,
TimelineItemAction.Developer,
TimelineItemAction.ReportContent,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test @Test
fun `present - compute for others message and can redact`() = runTest { fun `present - compute for others message and can redact`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = true) val presenter = anActionListPresenter(isBuildDebuggable = true)
@ -149,7 +180,7 @@ class ActionListPresenterTest {
isMine = false, isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, true)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true))
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
@ -180,7 +211,7 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -213,7 +244,7 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = aTimelineItemImageContent(), content = aTimelineItemImageContent(),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -244,7 +275,7 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = aTimelineItemStateEventContent(), content = aTimelineItemStateEventContent(),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -273,7 +304,7 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = aTimelineItemStateEventContent(), content = aTimelineItemStateEventContent(),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -301,7 +332,7 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -338,10 +369,10 @@ class ActionListPresenterTest {
content = TimelineItemRedactedContent, content = TimelineItemRedactedContent,
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java) assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent, canRedact = false, canSendMessage = true))
awaitItem().run { awaitItem().run {
assertThat(target).isEqualTo(ActionListState.Target.None) assertThat(target).isEqualTo(ActionListState.Target.None)
assertThat(displayEmojiReactions).isFalse() assertThat(displayEmojiReactions).isFalse()
@ -362,7 +393,7 @@ class ActionListPresenterTest {
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false), content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
@ -389,7 +420,7 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = aTimelineItemPollContent(), content = aTimelineItemPollContent(),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
@ -415,7 +446,7 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = aTimelineItemPollContent(isEnded = true), content = aTimelineItemPollContent(isEnded = true),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(

View file

@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode
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 im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
@ -37,6 +38,7 @@ import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitWithLatch import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@ -260,7 +262,11 @@ class TimelinePresenterTest {
@Test @Test
fun `present - PollAnswerSelected event calls into rust room api and analytics`() = runTest { fun `present - PollAnswerSelected event calls into rust room api and analytics`() = runTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = createTimelinePresenter(room) val analyticsService = FakeAnalyticsService()
val presenter = createTimelinePresenter(
room = room,
analyticsService = analyticsService,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -271,7 +277,8 @@ class TimelinePresenterTest {
assertThat(room.sendPollResponseInvocations.size).isEqualTo(1) assertThat(room.sendPollResponseInvocations.size).isEqualTo(1)
assertThat(room.sendPollResponseInvocations.first().answers).isEqualTo(listOf("anAnswerId")) assertThat(room.sendPollResponseInvocations.first().answers).isEqualTo(listOf("anAnswerId"))
assertThat(room.sendPollResponseInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID) assertThat(room.sendPollResponseInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
// TODO Polls: Test poll vote analytic assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents.last()).isEqualTo(PollVote())
} }
private fun TestScope.createTimelinePresenter( private fun TestScope.createTimelinePresenter(
@ -282,18 +289,21 @@ class TimelinePresenterTest {
timelineItemsFactory = timelineItemsFactory, timelineItemsFactory = timelineItemsFactory,
room = FakeMatrixRoom(matrixTimeline = timeline), room = FakeMatrixRoom(matrixTimeline = timeline),
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
appScope = this appScope = this,
analyticsService = FakeAnalyticsService(),
) )
} }
private fun TestScope.createTimelinePresenter( private fun TestScope.createTimelinePresenter(
room: MatrixRoom, room: MatrixRoom,
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): TimelinePresenter { ): TimelinePresenter {
return TimelinePresenter( return TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
room = room, room = room,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
appScope = this appScope = this,
analyticsService = analyticsService,
) )
} }
} }

View file

@ -24,8 +24,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Poll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -86,13 +84,13 @@ internal fun PollTitle(
) { ) {
if (isPollEnded) { if (isPollEnded) {
Icon( Icon(
resourceId = VectorIcons.EndPoll, resourceId = VectorIcons.PollEnd,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
) )
} else { } else {
Icon( Icon(
imageVector = Icons.Outlined.Poll, resourceId = VectorIcons.Poll,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
) )

View file

@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.features.messages.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
@ -48,6 +49,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -24,15 +24,17 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class CreatePollNode @AssistedInject constructor( class CreatePollNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: CreatePollPresenter.Factory, presenterFactory: CreatePollPresenter.Factory,
// analyticsService: AnalyticsService, // TODO Polls: add analytics analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val presenter = presenterFactory.create(backNavigator = ::navigateUp) private val presenter = presenterFactory.create(backNavigator = ::navigateUp)
@ -40,8 +42,7 @@ class CreatePollNode @AssistedInject constructor(
init { init {
lifecycle.subscribe( lifecycle.subscribe(
onResume = { onResume = {
// TODO Polls: add analytics analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreatePollView))
// analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.PollView))
} }
) )
} }

View file

@ -29,9 +29,13 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.PollCreation
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -44,9 +48,9 @@ private const val MAX_SELECTIONS = 1
class CreatePollPresenter @AssistedInject constructor( class CreatePollPresenter @AssistedInject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
// private val analyticsService: AnalyticsService, // TODO Polls: add analytics private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext,
@Assisted private val navigateUp: () -> Unit, @Assisted private val navigateUp: () -> Unit,
// private val messageComposerContext: MessageComposerContext, // TODO Polls: add analytics
) : Presenter<CreatePollState> { ) : Presenter<CreatePollState> {
@AssistedFactory @AssistedFactory
@ -78,7 +82,21 @@ class CreatePollPresenter @AssistedInject constructor(
maxSelections = MAX_SELECTIONS, maxSelections = MAX_SELECTIONS,
pollKind = pollKind, pollKind = pollKind,
) )
// analyticsService.capture(PollCreate()) // TODO Polls: add analytics analyticsService.capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isReply = messageComposerContext.composerMode.isReply,
messageType = Composer.MessageType.Poll,
)
)
analyticsService.capture(
PollCreation(
action = PollCreation.Action.Create,
isUndisclosed = pollKind == PollKind.Undisclosed,
numberOfAnswers = answers.size,
)
)
navigateUp() navigateUp()
} else { } else {
Timber.d("Cannot create poll") Timber.d("Cannot create poll")
@ -153,7 +171,7 @@ private val pollKindSaver: Saver<MutableState<PollKind>, Boolean> = Saver(
}, },
restore = { restore = {
mutableStateOf( mutableStateOf(
when(it) { when (it) {
true -> PollKind.Undisclosed true -> PollKind.Undisclosed
else -> PollKind.Disclosed else -> PollKind.Disclosed
} }

View file

@ -20,9 +20,13 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.PollCreation
import io.element.android.features.messages.test.MessageComposerContextFake
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.test.room.CreatePollInvocation import io.element.android.libraries.matrix.test.room.CreatePollInvocation
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -37,11 +41,13 @@ class CreatePollPresenterTest {
private var navUpInvocationsCount = 0 private var navUpInvocationsCount = 0
private val fakeMatrixRoom = FakeMatrixRoom() private val fakeMatrixRoom = FakeMatrixRoom()
// private val fakeAnalyticsService = FakeAnalyticsService() // TODO Polls: add analytics private val fakeAnalyticsService = FakeAnalyticsService()
private val messageComposerContextFake = MessageComposerContextFake()
private val presenter = CreatePollPresenter( private val presenter = CreatePollPresenter(
room = fakeMatrixRoom, room = fakeMatrixRoom,
// analyticsService = fakeAnalyticsService, // TODO Polls: add analytics analyticsService = fakeAnalyticsService,
messageComposerContext = messageComposerContextFake,
navigateUp = { navUpInvocationsCount++ }, navigateUp = { navUpInvocationsCount++ },
) )
@ -104,6 +110,22 @@ class CreatePollPresenterTest {
pollKind = PollKind.Disclosed pollKind = PollKind.Disclosed
) )
) )
Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
Truth.assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
Composer(
inThread = false,
isEditing = false,
isReply = false,
messageType = Composer.MessageType.Poll,
)
)
Truth.assertThat(fakeAnalyticsService.capturedEvents[1]).isEqualTo(
PollCreation(
action = PollCreation.Action.Create,
isUndisclosed = false,
numberOfAnswers = 2,
)
)
} }
} }

View file

@ -158,13 +158,19 @@ private fun DefaultRoomListTopBar(
.nestedScroll(scrollBehavior.nestedScrollConnection) .nestedScroll(scrollBehavior.nestedScrollConnection)
.avatarBloom( .avatarBloom(
avatarData = avatarData, avatarData = avatarData,
background = ElementTheme.materialColors.background, background = if (ElementTheme.isLightTheme) {
// Workaround to display a very subtle bloom for avatars with very soft colors
Color(0xFFF9F9F9)
} else {
ElementTheme.materialColors.background
},
blurSize = DpSize(avatarBloomSize, avatarBloomSize), blurSize = DpSize(avatarBloomSize, avatarBloomSize),
offset = DpOffset(24.dp, 24.dp + statusBarPadding), offset = DpOffset(24.dp, 24.dp + statusBarPadding),
clipToSize = if (appBarHeight > 0) DpSize( clipToSize = if (appBarHeight > 0) DpSize(
avatarBloomSize, avatarBloomSize,
appBarHeight.toDp() appBarHeight.toDp()
) else DpSize.Unspecified, ) else DpSize.Unspecified,
bottomSoftEdgeColor = ElementTheme.materialColors.background,
bottomSoftEdgeAlpha = 1f - collapsedFraction, bottomSoftEdgeAlpha = 1f - collapsedFraction,
alpha = if (areSearchResultsDisplayed) 0f else 1f, alpha = if (areSearchResultsDisplayed) 0f else 1f,
) )

View file

@ -46,7 +46,7 @@ dependencyanalysis = "1.21.0"
stem = "2.3.0" stem = "2.3.0"
sqldelight = "1.5.5" sqldelight = "1.5.5"
telephoto = "0.6.0" telephoto = "0.6.0"
wysiwyg = "2.9.0" wysiwyg = "2.10.0"
# DI # DI
dagger = "2.48" dagger = "2.48"
@ -149,7 +149,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.50" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.51"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
@ -169,7 +169,7 @@ maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1"
# Analytics # Analytics
posthog = "com.posthog.android:posthog:2.0.3" posthog = "com.posthog.android:posthog:2.0.3"
sentry = "io.sentry:sentry-android:6.29.0" sentry = "io.sentry:sentry-android:6.29.0"
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:42b2faa417c1e95f430bf8f6e379adba25ad5ef8" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:e9cd9adaf18cec52ed851395eb84358b4f9b8d7f"
# Emojibase # Emojibase
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3" matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3"

View file

@ -27,7 +27,8 @@ object VectorIcons {
val ReportContent = R.drawable.ic_report_content val ReportContent = R.drawable.ic_report_content
val Groups = R.drawable.ic_groups val Groups = R.drawable.ic_groups
val Share = R.drawable.ic_share val Share = R.drawable.ic_share
val EndPoll = R.drawable.ic_poll_end val Poll = R.drawable.ic_poll
val PollEnd = R.drawable.ic_poll_end
val Bold = R.drawable.ic_bold val Bold = R.drawable.ic_bold
val BulletList = R.drawable.ic_bullet_list val BulletList = R.drawable.ic_bullet_list
val CodeBlock = R.drawable.ic_code_block val CodeBlock = R.drawable.ic_code_block

View file

@ -45,6 +45,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -96,8 +97,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.toOffset import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize import androidx.compose.ui.unit.toSize
import coil.compose.rememberAsyncImagePainter import coil.imageLoader
import coil.request.DefaultRequestOptions
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Size
import com.airbnb.android.showkase.annotation.ShowkaseComposable import com.airbnb.android.showkase.annotation.ShowkaseComposable
import com.vanniktech.blurhash.BlurHash import com.vanniktech.blurhash.BlurHash
import io.element.android.libraries.designsystem.R import io.element.android.libraries.designsystem.R
@ -114,6 +117,8 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -125,7 +130,7 @@ object BloomDefaults {
* Number of components to use with BlurHash to generate the blur effect. * Number of components to use with BlurHash to generate the blur effect.
* Larger values mean more detailed blurs. * Larger values mean more detailed blurs.
*/ */
const val HASH_COMPONENTS = 8 const val HASH_COMPONENTS = 5
/** Default bloom layers. */ /** Default bloom layers. */
@Composable @Composable
@ -159,6 +164,7 @@ data class BloomLayer(
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component. * @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
* @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped. * @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
* @param layerConfiguration The configuration for the bloom layers. If not specified the default layers configuration will be used. * @param layerConfiguration The configuration for the bloom layers. If not specified the default layers configuration will be used.
* @param bottomSoftEdgeColor The color to use for the bottom soft edge. If not specified the [background] color will be used.
* @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn. * @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
* @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge. * @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
* @param alpha The alpha value to apply to the bloom effect. * @param alpha The alpha value to apply to the bloom effect.
@ -170,6 +176,7 @@ fun Modifier.bloom(
offset: DpOffset = DpOffset.Unspecified, offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified, clipToSize: DpSize = DpSize.Unspecified,
layerConfiguration: ImmutableList<BloomLayer>? = null, layerConfiguration: ImmutableList<BloomLayer>? = null,
bottomSoftEdgeColor: Color = background,
bottomSoftEdgeHeight: Dp = 40.dp, bottomSoftEdgeHeight: Dp = 40.dp,
@FloatRange(from = 0.0, to = 1.0) @FloatRange(from = 0.0, to = 1.0)
bottomSoftEdgeAlpha: Float = 1.0f, bottomSoftEdgeAlpha: Float = 1.0f,
@ -238,7 +245,7 @@ fun Modifier.bloom(
val bottomEdgeGradient = LinearGradientShader( val bottomEdgeGradient = LinearGradientShader(
from = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeightPixels).toOffset(), from = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeightPixels).toOffset(),
to = IntOffset(0, clipToPixelSize.height).toOffset(), to = IntOffset(0, clipToPixelSize.height).toOffset(),
listOf(Color.Transparent, background), listOf(Color.Transparent, bottomSoftEdgeColor),
listOf(0f, 1f) listOf(0f, 1f)
) )
val bottomEdgeGradientBrush = ShaderBrush(bottomEdgeGradient) val bottomEdgeGradientBrush = ShaderBrush(bottomEdgeGradient)
@ -297,6 +304,7 @@ fun Modifier.bloom(
* @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component. * @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component. * @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
* @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped. * @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
* @param bottomSoftEdgeColor The color to use for the bottom soft edge. If not specified the [background] color will be used.
* @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn. * @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
* @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge. * @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
* @param alpha The alpha value to apply to the bloom effect. * @param alpha The alpha value to apply to the bloom effect.
@ -307,6 +315,7 @@ fun Modifier.avatarBloom(
blurSize: DpSize = DpSize.Unspecified, blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified, offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified, clipToSize: DpSize = DpSize.Unspecified,
bottomSoftEdgeColor: Color = background,
bottomSoftEdgeHeight: Dp = 40.dp, bottomSoftEdgeHeight: Dp = 40.dp,
@FloatRange(from = 0.0, to = 1.0) @FloatRange(from = 0.0, to = 1.0)
bottomSoftEdgeAlpha: Float = 1.0f, bottomSoftEdgeAlpha: Float = 1.0f,
@ -317,21 +326,35 @@ fun Modifier.avatarBloom(
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
avatarData ?: return@composed this avatarData ?: return@composed this
// Request the avatar contents to use as the bloom source
val context = LocalContext.current
val density = LocalDensity.current
if (avatarData.url != null) { if (avatarData.url != null) {
// Request the avatar contents to use as the bloom source val painterRequest = remember(avatarData) {
val painter = rememberAsyncImagePainter( ImageRequest.Builder(context)
ImageRequest.Builder(LocalContext.current)
.data(avatarData) .data(avatarData)
// Allow cache and default dispatchers
.defaults(DefaultRequestOptions())
// Needed to be able to read pixels from the Bitmap for the hash // Needed to be able to read pixels from the Bitmap for the hash
.allowHardware(false) .allowHardware(false)
// Reduce size so it loads faster for large avatars
.size(with(density) { Size(64.dp.roundToPx(), 64.dp.roundToPx()) })
.build() .build()
) }
var blurHash by remember { mutableStateOf<String?>(null) }
// By making it saveable, we'll 'cache' the previous bloom effect until a new one is loaded
var blurHash by rememberSaveable(avatarData) { mutableStateOf<String?>(null) }
LaunchedEffect(avatarData) { LaunchedEffect(avatarData) {
val drawable = painter.imageLoader.execute(painter.request).drawable ?: return@LaunchedEffect withContext(Dispatchers.IO) {
val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect val drawable =
blurHash = BlurHash.encode(bitmap, BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS) context.imageLoader.execute(painterRequest).drawable ?: return@withContext
val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@withContext
blurHash = BlurHash.encode(
bitmap,
BloomDefaults.HASH_COMPONENTS,
BloomDefaults.HASH_COMPONENTS
)
}
} }
bloom( bloom(
@ -363,6 +386,7 @@ fun Modifier.avatarBloom(
blurSize = blurSize, blurSize = blurSize,
offset = offset, offset = offset,
clipToSize = clipToSize, clipToSize = clipToSize,
bottomSoftEdgeColor = bottomSoftEdgeColor,
bottomSoftEdgeHeight = bottomSoftEdgeHeight, bottomSoftEdgeHeight = bottomSoftEdgeHeight,
bottomSoftEdgeAlpha = bottomSoftEdgeAlpha, bottomSoftEdgeAlpha = bottomSoftEdgeAlpha,
alpha = alpha, alpha = alpha,
@ -517,7 +541,13 @@ internal fun BloomInitialsPreview(@PreviewParameter(InitialsColorStateProvider::
modifier = Modifier.size(256.dp) modifier = Modifier.size(256.dp)
.bloom( .bloom(
hash = hash, hash = hash,
background = ElementTheme.materialColors.background, background = if (ElementTheme.isLightTheme) {
// Workaround to display a very subtle bloom for avatars with very soft colors
Color(0xFFF9F9F9)
} else {
ElementTheme.materialColors.background
},
bottomSoftEdgeColor = ElementTheme.materialColors.background,
blurSize = DpSize(256.dp, 256.dp), blurSize = DpSize(256.dp, 256.dp),
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M7.333,15.583C7.593,15.583 7.811,15.495 7.986,15.32C8.162,15.144 8.25,14.926 8.25,14.667V10.083C8.25,9.824 8.162,9.606 7.986,9.43C7.811,9.255 7.593,9.167 7.333,9.167C7.074,9.167 6.856,9.255 6.68,9.43C6.505,9.606 6.417,9.824 6.417,10.083V14.667C6.417,14.926 6.505,15.144 6.68,15.32C6.856,15.495 7.074,15.583 7.333,15.583ZM11,15.583C11.26,15.583 11.477,15.495 11.653,15.32C11.829,15.144 11.917,14.926 11.917,14.667V7.333C11.917,7.074 11.829,6.856 11.653,6.68C11.477,6.505 11.26,6.417 11,6.417C10.74,6.417 10.523,6.505 10.347,6.68C10.171,6.856 10.083,7.074 10.083,7.333V14.667C10.083,14.926 10.171,15.144 10.347,15.32C10.523,15.495 10.74,15.583 11,15.583ZM14.667,15.583C14.926,15.583 15.144,15.495 15.32,15.32C15.495,15.144 15.583,14.926 15.583,14.667V12.833C15.583,12.574 15.495,12.356 15.32,12.18C15.144,12.005 14.926,11.917 14.667,11.917C14.407,11.917 14.189,12.005 14.014,12.18C13.838,12.356 13.75,12.574 13.75,12.833V14.667C13.75,14.926 13.838,15.144 14.014,15.32C14.189,15.495 14.407,15.583 14.667,15.583ZM4.583,19.25C4.079,19.25 3.648,19.07 3.289,18.712C2.93,18.352 2.75,17.921 2.75,17.417V4.583C2.75,4.079 2.93,3.648 3.289,3.289C3.648,2.93 4.079,2.75 4.583,2.75H17.417C17.921,2.75 18.352,2.93 18.712,3.289C19.07,3.648 19.25,4.079 19.25,4.583V17.417C19.25,17.921 19.07,18.352 18.712,18.712C18.352,19.07 17.921,19.25 17.417,19.25H4.583ZM4.583,17.417H17.417V4.583H4.583V17.417Z"
android:fillColor="#1B1D22"/>
</vector>

View file

@ -1,14 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp" android:width="22dp"
android:height="19dp" android:height="22dp"
android:viewportWidth="20" android:viewportWidth="22"
android:viewportHeight="19"> android:viewportHeight="22">
<path <path
android:pathData="M18,8.659V17C18,17.55 17.804,18.021 17.413,18.413C17.021,18.804 16.55,19 16,19H2C1.45,19 0.979,18.804 0.587,18.413C0.196,18.021 0,17.55 0,17V3C0,2.45 0.196,1.979 0.587,1.587C0.979,1.196 1.45,1 2,1H10.341C10.12,1.626 10,2.299 10,3L2,3V17H16V9C16.701,9 17.374,8.88 18,8.659ZM5.713,14.712C5.521,14.904 5.283,15 5,15C4.717,15 4.479,14.904 4.287,14.712C4.096,14.521 4,14.283 4,14V9C4,8.717 4.096,8.479 4.287,8.288C4.479,8.096 4.717,8 5,8C5.283,8 5.521,8.096 5.713,8.288C5.904,8.479 6,8.717 6,9V14C6,14.283 5.904,14.521 5.713,14.712ZM9.712,14.712C9.521,14.904 9.283,15 9,15C8.717,15 8.479,14.904 8.288,14.712C8.096,14.521 8,14.283 8,14V6C8,5.717 8.096,5.479 8.288,5.287C8.479,5.096 8.717,5 9,5C9.283,5 9.521,5.096 9.712,5.287C9.904,5.479 10,5.717 10,6V14C10,14.283 9.904,14.521 9.712,14.712ZM13.712,14.712C13.521,14.904 13.283,15 13,15C12.717,15 12.479,14.904 12.288,14.712C12.096,14.521 12,14.283 12,14V12C12,11.717 12.096,11.479 12.288,11.288C12.479,11.096 12.717,11 13,11C13.283,11 13.521,11.096 13.712,11.288C13.904,11.479 14,11.717 14,12V14C14,14.283 13.904,14.521 13.712,14.712Z" android:pathData="M17.148,7.065L20.815,3.399C21.173,3.041 21.173,2.46 20.815,2.102C20.457,1.744 19.876,1.744 19.518,2.102L16.5,5.121L15.315,3.936C14.957,3.578 14.377,3.578 14.019,3.936C13.66,4.294 13.66,4.874 14.019,5.232L15.852,7.065C16.21,7.423 16.79,7.423 17.148,7.065Z"
android:fillColor="#1B1D22" android:fillColor="#1B1D22"/>
android:fillType="evenOdd"/>
<path <path
android:pathData="M19.707,0.293C20.098,0.683 20.098,1.317 19.707,1.707L15.707,5.707C15.317,6.098 14.683,6.098 14.293,5.707L12.293,3.707C11.902,3.317 11.902,2.683 12.293,2.293C12.683,1.902 13.317,1.902 13.707,2.293L15,3.586L18.293,0.293C18.683,-0.098 19.317,-0.098 19.707,0.293Z" android:pathData="M19.25,17.417V9.771C18.677,9.974 18.059,10.084 17.417,10.084V17.417H4.583V4.584L11.917,4.584C11.917,3.941 12.027,3.324 12.23,2.751H4.583C4.079,2.751 3.648,2.93 3.289,3.289C2.93,3.648 2.75,4.08 2.75,4.584V17.417C2.75,17.921 2.93,18.353 3.289,18.712C3.648,19.071 4.079,19.251 4.583,19.251H17.417C17.921,19.251 18.352,19.071 18.712,18.712C19.07,18.353 19.25,17.921 19.25,17.417Z"
android:fillColor="#1B1D22" android:fillColor="#1B1D22"/>
android:fillType="evenOdd"/> <path
android:pathData="M7.333,15.584C7.593,15.584 7.811,15.496 7.986,15.32C8.162,15.145 8.25,14.927 8.25,14.667V10.084C8.25,9.824 8.162,9.607 7.986,9.431C7.811,9.255 7.593,9.167 7.333,9.167C7.074,9.167 6.856,9.255 6.68,9.431C6.505,9.607 6.417,9.824 6.417,10.084V14.667C6.417,14.927 6.505,15.145 6.68,15.32C6.856,15.496 7.074,15.584 7.333,15.584Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M11,15.584C11.26,15.584 11.477,15.496 11.653,15.32C11.829,15.145 11.917,14.927 11.917,14.667V7.334C11.917,7.074 11.829,6.857 11.653,6.681C11.477,6.505 11.26,6.417 11,6.417C10.74,6.417 10.523,6.505 10.347,6.681C10.171,6.857 10.083,7.074 10.083,7.334V14.667C10.083,14.927 10.171,15.145 10.347,15.32C10.523,15.496 10.74,15.584 11,15.584Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M14.667,15.584C14.926,15.584 15.144,15.496 15.32,15.32C15.495,15.145 15.583,14.927 15.583,14.667V12.834C15.583,12.574 15.495,12.357 15.32,12.181C15.144,12.005 14.926,11.917 14.667,11.917C14.407,11.917 14.189,12.005 14.014,12.181C13.838,12.357 13.75,12.574 13.75,12.834V14.667C13.75,14.927 13.838,15.145 14.014,15.32C14.189,15.496 14.407,15.584 14.667,15.584Z"
android:fillColor="#1B1D22"/>
</vector> </vector>

View file

@ -44,4 +44,9 @@ enum class FeatureFlags(
// Do not forget to edit StaticFeatureFlagProvider when enabling the feature. // Do not forget to edit StaticFeatureFlagProvider when enabling the feature.
defaultValue = false, defaultValue = false,
), ),
RichTextEditor(
key = "feature.richtexteditor",
title = "Enable rich text editor",
defaultValue = true,
),
} }

View file

@ -35,6 +35,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.LocationSharing -> true FeatureFlags.LocationSharing -> true
FeatureFlags.Polls -> true FeatureFlags.Polls -> true
FeatureFlags.NotificationSettings -> false FeatureFlags.NotificationSettings -> false
FeatureFlags.RichTextEditor -> true
} }
} else { } else {
false false

View file

@ -79,11 +79,11 @@ interface MatrixRoom : Closeable {
suspend fun userAvatarUrl(userId: UserId): Result<String?> suspend fun userAvatarUrl(userId: UserId): Result<String?>
suspend fun sendMessage(body: String, htmlBody: String): Result<Unit> suspend fun sendMessage(body: String, htmlBody: String?): Result<Unit>
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit> suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result<Unit>
suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit> suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result<Unit>
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit> suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>

View file

@ -26,6 +26,9 @@ val oidcConfiguration: OidcConfiguration = OidcConfiguration(
logoUri = "https://element.io/mobile-icon.png", logoUri = "https://element.io/mobile-icon.png",
tosUri = "https://element.io/acceptable-use-policy-terms", tosUri = "https://element.io/acceptable-use-policy-terms",
policyUri = "https://element.io/privacy", policyUri = "https://element.io/privacy",
contacts = listOf(
"support@element.io",
),
/** /**
* Some homeservers/auth issuers don't support dynamic client registration, and have to be registered manually * Some homeservers/auth issuers don't support dynamic client registration, and have to be registered manually
*/ */

View file

@ -63,10 +63,12 @@ import org.matrix.rustcomponents.sdk.RequiredState
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMember import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.RoomSubscription import org.matrix.rustcomponents.sdk.RoomSubscription
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.genTransactionId import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
@ -227,32 +229,32 @@ class RustMatrixRoom(
} }
} }
override suspend fun sendMessage(body: String, htmlBody: String): Result<Unit> = withContext(roomDispatcher) { override suspend fun sendMessage(body: String, htmlBody: String?): Result<Unit> = withContext(roomDispatcher) {
val transactionId = genTransactionId() val transactionId = genTransactionId()
messageEventContentFromHtml(body, htmlBody).use { content -> messageEventContentFromParts(body, htmlBody).use { content ->
runCatching { runCatching {
innerRoom.send(content, transactionId) innerRoom.send(content, transactionId)
} }
} }
} }
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit> = override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result<Unit> =
withContext(roomDispatcher) { withContext(roomDispatcher) {
if (originalEventId != null) { if (originalEventId != null) {
runCatching { runCatching {
innerRoom.edit(messageEventContentFromHtml(body, htmlBody), originalEventId.value, transactionId?.value) innerRoom.edit(messageEventContentFromParts(body, htmlBody), originalEventId.value, transactionId?.value)
} }
} else { } else {
runCatching { runCatching {
transactionId?.let { cancelSend(it) } transactionId?.let { cancelSend(it) }
innerRoom.send(messageEventContentFromHtml(body, htmlBody), genTransactionId()) innerRoom.send(messageEventContentFromParts(body, htmlBody), genTransactionId())
} }
} }
} }
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit> = withContext(roomDispatcher) { override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result<Unit> = withContext(roomDispatcher) {
runCatching { runCatching {
innerRoom.sendReply(messageEventContentFromHtml(body, htmlBody), eventId.value, genTransactionId()) innerRoom.sendReply(messageEventContentFromParts(body, htmlBody), eventId.value, genTransactionId())
} }
} }
@ -456,4 +458,11 @@ class RustMatrixRoom(
MediaUploadHandlerImpl(files, handle()) MediaUploadHandlerImpl(files, handle())
} }
} }
private fun messageEventContentFromParts(body: String, htmlBody: String?): RoomMessageEventContentWithoutRelation =
if(htmlBody != null) {
messageEventContentFromHtml(body, htmlBody)
} else {
messageEventContentFromMarkdown(body)
}
} }

View file

@ -92,7 +92,7 @@ class FakeMatrixRoom(
private var sendPollResponseResult = Result.success(Unit) private var sendPollResponseResult = Result.success(Unit)
private var endPollResult = Result.success(Unit) private var endPollResult = Result.success(Unit)
private var progressCallbackValues = emptyList<Pair<Long, Long>>() private var progressCallbackValues = emptyList<Pair<Long, Long>>()
val editMessageCalls = mutableListOf<Pair<String, String>>() val editMessageCalls = mutableListOf<Pair<String, String?>>()
var sendMediaCount = 0 var sendMediaCount = 0
private set private set
@ -171,7 +171,7 @@ class FakeMatrixRoom(
userAvatarUrlResult userAvatarUrlResult
} }
override suspend fun sendMessage(body: String, htmlBody: String) = simulateLongTask { override suspend fun sendMessage(body: String, htmlBody: String?) = simulateLongTask {
Result.success(Unit) Result.success(Unit)
} }
@ -200,15 +200,15 @@ class FakeMatrixRoom(
return cancelSendResult return cancelSendResult
} }
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit> { override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result<Unit> {
editMessageCalls += body to htmlBody editMessageCalls += body to htmlBody
return Result.success(Unit) return Result.success(Unit)
} }
var replyMessageParameter: Pair<String, String>? = null var replyMessageParameter: Pair<String, String?>? = null
private set private set
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit> { override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result<Unit> {
replyMessageParameter = body to htmlBody replyMessageParameter = body to htmlBody
return Result.success(Unit) return Result.success(Unit)
} }

View file

@ -17,6 +17,6 @@
package io.element.android.libraries.textcomposer package io.element.android.libraries.textcomposer
data class Message( data class Message(
val html: String, val html: String?,
val markdown: String, val markdown: String,
) )

View file

@ -91,6 +91,7 @@ fun TextComposer(
state: RichTextEditorState, state: RichTextEditorState,
composerMode: MessageComposerMode, composerMode: MessageComposerMode,
canSendMessage: Boolean, canSendMessage: Boolean,
enableTextFormatting: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showTextFormatting: Boolean = false, showTextFormatting: Boolean = false,
onRequestFocus: () -> Unit = {}, onRequestFocus: () -> Unit = {},
@ -101,7 +102,8 @@ fun TextComposer(
onError: (Throwable) -> Unit = {}, onError: (Throwable) -> Unit = {},
) { ) {
val onSendClicked = { val onSendClicked = {
onSendMessage(Message(html = state.messageHtml, markdown = state.messageMarkdown)) val html = if (enableTextFormatting) state.messageHtml else null
onSendMessage(Message(html = html, markdown = state.messageMarkdown))
} }
Column( Column(
@ -600,6 +602,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
onSendMessage = {}, onSendMessage = {},
composerMode = MessageComposerMode.Normal(""), composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true,
) )
TextComposer( TextComposer(
RichTextEditorState("A message", fake = true).apply { requestFocus() }, RichTextEditorState("A message", fake = true).apply { requestFocus() },
@ -607,6 +610,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
onSendMessage = {}, onSendMessage = {},
composerMode = MessageComposerMode.Normal(""), composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true,
) )
TextComposer( TextComposer(
RichTextEditorState( RichTextEditorState(
@ -619,6 +623,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
onSendMessage = {}, onSendMessage = {},
composerMode = MessageComposerMode.Normal(""), composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true,
) )
TextComposer( TextComposer(
RichTextEditorState("A message without focus", fake = true), RichTextEditorState("A message without focus", fake = true),
@ -626,6 +631,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
onSendMessage = {}, onSendMessage = {},
composerMode = MessageComposerMode.Normal(""), composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true,
) )
} }
} }
@ -639,18 +645,21 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
canSendMessage = false, canSendMessage = false,
showTextFormatting = true, showTextFormatting = true,
composerMode = MessageComposerMode.Normal(""), composerMode = MessageComposerMode.Normal(""),
enableTextFormatting = true,
) )
TextComposer( TextComposer(
RichTextEditorState("A message", fake = true), RichTextEditorState("A message", fake = true),
canSendMessage = true, canSendMessage = true,
showTextFormatting = true, showTextFormatting = true,
composerMode = MessageComposerMode.Normal(""), composerMode = MessageComposerMode.Normal(""),
enableTextFormatting = true,
) )
TextComposer( TextComposer(
RichTextEditorState("A message\nWith several lines\nTo preview larger textfields and long lines with overflow", fake = true), RichTextEditorState("A message\nWith several lines\nTo preview larger textfields and long lines with overflow", fake = true),
canSendMessage = true, canSendMessage = true,
showTextFormatting = true, showTextFormatting = true,
composerMode = MessageComposerMode.Normal(""), composerMode = MessageComposerMode.Normal(""),
enableTextFormatting = true,
) )
} }
} }
@ -664,6 +673,7 @@ internal fun TextComposerEditPreview() = ElementPreview {
onSendMessage = {}, onSendMessage = {},
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true,
) )
} }
@ -684,6 +694,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
"To preview larger textfields and long lines with overflow" "To preview larger textfields and long lines with overflow"
), ),
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true,
) )
TextComposer( TextComposer(
RichTextEditorState("A message", fake = true), RichTextEditorState("A message", fake = true),
@ -701,6 +712,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "image.jpg" defaultContent = "image.jpg"
), ),
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true,
) )
TextComposer( TextComposer(
RichTextEditorState("A message", fake = true), RichTextEditorState("A message", fake = true),
@ -718,6 +730,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "video.mp4" defaultContent = "video.mp4"
), ),
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true,
) )
TextComposer( TextComposer(
RichTextEditorState("A message", fake = true), RichTextEditorState("A message", fake = true),
@ -735,6 +748,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "logs.txt" defaultContent = "logs.txt"
), ),
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true,
) )
TextComposer( TextComposer(
RichTextEditorState("A message", fake = true).apply { requestFocus() }, RichTextEditorState("A message", fake = true).apply { requestFocus() },
@ -752,6 +766,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "Shared location" defaultContent = "Shared location"
), ),
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true,
) )
} }
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ee62a7f77e591d238878e72e3f2fbe489e8c4e8c6a5c121b16e120ad5c43836d oid sha256:8421be9fe6518a4a610e561692352c28c63b1e588fae4f7617f6be94439852f2
size 44185 size 45078

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:20847fc7ab9e27171ae0ae37a95e7524aac0bd2479ff5b8b1130a5bc26dbb3e6 oid sha256:d6a15254ca331bc6d8ec6a6408b0a5a3d4fd26961e7c6025805ec7841a22e017
size 28232 size 28313

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d4bdf3944d30980e9bfd7cfffbabe2dac9861748cc3006e751bfd5cbf59c86db oid sha256:4c30a6c0c7169044216a02dde5605d59fba9307499855ffc1fa0d3fcd1650f5e
size 42475 size 43291

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a62ebcf0024a1876984c936c29037cf04e2e02aefb3bea461030c092fbde2937 oid sha256:e0355effa8376bc88f9474f4d0e7f595708702f4fda5e39becf811fef10b0d16
size 26983 size 27081

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6c0485788c8776536a01613058ab001bad02a67519cf0cb4892505c7c3715fea oid sha256:c40273c36eb1479f284e75fa91d4a75b1ae97edd0242dda37a2d4a8f10394928
size 144681 size 138700

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b4a259f036e555fdc52ecc8f0c9578dae70ed677235fbfd344bc31cbabc76be5 oid sha256:d7c47c713c74766c39d3b349d9e421ea588261e043105a5475cd8e68af782806
size 181478 size 185325

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d5966cae164fed6a3a12c1e1dd940ae7b0601e4ba69d11f49d18740bdab8c520 oid sha256:5d5479f3a6ea138b00c8f6a376cc4a67984385a087fb32bcdb764b2996e89402
size 130130 size 137137

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6c0485788c8776536a01613058ab001bad02a67519cf0cb4892505c7c3715fea oid sha256:c40273c36eb1479f284e75fa91d4a75b1ae97edd0242dda37a2d4a8f10394928
size 144681 size 138700

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b4a259f036e555fdc52ecc8f0c9578dae70ed677235fbfd344bc31cbabc76be5 oid sha256:d7c47c713c74766c39d3b349d9e421ea588261e043105a5475cd8e68af782806
size 181478 size 185325

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b8cb21e08faf31779111d78c2be1e4e68e6f3bc994b16c108bd654f13d39fa02 oid sha256:5d5479f3a6ea138b00c8f6a376cc4a67984385a087fb32bcdb764b2996e89402
size 130130 size 137137

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:15d14bf99af3cd0433870ebd6032d9bf4a45196e2ef1df7184cc55859a704dee oid sha256:339e5f22a47e29b6f681ac169be3b65568b7fa5d1197b0e81fb68bef639ae5c2
size 49008 size 49033

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6e47fc219bbd63b76d01a5e50c3e4c6b1b0a8b4ec40b08b13271d0b2673d8d5e oid sha256:c439f506df4eaed4ad35148d1734630a89b0de3ccb097864b7ff853c679772c1
size 50932 size 50964

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cacb91ffca97f21bbc19b29525813f58fc8017a858aa28ccd4e620b70a8cd9ca oid sha256:f85799360092c7e2d02f64f32668279c4e0c39bca3d45c9db13d503ddb7bf753
size 46119 size 46162

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3f1c14a23ee598ece6843a68d3ca0b1d1f725f53174bd493e27c6137de70c508 oid sha256:b05475d6509b15fcffeae54bdebf434a529a99af5173c48992677a88b654f3eb
size 48296 size 48336

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:367f76f13127a604d1fec6e86d56611b01765dc90462dd329cd567e55a52fdca oid sha256:eb78a3bc85d7f9ead19ba7d2723b997af1a399167ce28004be8ccbf0dd8ce5fd
size 7827 size 7736

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a14f96fc1119481ca8e7336ac0a65cb831a2bfff3e8e43270b0da8844f419f0e oid sha256:499df8785b50d0bd1d5466b8a5b65ab1a8eb358f0c1341ad5956f5d1931859ed
size 7982 size 7987

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c9caf6d9b49cf7eeaa41025617a7cc5b1d89bbd7468f7ecbfc0cac73ca4a04cc oid sha256:021d867d665e0fa77aa10a85e734e4a9416b96d627b21ce462f0e327c5e58333
size 7452 size 7392

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f2782edd31f68b7aa27e0c35b29d7c84574326cb56c65394738357479d408f5f oid sha256:06617cda0f93ce0ea26b2a77a243371cdaf4f9a2956d8159ecb7196f7f6fe082
size 144667 size 139282

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cd352f748e3fd08225aa714181fdc18d53e91a8c62f8e5e860b67ff5aaeb7b8e oid sha256:55d2d11ba729a68b1b62e9a69c3308594839fdb3612ff3db59850f0b346c28da
size 181969 size 186118

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:43da903ec671dd1cb591396306ce8c057432ffa8c1bfce7a509f28576e82b530 oid sha256:62c57a45c2cb1aac23fa0c3af44f90a5d7b9a1e1626ec49eb94a2c126f53f96d
size 130337 size 137535

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f2782edd31f68b7aa27e0c35b29d7c84574326cb56c65394738357479d408f5f oid sha256:06617cda0f93ce0ea26b2a77a243371cdaf4f9a2956d8159ecb7196f7f6fe082
size 144667 size 139282

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cd352f748e3fd08225aa714181fdc18d53e91a8c62f8e5e860b67ff5aaeb7b8e oid sha256:55d2d11ba729a68b1b62e9a69c3308594839fdb3612ff3db59850f0b346c28da
size 181969 size 186118

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8141eaa257454258d2926df5ee970daf5ee165b774e4b948256b90e22a388d4c oid sha256:62c57a45c2cb1aac23fa0c3af44f90a5d7b9a1e1626ec49eb94a2c126f53f96d
size 130337 size 137535

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f95fbf1b51966c1c6b7b149c771f33400455d3c999b505e1aa65f0e3eb41c0ef oid sha256:f0ad6895582183b697732ef032f89a6a0cad32106c9bbf30925c390dcab65ee6
size 140928 size 151752

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e8d522eb99079792d010e053d488f78a95e91f5a1b614b9d512bd005f0df81cf oid sha256:676b62ab782652c45a7267aac5df12943fd181bea48964d28dce664884ae4182
size 146976 size 156619

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c196f9cb2da2366e4e67d59012f61c5f50a5775475230c3a0cbc8d823ba53dcd oid sha256:f6010f8c84263cad47587a20c9d7da64aef2a8520c01765ee4966e4b6bdd8327
size 63317 size 63371

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:59773d8a5435a37b3d47bf0c41412299b0b277af4be7a1a25ccb33b4b60a32db oid sha256:6e12464ce600a1cd6ca6ea9dd930c2235e033896b336109e094b84dcf2d4e624
size 65397 size 65459

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ee692fa3f3dbe4500cff084526a4537fef6444440c78f63a03d990a6a027f794 oid sha256:e74d57cb6f5cda78086989289d0f0543cfd3747e1cebec0bd656bd5be2d8bac1
size 69844 size 69944

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0b8155faa2599615048193667f540664c7185ac7b6a9159afdc14dfb6286d62e oid sha256:9baf1ea5fca980be4744655283bd61de650c9f8399a9f3441a9a3f9c5f43566e
size 71826 size 71852

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:771343ea6a9d83160284b3a9248f744c68ab25c2ad64817269c3628a5ca67972 oid sha256:343d84dc714bb672084362155362b04936ae491f437e492332bff553d7776078
size 62319 size 62478

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4663107c550f193aa9bb23b6bccf9229aed5ad30c380ce6b9a326e6a3fe5def7 oid sha256:daee8f18d24bcc07cf8edd9b902b03c3fb406199a925606b1caf34cee08ff9a1
size 64854 size 65076

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ca51c27cb553709458343bf16d8402a55eb1b7187b4b85191c3318f5b88c29db oid sha256:f0cb7b8bd23784f2323ab61baad1a910b343fc498ea61f7364edeff9111992d0
size 69312 size 69478

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b0a825d8090414c617780bf2d7332b8e80e5e4de066cd117c53f5e90cae8d4f6 oid sha256:54a52c6de5fd501e5c4f260343dd9ec582b0a1dd094a18f43f2fc4aa2c8acfcc
size 72112 size 72145

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:eebf95f938f483da9b087eda86ae4a1baac594bc4b6f1f893d869b759679526c oid sha256:b991a34a137d82ff4dedf48316a05ebbc008233984b11af70e64889a5fb5eda8
size 123267 size 127438

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4875df98092a8ef8bd1c2fda8c4a038ae2830b9ffa57e4ab71859523f97c26c9 oid sha256:43b6514716d18382c8962520928d9d2ff6d0f665b2471e8a24e5d2e44a25c88c
size 128062 size 132295

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fe0a95b6c94668709c75052feec2953d310b8bcfda42758b2898e28b384ce193 oid sha256:bdcc950b0ce924a73ae742f0bb3fc7279de46f36ba01d377822a2a14b6f90343
size 56189 size 56239

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:30119e8fcb65e8cbf058191268f0d70633a62b160b9f49f71879272d0d076537 oid sha256:0d7bc773e70c57226840b30675f14a7fd99026100d61f48eeab64fb55d43f7c6
size 241818 size 230426

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:73ca2b85c0f1f8f13cd3a0aae9d394116a460aa2c511ca1f2672a356eee496d2 oid sha256:87f93b06f658352e7d97617c28dc0a4bd7454263fd402abfc2676f548c41d6b2
size 241553 size 231314

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ff7953fabf75b346e61bd4335f3753331b498dbe5f17f9af3aad8a6a1ce051cd oid sha256:f13fbb9bc776fa230b69c41e3faab659c52083d4e4f737f84b472c966f42c8dd
size 239490 size 229746

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e262b1128df681c9a98bbefe426a891d02e0500646319f2e1756c1b5c93f2d23 oid sha256:d312c80758e940b368e0b9eb2d41efb55c158691164bfde3456d7540080e46a9
size 239303 size 230651

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:15d14bf99af3cd0433870ebd6032d9bf4a45196e2ef1df7184cc55859a704dee oid sha256:339e5f22a47e29b6f681ac169be3b65568b7fa5d1197b0e81fb68bef639ae5c2
size 49008 size 49033

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cacb91ffca97f21bbc19b29525813f58fc8017a858aa28ccd4e620b70a8cd9ca oid sha256:f85799360092c7e2d02f64f32668279c4e0c39bca3d45c9db13d503ddb7bf753
size 46119 size 46162

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:434c4f6dc4ee4c66291eca2ff1a8ab2471aaf521dbbf0d32f53f277ea6519c07 oid sha256:85fbaa916a38c9b75a527dc3fddded9e9d06a98ab20f9f3eb74596c24ba4b5b7
size 49107 size 49105

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:63685437c43543115e8f509919ef179d70a620c012c8a46ebf6682dcfe9820c6 oid sha256:cee9e96cfefa6f84fb924857a92fd4ba04bbd196d94a6345bb821ff769fbd56d
size 45890 size 45889

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2264139761f7fe3977ece3c9a7d949ac51223dd209497b7b311987cba3e5a069 oid sha256:53cffdc5ea12b16db3f87ab12c28fede82ce5a744c222c9f19e5c35ef751c583
size 47154 size 47179

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f3ff35998ce8558b5a7af84e378bee039e36099084b357fffccf329a4983b035 oid sha256:96d696014b24a2a222ba331d754d6048063a9ec573158edc41ded92ed588b60d
size 43551 size 43593

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:51931b01aa27677c8d29a79bf92f4e8b48fc2be05e80fdabedfb9026dcbd8899 oid sha256:b967a0dc3bd9a466790503dd49c2fff6ce70156b51603022916aa9db3fc5c372
size 31634 size 29976

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5441c89fc077720b52479d4daf1ebd5fe5b98b63f35141a550dac228fe1c649f oid sha256:8ec89c6b12c4eb1f8059d9d0d183f6635f5647393134c858720f3ef72b8c4c36
size 10617 size 36837

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9156202a9f621b75cff3ca45aac33d72a938620049948828fc34aab3b9e0980d oid sha256:71c2ac04d7fb904b648fc383f430e3b8a4003e93e5964b60eb545248186cd8dc
size 55382 size 53912

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0c7ee3acbf4653e49633e68f896fe0f5f73591c2cbc889204cc244b319996e0d oid sha256:3277b51e1a7153487e2d9764d5c495419f8df7fa635574302237ebf3549a4b3c
size 78348 size 76821

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9156202a9f621b75cff3ca45aac33d72a938620049948828fc34aab3b9e0980d oid sha256:71c2ac04d7fb904b648fc383f430e3b8a4003e93e5964b60eb545248186cd8dc
size 55382 size 53912

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:55800431dfe4453fa3c11f97bfae53ceaf95f1c18b72bcc8620b8c3295b26b27 oid sha256:0104fa9aca0b3d25f06225f54529e88d78f7d42aabd102b2c065c774427bbf3b
size 56388 size 55038

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b2d288f46eb76b8a71a8c1a2ec60ba7c3b8b1bf2a92be9d8daf33c06b3a0f126 oid sha256:c2bf9d1733fc7230816cbcfbcef6780d34e687a064c289f2f9d651cf3047a1bd
size 56593 size 55107

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c0e5bc6abe96bff50960c5ace2c4eccd5aabdd9f3ccd015fae867b4c3bc2b423 oid sha256:a3cdb5de68791a0e5425ff9a29eaa8c1dc09aea9448211b9ad32f136bf11ace7
size 56902 size 55418

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1f385e70f3a8bda7c0c2f7c090ba665575239299472c0fbd62f2821783ceb065 oid sha256:3a9958e430c07445b9dcdc31a483c7925e2bd2a0d3fbbaf1ee5482051e2afe51
size 39016 size 65488

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4bdb22d629e4bc3273aac4944802d1c91b43065858f4f4cc90052bd78ae6dd89 oid sha256:c238218a16f7dc5c25125177434a8397821d5881971dd0404b9d79b7b181a592
size 62998 size 89294

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