mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Add mark as read buttons
This commit is contained in:
+2
@@ -25,6 +25,8 @@ sealed interface TimelineEvent {
|
|||||||
|
|
||||||
data object HideShieldDialog : TimelineEvent
|
data object HideShieldDialog : TimelineEvent
|
||||||
|
|
||||||
|
data object MarkAllAsRead : TimelineEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Events coming from a timeline item.
|
* Events coming from a timeline item.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+8
@@ -95,6 +95,7 @@ class TimelinePresenter(
|
|||||||
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
|
private val markAsFullyRead: MarkAsFullyRead,
|
||||||
) : Presenter<TimelineState> {
|
) : Presenter<TimelineState> {
|
||||||
private val tag = "TimelinePresenter"
|
private val tag = "TimelinePresenter"
|
||||||
|
|
||||||
@@ -224,6 +225,13 @@ class TimelinePresenter(
|
|||||||
timelineController.focusOnLive()
|
timelineController.focusOnLive()
|
||||||
}
|
}
|
||||||
TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null
|
TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null
|
||||||
|
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
|
||||||
|
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
|
||||||
|
Timber.tag(tag).w(it, "Failed to get latest event id to mark as fully read")
|
||||||
|
return@launch
|
||||||
|
} ?: return@launch
|
||||||
|
markAsFullyRead(room.roomId, latestEventId)
|
||||||
|
}
|
||||||
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
|
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
|
||||||
is TimelineEvent.ComputeVerifiedUserSendFailure -> {
|
is TimelineEvent.ComputeVerifiedUserSendFailure -> {
|
||||||
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
|
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
|
||||||
|
|||||||
+82
-24
@@ -16,19 +16,24 @@ import androidx.compose.animation.scaleIn
|
|||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -42,6 +47,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
@@ -51,6 +58,7 @@ import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
@@ -72,8 +80,10 @@ import io.element.android.libraries.androidutils.system.copyToClipboard
|
|||||||
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
|
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
|
||||||
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
|
||||||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
import io.element.android.libraries.designsystem.text.roundToPx
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
|
||||||
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.utils.animateScrollToItemCenter
|
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
@@ -131,6 +141,10 @@ fun TimelineView(
|
|||||||
state.eventSink(TimelineEvent.JumpToLive)
|
state.eventSink(TimelineEvent.JumpToLive)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onMarkAllAsRead() {
|
||||||
|
state.eventSink(TimelineEvent.MarkAllAsRead)
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
|
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
@@ -220,6 +234,7 @@ fun TimelineView(
|
|||||||
onScrollFinishAt = ::onScrollFinishAt,
|
onScrollFinishAt = ::onScrollFinishAt,
|
||||||
onJumpToLive = ::onJumpToLive,
|
onJumpToLive = ::onJumpToLive,
|
||||||
onFocusEventRender = ::onFocusEventRender,
|
onFocusEventRender = ::onFocusEventRender,
|
||||||
|
onMarkAllAsRead = ::onMarkAllAsRead,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (state.displayFloatingDateBadge && useReverseLayout) {
|
if (state.displayFloatingDateBadge && useReverseLayout) {
|
||||||
@@ -305,6 +320,7 @@ private fun BoxScope.TimelineScrollHelper(
|
|||||||
onScrollFinishAt: (Int) -> Unit,
|
onScrollFinishAt: (Int) -> Unit,
|
||||||
onJumpToLive: () -> Unit,
|
onJumpToLive: () -> Unit,
|
||||||
onFocusEventRender: () -> Unit,
|
onFocusEventRender: () -> Unit,
|
||||||
|
onMarkAllAsRead: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||||
@@ -389,27 +405,30 @@ private fun BoxScope.TimelineScrollHelper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(end = 24.dp, bottom = 24.dp)
|
||||||
|
) {
|
||||||
|
JumpToPositionButton(
|
||||||
|
icon = CompoundIcons.ChevronUp(),
|
||||||
|
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_unread_messages),
|
||||||
|
modifier = Modifier.padding(bottom = if (isJumpToBottomVisible) 12.dp else 0.dp),
|
||||||
|
isVisible = isJumpToUnreadVisible,
|
||||||
|
hasUnread = true,
|
||||||
|
onClick = ::jumpToReadMarker,
|
||||||
|
onMarkAsRead = onMarkAllAsRead,
|
||||||
|
)
|
||||||
JumpToPositionButton(
|
JumpToPositionButton(
|
||||||
icon = CompoundIcons.ChevronDown(),
|
icon = CompoundIcons.ChevronDown(),
|
||||||
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom),
|
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom),
|
||||||
isVisible = isJumpToBottomVisible,
|
isVisible = isJumpToBottomVisible,
|
||||||
hasUnread = displayJumpToUnread && newEventState is NewEventState.FromOther,
|
hasUnread = displayJumpToUnread && newEventState is NewEventState.FromOther,
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(end = 24.dp, bottom = 12.dp),
|
|
||||||
onClick = ::jumpToBottom,
|
onClick = ::jumpToBottom,
|
||||||
|
onMarkAsRead = onMarkAllAsRead,
|
||||||
|
dotAlignment = Alignment.BottomCenter,
|
||||||
)
|
)
|
||||||
JumpToPositionButton(
|
}
|
||||||
icon = CompoundIcons.ChevronUp(),
|
|
||||||
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_unread_messages),
|
|
||||||
isVisible = isJumpToUnreadVisible,
|
|
||||||
hasUnread = true,
|
|
||||||
// Stacked directly above the scroll-to-bottom FAB: 12dp base + 36dp FAB + 8dp gap.
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(end = 24.dp, bottom = 56.dp),
|
|
||||||
onClick = ::jumpToReadMarker,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -419,7 +438,9 @@ private fun JumpToPositionButton(
|
|||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
hasUnread: Boolean,
|
hasUnread: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onMarkAsRead: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
dotAlignment: Alignment = Alignment.TopCenter,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -427,26 +448,63 @@ private fun JumpToPositionButton(
|
|||||||
enter = scaleIn(animationSpec = tween(100)),
|
enter = scaleIn(animationSpec = tween(100)),
|
||||||
exit = scaleOut(animationSpec = tween(100)),
|
exit = scaleOut(animationSpec = tween(100)),
|
||||||
) {
|
) {
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
Box {
|
Box {
|
||||||
FloatingActionButton(
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.shadow(elevation = 0.dp, shape = CircleShape)
|
||||||
|
.background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.border(1.dp, ElementTheme.colors.borderDisabled, CircleShape)
|
||||||
|
.combinedClickable(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
|
onLongClick = { menuExpanded = true },
|
||||||
shape = CircleShape,
|
)
|
||||||
modifier = Modifier.size(36.dp),
|
.testTag(TestTags.floatingActionButton),
|
||||||
containerColor = ElementTheme.colors.bgSubtleSecondary,
|
contentAlignment = Alignment.Center,
|
||||||
contentColor = ElementTheme.colors.iconSecondary,
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
|
tint = ElementTheme.colors.iconSecondary,
|
||||||
|
)
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onDismissRequest = { menuExpanded = false },
|
||||||
|
minWidth = 0.dp,
|
||||||
|
offset = DpOffset(x = -44.dp, y = 40.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
menuExpanded = false
|
||||||
|
onMarkAsRead()
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.MarkAsRead(),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ElementTheme.colors.iconPrimary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = CommonStrings.action_mark_as_read),
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val dotYOffset = if (dotAlignment == Alignment.BottomCenter) 4.dp else (-4).dp
|
||||||
TimelineUnreadIndicator(
|
TimelineUnreadIndicator(
|
||||||
isVisible = hasUnread,
|
isVisible = hasUnread,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(dotAlignment)
|
||||||
.offset { IntOffset(x = 4.dp.roundToPx(), y = -4.dp.roundToPx()) },
|
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -1206,6 +1206,38 @@ class TimelinePresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - MarkAllAsRead invokes markAsFullyRead with latest event id`() = runTest {
|
||||||
|
val markAsFullyReadRecorder = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
|
||||||
|
val presenter = createTimelinePresenter(
|
||||||
|
timeline = FakeTimeline(getLatestEventIdResult = { Result.success(AN_EVENT_ID) }),
|
||||||
|
markAsFullyRead = FakeMarkAsFullyRead(markAsFullyReadRecorder),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.eventSink(TimelineEvent.MarkAllAsRead)
|
||||||
|
advanceUntilIdle()
|
||||||
|
markAsFullyReadRecorder.assertions().isCalledOnce().with(value(A_ROOM_ID), value(AN_EVENT_ID))
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - MarkAllAsRead does not invoke markAsFullyRead when latest event id lookup fails`() = runTest {
|
||||||
|
val markAsFullyReadRecorder = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
|
||||||
|
val presenter = createTimelinePresenter(
|
||||||
|
timeline = FakeTimeline(getLatestEventIdResult = { Result.failure(RuntimeException("boom")) }),
|
||||||
|
markAsFullyRead = FakeMarkAsFullyRead(markAsFullyReadRecorder),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.eventSink(TimelineEvent.MarkAllAsRead)
|
||||||
|
advanceUntilIdle()
|
||||||
|
markAsFullyReadRecorder.assertions().isNeverCalled()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||||
return awaitItem()
|
return awaitItem()
|
||||||
}
|
}
|
||||||
@@ -1244,6 +1276,7 @@ class TimelinePresenterTest {
|
|||||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||||
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
||||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||||
|
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
|
||||||
): TimelinePresenter {
|
): TimelinePresenter {
|
||||||
return TimelinePresenter(
|
return TimelinePresenter(
|
||||||
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
||||||
@@ -1262,6 +1295,7 @@ class TimelinePresenterTest {
|
|||||||
roomCallStatePresenter = { aStandByCallState() },
|
roomCallStatePresenter = { aStandByCallState() },
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
|
markAsFullyRead = markAsFullyRead,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -35,6 +35,7 @@ fun DropdownMenuItem(
|
|||||||
leadingIcon: @Composable (() -> Unit)? = null,
|
leadingIcon: @Composable (() -> Unit)? = null,
|
||||||
trailingIcon: @Composable (() -> Unit)? = null,
|
trailingIcon: @Composable (() -> Unit)? = null,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
contentPadding: PaddingValues = DropDownMenuItemDefaults.contentPadding,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
) {
|
) {
|
||||||
androidx.compose.material3.DropdownMenuItem(
|
androidx.compose.material3.DropdownMenuItem(
|
||||||
@@ -49,7 +50,7 @@ fun DropdownMenuItem(
|
|||||||
trailingIcon = trailingIcon,
|
trailingIcon = trailingIcon,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
colors = DropDownMenuItemDefaults.colors(),
|
colors = DropDownMenuItemDefaults.colors(),
|
||||||
contentPadding = DropDownMenuItemDefaults.contentPadding,
|
contentPadding = contentPadding,
|
||||||
interactionSource = interactionSource
|
interactionSource = interactionSource
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
<resources>
|
<resources>
|
||||||
<string name="common_black">"Black"</string>
|
<string name="common_black">"Black"</string>
|
||||||
|
<string name="action_mark_as_read">"Mark as read"</string>
|
||||||
<string name="a11y_jump_to_unread_messages">"Jump to unread messages"</string>
|
<string name="a11y_jump_to_unread_messages">"Jump to unread messages"</string>
|
||||||
<plurals name="a11y_jump_to_unread_messages_count">
|
<plurals name="a11y_jump_to_unread_messages_count">
|
||||||
<item quantity="one">"Jump to %1$d unread message"</item>
|
<item quantity="one">"Jump to %1$d unread message"</item>
|
||||||
|
|||||||
Reference in New Issue
Block a user