Merge branch 'develop' into feature/dla/emojibase_integration

This commit is contained in:
David Langley 2023-08-31 11:38:28 +01:00 committed by GitHub
commit d2e4cda28c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 259 additions and 50 deletions

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.0" /> <option name="version" value="1.9.10" />
</component> </component>
</project> </project>

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

@ -0,0 +1 @@
Fix the orientation of sent images.

View file

@ -22,4 +22,8 @@ sealed interface TimelineEvents {
data object LoadMore : TimelineEvents data object LoadMore : TimelineEvents
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
data class PollAnswerSelected(
val pollStartId: EventId,
val answerId: String
) : TimelineEvents
} }

View file

@ -87,6 +87,13 @@ class TimelinePresenter @Inject constructor(
lastReadReceiptId = lastReadReceiptId lastReadReceiptId = lastReadReceiptId
) )
} }
is TimelineEvents.PollAnswerSelected -> appScope.launch {
room.sendPollResponse(
pollStartId = event.pollStartId,
answers = listOf(event.answerId),
)
// TODO Polls: Send poll vote analytic
}
} }
} }

View file

@ -100,6 +100,9 @@ fun TimelineView(
// TODO implement this logic once we have support to 'jump to event X' in sliding sync // TODO implement this logic once we have support to 'jump to event X' in sliding sync
} }
fun onPollAnswerSelected(pollStartId: EventId, answerId: String) {
state.eventSink(TimelineEvents.PollAnswerSelected(pollStartId, answerId))
}
Box(modifier = modifier) { Box(modifier = modifier) {
LazyColumn( LazyColumn(
@ -125,6 +128,7 @@ fun TimelineView(
onReactionLongClick = onReactionLongClicked, onReactionLongClick = onReactionLongClicked,
onMoreReactionsClick = onMoreReactionsClicked, onMoreReactionsClick = onMoreReactionsClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onPollAnswerSelected = ::onPollAnswerSelected,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
) )
} }
@ -162,6 +166,7 @@ fun TimelineItemRow(
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
when (timelineItem) { when (timelineItem) {
@ -194,6 +199,7 @@ fun TimelineItemRow(
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) }, onSwipeToReply = { onSwipeToReply(timelineItem) },
onPollAnswerSelected = onPollAnswerSelected,
modifier = modifier, modifier = modifier,
) )
} }
@ -231,6 +237,7 @@ fun TimelineItemRow(
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onPollAnswerSelected = onPollAnswerSelected,
onSwipeToReply = {}, onSwipeToReply = {},
) )
} }

View file

@ -118,6 +118,7 @@ fun TimelineItemEventRow(
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
onSwipeToReply: () -> Unit, onSwipeToReply: () -> Unit,
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -175,6 +176,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onPollAnswerSelected = onPollAnswerSelected,
) )
} }
} }
@ -191,6 +193,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onPollAnswerSelected = onPollAnswerSelected,
) )
} }
} }
@ -232,6 +235,7 @@ private fun TimelineItemEventRowContent(
onReactionClicked: (emoji: String) -> Unit, onReactionClicked: (emoji: String) -> Unit,
onReactionLongClicked: (emoji: String) -> Unit, onReactionLongClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun ConstrainScope.linkStartOrEnd(event: TimelineItem.Event) = if (event.isMine) { fun ConstrainScope.linkStartOrEnd(event: TimelineItem.Event) = if (event.isMine) {
@ -289,7 +293,8 @@ private fun TimelineItemEventRowContent(
inReplyToClick = inReplyToClicked, inReplyToClick = inReplyToClicked,
onTimestampClicked = { onTimestampClicked = {
onTimestampClicked(event) onTimestampClicked(event)
} },
onPollAnswerSelected = onPollAnswerSelected,
) )
} }
@ -360,6 +365,7 @@ private fun MessageEventBubbleContent(
onMessageLongClick: () -> Unit, onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit, onTimestampClicked: () -> Unit,
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
@SuppressLint("ModifierParameter") bubbleModifier: Modifier = Modifier, // need to rename this modifier to distinguish it from the following ones @SuppressLint("ModifierParameter") bubbleModifier: Modifier = Modifier, // need to rename this modifier to distinguish it from the following ones
) { ) {
val timestampPosition = when (event.content) { val timestampPosition = when (event.content) {
@ -385,6 +391,7 @@ private fun MessageEventBubbleContent(
onClick = onMessageClick, onClick = onMessageClick,
onLongClick = onMessageLongClick, onLongClick = onMessageLongClick,
extraPadding = event.toExtraPadding(), extraPadding = event.toExtraPadding(),
onPollAnswerSelected = onPollAnswerSelected,
modifier = modifier, modifier = modifier,
) )
} }
@ -607,6 +614,7 @@ private fun ContentToPreview() {
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
onPollAnswerSelected = { _, _ -> },
) )
TimelineItemEventRow( TimelineItemEventRow(
event = aTimelineItemEvent( event = aTimelineItemEvent(
@ -627,6 +635,7 @@ private fun ContentToPreview() {
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
onPollAnswerSelected = { _, _ -> },
) )
} }
} }
@ -674,6 +683,7 @@ private fun ContentToPreviewWithReply() {
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
onPollAnswerSelected = { _, _ -> },
) )
TimelineItemEventRow( TimelineItemEventRow(
event = aTimelineItemEvent( event = aTimelineItemEvent(
@ -695,6 +705,7 @@ private fun ContentToPreviewWithReply() {
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
onPollAnswerSelected = { _, _ -> },
) )
} }
} }
@ -752,6 +763,7 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
onPollAnswerSelected = { _, _ -> },
) )
} }
} }
@ -792,6 +804,7 @@ private fun ContentWithManyReactionsToPreview() {
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onTimestampClicked = {}, onTimestampClicked = {},
onPollAnswerSelected = { _, _ -> },
) )
} }
} }
@ -816,6 +829,7 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onTimestampClicked = {}, onTimestampClicked = {},
onPollAnswerSelected = { _, _ -> },
) )
} }
@ -836,5 +850,6 @@ internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onTimestampClicked = {}, onTimestampClicked = {},
onPollAnswerSelected = { _, _ -> },
) )
} }

View file

@ -70,6 +70,7 @@ fun TimelineItemStateEventRow(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
extraPadding = noExtraPadding, extraPadding = noExtraPadding,
onPollAnswerSelected = { _, _ -> error("Polls are not supported in state events") },
modifier = Modifier.defaultTimelineContentPadding() modifier = Modifier.defaultTimelineContentPadding()
) )
} }

View file

@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
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.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.matrix.api.core.EventId
@Composable @Composable
fun TimelineItemEventContentView( fun TimelineItemEventContentView(
@ -39,6 +40,7 @@ fun TimelineItemEventContentView(
extraPadding: ExtraPadding, extraPadding: ExtraPadding,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
when (content) { when (content) {
@ -93,7 +95,7 @@ fun TimelineItemEventContentView(
) )
is TimelineItemPollContent -> TimelineItemPollView( is TimelineItemPollContent -> TimelineItemPollView(
content = content, content = content,
onAnswerSelected = {}, onAnswerSelected = onPollAnswerSelected,
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -24,16 +24,17 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.poll.api.PollContentView import io.element.android.features.poll.api.PollContentView
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.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
fun TimelineItemPollView( fun TimelineItemPollView(
content: TimelineItemPollContent, content: TimelineItemPollContent,
onAnswerSelected: (PollAnswer) -> Unit, onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
PollContentView( PollContentView(
eventId = content.eventId,
question = content.question, question = content.question,
answerItems = content.answerItems.toImmutableList(), answerItems = content.answerItems.toImmutableList(),
pollKind = content.pollKind, pollKind = content.pollKind,
@ -49,6 +50,6 @@ internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollConte
ElementPreview { ElementPreview {
TimelineItemPollView( TimelineItemPollView(
content = content, content = content,
onAnswerSelected = {}, onAnswerSelected = { _, _ -> },
) )
} }

View file

@ -55,7 +55,7 @@ class TimelineItemContentFactory @Inject constructor(
is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem) is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem)
is StateContent -> stateFactory.create(eventTimelineItem) is StateContent -> stateFactory.create(eventTimelineItem)
is StickerContent -> stickerFactory.create(itemContent) is StickerContent -> stickerFactory.create(itemContent)
is PollContent -> pollFactory.create(itemContent) is PollContent -> pollFactory.create(itemContent, eventTimelineItem.eventId)
is UnableToDecryptContent -> utdFactory.create(itemContent) is UnableToDecryptContent -> utdFactory.create(itemContent)
is UnknownContent -> TimelineItemUnknownContent is UnknownContent -> TimelineItemUnknownContent
} }

View file

@ -23,6 +23,7 @@ import io.element.android.features.poll.api.PollAnswerItem
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.isDisclosed import io.element.android.libraries.matrix.api.poll.isDisclosed
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import javax.inject.Inject import javax.inject.Inject
@ -32,7 +33,10 @@ class TimelineItemContentPollFactory @Inject constructor(
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
) { ) {
suspend fun create(content: PollContent): TimelineItemEventContent { suspend fun create(
content: PollContent,
eventId: EventId?
): TimelineItemEventContent {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.Polls)) return TimelineItemUnknownContent if (!featureFlagService.isFeatureEnabled(FeatureFlags.Polls)) return TimelineItemUnknownContent
// Todo Move this computation to the matrix rust sdk // Todo Move this computation to the matrix rust sdk
@ -67,6 +71,7 @@ class TimelineItemContentPollFactory @Inject constructor(
} }
return TimelineItemPollContent( return TimelineItemPollContent(
eventId = eventId,
question = content.question, question = content.question,
answerItems = answerItems, answerItems = answerItems,
pollKind = content.kind, pollKind = content.kind,

View file

@ -17,9 +17,11 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.features.poll.api.PollAnswerItem import io.element.android.features.poll.api.PollAnswerItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
data class TimelineItemPollContent( data class TimelineItemPollContent(
val eventId: EventId?,
val question: String, val question: String,
val answerItems: List<PollAnswerItem>, val answerItems: List<PollAnswerItem>,
val pollKind: PollKind, val pollKind: PollKind,

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.poll.api.aPollAnswerItemList import io.element.android.features.poll.api.aPollAnswerItemList
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
open class TimelineItemPollContentProvider : PreviewParameterProvider<TimelineItemPollContent> { open class TimelineItemPollContentProvider : PreviewParameterProvider<TimelineItemPollContent> {
@ -30,6 +31,7 @@ open class TimelineItemPollContentProvider : PreviewParameterProvider<TimelineIt
fun aTimelineItemPollContent(): TimelineItemPollContent { fun aTimelineItemPollContent(): TimelineItemPollContent {
return TimelineItemPollContent( return TimelineItemPollContent(
eventId = EventId("\$anEventId"),
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(), answerItems = aPollAnswerItemList(),

View file

@ -48,7 +48,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location) is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt) is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed) is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemPollContent, // Todo Polls: handle summary is TimelineItemPollContent -> event.content.question
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event) is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image) is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video) is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)

View file

@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.poll.api.PollAnswerItem import io.element.android.features.poll.api.PollAnswerItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollAnswer
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.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
@ -384,6 +385,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
content = TimelineItemPollContent( content = TimelineItemPollContent(
eventId = EventId("\$anEventId"),
question = "Some question?", question = "Some question?",
answerItems = listOf( answerItems = listOf(
PollAnswerItem( PollAnswerItem(

View file

@ -25,7 +25,7 @@ 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
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.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
@ -36,8 +36,10 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aMessageContent 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.tests.testutils.awaitWithLatch import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -248,6 +250,23 @@ class TimelinePresenterTest {
} }
} }
@Test
fun `present - PollAnswerSelected event calls into rust room api and analytics`() = runTest {
val room = FakeMatrixRoom()
val presenter = createTimelinePresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(TimelineEvents.PollAnswerSelected(AN_EVENT_ID, "anAnswerId"))
}
delay(1)
assertThat(room.sendPollResponseInvocations.size).isEqualTo(1)
assertThat(room.sendPollResponseInvocations.first().answers).isEqualTo(listOf("anAnswerId"))
assertThat(room.sendPollResponseInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
// TODO Polls: Test poll vote analytic
}
private fun TestScope.createTimelinePresenter( private fun TestScope.createTimelinePresenter(
timeline: MatrixTimeline = FakeMatrixTimeline(), timeline: MatrixTimeline = FakeMatrixTimeline(),
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory() timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory()
@ -259,4 +278,15 @@ class TimelinePresenterTest {
appScope = this appScope = this
) )
} }
private fun TestScope.createTimelinePresenter(
room: MatrixRoom,
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = room,
dispatchers = testCoroutineDispatchers(),
appScope = this
)
}
} }

View file

@ -22,10 +22,12 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.poll.api.PollAnswerItem import io.element.android.features.poll.api.PollAnswerItem
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollAnswer
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.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_10 import io.element.android.libraries.matrix.test.A_USER_ID_10
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
@ -49,14 +51,14 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Disclosed poll - not ended, no votes`() = runTest { fun `Disclosed poll - not ended, no votes`() = runTest {
Truth.assertThat(factory.create(aPollContent())).isEqualTo(aTimelineItemPollContent()) Truth.assertThat(factory.create(aPollContent(), eventId = null)).isEqualTo(aTimelineItemPollContent())
} }
@Test @Test
fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest { fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(votes = votes)) factory.create(aPollContent(votes = votes), eventId = null)
) )
.isEqualTo( .isEqualTo(
aTimelineItemPollContent( aTimelineItemPollContent(
@ -73,7 +75,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Disclosed poll - ended, no votes, no winner`() = runTest { fun `Disclosed poll - ended, no votes, no winner`() = runTest {
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(endTime = 1UL)) factory.create(aPollContent(endTime = 1UL), eventId = null)
).isEqualTo( ).isEqualTo(
aTimelineItemPollContent().let { aTimelineItemPollContent().let {
it.copy( it.copy(
@ -88,7 +90,7 @@ internal class TimelineItemContentPollFactoryTest {
fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest { fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(votes = votes, endTime = 1UL)) factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
) )
.isEqualTo( .isEqualTo(
aTimelineItemPollContent( aTimelineItemPollContent(
@ -107,7 +109,7 @@ internal class TimelineItemContentPollFactoryTest {
fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest { fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id } val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(votes = votes, endTime = 1UL)) factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
) )
.isEqualTo( .isEqualTo(
aTimelineItemPollContent( aTimelineItemPollContent(
@ -125,9 +127,9 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Undisclosed poll - not ended, no votes`() = runTest { fun `Undisclosed poll - not ended, no votes`() = runTest {
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(PollKind.Undisclosed).copy()) factory.create(aPollContent(PollKind.Undisclosed).copy(), eventId = null)
).isEqualTo( ).isEqualTo(
aTimelineItemPollContent(PollKind.Undisclosed).let { aTimelineItemPollContent(pollKind = PollKind.Undisclosed).let {
it.copy(answerItems = it.answerItems.map { answerItem -> answerItem.copy(isDisclosed = false) }) it.copy(answerItems = it.answerItems.map { answerItem -> answerItem.copy(isDisclosed = false) })
} }
) )
@ -137,7 +139,7 @@ internal class TimelineItemContentPollFactoryTest {
fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest { fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes)) factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes), eventId = null)
) )
.isEqualTo( .isEqualTo(
aTimelineItemPollContent( aTimelineItemPollContent(
@ -155,7 +157,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Undisclosed poll - ended, no votes, no winner`() = runTest { fun `Undisclosed poll - ended, no votes, no winner`() = runTest {
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(pollKind = PollKind.Undisclosed, endTime = 1UL)) factory.create(aPollContent(pollKind = PollKind.Undisclosed, endTime = 1UL), eventId = null)
).isEqualTo( ).isEqualTo(
aTimelineItemPollContent().let { aTimelineItemPollContent().let {
it.copy( it.copy(
@ -173,7 +175,7 @@ internal class TimelineItemContentPollFactoryTest {
fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest { fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL)) factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL), eventId = null)
) )
.isEqualTo( .isEqualTo(
aTimelineItemPollContent( aTimelineItemPollContent(
@ -193,7 +195,7 @@ internal class TimelineItemContentPollFactoryTest {
fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest { fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id } val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL)) factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL), eventId = null)
) )
.isEqualTo( .isEqualTo(
aTimelineItemPollContent( aTimelineItemPollContent(
@ -209,6 +211,15 @@ internal class TimelineItemContentPollFactoryTest {
) )
} }
@Test
fun `eventId is populated`() = runTest {
Truth.assertThat(factory.create(aPollContent(), eventId = null))
.isEqualTo(aTimelineItemPollContent(eventId = null))
Truth.assertThat(factory.create(aPollContent(), eventId = AN_EVENT_ID))
.isEqualTo(aTimelineItemPollContent(eventId = AN_EVENT_ID))
}
private fun aPollContent( private fun aPollContent(
pollKind: PollKind = PollKind.Disclosed, pollKind: PollKind = PollKind.Disclosed,
votes: Map<String, List<UserId>> = emptyMap(), votes: Map<String, List<UserId>> = emptyMap(),
@ -223,6 +234,7 @@ internal class TimelineItemContentPollFactoryTest {
) )
private fun aTimelineItemPollContent( private fun aTimelineItemPollContent(
eventId: EventId? = null,
pollKind: PollKind = PollKind.Disclosed, pollKind: PollKind = PollKind.Disclosed,
answerItems: List<PollAnswerItem> = listOf( answerItems: List<PollAnswerItem> = listOf(
aPollAnswerItem(A_POLL_ANSWER_1), aPollAnswerItem(A_POLL_ANSWER_1),
@ -232,6 +244,7 @@ internal class TimelineItemContentPollFactoryTest {
), ),
isEnded: Boolean = false, isEnded: Boolean = false,
) = TimelineItemPollContent( ) = TimelineItemPollContent(
eventId = eventId,
question = A_POLL_QUESTION, question = A_POLL_QUESTION,
answerItems = answerItems, answerItems = answerItems,
pollKind = pollKind, pollKind = pollKind,

View file

@ -35,6 +35,7 @@ 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.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
@ -43,13 +44,18 @@ import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun PollContentView( fun PollContentView(
eventId: EventId?,
question: String, question: String,
answerItems: ImmutableList<PollAnswerItem>, answerItems: ImmutableList<PollAnswerItem>,
pollKind: PollKind, pollKind: PollKind,
isPollEnded: Boolean, isPollEnded: Boolean,
onAnswerSelected: (PollAnswer) -> Unit, onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onAnswerSelected(pollAnswer: PollAnswer) {
eventId?.let { onAnswerSelected(it, pollAnswer.id) }
}
Column( Column(
modifier = modifier modifier = modifier
.selectableGroup() .selectableGroup()
@ -58,7 +64,7 @@ fun PollContentView(
) { ) {
PollTitle(title = question) PollTitle(title = question)
PollAnswers(answerItems = answerItems, onAnswerSelected = onAnswerSelected) PollAnswers(answerItems = answerItems, onAnswerSelected = ::onAnswerSelected)
when { when {
isPollEnded || pollKind == PollKind.Disclosed -> DisclosedPollBottomNotice(answerItems) isPollEnded || pollKind == PollKind.Disclosed -> DisclosedPollBottomNotice(answerItems)
@ -134,11 +140,12 @@ fun ColumnScope.UndisclosedPollBottomNotice(modifier: Modifier = Modifier) {
@Composable @Composable
internal fun PollContentUndisclosedPreview() = ElementPreview { internal fun PollContentUndisclosedPreview() = ElementPreview {
PollContentView( PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(isDisclosed = false), answerItems = aPollAnswerItemList(isDisclosed = false),
pollKind = PollKind.Undisclosed, pollKind = PollKind.Undisclosed,
isPollEnded = false, isPollEnded = false,
onAnswerSelected = { }, onAnswerSelected = { _, _ -> },
) )
} }
@ -146,11 +153,12 @@ internal fun PollContentUndisclosedPreview() = ElementPreview {
@Composable @Composable
internal fun PollContentDisclosedPreview() = ElementPreview { internal fun PollContentDisclosedPreview() = ElementPreview {
PollContentView( PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(), answerItems = aPollAnswerItemList(),
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
isPollEnded = false, isPollEnded = false,
onAnswerSelected = { }, onAnswerSelected = { _, _ -> },
) )
} }
@ -158,10 +166,11 @@ internal fun PollContentDisclosedPreview() = ElementPreview {
@Composable @Composable
internal fun PollContentEndedPreview() = ElementPreview { internal fun PollContentEndedPreview() = ElementPreview {
PollContentView( PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(isEnded = true), answerItems = aPollAnswerItemList(isEnded = true),
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
isPollEnded = false, isPollEnded = false,
onAnswerSelected = { }, onAnswerSelected = { _, _ -> },
) )
} }

View file

@ -25,12 +25,19 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.poll.impl.R import io.element.android.features.poll.impl.R
@ -68,6 +75,10 @@ fun CreatePollView(
onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) }, onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) },
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
) )
val questionFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
questionFocusRequester.requestFocus()
}
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@ -113,10 +124,13 @@ fun CreatePollView(
onValueChange = { onValueChange = {
state.eventSink(CreatePollEvents.SetQuestion(it)) state.eventSink(CreatePollEvents.SetQuestion(it))
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.focusRequester(questionFocusRequester)
.fillMaxWidth(),
placeholder = { placeholder = {
Text(text = stringResource(id = R.string.screen_create_poll_question_hint)) Text(text = stringResource(id = R.string.screen_create_poll_question_hint))
}, },
keyboardOptions = keyboardOptions,
) )
} }
) )
@ -133,6 +147,7 @@ fun CreatePollView(
placeholder = { placeholder = {
Text(text = stringResource(id = R.string.screen_create_poll_answer_hint, index + 1)) Text(text = stringResource(id = R.string.screen_create_poll_answer_hint, index + 1))
}, },
keyboardOptions = keyboardOptions,
) )
}, },
trailingContent = ListItemContent.Custom { trailingContent = ListItemContent.Custom {
@ -185,3 +200,8 @@ internal fun CreatePollViewPreview(
state = state, state = state,
) )
} }
private val keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Next,
)

View file

@ -48,7 +48,7 @@ sqldelight = "1.5.5"
telephoto = "0.6.0-SNAPSHOT" telephoto = "0.6.0-SNAPSHOT"
# DI # DI
dagger = "2.47" dagger = "2.48"
anvil = "2.4.7-1-8" anvil = "2.4.7-1-8"
# Auto service # Auto service
@ -146,7 +146,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.47" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.48"
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }

View file

@ -22,7 +22,6 @@ import android.graphics.Matrix
import androidx.core.graphics.scale import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import java.io.File import java.io.File
import java.io.InputStream
import kotlin.math.min import kotlin.math.min
fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) { fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) {
@ -32,13 +31,6 @@ fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int
} }
} }
/**
* Reads the EXIF metadata from the [inputStream] and rotates the current [Bitmap] to match it.
* @return The resulting [Bitmap] or `null` if no metadata was found.
*/
fun Bitmap.rotateToMetadataOrientation(inputStream: InputStream): Result<Bitmap> =
runCatching { rotateToMetadataOrientation(this, ExifInterface(inputStream)) }
/** /**
* Scales the current [Bitmap] to fit the ([maxWidth], [maxHeight]) bounds while keeping aspect ratio. * Scales the current [Bitmap] to fit the ([maxWidth], [maxHeight]) bounds while keeping aspect ratio.
* @throws IllegalStateException if [maxWidth] or [maxHeight] <= 0. * @throws IllegalStateException if [maxWidth] or [maxHeight] <= 0.
@ -77,8 +69,11 @@ fun BitmapFactory.Options.calculateInSampleSize(desiredWidth: Int, desiredHeight
return inSampleSize return inSampleSize
} }
private fun rotateToMetadataOrientation(bitmap: Bitmap, exifInterface: ExifInterface): Bitmap { /**
val orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) * Decodes the [inputStream] into a [Bitmap] and applies the needed rotation based on [orientation].
* This orientation value must be one of `ExifInterface.ORIENTATION_*` constants.
*/
fun Bitmap.rotateToMetadataOrientation(orientation: Int): Bitmap {
val matrix = Matrix() val matrix = Matrix()
when (orientation) { when (orientation) {
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
@ -94,8 +89,8 @@ private fun rotateToMetadataOrientation(bitmap: Bitmap, exifInterface: ExifInter
matrix.preRotate(90f) matrix.preRotate(90f)
matrix.preScale(-1f, 1f) matrix.preScale(-1f, 1f)
} }
else -> return bitmap else -> return this
} }
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
} }

View file

@ -157,6 +157,22 @@ interface MatrixRoom : Closeable {
pollKind: PollKind, pollKind: PollKind,
): Result<Unit> ): Result<Unit>
/**
* Send a response to a poll.
*
* @param pollStartId The event ID of the poll start event.
* @param answers The list of answer ids to send.
*/
suspend fun sendPollResponse(pollStartId: EventId, answers: List<String>): Result<Unit>
/**
* Ends a poll in the room.
*
* @param pollStartId The event ID of the poll start event.
* @param text Fallback text of the poll end event.
*/
suspend fun endPoll(pollStartId: EventId, text: String): Result<Unit>
override fun close() = destroy() override fun close() = destroy()
} }

View file

@ -23,7 +23,8 @@ val oidcConfiguration: OidcConfiguration = OidcConfiguration(
clientName = "Element", clientName = "Element",
redirectUri = OidcConfig.redirectUri, redirectUri = OidcConfig.redirectUri,
clientUri = "https://element.io", clientUri = "https://element.io",
tosUri = "https://element.io/user-terms-of-service", logoUri = "https://element.io/mobile-icon.png",
tosUri = "https://element.io/acceptable-use-policy-terms",
policyUri = "https://element.io/privacy", policyUri = "https://element.io/privacy",
/** /**
* 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

@ -215,7 +215,7 @@ class RustMatrixRoom(
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> = withContext(roomDispatcher) { override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> = withContext(roomDispatcher) {
if (originalEventId != null) { if (originalEventId != null) {
runCatching { runCatching {
innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId?.value) innerRoom.edit(messageEventContentFromMarkdown(message), originalEventId.value, transactionId?.value)
} }
} else { } else {
runCatching { runCatching {
@ -226,10 +226,8 @@ class RustMatrixRoom(
} }
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> = withContext(roomDispatcher) { override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> = withContext(roomDispatcher) {
val transactionId = genTransactionId()
// val content = messageEventContentFromMarkdown(message)
runCatching { runCatching {
innerRoom.sendReply(/* TODO use content */ message, eventId.value, transactionId) innerRoom.sendReply(messageEventContentFromMarkdown(message), eventId.value, genTransactionId())
} }
} }
@ -402,6 +400,32 @@ class RustMatrixRoom(
} }
} }
override suspend fun sendPollResponse(
pollStartId: EventId,
answers: List<String>
): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.sendPollResponse(
pollStartId = pollStartId.value,
answers = answers,
txnId = genTransactionId(),
)
}
}
override suspend fun endPoll(
pollStartId: EventId,
text: String
): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.endPoll(
pollStartId = pollStartId.value,
text = text,
txnId = genTransactionId(),
)
}
}
private suspend fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> { private suspend fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
return runCatching { return runCatching {
MediaUploadHandlerImpl(files, handle()) MediaUploadHandlerImpl(files, handle())

View file

@ -85,6 +85,8 @@ class FakeMatrixRoom(
private var reportContentResult = Result.success(Unit) private var reportContentResult = Result.success(Unit)
private var sendLocationResult = Result.success(Unit) private var sendLocationResult = Result.success(Unit)
private var createPollResult = Result.success(Unit) private var createPollResult = Result.success(Unit)
private var sendPollResponseResult = 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<String>() val editMessageCalls = mutableListOf<String>()
@ -109,6 +111,12 @@ class FakeMatrixRoom(
private val _createPollInvocations = mutableListOf<CreatePollInvocation>() private val _createPollInvocations = mutableListOf<CreatePollInvocation>()
val createPollInvocations: List<CreatePollInvocation> = _createPollInvocations val createPollInvocations: List<CreatePollInvocation> = _createPollInvocations
private val _sendPollResponseInvocations = mutableListOf<SendPollResponseInvocation>()
val sendPollResponseInvocations: List<SendPollResponseInvocation> = _sendPollResponseInvocations
private val _endPollInvocations = mutableListOf<EndPollInvocation>()
val endPollInvocations: List<EndPollInvocation> = _endPollInvocations
var invitedUserId: UserId? = null var invitedUserId: UserId? = null
private set private set
@ -320,6 +328,22 @@ class FakeMatrixRoom(
return createPollResult return createPollResult
} }
override suspend fun sendPollResponse(
pollStartId: EventId,
answers: List<String>
): Result<Unit> = simulateLongTask {
_sendPollResponseInvocations.add(SendPollResponseInvocation(pollStartId, answers))
return sendPollResponseResult
}
override suspend fun endPoll(
pollStartId: EventId,
text: String
): Result<Unit> = simulateLongTask {
_endPollInvocations.add(EndPollInvocation(pollStartId, text))
return endPollResult
}
fun givenLeaveRoomError(throwable: Throwable?) { fun givenLeaveRoomError(throwable: Throwable?) {
this.leaveRoomError = throwable this.leaveRoomError = throwable
} }
@ -416,6 +440,14 @@ class FakeMatrixRoom(
createPollResult = result createPollResult = result
} }
fun givenSendPollResponseResult(result: Result<Unit>) {
sendPollResponseResult = result
}
fun givenEndPollResult(result: Result<Unit>) {
endPollResult = result
}
fun givenProgressCallbackValues(values: List<Pair<Long, Long>>) { fun givenProgressCallbackValues(values: List<Pair<Long, Long>>) {
progressCallbackValues = values progressCallbackValues = values
} }
@ -435,3 +467,13 @@ data class CreatePollInvocation(
val maxSelections: Int, val maxSelections: Int,
val pollKind: PollKind, val pollKind: PollKind,
) )
data class SendPollResponseInvocation(
val pollStartId: EventId,
val answers: List<String>,
)
data class EndPollInvocation(
val pollStartId: EventId,
val text: String,
)

View file

@ -119,10 +119,17 @@ class AndroidMediaPreProcessor @Inject constructor(
private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo {
suspend fun processImageWithCompression(): MediaUploadInfo { suspend fun processImageWithCompression(): MediaUploadInfo {
// Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail.
val orientation = contentResolver.openInputStream(uri).use { input ->
val exifInterface = input?.let { ExifInterface(it) }
exifInterface?.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
} ?: ExifInterface.ORIENTATION_UNDEFINED
val compressionResult = contentResolver.openInputStream(uri).use { input -> val compressionResult = contentResolver.openInputStream(uri).use { input ->
imageCompressor.compressToTmpFile( imageCompressor.compressToTmpFile(
inputStream = requireNotNull(input), inputStream = requireNotNull(input),
resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE), resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE),
orientation = orientation,
).getOrThrow() ).getOrThrow()
} }
val thumbnailResult: ThumbnailResult = thumbnailFactory.createImageThumbnail(compressionResult.file) val thumbnailResult: ThumbnailResult = thumbnailFactory.createImageThumbnail(compressionResult.file)

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.mediaupload
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.exifinterface.media.ExifInterface
import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize
import io.element.android.libraries.androidutils.bitmap.resizeToMax import io.element.android.libraries.androidutils.bitmap.resizeToMax
import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation
@ -37,17 +38,18 @@ class ImageCompressor @Inject constructor(
/** /**
* Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode], then writes it into a * Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode], then writes it into a
* temporary file using the passed [format] and [desiredQuality]. * temporary file using the passed [format], [orientation] and [desiredQuality].
* @return a [Result] containing the resulting [ImageCompressionResult] with the temporary [File] and some metadata. * @return a [Result] containing the resulting [ImageCompressionResult] with the temporary [File] and some metadata.
*/ */
suspend fun compressToTmpFile( suspend fun compressToTmpFile(
inputStream: InputStream, inputStream: InputStream,
resizeMode: ResizeMode, resizeMode: ResizeMode,
format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG,
orientation: Int = ExifInterface.ORIENTATION_UNDEFINED,
desiredQuality: Int = 80, desiredQuality: Int = 80,
): Result<ImageCompressionResult> = withContext(Dispatchers.IO) { ): Result<ImageCompressionResult> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val compressedBitmap = compressToBitmap(inputStream, resizeMode).getOrThrow() val compressedBitmap = compressToBitmap(inputStream, resizeMode, orientation).getOrThrow()
// Encode bitmap to the destination temporary file // Encode bitmap to the destination temporary file
val tmpFile = context.createTmpFile(extension = "jpeg") val tmpFile = context.createTmpFile(extension = "jpeg")
tmpFile.outputStream().use { tmpFile.outputStream().use {
@ -63,19 +65,20 @@ class ImageCompressor @Inject constructor(
} }
/** /**
* Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode]. * Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode] and [orientation].
* @return a [Result] containing the resulting [Bitmap]. * @return a [Result] containing the resulting [Bitmap].
*/ */
fun compressToBitmap( fun compressToBitmap(
inputStream: InputStream, inputStream: InputStream,
resizeMode: ResizeMode, resizeMode: ResizeMode,
orientation: Int,
): Result<Bitmap> = runCatching { ): Result<Bitmap> = runCatching {
BufferedInputStream(inputStream).use { input -> BufferedInputStream(inputStream).use { input ->
val options = BitmapFactory.Options() val options = BitmapFactory.Options()
calculateDecodingScale(input, resizeMode, options) calculateDecodingScale(input, resizeMode, options)
val decodedBitmap = BitmapFactory.decodeStream(input, null, options) val decodedBitmap = BitmapFactory.decodeStream(input, null, options)
?: error("Decoding Bitmap from InputStream failed") ?: error("Decoding Bitmap from InputStream failed")
val rotatedBitmap = decodedBitmap.rotateToMetadataOrientation(input).getOrThrow() val rotatedBitmap = decodedBitmap.rotateToMetadataOrientation(orientation)
if (resizeMode is ResizeMode.Strict) { if (resizeMode is ResizeMode.Strict) {
rotatedBitmap.resizeToMax(resizeMode.maxWidth, resizeMode.maxHeight) rotatedBitmap.resizeToMax(resizeMode.maxWidth, resizeMode.maxHeight)
} else { } else {