Pinned messages : introduces banner view

This commit is contained in:
ganfra
2024-07-23 13:05:59 +02:00
parent 454dc389e0
commit df09d82a58
12 changed files with 429 additions and 16 deletions

View File

@@ -39,6 +39,8 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
@@ -98,6 +100,7 @@ class MessagesPresenter @AssistedInject constructor(
private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val dispatchers: CoroutineDispatchers,
@@ -129,6 +132,7 @@ class MessagesPresenter @AssistedInject constructor(
val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
@@ -231,6 +235,7 @@ class MessagesPresenter @AssistedInject constructor(
enableVoiceMessages = enableVoiceMessages,
appName = buildMeta.applicationName,
callState = callState,
pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = { handleEvents(it) }
)
}

View File

@@ -19,6 +19,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
@@ -57,6 +58,7 @@ data class MessagesState(
val enableVoiceMessages: Boolean,
val callState: RoomCallState,
val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val eventSink: (MessagesEvents) -> Unit
)

View File

@@ -22,6 +22,8 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.pinned.banner.aPinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
@@ -87,6 +89,12 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
aMessagesState(
callState = RoomCallState.DISABLED,
),
aMessagesState(
pinnedMessagesBannerState = aPinnedMessagesBannerState(
pinnedMessagesCount = 4,
currentPinnedMessageIndex = 0,
),
),
)
}
@@ -116,6 +124,7 @@ fun aMessagesState(
showReinvitePrompt: Boolean = false,
enableVoiceMessages: Boolean = true,
callState: RoomCallState = RoomCallState.ENABLED,
pinnedMessagesBannerState: PinnedMessagesBannerState = aPinnedMessagesBannerState(),
eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@@ -142,6 +151,7 @@ fun aMessagesState(
enableVoiceMessages = enableVoiceMessages,
callState = callState,
appName = "Element",
pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = eventSink,
)

View File

@@ -68,6 +68,7 @@ import io.element.android.features.messages.impl.messagecomposer.AttachmentsBott
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.components.JoinCallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
@@ -373,22 +374,24 @@ private fun MessagesViewContent(
RectangleShape
},
content = { paddingValues ->
TimelineView(
state = state.timelineState,
typingNotificationState = state.typingNotificationState,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onMessageClick = onMessageClick,
onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
modifier = Modifier.padding(paddingValues),
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
)
Box(modifier = Modifier.padding(paddingValues)) {
TimelineView(
state = state.timelineState,
typingNotificationState = state.typingNotificationState,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onMessageClick = onMessageClick,
onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
)
PinnedMessagesBannerView(state = state.pinnedMessagesBannerState)
}
},
sheetContent = { subcomposing: Boolean ->
MessagesViewComposerBottomSheetContents(

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@Module
interface MessagesModule {
@Binds
fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter<PinnedMessagesBannerState>
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
sealed interface PinnedMessagesBannerEvents {
data object MoveToNextPinned : PinnedMessagesBannerEvents
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
class PinnedMessagesBannerPresenter @Inject constructor() : Presenter<PinnedMessagesBannerState> {
@Composable
override fun present(): PinnedMessagesBannerState {
var pinnedMessageCount by remember {
mutableIntStateOf(0)
}
var currentPinnedMessageIndex by rememberSaveable {
mutableIntStateOf(0)
}
fun handleEvent(event: PinnedMessagesBannerEvents) {
when (event) {
is PinnedMessagesBannerEvents.MoveToNextPinned -> {
if (currentPinnedMessageIndex < pinnedMessageCount - 1) {
currentPinnedMessageIndex++
} else {
currentPinnedMessageIndex = 0
}
}
}
}
return PinnedMessagesBannerState(
pinnedMessagesCount = pinnedMessageCount,
currentPinnedMessageIndex = currentPinnedMessageIndex,
eventSink = ::handleEvent
)
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
data class PinnedMessagesBannerState(
val pinnedMessagesCount: Int,
val currentPinnedMessageIndex: Int,
val eventSink: (PinnedMessagesBannerEvents) -> Unit
) {
val displayBanner = pinnedMessagesCount > 0 && currentPinnedMessageIndex < pinnedMessagesCount
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider<PinnedMessagesBannerState> {
override val values: Sequence<PinnedMessagesBannerState>
get() = sequenceOf(
aPinnedMessagesBannerState(pinnedMessagesCount = 1, currentPinnedMessageIndex = 0),
aPinnedMessagesBannerState(pinnedMessagesCount = 2, currentPinnedMessageIndex = 0),
aPinnedMessagesBannerState(pinnedMessagesCount = 4, currentPinnedMessageIndex = 0),
aPinnedMessagesBannerState(pinnedMessagesCount = 4, currentPinnedMessageIndex = 1),
aPinnedMessagesBannerState(pinnedMessagesCount = 4, currentPinnedMessageIndex = 2),
aPinnedMessagesBannerState(pinnedMessagesCount = 4, currentPinnedMessageIndex = 3),
)
}
internal fun aPinnedMessagesBannerState(
pinnedMessagesCount: Int = 0,
currentPinnedMessageIndex: Int = -1,
eventSink: (PinnedMessagesBannerEvents) -> Unit = {}
) = PinnedMessagesBannerState(
pinnedMessagesCount = pinnedMessagesCount,
currentPinnedMessageIndex = currentPinnedMessageIndex,
eventSink = eventSink
)

View File

@@ -0,0 +1,204 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalFoundationApi::class)
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.TextButton
import io.element.android.libraries.designsystem.theme.pinnedMessageBannerBorder
import io.element.android.libraries.designsystem.theme.pinnedMessageBannerIndicator
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
@Composable
fun PinnedMessagesBannerView(
state: PinnedMessagesBannerState,
modifier: Modifier = Modifier,
) {
if (!state.displayBanner) return
val borderColor = ElementTheme.colors.pinnedMessageBannerBorder
Row(
modifier = modifier
.background(color = ElementTheme.colors.bgCanvasDefault)
.fillMaxWidth()
.drawBehind {
val strokeWidth = 0.5.dp.toPx()
val y = size.height - strokeWidth / 2
drawLine(
borderColor,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
drawLine(
borderColor,
Offset(0f, 0f),
Offset(size.width, 0f),
strokeWidth
)
}
.shadow(elevation = 5.dp, spotColor = Color.Transparent)
.heightIn(min = 64.dp)
.clickable {
state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(10.dp)
) {
Spacer(modifier = Modifier.width(16.dp))
PinIndicators(
pinIndex = state.currentPinnedMessageIndex,
pinsCount = state.pinnedMessagesCount,
modifier = Modifier.heightIn(max = 40.dp)
)
Icon(
imageVector = CompoundIcons.PinSolid(),
contentDescription = null,
tint = ElementTheme.materialColors.secondary,
modifier = Modifier.size(20.dp)
)
PinnedMessageItem(
index = state.currentPinnedMessageIndex,
totalCount = state.pinnedMessagesCount,
message = "This is a pinned message",
modifier = Modifier.weight(1f)
)
TextButton(text = "View all", onClick = { /*TODO*/ })
}
}
@Composable
private fun PinIndicators(
pinIndex: Int,
pinsCount: Int,
modifier: Modifier = Modifier,
) {
val indicatorHeight by remember {
derivedStateOf {
when (pinsCount) {
0 -> 0
1 -> 32
2 -> 18
else -> 11
}
}
}
val lazyListState = rememberLazyListState()
LaunchedEffect(pinIndex) {
val viewportSize = lazyListState.layoutInfo.viewportSize
lazyListState.animateScrollToItem(
pinIndex,
(indicatorHeight / 2 - viewportSize.height / 2)
)
}
LazyColumn(
modifier = modifier,
state = lazyListState,
verticalArrangement = spacedBy(2.dp),
userScrollEnabled = false
) {
items(pinsCount) { index ->
Box(
modifier = Modifier
.width(2.dp)
.height(indicatorHeight.dp)
.background(
color = if (index == pinIndex) {
ElementTheme.colors.iconAccentPrimary
} else {
ElementTheme.colors.pinnedMessageBannerIndicator
}
)
)
}
}
}
@Composable
private fun PinnedMessageItem(
index: Int,
totalCount: Int,
message: String,
modifier: Modifier = Modifier,
) {
val countMessage = "${index + 1} of $totalCount"
val fullMessage = "$countMessage Pinned messages"
Column(modifier = modifier) {
if (totalCount > 1) {
Text(
text = annotatedTextWithBold(
text = fullMessage,
boldText = countMessage,
),
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textActionAccent,
maxLines = 1,
)
}
Text(
text = message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
}
@PreviewsDayNight
@Composable
fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview {
PinnedMessagesBannerView(
state = state
)
}

View File

@@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.pinned.banner.aPinnedMessagesBannerState
import io.element.android.features.messages.impl.textcomposer.TestRichTextEditorStateFactory
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
@@ -56,6 +57,7 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -828,6 +830,7 @@ class MessagesPresenterTest {
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
@@ -838,6 +841,7 @@ class MessagesPresenterTest {
customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter,
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
pinnedMessagesBannerPresenter = { aPinnedMessagesBannerState() },
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
navigator = navigator,

View File

@@ -179,6 +179,14 @@ val SemanticColors.badgeNegativeBackgroundColor
val SemanticColors.badgeNegativeContentColor
get() = if (isLight) LightColorTokens.colorRed1100 else DarkColorTokens.colorRed1100
@OptIn(CoreColorToken::class)
val SemanticColors.pinnedMessageBannerIndicator
get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600
@OptIn(CoreColorToken::class)
val SemanticColors.pinnedMessageBannerBorder
get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400
@PreviewsDayNight
@Composable
internal fun ColorAliasesPreview() = ElementPreview {