diff --git a/app/src/main/java/chat/revolt/components/generic/IconPlaceholder.kt b/app/src/main/java/chat/revolt/components/generic/IconPlaceholder.kt index c11f4d74..a76f21c9 100644 --- a/app/src/main/java/chat/revolt/components/generic/IconPlaceholder.kt +++ b/app/src/main/java/chat/revolt/components/generic/IconPlaceholder.kt @@ -1,7 +1,8 @@ package chat.revolt.components.generic +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -13,18 +14,23 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +@OptIn(ExperimentalFoundationApi::class) @Composable fun IconPlaceholder( name: String, modifier: Modifier = Modifier, onClick: () -> Unit = {}, + onLongClick: () -> Unit = {} ) { Box( contentAlignment = Alignment.Center, modifier = modifier .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) .then( - if (onClick != {}) Modifier.clickable(onClick = onClick) + if (onClick != {} || onLongClick != {}) Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) else Modifier ) ) { diff --git a/app/src/main/java/chat/revolt/components/generic/Markdown.kt b/app/src/main/java/chat/revolt/components/generic/Markdown.kt index be6b6c15..0cbda9a0 100644 --- a/app/src/main/java/chat/revolt/components/generic/Markdown.kt +++ b/app/src/main/java/chat/revolt/components/generic/Markdown.kt @@ -78,7 +78,7 @@ fun UIMarkdown( memberMap = mapOf(), userMap = RevoltAPI.userCache.toMap(), channelMap = RevoltAPI.channelCache.mapValues { ch -> - ch.value.name ?: ch.value.id!! + ch.value.name ?: ch.value.id ?: "{this does not exist 🤫}" }, emojiMap = RevoltAPI.emojiCache, serverId = null diff --git a/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt b/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt index 1cdb03a4..a5a13a39 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt @@ -1,33 +1,52 @@ package chat.revolt.components.screens.chat.drawer.channel +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.revolt.R +import chat.revolt.activities.RevoltTweenDp +import chat.revolt.activities.RevoltTweenFloat import chat.revolt.api.REVOLT_FILES import chat.revolt.api.RevoltAPI import chat.revolt.api.internals.ChannelUtils @@ -37,8 +56,12 @@ import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.presenceFromStatus import chat.revolt.components.screens.chat.drawer.server.DrawerChannel import chat.revolt.sheets.ChannelContextSheet +import kotlin.math.max -@OptIn(ExperimentalMaterial3Api::class) +const val BANNER_HEIGHT_COMPACT = 56 +const val BANNER_HEIGHT_EXPANDED = 128 + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun RowScope.ChannelList( serverId: String, @@ -46,7 +69,27 @@ fun RowScope.ChannelList( currentChannel: String?, onChannelClick: (String) -> Unit, onSpecialClick: (String) -> Unit, + onServerSheetOpenFor: (String) -> Unit, ) { + val lazyListState = rememberLazyListState() + val enableSmallBanner by remember { + derivedStateOf { + lazyListState.firstVisibleItemScrollOffset > 40 || + lazyListState.firstVisibleItemIndex > 0 + } + } + + val bannerHeight by animateDpAsState( + targetValue = if (enableSmallBanner) BANNER_HEIGHT_COMPACT.dp else BANNER_HEIGHT_EXPANDED.dp, + animationSpec = RevoltTweenDp, + label = "Banner Height" + ) + val bannerImageOpacity by animateFloatAsState( + targetValue = if (enableSmallBanner) 0f else 1f, + animationSpec = RevoltTweenFloat, + label = "Banner Image Opacity" + ) + var channelContextSheetShown by remember { mutableStateOf(false) } var channelContextSheetTarget by remember { mutableStateOf("") } @@ -87,17 +130,30 @@ fun RowScope.ChannelList( Modifier .weight(1f) .fillMaxSize(), + state = lazyListState, ) { if (serverId == "home") { - item( + stickyHeader( key = "header" ) { - Text( - text = stringResource(R.string.direct_messages), - style = MaterialTheme.typography.labelLarge, - fontSize = 24.sp, - modifier = Modifier.padding(16.dp) - ) + Box( + modifier = Modifier + .padding(start = 8.dp, end = 8.dp, top = 0.dp, bottom = 8.dp) + .alpha(0.9f) + .height(BANNER_HEIGHT_COMPACT.dp + 8.dp) // due to padding in Text + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)) + .weight(1f) + ) { + Text( + text = stringResource(R.string.direct_messages), + style = MaterialTheme.typography.labelLarge, + fontSize = 16.sp, + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 16.dp) + ) + } } item( @@ -189,28 +245,110 @@ fun RowScope.ChannelList( } else { val server = RevoltAPI.serverCache[serverId] - item { - Text( - text = server?.name - ?: stringResource(R.string.unknown), - style = MaterialTheme.typography.labelLarge, - fontSize = 24.sp, - modifier = Modifier.padding(16.dp) - ) - } + stickyHeader { + Box( + contentAlignment = Alignment.BottomStart, + modifier = Modifier + .then( + // if there is no banner, we change the design slightly. + // instead of there being a banner card we make a "classic" + // sticky header á la Google Messages + if (server?.banner != null) { + Modifier.padding(vertical = 8.dp, horizontal = 8.dp) + } else { + Modifier.padding( + start = 0.dp, + end = 8.dp, + top = 0.dp, + bottom = 0.dp + ) + } + ) + .fillMaxWidth() + ) { + if (server?.banner != null) { + Box(modifier = Modifier.height(bannerHeight)) { + Box( + modifier = Modifier + .alpha(max(0.95f, bannerImageOpacity)) + .fillMaxSize() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surface) + ) - server?.banner?.let { - item { - RemoteImage( - url = "$REVOLT_FILES/banners/${it.id}", - description = null, - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 16.dp) - .clip(RoundedCornerShape(16.dp)) - ) + RemoteImage( + url = "$REVOLT_FILES/banners/${server.banner.id}", + description = null, + modifier = Modifier + .alpha(bannerImageOpacity) + .fillMaxSize() + .clip(RoundedCornerShape(16.dp)) + ) + + Box( + modifier = Modifier + .alpha(bannerImageOpacity) + .fillMaxSize() + .clip(RoundedCornerShape(16.dp)) + .background( + Brush.verticalGradient( + listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.3f) + ) + ) + ) + ) + } + } else { + Box( + modifier = Modifier + .alpha(0.9f) + .height(BANNER_HEIGHT_COMPACT.dp + 8.dp) // due to padding in Text + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)) + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = (server?.name + ?: stringResource(R.string.unknown)), + style = MaterialTheme.typography.labelLarge, + fontSize = 16.sp, + modifier = Modifier + .then( + if (server?.banner != null) { + Modifier.padding(16.dp) + } else { + Modifier.padding( + start = 24.dp, + end = 24.dp, + top = 16.dp, + bottom = 16.dp + ) + } + ) + .weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + IconButton(onClick = { + onServerSheetOpenFor(serverId) + }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource( + id = R.string.settings + ) + ) + } + } } + } if (server?.channels?.isEmpty() == true) { @@ -242,8 +380,8 @@ fun RowScope.ChannelList( server?.channels?.get(it)?.let { channelId -> RevoltAPI.channelCache[channelId]?.let { ch -> DrawerChannel( - name = ch.name!!, - channelType = ch.channelType!!, + name = ch.name ?: stringResource(R.string.unknown), + channelType = ch.channelType ?: ChannelType.TextChannel, selected = currentDestination == "channel/{channelId}" && currentChannel == ch.id, hasUnread = ch.lastMessageID?.let { lastMessageID -> RevoltAPI.unreads.hasUnread( diff --git a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerServer.kt b/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerServer.kt index fba23bcb..73789ac6 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerServer.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerServer.kt @@ -2,8 +2,9 @@ package chat.revolt.components.screens.chat.drawer.server import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -20,11 +21,13 @@ import chat.revolt.api.REVOLT_FILES import chat.revolt.components.generic.IconPlaceholder import chat.revolt.components.generic.RemoteImage +@OptIn(ExperimentalFoundationApi::class) @Composable fun DrawerServer( iconId: String?, serverName: String, hasUnreads: Boolean, + onLongClick: () -> Unit, onClick: () -> Unit ) { val unreadIndicatorAlpha = animateFloatAsState( @@ -43,13 +46,17 @@ fun DrawerServer( .padding(8.dp) .size(48.dp) .clip(CircleShape) - .clickable(onClick = onClick), + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ), description = serverName ) } else { IconPlaceholder( name = serverName, onClick = onClick, + onLongClick = onLongClick, modifier = Modifier .padding(8.dp) .size(48.dp) diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index 1143919c..03b944a9 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -187,6 +187,9 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil var showStatusSheet by remember { mutableStateOf(false) } var showAddServerSheet by remember { mutableStateOf(false) } + var showServerContextSheet by remember { mutableStateOf(false) } + var serverContextSheetTarget by remember { mutableStateOf("") } + BackHandler(enabled = drawerState.isClosed) { scope.launch { drawerState.open() @@ -304,6 +307,22 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil } } + + if (showServerContextSheet) { + val serverContextSheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + sheetState = serverContextSheetState, + onDismissRequest = { + showServerContextSheet = false + }, + ) { + Column { + Text(text = "this is server context sheet for $serverContextSheetTarget") + } + } + } + Column( modifier = Modifier .fillMaxWidth() @@ -385,6 +404,10 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil hasUnreads = RevoltAPI.unreads.serverHasUnread( server.id ), + onLongClick = { + serverContextSheetTarget = server.id + showServerContextSheet = true + }, ) { viewModel.navigateToServer( server.id, @@ -422,6 +445,10 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil viewModel.navigateToSpecial(destination, navController) scope.launch { drawerState.close() } }, + onServerSheetOpenFor = { target -> + serverContextSheetTarget = target + showServerContextSheet = true + }, ) } }