feat: Update call started timeline item + declined support
This commit is contained in:
parent
d215354e64
commit
f0dc4eeace
9 changed files with 138 additions and 42 deletions
|
|
@ -22,17 +22,19 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
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.aTimelineRoomInfo
|
||||
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.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.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
|
@ -42,6 +44,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
|
||||
@Composable
|
||||
internal fun TimelineItemCallNotifyView(
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
event: TimelineItem.Event,
|
||||
content: TimelineItemRtcNotificationContent,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
|
|
@ -62,37 +65,22 @@ internal fun TimelineItemCallNotifyView(
|
|||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = event.senderAvatar,
|
||||
avatarType = AvatarType.User,
|
||||
Icon(
|
||||
modifier = Modifier.size(20.sp.toDp()),
|
||||
imageVector = getIcon(timelineRoomInfo, content),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = event.safeSenderName,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
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(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = stringResource(getTextRes(timelineRoomInfo, content)),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = event.sentTime,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
|
|
@ -103,15 +91,51 @@ internal fun TimelineItemCallNotifyView(
|
|||
}
|
||||
}
|
||||
|
||||
private fun getTextRes(
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
content: TimelineItemRtcNotificationContent
|
||||
): Int = if (timelineRoomInfo.isDm) {
|
||||
when (content.state) {
|
||||
RtcNotificationState.Declined -> CommonStrings.common_call_declined
|
||||
RtcNotificationState.DeclinedByMe -> CommonStrings.common_call_you_declined
|
||||
RtcNotificationState.None -> CommonStrings.common_call_started
|
||||
}
|
||||
} else {
|
||||
// Only show declined info in DMs
|
||||
CommonStrings.common_call_started
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getIcon(
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
content: TimelineItemRtcNotificationContent
|
||||
): ImageVector {
|
||||
val showAsDeclined = timelineRoomInfo.isDm && (
|
||||
content.state == RtcNotificationState.Declined ||
|
||||
content.state == RtcNotificationState.DeclinedByMe
|
||||
)
|
||||
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
|
||||
@Composable
|
||||
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
listOf(
|
||||
TimelineItemRtcNotificationContent(CallIntent.AUDIO),
|
||||
TimelineItemRtcNotificationContent(CallIntent.VIDEO),
|
||||
).forEach { content ->
|
||||
(aTimelineRoomInfo() to TimelineItemRtcNotificationContent(CallIntent.AUDIO, RtcNotificationState.None)),
|
||||
(aTimelineRoomInfo() to TimelineItemRtcNotificationContent(CallIntent.VIDEO, RtcNotificationState.None)),
|
||||
(aTimelineRoomInfo(isDm = true) to TimelineItemRtcNotificationContent(CallIntent.AUDIO, RtcNotificationState.Declined)),
|
||||
(aTimelineRoomInfo(isDm = true) to TimelineItemRtcNotificationContent(CallIntent.VIDEO, RtcNotificationState.Declined)),
|
||||
(aTimelineRoomInfo(isDm = true) to TimelineItemRtcNotificationContent(CallIntent.VIDEO, RtcNotificationState.DeclinedByMe)),
|
||||
(aTimelineRoomInfo(isDm = false) to TimelineItemRtcNotificationContent(CallIntent.VIDEO, RtcNotificationState.None)),
|
||||
).forEach { (info, content) ->
|
||||
TimelineItemCallNotifyView(
|
||||
timelineRoomInfo = info,
|
||||
event = aTimelineItemEvent(content = content),
|
||||
content = content,
|
||||
onLongClick = {},
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ internal fun TimelineItemRow(
|
|||
is TimelineItemRtcNotificationContent -> {
|
||||
TimelineItemCallNotifyView(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
event = timelineItem,
|
||||
content = timelineItem.content,
|
||||
onLongClick = onLongClick,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
|
|||
|
||||
import dev.zacsweers.metro.Inject
|
||||
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.TimelineItemLegacyCallInviteContent
|
||||
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 UnableToDecryptContent -> utdFactory.create(itemContent)
|
||||
is CallNotifyContent -> TimelineItemRtcNotificationContent(
|
||||
itemContent.callIntent
|
||||
callIntent = itemContent.callIntent,
|
||||
state = when {
|
||||
itemContent.declinedBy.isEmpty().not() && itemContent.declinedBy.any { it == sessionId } -> RtcNotificationState.DeclinedByMe
|
||||
itemContent.declinedBy.isEmpty().not() -> RtcNotificationState.Declined
|
||||
else -> RtcNotificationState.None
|
||||
}
|
||||
)
|
||||
is UnknownContent -> TimelineItemUnknownContent
|
||||
is LiveLocationContent -> {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,24 @@
|
|||
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.timeline.item.event.EventType
|
||||
|
||||
class TimelineItemRtcNotificationContent(val callIntent: CallIntent) : TimelineItemEventContent {
|
||||
override val type: String = "org.matrix.msc4075.rtc.notification"
|
||||
// For now this is just an enum, but could be a
|
||||
// sealed class if we need the list of users who declined.
|
||||
enum class RtcNotificationState {
|
||||
/** Some users have declined */
|
||||
Declined,
|
||||
|
||||
/** I have declined this call */
|
||||
DeclinedByMe,
|
||||
|
||||
// Future sates could be `Missed`? `ongoing`...
|
||||
None
|
||||
}
|
||||
|
||||
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 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.TimelineItemEncryptedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
|
|
@ -56,7 +57,12 @@ class DefaultMessageSummaryFormatter(
|
|||
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
|
||||
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
|
||||
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
|
||||
is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started)
|
||||
is TimelineItemRtcNotificationContent -> when (content.state) {
|
||||
RtcNotificationState.Declined ->
|
||||
context.getString(CommonStrings.common_call_declined)
|
||||
RtcNotificationState.DeclinedByMe -> context.getString(CommonStrings.common_call_you_declined)
|
||||
RtcNotificationState.None -> context.getString(CommonStrings.common_call_started)
|
||||
}
|
||||
}
|
||||
// Truncate the message to a safe length to avoid crashes in Compose
|
||||
.toSafeLength()
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ class DefaultRoomLatestEventFormatter(
|
|||
private val profileChangeContentFormatter: ProfileChangeContentFormatter,
|
||||
private val stateContentFormatter: StateContentFormatter,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val rtcNotificationContentFormatter: RtcNotificationContentFormatter,
|
||||
) : RoomLatestEventFormatter {
|
||||
override fun format(
|
||||
latestEvent: LatestEventValue.Local,
|
||||
|
|
@ -121,7 +122,7 @@ class DefaultRoomLatestEventFormatter(
|
|||
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -119,7 +119,8 @@ data class LiveLocationContent(
|
|||
data object LegacyCallInviteContent : EventContent
|
||||
|
||||
data class CallNotifyContent(
|
||||
val callIntent: CallIntent
|
||||
val callIntent: CallIntent,
|
||||
val declinedBy: List<UserId>
|
||||
) : EventContent
|
||||
|
||||
data object UnknownContent : EventContent
|
||||
|
|
|
|||
|
|
@ -153,7 +153,8 @@ class TimelineEventContentMapper(
|
|||
CallIntent.AUDIO
|
||||
} else {
|
||||
CallIntent.VIDEO
|
||||
}
|
||||
},
|
||||
declinedBy = it.declinedBy.map(::UserId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue