favorite : branch RoomNotableTags methods

This commit is contained in:
ganfra 2024-01-31 21:24:37 +01:00
parent c0e6813e3b
commit a63d331f36
18 changed files with 285 additions and 19 deletions

View file

@ -20,4 +20,5 @@ sealed interface RoomDetailsEvent {
data object LeaveRoom : RoomDetailsEvent
data object MuteNotification : RoomDetailsEvent
data object UnmuteNotification : RoomDetailsEvent
data class SetIsFavorite(val isFavorite: Boolean) : RoomDetailsEvent
}

View file

@ -44,9 +44,11 @@ import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.room.tags.RoomNotableTags
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -95,6 +97,7 @@ class RoomDetailsPresenter @Inject constructor(
val dmMember by room.getDirectRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType by getRoomType(dmMember)
val isFavorite by isFavorite()
val topicState = remember(canEditTopic, roomTopic, roomType) {
val topic = roomTopic
@ -122,6 +125,12 @@ class RoomDetailsPresenter @Inject constructor(
client.notificationSettingsService().unmuteRoom(room.roomId, room.isEncrypted, room.isOneToOne)
}
}
is RoomDetailsEvent.SetIsFavorite -> {
scope.launch(dispatchers.io) {
val tags = RoomNotableTags(isFavorite = event.isFavorite)
room.updateNotableTags(tags)
}
}
}
}
@ -142,6 +151,7 @@ class RoomDetailsPresenter @Inject constructor(
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
isFavorite = isFavorite,
eventSink = ::handleEvents,
)
}
@ -164,6 +174,11 @@ class RoomDetailsPresenter @Inject constructor(
}
}
@Composable
private fun isFavorite() = remember {
room.notableTagsFlow.map { it.isFavorite }
}.collectAsState(initial = false)
@Composable
private fun getCanInvite(membersState: MatrixRoomMembersState) = produceState(false, membersState) {
value = room.canInvite().getOrElse { false }

View file

@ -36,6 +36,7 @@ data class RoomDetailsState(
val canShowNotificationSettings: Boolean,
val leaveRoomState: LeaveRoomState,
val roomNotificationSettings: RoomNotificationSettings?,
val isFavorite: Boolean,
val eventSink: (RoomDetailsEvent) -> Unit
)

View file

@ -36,6 +36,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aDmRoomDetailsState().copy(roomName = "Daniel"),
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
aRoomDetailsState().copy(canInvite = true),
aRoomDetailsState().copy(isFavorite = true),
aRoomDetailsState().copy(
canEdit = true,
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
@ -86,6 +87,7 @@ fun aRoomDetailsState() = RoomDetailsState(
roomMemberDetailsState = null,
leaveRoomState = aLeaveRoomState(),
roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.MUTE, isDefault = false),
isFavorite = false,
eventSink = {}
)

View file

@ -61,6 +61,7 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.MainActionButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -163,6 +164,13 @@ fun RoomDetailsView(
)
}
FavoriteSection(
isFavorite = state.isFavorite,
onFavoriteChanges = {
state.eventSink(RoomDetailsEvent.SetIsFavorite(it))
}
)
if (state.roomType is RoomDetailsType.Room) {
MembersSection(
memberCount = state.memberCount,
@ -355,6 +363,22 @@ private fun NotificationSection(
}
}
@Composable
private fun FavoriteSection(
isFavorite: Boolean,
onFavoriteChanges: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
PreferenceCategory(modifier = modifier) {
PreferenceSwitch(
icon = CompoundIcons.FavouriteOff,
title = "Favorite",
isChecked = isFavorite,
onCheckedChange = onFavoriteChanges
)
}
}
@Composable
private fun MembersSection(
memberCount: Long,

View file

@ -26,6 +26,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -56,7 +58,10 @@ fun RoomListContextMenu(
onLeaveRoomClicked = {
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId))
}
},
onFavoriteChanged = { isFavorite ->
eventSink(RoomListEvents.MarkRoomAsFavorite(contextMenu.roomId, isFavorite))
},
)
}
}
@ -66,6 +71,7 @@ private fun RoomListModalBottomSheetContent(
contextMenu: RoomListState.ContextMenu.Shown,
onRoomSettingsClicked: (roomId: RoomId) -> Unit,
onLeaveRoomClicked: (roomId: RoomId) -> Unit,
onFavoriteChanged: (isFavorite: Boolean) -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth()
@ -78,6 +84,31 @@ private fun RoomListModalBottomSheetContent(
)
}
)
ListItem(
headlineContent = {
Text(
text = "Favourite",
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.FavouriteOff,
contentDescription = "Favourite"
)
),
trailingContent = ListItemContent.Switch(
checked = contextMenu.isFavorite.dataOrNull().orFalse(),
enabled = contextMenu.isFavorite is AsyncData.Success,
onChange = { isFavorite ->
onFavoriteChanged(isFavorite)
},
),
onClick = {
onFavoriteChanged(!contextMenu.isFavorite.dataOrNull().orFalse())
},
style = ListItemStyle.Primary,
)
ListItem(
headlineContent = {
Text(
@ -96,11 +127,13 @@ private fun RoomListModalBottomSheetContent(
)
ListItem(
headlineContent = {
val leaveText = stringResource(id = if (contextMenu.isDm) {
CommonStrings.action_leave_conversation
} else {
CommonStrings.action_leave_room
})
val leaveText = stringResource(
id = if (contextMenu.isDm) {
CommonStrings.action_leave_conversation
} else {
CommonStrings.action_leave_room
}
)
Text(text = leaveText)
},
modifier = Modifier.clickable { onLeaveRoomClicked(contextMenu.roomId) },
@ -126,9 +159,11 @@ internal fun RoomListModalBottomSheetContentPreview() = ElementPreview {
roomId = RoomId(value = "!aRoom:aDomain"),
roomName = "aRoom",
isDm = false,
isFavorite = AsyncData.Success(false),
),
onRoomSettingsClicked = {},
onLeaveRoomClicked = {}
onLeaveRoomClicked = {},
onFavoriteChanged = {},
)
}
@ -140,8 +175,10 @@ internal fun RoomListModalBottomSheetContentForDmPreview() = ElementPreview {
roomId = RoomId(value = "!aRoom:aDomain"),
roomName = "aRoom",
isDm = true,
isFavorite = AsyncData.Success(false),
),
onRoomSettingsClicked = {},
onLeaveRoomClicked = {}
onLeaveRoomClicked = {},
onFavoriteChanged = {},
)
}

View file

@ -28,4 +28,5 @@ sealed interface RoomListEvents {
data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
data object HideContextMenu : RoomListEvents
data class LeaveRoom(val roomId: RoomId) : RoomListEvents
data class MarkRoomAsFavorite(val roomId: RoomId, val isFavorite: Boolean) : RoomListEvents
}

View file

@ -24,6 +24,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.leaveroom.api.LeaveRoomEvent
@ -32,7 +33,10 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.coroutine.cancel
import io.element.android.libraries.architecture.coroutine.rememberJob
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -41,11 +45,17 @@ import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.room.tags.RoomNotableTags
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val EXTENDED_RANGE_SIZE = 40
@ -72,12 +82,12 @@ class RoomListPresenter @Inject constructor(
val filteredRoomList by roomListDataSource.filteredRooms.collectAsState()
val filter by roomListDataSource.filter.collectAsState()
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
roomListDataSource.launchIn(this)
initialLoad(matrixUser)
}
// Session verification status (unknown, not verified, verified)
val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false)
var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) }
@ -101,8 +111,8 @@ class RoomListPresenter @Inject constructor(
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
var contextMenu by remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
val showContextMenuJob = rememberJob()
fun handleEvents(event: RoomListEvents) {
when (event) {
@ -117,14 +127,14 @@ class RoomListPresenter @Inject constructor(
displaySearchResults = !displaySearchResults
}
is RoomListEvents.ShowContextMenu -> {
contextMenu = RoomListState.ContextMenu.Shown(
roomId = event.roomListRoomSummary.roomId,
roomName = event.roomListRoomSummary.name,
isDm = event.roomListRoomSummary.isDm,
)
showContextMenuJob.value = coroutineScope.showContextMenu(event, contextMenu)
}
is RoomListEvents.HideContextMenu -> {
showContextMenuJob.cancel()
contextMenu.value = RoomListState.ContextMenu.Hidden
}
is RoomListEvents.HideContextMenu -> contextMenu = RoomListState.ContextMenu.Hidden
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId))
is RoomListEvents.MarkRoomAsFavorite -> coroutineScope.markRoomAsFavorite(event)
}
}
@ -142,7 +152,7 @@ class RoomListPresenter @Inject constructor(
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
invitesState = inviteStateDataSource.inviteState(),
displaySearchResults = displaySearchResults,
contextMenu = contextMenu,
contextMenu = contextMenu.value,
leaveRoomState = leaveRoomState,
eventSink = ::handleEvents
)
@ -152,6 +162,38 @@ class RoomListPresenter @Inject constructor(
matrixUser.value = client.getCurrentUser()
}
private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch {
val initialState = RoomListState.ContextMenu.Shown(
roomId = event.roomListRoomSummary.roomId,
roomName = event.roomListRoomSummary.name,
isDm = event.roomListRoomSummary.isDm,
isFavorite = AsyncData.Loading(),
)
contextMenuState.value = initialState
val room = client.getRoom(event.roomListRoomSummary.roomId)
if (room != null) {
room.notableTagsFlow
.distinctUntilChanged()
.onEach { tags ->
val newState = initialState.copy(isFavorite = AsyncData.Success(tags.isFavorite))
contextMenuState.value = newState
}
.launchIn(this)
} else {
contextMenuState.value = initialState.copy(isFavorite = AsyncData.Failure(IllegalStateException("Room not found")))
}
}
private fun CoroutineScope.markRoomAsFavorite(event: RoomListEvents.MarkRoomAsFavorite) = launch {
val room = client.getRoom(event.roomId)
if (room != null) {
val notableTags = RoomNotableTags(isFavorite = event.isFavorite);
room.updateNotableTags(notableTags)
}else {
Timber.w("Room ${event.roomId} not found, can't mark as favorite");
}
}
private fun updateVisibleRange(range: IntRange) {
if (range.isEmpty()) return
val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2
@ -162,3 +204,5 @@ class RoomListPresenter @Inject constructor(
client.roomListService.updateAllRoomsVisibleRange(extendedRange)
}
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.roomlist.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -47,6 +48,7 @@ data class RoomListState(
val roomId: RoomId,
val roomName: String,
val isDm: Boolean,
val isFavorite: AsyncData<Boolean>,
) : ContextMenu
}
}

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -46,6 +47,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
roomId = RoomId("!aRoom:aDomain"),
roomName = "A nice room name",
isDm = false,
isFavorite = AsyncData.Success(true),
)
),
aRoomListState().copy(displayRecoveryKeyPrompt = true),