Merge pull request #6649 from element-hq/feature/valere/call/decline_timeline_rendering
feat: Update call started timeline item + declined support
This commit is contained in:
commit
388dd9be67
16 changed files with 360 additions and 56 deletions
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components
|
package io.element.android.features.messages.impl.timeline.components
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -22,17 +23,19 @@ import androidx.compose.material3.Text
|
||||||
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
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
|
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
|
||||||
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.RtcNotificationState
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
|
||||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -42,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemCallNotifyView(
|
internal fun TimelineItemCallNotifyView(
|
||||||
|
timelineRoomInfo: TimelineRoomInfo,
|
||||||
event: TimelineItem.Event,
|
event: TimelineItem.Event,
|
||||||
content: TimelineItemRtcNotificationContent,
|
content: TimelineItemRtcNotificationContent,
|
||||||
onLongClick: (TimelineItem.Event) -> Unit,
|
onLongClick: (TimelineItem.Event) -> Unit,
|
||||||
|
|
@ -62,37 +66,22 @@ internal fun TimelineItemCallNotifyView(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Icon(
|
||||||
avatarData = event.senderAvatar,
|
modifier = Modifier.size(20.sp.toDp()),
|
||||||
avatarType = AvatarType.User,
|
imageVector = getIcon(timelineRoomInfo, content),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ElementTheme.colors.iconSecondary,
|
||||||
)
|
)
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
Text(
|
||||||
text = event.safeSenderName,
|
modifier = Modifier.weight(1f),
|
||||||
style = ElementTheme.typography.fontBodyLgMedium,
|
text = stringResource(getTextRes(timelineRoomInfo, content)),
|
||||||
maxLines = 1,
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
overflow = TextOverflow.Ellipsis,
|
color = ElementTheme.colors.textSecondary,
|
||||||
)
|
maxLines = 1,
|
||||||
Row(
|
overflow = TextOverflow.Ellipsis,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
modifier = Modifier.size(20.sp.toDp()),
|
|
||||||
imageVector =
|
|
||||||
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = ElementTheme.colors.iconSecondary,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(CommonStrings.common_call_started),
|
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
|
||||||
color = ElementTheme.colors.textSecondary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = event.sentTime,
|
text = event.sentTime,
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
|
@ -103,19 +92,56 @@ internal fun TimelineItemCallNotifyView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
private fun getTextRes(
|
||||||
|
timelineRoomInfo: TimelineRoomInfo,
|
||||||
|
content: TimelineItemRtcNotificationContent
|
||||||
|
): Int = if (timelineRoomInfo.isDm) {
|
||||||
|
when (content.state) {
|
||||||
|
is RtcNotificationState.Declined -> {
|
||||||
|
if (content.state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
|
||||||
|
}
|
||||||
|
RtcNotificationState.Started -> CommonStrings.common_call_started
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// In Rooms, do not show declined info.
|
||||||
|
CommonStrings.common_call_started
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getIcon(
|
||||||
|
timelineRoomInfo: TimelineRoomInfo,
|
||||||
|
content: TimelineItemRtcNotificationContent
|
||||||
|
): ImageVector {
|
||||||
|
val showAsDeclined = timelineRoomInfo.isDm && content.state is RtcNotificationState.Declined
|
||||||
|
val icon = if (showAsDeclined) {
|
||||||
|
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallDeclinedSolid() else CompoundIcons.VideoCallDeclinedSolid()
|
||||||
|
} else {
|
||||||
|
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid()
|
||||||
|
}
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
|
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
listOf(
|
listOf(false, true).forEach { isDm ->
|
||||||
TimelineItemRtcNotificationContent(CallIntent.AUDIO),
|
listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
|
||||||
TimelineItemRtcNotificationContent(CallIntent.VIDEO),
|
listOf(
|
||||||
).forEach { content ->
|
RtcNotificationState.Started,
|
||||||
TimelineItemCallNotifyView(
|
RtcNotificationState.Declined(byMe = false),
|
||||||
event = aTimelineItemEvent(content = content),
|
RtcNotificationState.Declined(byMe = true),
|
||||||
content = content,
|
).forEach { state ->
|
||||||
onLongClick = {},
|
val content = TimelineItemRtcNotificationContent(callIntent, state)
|
||||||
)
|
TimelineItemCallNotifyView(
|
||||||
|
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
|
||||||
|
event = aTimelineItemEvent(content = content),
|
||||||
|
content = content,
|
||||||
|
onLongClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ internal fun TimelineItemRow(
|
||||||
is TimelineItemRtcNotificationContent -> {
|
is TimelineItemRtcNotificationContent -> {
|
||||||
TimelineItemCallNotifyView(
|
TimelineItemCallNotifyView(
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
||||||
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
event = timelineItem,
|
event = timelineItem,
|
||||||
content = timelineItem.content,
|
content = timelineItem.content,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
|
||||||
|
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
|
||||||
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.TimelineItemLegacyCallInviteContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||||
|
|
@ -104,7 +105,12 @@ class TimelineItemContentFactory(
|
||||||
is PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent)
|
is PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent)
|
||||||
is UnableToDecryptContent -> utdFactory.create(itemContent)
|
is UnableToDecryptContent -> utdFactory.create(itemContent)
|
||||||
is CallNotifyContent -> TimelineItemRtcNotificationContent(
|
is CallNotifyContent -> TimelineItemRtcNotificationContent(
|
||||||
itemContent.callIntent
|
callIntent = itemContent.callIntent,
|
||||||
|
state = if (itemContent.declinedBy.isEmpty()) {
|
||||||
|
RtcNotificationState.Started
|
||||||
|
} else {
|
||||||
|
RtcNotificationState.Declined(itemContent.declinedBy.any { it == sessionId })
|
||||||
|
}
|
||||||
)
|
)
|
||||||
is UnknownContent -> TimelineItemUnknownContent
|
is UnknownContent -> TimelineItemUnknownContent
|
||||||
is LiveLocationContent -> {
|
is LiveLocationContent -> {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,19 @@
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.notification.CallIntent
|
import io.element.android.libraries.matrix.api.notification.CallIntent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||||
|
|
||||||
class TimelineItemRtcNotificationContent(val callIntent: CallIntent) : TimelineItemEventContent {
|
// State of the call, for now only isDeclined but in the future could be missed, active.
|
||||||
override val type: String = "org.matrix.msc4075.rtc.notification"
|
sealed interface RtcNotificationState {
|
||||||
|
/** Some users have declined, byMe indicates if the current user is one of them. */
|
||||||
|
data class Declined(val byMe: Boolean) : RtcNotificationState
|
||||||
|
|
||||||
|
object Started : RtcNotificationState
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimelineItemRtcNotificationContent(
|
||||||
|
val callIntent: CallIntent,
|
||||||
|
val state: RtcNotificationState,
|
||||||
|
) : TimelineItemEventContent {
|
||||||
|
override val type: String = EventType.RTC_NOTIFICATION
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.utils.messagesummary
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
|
|
@ -56,7 +57,16 @@ class DefaultMessageSummaryFormatter(
|
||||||
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
|
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
|
||||||
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
|
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
|
||||||
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
|
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
|
||||||
is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started)
|
is TimelineItemRtcNotificationContent -> when (content.state) {
|
||||||
|
is RtcNotificationState.Declined -> {
|
||||||
|
if (content.state.byMe) {
|
||||||
|
context.getString(CommonStrings.common_call_you_declined)
|
||||||
|
} else {
|
||||||
|
context.getString(CommonStrings.common_call_declined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RtcNotificationState.Started -> context.getString(CommonStrings.common_call_started)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Truncate the message to a safe length to avoid crashes in Compose
|
// Truncate the message to a safe length to avoid crashes in Compose
|
||||||
.toSafeLength()
|
.toSafeLength()
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
|
||||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
|
|
@ -1169,7 +1170,7 @@ class ActionListPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO),
|
content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO, state = RtcNotificationState.Started),
|
||||||
)
|
)
|
||||||
initialState.eventSink.invoke(
|
initialState.eventSink.invoke(
|
||||||
ActionListEvent.ComputeForMessage(
|
ActionListEvent.ComputeForMessage(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent.Mode
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
||||||
|
import io.element.android.features.messages.impl.utils.messagesummary.DefaultMessageSummaryFormatter
|
||||||
|
import io.element.android.libraries.matrix.api.notification.CallIntent
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.aProfileDetails
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class DefaultMessageSummaryFormatterTest {
|
||||||
|
private val formatter = DefaultMessageSummaryFormatter(
|
||||||
|
RuntimeEnvironment.getApplication() as Context
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "en")
|
||||||
|
fun `format call notification started`() {
|
||||||
|
val expected = formatter.format(
|
||||||
|
TimelineItemRtcNotificationContent(
|
||||||
|
callIntent = CallIntent.VIDEO,
|
||||||
|
state = RtcNotificationState.Started
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(expected).isEqualTo("Call started")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "en")
|
||||||
|
fun `format call notification declined by me`() {
|
||||||
|
val expected = formatter.format(
|
||||||
|
TimelineItemRtcNotificationContent(
|
||||||
|
callIntent = CallIntent.VIDEO,
|
||||||
|
state = RtcNotificationState.Declined(byMe = true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(expected).isEqualTo("You declined a call")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "en")
|
||||||
|
fun `format call notification declined`() {
|
||||||
|
val expected = formatter.format(
|
||||||
|
TimelineItemRtcNotificationContent(
|
||||||
|
callIntent = CallIntent.VIDEO,
|
||||||
|
state = RtcNotificationState.Declined(byMe = false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(expected).isEqualTo("Call declined")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "en")
|
||||||
|
fun `format live location`() {
|
||||||
|
val expected = formatter.format(
|
||||||
|
aLocationContent(isLive = true)
|
||||||
|
)
|
||||||
|
assertThat(expected).isEqualTo("Shared live location")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "en")
|
||||||
|
fun `format static location`() {
|
||||||
|
val expected = formatter.format(
|
||||||
|
aLocationContent(isLive = false)
|
||||||
|
)
|
||||||
|
assertThat(expected).isEqualTo("Shared location")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aLocationContent(isLive: Boolean) = TimelineItemLocationContent(
|
||||||
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
|
description = null,
|
||||||
|
assetType = null,
|
||||||
|
mode = if (isLive) {
|
||||||
|
Mode.Live(
|
||||||
|
lastKnownLocation = Location.fromGeoUri("geo:1,5"),
|
||||||
|
isActive = true,
|
||||||
|
endsAt = "",
|
||||||
|
endTimestamp = 0,
|
||||||
|
isOwnUser = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Mode.Static(
|
||||||
|
location = Location.fromGeoUri("geo:1,5")!!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -54,6 +54,7 @@ class DefaultRoomLatestEventFormatter(
|
||||||
private val roomMembershipContentFormatter: RoomMembershipContentFormatter,
|
private val roomMembershipContentFormatter: RoomMembershipContentFormatter,
|
||||||
private val profileChangeContentFormatter: ProfileChangeContentFormatter,
|
private val profileChangeContentFormatter: ProfileChangeContentFormatter,
|
||||||
private val stateContentFormatter: StateContentFormatter,
|
private val stateContentFormatter: StateContentFormatter,
|
||||||
|
private val rtcNotificationContentFormatter: RtcNotificationContentFormatter,
|
||||||
private val permalinkParser: PermalinkParser,
|
private val permalinkParser: PermalinkParser,
|
||||||
) : RoomLatestEventFormatter {
|
) : RoomLatestEventFormatter {
|
||||||
override fun format(
|
override fun format(
|
||||||
|
|
@ -121,7 +122,7 @@ class DefaultRoomLatestEventFormatter(
|
||||||
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||||
}
|
}
|
||||||
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call)
|
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call)
|
||||||
is CallNotifyContent -> sp.getString(CommonStrings.common_call_started)
|
is CallNotifyContent -> rtcNotificationContentFormatter.format(content, isDmRoom)
|
||||||
}?.take(DEFAULT_SAFE_LENGTH)
|
}?.take(DEFAULT_SAFE_LENGTH)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.eventformatter.impl
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class RtcNotificationContentFormatter(
|
||||||
|
private val matrixClient: MatrixClient,
|
||||||
|
private val sp: StringProvider,
|
||||||
|
) {
|
||||||
|
fun format(
|
||||||
|
content: CallNotifyContent,
|
||||||
|
isDm: Boolean,
|
||||||
|
): CharSequence {
|
||||||
|
return if (isDm) {
|
||||||
|
val isDeclined = content.declinedBy.isNotEmpty()
|
||||||
|
val isDeclinedByMe = content.declinedBy.any { matrixClient.isMe(it) }
|
||||||
|
if (isDeclinedByMe) {
|
||||||
|
sp.getString(CommonStrings.common_call_you_declined)
|
||||||
|
} else if (isDeclined) {
|
||||||
|
sp.getString(CommonStrings.common_call_declined)
|
||||||
|
} else {
|
||||||
|
sp.getString(CommonStrings.common_call_started)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sp.getString(CommonStrings.common_call_started)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,7 +74,8 @@ class DefaultRoomLatestEventFormatterTest {
|
||||||
roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider),
|
roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider),
|
||||||
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
|
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
|
||||||
stateContentFormatter = StateContentFormatter(stringProvider),
|
stateContentFormatter = StateContentFormatter(stringProvider),
|
||||||
permalinkParser = FakePermalinkParser(),
|
rtcNotificationContentFormatter = RtcNotificationContentFormatter(fakeMatrixClient, stringProvider),
|
||||||
|
permalinkParser = FakePermalinkParser()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.eventformatter.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.notification.CallIntent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_3
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import kotlin.toString
|
||||||
|
|
||||||
|
@Suppress("LargeClass")
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class RtcNotificationContentFormatterTest {
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var fakeMatrixClient: FakeMatrixClient
|
||||||
|
private lateinit var formatter: RtcNotificationContentFormatter
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
context = RuntimeEnvironment.getApplication() as Context
|
||||||
|
fakeMatrixClient = FakeMatrixClient()
|
||||||
|
val stringProvider = AndroidStringProvider(context.resources)
|
||||||
|
formatter = RtcNotificationContentFormatter(
|
||||||
|
fakeMatrixClient,
|
||||||
|
stringProvider
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "en")
|
||||||
|
fun `Should not display declined info in rooms`() {
|
||||||
|
val result = formatter.format(
|
||||||
|
CallNotifyContent(
|
||||||
|
CallIntent.VIDEO,
|
||||||
|
declinedBy = listOf(A_USER_ID_2, A_USER_ID_3)
|
||||||
|
),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
val expected = "Call started"
|
||||||
|
assertThat(result.toString()).isEqualTo(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "en")
|
||||||
|
fun `Declined by me variant`() {
|
||||||
|
val result = formatter.format(
|
||||||
|
CallNotifyContent(
|
||||||
|
CallIntent.VIDEO,
|
||||||
|
declinedBy = listOf(fakeMatrixClient.sessionId)
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
val expected = "You declined a call"
|
||||||
|
assertThat(result.toString()).isEqualTo(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "en")
|
||||||
|
fun `Declined by other variant`() {
|
||||||
|
val result = formatter.format(
|
||||||
|
CallNotifyContent(
|
||||||
|
CallIntent.VIDEO,
|
||||||
|
declinedBy = listOf(A_USER_ID_2)
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
val expected = "Call declined"
|
||||||
|
assertThat(result.toString()).isEqualTo(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "en")
|
||||||
|
fun `Call started in DM`() {
|
||||||
|
val result = formatter.format(
|
||||||
|
CallNotifyContent(
|
||||||
|
CallIntent.AUDIO,
|
||||||
|
declinedBy = listOf()
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
val expected = "Call started"
|
||||||
|
assertThat(result.toString()).isEqualTo(expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -119,7 +119,8 @@ data class LiveLocationContent(
|
||||||
data object LegacyCallInviteContent : EventContent
|
data object LegacyCallInviteContent : EventContent
|
||||||
|
|
||||||
data class CallNotifyContent(
|
data class CallNotifyContent(
|
||||||
val callIntent: CallIntent
|
val callIntent: CallIntent,
|
||||||
|
val declinedBy: List<UserId>
|
||||||
) : EventContent
|
) : EventContent
|
||||||
|
|
||||||
data object UnknownContent : EventContent
|
data object UnknownContent : EventContent
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,8 @@ class TimelineEventContentMapper(
|
||||||
CallIntent.AUDIO
|
CallIntent.AUDIO
|
||||||
} else {
|
} else {
|
||||||
CallIntent.VIDEO
|
CallIntent.VIDEO
|
||||||
}
|
},
|
||||||
|
declinedBy = it.declinedBy.map(::UserId)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ class DefaultEventItemFactoryTest {
|
||||||
fun `create check all null cases`() {
|
fun `create check all null cases`() {
|
||||||
val factory = createEventItemFactory()
|
val factory = createEventItemFactory()
|
||||||
val contents = listOf(
|
val contents = listOf(
|
||||||
CallNotifyContent(callIntent = CallIntent.VIDEO),
|
CallNotifyContent(callIntent = CallIntent.VIDEO, emptyList()),
|
||||||
FailedToParseMessageLikeContent("", ""),
|
FailedToParseMessageLikeContent("", ""),
|
||||||
FailedToParseStateContent("", "", ""),
|
FailedToParseStateContent("", "", ""),
|
||||||
LegacyCallInviteContent,
|
LegacyCallInviteContent,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3624fe8448ae4af2481e2023a978ffab2d69f2784b7cef41e5ae2e2dbe8fdbd5
|
oid sha256:5529e89e00208e38522f5206f5b8d304bd472b27071cab4e0d3c2daf3cb64db0
|
||||||
size 17804
|
size 49741
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6bd02d39619efbcaa6d96f1e75a0d14a572c43e60bad0c3f84d6a5a48b6fbda1
|
oid sha256:fa29aaa82f21912dd5147ffb6fdc457fd5880e8be4abde4f0177d0ab1a412fe2
|
||||||
size 17395
|
size 48245
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue